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 last_heartbeat: Arc::new(Mutex::new(None)),
414 recent_logs: Arc::new(Mutex::new(VecDeque::new())),
415 };
416 let stop = Arc::new(AtomicBool::new(false));
417 AppDeps {
418 cfg,
419 logs,
420 busy,
421 paused,
422 observers,
423 stop,
424 config_path: PathBuf::from("/tmp/studio-worker-test.toml"),
425 tokio: handle,
426 }
427 }
428
429 #[test]
430 fn start_minimised_pending_follows_the_config() {
431 let app = App::new(mock_deps());
433 assert!(
434 app.start_minimised_pending(),
435 "default config must request a minimised start"
436 );
437
438 let deps = mock_deps();
440 deps.cfg.lock().start_minimised = false;
441 let app = App::new(deps);
442 assert!(!app.start_minimised_pending());
443 }
444
445 #[test]
446 fn log_tray_variant_change_emits_structured_transition() {
447 use crate::test_support::capture;
448 let logs = capture(|| {
449 super::log_tray_variant_change(TrayVariant::Disconnected, TrayVariant::Busy);
450 });
451 assert!(logs.contains("INFO"), "expected INFO event, got: {logs}");
452 assert!(
453 logs.contains("studio_worker::ui::app"),
454 "expected app target, got: {logs}"
455 );
456 assert!(
457 logs.contains("op=\"tray_variant\""),
458 "expected op field: {logs}"
459 );
460 assert!(
461 logs.contains("from=Disconnected"),
462 "expected from field: {logs}"
463 );
464 assert!(logs.contains("to=Busy"), "expected to field: {logs}");
465 }
466
467 #[test]
468 fn new_defaults_to_status_tab() {
469 let app = App::new(mock_deps());
470 assert_eq!(app.current_tab(), Tab::Status);
471 }
472
473 #[test]
474 fn set_tab_switches() {
475 let mut app = App::new(mock_deps());
476 app.set_tab(Tab::Logs);
477 assert_eq!(app.current_tab(), Tab::Logs);
478 }
479
480 #[test]
484 fn render_does_not_panic_under_test_ui() {
485 let mut app = App::new(mock_deps());
486 egui::__run_test_ui(|ui| {
487 app.render(ui);
488 });
489 }
490
491 #[test]
492 fn render_each_tab_does_not_panic() {
493 for tab in Tab::ALL {
494 let mut app = App::new(mock_deps());
495 app.set_tab(tab);
496 egui::__run_test_ui(|ui| {
497 app.render(ui);
498 });
499 }
500 }
501
502 #[test]
503 fn config_tab_does_not_publish_unsaved_edits_to_shared_config() {
504 let mut app = App::new(mock_deps());
505 app.config_draft.current.api_base_url = "https://unsaved.example".into();
506
507 egui::__run_test_ui(|ui| {
508 app.render_config(ui);
509 });
510
511 assert_eq!(
512 app.deps.cfg.lock().api_base_url,
513 Config::default().api_base_url,
514 "editing the draft must not affect the live runtime config until Save succeeds"
515 );
516 }
517
518 fn completed_recent_job(id: &str) -> crate::runtime::RecentJob {
519 let now = chrono::Utc::now();
520 crate::runtime::RecentJob {
521 job_id: id.into(),
522 kind: crate::types::TaskKind::Image,
523 model: "synthetic".into(),
524 prompt: "p".into(),
525 outcome: crate::runtime::JobOutcome::Completed,
526 started_at: now,
527 finished_at: now,
528 }
529 }
530
531 type Captured = Arc<Mutex<Vec<(String, String)>>>;
534
535 fn app_with_capturing_notifier(deps: AppDeps) -> (App, Captured) {
536 let captured: Captured = Arc::new(Mutex::new(Vec::new()));
537 let notifier = Box::new(crate::ui::notifier::CapturingNotifier {
538 captured: captured.clone(),
539 });
540 let mut app = App::with_notifier(deps, notifier);
541 app.set_notification_prefs(NotificationPrefs {
542 on_completion: true,
543 on_failure: true,
544 });
545 (app, captured)
546 }
547
548 #[test]
549 fn drain_notifications_fires_for_each_new_completed_job() {
550 let deps = mock_deps();
551 let observers = deps.observers.clone();
552 let (mut app, captured) = app_with_capturing_notifier(deps);
553
554 crate::runtime::record_recent_job(&observers, completed_recent_job("a"));
555 crate::runtime::record_recent_job(&observers, completed_recent_job("b"));
556 app.drain_notifications();
557
558 assert_eq!(captured.lock().len(), 2);
559 }
560
561 #[test]
562 fn drain_notifications_is_idempotent_without_new_jobs() {
563 let deps = mock_deps();
564 let observers = deps.observers.clone();
565 let (mut app, captured) = app_with_capturing_notifier(deps);
566
567 crate::runtime::record_recent_job(&observers, completed_recent_job("a"));
568 app.drain_notifications();
569 app.drain_notifications();
570 app.drain_notifications();
571
572 assert_eq!(
573 captured.lock().len(),
574 1,
575 "re-draining with no new jobs must not re-notify"
576 );
577 }
578
579 #[test]
580 fn drain_notifications_fires_after_recent_jobs_ring_saturates() {
581 let deps = mock_deps();
582 let observers = deps.observers.clone();
583 let (mut app, captured) = app_with_capturing_notifier(deps);
584
585 for i in 0..(crate::runtime::RECENT_JOBS_CAP + 5) {
589 crate::runtime::record_recent_job(
590 &observers,
591 completed_recent_job(&format!("warm-{i}")),
592 );
593 }
594 app.drain_notifications();
595 captured.lock().clear();
596
597 crate::runtime::record_recent_job(&observers, completed_recent_job("after-saturation"));
600 app.drain_notifications();
601
602 let shown = captured.lock();
603 assert_eq!(
604 shown.len(),
605 1,
606 "a job completing after the ring saturates must still notify"
607 );
608 assert!(
609 shown[0].1.contains("image"),
610 "notification body should describe the new job, got: {:?}",
611 shown[0]
612 );
613 }
614}