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 tabs render, and the operator's actions go back over the
6//! local API.  When no daemon runs, the poller starts one.
7//!
8//! Gated behind the `ui` cargo feature so headless installs and the
9//! service path don't pull in egui / eframe / the tray backends.
10
11pub mod actions;
12pub mod app;
13pub mod notifier;
14pub mod tab;
15pub mod tabs;
16pub mod tray;
17pub mod tray_host;
18
19use std::sync::{atomic::AtomicBool, Arc};
20use std::time::Duration;
21
22use anyhow::{anyhow, Result};
23use parking_lot::Mutex;
24
25use crate::{
26    config,
27    daemon_link::{Action, Poller, ProcessStarter, Replica},
28};
29
30const TRACE_TARGET: &str = "studio_worker::ui";
31
32/// Carries the display-retry attempt across the restart in place.
33pub const DISPLAY_ATTEMPT_ENV: &str = "STUDIO_WORKER_UI_DISPLAY_ATTEMPT";
34
35/// First wait before retrying the display, doubled per attempt.
36pub const DISPLAY_RETRY_BASE: Duration = Duration::from_secs(2);
37/// Longest wait between display attempts.
38pub const DISPLAY_RETRY_MAX: Duration = Duration::from_secs(60);
39
40/// How long to wait before display attempt `attempt + 1`.
41pub fn display_retry_delay(attempt: u32) -> Duration {
42    DISPLAY_RETRY_BASE
43        .saturating_mul(2u32.saturating_pow(attempt.min(16)))
44        .min(DISPLAY_RETRY_MAX)
45}
46
47/// The display attempt this process is, from [`DISPLAY_ATTEMPT_ENV`].
48pub fn display_attempt(env_value: Option<&str>) -> u32 {
49    env_value.and_then(|v| v.parse().ok()).unwrap_or(0)
50}
51
52/// Log a failed display attempt and answer how long to wait.
53pub fn log_display_wait(attempt: u32, error: &str) -> Duration {
54    let delay = display_retry_delay(attempt);
55    tracing::warn!(
56        target: TRACE_TARGET,
57        op = "display_wait",
58        attempt = attempt + 1,
59        retry_in_secs = delay.as_secs(),
60        error = %error,
61        "no usable display yet; the tray UI will retry"
62    );
63    delay
64}
65
66/// Entry point for `studio-worker ui`.
67pub fn run(config_path: Option<&str>) -> Result<()> {
68    let path = config::resolve_path(config_path)?;
69    let attempt = display_attempt(std::env::var(DISPLAY_ATTEMPT_ENV).ok().as_deref());
70    tracing::info!(
71        target: TRACE_TARGET,
72        op = "startup",
73        config_path = %path.display(),
74        display_attempt = attempt,
75        "tray UI starting as a client of the daemon"
76    );
77    ensure_autostart();
78
79    // The poller runs whether or not the window can open: it starts the
80    // daemon when none runs, even while the UI waits for a display.
81    let replica = Replica::default();
82    let stop = Arc::new(AtomicBool::new(false));
83    let repaint: Arc<Mutex<Option<eframe::egui::Context>>> = Arc::default();
84    let exe = std::env::current_exe()?;
85    let poller = Poller::new(
86        replica.clone(),
87        path.clone(),
88        Box::new(ProcessStarter {
89            exe,
90            config_path: path.clone(),
91        }),
92    );
93    std::thread::spawn({
94        let stop = stop.clone();
95        let repaint = repaint.clone();
96        move || {
97            poller.run(stop, || {
98                if let Some(ctx) = repaint.lock().as_ref() {
99                    ctx.request_repaint();
100                }
101            })
102        }
103    });
104
105    let actions = actions::ActionRunner::new(path.clone(), replica.clone());
106    let deps = app::AppDeps {
107        replica: replica.clone(),
108        start_minimised: config::peek(&path).start_minimised,
109        actions: actions.clone(),
110        config_path: path,
111        tokio: tokio::runtime::Handle::current(),
112    };
113
114    // Start-minimised is requested by the App on its first frame via
115    // `ViewportCommand::Minimized` — egui 0.34's ViewportBuilder has
116    // no `with_minimized`.
117    let mut viewport = eframe::egui::ViewportBuilder::default()
118        .with_inner_size([1000.0, 760.0])
119        .with_min_inner_size([640.0, 480.0])
120        .with_title("studio-worker");
121    // In development, open on the left monitor instead of the
122    // primary screen.  Override with STUDIO_WORKER_WINDOW_POS="x,y".
123    if let Some([x, y]) =
124        dev_window_position(std::env::var("STUDIO_WORKER_WINDOW_POS").ok().as_deref())
125    {
126        viewport = viewport.with_position([x, y]);
127    }
128    let native_options = eframe::NativeOptions {
129        viewport,
130        ..Default::default()
131    };
132
133    let initial_paused = replica.paused.load(std::sync::atomic::Ordering::SeqCst);
134    // The Linux (ksni) tray backend runs on the tokio runtime.
135    let tokio_for_tray = tokio::runtime::Handle::current();
136    let set_paused: tray_host::SetPaused = {
137        let actions = actions.clone();
138        Arc::new(move |paused| actions.run(Action::SetPaused(paused)))
139    };
140
141    let outcome = eframe::run_native(
142        "studio-worker",
143        native_options,
144        Box::new(move |cc| {
145            // Dark mode by default (project design rule).
146            cc.egui_ctx.set_visuals(eframe::egui::Visuals::dark());
147            *repaint.lock() = Some(cc.egui_ctx.clone());
148            actions.attach(cc.egui_ctx.clone());
149            let mut app = app::App::with_notifier(deps, app::App::default_notifier_box());
150            // Best-effort tray: the window works without one.
151            if let Some(tray) = tray_host::install(
152                cc.egui_ctx.clone(),
153                replica.paused.clone(),
154                set_paused,
155                app.quit_requested_handle(),
156                tokio_for_tray,
157                initial_paused,
158            ) {
159                app.attach_tray(tray);
160            }
161            Ok(Box::new(app))
162        }),
163    );
164    match outcome {
165        Ok(()) => {
166            stop.store(true, std::sync::atomic::Ordering::SeqCst);
167            Ok(())
168        }
169        Err(err) => {
170            let delay = log_display_wait(attempt, &err.to_string());
171            std::thread::sleep(delay);
172            restart_for_display(attempt + 1)
173        }
174    }
175}
176
177/// Start this UI again in place with the next display attempt.  The
178/// windowing library allows one event loop per process and caches a
179/// failed display connection, so a retry needs a fresh process.
180#[cfg_attr(coverage_nightly, coverage(off))]
181fn restart_for_display(attempt: u32) -> Result<()> {
182    let exe = std::env::current_exe()?;
183    let mut cmd = std::process::Command::new(exe);
184    cmd.args(std::env::args_os().skip(1))
185        .env(DISPLAY_ATTEMPT_ENV, attempt.to_string());
186    #[cfg(unix)]
187    {
188        use std::os::unix::process::CommandExt as _;
189        let err = cmd.exec();
190        Err(anyhow!(
191            "restarting the tray UI for the display failed: {err}"
192        ))
193    }
194    #[cfg(not(unix))]
195    {
196        cmd.spawn()
197            .map_err(|e| anyhow!("restarting the tray UI for the display failed: {e}"))?;
198        std::process::exit(0);
199    }
200}
201
202/// Keep the tray UI's login entry installed and pointing at this
203/// executable.  Best-effort: a failure is logged, never fatal.
204fn ensure_autostart() {
205    match std::env::current_exe() {
206        Ok(exe) => {
207            if let Err(e) = crate::autostart::ensure(&exe) {
208                tracing::warn!(
209                    target: "studio_worker::ui",
210                    op = "autostart",
211                    error = %e,
212                    "could not install the login entry for the tray UI"
213                );
214            }
215        }
216        Err(e) => tracing::warn!(
217            target: "studio_worker::ui",
218            op = "autostart",
219            error = %e,
220            "could not resolve the current executable for the login entry"
221        ),
222    }
223}
224
225/// Decide where to place the window on launch.
226///
227/// - An explicit `STUDIO_WORKER_WINDOW_POS="x,y"` always wins (any build).
228/// - Otherwise, debug builds default to the left monitor's top-left so
229///   the window opens on the left screen during development.
230/// - Release builds return `None`, letting the window manager decide.
231fn dev_window_position(env: Option<&str>) -> Option<[f32; 2]> {
232    if let Some(raw) = env {
233        let mut parts = raw.split(',').map(str::trim);
234        if let (Some(x), Some(y), None) = (parts.next(), parts.next(), parts.next()) {
235            if let (Ok(x), Ok(y)) = (x.parse::<f32>(), y.parse::<f32>()) {
236                return Some([x, y]);
237            }
238        }
239        return None;
240    }
241    // The left monitor sits at the X11 root origin; a small inset keeps
242    // the title bar clear of the screen edge.  Release builds defer to
243    // the window manager.
244    #[cfg(debug_assertions)]
245    let default = Some([48.0, 48.0]);
246    #[cfg(not(debug_assertions))]
247    let default = None;
248    default
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn the_display_retry_doubles_up_to_a_minute() {
257        assert_eq!(display_retry_delay(0), Duration::from_secs(2));
258        assert_eq!(display_retry_delay(1), Duration::from_secs(4));
259        assert_eq!(display_retry_delay(4), Duration::from_secs(32));
260        assert_eq!(display_retry_delay(5), DISPLAY_RETRY_MAX);
261        assert_eq!(display_retry_delay(u32::MAX), DISPLAY_RETRY_MAX);
262    }
263
264    #[test]
265    fn the_display_attempt_comes_from_the_environment() {
266        assert_eq!(display_attempt(None), 0);
267        assert_eq!(display_attempt(Some("3")), 3);
268        assert_eq!(display_attempt(Some("junk")), 0);
269    }
270
271    #[test]
272    fn a_display_wait_is_logged_with_its_attempt() {
273        let logs = crate::test_support::capture(|| {
274            let delay = log_display_wait(1, "Invalid MIT-MAGIC-COOKIE-1 key");
275            assert_eq!(delay, Duration::from_secs(4));
276        });
277        assert!(logs.contains("op=\"display_wait\""), "{logs}");
278        assert!(logs.contains("attempt=2"), "{logs}");
279        assert!(logs.contains("retry_in_secs=4"), "{logs}");
280        assert!(logs.contains("MIT-MAGIC-COOKIE"), "{logs}");
281    }
282
283    #[test]
284    fn parses_explicit_position_override() {
285        assert_eq!(dev_window_position(Some("100,200")), Some([100.0, 200.0]));
286    }
287
288    #[test]
289    fn trims_whitespace_around_coords() {
290        assert_eq!(dev_window_position(Some(" 10 , 20 ")), Some([10.0, 20.0]));
291    }
292
293    #[test]
294    fn rejects_malformed_override() {
295        assert_eq!(dev_window_position(Some("not-a-pos")), None);
296        assert_eq!(dev_window_position(Some("1,2,3")), None);
297        assert_eq!(dev_window_position(Some("1")), None);
298    }
299
300    #[cfg(debug_assertions)]
301    #[test]
302    fn defaults_to_left_screen_in_debug() {
303        assert_eq!(dev_window_position(None), Some([48.0, 48.0]));
304    }
305
306    #[cfg(not(debug_assertions))]
307    #[test]
308    fn defers_to_wm_in_release() {
309        assert_eq!(dev_window_position(None), None);
310    }
311}