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