1#[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
20const LATEST_URL: &str = "https://api.github.com/repos/lhj6102/artifactize/releases/latest";
22const SITE: &str = "https://github.com/lhj6102/artifactize";
24const TEST_LATEST_PATH: &str = "/api/releases/latest";
26pub const OFF_VARIABLE: &str = "ARTIFACTIZE_NO_UPDATE_CHECK";
28pub const TEST_ENDPOINT_VARIABLE: &str = "ARTIFACTIZE_RELEASES_URL";
32const INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
35const RECHECK: Duration = Duration::from_secs(60 * 60);
38const TEST_RECHECK: Duration = Duration::from_secs(1);
40const TIMEOUT: Duration = Duration::from_secs(10);
42const CACHE: &str = "release-check.json";
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
47#[serde(rename_all = "camelCase")]
48pub enum Channel {
49 Store,
51 InstallSh,
53 InstallPs1,
55 Homebrew,
57 HomebrewCask,
59 Cargo,
61 Dmg,
63 Deb,
65 AppImage,
67 Unknown,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
73#[serde(tag = "kind", rename_all = "camelCase")]
74pub enum Update {
75 Command { command: String },
77 Download {
79 url: String,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 command: Option<String>,
82 },
83 Page { url: String },
85}
86
87impl Channel {
88 pub fn update(self, latest: &Version) -> Option<Update> {
90 crate::platform::install::update(self, latest, SITE)
91 }
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
96#[serde(rename_all = "camelCase")]
97pub enum Unchecked {
98 Store,
100 Off,
102 Development,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
108pub enum Outcome {
109 Unchecked(Unchecked),
111 Latest(Version),
113 Newer { latest: Version, update: Update },
115 Failed(String),
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct Status {
122 pub running: Version,
123 pub channel: Channel,
124 pub outcome: Outcome,
125}
126
127struct Source {
129 url: String,
130 site: String,
131}
132
133pub 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
149pub 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
160pub fn running() -> Version {
162 Version::parse(env!("CARGO_PKG_VERSION")).expect("the crate version is semantic")
163}
164
165fn 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 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
207fn 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#[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
228async 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 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
249fn fresh(checked_at: SystemTime, now: SystemTime) -> bool {
252 now.duration_since(checked_at)
253 .is_ok_and(|age| age < INTERVAL)
254}
255
256mod 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 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
277fn read_cache(path: &Path) -> Option<Cached> {
279 serde_json::from_slice(&std::fs::read(path).ok()?).ok()
280}
281
282fn 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
292async 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}