Skip to main content

studio_worker/ui/tabs/
about.rs

1//! About tab — version, release name, config path, manual update check.
2
3use std::{
4    path::{Path, PathBuf},
5    sync::Arc,
6};
7
8use eframe::egui;
9use parking_lot::Mutex;
10use tokio::runtime::Handle;
11
12use crate::{runtime, update, AGENT_VERSION, RELEASE_NAME};
13
14/// Tracing target for the About tab.  Stable so operators can filter
15/// the manual update-check breadcrumbs with
16/// `RUST_LOG=studio_worker::ui::about=info`.
17const TRACE_TARGET: &str = "studio_worker::ui::about";
18
19#[derive(Debug, Clone, Default)]
20pub struct AboutState {
21    pub last_check: Arc<Mutex<Option<CheckLine>>>,
22}
23
24#[derive(Debug, Clone, PartialEq)]
25pub enum CheckLine {
26    InFlight,
27    Result(String),
28}
29
30#[derive(Debug, Clone, PartialEq)]
31pub struct AboutView {
32    pub version: &'static str,
33    pub release_name: &'static str,
34    pub config_path: PathBuf,
35    pub last_check: Option<CheckLine>,
36}
37
38impl AboutView {
39    pub fn build(state: &AboutState, config_path: &Path) -> Self {
40        Self {
41            version: AGENT_VERSION,
42            release_name: RELEASE_NAME,
43            config_path: config_path.to_path_buf(),
44            last_check: state.last_check.lock().clone(),
45        }
46    }
47}
48
49pub fn render(
50    ui: &mut egui::Ui,
51    view: &AboutView,
52    state: &AboutState,
53    tokio: &Handle,
54    config_path: &Path,
55) {
56    ui.heading("About studio-worker");
57    ui.add_space(4.0);
58
59    egui::Grid::new("about_grid")
60        .num_columns(2)
61        .spacing([12.0, 6.0])
62        .show(ui, |ui| {
63            ui.label("Version");
64            ui.monospace(view.version);
65            ui.end_row();
66
67            ui.label("Sentry release");
68            ui.monospace(view.release_name);
69            ui.end_row();
70
71            ui.label("Config file");
72            ui.monospace(view.config_path.to_string_lossy());
73            ui.end_row();
74        });
75
76    ui.add_space(12.0);
77    ui.horizontal(|ui| {
78        let busy = matches!(view.last_check, Some(CheckLine::InFlight));
79        if ui
80            .add_enabled(!busy, egui::Button::new("Check for updates"))
81            .clicked()
82        {
83            spawn_check(
84                tokio.clone(),
85                state.last_check.clone(),
86                config_path.to_path_buf(),
87            );
88        }
89        match &view.last_check {
90            None => {}
91            Some(CheckLine::InFlight) => {
92                ui.spinner();
93                ui.label("Checking the release feed\u{2026}");
94            }
95            Some(CheckLine::Result(line)) => {
96                ui.label(line);
97            }
98        }
99    });
100}
101
102fn spawn_check(tokio: Handle, slot: Arc<Mutex<Option<CheckLine>>>, config_path: PathBuf) {
103    *slot.lock() = Some(CheckLine::InFlight);
104    let path_str = config_path.to_string_lossy().to_string();
105    tokio.spawn(async move {
106        // Build a fresh CheckOutcome string through the same formatter
107        // `studio-worker check-update` uses on the CLI so messages are
108        // identical between surfaces.
109        let outcome = run_check(Some(path_str.as_str())).await;
110        let line = record_check_outcome(outcome);
111        *slot.lock() = Some(CheckLine::Result(line));
112    });
113}
114
115/// Log the outcome of a user-initiated "Check for updates" and return
116/// the line to surface in the UI.  The auto-update loop emits its own
117/// breadcrumbs; without this the manual path left no trace in the
118/// journal (or Sentry) when a check errored or surfaced a new release.
119fn record_check_outcome(outcome: anyhow::Result<update::CheckOutcome>) -> String {
120    match outcome {
121        Ok(o) => {
122            match &o {
123                update::CheckOutcome::UpToDate { current } => tracing::info!(
124                    target: TRACE_TARGET,
125                    op = "manual_check",
126                    result = "up_to_date",
127                    current = %current,
128                    "manual update check completed"
129                ),
130                update::CheckOutcome::NewerAvailable { current, latest } => tracing::info!(
131                    target: TRACE_TARGET,
132                    op = "manual_check",
133                    result = "newer_available",
134                    current = %current,
135                    latest = %latest,
136                    "manual update check found a newer release"
137                ),
138            }
139            runtime::format_check_outcome(&o)
140        }
141        Err(e) => {
142            tracing::warn!(
143                target: TRACE_TARGET,
144                op = "manual_check",
145                error = %e,
146                "manual update check failed"
147            );
148            format!("check failed: {e}")
149        }
150    }
151}
152
153async fn run_check(config_path: Option<&str>) -> anyhow::Result<update::CheckOutcome> {
154    use semver::Version;
155
156    let (cfg, _) = crate::config::load(config_path)?;
157    let current = Version::parse(AGENT_VERSION)?;
158    let outcome = tokio::task::spawn_blocking(move || {
159        update::check(&cfg.auto_update_feed, &current, cfg.auto_update_prerelease)
160    })
161    .await??;
162    Ok(outcome)
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn build_returns_static_version_strings() {
171        let state = AboutState::default();
172        let view = AboutView::build(&state, Path::new("/tmp/c.toml"));
173        assert_eq!(view.version, AGENT_VERSION);
174        assert_eq!(view.release_name, RELEASE_NAME);
175        assert_eq!(view.config_path, PathBuf::from("/tmp/c.toml"));
176        assert!(view.last_check.is_none());
177    }
178
179    #[test]
180    fn build_surfaces_last_check_when_set() {
181        let state = AboutState::default();
182        *state.last_check.lock() = Some(CheckLine::Result("up to date".into()));
183        let view = AboutView::build(&state, Path::new("/tmp/c.toml"));
184        assert_eq!(
185            view.last_check,
186            Some(CheckLine::Result("up to date".into()))
187        );
188    }
189
190    // -----------------------------------------------------------------
191    // Structured tracing for the manual "Check for updates" path.  The
192    // auto-update loop emits its own breadcrumbs, but without these the
193    // user-initiated check left no trace in the journal (or Sentry)
194    // when it errored or found a newer release.  Uses the shared
195    // `test_support::capture` sink (see that module for the why).
196    // -----------------------------------------------------------------
197    use crate::test_support::capture;
198    use semver::Version;
199
200    #[test]
201    fn record_check_outcome_logs_up_to_date_at_info() {
202        let logs = capture(|| {
203            let line = record_check_outcome(Ok(update::CheckOutcome::UpToDate {
204                current: Version::new(1, 2, 3),
205            }));
206            assert_eq!(line, "up to date: 1.2.3");
207        });
208        assert!(logs.contains("INFO"), "expected INFO event, got: {logs}");
209        assert!(
210            logs.contains("studio_worker::ui::about"),
211            "expected about target, got: {logs}"
212        );
213        assert!(
214            logs.contains("op=\"manual_check\""),
215            "expected op field, got: {logs}"
216        );
217        assert!(
218            logs.contains("result=\"up_to_date\""),
219            "expected result field, got: {logs}"
220        );
221    }
222
223    #[test]
224    fn record_check_outcome_logs_newer_available_at_info() {
225        let logs = capture(|| {
226            let line = record_check_outcome(Ok(update::CheckOutcome::NewerAvailable {
227                current: Version::new(1, 0, 0),
228                latest: Version::new(2, 0, 0),
229            }));
230            assert_eq!(line, "update available: 1.0.0 -> 2.0.0");
231        });
232        assert!(
233            logs.contains("result=\"newer_available\""),
234            "expected result field, got: {logs}"
235        );
236        assert!(
237            logs.contains("2.0.0"),
238            "expected latest version, got: {logs}"
239        );
240    }
241
242    #[test]
243    fn record_check_outcome_logs_failure_at_warn() {
244        let logs = capture(|| {
245            let line = record_check_outcome(Err(anyhow::anyhow!("feed exploded")));
246            assert!(line.contains("check failed"));
247            assert!(line.contains("feed exploded"));
248        });
249        assert!(logs.contains("WARN"), "expected WARN event, got: {logs}");
250        assert!(
251            logs.contains("op=\"manual_check\""),
252            "expected op field, got: {logs}"
253        );
254        assert!(
255            logs.contains("feed exploded"),
256            "expected the error in the log, got: {logs}"
257        );
258    }
259}