mirror of
https://github.com/storytold/craft-launcher.git
synced 2026-11-13 21:52:56 +00:00
A check the user presses took the ArtCraft API's feed as the answer for every app it vouches for, and the feed polls GitHub on its own schedule, so a release published minutes earlier didn't show for 5 to 20 minutes (#91). Automatic checks should keep leaning on the feed: that is what keeps the launcher within GitHub's request limit. - `ReleaseSource::newest_tag(repo)`: the newest stable release's tag by a route that costs no API quota. `GitHubSource` reads it from the `releases/latest` redirect the quota-free fallback already uses. - `latest_for`: on a pressed check only, before taking the feed's answer, it asks `newest_tag`; if that names something newer than the feed's release (or any release where the feed says there is none), GitHub is asked for that app. An unreadable, unparsable or older redirect leaves the feed's answer in place. A test with a source whose redirect names the feed's own release, a newer one, nothing, or nonsense covers each case, pressed and automatic. Fixes #91
199 lines
8.2 KiB
Rust
199 lines
8.2 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 artcraft_launcher_core::protocol::{LatestRelease, Source};
|
|
use artcraft_launcher_core::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}"),
|
|
}
|
|
}
|
|
}
|