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}
87
88impl App {
89 pub fn new(deps: AppDeps) -> Self {
90 Self::with_notifier(deps, Self::default_notifier())
91 }
92
93 pub fn with_notifier(deps: AppDeps, notifier: Box<dyn Notifier + Send + Sync>) -> Self {
95 Self::with_notifier_and_registration(deps, notifier, crate::auto_register::shared_initial())
96 }
97
98 pub fn with_notifier_and_registration(
101 deps: AppDeps,
102 notifier: Box<dyn Notifier + Send + Sync>,
103 registration: crate::auto_register::SharedRegistration,
104 ) -> Self {
105 let config_draft = {
106 let cfg = deps.cfg.lock();
107 ConfigDraft::from(&cfg)
108 };
109 let vram_total_gb = sys::detect_vram_gb().unwrap_or(0.0);
110 Self {
111 deps,
112 tab: Tab::initial(),
113 registration,
114 config_draft,
115 log_filter: LogFilter::default(),
116 about_state: AboutState::default(),
117 vram_total_gb,
118 last_notified: None,
119 notifier,
120 notification_prefs: NotificationPrefs::default(),
121 tray_variant: TrayVariant::Disconnected,
122 quit_requested: Arc::new(std::sync::atomic::AtomicBool::new(false)),
123 tray: None,
124 }
125 }
126
127 pub fn registration_handle(&self) -> crate::auto_register::SharedRegistration {
128 self.registration.clone()
129 }
130
131 pub fn attach_tray(&mut self, tray: super::tray_host::TrayHandle) {
132 self.tray = Some(tray);
133 }
134
135 pub fn quit_requested_handle(&self) -> Arc<std::sync::atomic::AtomicBool> {
136 self.quit_requested.clone()
137 }
138
139 pub fn notification_prefs(&self) -> NotificationPrefs {
140 self.notification_prefs
141 }
142
143 pub fn set_notification_prefs(&mut self, prefs: NotificationPrefs) {
144 self.notification_prefs = prefs;
145 }
146
147 pub fn tray_variant(&self) -> TrayVariant {
148 self.tray_variant
149 }
150
151 fn default_notifier() -> Box<dyn Notifier + Send + Sync> {
152 Self::default_notifier_box()
153 }
154
155 pub fn default_notifier_box() -> Box<dyn Notifier + Send + Sync> {
157 Box::new(super::notifier::DesktopNotifier)
158 }
159
160 pub fn drain_notifications(&mut self) {
163 let new_entries: Vec<_> = {
169 let ring = self.deps.observers.recent_jobs.lock();
170 let mut collected = Vec::new();
171 for entry in ring.iter() {
172 if self
173 .last_notified
174 .as_ref()
175 .is_some_and(|(id, ts)| entry.job_id == *id && entry.finished_at == *ts)
176 {
177 break;
178 }
179 collected.push(entry.clone());
180 }
181 collected
182 };
183 if let Some(newest) = new_entries.first() {
184 self.last_notified = Some((newest.job_id.clone(), newest.finished_at));
185 }
186 for entry in new_entries.into_iter().rev() {
189 if let NotifyDecision::Show { title, body } = decide(self.notification_prefs, &entry) {
190 self.notifier.show(&title, &body);
191 }
192 }
193 }
194
195 pub fn refresh_tray_variant(&mut self) -> TrayVariant {
198 let busy = self.deps.busy.load(std::sync::atomic::Ordering::SeqCst);
199 let hb = self.deps.observers.last_heartbeat.lock().clone();
200 let v = tray::derive_variant(busy, hb.as_ref(), HEARTBEAT_INTERVAL);
201 if v != self.tray_variant {
202 log_tray_variant_change(self.tray_variant, v);
203 if let Some(tray) = self.tray.as_mut() {
207 tray.set_variant(v);
208 }
209 }
210 self.tray_variant = v;
211 v
212 }
213
214 pub fn render(&mut self, ui: &mut egui::Ui) {
219 egui::Panel::top("tab_bar").show_inside(ui, |ui| {
220 ui.add_space(4.0);
221 ui.horizontal(|ui| {
222 for tab in Tab::ALL {
223 let selected = self.tab == tab;
224 if ui.selectable_label(selected, tab.label()).clicked() {
225 self.tab = tab;
226 }
227 }
228 });
229 ui.add_space(4.0);
230 });
231
232 egui::CentralPanel::default().show_inside(ui, |ui| {
233 egui::ScrollArea::vertical().show(ui, |ui| match self.tab {
234 Tab::Status => self.render_status(ui),
235 Tab::Jobs => self.render_jobs(ui),
236 Tab::Config => self.render_config(ui),
237 Tab::Logs => self.render_logs(ui),
238 Tab::About => self.render_about(ui),
239 });
240 });
241
242 ui.ctx().request_repaint_after(Duration::from_millis(250));
245 }
246
247 fn pre_render(&mut self, ctx: &egui::Context) {
250 self.drain_notifications();
251 self.refresh_tray_variant();
252
253 if ctx.input(|i| i.viewport().close_requested())
256 && !self
257 .quit_requested
258 .load(std::sync::atomic::Ordering::SeqCst)
259 {
260 tracing::info!(
261 target: TRACE_TARGET,
262 op = "hide_to_tray",
263 "window close intercepted; hiding to tray (worker keeps running)"
264 );
265 ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
266 ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false));
267 }
268
269 if self
271 .quit_requested
272 .load(std::sync::atomic::Ordering::SeqCst)
273 {
274 self.deps
275 .stop
276 .store(true, std::sync::atomic::Ordering::SeqCst);
277 ctx.send_viewport_cmd(egui::ViewportCommand::Close);
278 }
279 }
280
281 pub fn current_tab(&self) -> Tab {
283 self.tab
284 }
285
286 pub fn set_tab(&mut self, tab: Tab) {
288 self.tab = tab;
289 }
290
291 pub fn deps(&self) -> &AppDeps {
292 &self.deps
293 }
294
295 fn render_jobs(&mut self, ui: &mut egui::Ui) {
296 let view = jobs_tab::JobsView::build(&self.deps.observers, chrono::Utc::now());
297 jobs_tab::render(ui, &view);
298 }
299
300 fn render_config(&mut self, ui: &mut egui::Ui) {
301 let saved = config_tab::render(
302 ui,
303 &mut self.config_draft,
304 &self.deps.config_path,
305 &mut self.notification_prefs,
306 );
307 if saved {
311 let mut shared = self.deps.cfg.lock();
312 *shared = self.config_draft.current.clone();
313 }
314 }
315
316 fn render_logs(&mut self, ui: &mut egui::Ui) {
317 logs_tab::render(ui, &self.deps.observers.recent_logs, &mut self.log_filter);
318 }
319
320 fn render_about(&mut self, ui: &mut egui::Ui) {
321 let view = about_tab::AboutView::build(&self.about_state, &self.deps.config_path);
322 about_tab::render(
323 ui,
324 &view,
325 &self.about_state,
326 &self.deps.tokio,
327 &self.deps.config_path,
328 );
329 }
330
331 fn render_status(&mut self, ui: &mut egui::Ui) {
332 let registration_snapshot = self.registration.lock().clone();
333 let paused_flag = self.deps.paused.clone();
334 let view = {
335 let cfg = self.deps.cfg.lock();
336 let busy = self.deps.busy.load(std::sync::atomic::Ordering::SeqCst);
337 let paused = paused_flag.load(std::sync::atomic::Ordering::SeqCst);
338 let hb = self.deps.observers.last_heartbeat.lock().clone();
339 status_tab::StatusView::build(
340 &cfg,
341 ®istration_snapshot,
342 busy,
343 paused,
344 hb.as_ref(),
345 self.vram_total_gb,
346 )
347 };
348 status_tab::render(ui, &view, &paused_flag);
349 }
350}
351
352impl eframe::App for App {
353 fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
354 let ctx = ui.ctx().clone();
355 self.pre_render(&ctx);
356 self.render(ui);
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use std::collections::VecDeque;
363
364 use super::*;
365 use crate::{config::Config, runtime::WorkerObservers};
366
367 fn mock_deps() -> AppDeps {
368 static RT: std::sync::OnceLock<tokio::runtime::Runtime> = std::sync::OnceLock::new();
373 let handle = RT
374 .get_or_init(|| {
375 tokio::runtime::Builder::new_multi_thread()
376 .enable_all()
377 .worker_threads(1)
378 .build()
379 .expect("tokio runtime")
380 })
381 .handle()
382 .clone();
383 let cfg = crate::config::shared(Config::default());
384 let logs = Arc::new(Mutex::new(Vec::new()));
385 let busy = Arc::new(AtomicBool::new(false));
386 let paused = Arc::new(AtomicBool::new(false));
387 let observers = WorkerObservers {
388 current_job: Arc::new(Mutex::new(None)),
389 recent_jobs: Arc::new(Mutex::new(VecDeque::new())),
390 last_heartbeat: Arc::new(Mutex::new(None)),
391 recent_logs: Arc::new(Mutex::new(VecDeque::new())),
392 };
393 let stop = Arc::new(AtomicBool::new(false));
394 AppDeps {
395 cfg,
396 logs,
397 busy,
398 paused,
399 observers,
400 stop,
401 config_path: PathBuf::from("/tmp/studio-worker-test.toml"),
402 tokio: handle,
403 }
404 }
405
406 #[test]
407 fn log_tray_variant_change_emits_structured_transition() {
408 use crate::test_support::capture;
409 let logs = capture(|| {
410 super::log_tray_variant_change(TrayVariant::Disconnected, TrayVariant::Busy);
411 });
412 assert!(logs.contains("INFO"), "expected INFO event, got: {logs}");
413 assert!(
414 logs.contains("studio_worker::ui::app"),
415 "expected app target, got: {logs}"
416 );
417 assert!(
418 logs.contains("op=\"tray_variant\""),
419 "expected op field: {logs}"
420 );
421 assert!(
422 logs.contains("from=Disconnected"),
423 "expected from field: {logs}"
424 );
425 assert!(logs.contains("to=Busy"), "expected to field: {logs}");
426 }
427
428 #[test]
429 fn new_defaults_to_status_tab() {
430 let app = App::new(mock_deps());
431 assert_eq!(app.current_tab(), Tab::Status);
432 }
433
434 #[test]
435 fn set_tab_switches() {
436 let mut app = App::new(mock_deps());
437 app.set_tab(Tab::Logs);
438 assert_eq!(app.current_tab(), Tab::Logs);
439 }
440
441 #[test]
445 fn render_does_not_panic_under_test_ui() {
446 let mut app = App::new(mock_deps());
447 egui::__run_test_ui(|ui| {
448 app.render(ui);
449 });
450 }
451
452 #[test]
453 fn render_each_tab_does_not_panic() {
454 for tab in Tab::ALL {
455 let mut app = App::new(mock_deps());
456 app.set_tab(tab);
457 egui::__run_test_ui(|ui| {
458 app.render(ui);
459 });
460 }
461 }
462
463 #[test]
464 fn config_tab_does_not_publish_unsaved_edits_to_shared_config() {
465 let mut app = App::new(mock_deps());
466 app.config_draft.current.api_base_url = "https://unsaved.example".into();
467
468 egui::__run_test_ui(|ui| {
469 app.render_config(ui);
470 });
471
472 assert_eq!(
473 app.deps.cfg.lock().api_base_url,
474 Config::default().api_base_url,
475 "editing the draft must not affect the live runtime config until Save succeeds"
476 );
477 }
478
479 fn completed_recent_job(id: &str) -> crate::runtime::RecentJob {
480 let now = chrono::Utc::now();
481 crate::runtime::RecentJob {
482 job_id: id.into(),
483 kind: crate::types::TaskKind::Image,
484 model: "synthetic".into(),
485 prompt: "p".into(),
486 outcome: crate::runtime::JobOutcome::Completed,
487 started_at: now,
488 finished_at: now,
489 }
490 }
491
492 type Captured = Arc<Mutex<Vec<(String, String)>>>;
495
496 fn app_with_capturing_notifier(deps: AppDeps) -> (App, Captured) {
497 let captured: Captured = Arc::new(Mutex::new(Vec::new()));
498 let notifier = Box::new(crate::ui::notifier::CapturingNotifier {
499 captured: captured.clone(),
500 });
501 let mut app = App::with_notifier(deps, notifier);
502 app.set_notification_prefs(NotificationPrefs {
503 on_completion: true,
504 on_failure: true,
505 });
506 (app, captured)
507 }
508
509 #[test]
510 fn drain_notifications_fires_for_each_new_completed_job() {
511 let deps = mock_deps();
512 let observers = deps.observers.clone();
513 let (mut app, captured) = app_with_capturing_notifier(deps);
514
515 crate::runtime::record_recent_job(&observers, completed_recent_job("a"));
516 crate::runtime::record_recent_job(&observers, completed_recent_job("b"));
517 app.drain_notifications();
518
519 assert_eq!(captured.lock().len(), 2);
520 }
521
522 #[test]
523 fn drain_notifications_is_idempotent_without_new_jobs() {
524 let deps = mock_deps();
525 let observers = deps.observers.clone();
526 let (mut app, captured) = app_with_capturing_notifier(deps);
527
528 crate::runtime::record_recent_job(&observers, completed_recent_job("a"));
529 app.drain_notifications();
530 app.drain_notifications();
531 app.drain_notifications();
532
533 assert_eq!(
534 captured.lock().len(),
535 1,
536 "re-draining with no new jobs must not re-notify"
537 );
538 }
539
540 #[test]
541 fn drain_notifications_fires_after_recent_jobs_ring_saturates() {
542 let deps = mock_deps();
543 let observers = deps.observers.clone();
544 let (mut app, captured) = app_with_capturing_notifier(deps);
545
546 for i in 0..(crate::runtime::RECENT_JOBS_CAP + 5) {
550 crate::runtime::record_recent_job(
551 &observers,
552 completed_recent_job(&format!("warm-{i}")),
553 );
554 }
555 app.drain_notifications();
556 captured.lock().clear();
557
558 crate::runtime::record_recent_job(&observers, completed_recent_job("after-saturation"));
561 app.drain_notifications();
562
563 let shown = captured.lock();
564 assert_eq!(
565 shown.len(),
566 1,
567 "a job completing after the ring saturates must still notify"
568 );
569 assert!(
570 shown[0].1.contains("image"),
571 "notification body should describe the new job, got: {:?}",
572 shown[0]
573 );
574 }
575}