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