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) {
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(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 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
205fn 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#[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
226async 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 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
247fn fresh(checked_at: SystemTime, now: SystemTime) -> bool {
250 now.duration_since(checked_at)
251 .is_ok_and(|age| age < INTERVAL)
252}
253
254mod 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 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
275fn read_cache(path: &Path) -> Option<Cached> {
277 serde_json::from_slice(&std::fs::read(path).ok()?).ok()
278}
279
280fn 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
290async 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}