pub mod github;
mod notes;
pub use github::{Release, Version};
use crate::app::i18n::fill;
use crate::app::menu_ui;
use crate::app::open::open_url;
use crate::app::palette;
use crate::app::settings::GameSettings;
use crate::app::{RoundNotice, Screen, dev};
use bevy::prelude::*;
use github::{demo_release, fetch_latest, latest_release_urls};
use notes::{notes_budget, notes_lines};
use std::sync::{Arc, OnceLock};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Choice {
Yes,
No,
}
impl Choice {
pub const ALL: [Choice; 2] = [Choice::Yes, Choice::No];
}
#[derive(Resource, Default)]
pub struct UpdateCheck {
reply: Option<Arc<OnceLock<Option<Release>>>>,
pub offer: Option<Release>,
pub selected: usize,
stashed_notice: String,
}
impl UpdateCheck {
fn take_reply(&mut self) -> Option<Option<Release>> {
let answered = self.reply.as_ref()?.get().cloned()?;
self.reply = None;
Some(answered)
}
pub fn worth_offering(current: &Version, release: &Release) -> bool {
release.version > *current
}
}
#[derive(Component)]
pub struct NewVersionUi;
#[derive(Component)]
pub struct NewVersionRow(usize);
pub fn start_check(settings: Res<GameSettings>, mut check: ResMut<UpdateCheck>) {
if dev::no_update_check() {
return;
}
let slot = Arc::new(OnceLock::new());
if dev::update_demo() {
let _ = slot.set(Some(demo_release()));
check.reply = Some(slot);
return;
}
if !settings.check_updates {
return;
}
let Some(urls) = latest_release_urls() else {
warn!("update check: the manifest's repository is not on GitHub");
return;
};
let answer = Arc::clone(&slot);
let spawned = std::thread::Builder::new()
.name("update-check".into())
.spawn(move || {
let _ = answer.set(fetch_latest(&urls));
});
match spawned {
Ok(_) => check.reply = Some(slot),
Err(e) => warn!("update check: could not start: {e}"),
}
}
pub fn poll_check(
settings: Res<GameSettings>,
mut check: ResMut<UpdateCheck>,
mut notice: ResMut<RoundNotice>,
mut next_screen: ResMut<NextState<Screen>>,
) {
if !settings.check_updates && !dev::update_demo() {
check.reply = None;
check.offer = None;
return;
}
if let Some(Some(release)) = check.take_reply()
&& UpdateCheck::worth_offering(&Version::current(), &release)
{
check.offer = Some(release);
check.selected = 0;
}
if check.offer.is_some() {
check.stashed_notice = std::mem::take(&mut notice.0);
next_screen.set(Screen::NewVersion);
}
}
pub fn enter_new_version(
mut commands: Commands,
settings: Res<GameSettings>,
check: Res<UpdateCheck>,
ui_scale: Res<UiScale>,
windows: Query<&Window>,
) {
let Some(release) = &check.offer else {
return;
};
let tr = settings.tr();
let frame_h = windows
.single()
.map_or(crate::app::settings::DESIGN_H, |window| {
window.height() / ui_scale.0
})
- 2.0 * menu_ui::BAR_H;
let mut lines = notes_lines(&release.notes, notes_budget(frame_h));
if lines.is_empty() {
lines.push(tr.update_no_notes.to_string());
}
commands
.spawn((NewVersionUi, menu_ui::between_bars()))
.with_children(|wrap| {
wrap.spawn(menu_ui::screen_card()).with_children(|card| {
card.spawn((
Text::new(fill(
tr.update_title,
&[("v", &release.version.to_string())],
)),
menu_ui::display_font(26.0),
TextColor(palette::GOLD),
));
card.spawn((
Text::new(fill(
tr.update_have,
&[("v", &Version::current().to_string())],
)),
TextFont {
font_size: FontSize::Px(menu_ui::type_scale::BODY),
..default()
},
TextColor(palette::PARCHMENT.with_alpha(0.55)),
));
card.spawn((
Node {
flex_direction: FlexDirection::Column,
align_items: AlignItems::FlexStart,
align_self: AlignSelf::Stretch,
row_gap: Val::Px(1.0),
margin: UiRect::vertical(Val::Px(8.0)),
padding: UiRect::axes(Val::Px(10.0), Val::Px(8.0)),
border: UiRect::top(Val::Px(1.0)).with_bottom(Val::Px(1.0)),
..default()
},
BorderColor::all(palette::CARD_EDGE),
))
.with_children(|notes| {
for line in lines {
notes.spawn((
Text::new(line),
TextFont {
font_size: FontSize::Px(menu_ui::type_scale::BODY),
..default()
},
TextLayout::no_wrap(),
TextColor(palette::PARCHMENT),
));
}
});
card.spawn((
Text::new(tr.update_question),
TextFont {
font_size: FontSize::Px(menu_ui::type_scale::ROW),
..default()
},
TextColor(palette::PARCHMENT),
));
menu_ui::spawn_rows(card, Choice::ALL.len(), 22.0, NewVersionRow);
});
wrap.spawn((
Text::new(tr.update_note),
TextFont {
font_size: FontSize::Px(menu_ui::type_scale::BODY),
..default()
},
TextLayout::no_wrap(),
TextColor(palette::PARCHMENT.with_alpha(0.70)),
Node {
padding: UiRect::axes(Val::Px(12.0), Val::Px(3.0)),
border_radius: BorderRadius::all(Val::Px(11.0)),
..default()
},
BackgroundColor(palette::PILL_FILL),
));
});
}
pub fn pick(keys: &ButtonInput<KeyCode>, selected: usize) -> (usize, Option<Choice>) {
let selected = menu_ui::nav(keys, selected, Choice::ALL.len());
let choice = if menu_ui::enter(keys) {
Some(Choice::ALL[selected])
} else if keys.just_pressed(KeyCode::Escape) {
Some(Choice::No)
} else {
None
};
(selected, choice)
}
pub fn new_version_input(
keys: Res<ButtonInput<KeyCode>>,
settings: Res<GameSettings>,
mut check: ResMut<UpdateCheck>,
mut notice: ResMut<RoundNotice>,
mut next_screen: ResMut<NextState<Screen>>,
) {
let (selected, choice) = pick(&keys, check.selected);
check.selected = selected;
let Some(choice) = choice else {
return;
};
let mut said = std::mem::take(&mut check.stashed_notice);
if choice == Choice::Yes
&& let Some(release) = &check.offer
{
let tr = settings.tr();
match open_url(&release.url) {
Ok(()) if said.is_empty() => said = tr.update_opened.to_string(),
Ok(()) => {}
Err(e) => {
warn!("update check: could not open a browser: {e}");
said = fill(tr.update_open_failed, &[("url", &release.url)]);
}
}
}
notice.0 = said;
next_screen.set(Screen::Menu);
}
pub fn update_new_version_rows(
check: Res<UpdateCheck>,
settings: Res<GameSettings>,
mut rows: Query<(&NewVersionRow, &mut Text, &mut TextColor)>,
) {
let tr = settings.tr();
let labels = [tr.update_yes, tr.update_no];
for (row, mut text, mut color) in &mut rows {
menu_ui::paint_row(
row.0 == check.selected,
labels[row.0],
&mut text,
&mut color,
);
}
}
pub fn exit_new_version(
mut commands: Commands,
mut check: ResMut<UpdateCheck>,
ui: Query<Entity, With<NewVersionUi>>,
) {
check.offer = None;
for entity in &ui {
commands.entity(entity).despawn();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::open::tests::OPENED;
#[test]
fn only_a_newer_release_is_offered() {
let release = |tag: &str| Release {
version: Version::parse(tag).unwrap(),
tag: tag.to_string(),
url: "https://x/y".to_string(),
notes: String::new(),
};
let current = Version::parse("v0.5.0").unwrap();
assert!(UpdateCheck::worth_offering(¤t, &release("v0.5.1")));
assert!(UpdateCheck::worth_offering(¤t, &release("v1.0.0")));
assert!(!UpdateCheck::worth_offering(¤t, &release("v0.5.0")));
assert!(!UpdateCheck::worth_offering(¤t, &release("v0.4.9")));
assert!(UpdateCheck::worth_offering(
¤t,
&release("v0.6.0-rc.1")
));
assert!(!UpdateCheck::worth_offering(
¤t,
&release("v0.5.0-rc.2")
));
let candidate = Version::parse("v0.5.0-rc.2").unwrap();
assert!(UpdateCheck::worth_offering(&candidate, &release("v0.5.0")));
let plain = Version::parse("1.2.3").unwrap();
assert!(!UpdateCheck::worth_offering(
&plain,
&release("v1.2.3+linux")
));
let tagged = Version::parse("v1.2.3+linux").unwrap();
assert!(!UpdateCheck::worth_offering(&tagged, &release("1.2.3")));
assert!(UpdateCheck::worth_offering(&tagged, &release("v1.2.4")));
}
#[test]
fn the_keys_walk_and_take_the_rows() {
let mut keys = ButtonInput::<KeyCode>::default();
assert_eq!(pick(&keys, 0), (0, None));
keys.press(KeyCode::KeyS);
assert_eq!(pick(&keys, 0), (1, None));
assert_eq!(pick(&keys, 1), (0, None));
keys.clear();
keys.press(KeyCode::Enter);
assert_eq!(pick(&keys, 0), (0, Some(Choice::Yes)));
assert_eq!(pick(&keys, 1), (1, Some(Choice::No)));
keys.clear();
keys.press(KeyCode::Escape);
assert_eq!(pick(&keys, 0), (0, Some(Choice::No)));
}
#[test]
fn a_newer_release_takes_the_menu_to_the_page_and_no_brings_it_back() {
let mut app = App::new();
app.add_plugins(bevy::state::app::StatesPlugin);
app.init_state::<Screen>();
app.init_resource::<ButtonInput<KeyCode>>();
app.insert_resource(GameSettings::default());
app.init_resource::<RoundNotice>();
app.init_resource::<UpdateCheck>();
app.init_resource::<UiScale>();
app.add_systems(
Update,
(
poll_check.run_if(in_state(Screen::Menu)),
(new_version_input, update_new_version_rows)
.chain()
.run_if(in_state(Screen::NewVersion)),
),
);
app.add_systems(OnEnter(Screen::NewVersion), enter_new_version);
app.add_systems(OnExit(Screen::NewVersion), exit_new_version);
let screen = |app: &App| *app.world().resource::<State<Screen>>().get();
let tap = |app: &mut App, key: KeyCode| {
let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
keys.press(key);
app.update();
let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
keys.release(key);
keys.clear();
};
let slot = Arc::new(OnceLock::new());
let _ = slot.set(Some(Release {
version: Version::parse("v9.0.0").unwrap(),
tag: "v9.0.0".to_string(),
url: "https://x/y".to_string(),
notes: "- one\n- two".to_string(),
}));
app.world_mut().resource_mut::<UpdateCheck>().reply = Some(slot);
app.update();
app.update();
assert_eq!(screen(&app), Screen::NewVersion);
let mut rows = app.world_mut().query::<&NewVersionRow>();
assert_eq!(rows.iter(app.world()).count(), Choice::ALL.len());
let mut ui = app.world_mut().query::<&NewVersionUi>();
assert_eq!(ui.iter(app.world()).count(), 1);
tap(&mut app, KeyCode::KeyS);
assert_eq!(app.world().resource::<UpdateCheck>().selected, 1);
tap(&mut app, KeyCode::Enter);
app.update();
assert_eq!(screen(&app), Screen::Menu);
assert!(app.world().resource::<UpdateCheck>().offer.is_none());
assert_eq!(ui.iter(app.world()).count(), 0);
assert_eq!(app.world().resource::<RoundNotice>().0, "");
app.update();
app.update();
assert_eq!(screen(&app), Screen::Menu);
let slot = Arc::new(OnceLock::new());
let _ = slot.set(Some(Release {
version: Version::parse("v9.0.1").unwrap(),
tag: "v9.0.1".to_string(),
url: "https://x/y".to_string(),
notes: String::new(),
}));
app.world_mut().resource_mut::<UpdateCheck>().reply = Some(slot);
app.world_mut().resource_mut::<RoundNotice>().0 = "could not put the round down".into();
app.update();
app.update();
assert_eq!(screen(&app), Screen::NewVersion);
app.world_mut().resource_mut::<RoundNotice>().0.clear();
tap(&mut app, KeyCode::Escape);
app.update();
assert_eq!(screen(&app), Screen::Menu);
assert_eq!(
app.world().resource::<RoundNotice>().0,
"could not put the round down"
);
let slot = Arc::new(OnceLock::new());
let _ = slot.set(Some(Release {
version: Version::parse("v9.0.2").unwrap(),
tag: "v9.0.2".to_string(),
url: "https://github.com/x/y/releases/tag/v9.0.2".to_string(),
notes: String::new(),
}));
app.world_mut().resource_mut::<UpdateCheck>().reply = Some(slot);
app.world_mut().resource_mut::<RoundNotice>().0 = "could not put the round down".into();
app.update();
app.update();
assert_eq!(screen(&app), Screen::NewVersion);
app.world_mut().resource_mut::<RoundNotice>().0.clear();
let opened_before = OPENED.lock().unwrap().len();
tap(&mut app, KeyCode::Enter);
app.update();
assert_eq!(screen(&app), Screen::Menu);
assert_eq!(
app.world().resource::<RoundNotice>().0,
"could not put the round down"
);
assert_eq!(
OPENED.lock().unwrap()[opened_before..],
["https://github.com/x/y/releases/tag/v9.0.2".to_string()]
);
app.world_mut().resource_mut::<RoundNotice>().0.clear();
let slot = Arc::new(OnceLock::new());
let _ = slot.set(Some(Release {
version: Version::parse("v9.0.3").unwrap(),
tag: "v9.0.3".to_string(),
url: "https://github.com/x/y/releases/tag/v9.0.3".to_string(),
notes: String::new(),
}));
app.world_mut().resource_mut::<UpdateCheck>().reply = Some(slot);
app.update();
app.update();
assert_eq!(screen(&app), Screen::NewVersion);
tap(&mut app, KeyCode::Enter);
app.update();
assert_eq!(screen(&app), Screen::Menu);
let tr = GameSettings::default().tr();
assert_eq!(app.world().resource::<RoundNotice>().0, tr.update_opened);
assert_eq!(
OPENED.lock().unwrap().last().map(String::as_str),
Some("https://github.com/x/y/releases/tag/v9.0.3")
);
app.world_mut().resource_mut::<RoundNotice>().0.clear();
let slot = Arc::new(OnceLock::new());
let _ = slot.set(Some(Release {
version: Version::parse("v9.0.4").unwrap(),
tag: "v9.0.4".to_string(),
url: crate::app::open::tests::UNOPENABLE.to_string(),
notes: String::new(),
}));
app.world_mut().resource_mut::<UpdateCheck>().reply = Some(slot);
app.update();
app.update();
assert_eq!(screen(&app), Screen::NewVersion);
tap(&mut app, KeyCode::Enter);
app.update();
assert_eq!(screen(&app), Screen::Menu);
assert_eq!(
app.world().resource::<RoundNotice>().0,
fill(
tr.update_open_failed,
&[("url", crate::app::open::tests::UNOPENABLE)]
)
);
}
#[test]
fn switching_the_check_off_drops_an_answer_in_flight() {
let mut app = App::new();
app.add_plugins(bevy::state::app::StatesPlugin);
app.init_state::<Screen>();
app.insert_resource(GameSettings {
check_updates: false,
..GameSettings::default()
});
app.init_resource::<RoundNotice>();
app.init_resource::<UpdateCheck>();
app.add_systems(Update, poll_check.run_if(in_state(Screen::Menu)));
let slot = Arc::new(OnceLock::new());
let _ = slot.set(Some(demo_release()));
app.world_mut().resource_mut::<UpdateCheck>().reply = Some(slot);
app.update();
app.update();
assert_eq!(*app.world().resource::<State<Screen>>().get(), Screen::Menu);
let check = app.world().resource::<UpdateCheck>();
assert!(check.reply.is_none() && check.offer.is_none());
}
#[test]
fn nothing_newer_is_nothing_said() {
let mut app = App::new();
app.add_plugins(bevy::state::app::StatesPlugin);
app.init_state::<Screen>();
app.init_resource::<UpdateCheck>();
app.insert_resource(GameSettings::default());
app.init_resource::<RoundNotice>();
app.add_systems(Update, poll_check.run_if(in_state(Screen::Menu)));
let screen = |app: &App| *app.world().resource::<State<Screen>>().get();
for reply in [
None,
Some(Release {
version: Version::current(),
tag: Version::current().to_string(),
url: "https://x/y".to_string(),
notes: String::new(),
}),
] {
let slot = Arc::new(OnceLock::new());
let _ = slot.set(reply);
app.world_mut().resource_mut::<UpdateCheck>().reply = Some(slot);
app.update();
app.update();
assert_eq!(screen(&app), Screen::Menu);
assert!(app.world().resource::<UpdateCheck>().reply.is_none());
}
}
}