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 Winget,
53 InstallSh,
55 InstallPs1,
57 Homebrew,
59 HomebrewCask,
61 Cargo,
63 Dmg,
65 Deb,
67 AppImage,
69 Unknown,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
75#[serde(tag = "kind", rename_all = "camelCase")]
76pub enum Update {
77 Command { command: String },
79 Download {
81 url: String,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 command: Option<String>,
84 },
85 Page { url: String },
87}
88
89impl Channel {
90 pub fn update(self, latest: &Version) -> Option<Update> {
92 crate::platform::install::update(self, latest, SITE)
93 }
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
98#[serde(rename_all = "camelCase")]
99pub enum Unchecked {
100 Store,
102 Off,
104 Development,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
110pub enum Outcome {
111 Unchecked(Unchecked),
113 Latest(Version),
115 Newer { latest: Version, update: Update },
117 Failed(String),
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct Status {
124 pub running: Version,
125 pub channel: Channel,
126 pub outcome: Outcome,
127}
128
129struct Source {
131 url: String,
132 site: String,
133}
134
135pub 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
151pub 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
162pub fn running() -> Version {
164 Version::parse(env!("CARGO_PKG_VERSION")).expect("the crate version is semantic")
165}
166
167fn 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 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
209fn 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#[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
230async 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 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
251fn fresh(checked_at: SystemTime, now: SystemTime) -> bool {
254 now.duration_since(checked_at)
255 .is_ok_and(|age| age < INTERVAL)
256}
257
258mod 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 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
279fn read_cache(path: &Path) -> Option<Cached> {
281 serde_json::from_slice(&std::fs::read(path).ok()?).ok()
282}
283
284fn 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
294async 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}