//! Checks for a newer release through curl and Gitea's API: at start and once a day while the settings ask, and whenever Settings asks at once. use std::{ process::{Command, Stdio}, time::Duration, }; use anyhow::Context as _; use gpui::{App, Global, SystemNotification}; use serde::Deserialize; use crate::{settings::SettingsStore, workspace::TITLE}; /// The latest published release. const LATEST: &str = "https://git.zacharyzhang.com/api/v1/repos/ZacharyZhang-NY/ELYTerminal/releases/latest"; /// How long an automatic check waits for the next. const DAY: Duration = Duration::from_secs(24 * 60 * 60); /// The tag a newer release's notification carries. const TAG: &str = "elyterminal-update"; /// The running version. pub const VERSION: &str = env!("CARGO_PKG_VERSION"); /// What the last check found. #[derive(Clone, Debug, PartialEq)] pub enum Updates { Unchecked, Checking, Latest, Newer { version: String, page: String }, Failed(String), } impl Global for Updates {} /// The fields of a release the check reads. #[derive(Debug, Deserialize)] struct Release { tag_name: String, html_url: String, } /// Checks at once and then once a day, each time the settings ask for it. pub fn start(cx: &mut App) { cx.set_global(Updates::Unchecked); cx.spawn(async move |cx| { loop { cx.update(|cx| { if SettingsStore::get(cx).check_updates { check(true, cx); } }); cx.background_executor().timer(DAY).await; } }) .detach(); } /// Asks for the latest release off the main thread; when `auto`, a newer one posts a notification. pub fn check(auto: bool, cx: &mut App) { if *cx.global::() == Updates::Checking { return; } log::info!("updates: checking for a release newer than {VERSION}"); cx.set_global(Updates::Checking); let fetched = cx.background_executor().spawn(async { latest() }); cx.spawn(async move |cx| { let found = fetched.await.and_then(newer); cx.update(|cx| finish(found, auto, cx)); }) .detach(); } /// Keeps what the check found, and tells of a newer release it found by itself. fn finish(found: anyhow::Result>, auto: bool, cx: &mut App) { let found = match found { Ok(Some((version, page))) => { log::info!("updates: {version} is out, newer than {VERSION}, at {page}"); if auto { cx.show_system_notification(SystemNotification { tag: TAG.into(), title: TITLE.into(), body: format!("ELYTerminal {version} is out. Press to open its page.").into(), actions: Vec::new(), }); } Updates::Newer { version, page } } Ok(None) => { log::info!("updates: {VERSION} is the latest"); Updates::Latest } Err(error) => { log::error!("{error:#}"); Updates::Failed(format!("{error:#}")) } }; cx.set_global(found); } /// Opens the newer release's page when its notification is pressed; tells whether `tag` was that notification's. pub fn pressed(tag: &str, cx: &mut App) -> bool { if tag != TAG { return false; } match cx.global::().clone() { Updates::Newer { page, .. } => { log::info!("updates: the notification opens {page}"); cx.open_url(&page); } found => { log::info!("updates: the notification was pressed, the last check finding {found:?}") } } true } /// The latest release, which curl fetches from Gitea's API. fn latest() -> anyhow::Result { let mut command = Command::new("curl"); #[cfg(windows)] std::os::windows::process::CommandExt::creation_flags( &mut command, crate::git::CREATE_NO_WINDOW, ); let output = command .args(["--fail", "--silent", "--show-error", "--location"]) .args(["--max-time", "30", LATEST]) .stdin(Stdio::null()) .output() .context("updates: curl did not start")?; anyhow::ensure!( output.status.success(), "updates: curl failed: {}", String::from_utf8_lossy(&output.stderr).trim() ); serde_json::from_slice(&output.stdout).context("updates: the latest release does not read") } /// The release's version and page when it is newer than this one. fn newer(release: Release) -> anyhow::Result> { let latest = version(&release.tag_name)?; let shown = release.tag_name.trim_start_matches('v').to_string(); Ok((latest > version(VERSION)?).then_some((shown, release.html_url))) } /// `tag`'s three numbers, from `v0.1.6` or `0.1.6`. fn version(tag: &str) -> anyhow::Result<[u64; 3]> { let numbers: Vec = (tag.trim_start_matches('v').split('.')) .map(str::parse) .collect::>() .with_context(|| format!("updates: {tag:?} is no version"))?; numbers .try_into() .map_err(|_| anyhow::anyhow!("updates: {tag:?} is no version of three numbers")) } #[cfg(test)] mod tests { use super::*; #[test] fn a_tag_reads_as_three_numbers() { assert_eq!(version("v0.1.6").expect("a tag"), [0, 1, 6]); assert_eq!(version("1.10.0").expect("no v"), [1, 10, 0]); assert!(version("v0.1").is_err(), "two numbers"); assert!(version("v0.1.6-beta").is_err(), "a suffix"); } #[test] fn only_a_later_release_is_newer() { let release = |tag: &str| Release { tag_name: tag.into(), html_url: format!("https://example.org/{tag}"), }; let [major, minor, patch] = version(VERSION).expect("this version"); let later = format!("v{major}.{minor}.{}", patch + 1); let found = newer(release(&later)).expect("reads"); let shown = later.trim_start_matches('v').to_string(); assert_eq!(found, Some((shown, format!("https://example.org/{later}")))); assert_eq!( newer(release(&format!("v{VERSION}"))).expect("reads"), None, "this one" ); assert_eq!( newer(release("v0.0.1")).expect("reads"), None, "an older one" ); assert_eq!( newer(release(&format!("v{}.0.0", major + 1))) .expect("reads") .map(|(v, _)| v), Some(format!("{}.0.0", major + 1)), "by its numbers, not as text" ); } #[test] fn gitea_s_release_reads() { let body = r#"{"id":213,"tag_name":"v0.1.5","name":"ELYTerminal 0.1.5","html_url":"https://git.zacharyzhang.com/ZacharyZhang-NY/ELYTerminal/releases/tag/v0.1.5","draft":false,"assets":[]}"#; let release: Release = serde_json::from_str(body).expect("a release"); assert_eq!(release.tag_name, "v0.1.5"); assert!(release.html_url.ends_with("/releases/tag/v0.1.5")); } }