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 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#[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 let outcome = run_check(feed).await;
127 let line = record_check_outcome(outcome);
128 *slot.lock() = Some(CheckLine::Result(line));
129 });
130}
131
132fn 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, ¤t, 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 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}