Skip to main content

studio_worker/ui/
mod.rs

1//! The tray UI: an egui window + system tray that is a client of the
2//! daemon (`studio-worker run`).  See `docs/runtime/daemon-and-tray.md`.
3//!
4//! The UI never runs a job: a poller mirrors the daemon's state into a
5//! [`Replica`] the pages render, and the operator's actions go back over the
6//! local API.  When no daemon runs, the poller starts one.  One tray UI runs
7//! per config directory (`single_instance`).
8//!
9//! Gated behind the `ui` cargo feature so headless installs and the
10//! service path don't pull in egui / eframe / the tray backends.
11
12pub mod actions;
13pub mod app;
14pub mod chrome;
15pub mod format;
16pub mod icons;
17pub mod log_view;
18pub mod notifier;
19pub mod page;
20pub mod pages;
21pub mod prefs;
22pub mod pulse;
23pub mod single_instance;
24pub mod theme;
25pub mod tray;
26pub mod tray_host;
27pub mod widgets;
28
29use std::sync::{atomic::AtomicBool, Arc};
30use std::time::Duration;
31
32use anyhow::{anyhow, Result};
33use parking_lot::Mutex;
34
35use crate::{
36    config,
37    daemon_link::{Action, Poller, ProcessStarter, Replica},
38};
39
40const TRACE_TARGET: &str = "studio_worker::ui";
41
42/// Carries the display-retry attempt across the restart in place.
43pub const DISPLAY_ATTEMPT_ENV: &str = "STUDIO_WORKER_UI_DISPLAY_ATTEMPT";
44
45/// First wait before retrying the display, doubled per attempt.
46pub const DISPLAY_RETRY_BASE: Duration = Duration::from_secs(2);
47/// Longest wait between display attempts.
48pub const DISPLAY_RETRY_MAX: Duration = Duration::from_secs(60);
49
50/// How long to wait before display attempt `attempt + 1`.
51pub fn display_retry_delay(attempt: u32) -> Duration {
52    DISPLAY_RETRY_BASE
53        .saturating_mul(2u32.saturating_pow(attempt.min(16)))
54        .min(DISPLAY_RETRY_MAX)
55}
56
57/// The display attempt this process is, from [`DISPLAY_ATTEMPT_ENV`].
58pub fn display_attempt(env_value: Option<&str>) -> u32 {
59    env_value.and_then(|v| v.parse().ok()).unwrap_or(0)
60}
61
62/// Log a failed display attempt and answer how long to wait.
63pub fn log_display_wait(attempt: u32, error: &str) -> Duration {
64    let delay = display_retry_delay(attempt);
65    tracing::warn!(
66        target: TRACE_TARGET,
67        op = "display_wait",
68        attempt = attempt + 1,
69        retry_in_secs = delay.as_secs(),
70        error = %error,
71        "no usable display yet; the tray UI will retry"
72    );
73    delay
74}
75
76/// Entry point for `studio-worker ui`.
77pub fn run(config_path: Option<&str>) -> Result<()> {
78    let path = config::resolve_path(config_path)?;
79    let attempt = display_attempt(std::env::var(DISPLAY_ATTEMPT_ENV).ok().as_deref());
80    tracing::info!(
81        target: TRACE_TARGET,
82        op = "startup",
83        config_path = %path.display(),
84        display_attempt = attempt,
85        "tray UI starting as a client of the daemon"
86    );
87    let _ui_lock = match take_ui_lock(&path, attempt) {
88        UiLockOutcome::Held(lock) => lock,
89        UiLockOutcome::HandedOver => return Ok(()),
90    };
91    ensure_autostart();
92
93    // The poller runs whether or not the window can open: it starts the
94    // daemon when none runs, even while the UI waits for a display.
95    let replica = Replica::default();
96    let stop = Arc::new(AtomicBool::new(false));
97    let repaint: Arc<Mutex<Option<eframe::egui::Context>>> = Arc::default();
98    let exe = std::env::current_exe()?;
99    let poller = Poller::new(
100        replica.clone(),
101        path.clone(),
102        Box::new(ProcessStarter {
103            exe,
104            config_path: path.clone(),
105        }),
106    );
107    std::thread::spawn({
108        let stop = stop.clone();
109        let repaint = repaint.clone();
110        move || {
111            poller.run(stop, || {
112                if let Some(ctx) = repaint.lock().as_ref() {
113                    ctx.request_repaint();
114                }
115            })
116        }
117    });
118
119    std::thread::spawn({
120        let stop = stop.clone();
121        let repaint = repaint.clone();
122        let path = path.clone();
123        move || {
124            single_instance::watch(path, stop, || match repaint.lock().as_ref() {
125                Some(ctx) => {
126                    raise_window(ctx);
127                    true
128                }
129                None => false,
130            })
131        }
132    });
133
134    let actions = actions::ActionRunner::new(path.clone(), replica.clone());
135    let deps = app::AppDeps {
136        replica: replica.clone(),
137        start_minimised: config::peek(&path).start_minimised,
138        actions: actions.clone(),
139        config_path: path,
140        tokio: tokio::runtime::Handle::current(),
141    };
142
143    // Start-minimised is requested by the App on its first frame via
144    // `ViewportCommand::Minimized` — egui 0.34's ViewportBuilder has
145    // no `with_minimized`.
146    let mut viewport = eframe::egui::ViewportBuilder::default()
147        .with_inner_size([1240.0, 820.0])
148        .with_min_inner_size([960.0, 600.0])
149        .with_title("studio-worker");
150    // In development, open on the left monitor instead of the
151    // primary screen.  Override with STUDIO_WORKER_WINDOW_POS="x,y".
152    if let Some([x, y]) =
153        dev_window_position(std::env::var("STUDIO_WORKER_WINDOW_POS").ok().as_deref())
154    {
155        viewport = viewport.with_position([x, y]);
156    }
157    let native_options = eframe::NativeOptions {
158        viewport,
159        ..Default::default()
160    };
161
162    let initial_paused = replica.paused.load(std::sync::atomic::Ordering::SeqCst);
163    // The Linux (ksni) tray backend runs on the tokio runtime.
164    let tokio_for_tray = tokio::runtime::Handle::current();
165    let set_paused: tray_host::SetPaused = {
166        let actions = actions.clone();
167        Arc::new(move |paused| actions.run(Action::SetPaused(paused)))
168    };
169
170    let app_theme = prefs::load(&prefs::path_for(&deps.config_path)).theme;
171    let outcome = eframe::run_native(
172        "studio-worker",
173        native_options,
174        Box::new(move |cc| {
175            // Dark by default (project design rule); the operator's choice
176            // from ui.toml otherwise.
177            theme::apply(&cc.egui_ctx, app_theme);
178            *repaint.lock() = Some(cc.egui_ctx.clone());
179            actions.attach(cc.egui_ctx.clone());
180            let mut app = app::App::with_notifier(deps, app::App::default_notifier_box());
181            // Best-effort tray: the window works without one.
182            if let Some(tray) = tray_host::install(
183                cc.egui_ctx.clone(),
184                replica.paused.clone(),
185                set_paused,
186                app.quit_requested_handle(),
187                tokio_for_tray,
188                initial_paused,
189            ) {
190                app.attach_tray(tray);
191            }
192            Ok(Box::new(app))
193        }),
194    );
195    match outcome {
196        Ok(()) => {
197            stop.store(true, std::sync::atomic::Ordering::SeqCst);
198            Ok(())
199        }
200        Err(err) => {
201            let delay = log_display_wait(attempt, &err.to_string());
202            std::thread::sleep(delay);
203            restart_for_display(attempt + 1)
204        }
205    }
206}
207
208/// What [`take_ui_lock`] decided.
209enum UiLockOutcome {
210    /// This process is the tray UI; `None` when the lock could not be
211    /// opened and the UI runs without the guard.
212    Held(Option<single_instance::UiLock>),
213    /// Another tray UI runs for this config and was asked to show itself.
214    HandedOver,
215}
216
217/// Take the UI lock, or hand over to the tray UI that holds it.  A UI
218/// restarting itself for the display waits for its predecessor's lock.
219fn take_ui_lock(path: &std::path::Path, display_attempt: u32) -> UiLockOutcome {
220    let (attempts, pause) = if display_attempt > 0 {
221        (
222            single_instance::RESTART_ATTEMPTS,
223            single_instance::RESTART_PAUSE,
224        )
225    } else {
226        (1, Duration::ZERO)
227    };
228    match single_instance::acquire(path, attempts, pause) {
229        Ok(single_instance::Instance::Primary(lock)) => UiLockOutcome::Held(Some(lock)),
230        Ok(single_instance::Instance::Secondary) => {
231            // Best-effort: the failure is logged, and exiting is right either
232            // way (the running UI already has a tray icon).
233            let _ = single_instance::hand_over(path);
234            UiLockOutcome::HandedOver
235        }
236        Err(e) => {
237            tracing::warn!(
238                target: TRACE_TARGET,
239                op = "single_instance",
240                error = %e,
241                "could not open the ui lock; running without the single-instance guard"
242            );
243            UiLockOutcome::Held(None)
244        }
245    }
246}
247
248/// Show, un-minimise and focus the window (from any thread).
249pub fn raise_window(ctx: &eframe::egui::Context) {
250    use eframe::egui::ViewportCommand;
251    ctx.send_viewport_cmd(ViewportCommand::Visible(true));
252    ctx.send_viewport_cmd(ViewportCommand::Minimized(false));
253    ctx.send_viewport_cmd(ViewportCommand::Focus);
254    ctx.request_repaint();
255}
256
257/// Start this UI again in place with the next display attempt.  The
258/// windowing library allows one event loop per process and caches a
259/// failed display connection, so a retry needs a fresh process.
260#[cfg_attr(coverage_nightly, coverage(off))]
261fn restart_for_display(attempt: u32) -> Result<()> {
262    let exe = std::env::current_exe()?;
263    let mut cmd = std::process::Command::new(exe);
264    cmd.args(std::env::args_os().skip(1))
265        .env(DISPLAY_ATTEMPT_ENV, attempt.to_string());
266    #[cfg(unix)]
267    {
268        use std::os::unix::process::CommandExt as _;
269        let err = cmd.exec();
270        Err(anyhow!(
271            "restarting the tray UI for the display failed: {err}"
272        ))
273    }
274    #[cfg(not(unix))]
275    {
276        cmd.spawn()
277            .map_err(|e| anyhow!("restarting the tray UI for the display failed: {e}"))?;
278        std::process::exit(0);
279    }
280}
281
282/// Keep the tray UI's login entry installed and pointing at this
283/// executable.  Best-effort: a failure is logged, never fatal.
284fn ensure_autostart() {
285    match std::env::current_exe() {
286        Ok(exe) => {
287            if let Err(e) = crate::autostart::ensure(&exe) {
288                tracing::warn!(
289                    target: "studio_worker::ui",
290                    op = "autostart",
291                    error = %e,
292                    "could not install the login entry for the tray UI"
293                );
294            }
295        }
296        Err(e) => tracing::warn!(
297            target: "studio_worker::ui",
298            op = "autostart",
299            error = %e,
300            "could not resolve the current executable for the login entry"
301        ),
302    }
303}
304
305/// Decide where to place the window on launch.
306///
307/// - An explicit `STUDIO_WORKER_WINDOW_POS="x,y"` always wins (any build).
308/// - Otherwise, debug builds default to the left monitor's top-left so
309///   the window opens on the left screen during development.
310/// - Release builds return `None`, letting the window manager decide.
311fn dev_window_position(env: Option<&str>) -> Option<[f32; 2]> {
312    if let Some(raw) = env {
313        let mut parts = raw.split(',').map(str::trim);
314        if let (Some(x), Some(y), None) = (parts.next(), parts.next(), parts.next()) {
315            if let (Ok(x), Ok(y)) = (x.parse::<f32>(), y.parse::<f32>()) {
316                return Some([x, y]);
317            }
318        }
319        return None;
320    }
321    // The left monitor sits at the X11 root origin; a small inset keeps
322    // the title bar clear of the screen edge.  Release builds defer to
323    // the window manager.
324    #[cfg(debug_assertions)]
325    let default = Some([48.0, 48.0]);
326    #[cfg(not(debug_assertions))]
327    let default = None;
328    default
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    #[test]
336    fn the_display_retry_doubles_up_to_a_minute() {
337        assert_eq!(display_retry_delay(0), Duration::from_secs(2));
338        assert_eq!(display_retry_delay(1), Duration::from_secs(4));
339        assert_eq!(display_retry_delay(4), Duration::from_secs(32));
340        assert_eq!(display_retry_delay(5), DISPLAY_RETRY_MAX);
341        assert_eq!(display_retry_delay(u32::MAX), DISPLAY_RETRY_MAX);
342    }
343
344    #[test]
345    fn the_display_attempt_comes_from_the_environment() {
346        assert_eq!(display_attempt(None), 0);
347        assert_eq!(display_attempt(Some("3")), 3);
348        assert_eq!(display_attempt(Some("junk")), 0);
349    }
350
351    #[test]
352    fn a_display_wait_is_logged_with_its_attempt() {
353        let logs = crate::test_support::capture(|| {
354            let delay = log_display_wait(1, "Invalid MIT-MAGIC-COOKIE-1 key");
355            assert_eq!(delay, Duration::from_secs(4));
356        });
357        assert!(logs.contains("op=\"display_wait\""), "{logs}");
358        assert!(logs.contains("attempt=2"), "{logs}");
359        assert!(logs.contains("retry_in_secs=4"), "{logs}");
360        assert!(logs.contains("MIT-MAGIC-COOKIE"), "{logs}");
361    }
362
363    #[test]
364    fn a_second_ui_hands_over_to_the_first_and_leaves_a_raise_request() {
365        let dir = tempfile::tempdir().unwrap();
366        let config = dir.path().join("config.toml");
367        let first = take_ui_lock(&config, 0);
368        assert!(matches!(first, UiLockOutcome::Held(Some(_))));
369        assert!(matches!(
370            take_ui_lock(&config, 0),
371            UiLockOutcome::HandedOver
372        ));
373        assert!(single_instance::take_raise_request(&config));
374    }
375
376    #[test]
377    fn a_ui_restarting_for_the_display_waits_for_its_predecessors_lock() {
378        let dir = tempfile::tempdir().unwrap();
379        let config = dir.path().join("config.toml");
380        let predecessor = take_ui_lock(&config, 0);
381        let release = std::thread::spawn(move || {
382            std::thread::sleep(Duration::from_millis(50));
383            drop(predecessor);
384        });
385        assert!(matches!(
386            take_ui_lock(&config, 1),
387            UiLockOutcome::Held(Some(_))
388        ));
389        release.join().unwrap();
390    }
391
392    #[test]
393    fn an_unopenable_ui_lock_runs_without_the_guard_and_says_so() {
394        let dir = tempfile::tempdir().unwrap();
395        // A file where the config directory should be: the lock cannot open.
396        let blocker = dir.path().join("not-a-dir");
397        std::fs::write(&blocker, "").unwrap();
398        let config = blocker.join("config.toml");
399        let logs = crate::test_support::capture(move || {
400            assert!(matches!(
401                take_ui_lock(&config, 0),
402                UiLockOutcome::Held(None)
403            ));
404        });
405        assert!(logs.contains("without the single-instance guard"), "{logs}");
406    }
407
408    #[test]
409    fn parses_explicit_position_override() {
410        assert_eq!(dev_window_position(Some("100,200")), Some([100.0, 200.0]));
411    }
412
413    #[test]
414    fn trims_whitespace_around_coords() {
415        assert_eq!(dev_window_position(Some(" 10 , 20 ")), Some([10.0, 20.0]));
416    }
417
418    #[test]
419    fn rejects_malformed_override() {
420        assert_eq!(dev_window_position(Some("not-a-pos")), None);
421        assert_eq!(dev_window_position(Some("1,2,3")), None);
422        assert_eq!(dev_window_position(Some("1")), None);
423    }
424
425    #[cfg(debug_assertions)]
426    #[test]
427    fn defaults_to_left_screen_in_debug() {
428        assert_eq!(dev_window_position(None), Some([48.0, 48.0]));
429    }
430
431    #[cfg(not(debug_assertions))]
432    #[test]
433    fn defers_to_wm_in_release() {
434        assert_eq!(dev_window_position(None), None);
435    }
436}