Skip to main content

artifactize_core/
release.rs

1//! Newer releases: whether one is out, and how to update the way this copy was installed.
2//!
3//! artifactize never updates itself. Every install channel keeps its own update path, and
4//! only the Microsoft Store updates on its own, so a newer release is told together with the
5//! update command of the channel this copy came from (#316). The desktop app and
6//! `artifactize doctor` show the same lookup, made at most once a day and kept in the state.
7
8#[cfg(test)]
9mod tests;
10
11use std::{
12    io::Write,
13    path::Path,
14    time::{Duration, SystemTime},
15};
16
17use semver::Version;
18use serde::{Deserialize, Serialize};
19
20/// GitHub's latest stable release of the repository: drafts and prereleases are never latest.
21const LATEST_URL: &str = "https://api.github.com/repos/lhj6102/artifactize/releases/latest";
22/// The repository's site, below which its releases' pages and files are.
23const SITE: &str = "https://github.com/lhj6102/artifactize";
24/// Where a stand-in site answers the latest-release lookup, below its root.
25const TEST_LATEST_PATH: &str = "/api/releases/latest";
26/// Set to anything but empty, artifactize does not look up releases or contact GitHub.
27pub const OFF_VARIABLE: &str = "ARTIFACTIZE_NO_UPDATE_CHECK";
28/// A loopback URL standing in for the repository's site, for tests: it answers the lookup
29/// below [`TEST_LATEST_PATH`], and the release's pages and files are linked below it. It is
30/// asked even by a development build; its answer is kept as GitHub's is, for its own site.
31pub const TEST_ENDPOINT_VARIABLE: &str = "ARTIFACTIZE_RELEASES_URL";
32/// About once a day: a release is not urgent, and GitHub's API allows an address 60
33/// unauthenticated requests an hour.
34const INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
35/// How often a long-running app asks again: hourly finds a release out that day, while the
36/// lookup itself reaches GitHub at most once a day.
37const RECHECK: Duration = Duration::from_secs(60 * 60);
38/// How often it asks with a test endpoint, so a test sees the app ask again soon.
39const TEST_RECHECK: Duration = Duration::from_secs(1);
40/// A lookup still unanswered then is abandoned; the next one tries again.
41const TIMEOUT: Duration = Duration::from_secs(10);
42/// The last answer, in the state directory.
43const CACHE: &str = "release-check.json";
44
45/// How this copy of artifactize was installed. Each channel has its own update path.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
47#[serde(rename_all = "camelCase")]
48pub enum Channel {
49    /// The Microsoft Store package, which the Store updates.
50    Store,
51    /// winget's own package `lhj6102.Artifactize`.
52    Winget,
53    /// `install.sh`, on Linux or macOS.
54    InstallSh,
55    /// `install.ps1`, on Windows.
56    InstallPs1,
57    /// The Homebrew formula.
58    Homebrew,
59    /// The Homebrew cask, with the desktop app.
60    HomebrewCask,
61    /// `cargo install` or `cargo binstall`.
62    Cargo,
63    /// The macOS disk image, dragged to Applications.
64    Dmg,
65    /// The Linux `.deb` package.
66    Deb,
67    /// A Linux AppImage, or the commands it set up.
68    AppImage,
69    /// Anything else, such as an unpacked archive or a source build.
70    Unknown,
71}
72
73/// How to update to a newer release.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
75#[serde(tag = "kind", rename_all = "camelCase")]
76pub enum Update {
77    /// Run this command.
78    Command { command: String },
79    /// Download this file and install it by hand, with the command when there is one.
80    Download {
81        url: String,
82        #[serde(skip_serializing_if = "Option::is_none")]
83        command: Option<String>,
84    },
85    /// See the release page: the install channel is unknown.
86    Page { url: String },
87}
88
89impl Channel {
90    /// How to update this channel's install to `latest`; `None` when the Store does it.
91    pub fn update(self, latest: &Version) -> Option<Update> {
92        crate::platform::install::update(self, latest, SITE)
93    }
94}
95
96/// Why releases were not looked up.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
98#[serde(rename_all = "camelCase")]
99pub enum Unchecked {
100    /// The Microsoft Store updates this copy.
101    Store,
102    /// [`OFF_VARIABLE`] is set.
103    Off,
104    /// A development build, whose version is not a release's.
105    Development,
106}
107
108/// What the lookup found.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub enum Outcome {
111    /// Releases were not looked up.
112    Unchecked(Unchecked),
113    /// This copy is the latest release, or newer.
114    Latest(Version),
115    /// A newer release is out.
116    Newer { latest: Version, update: Update },
117    /// The lookup failed; the next one tries again.
118    Failed(String),
119}
120
121/// This copy, how it was installed and what its release lookup found.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct Status {
124    pub running: Version,
125    pub channel: Channel,
126    pub outcome: Outcome,
127}
128
129/// Where a lookup asks for the latest release, and the site its links point into.
130struct Source {
131    url: String,
132    site: String,
133}
134
135/// Look up whether a newer release is out for this copy, reusing an answer kept in `state`
136/// for a day. It never fails: a failed lookup is its [`Outcome::Failed`].
137pub async fn check(state: &Path) -> Status {
138    let running = running();
139    let channel = crate::platform::install::channel();
140    let outcome = match source(&running, channel) {
141        Ok(source) => look_up(&running, channel, &source, state, SystemTime::now()).await,
142        Err(outcome) => outcome,
143    };
144    Status {
145        running,
146        channel,
147        outcome,
148    }
149}
150
151/// How long a long-running app waits before it asks [`check`] again.
152pub fn recheck_interval() -> Duration {
153    if crate::platform::environment::var(TEST_ENDPOINT_VARIABLE)
154        .is_some_and(|value| !value.is_empty())
155    {
156        TEST_RECHECK
157    } else {
158        RECHECK
159    }
160}
161
162/// This copy's version.
163pub fn running() -> Version {
164    Version::parse(env!("CARGO_PKG_VERSION")).expect("the crate version is semantic")
165}
166
167/// Where to look up the latest release, or why not to.
168fn source(running: &Version, channel: Channel) -> Result<Source, Outcome> {
169    if crate::platform::environment::var(OFF_VARIABLE).is_some_and(|value| !value.is_empty()) {
170        return Err(Outcome::Unchecked(Unchecked::Off));
171    }
172    if channel == Channel::Store {
173        return Err(Outcome::Unchecked(Unchecked::Store));
174    }
175    match crate::llm::variable_endpoint(TEST_ENDPOINT_VARIABLE) {
176        Ok(Some(site)) => Ok(Source {
177            url: format!("{site}{TEST_LATEST_PATH}"),
178            site: site.root().to_owned(),
179        }),
180        // The workspace's own version, which a release replaces.
181        Ok(None) if running.pre.as_str() == "dev" => {
182            Err(Outcome::Unchecked(Unchecked::Development))
183        }
184        Ok(None) => Ok(Source {
185            url: LATEST_URL.into(),
186            site: SITE.into(),
187        }),
188        Err(error) => Err(Outcome::Failed(error)),
189    }
190}
191
192async fn look_up(
193    running: &Version,
194    channel: Channel,
195    source: &Source,
196    state: &Path,
197    now: SystemTime,
198) -> Outcome {
199    match latest(source, state, now).await {
200        Ok(latest) => decide(running, channel, latest, &source.site),
201        Err(error) => Outcome::Failed(error),
202    }
203}
204
205/// What `latest` means for this copy, whose updates link into `site`.
206fn decide(running: &Version, channel: Channel, latest: Version, site: &str) -> Outcome {
207    if latest <= *running {
208        return Outcome::Latest(latest);
209    }
210    match crate::platform::install::update(channel, &latest, site) {
211        Some(update) => Outcome::Newer { latest, update },
212        None => Outcome::Unchecked(Unchecked::Store),
213    }
214}
215
216/// The last answer, which site gave it and when.
217#[derive(Debug, Serialize, Deserialize)]
218#[serde(rename_all = "camelCase")]
219struct Cached {
220    #[serde(with = "epoch_seconds")]
221    checked_at: SystemTime,
222    site: String,
223    latest: Version,
224}
225
226/// The latest release: the source's kept answer while it is younger than a day, else a new one.
227async fn latest(source: &Source, state: &Path, now: SystemTime) -> Result<Version, String> {
228    let cache = state.join(CACHE);
229    if let Some(cached) = read_cache(&cache)
230        .filter(|cached| cached.site == source.site && fresh(cached.checked_at, now))
231    {
232        return Ok(cached.latest);
233    }
234    let latest = fetch(&source.url).await?;
235    // An answer that cannot be kept only means asking again next time.
236    let _ = write_cache(
237        state,
238        &Cached {
239            checked_at: now,
240            site: source.site.clone(),
241            latest: latest.clone(),
242        },
243    );
244    Ok(latest)
245}
246
247/// Whether an answer from `checked_at` is still current at `now`. One from the future, after
248/// the clock went back, is not.
249fn fresh(checked_at: SystemTime, now: SystemTime) -> bool {
250    now.duration_since(checked_at)
251        .is_ok_and(|age| age < INTERVAL)
252}
253
254/// `checkedAt` in the kept answer: whole seconds since the Unix epoch.
255mod epoch_seconds {
256    use serde::{Deserialize, Deserializer, Serializer, de::Error};
257    use std::time::{Duration, SystemTime, UNIX_EPOCH};
258
259    pub fn serialize<S: Serializer>(time: &SystemTime, serializer: S) -> Result<S::Ok, S::Error> {
260        serializer.serialize_u64(
261            time.duration_since(UNIX_EPOCH)
262                .map_or(0, |since| since.as_secs()),
263        )
264    }
265
266    /// A time no clock can show is refused, so an answer kept with one is asked again.
267    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<SystemTime, D::Error> {
268        let seconds = u64::deserialize(deserializer)?;
269        UNIX_EPOCH
270            .checked_add(Duration::from_secs(seconds))
271            .ok_or_else(|| D::Error::custom("checkedAt is past any clock"))
272    }
273}
274
275/// The kept answer; a missing or unreadable one is none.
276fn read_cache(path: &Path) -> Option<Cached> {
277    serde_json::from_slice(&std::fs::read(path).ok()?).ok()
278}
279
280/// Keep `cached` in `state`, which must exist: a lookup never creates the state directory.
281fn write_cache(state: &Path, cached: &Cached) -> Result<(), String> {
282    let mut file = tempfile::NamedTempFile::new_in(state).map_err(|e| e.to_string())?;
283    serde_json::to_writer(&mut file, cached).map_err(|e| e.to_string())?;
284    file.flush().map_err(|e| e.to_string())?;
285    file.persist(state.join(CACHE))
286        .map_err(|e| e.error.to_string())?;
287    Ok(())
288}
289
290/// Ask `url`, GitHub's latest-release API or its stand-in, for the latest release's version.
291async fn fetch(url: &str) -> Result<Version, String> {
292    #[derive(Deserialize)]
293    struct Latest {
294        tag_name: String,
295    }
296    let client = reqwest::Client::builder()
297        .timeout(TIMEOUT)
298        .user_agent(concat!("artifactize/", env!("CARGO_PKG_VERSION")))
299        .build()
300        .map_err(|e| e.to_string())?;
301    let latest: Latest = client
302        .get(url)
303        .header(reqwest::header::ACCEPT, "application/vnd.github+json")
304        .send()
305        .await
306        .and_then(reqwest::Response::error_for_status)
307        .map_err(|e| e.without_url().to_string())?
308        .json()
309        .await
310        .map_err(|e| e.without_url().to_string())?;
311    let tag = &latest.tag_name;
312    Version::parse(tag.strip_prefix('v').unwrap_or(tag))
313        .map_err(|e| format!("the latest release's tag {tag:?} is not a version: {e}"))
314}