1use std::{
5 path::PathBuf,
6 sync::{atomic::AtomicBool, Arc},
7 time::Duration,
8};
9
10use eframe::egui;
11use parking_lot::Mutex;
12use tokio::runtime::Handle;
13
14use crate::{
15 config::SharedConfig,
16 runtime::{WorkerObservers, HEARTBEAT_INTERVAL},
17 sys,
18 types::LogEntry,
19};
20
21use super::{
22 notifier::{decide, NotificationPrefs, Notifier, NotifyDecision},
23 tab::Tab,
24 tabs::{
25 about::{self as about_tab, AboutState},
26 config::{self as config_tab, ConfigDraft},
27 jobs as jobs_tab,
28 logs::{self as logs_tab, LogFilter},
29 status as status_tab,
30 },
31 tray::{self, TrayVariant},
32};
33
34const TRACE_TARGET: &str = "studio_worker::ui::app";
37
38fn log_tray_variant_change(from: TrayVariant, to: TrayVariant) {
43 tracing::info!(
44 target: TRACE_TARGET,
45 op = "tray_variant",
46 from = ?from,
47 to = ?to,
48 "tray status indicator changed"
49 );
50}
51
52pub struct AppDeps {
54 pub cfg: SharedConfig,
55 pub logs: Arc<Mutex<Vec<LogEntry>>>,
56 pub busy: Arc<AtomicBool>,
57 pub paused: Arc<AtomicBool>,
61 pub observers: WorkerObservers,
62 pub stop: Arc<AtomicBool>,
63 pub config_path: PathBuf,
64 pub tokio: Handle,
65}
66
67pub struct App {
68 deps: AppDeps,
69 tab: Tab,
70 registration: crate::auto_register::SharedRegistration,
71 config_draft: ConfigDraft,
72 log_filter: LogFilter,
73 about_state: AboutState,
74 vram_total_gb: f32,
75 last_notified: Option<(String, chrono::DateTime<chrono::Utc>)>,
81 notifier: Box<dyn Notifier + Send + Sync>,
82 notification_prefs: NotificationPrefs,
83 tray_variant: TrayVariant,
84 quit_requested: Arc<std::sync::atomic::AtomicBool>,
85 tray: Option<super::tray_host::TrayHandle>,
86 start_minimised_pending: bool,
91}
92
93impl App {
94 pub fn new(deps: AppDeps) -> Self {
95 Self::with_notifier(deps, Self::default_notifier())
96 }
97
98 pub fn with_notifier(deps: AppDeps, notifier: Box<dyn Notifier + Send + Sync>) -> Self {
100 Self::with_notifier_and_registration(deps, notifier, crate::auto_register::shared_initial())
101 }
102
103 pub fn with_notifier_and_registration(
106 deps: AppDeps,
107 notifier: Box<dyn Notifier + Send + Sync>,
108 registration: crate::auto_register::SharedRegistration,
109 ) -> Self {
110 let (config_draft, start_minimised_pending) = {
111 let cfg = deps.cfg.lock();
112 (ConfigDraft::from(&cfg), cfg.start_minimised)
113 };
114 let vram_total_gb = sys::detect_vram_gb().unwrap_or(0.0);
115 Self {
116 deps,
117 tab: Tab::initial(),
118 registration,
119 config_draft,
120 log_filter: LogFilter::default(),
121 about_state: AboutState::default(),
122 vram_total_gb,
123 last_notified: None,
124 notifier,
125 notification_prefs: NotificationPrefs::default(),
126 tray_variant: TrayVariant::Disconnected,
127 quit_requested: Arc::new(std::sync::atomic::AtomicBool::new(false)),
128 tray: None,
129 start_minimised_pending,
130 }
131 }
132
133 pub fn start_minimised_pending(&self) -> bool {
136 self.start_minimised_pending
137 }
138
139 pub fn registration_handle(&self) -> crate::auto_register::SharedRegistration {
140 self.registration.clone()
141 }
142
143 pub fn attach_tray(&mut self, tray: super::tray_host::TrayHandle) {
144 self.tray = Some(tray);
145 }
146
147 pub fn quit_requested_handle(&self) -> Arc<std::sync::atomic::AtomicBool> {
148 self.quit_requested.clone()
149 }
150
151 pub fn notification_prefs(&self) -> NotificationPrefs {
152 self.notification_prefs
153 }
154
155 pub fn set_notification_prefs(&mut self, prefs: NotificationPrefs) {
156 self.notification_prefs = prefs;
157 }
158
159 pub fn tray_variant(&self) -> TrayVariant {
160 self.tray_variant
161 }
162
163 fn default_notifier() -> Box<dyn Notifier + Send + Sync> {
164 Self::default_notifier_box()
165 }
166
167 pub fn default_notifier_box() -> Box<dyn Notifier + Send + Sync> {
169 Box::new(super::notifier::DesktopNotifier)
170 }
171
172 pub fn drain_notifications(&mut self) {
175 let new_entries: Vec<_> = {
181 let ring = self.deps.observers.recent_jobs.lock();
182 let mut collected = Vec::new();
183 for entry in ring.iter() {
184 if self
185 .last_notified
186 .as_ref()
187 .is_some_and(|(id, ts)| entry.job_id == *id && entry.finished_at == *ts)
188 {
189 break;
190 }
191 collected.push(entry.clone());
192 }
193 collected
194 };
195 if let Some(newest) = new_entries.first() {
196 self.last_notified = Some((newest.job_id.clone(), newest.finished_at));
197 }
198 for entry in new_entries.into_iter().rev() {
201 if let NotifyDecision::Show { title, body } = decide(self.notification_prefs, &entry) {
202 self.notifier.show(&title, &body);
203 }
204 }
205 }
206
207 pub fn refresh_tray_variant(&mut self) -> TrayVariant {
210 let busy = self.deps.busy.load(std::sync::atomic::Ordering::SeqCst);
211 let hb = self.deps.observers.last_heartbeat.lock().clone();
212 let v = tray::derive_variant(busy, hb.as_ref(), HEARTBEAT_INTERVAL);
213 if v != self.tray_variant {
214 log_tray_variant_change(self.tray_variant, v);
215 if let Some(tray) = self.tray.as_mut() {
219 tray.set_variant(v);
220 }
221 }
222 self.tray_variant = v;
223 v
224 }
225
226 pub fn render(&mut self, ui: &mut egui::Ui) {
231 egui::Panel::top("tab_bar").show_inside(ui, |ui| {
232 ui.add_space(4.0);
233 ui.horizontal(|ui| {
234 for tab in Tab::ALL {
235 let selected = self.tab == tab;
236 if ui.selectable_label(selected, tab.label()).clicked() {
237 self.tab = tab;
238 }
239 }
240 });
241 ui.add_space(4.0);
242 });
243
244 egui::CentralPanel::default().show_inside(ui, |ui| {
245 egui::ScrollArea::vertical().show(ui, |ui| match self.tab {
246 Tab::Status => self.render_status(ui),
247 Tab::Jobs => self.render_jobs(ui),
248 Tab::Config => self.render_config(ui),
249 Tab::Logs => self.render_logs(ui),
250 Tab::About => self.render_about(ui),
251 });
252 });
253
254 ui.ctx().request_repaint_after(Duration::from_millis(250));
257 }
258
259 fn pre_render(&mut self, ctx: &egui::Context) {
262 if self.start_minimised_pending {
264 self.start_minimised_pending = false;
265 tracing::info!(
266 target: TRACE_TARGET,
267 op = "start_minimised",
268 "minimising window on startup (config start_minimised)"
269 );
270 ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true));
271 }
272
273 self.drain_notifications();
274 self.refresh_tray_variant();
275
276 if ctx.input(|i| i.viewport().close_requested())
279 && !self
280 .quit_requested
281 .load(std::sync::atomic::Ordering::SeqCst)
282 {
283 tracing::info!(
284 target: TRACE_TARGET,
285 op = "hide_to_tray",
286 "window close intercepted; hiding to tray (worker keeps running)"
287 );
288 ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
289 ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false));
290 }
291
292 if self
294 .quit_requested
295 .load(std::sync::atomic::Ordering::SeqCst)
296 {
297 self.deps
298 .stop
299 .store(true, std::sync::atomic::Ordering::SeqCst);
300 ctx.send_viewport_cmd(egui::ViewportCommand::Close);
301 }
302 }
303
304 pub fn current_tab(&self) -> Tab {
306 self.tab
307 }
308
309 pub fn set_tab(&mut self, tab: Tab) {
311 self.tab = tab;
312 }
313
314 pub fn deps(&self) -> &AppDeps {
315 &self.deps
316 }
317
318 fn render_jobs(&mut self, ui: &mut egui::Ui) {
319 let view = jobs_tab::JobsView::build(&self.deps.observers, chrono::Utc::now());
320 jobs_tab::render(ui, &view);
321 }
322
323 fn render_config(&mut self, ui: &mut egui::Ui) {
324 let saved = config_tab::render(
325 ui,
326 &mut self.config_draft,
327 &self.deps.config_path,
328 &mut self.notification_prefs,
329 );
330 if saved {
334 let mut shared = self.deps.cfg.lock();
335 *shared = self.config_draft.current.clone();
336 }
337 }
338
339 fn render_logs(&mut self, ui: &mut egui::Ui) {
340 logs_tab::render(ui, &self.deps.observers.recent_logs, &mut self.log_filter);
341 }
342
343 fn render_about(&mut self, ui: &mut egui::Ui) {
344 let view = about_tab::AboutView::build(&self.about_state, &self.deps.config_path);
345 about_tab::render(
346 ui,
347 &view,
348 &self.about_state,
349 &self.deps.tokio,
350 &self.deps.config_path,
351 );
352 }
353
354 fn render_status(&mut self, ui: &mut egui::Ui) {
355 let registration_snapshot = self.registration.lock().clone();
356 let paused_flag = self.deps.paused.clone();
357 let view = {
358 let cfg = self.deps.cfg.lock();
359 let busy = self.deps.busy.load(std::sync::atomic::Ordering::SeqCst);
360 let paused = paused_flag.load(std::sync::atomic::Ordering::SeqCst);
361 let hb = self.deps.observers.last_heartbeat.lock().clone();
362 status_tab::StatusView::build(
363 &cfg,
364 ®istration_snapshot,
365 busy,
366 paused,
367 hb.as_ref(),
368 self.vram_total_gb,
369 )
370 };
371 status_tab::render(ui, &view, &paused_flag);
372 }
373}
374
375impl eframe::App for App {
376 fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
377 let ctx = ui.ctx().clone();
378 self.pre_render(&ctx);
379 self.render(ui);
380 }
381}
382
383#[cfg(test)]
384mod tests {
385 use std::collections::VecDeque;
386
387 use super::*;
388 use crate::{config::Config, runtime::WorkerObservers};
389
390 fn mock_deps() -> AppDeps {
391 static RT: std::sync::OnceLock<tokio::runtime::Runtime> = std::sync::OnceLock::new();
396 let handle = RT
397 .get_or_init(|| {
398 tokio::runtime::Builder::new_multi_thread()
399 .enable_all()
400 .worker_threads(1)
401 .build()
402 .expect("tokio runtime")
403 })
404 .handle()
405 .clone();
406 let cfg = crate::config::shared(Config::default());
407 let logs = Arc::new(Mutex::new(Vec::new()));
408 let busy = Arc::new(AtomicBool::new(false));
409 let paused = Arc::new(AtomicBool::new(false));
410 let observers = WorkerObservers {
411 current_job: Arc::new(Mutex::new(None)),
412 recent_jobs: Arc::new(Mutex::new(VecDeque::new())),
413 local_jobs: Arc::new(Mutex::new(VecDeque::new())),
414 local_api_url: Arc::new(Mutex::new(None)),
415 last_heartbeat: Arc::new(Mutex::new(None)),
416 recent_logs: Arc::new(Mutex::new(VecDeque::new())),
417 };
418 let stop = Arc::new(AtomicBool::new(false));
419 AppDeps {
420 cfg,
421 logs,
422 busy,
423 paused,
424 observers,
425 stop,
426 config_path: PathBuf::from("/tmp/studio-worker-test.toml"),
427 tokio: handle,
428 }
429 }
430
431 #[test]
432 fn start_minimised_pending_follows_the_config() {
433 let app = App::new(mock_deps());
435 assert!(
436 app.start_minimised_pending(),
437 "default config must request a minimised start"
438 );
439
440 let deps = mock_deps();
442 deps.cfg.lock().start_minimised = false;
443 let app = App::new(deps);
444 assert!(!app.start_minimised_pending());
445 }
446
447 #[test]
448 fn log_tray_variant_change_emits_structured_transition() {
449 use crate::test_support::capture;
450 let logs = capture(|| {
451 super::log_tray_variant_change(TrayVariant::Disconnected, TrayVariant::Busy);
452 });
453 assert!(logs.contains("INFO"), "expected INFO event, got: {logs}");
454 assert!(
455 logs.contains("studio_worker::ui::app"),
456 "expected app target, got: {logs}"
457 );
458 assert!(
459 logs.contains("op=\"tray_variant\""),
460 "expected op field: {logs}"
461 );
462 assert!(
463 logs.contains("from=Disconnected"),
464 "expected from field: {logs}"
465 );
466 assert!(logs.contains("to=Busy"), "expected to field: {logs}");
467 }
468
469 #[test]
470 fn new_defaults_to_status_tab() {
471 let app = App::new(mock_deps());
472 assert_eq!(app.current_tab(), Tab::Status);
473 }
474
475 #[test]
476 fn set_tab_switches() {
477 let mut app = App::new(mock_deps());
478 app.set_tab(Tab::Logs);
479 assert_eq!(app.current_tab(), Tab::Logs);
480 }
481
482 #[test]
486 fn render_does_not_panic_under_test_ui() {
487 let mut app = App::new(mock_deps());
488 egui::__run_test_ui(|ui| {
489 app.render(ui);
490 });
491 }
492
493 #[test]
494 fn render_each_tab_does_not_panic() {
495 for tab in Tab::ALL {
496 let mut app = App::new(mock_deps());
497 app.set_tab(tab);
498 egui::__run_test_ui(|ui| {
499 app.render(ui);
500 });
501 }
502 }
503
504 #[test]
505 fn config_tab_does_not_publish_unsaved_edits_to_shared_config() {
506 let mut app = App::new(mock_deps());
507 app.config_draft.current.api_base_url = "https://unsaved.example".into();
508
509 egui::__run_test_ui(|ui| {
510 app.render_config(ui);
511 });
512
513 assert_eq!(
514 app.deps.cfg.lock().api_base_url,
515 Config::default().api_base_url,
516 "editing the draft must not affect the live runtime config until Save succeeds"
517 );
518 }
519
520 fn completed_recent_job(id: &str) -> crate::runtime::RecentJob {
521 let now = chrono::Utc::now();
522 crate::runtime::RecentJob {
523 job_id: id.into(),
524 kind: crate::types::TaskKind::Image,
525 model: "synthetic".into(),
526 prompt: "p".into(),
527 outcome: crate::runtime::JobOutcome::Completed,
528 started_at: now,
529 finished_at: now,
530 }
531 }
532
533 type Captured = Arc<Mutex<Vec<(String, String)>>>;
536
537 fn app_with_capturing_notifier(deps: AppDeps) -> (App, Captured) {
538 let captured: Captured = Arc::new(Mutex::new(Vec::new()));
539 let notifier = Box::new(crate::ui::notifier::CapturingNotifier {
540 captured: captured.clone(),
541 });
542 let mut app = App::with_notifier(deps, notifier);
543 app.set_notification_prefs(NotificationPrefs {
544 on_completion: true,
545 on_failure: true,
546 });
547 (app, captured)
548 }
549
550 #[test]
551 fn drain_notifications_fires_for_each_new_completed_job() {
552 let deps = mock_deps();
553 let observers = deps.observers.clone();
554 let (mut app, captured) = app_with_capturing_notifier(deps);
555
556 crate::runtime::record_recent_job(&observers, completed_recent_job("a"));
557 crate::runtime::record_recent_job(&observers, completed_recent_job("b"));
558 app.drain_notifications();
559
560 assert_eq!(captured.lock().len(), 2);
561 }
562
563 #[test]
564 fn drain_notifications_is_idempotent_without_new_jobs() {
565 let deps = mock_deps();
566 let observers = deps.observers.clone();
567 let (mut app, captured) = app_with_capturing_notifier(deps);
568
569 crate::runtime::record_recent_job(&observers, completed_recent_job("a"));
570 app.drain_notifications();
571 app.drain_notifications();
572 app.drain_notifications();
573
574 assert_eq!(
575 captured.lock().len(),
576 1,
577 "re-draining with no new jobs must not re-notify"
578 );
579 }
580
581 #[test]
582 fn drain_notifications_fires_after_recent_jobs_ring_saturates() {
583 let deps = mock_deps();
584 let observers = deps.observers.clone();
585 let (mut app, captured) = app_with_capturing_notifier(deps);
586
587 for i in 0..(crate::runtime::RECENT_JOBS_CAP + 5) {
591 crate::runtime::record_recent_job(
592 &observers,
593 completed_recent_job(&format!("warm-{i}")),
594 );
595 }
596 app.drain_notifications();
597 captured.lock().clear();
598
599 crate::runtime::record_recent_job(&observers, completed_recent_job("after-saturation"));
602 app.drain_notifications();
603
604 let shown = captured.lock();
605 assert_eq!(
606 shown.len(),
607 1,
608 "a job completing after the ring saturates must still notify"
609 );
610 assert!(
611 shown[0].1.contains("image"),
612 "notification body should describe the new job, got: {:?}",
613 shown[0]
614 );
615 }
616}