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/// Handle the [`App`](crate::ui::app::App) keeps for the lifetime of the
30/// window; dropping it tears the tray down.  `set_variant` pushes a new
31/// health colour + tooltip when the worker's state changes.
32pub struct TrayHandle {
33    inner: Inner,
34}
35
36impl TrayHandle {
37    /// Push a new health variant to the OS tray.  Best-effort: any
38    /// failure is logged once, never panics.
39    pub fn set_variant(&mut self, variant: TrayVariant) {
40        self.inner.set_variant(variant);
41    }
42}
43
44// ---------------------------------------------------------------------------
45// Linux backend — ksni (pure Rust, no GTK).
46// ---------------------------------------------------------------------------
47
48#[cfg(target_os = "linux")]
49struct Inner {
50    tx: tokio::sync::mpsc::UnboundedSender<TrayVariant>,
51    warned: bool,
52}
53
54#[cfg(target_os = "linux")]
55impl Inner {
56    fn set_variant(&mut self, variant: TrayVariant) {
57        // The ksni service runs on the tokio runtime; forward the new
58        // variant to it.  A send error means the service never started
59        // (or already shut down) — warn once so the operator knows the
60        // status icon is stale rather than silently swallowing it.
61        if self.tx.send(variant).is_err() && !self.warned {
62            self.warned = true;
63            tracing::warn!(
64                target: TRACE_TARGET,
65                op = "set_variant",
66                "linux tray service is not running; status icon will not update"
67            );
68        }
69    }
70}
71
72/// The ksni `Tray` model.  Holds the shared runtime flags so menu
73/// activations (and a left-click) drive the same actions the in-window
74/// controls do.
75#[cfg(target_os = "linux")]
76struct KsniTray {
77    variant: TrayVariant,
78    paused: Arc<AtomicBool>,
79    quit: Arc<AtomicBool>,
80    ctx: egui::Context,
81}
82
83#[cfg(target_os = "linux")]
84impl KsniTray {
85    fn show_window(&self) {
86        tracing::info!(target: TRACE_TARGET, "open window requested from tray");
87        self.ctx
88            .send_viewport_cmd(egui::ViewportCommand::Visible(true));
89        self.ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
90        self.ctx.request_repaint();
91    }
92
93    fn toggle_pause(&self) {
94        let was_paused = self.paused.fetch_xor(true, Ordering::SeqCst);
95        tracing::info!(
96            target: TRACE_TARGET,
97            paused = !was_paused,
98            "pause toggled from tray menu"
99        );
100        self.ctx.request_repaint();
101    }
102
103    fn request_quit(&self) {
104        tracing::info!(
105            target: TRACE_TARGET,
106            "quit requested from tray menu; stopping worker"
107        );
108        self.quit.store(true, Ordering::SeqCst);
109        self.ctx.request_repaint();
110    }
111}
112
113#[cfg(target_os = "linux")]
114impl ksni::Tray for KsniTray {
115    fn id(&self) -> String {
116        "studio-worker".into()
117    }
118
119    fn title(&self) -> String {
120        "studio-worker".into()
121    }
122
123    fn icon_pixmap(&self) -> Vec<ksni::Icon> {
124        vec![ksni::Icon {
125            width: 16,
126            height: 16,
127            data: tray::rgba_to_argb32(&self.variant.rgba_16()),
128        }]
129    }
130
131    fn tool_tip(&self) -> ksni::ToolTip {
132        ksni::ToolTip {
133            title: self.variant.tooltip().to_string(),
134            ..Default::default()
135        }
136    }
137
138    fn activate(&mut self, _x: i32, _y: i32) {
139        self.show_window();
140    }
141
142    fn menu(&self) -> Vec<ksni::MenuItem<Self>> {
143        use ksni::menu::StandardItem;
144        // `menu_labels` flips Pause/Resume on `auto_enabled` (= not paused).
145        let labels = tray::menu_labels(!self.paused.load(Ordering::SeqCst));
146        vec![
147            StandardItem {
148                label: labels.open_window.to_string(),
149                activate: Box::new(|t: &mut Self| t.show_window()),
150                ..Default::default()
151            }
152            .into(),
153            StandardItem {
154                label: labels.toggle_auto.clone(),
155                activate: Box::new(|t: &mut Self| t.toggle_pause()),
156                ..Default::default()
157            }
158            .into(),
159            ksni::MenuItem::Separator,
160            StandardItem {
161                label: labels.quit.to_string(),
162                activate: Box::new(|t: &mut Self| t.request_quit()),
163                ..Default::default()
164            }
165            .into(),
166        ]
167    }
168}
169
170/// Spawn the ksni tray on the tokio runtime and return a handle that
171/// forwards variant updates to it.  The service is set up
172/// asynchronously; variant updates sent before it is ready are buffered
173/// and applied once it starts.  Returns `Some` immediately (the channel
174/// always exists); the icon itself appears only if a StatusNotifier host
175/// is present (KDE, GNOME-with-AppIndicator, etc.).
176#[cfg(target_os = "linux")]
177pub fn install(
178    ctx: egui::Context,
179    paused: Arc<AtomicBool>,
180    quit: Arc<AtomicBool>,
181    tokio: tokio::runtime::Handle,
182    _initial_paused: bool,
183) -> Option<TrayHandle> {
184    use ksni::TrayMethods;
185    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<TrayVariant>();
186    tokio.spawn(async move {
187        let tray = KsniTray {
188            variant: TrayVariant::Disconnected,
189            paused,
190            quit,
191            ctx,
192        };
193        let handle = match tray.spawn().await {
194            Ok(h) => {
195                tracing::info!(target: TRACE_TARGET, "linux tray (ksni) started");
196                h
197            }
198            Err(e) => {
199                tracing::warn!(
200                    target: TRACE_TARGET,
201                    error = %e,
202                    "linux tray (ksni) failed to start; running without a tray"
203                );
204                return;
205            }
206        };
207        while let Some(variant) = rx.recv().await {
208            handle
209                .update(move |t: &mut KsniTray| t.variant = variant)
210                .await;
211        }
212        // All senders dropped (window closed) — tear the tray down.
213        handle.shutdown().await;
214    });
215    Some(TrayHandle {
216        inner: Inner { tx, warned: false },
217    })
218}
219
220// ---------------------------------------------------------------------------
221// macOS / Windows backend — tray-icon (native).
222// ---------------------------------------------------------------------------
223
224#[cfg(not(target_os = "linux"))]
225struct Inner {
226    icon: Option<tray_icon::TrayIcon>,
227}
228
229#[cfg(not(target_os = "linux"))]
230impl Inner {
231    fn set_variant(&mut self, variant: TrayVariant) {
232        let Some(icon) = self.icon.as_ref() else {
233            return;
234        };
235        match tray_icon::Icon::from_rgba(variant.rgba_16(), 16, 16) {
236            Ok(new_icon) => {
237                if let Err(e) = icon.set_icon(Some(new_icon)) {
238                    tracing::warn!(
239                        target: TRACE_TARGET,
240                        op = "set_variant",
241                        error = %e,
242                        "failed to update tray icon"
243                    );
244                }
245            }
246            Err(e) => tracing::warn!(
247                target: TRACE_TARGET,
248                op = "set_variant",
249                error = %e,
250                "failed to build tray icon image"
251            ),
252        }
253        if let Err(e) = icon.set_tooltip(Some(variant.tooltip())) {
254            tracing::warn!(
255                target: TRACE_TARGET,
256                op = "set_variant",
257                error = %e,
258                "failed to update tray tooltip"
259            );
260        }
261    }
262}
263
264/// Build the native tray icon + menu (on the current — main — thread,
265/// as tray-icon requires) and spawn a thread that routes menu clicks to
266/// the shared runtime flags.  Returns `None` only when the platform tray
267/// host rejects the icon; the window UI keeps working regardless.
268#[cfg(not(target_os = "linux"))]
269pub fn install(
270    ctx: egui::Context,
271    paused: Arc<AtomicBool>,
272    quit: Arc<AtomicBool>,
273    _tokio: tokio::runtime::Handle,
274    initial_paused: bool,
275) -> Option<TrayHandle> {
276    use tray_icon::menu::{Menu, MenuEvent, MenuId, MenuItem};
277    use tray_icon::{Icon, TrayIconBuilder};
278
279    let open_id = MenuId::new(tray::menu_ids::OPEN_WINDOW);
280    let toggle_id = MenuId::new(tray::menu_ids::TOGGLE_AUTO);
281    let quit_id = MenuId::new(tray::menu_ids::QUIT);
282
283    // `menu_labels` flips Pause/Resume on `auto_enabled` (= not paused).
284    let labels = tray::menu_labels(!initial_paused);
285    let menu = Menu::new();
286    let _ = menu.append(&MenuItem::with_id(
287        open_id.clone(),
288        labels.open_window,
289        true,
290        None,
291    ));
292    let _ = menu.append(&MenuItem::with_id(
293        toggle_id.clone(),
294        &labels.toggle_auto,
295        true,
296        None,
297    ));
298    let _ = menu.append(&MenuItem::with_id(quit_id.clone(), labels.quit, true, None));
299
300    let variant = TrayVariant::Disconnected;
301    let icon = Icon::from_rgba(variant.rgba_16(), 16, 16).ok();
302    let mut builder = TrayIconBuilder::new()
303        .with_tooltip(variant.tooltip())
304        .with_menu(Box::new(menu));
305    if let Some(i) = icon {
306        builder = builder.with_icon(i);
307    }
308    let tray_icon = match builder.build() {
309        Ok(t) => Some(t),
310        Err(e) => {
311            tracing::warn!(
312                target: TRACE_TARGET,
313                error = %e,
314                "tray build failed; running without a tray"
315            );
316            None
317        }
318    };
319
320    // Route muda menu events to the shared flags on a background thread.
321    std::thread::spawn(move || {
322        let rx = MenuEvent::receiver();
323        while let Ok(event) = rx.recv() {
324            if event.id == open_id {
325                tracing::info!(target: TRACE_TARGET, "open window requested from tray menu");
326                ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
327                ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
328            } else if event.id == toggle_id {
329                let was_paused = paused.fetch_xor(true, Ordering::SeqCst);
330                tracing::info!(
331                    target: TRACE_TARGET,
332                    paused = !was_paused,
333                    "pause toggled from tray menu"
334                );
335            } else if event.id == quit_id {
336                tracing::info!(
337                    target: TRACE_TARGET,
338                    "quit requested from tray menu; stopping worker"
339                );
340                quit.store(true, Ordering::SeqCst);
341            }
342            ctx.request_repaint();
343        }
344    });
345
346    Some(TrayHandle {
347        inner: Inner { icon: tray_icon },
348    })
349}