1pub 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
32pub const DISPLAY_ATTEMPT_ENV: &str = "STUDIO_WORKER_UI_DISPLAY_ATTEMPT";
34
35pub const DISPLAY_RETRY_BASE: Duration = Duration::from_secs(2);
37pub const DISPLAY_RETRY_MAX: Duration = Duration::from_secs(60);
39
40pub 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
47pub fn display_attempt(env_value: Option<&str>) -> u32 {
49 env_value.and_then(|v| v.parse().ok()).unwrap_or(0)
50}
51
52pub 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
66pub 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 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 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 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 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 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 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#[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
202fn 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
225fn 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 #[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}