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/// Asks the daemon to pause (`true`) or resume.  The tray reads the
26/// replica's `paused` flag for its label and calls this on a click.
27pub type SetPaused = Arc<dyn Fn(bool) + Send + Sync>;
28
29/// Tracing target for tray lifecycle + click events.  Stable so
30/// operators can filter with `RUST_LOG=studio_worker::ui::tray=debug`.
31const TRACE_TARGET: &str = "studio_worker::ui::tray";
32
33/// Emit a structured breadcrumb when the native tray icon image fails to
34/// build from its RGBA buffer.  Shared by both build sites — `install`
35/// (startup) and `set_variant` (per health change) — so a failure is
36/// never swallowed and the breadcrumb is unit-testable on the Linux CI
37/// box even though the call sites are `#[cfg(not(target_os = "linux"))]`.
38/// `op` names the lifecycle phase that hit the failure.
39#[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
49/// Handle the [`App`](crate::ui::app::App) keeps for the lifetime of the
50/// window; dropping it tears the tray down.  `set_variant` pushes a new
51/// health colour + tooltip when the worker's state changes.
52pub struct TrayHandle {
53    inner: Inner,
54}
55
56impl TrayHandle {
57    /// Push a new health variant to the OS tray.  Best-effort: any
58    /// failure is logged once, never panics.
59    pub fn set_variant(&mut self, variant: TrayVariant) {
60        self.inner.set_variant(variant);
61    }
62}
63
64// ---------------------------------------------------------------------------
65// Linux backend — ksni (pure Rust, no GTK).
66// ---------------------------------------------------------------------------
67
68#[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        // The ksni service runs on the tokio runtime; forward the new
78        // variant to it.  A send error means the service never started
79        // (or already shut down) — warn once so the operator knows the
80        // status icon is stale rather than silently swallowing it.
81        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/// The ksni `Tray` model.  Holds the shared runtime flags so menu
93/// activations (and a left-click) drive the same actions the in-window
94/// controls do.
95#[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        // `menu_labels` flips Pause/Resume on `auto_enabled` (= not paused).
167        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/// Spawn the ksni tray on the tokio runtime and return a handle that
193/// forwards variant updates to it.  The service is set up
194/// asynchronously; variant updates sent before it is ready are buffered
195/// and applied once it starts.  Returns `Some` immediately (the channel
196/// always exists); the icon itself appears only if a StatusNotifier host
197/// is present (KDE, GNOME-with-AppIndicator, etc.).
198#[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        // All senders dropped (window closed) — tear the tray down.
237        handle.shutdown().await;
238    });
239    Some(TrayHandle {
240        inner: Inner { tx, warned: false },
241    })
242}
243
244// ---------------------------------------------------------------------------
245// macOS / Windows backend — tray-icon (native).
246// ---------------------------------------------------------------------------
247
248#[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/// Build the native tray icon + menu (on the current — main — thread,
284/// as tray-icon requires) and spawn a thread that routes menu clicks to
285/// the shared runtime flags.  Returns `None` only when the platform tray
286/// host rejects the icon; the window UI keeps working regardless.
287#[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    // `menu_labels` flips Pause/Resume on `auto_enabled` (= not paused).
304    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    // Route muda menu events to the shared flags on a background thread.
347    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    // The mac/win tray builds an icon image from a static RGBA buffer in
383    // two places (`install` at startup, `set_variant` on every health
384    // change).  Both must surface a build failure rather than swallow it,
385    // so the breadcrumb is exercised here on the Linux CI box even though
386    // the call sites themselves are `#[cfg(not(target_os = "linux"))]`.
387    #[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}