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