1pub 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
42pub const DISPLAY_ATTEMPT_ENV: &str = "STUDIO_WORKER_UI_DISPLAY_ATTEMPT";
44
45pub const DISPLAY_RETRY_BASE: Duration = Duration::from_secs(2);
47pub const DISPLAY_RETRY_MAX: Duration = Duration::from_secs(60);
49
50pub 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
57pub fn display_attempt(env_value: Option<&str>) -> u32 {
59 env_value.and_then(|v| v.parse().ok()).unwrap_or(0)
60}
61
62pub 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
76pub 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 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 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 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 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 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 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
208enum UiLockOutcome {
210 Held(Option<single_instance::UiLock>),
213 HandedOver,
215}
216
217fn 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 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
248pub 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#[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
282fn 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
305fn 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 #[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 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}