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    let mut viewport = eframe::egui::ViewportBuilder::default()
119        .with_inner_size([960.0, 720.0])
120        .with_min_inner_size([640.0, 480.0])
121        .with_title("studio-worker");
122    // In development, open on the left monitor instead of the
123    // primary screen.  Override with STUDIO_WORKER_WINDOW_POS="x,y".
124    if let Some([x, y]) =
125        dev_window_position(std::env::var("STUDIO_WORKER_WINDOW_POS").ok().as_deref())
126    {
127        viewport = viewport.with_position([x, y]);
128    }
129    let native_options = eframe::NativeOptions {
130        viewport,
131        ..Default::default()
132    };
133
134    // The tray menu label flips between "Pause" / "Resume" based on
135    // the current paused state; start with the live value so the
136    // first render is correct.
137    let initial_paused = paused.load(std::sync::atomic::Ordering::SeqCst);
138    // The Linux (ksni) tray backend runs on the tokio runtime; hand it
139    // a runtime handle so it can spawn its zbus service.
140    let tokio_for_tray = handle.clone();
141
142    eframe::run_native(
143        "studio-worker",
144        native_options,
145        Box::new(move |cc| {
146            // Dark mode by default (project design rule).
147            cc.egui_ctx.set_visuals(eframe::egui::Visuals::dark());
148            let app = app::App::with_notifier_and_registration(
149                app_state,
150                app::App::default_notifier_box(),
151                registration,
152            );
153            let quit_handle = app.quit_requested_handle();
154
155            // Best-effort tray.  Linux uses ksni (pure Rust); macOS /
156            // Windows use tray-icon.  Either may be unavailable (no
157            // StatusNotifier host, no system tray) — the window UI keeps
158            // working without it rather than aborting startup.
159            let tray_handle = tray_host::install(
160                cc.egui_ctx.clone(),
161                paused.clone(),
162                quit_handle,
163                tokio_for_tray,
164                initial_paused,
165            );
166            // Stash the tray inside the App so it lives as long as the
167            // event loop (dropping it removes the icon).
168            let mut app = app;
169            if let Some(tray) = tray_handle {
170                app.attach_tray(tray);
171            }
172            Ok(Box::new(app))
173        }),
174    )
175    .map_err(|e| anyhow!("eframe: {e}"))?;
176
177    // Signal loops to wind down once the window closes.
178    stop.store(true, std::sync::atomic::Ordering::SeqCst);
179    Ok(())
180}
181
182/// Reconcile the on-login autostart entry with the configured
183/// `auto_start` at UI launch.  The decision is the pure
184/// [`autostart::launch_sync_action`]; this only performs the chosen
185/// side effect and logs the outcome.
186fn sync_autostart_on_launch(auto_start: bool) {
187    use crate::autostart::{self, AutostartSync};
188    match autostart::launch_sync_action(auto_start, autostart::is_enabled()) {
189        AutostartSync::Enable => match std::env::current_exe() {
190            Ok(exe) => {
191                if let Err(e) = autostart::enable(&exe) {
192                    tracing::warn!(
193                        target: "studio_worker::ui",
194                        error = %e,
195                        "could not enable autostart-on-login"
196                    );
197                }
198            }
199            Err(e) => tracing::warn!(
200                target: "studio_worker::ui",
201                error = %e,
202                "could not resolve current exe to enable autostart-on-login"
203            ),
204        },
205        AutostartSync::Disable => {
206            if let Err(e) = autostart::disable() {
207                tracing::warn!(
208                    target: "studio_worker::ui",
209                    error = %e,
210                    "could not disable stale autostart-on-login"
211                );
212            }
213        }
214        AutostartSync::Noop => {}
215    }
216}
217
218/// Decide where to place the window on launch.
219///
220/// - An explicit `STUDIO_WORKER_WINDOW_POS="x,y"` always wins (any build).
221/// - Otherwise, debug builds default to the left monitor's top-left so
222///   the window opens on the left screen during development.
223/// - Release builds return `None`, letting the window manager decide.
224fn dev_window_position(env: Option<&str>) -> Option<[f32; 2]> {
225    if let Some(raw) = env {
226        let mut parts = raw.split(',').map(str::trim);
227        if let (Some(x), Some(y), None) = (parts.next(), parts.next(), parts.next()) {
228            if let (Ok(x), Ok(y)) = (x.parse::<f32>(), y.parse::<f32>()) {
229                return Some([x, y]);
230            }
231        }
232        return None;
233    }
234    // The left monitor sits at the X11 root origin; a small inset keeps
235    // the title bar clear of the screen edge.  Release builds defer to
236    // the window manager.
237    #[cfg(debug_assertions)]
238    let default = Some([48.0, 48.0]);
239    #[cfg(not(debug_assertions))]
240    let default = None;
241    default
242}
243
244#[cfg(test)]
245mod tests {
246    use super::dev_window_position;
247
248    #[test]
249    fn parses_explicit_position_override() {
250        assert_eq!(dev_window_position(Some("100,200")), Some([100.0, 200.0]));
251    }
252
253    #[test]
254    fn trims_whitespace_around_coords() {
255        assert_eq!(dev_window_position(Some(" 10 , 20 ")), Some([10.0, 20.0]));
256    }
257
258    #[test]
259    fn rejects_malformed_override() {
260        assert_eq!(dev_window_position(Some("not-a-pos")), None);
261        assert_eq!(dev_window_position(Some("1,2,3")), None);
262        assert_eq!(dev_window_position(Some("1")), None);
263    }
264
265    #[cfg(debug_assertions)]
266    #[test]
267    fn defaults_to_left_screen_in_debug() {
268        assert_eq!(dev_window_position(None), Some([48.0, 48.0]));
269    }
270
271    #[cfg(not(debug_assertions))]
272    #[test]
273    fn defers_to_wm_in_release() {
274        assert_eq!(dev_window_position(None), None);
275    }
276}