use std::{
path::{Path, PathBuf},
sync::Arc,
};
use eframe::egui;
use parking_lot::Mutex;
use tokio::runtime::Handle;
use crate::{runtime, update, AGENT_VERSION, RELEASE_NAME};
const TRACE_TARGET: &str = "studio_worker::ui::about";
#[derive(Debug, Clone, Default)]
pub struct AboutState {
pub last_check: Arc<Mutex<Option<CheckLine>>>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum CheckLine {
InFlight,
Result(String),
}
#[derive(Debug, Clone, PartialEq)]
pub struct AboutView {
pub version: &'static str,
pub daemon_version: Option<String>,
pub release_name: &'static str,
pub config_path: PathBuf,
pub last_check: Option<CheckLine>,
}
impl AboutView {
pub fn build(state: &AboutState, config_path: &Path, daemon_version: Option<String>) -> Self {
Self {
version: AGENT_VERSION,
daemon_version,
release_name: RELEASE_NAME,
config_path: config_path.to_path_buf(),
last_check: state.last_check.lock().clone(),
}
}
}
pub fn render(
ui: &mut egui::Ui,
view: &AboutView,
state: &AboutState,
tokio: &Handle,
feed: &UpdateFeed,
) {
ui.heading("About studio-worker");
ui.add_space(4.0);
egui::Grid::new("about_grid")
.num_columns(2)
.spacing([12.0, 6.0])
.show(ui, |ui| {
ui.label("Tray UI version");
ui.monospace(view.version);
ui.end_row();
ui.label("Daemon version");
match &view.daemon_version {
Some(v) if v == view.version => ui.monospace(v),
Some(v) => ui.colored_label(
egui::Color32::from_rgb(232, 168, 56),
format!("{v} (restart the tray UI to match)"),
),
None => ui.label("not reachable"),
};
ui.end_row();
ui.label("Sentry release");
ui.monospace(view.release_name);
ui.end_row();
ui.label("Config file");
ui.monospace(view.config_path.to_string_lossy());
ui.end_row();
});
ui.add_space(12.0);
ui.horizontal(|ui| {
let busy = matches!(view.last_check, Some(CheckLine::InFlight));
if ui
.add_enabled(!busy, egui::Button::new("Check for updates"))
.clicked()
{
spawn_check(tokio.clone(), state.last_check.clone(), feed.clone());
}
match &view.last_check {
None => {}
Some(CheckLine::InFlight) => {
ui.spinner();
ui.label("Checking the release feed\u{2026}");
}
Some(CheckLine::Result(line)) => {
ui.label(line);
}
}
});
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateFeed {
pub url: String,
pub prerelease: bool,
}
fn spawn_check(tokio: Handle, slot: Arc<Mutex<Option<CheckLine>>>, feed: UpdateFeed) {
*slot.lock() = Some(CheckLine::InFlight);
tokio.spawn(async move {
let outcome = run_check(feed).await;
let line = record_check_outcome(outcome);
*slot.lock() = Some(CheckLine::Result(line));
});
}
fn record_check_outcome(outcome: anyhow::Result<update::CheckOutcome>) -> String {
match outcome {
Ok(o) => {
match &o {
update::CheckOutcome::UpToDate { current } => tracing::info!(
target: TRACE_TARGET,
op = "manual_check",
result = "up_to_date",
current = %current,
"manual update check completed"
),
update::CheckOutcome::NewerAvailable { current, latest } => tracing::info!(
target: TRACE_TARGET,
op = "manual_check",
result = "newer_available",
current = %current,
latest = %latest,
"manual update check found a newer release"
),
}
runtime::format_check_outcome(&o)
}
Err(e) => {
tracing::warn!(
target: TRACE_TARGET,
op = "manual_check",
error = %e,
"manual update check failed"
);
format!("check failed: {e}")
}
}
}
async fn run_check(feed: UpdateFeed) -> anyhow::Result<update::CheckOutcome> {
let current = semver::Version::parse(AGENT_VERSION)?;
let outcome =
tokio::task::spawn_blocking(move || update::check(&feed.url, ¤t, feed.prerelease))
.await??;
Ok(outcome)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_returns_static_version_strings() {
let state = AboutState::default();
let view = AboutView::build(&state, Path::new("/tmp/c.toml"), None);
assert_eq!(view.version, AGENT_VERSION);
assert_eq!(view.release_name, RELEASE_NAME);
assert_eq!(view.config_path, PathBuf::from("/tmp/c.toml"));
assert!(view.last_check.is_none());
}
#[test]
fn build_surfaces_last_check_when_set() {
let state = AboutState::default();
*state.last_check.lock() = Some(CheckLine::Result("up to date".into()));
let view = AboutView::build(&state, Path::new("/tmp/c.toml"), None);
assert_eq!(
view.last_check,
Some(CheckLine::Result("up to date".into()))
);
}
use crate::test_support::capture;
use semver::Version;
#[test]
fn record_check_outcome_logs_up_to_date_at_info() {
let logs = capture(|| {
let line = record_check_outcome(Ok(update::CheckOutcome::UpToDate {
current: Version::new(1, 2, 3),
}));
assert_eq!(line, "up to date: 1.2.3");
});
assert!(logs.contains("INFO"), "expected INFO event, got: {logs}");
assert!(
logs.contains("studio_worker::ui::about"),
"expected about target, got: {logs}"
);
assert!(
logs.contains("op=\"manual_check\""),
"expected op field, got: {logs}"
);
assert!(
logs.contains("result=\"up_to_date\""),
"expected result field, got: {logs}"
);
}
#[test]
fn record_check_outcome_logs_newer_available_at_info() {
let logs = capture(|| {
let line = record_check_outcome(Ok(update::CheckOutcome::NewerAvailable {
current: Version::new(1, 0, 0),
latest: Version::new(2, 0, 0),
}));
assert_eq!(line, "update available: 1.0.0 -> 2.0.0");
});
assert!(
logs.contains("result=\"newer_available\""),
"expected result field, got: {logs}"
);
assert!(
logs.contains("2.0.0"),
"expected latest version, got: {logs}"
);
}
#[test]
fn record_check_outcome_logs_failure_at_warn() {
let logs = capture(|| {
let line = record_check_outcome(Err(anyhow::anyhow!("feed exploded")));
assert!(line.contains("check failed"));
assert!(line.contains("feed exploded"));
});
assert!(logs.contains("WARN"), "expected WARN event, got: {logs}");
assert!(
logs.contains("op=\"manual_check\""),
"expected op field, got: {logs}"
);
assert!(
logs.contains("feed exploded"),
"expected the error in the log, got: {logs}"
);
}
}