Skip to main content

studio_worker/ui/
mod.rs

1//! Native egui desktop UI.  See `plans/native-ui.md` for the full
2//! design (tabs, tray icon, notifications, autostart).
3//!
4//! This module is gated behind the `ui` cargo feature so headless
5//! installs and the systemd / launchd service path don't pull in
6//! egui / eframe / tray-icon / notify-rust + their system libs.
7
8pub mod app;
9pub mod notifier;
10pub mod tab;
11pub mod tabs;
12pub mod tray;
13pub mod tray_host;
14
15use std::sync::{atomic::AtomicBool, Arc};
16use std::time::Duration;
17
18use anyhow::{anyhow, Result};
19use parking_lot::Mutex;
20
21use crate::{
22    auto_register::{self, RegistrationState},
23    config,
24    runtime::{self, LoopSchedule, WorkerObservers},
25    types::LogEntry,
26};
27
28/// Entry point for `studio-worker ui`.  Loads config, spawns the four
29/// background loops on the calling tokio runtime, then hands the main
30/// thread to eframe (which it owns for the lifetime of the window).
31pub fn run(config_path: Option<&str>) -> Result<()> {
32    let (cfg, path) = config::load(config_path)?;
33    runtime::log_startup_banner(&cfg, &path);
34
35    // Honour `auto_start`: make the tray UI come back on login without
36    // the operator having to toggle anything.  Best-effort and
37    // idempotent — a failure is logged, never fatal.
38    sync_autostart_on_launch(cfg.auto_start);
39
40    let cfg = config::shared(cfg);
41    let stop = Arc::new(AtomicBool::new(false));
42    let busy = Arc::new(AtomicBool::new(false));
43    // Operator pause toggle.  Runtime-only: never persisted so the
44    // worker comes up unpaused on every launch.
45    let paused = Arc::new(AtomicBool::new(false));
46    let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
47    let observers = WorkerObservers::default();
48
49    let registration = auto_register::shared_initial();
50
51    // Spawn the loops on the tokio runtime that's already driving
52    // `run_cli` (multi-threaded — main.rs builds `Runtime::new()`).
53    // `eframe::run_native` blocks the main thread; the loops keep
54    // ticking on worker threads.
55    let handle = tokio::runtime::Handle::current();
56
57    // Auto-register loop: polls every 30s until Approved or Rejected.
58    // Then the WS session takes over.
59    let cfg_autoreg = cfg.clone();
60    let path_autoreg = path.clone();
61    let registration_autoreg = registration.clone();
62    let stop_autoreg = stop.clone();
63    handle.spawn(async move {
64        loop {
65            if stop_autoreg.load(std::sync::atomic::Ordering::SeqCst) {
66                return;
67            }
68            let state =
69                auto_register::tick(&cfg_autoreg, &path_autoreg, &registration_autoreg).await;
70            if matches!(
71                state,
72                RegistrationState::Approved | RegistrationState::Rejected { .. }
73            ) {
74                return;
75            }
76            for _ in 0..30 {
77                if stop_autoreg.load(std::sync::atomic::Ordering::SeqCst) {
78                    return;
79                }
80                tokio::time::sleep(Duration::from_secs(1)).await;
81            }
82        }
83    });
84
85    let cfg_loops = cfg.clone();
86    let stop_loops = stop.clone();
87    let logs_loops = logs.clone();
88    let busy_loops = busy.clone();
89    let paused_loops = paused.clone();
90    let observers_loops = observers.clone();
91    handle.spawn(async move {
92        if let Err(e) = runtime::run_loops(
93            cfg_loops,
94            stop_loops,
95            logs_loops,
96            busy_loops,
97            paused_loops,
98            observers_loops,
99            LoopSchedule::default(),
100        )
101        .await
102        {
103            tracing::error!(target: "studio_worker::ui", error = %e, "run_loops exited");
104        }
105    });
106
107    let app_state = app::AppDeps {
108        cfg: cfg.clone(),
109        logs: logs.clone(),
110        busy: busy.clone(),
111        paused: paused.clone(),
112        observers: observers.clone(),
113        stop: stop.clone(),
114        config_path: path,
115        tokio: handle.clone(),
116    };
117
118    // Start-minimised is requested by the App on its first frame via
119    // `ViewportCommand::Minimized` — egui 0.34's ViewportBuilder has
120    // no `with_minimized`.
121    let mut viewport = eframe::egui::ViewportBuilder::default()
122        .with_inner_size([960.0, 720.0])
123        .with_min_inner_size([640.0, 480.0])
124        .with_title("studio-worker");
125    // In development, open on the left monitor instead of the
126    // primary screen.  Override with STUDIO_WORKER_WINDOW_POS="x,y".
127    if let Some([x, y]) =
128        dev_window_position(std::env::var("STUDIO_WORKER_WINDOW_POS").ok().as_deref())
129    {
130        viewport = viewport.with_position([x, y]);
131    }
132    let native_options = eframe::NativeOptions {
133        viewport,
134        ..Default::default()
135    };
136
137    // The tray menu label flips between "Pause" / "Resume" based on
138    // the current paused state; start with the live value so the
139    // first render is correct.
140    let initial_paused = paused.load(std::sync::atomic::Ordering::SeqCst);
141    // The Linux (ksni) tray backend runs on the tokio runtime; hand it
142    // a runtime handle so it can spawn its zbus service.
143    let tokio_for_tray = handle.clone();
144
145    eframe::run_native(
146        "studio-worker",
147        native_options,
148        Box::new(move |cc| {
149            // Dark mode by default (project design rule).
150            cc.egui_ctx.set_visuals(eframe::egui::Visuals::dark());
151            let app = app::App::with_notifier_and_registration(
152                app_state,
153                app::App::default_notifier_box(),
154                registration,
155            );
156            let quit_handle = app.quit_requested_handle();
157
158            // Best-effort tray.  Linux uses ksni (pure Rust); macOS /
159            // Windows use tray-icon.  Either may be unavailable (no
160            // StatusNotifier host, no system tray) — the window UI keeps
161            // working without it rather than aborting startup.
162            let tray_handle = tray_host::install(
163                cc.egui_ctx.clone(),
164                paused.clone(),
165                quit_handle,
166                tokio_for_tray,
167                initial_paused,
168            );
169            // Stash the tray inside the App so it lives as long as the
170            // event loop (dropping it removes the icon).
171            let mut app = app;
172            if let Some(tray) = tray_handle {
173                app.attach_tray(tray);
174            }
175            Ok(Box::new(app))
176        }),
177    )
178    .map_err(|e| anyhow!("eframe: {e}"))?;
179
180    // Signal loops to wind down once the window closes.
181    stop.store(true, std::sync::atomic::Ordering::SeqCst);
182    Ok(())
183}
184
185/// Reconcile the on-login autostart entry with the configured
186/// `auto_start` at UI launch.  The decision is the pure
187/// [`autostart::launch_sync_action`]; this only performs the chosen
188/// side effect and logs the outcome.
189fn sync_autostart_on_launch(auto_start: bool) {
190    use crate::autostart::{self, AutostartSync};
191    match autostart::launch_sync_action(auto_start, autostart::is_enabled()) {
192        AutostartSync::Enable => match std::env::current_exe() {
193            Ok(exe) => {
194                if let Err(e) = autostart::enable(&exe) {
195                    tracing::warn!(
196                        target: "studio_worker::ui",
197                        error = %e,
198                        "could not enable autostart-on-login"
199                    );
200                }
201            }
202            Err(e) => tracing::warn!(
203                target: "studio_worker::ui",
204                error = %e,
205                "could not resolve current exe to enable autostart-on-login"
206            ),
207        },
208        AutostartSync::Disable => {
209            if let Err(e) = autostart::disable() {
210                tracing::warn!(
211                    target: "studio_worker::ui",
212                    error = %e,
213                    "could not disable stale autostart-on-login"
214                );
215            }
216        }
217        AutostartSync::Noop => {}
218    }
219}
220
221/// Decide where to place the window on launch.
222///
223/// - An explicit `STUDIO_WORKER_WINDOW_POS="x,y"` always wins (any build).
224/// - Otherwise, debug builds default to the left monitor's top-left so
225///   the window opens on the left screen during development.
226/// - Release builds return `None`, letting the window manager decide.
227fn dev_window_position(env: Option<&str>) -> Option<[f32; 2]> {
228    if let Some(raw) = env {
229        let mut parts = raw.split(',').map(str::trim);
230        if let (Some(x), Some(y), None) = (parts.next(), parts.next(), parts.next()) {
231            if let (Ok(x), Ok(y)) = (x.parse::<f32>(), y.parse::<f32>()) {
232                return Some([x, y]);
233            }
234        }
235        return None;
236    }
237    // The left monitor sits at the X11 root origin; a small inset keeps
238    // the title bar clear of the screen edge.  Release builds defer to
239    // the window manager.
240    #[cfg(debug_assertions)]
241    let default = Some([48.0, 48.0]);
242    #[cfg(not(debug_assertions))]
243    let default = None;
244    default
245}
246
247#[cfg(test)]
248mod tests {
249    use super::dev_window_position;
250
251    #[test]
252    fn parses_explicit_position_override() {
253        assert_eq!(dev_window_position(Some("100,200")), Some([100.0, 200.0]));
254    }
255
256    #[test]
257    fn trims_whitespace_around_coords() {
258        assert_eq!(dev_window_position(Some(" 10 , 20 ")), Some([10.0, 20.0]));
259    }
260
261    #[test]
262    fn rejects_malformed_override() {
263        assert_eq!(dev_window_position(Some("not-a-pos")), None);
264        assert_eq!(dev_window_position(Some("1,2,3")), None);
265        assert_eq!(dev_window_position(Some("1")), None);
266    }
267
268    #[cfg(debug_assertions)]
269    #[test]
270    fn defaults_to_left_screen_in_debug() {
271        assert_eq!(dev_window_position(None), Some([48.0, 48.0]));
272    }
273
274    #[cfg(not(debug_assertions))]
275    #[test]
276    fn defers_to_wm_in_release() {
277        assert_eq!(dev_window_position(None), None);
278    }
279}