1use 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
14const 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 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
115fn 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, ¤t, 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 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}