mirror of
https://github.com/storytold/craft-launcher.git
synced 2026-10-13 23:08:16 +00:00
Footer names the services in use; retry the ArtCraft API before GitHub
Footer: "Checked just now · using ArtCraft API", "· using GitHub" or "· using ArtCraft API and GitHub", from where each release on screen came from (Latest events now carry their origin), so it follows what's shown. Retries and fallback: - The very first poll (nothing ever loaded or saved) tries the ArtCraft API once, then GitHub at once, so the window fills - Every other poll tries the API up to three times (1 s, then 3 s apart) - Until every app has loaded once, a failing API lets GitHub be asked fairly freely (every 5 minutes per app) - After that, GitHub is asked only for apps the API doesn't list or hasn't refreshed in a day, or once the API has seriously failed (three checks in a row and an hour without an answer); then hourly per app. Until then the API's last answer keeps being shown Tests: the fallback policy for each stage, and the footer wording. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4a86071159
commit
b42d1f49ec
7 files changed
+273
-56
No files matched your search
@@ -3,7 +3,7 @@
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, LatestRelease, Source};
|
||||
use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, LatestRelease, Origin, Source};
|
||||
use artcraft_launcher_core::release::{self, Release};
|
||||
use artcraft_launcher_core::{Arch, Os, Platform, SUITE, catalog, version};
|
||||
use artcraft_launcher_ui_egui::{Host, LauncherUi, Settings};
|
||||
@@ -94,7 +94,7 @@ impl WebBackend {
|
||||
let latest = latest.unwrap_or_else(|e| {
|
||||
LatestRelease::Failed(if e.is_some() { "GitHub's hourly limit was reached".into() } else { "couldn't reach GitHub".into() })
|
||||
});
|
||||
events.borrow_mut().push(Event::Latest { app: app.id.to_string(), latest });
|
||||
events.borrow_mut().push(Event::Latest { app: app.id.to_string(), latest, from: Origin::GitHub });
|
||||
ctx.request_repaint();
|
||||
}
|
||||
if let Ok(LatestRelease::Found(r)) = latest(catalog::LAUNCHER_REPO, false).await
|
||||
|
||||
@@ -97,6 +97,24 @@ pub const PAUSED: &str = "paused";
|
||||
/// The `JobFinished` error a cancelled job ends with (not a failure).
|
||||
pub const CANCELLED: &str = "cancelled";
|
||||
|
||||
/// Which service an app's release information came from (the footer names them).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Origin {
|
||||
/// ArtCraft's release feed (storyteller-web), which polls GitHub for everyone.
|
||||
ArtCraftApi,
|
||||
/// GitHub directly: the fallback.
|
||||
GitHub,
|
||||
}
|
||||
|
||||
impl Origin {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::ArtCraftApi => "ArtCraft API",
|
||||
Self::GitHub => "GitHub",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where release information came from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Source {
|
||||
@@ -127,6 +145,8 @@ pub enum Event {
|
||||
Latest {
|
||||
app: String,
|
||||
latest: LatestRelease,
|
||||
/// The service this answer came from.
|
||||
from: Origin,
|
||||
},
|
||||
/// The latest ArtCraft Launcher release, when it's newer than this one.
|
||||
LauncherUpdate(Release),
|
||||
|
||||
+181
-46
@@ -9,7 +9,7 @@ use artcraft_launcher_core::catalog::{LAUNCHER, LAUNCHER_ID};
|
||||
use artcraft_launcher_core::feed::{Feed, FeedAnswer};
|
||||
use artcraft_launcher_core::platform::{Arch, Os, Platform, pick_asset};
|
||||
use artcraft_launcher_core::protocol::{
|
||||
Backend, BackendOptions, Capabilities, Command, Event, JobPhase, LatestRelease, PAUSED, PausedDownload, Source,
|
||||
Backend, BackendOptions, Capabilities, Command, Event, JobPhase, LatestRelease, Origin, PAUSED, PausedDownload, Source,
|
||||
};
|
||||
use artcraft_launcher_core::release::{Asset, Release};
|
||||
use artcraft_launcher_core::{CraftApp, Installed, PackageKind, SUITE, version};
|
||||
@@ -23,9 +23,19 @@ use crate::source::{GitHubSource, ReleaseSource};
|
||||
/// Automatic checks closer together than this reuse the last answers. The UI asks every five
|
||||
/// minutes; this stops a burst of requests (refresh, focus, timers) from repeating work.
|
||||
const FRESH_FOR: i64 = 4 * 60;
|
||||
/// When the release feed can't answer for an app, GitHub is asked about it at most this often
|
||||
/// (pressing Refresh asks right away).
|
||||
/// Once every app has loaded, GitHub is asked about an app the feed can't vouch for at most this
|
||||
/// often (pressing Refresh asks right away).
|
||||
const GITHUB_FALLBACK_EVERY: i64 = 60 * 60;
|
||||
/// Until every app has loaded once, GitHub may be asked a little more eagerly.
|
||||
const EARLY_GITHUB_EVERY: i64 = 5 * 60;
|
||||
/// Tries at the feed per check (after the very first poll), and the waits between them.
|
||||
const FEED_ATTEMPTS: usize = 3;
|
||||
const FEED_RETRY_WAITS: [Duration; 2] = [Duration::from_secs(1), Duration::from_secs(3)];
|
||||
/// After everything has loaded, the feed counts as seriously failed (and GitHub takes over)
|
||||
/// only after this many checks in a row without an answer…
|
||||
const SERIOUS_FEED_FAILURES: u32 = 3;
|
||||
/// …and this long without a good answer.
|
||||
const SERIOUS_FEED_OUTAGE: i64 = 60 * 60;
|
||||
/// Requests in flight at once during a check.
|
||||
const WORKERS: usize = 4;
|
||||
|
||||
@@ -47,6 +57,14 @@ struct Shared {
|
||||
launcher_release: Mutex<Option<Release>>,
|
||||
/// When GitHub was last asked about each app (Unix seconds), to keep the fallback rare.
|
||||
github_asked: Mutex<HashMap<String, i64>>,
|
||||
/// Apps with an answer (a release or "none yet") from either service.
|
||||
answered: Mutex<std::collections::HashSet<String>>,
|
||||
/// Every app has had an answer at least once: from then on GitHub is asked sparingly.
|
||||
loaded: AtomicBool,
|
||||
/// Checks in a row in which the feed didn't answer.
|
||||
feed_failures: Mutex<u32>,
|
||||
/// When the feed last answered (Unix seconds).
|
||||
feed_ok_at: Mutex<Option<i64>>,
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
@@ -95,6 +113,10 @@ impl NativeBackend {
|
||||
detecting: AtomicBool::new(false),
|
||||
launcher_release: Mutex::new(None),
|
||||
github_asked: Mutex::new(HashMap::new()),
|
||||
answered: Mutex::new(std::collections::HashSet::new()),
|
||||
loaded: AtomicBool::new(false),
|
||||
feed_failures: Mutex::new(0),
|
||||
feed_ok_at: Mutex::new(None),
|
||||
last_check: Mutex::new(None),
|
||||
});
|
||||
let backend = Self { shared, paths, http, source, platform: native_platform(), self_install: crate::self_update::current() };
|
||||
@@ -110,16 +132,16 @@ impl NativeBackend {
|
||||
let feed = if pre { None } else { load_feed(&self.paths) };
|
||||
let now = crate::now();
|
||||
for app in &SUITE {
|
||||
let latest = match feed.as_ref().map(|f| f.answer(app, now)) {
|
||||
Some(FeedAnswer::Fresh(Some(r))) => Some(LatestRelease::Found(r)),
|
||||
Some(FeedAnswer::Fresh(None)) => Some(LatestRelease::None),
|
||||
_ => self.source.as_ref().and_then(|s| s.cached(&feed_repo(feed.as_ref(), app), pre)),
|
||||
let answer = match feed.as_ref().map(|f| f.answer(app, now)) {
|
||||
Some(FeedAnswer::Fresh(Some(r))) => Some((LatestRelease::Found(r), Origin::ArtCraftApi)),
|
||||
Some(FeedAnswer::Fresh(None)) => Some((LatestRelease::None, Origin::ArtCraftApi)),
|
||||
_ => self.source.as_ref().and_then(|s| s.cached(&feed_repo(feed.as_ref(), app), pre)).map(|l| (l, Origin::GitHub)),
|
||||
};
|
||||
if let Some(latest) = latest {
|
||||
if let Some((latest, from)) = answer {
|
||||
if let LatestRelease::Found(r) = &latest {
|
||||
self.shared.latest.lock().unwrap_or_else(PoisonError::into_inner).insert(app.id.to_string(), r.clone());
|
||||
}
|
||||
self.shared.emit(Event::Latest { app: app.id.to_string(), latest });
|
||||
self.shared.emit(Event::Latest { app: app.id.to_string(), latest, from });
|
||||
}
|
||||
}
|
||||
if let Some(f) = feed {
|
||||
@@ -169,8 +191,11 @@ impl NativeBackend {
|
||||
fn check(&self, force: bool) {
|
||||
let (Some(source), Some(http)) = (self.source.clone(), self.http.clone()) else {
|
||||
for app in &SUITE {
|
||||
self.shared
|
||||
.emit(Event::Latest { app: app.id.to_string(), latest: LatestRelease::Failed("secure connections aren't available".into()) });
|
||||
self.shared.emit(Event::Latest {
|
||||
app: app.id.to_string(),
|
||||
latest: LatestRelease::Failed("secure connections aren't available".into()),
|
||||
from: Origin::GitHub,
|
||||
});
|
||||
}
|
||||
return;
|
||||
};
|
||||
@@ -184,8 +209,12 @@ impl NativeBackend {
|
||||
let pre = self.options().prereleases;
|
||||
std::thread::spawn(move || {
|
||||
shared.emit(Event::CheckStarted);
|
||||
// The feed serves stable releases only; with pre-releases on, GitHub decides.
|
||||
let feed = if pre { None } else { fetch_feed(&http, &paths) };
|
||||
let (feed, fallback) = if pre {
|
||||
// The feed serves stable releases only; with pre-releases on, GitHub decides.
|
||||
(None, Fallback { trust_feed: false, github_every: EARLY_GITHUB_EVERY })
|
||||
} else {
|
||||
feed_and_fallback(&http, &paths, &shared)
|
||||
};
|
||||
if let Some(f) = &feed {
|
||||
shared.emit(Event::Notices { announcements: f.announcements.clone(), alerts: f.alerts.clone() });
|
||||
}
|
||||
@@ -197,7 +226,7 @@ impl NativeBackend {
|
||||
scope.spawn(|| {
|
||||
loop {
|
||||
let Some(app) = queue.lock().unwrap_or_else(PoisonError::into_inner).pop() else { break };
|
||||
let (latest, used) = latest_for(app, feed.as_ref(), source.as_ref(), &shared, pre, force, now);
|
||||
let (latest, from, used) = latest_for(app, feed.as_ref(), fallback, source.as_ref(), &shared, pre, force, now);
|
||||
if let Some(used) = used {
|
||||
sources.lock().unwrap_or_else(PoisonError::into_inner).push(used);
|
||||
}
|
||||
@@ -217,12 +246,21 @@ impl NativeBackend {
|
||||
if let LatestRelease::Found(r) = &latest {
|
||||
shared.latest.lock().unwrap_or_else(PoisonError::into_inner).insert(app.id.to_string(), r.clone());
|
||||
}
|
||||
shared.emit(Event::Latest { app: app.id.to_string(), latest });
|
||||
if !matches!(latest, LatestRelease::Failed(_)) {
|
||||
shared.answered.lock().unwrap_or_else(PoisonError::into_inner).insert(app.id.to_string());
|
||||
}
|
||||
shared.emit(Event::Latest { app: app.id.to_string(), latest, from });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
source.save();
|
||||
{
|
||||
let answered = shared.answered.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if SUITE.iter().all(|a| answered.contains(a.id)) {
|
||||
shared.loaded.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
let sources = sources.into_inner().unwrap_or_else(PoisonError::into_inner);
|
||||
let source_used = if sources.contains(&Source::Cache) {
|
||||
Source::Cache
|
||||
@@ -434,26 +472,81 @@ fn partial_for(app: &CraftApp, shared: &Shared, paths: &Paths, platform: Option<
|
||||
Some((asset, done))
|
||||
}
|
||||
|
||||
/// The latest release of `app`, and where it came from: the feed when it vouches for the app,
|
||||
/// else GitHub (if it's due), else GitHub's last answer. `None` when there's nothing to say yet.
|
||||
/// How much the feed is trusted in one check, and how freely GitHub may be asked.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct Fallback {
|
||||
/// Use the feed's answers (fresh entries only). Off when the feed has seriously failed.
|
||||
trust_feed: bool,
|
||||
/// Ask GitHub about an app at most this often (seconds), unless forced.
|
||||
github_every: i64,
|
||||
}
|
||||
|
||||
/// Asks the feed (retrying, except on the very first poll) and decides how much to lean on
|
||||
/// GitHub this time:
|
||||
///
|
||||
/// - The very first poll (nothing has ever loaded, nothing saved): one try, then GitHub at once,
|
||||
/// so the window fills.
|
||||
/// - Until every app has loaded once: if the feed doesn't answer after its retries, GitHub is
|
||||
/// asked fairly freely (every 5 minutes per app).
|
||||
/// - After that: the feed's last answer stands in while it fails, and GitHub is asked only for
|
||||
/// apps the feed doesn't list or hasn't refreshed in a day, or once the feed has seriously
|
||||
/// failed (several checks in a row and an hour without an answer); then hourly per app.
|
||||
fn feed_and_fallback(http: &Http, paths: &Paths, shared: &Shared) -> (Option<Feed>, Fallback) {
|
||||
let saved = load_feed(paths);
|
||||
let loaded = shared.loaded.load(Ordering::SeqCst);
|
||||
let first_time = !loaded && saved.is_none();
|
||||
let attempts = if first_time { 1 } else { FEED_ATTEMPTS };
|
||||
let fresh = fetch_feed(http, paths, attempts);
|
||||
let now = crate::now();
|
||||
let failures = {
|
||||
let mut failures = shared.feed_failures.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if fresh.is_some() {
|
||||
*failures = 0;
|
||||
*shared.feed_ok_at.lock().unwrap_or_else(PoisonError::into_inner) = Some(now);
|
||||
} else {
|
||||
*failures = failures.saturating_add(1);
|
||||
}
|
||||
*failures
|
||||
};
|
||||
let ok_at = *shared.feed_ok_at.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let answered = fresh.is_some();
|
||||
let fallback = decide_fallback(answered, loaded, first_time, failures, ok_at, now);
|
||||
(fresh.or(saved), fallback)
|
||||
}
|
||||
|
||||
/// The policy in [`feed_and_fallback`], on its own so it can be tested.
|
||||
fn decide_fallback(answered: bool, loaded: bool, first_time: bool, failures: u32, ok_at: Option<i64>, now: i64) -> Fallback {
|
||||
let github_every = if loaded { GITHUB_FALLBACK_EVERY } else { EARLY_GITHUB_EVERY };
|
||||
let seriously_failed =
|
||||
!answered && (first_time || !loaded || (failures >= SERIOUS_FEED_FAILURES && ok_at.is_none_or(|t| now - t >= SERIOUS_FEED_OUTAGE)));
|
||||
Fallback { trust_feed: !seriously_failed, github_every }
|
||||
}
|
||||
|
||||
/// The latest release of `app`, which service it came from, and the GitHub route used (for the
|
||||
/// rate-limit notice): the feed when it's trusted and vouches for the app, else GitHub if it's
|
||||
/// due, else GitHub's last answer. No release when there's nothing new to say.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn latest_for(
|
||||
app: &CraftApp,
|
||||
feed: Option<&Feed>,
|
||||
fallback: Fallback,
|
||||
source: &dyn ReleaseSource,
|
||||
shared: &Shared,
|
||||
pre: bool,
|
||||
force: bool,
|
||||
now: i64,
|
||||
) -> (Option<LatestRelease>, Option<Source>) {
|
||||
match feed.map(|f| f.answer(app, now)) {
|
||||
Some(FeedAnswer::Fresh(Some(r))) => return (Some(LatestRelease::Found(r)), Some(Source::Feed)),
|
||||
Some(FeedAnswer::Fresh(None)) => return (Some(LatestRelease::None), Some(Source::Feed)),
|
||||
Some(FeedAnswer::AskGitHub) | None => {}
|
||||
) -> (Option<LatestRelease>, Origin, Option<Source>) {
|
||||
if fallback.trust_feed {
|
||||
match feed.map(|f| f.answer(app, now)) {
|
||||
Some(FeedAnswer::Fresh(Some(r))) => return (Some(LatestRelease::Found(r)), Origin::ArtCraftApi, Some(Source::Feed)),
|
||||
Some(FeedAnswer::Fresh(None)) => return (Some(LatestRelease::None), Origin::ArtCraftApi, Some(Source::Feed)),
|
||||
Some(FeedAnswer::AskGitHub) | None => {}
|
||||
}
|
||||
}
|
||||
let repo = feed_repo(feed, app);
|
||||
let due = {
|
||||
let mut asked = shared.github_asked.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let due = force || asked.get(app.id).is_none_or(|t| now - t >= GITHUB_FALLBACK_EVERY);
|
||||
let due = force || asked.get(app.id).is_none_or(|t| now - t >= fallback.github_every);
|
||||
if due {
|
||||
asked.insert(app.id.to_string(), now);
|
||||
}
|
||||
@@ -461,9 +554,9 @@ fn latest_for(
|
||||
};
|
||||
if due {
|
||||
let answer = source.latest(&repo, pre);
|
||||
(Some(answer.latest), Some(answer.source))
|
||||
(Some(answer.latest), Origin::GitHub, Some(answer.source))
|
||||
} else {
|
||||
(source.cached(&repo, pre), None)
|
||||
(source.cached(&repo, pre), Origin::GitHub, None)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -472,22 +565,28 @@ fn feed_repo(feed: Option<&Feed>, app: &CraftApp) -> String {
|
||||
feed.map_or_else(|| app.repo.to_string(), |f| f.repo_for(app))
|
||||
}
|
||||
|
||||
/// Asks ArtCraft's release feed, saving a good answer. When it can't be reached, the last saved
|
||||
/// answer stands in: its entries still only count while they're less than a day old.
|
||||
fn fetch_feed(http: &Http, paths: &Paths) -> Option<Feed> {
|
||||
match http.get_feed(&feed_url()) {
|
||||
Ok(body) => match Feed::parse(&body) {
|
||||
Some(feed) => {
|
||||
if let Err(e) = crate::paths::write_atomic(&paths.feed_cache(), body.as_bytes()) {
|
||||
log::warn!("couldn't save the release feed: {e}");
|
||||
/// Asks ArtCraft's release feed up to `attempts` times (waiting 1 s, then 3 s, between tries),
|
||||
/// saving a good answer. `None` when it never answered with something readable.
|
||||
fn fetch_feed(http: &Http, paths: &Paths, attempts: usize) -> Option<Feed> {
|
||||
let url = feed_url();
|
||||
for attempt in 0..attempts.max(1) {
|
||||
if attempt > 0 {
|
||||
std::thread::sleep(FEED_RETRY_WAITS.get(attempt - 1).copied().unwrap_or(Duration::from_secs(3)));
|
||||
}
|
||||
match http.get_feed(&url) {
|
||||
Ok(body) => match Feed::parse(&body) {
|
||||
Some(feed) => {
|
||||
if let Err(e) = crate::paths::write_atomic(&paths.feed_cache(), body.as_bytes()) {
|
||||
log::warn!("couldn't save the release feed: {e}");
|
||||
}
|
||||
return Some(feed);
|
||||
}
|
||||
return Some(feed);
|
||||
}
|
||||
None => log::warn!("the release feed answered with something unreadable"),
|
||||
},
|
||||
Err(e) => log::info!("release feed unavailable, using GitHub: {e}"),
|
||||
None => log::warn!("the release feed answered with something unreadable (try {})", attempt + 1),
|
||||
},
|
||||
Err(e) => log::info!("release feed unavailable (try {}): {e}", attempt + 1),
|
||||
}
|
||||
}
|
||||
load_feed(paths)
|
||||
None
|
||||
}
|
||||
|
||||
/// The feed's address. `ARTCRAFT_LAUNCHER_FEED_URL` points at a staging server or a local copy
|
||||
@@ -652,10 +751,16 @@ mod tests {
|
||||
last_check: Mutex::new(None),
|
||||
launcher_release: Mutex::new(None),
|
||||
github_asked: Mutex::new(HashMap::new()),
|
||||
answered: Mutex::new(std::collections::HashSet::new()),
|
||||
loaded: AtomicBool::new(false),
|
||||
feed_failures: Mutex::new(0),
|
||||
feed_ok_at: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
const NOW: i64 = 1_791_500_000;
|
||||
/// The steady state: everything has loaded and the feed is answering.
|
||||
const LOADED: Fallback = Fallback { trust_feed: true, github_every: GITHUB_FALLBACK_EVERY };
|
||||
|
||||
fn feed() -> Feed {
|
||||
Feed::parse(
|
||||
@@ -666,6 +771,36 @@ mod tests {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_poll_tries_once_and_falls_back_at_once() {
|
||||
// Nothing has ever loaded and the feed didn't answer: GitHub, freely.
|
||||
let f = decide_fallback(false, false, true, 1, None, NOW);
|
||||
assert_eq!(f, Fallback { trust_feed: false, github_every: EARLY_GITHUB_EVERY });
|
||||
// The feed answered: trust it.
|
||||
assert!(decide_fallback(true, false, true, 0, Some(NOW), NOW).trust_feed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn before_everything_loads_github_is_asked_more_eagerly() {
|
||||
// A saved feed but not everything loaded yet, and the feed isn't answering.
|
||||
let f = decide_fallback(false, false, false, 1, None, NOW);
|
||||
assert_eq!(f, Fallback { trust_feed: false, github_every: EARLY_GITHUB_EVERY });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_loading_github_waits_for_a_serious_feed_failure() {
|
||||
// A blip or two: keep showing the feed's last answer.
|
||||
for failures in 1..SERIOUS_FEED_FAILURES {
|
||||
let f = decide_fallback(false, true, false, failures, Some(NOW - 10 * 60), NOW);
|
||||
assert_eq!(f, Fallback { trust_feed: true, github_every: GITHUB_FALLBACK_EVERY }, "{failures}");
|
||||
}
|
||||
// Several checks in a row, but it answered recently: still trusted.
|
||||
assert!(decide_fallback(false, true, false, SERIOUS_FEED_FAILURES, Some(NOW - 20 * 60), NOW).trust_feed);
|
||||
// Several checks in a row and an hour without an answer: GitHub takes over, hourly.
|
||||
let f = decide_fallback(false, true, false, SERIOUS_FEED_FAILURES, Some(NOW - SERIOUS_FEED_OUTAGE), NOW);
|
||||
assert_eq!(f, Fallback { trust_feed: false, github_every: GITHUB_FALLBACK_EVERY });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_is_only_the_fallback_and_is_asked_rarely() {
|
||||
let github = CountingGitHub::default();
|
||||
@@ -675,33 +810,33 @@ mod tests {
|
||||
let feed = feed();
|
||||
|
||||
// The feed vouches for PhotoCraft: GitHub isn't asked.
|
||||
let (latest, used) = latest_for(photo, Some(&feed), &github, &shared, false, false, NOW);
|
||||
let (latest, _, used) = latest_for(photo, Some(&feed), LOADED, &github, &shared, false, false, NOW);
|
||||
assert!(matches!(latest, Some(LatestRelease::Found(ref r)) if r.tag == "v0.6.0"));
|
||||
assert_eq!(used, Some(Source::Feed));
|
||||
assert_eq!(github.0.load(Ordering::SeqCst), 0);
|
||||
|
||||
// The feed doesn't list WordCraft: GitHub is asked once…
|
||||
let (_, used) = latest_for(word, Some(&feed), &github, &shared, false, false, NOW);
|
||||
let (_, _, used) = latest_for(word, Some(&feed), LOADED, &github, &shared, false, false, NOW);
|
||||
assert_eq!(used, Some(Source::GitHubApi));
|
||||
assert_eq!(github.0.load(Ordering::SeqCst), 1);
|
||||
// …not again within the hour…
|
||||
let (_, used) = latest_for(word, Some(&feed), &github, &shared, false, false, NOW + 30 * 60);
|
||||
let (_, _, used) = latest_for(word, Some(&feed), LOADED, &github, &shared, false, false, NOW + 30 * 60);
|
||||
assert_eq!(used, None);
|
||||
assert_eq!(github.0.load(Ordering::SeqCst), 1);
|
||||
// …but Refresh asks right away, and an hour later it's due again.
|
||||
latest_for(word, Some(&feed), &github, &shared, false, true, NOW + 31 * 60);
|
||||
latest_for(word, Some(&feed), LOADED, &github, &shared, false, true, NOW + 31 * 60);
|
||||
assert_eq!(github.0.load(Ordering::SeqCst), 2);
|
||||
latest_for(word, Some(&feed), &github, &shared, false, false, NOW + 31 * 60 + GITHUB_FALLBACK_EVERY);
|
||||
latest_for(word, Some(&feed), LOADED, &github, &shared, false, false, NOW + 31 * 60 + GITHUB_FALLBACK_EVERY);
|
||||
assert_eq!(github.0.load(Ordering::SeqCst), 3);
|
||||
|
||||
// No feed answer at all (server down): GitHub, still rate-limited per app.
|
||||
let (_, used) = latest_for(photo, None, &github, &shared, false, false, NOW);
|
||||
let (_, _, used) = latest_for(photo, None, LOADED, &github, &shared, false, false, NOW);
|
||||
assert_eq!(used, Some(Source::GitHubApi));
|
||||
assert_eq!(github.0.load(Ordering::SeqCst), 4);
|
||||
|
||||
// A feed entry more than a day old doesn't count.
|
||||
let fresh_shared = super::tests::shared();
|
||||
let (_, used) = latest_for(photo, Some(&feed), &github, &fresh_shared, false, false, NOW + 2 * 86_400);
|
||||
let (_, _, used) = latest_for(photo, Some(&feed), LOADED, &github, &fresh_shared, false, false, NOW + 2 * 86_400);
|
||||
assert_eq!(used, Some(Source::GitHubApi));
|
||||
}
|
||||
}
|
||||
@@ -217,11 +217,12 @@ impl LauncherUi {
|
||||
/// Returns whether app state changed.
|
||||
fn handle(&mut self, ctx: &egui::Context, event: Event) -> bool {
|
||||
match event {
|
||||
Event::Latest { app, latest } => {
|
||||
Event::Latest { app, latest, from } => {
|
||||
if let Some(e) = self.entry_mut(&app) {
|
||||
// A failed check keeps the release we already know about.
|
||||
// A failed check keeps the release we already know about (and where it came from).
|
||||
if !(matches!(latest, LatestRelease::Failed(_)) && matches!(e.latest, Some(LatestRelease::Found(_)))) {
|
||||
e.latest = Some(latest);
|
||||
e.origin = Some(from);
|
||||
}
|
||||
}
|
||||
true
|
||||
@@ -443,6 +444,29 @@ impl LauncherUi {
|
||||
list.into_iter().map(|(_, e)| e.app.id).collect()
|
||||
}
|
||||
|
||||
/// "ArtCraft API", "GitHub" or "ArtCraft API and GitHub": the services behind the releases on
|
||||
/// screen right now. `None` until something has loaded.
|
||||
pub(crate) fn sources_label(&self) -> Option<String> {
|
||||
use artcraft_launcher_core::protocol::Origin;
|
||||
let mut api = false;
|
||||
let mut github = false;
|
||||
for e in &self.entries {
|
||||
if matches!(e.latest, Some(LatestRelease::Found(_) | LatestRelease::None)) {
|
||||
match e.origin {
|
||||
Some(Origin::ArtCraftApi) => api = true,
|
||||
Some(Origin::GitHub) => github = true,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
match (api, github) {
|
||||
(true, true) => Some(format!("{} and {}", Origin::ArtCraftApi.label(), Origin::GitHub.label())),
|
||||
(true, false) => Some(Origin::ArtCraftApi.label().to_string()),
|
||||
(false, true) => Some(Origin::GitHub.label().to_string()),
|
||||
(false, false) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn count(&self, filter: Filter) -> usize {
|
||||
self.entries
|
||||
.iter()
|
||||
|
||||
@@ -14,13 +14,15 @@ pub struct AppEntry {
|
||||
/// Detection has answered at least once.
|
||||
pub detected: bool,
|
||||
pub job: Option<JobPhase>,
|
||||
/// The service `latest` came from.
|
||||
pub origin: Option<artcraft_launcher_core::protocol::Origin>,
|
||||
/// A download waiting to be resumed.
|
||||
pub paused: Option<PausedDownload>,
|
||||
}
|
||||
|
||||
impl AppEntry {
|
||||
pub fn new(app: &'static CraftApp) -> Self {
|
||||
Self { app, latest: None, installed: None, detected: false, job: None, paused: None }
|
||||
Self { app, latest: None, installed: None, detected: false, job: None, origin: None, paused: None }
|
||||
}
|
||||
|
||||
pub fn release(&self) -> Option<&Release> {
|
||||
|
||||
@@ -249,8 +249,8 @@ impl LauncherUi {
|
||||
if self.checking {
|
||||
widgets::hud(ui, "Checking for updates…", t.faint);
|
||||
} else if let Some(at) = self.settings.last_check {
|
||||
let via = self.last_source.map(|s| format!(" · {}", s.label())).unwrap_or_default();
|
||||
widgets::hud(ui, &format!("Checked {}{via}", time::format_ago(at, now)), t.faint);
|
||||
let using = self.sources_label().map(|s| format!(" · using {s}")).unwrap_or_default();
|
||||
widgets::hud(ui, &format!("Checked {}{using}", time::format_ago(at, now)), t.faint);
|
||||
} else {
|
||||
widgets::hud(ui, "Not checked yet", t.faint);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, JobPhase, LatestRelease, PausedDownload, Source};
|
||||
use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, JobPhase, LatestRelease, Origin, PausedDownload, Source};
|
||||
use artcraft_launcher_core::release::{Asset, Release};
|
||||
use artcraft_launcher_core::status::InstallSource;
|
||||
use artcraft_launcher_core::{Arch, Installed, Os, Platform, SUITE, version};
|
||||
@@ -79,9 +79,13 @@ fn events() -> Vec<Event> {
|
||||
("cadcraft", "0.3.0"),
|
||||
];
|
||||
for (i, (id, v)) in versions.iter().enumerate() {
|
||||
ev.push(Event::Latest { app: (*id).into(), latest: LatestRelease::Found(release(id, v, NOW - 86_400 - i as i64 * 600)) });
|
||||
ev.push(Event::Latest {
|
||||
app: (*id).into(),
|
||||
latest: LatestRelease::Found(release(id, v, NOW - 86_400 - i as i64 * 600)),
|
||||
from: Origin::ArtCraftApi,
|
||||
});
|
||||
}
|
||||
ev.push(Event::Latest { app: "soundcraft".into(), latest: LatestRelease::None });
|
||||
ev.push(Event::Latest { app: "soundcraft".into(), latest: LatestRelease::None, from: Origin::ArtCraftApi });
|
||||
let installed = [("photocraft", "0.4.1"), ("lightcraft", "0.4.0"), ("wordcraft", "0.3.0"), ("effectcraft", "0.5.0")];
|
||||
for app in &SUITE {
|
||||
let inst = installed.iter().find(|(id, _)| *id == app.id).map(|(_, v)| Installed {
|
||||
@@ -517,6 +521,38 @@ fn automatic_checks_run_every_five_minutes() {
|
||||
assert_eq!(CheckEvery::Never.seconds(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_footer_names_the_services_behind_what_is_on_screen() {
|
||||
let footer = |extra: Vec<Event>| {
|
||||
let mut evs = events();
|
||||
evs.extend(extra);
|
||||
let sent = Rc::new(RefCell::new(Vec::new()));
|
||||
let mut backend = Some(Fake { events: evs, sent, manage: true, clock: Rc::new(Cell::new(NOW)) });
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).build_ui_state(
|
||||
move |ui, state: &mut Option<LauncherUi>| {
|
||||
if state.is_none()
|
||||
&& let Some(b) = backend.take()
|
||||
{
|
||||
*state = Some(LauncherUi::new(ui.ctx(), Box::new(b), Settings { notify: false, ..Settings::default() }));
|
||||
}
|
||||
if let Some(l) = state.as_mut() {
|
||||
l.logic(&ui.ctx().clone(), &mut NoHost);
|
||||
l.ui(ui, &mut NoHost);
|
||||
}
|
||||
},
|
||||
None,
|
||||
);
|
||||
h.run_steps(6);
|
||||
let label =
|
||||
h.get_by_label_contains("Checked ").accesskit_node().label().or_else(|| h.get_by_label_contains("Checked ").accesskit_node().value());
|
||||
label.unwrap_or_default()
|
||||
};
|
||||
assert_eq!(footer(Vec::new()), "Checked 3 min ago · using ArtCraft API");
|
||||
let from_github =
|
||||
vec![Event::Latest { app: "wordcraft".into(), latest: LatestRelease::Found(release("wordcraft", "0.3.1", NOW)), from: Origin::GitHub }];
|
||||
assert_eq!(footer(from_github), "Checked 3 min ago · using ArtCraft API and GitHub");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_build_offers_downloads() {
|
||||
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, false);
|
||||
|
||||
Reference in new issue
Block a user