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, crate::platform::environment::var) {
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, with the variables in `var`.
168fn source(
169    running: &Version,
170    channel: Channel,
171    var: impl Fn(&str) -> Option<std::ffi::OsString>,
172) -> Result<Source, Outcome> {
173    if var(OFF_VARIABLE).is_some_and(|value| !value.is_empty()) {
174        return Err(Outcome::Unchecked(Unchecked::Off));
175    }
176    if channel == Channel::Store {
177        return Err(Outcome::Unchecked(Unchecked::Store));
178    }
179    match crate::llm::variable_endpoint_in(TEST_ENDPOINT_VARIABLE, var) {
180        Ok(Some(site)) => Ok(Source {
181            url: format!("{site}{TEST_LATEST_PATH}"),
182            site: site.root().to_owned(),
183        }),
184        // The workspace's own version, which a release replaces.
185        Ok(None) if running.pre.as_str() == "dev" => {
186            Err(Outcome::Unchecked(Unchecked::Development))
187        }
188        Ok(None) => Ok(Source {
189            url: LATEST_URL.into(),
190            site: SITE.into(),
191        }),
192        Err(error) => Err(Outcome::Failed(error)),
193    }
194}
195
196async fn look_up(
197    running: &Version,
198    channel: Channel,
199    source: &Source,
200    state: &Path,
201    now: SystemTime,
202) -> Outcome {
203    match latest(source, state, now).await {
204        Ok(latest) => decide(running, channel, latest, &source.site),
205        Err(error) => Outcome::Failed(error),
206    }
207}
208
209/// What `latest` means for this copy, whose updates link into `site`.
210fn decide(running: &Version, channel: Channel, latest: Version, site: &str) -> Outcome {
211    if latest <= *running {
212        return Outcome::Latest(latest);
213    }
214    match crate::platform::install::update(channel, &latest, site) {
215        Some(update) => Outcome::Newer { latest, update },
216        None => Outcome::Unchecked(Unchecked::Store),
217    }
218}
219
220/// The last answer, which site gave it and when.
221#[derive(Debug, Serialize, Deserialize)]
222#[serde(rename_all = "camelCase")]
223struct Cached {
224    #[serde(with = "epoch_seconds")]
225    checked_at: SystemTime,
226    site: String,
227    latest: Version,
228}
229
230/// The latest release: the source's kept answer while it is younger than a day, else a new one.
231async fn latest(source: &Source, state: &Path, now: SystemTime) -> Result<Version, String> {
232    let cache = state.join(CACHE);
233    if let Some(cached) = read_cache(&cache)
234        .filter(|cached| cached.site == source.site && fresh(cached.checked_at, now))
235    {
236        return Ok(cached.latest);
237    }
238    let latest = fetch(&source.url).await?;
239    // An answer that cannot be kept only means asking again next time.
240    let _ = write_cache(
241        state,
242        &Cached {
243            checked_at: now,
244            site: source.site.clone(),
245            latest: latest.clone(),
246        },
247    );
248    Ok(latest)
249}
250
251/// Whether an answer from `checked_at` is still current at `now`. One from the future, after
252/// the clock went back, is not.
253fn fresh(checked_at: SystemTime, now: SystemTime) -> bool {
254    now.duration_since(checked_at)
255        .is_ok_and(|age| age < INTERVAL)
256}
257
258/// `checkedAt` in the kept answer: whole seconds since the Unix epoch.
259mod epoch_seconds {
260    use serde::{Deserialize, Deserializer, Serializer, de::Error};
261    use std::time::{Duration, SystemTime, UNIX_EPOCH};
262
263    pub fn serialize<S: Serializer>(time: &SystemTime, serializer: S) -> Result<S::Ok, S::Error> {
264        serializer.serialize_u64(
265            time.duration_since(UNIX_EPOCH)
266                .map_or(0, |since| since.as_secs()),
267        )
268    }
269
270    /// A time no clock can show is refused, so an answer kept with one is asked again.
271    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<SystemTime, D::Error> {
272        let seconds = u64::deserialize(deserializer)?;
273        UNIX_EPOCH
274            .checked_add(Duration::from_secs(seconds))
275            .ok_or_else(|| D::Error::custom("checkedAt is past any clock"))
276    }
277}
278
279/// The kept answer; a missing or unreadable one is none.
280fn read_cache(path: &Path) -> Option<Cached> {
281    serde_json::from_slice(&std::fs::read(path).ok()?).ok()
282}
283
284/// Keep `cached` in `state`, which must exist: a lookup never creates the state directory.
285fn write_cache(state: &Path, cached: &Cached) -> Result<(), String> {
286    let mut file = tempfile::NamedTempFile::new_in(state).map_err(|e| e.to_string())?;
287    serde_json::to_writer(&mut file, cached).map_err(|e| e.to_string())?;
288    file.flush().map_err(|e| e.to_string())?;
289    file.persist(state.join(CACHE))
290        .map_err(|e| e.error.to_string())?;
291    Ok(())
292}
293
294/// Ask `url`, GitHub's latest-release API or its stand-in, for the latest release's version.
295async fn fetch(url: &str) -> Result<Version, String> {
296    #[derive(Deserialize)]
297    struct Latest {
298        tag_name: String,
299    }
300    let client = reqwest::Client::builder()
301        .timeout(TIMEOUT)
302        .user_agent(concat!("artifactize/", env!("CARGO_PKG_VERSION")))
303        .build()
304        .map_err(|e| e.to_string())?;
305    let latest: Latest = client
306        .get(url)
307        .header(reqwest::header::ACCEPT, "application/vnd.github+json")
308        .send()
309        .await
310        .and_then(reqwest::Response::error_for_status)
311        .map_err(|e| e.without_url().to_string())?
312        .json()
313        .await
314        .map_err(|e| e.without_url().to_string())?;
315    let tag = &latest.tag_name;
316    Version::parse(tag.strip_prefix('v').unwrap_or(tag))
317        .map_err(|e| format!("the latest release's tag {tag:?} is not a version: {e}"))
318}