mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-06 18:43:09 +00:00
* feat(update): add craft-update crate for release checking, install, and self-updating * fix(update): provide non-windows stub for finish * fix(update): address CodeRabbit review feedback on craft-update * feat(dialog,self_update): enhance prompt styling, support standalone self-updates * fix(update): make startup prompt modal with Escape, skip non-file tar entries
199 lines
8.1 KiB
Rust
199 lines
8.1 KiB
Rust
//! Where release information comes from.
|
|
//!
|
|
//! [`GitHubSource`] asks the GitHub REST API (one request per app, revalidated with ETags) and,
|
|
//! when the API is out of quota, falls back to github.com's release pages: the
|
|
//! `releases/latest` redirect names the tag and `SHA256SUMS.txt` lists every file with its
|
|
//! checksum, which costs no API quota at all. A later [`ReleaseSource`] will ask the ArtCraft
|
|
//! catalog API for every app in one request.
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::sync::{Mutex, PoisonError};
|
|
|
|
use crate::protocol::{LatestRelease, Source};
|
|
use crate::release::{self, Release};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::http::{Fetched, Http};
|
|
|
|
/// One answer from a source.
|
|
#[derive(Clone, Debug)]
|
|
pub struct Answer {
|
|
pub latest: LatestRelease,
|
|
pub source: Source,
|
|
}
|
|
|
|
/// Looks up latest releases. Called from worker threads.
|
|
pub trait ReleaseSource: Send + Sync {
|
|
fn latest(&self, repo: &str, prereleases: bool) -> Answer;
|
|
/// The last answer saved for `repo`, for an instant first paint.
|
|
fn cached(&self, repo: &str, prereleases: bool) -> Option<LatestRelease>;
|
|
/// The tag of `repo`'s newest stable release, by a route that costs no API quota (GitHub's
|
|
/// `releases/latest` redirect), or `None` when it can't be told. Quick: a check pressed by the
|
|
/// user asks this for every app to see whether the feed has fallen behind.
|
|
fn newest_tag(&self, repo: &str) -> Option<String> {
|
|
let _ = repo;
|
|
None
|
|
}
|
|
/// API refused until this time (Unix seconds).
|
|
fn rate_limited_until(&self) -> Option<i64>;
|
|
/// Persist anything worth keeping.
|
|
fn save(&self);
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
|
struct Entry {
|
|
etag: Option<String>,
|
|
/// `None`: the repo has no releases.
|
|
release: Option<Release>,
|
|
prereleases: bool,
|
|
fetched_at: i64,
|
|
}
|
|
|
|
#[derive(Default, Serialize, Deserialize)]
|
|
struct CacheFile {
|
|
entries: BTreeMap<String, Entry>,
|
|
}
|
|
|
|
pub struct GitHubSource {
|
|
http: Http,
|
|
cache: Mutex<CacheFile>,
|
|
cache_path: std::path::PathBuf,
|
|
limited_until: Mutex<Option<i64>>,
|
|
}
|
|
|
|
impl GitHubSource {
|
|
pub fn new(http: Http, cache_path: std::path::PathBuf) -> Self {
|
|
let cache = std::fs::read(&cache_path).ok().and_then(|b| serde_json::from_slice::<CacheFile>(&b).ok()).unwrap_or_default();
|
|
Self { http, cache: Mutex::new(cache), cache_path, limited_until: Mutex::new(None) }
|
|
}
|
|
|
|
fn entry(&self, repo: &str, prereleases: bool) -> Option<Entry> {
|
|
let cache = self.cache.lock().unwrap_or_else(PoisonError::into_inner);
|
|
cache.entries.get(repo).filter(|e| e.prereleases == prereleases).cloned()
|
|
}
|
|
|
|
fn store(&self, repo: &str, entry: Entry) {
|
|
self.cache.lock().unwrap_or_else(PoisonError::into_inner).entries.insert(repo.to_string(), entry);
|
|
}
|
|
|
|
fn limited(&self) -> bool {
|
|
let until = *self.limited_until.lock().unwrap_or_else(PoisonError::into_inner);
|
|
until.is_some_and(|u| u > crate::now())
|
|
}
|
|
|
|
fn ask_api(&self, repo: &str, prereleases: bool) -> Result<Option<Answer>, String> {
|
|
let url = if prereleases {
|
|
format!("https://api.github.com/repos/{repo}/releases?per_page=30")
|
|
} else {
|
|
format!("https://api.github.com/repos/{repo}/releases/latest")
|
|
};
|
|
let saved = self.entry(repo, prereleases);
|
|
let found =
|
|
|release: Option<Release>| Answer { latest: release.map_or(LatestRelease::None, LatestRelease::Found), source: Source::GitHubApi };
|
|
match self.http.get_api(&url, saved.as_ref().and_then(|e| e.etag.as_deref()))? {
|
|
Fetched::Body { text, etag } => {
|
|
let release = if prereleases {
|
|
release::parse_github_release_list(&text, repo, true)?
|
|
} else {
|
|
Some(release::parse_github_release(&text, repo)?)
|
|
};
|
|
self.store(repo, Entry { etag, release: release.clone(), prereleases, fetched_at: crate::now() });
|
|
Ok(Some(found(release)))
|
|
}
|
|
Fetched::NotModified => match saved {
|
|
Some(e) => {
|
|
self.store(repo, Entry { fetched_at: crate::now(), ..e.clone() });
|
|
Ok(Some(found(e.release)))
|
|
}
|
|
None => Err("GitHub said nothing changed, but there's no saved answer".into()),
|
|
},
|
|
Fetched::NotFound => {
|
|
self.store(repo, Entry { etag: None, release: None, prereleases, fetched_at: crate::now() });
|
|
Ok(Some(found(None)))
|
|
}
|
|
Fetched::RateLimited { reset } => {
|
|
*self.limited_until.lock().unwrap_or_else(PoisonError::into_inner) = Some(reset.unwrap_or_else(|| crate::now() + 15 * 60));
|
|
Ok(None)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The quota-free route through github.com (stable releases only).
|
|
fn ask_pages(&self, repo: &str) -> Result<Answer, String> {
|
|
let location = self.http.redirect_target(&format!("https://github.com/{repo}/releases/latest"))?;
|
|
let Some(tag) = location.as_deref().and_then(|l| release::tag_from_redirect(l, repo)) else {
|
|
// No published release: github.com sends us to the release list instead.
|
|
return Ok(Answer { latest: LatestRelease::None, source: Source::GitHubPages });
|
|
};
|
|
if let Some(e) = self.entry(repo, false)
|
|
&& let Some(r) = e.release.as_ref().filter(|r| r.tag == tag)
|
|
{
|
|
return Ok(Answer { latest: LatestRelease::Found(r.clone()), source: Source::GitHubPages });
|
|
}
|
|
let sums_url = format!("{}{tag}/SHA256SUMS.txt", release::download_prefix(repo));
|
|
let sums = self.http.get_text(&sums_url)?.map(|t| release::parse_sha256sums(&t)).unwrap_or_default();
|
|
let r = release::release_from_sums(repo, &tag, &sums);
|
|
self.store(repo, Entry { etag: None, release: Some(r.clone()), prereleases: false, fetched_at: crate::now() });
|
|
Ok(Answer { latest: LatestRelease::Found(r), source: Source::GitHubPages })
|
|
}
|
|
}
|
|
|
|
impl ReleaseSource for GitHubSource {
|
|
fn latest(&self, repo: &str, prereleases: bool) -> Answer {
|
|
let api_error = if self.limited() {
|
|
None
|
|
} else {
|
|
match self.ask_api(repo, prereleases) {
|
|
Ok(Some(answer)) => return answer,
|
|
Ok(None) => None,
|
|
Err(e) => Some(e),
|
|
}
|
|
};
|
|
if !prereleases && api_error.is_none() {
|
|
match self.ask_pages(repo) {
|
|
Ok(answer) => return answer,
|
|
Err(e) => log::warn!("{repo}: release pages: {e}"),
|
|
}
|
|
}
|
|
let why = api_error.unwrap_or_else(|| "GitHub's request limit was reached".into());
|
|
match self.cached(repo, prereleases) {
|
|
Some(latest) => Answer { latest, source: Source::Cache },
|
|
None => Answer { latest: LatestRelease::Failed(why), source: Source::Cache },
|
|
}
|
|
}
|
|
|
|
fn cached(&self, repo: &str, prereleases: bool) -> Option<LatestRelease> {
|
|
self.entry(repo, prereleases).map(|e| e.release.map_or(LatestRelease::None, LatestRelease::Found))
|
|
}
|
|
|
|
fn newest_tag(&self, repo: &str) -> Option<String> {
|
|
match self.http.redirect_target(&format!("https://github.com/{repo}/releases/latest")) {
|
|
Ok(location) => location.as_deref().and_then(|l| release::tag_from_redirect(l, repo)),
|
|
Err(e) => {
|
|
log::debug!("{repo}: couldn't read the latest-release redirect: {e}");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
fn rate_limited_until(&self) -> Option<i64> {
|
|
let until = *self.limited_until.lock().unwrap_or_else(PoisonError::into_inner);
|
|
until.filter(|u| *u > crate::now())
|
|
}
|
|
|
|
fn save(&self) {
|
|
let json = {
|
|
let cache = self.cache.lock().unwrap_or_else(PoisonError::into_inner);
|
|
serde_json::to_vec(&*cache)
|
|
};
|
|
match json {
|
|
Ok(bytes) => {
|
|
if let Err(e) = crate::paths::write_atomic(&self.cache_path, &bytes) {
|
|
log::warn!("release cache: {e}");
|
|
}
|
|
}
|
|
Err(e) => log::warn!("release cache: {e}"),
|
|
}
|
|
}
|
|
}
|