1pub mod github;
19mod notes;
20
21pub use github::{Release, Version};
22
23use crate::app::i18n::fill;
24use crate::app::menu_ui;
25use crate::app::open::open_url;
26use crate::app::palette;
27use crate::app::settings::GameSettings;
28use crate::app::{RoundNotice, Screen, dev};
29use bevy::prelude::*;
30use github::{demo_release, fetch_latest, latest_release_urls};
31use notes::{notes_budget, notes_lines};
32use std::sync::{Arc, OnceLock};
33
34#[derive(Clone, Copy, PartialEq, Eq, Debug)]
36pub enum Choice {
37 Yes,
39 No,
43}
44
45impl Choice {
46 pub const ALL: [Choice; 2] = [Choice::Yes, Choice::No];
47}
48
49#[derive(Resource, Default)]
51pub struct UpdateCheck {
52 reply: Option<Arc<OnceLock<Option<Release>>>>,
55 pub offer: Option<Release>,
60 pub selected: usize,
62 stashed_notice: String,
65}
66
67impl UpdateCheck {
68 fn take_reply(&mut self) -> Option<Option<Release>> {
70 let answered = self.reply.as_ref()?.get().cloned()?;
71 self.reply = None;
72 Some(answered)
73 }
74
75 pub fn worth_offering(current: &Version, release: &Release) -> bool {
77 release.version > *current
78 }
79}
80
81#[derive(Component)]
83pub struct NewVersionUi;
84
85#[derive(Component)]
86pub struct NewVersionRow(usize);
87
88pub fn start_check(settings: Res<GameSettings>, mut check: ResMut<UpdateCheck>) {
90 if dev::no_update_check() {
91 return;
92 }
93 let slot = Arc::new(OnceLock::new());
94 if dev::update_demo() {
95 let _ = slot.set(Some(demo_release()));
96 check.reply = Some(slot);
97 return;
98 }
99 if !settings.check_updates {
100 return;
101 }
102 let Some(urls) = latest_release_urls() else {
103 warn!("update check: the manifest's repository is not on GitHub");
104 return;
105 };
106 let answer = Arc::clone(&slot);
107 let spawned = std::thread::Builder::new()
108 .name("update-check".into())
109 .spawn(move || {
110 let _ = answer.set(fetch_latest(&urls));
111 });
112 match spawned {
113 Ok(_) => check.reply = Some(slot),
114 Err(e) => warn!("update check: could not start: {e}"),
115 }
116}
117
118pub fn poll_check(
127 settings: Res<GameSettings>,
128 mut check: ResMut<UpdateCheck>,
129 mut notice: ResMut<RoundNotice>,
130 mut next_screen: ResMut<NextState<Screen>>,
131) {
132 if !settings.check_updates && !dev::update_demo() {
133 check.reply = None;
134 check.offer = None;
135 return;
136 }
137 if let Some(Some(release)) = check.take_reply()
138 && UpdateCheck::worth_offering(&Version::current(), &release)
139 {
140 check.offer = Some(release);
141 check.selected = 0;
142 }
143 if check.offer.is_some() {
144 check.stashed_notice = std::mem::take(&mut notice.0);
148 next_screen.set(Screen::NewVersion);
149 }
150}
151
152pub fn enter_new_version(
155 mut commands: Commands,
156 settings: Res<GameSettings>,
157 check: Res<UpdateCheck>,
158 ui_scale: Res<UiScale>,
159 windows: Query<&Window>,
160) {
161 let Some(release) = &check.offer else {
162 return;
163 };
164 let tr = settings.tr();
165 let frame_h = windows
169 .single()
170 .map_or(crate::app::settings::DESIGN_H, |window| {
171 window.height() / ui_scale.0
172 })
173 - 2.0 * menu_ui::BAR_H;
174 let mut lines = notes_lines(&release.notes, notes_budget(frame_h));
175 if lines.is_empty() {
176 lines.push(tr.update_no_notes.to_string());
177 }
178 commands
179 .spawn((NewVersionUi, menu_ui::between_bars()))
180 .with_children(|wrap| {
181 wrap.spawn(menu_ui::screen_card()).with_children(|card| {
182 card.spawn((
183 Text::new(fill(
184 tr.update_title,
185 &[("v", &release.version.to_string())],
186 )),
187 TextFont {
188 font_size: FontSize::Px(26.0),
189 ..default()
190 },
191 TextColor(palette::GOLD),
192 ));
193 card.spawn((
194 Text::new(fill(
195 tr.update_have,
196 &[("v", &Version::current().to_string())],
197 )),
198 TextFont {
199 font_size: FontSize::Px(menu_ui::type_scale::BODY),
200 ..default()
201 },
202 TextColor(palette::PARCHMENT.with_alpha(0.55)),
203 ));
204 card.spawn((
207 Node {
208 flex_direction: FlexDirection::Column,
209 align_items: AlignItems::FlexStart,
210 align_self: AlignSelf::Stretch,
211 row_gap: Val::Px(1.0),
212 margin: UiRect::vertical(Val::Px(8.0)),
213 padding: UiRect::axes(Val::Px(10.0), Val::Px(8.0)),
214 border: UiRect::top(Val::Px(1.0)).with_bottom(Val::Px(1.0)),
215 ..default()
216 },
217 BorderColor::all(palette::CARD_EDGE),
218 ))
219 .with_children(|notes| {
220 for line in lines {
221 notes.spawn((
222 Text::new(line),
223 TextFont {
224 font_size: FontSize::Px(menu_ui::type_scale::BODY),
225 ..default()
226 },
227 TextLayout::no_wrap(),
228 TextColor(palette::PARCHMENT),
229 ));
230 }
231 });
232 card.spawn((
233 Text::new(tr.update_question),
234 TextFont {
235 font_size: FontSize::Px(menu_ui::type_scale::ROW),
236 ..default()
237 },
238 TextColor(palette::PARCHMENT),
239 ));
240 menu_ui::spawn_rows(card, Choice::ALL.len(), 22.0, NewVersionRow);
241 });
242 wrap.spawn((
243 Text::new(tr.update_note),
244 TextFont {
245 font_size: FontSize::Px(menu_ui::type_scale::BODY),
246 ..default()
247 },
248 TextLayout::no_wrap(),
249 TextColor(palette::PARCHMENT.with_alpha(0.40)),
250 ));
251 });
252}
253
254pub fn pick(keys: &ButtonInput<KeyCode>, selected: usize) -> (usize, Option<Choice>) {
258 let selected = menu_ui::nav(keys, selected, Choice::ALL.len());
259 let choice = if menu_ui::enter(keys) {
260 Some(Choice::ALL[selected])
261 } else if keys.just_pressed(KeyCode::Escape) {
262 Some(Choice::No)
263 } else {
264 None
265 };
266 (selected, choice)
267}
268
269pub fn new_version_input(
271 keys: Res<ButtonInput<KeyCode>>,
272 settings: Res<GameSettings>,
273 mut check: ResMut<UpdateCheck>,
274 mut notice: ResMut<RoundNotice>,
275 mut next_screen: ResMut<NextState<Screen>>,
276) {
277 let (selected, choice) = pick(&keys, check.selected);
278 check.selected = selected;
279 let Some(choice) = choice else {
280 return;
281 };
282 let mut said = std::mem::take(&mut check.stashed_notice);
288 if choice == Choice::Yes
289 && let Some(release) = &check.offer
290 {
291 let tr = settings.tr();
292 match open_url(&release.url) {
293 Ok(()) if said.is_empty() => said = tr.update_opened.to_string(),
294 Ok(()) => {}
295 Err(e) => {
296 warn!("update check: could not open a browser: {e}");
297 said = fill(tr.update_open_failed, &[("url", &release.url)]);
298 }
299 }
300 }
301 notice.0 = said;
302 next_screen.set(Screen::Menu);
303}
304
305pub fn update_new_version_rows(
307 check: Res<UpdateCheck>,
308 settings: Res<GameSettings>,
309 mut rows: Query<(&NewVersionRow, &mut Text, &mut TextColor)>,
310) {
311 let tr = settings.tr();
312 let labels = [tr.update_yes, tr.update_no];
313 for (row, mut text, mut color) in &mut rows {
314 menu_ui::paint_row(
315 row.0 == check.selected,
316 labels[row.0],
317 &mut text,
318 &mut color,
319 );
320 }
321}
322
323pub fn exit_new_version(
326 mut commands: Commands,
327 mut check: ResMut<UpdateCheck>,
328 ui: Query<Entity, With<NewVersionUi>>,
329) {
330 check.offer = None;
331 for entity in &ui {
332 commands.entity(entity).despawn();
333 }
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339 use crate::app::open::tests::OPENED;
340
341 #[test]
344 fn only_a_newer_release_is_offered() {
345 let release = |tag: &str| Release {
346 version: Version::parse(tag).unwrap(),
347 tag: tag.to_string(),
348 url: "https://x/y".to_string(),
349 notes: String::new(),
350 };
351 let current = Version::parse("v0.5.0").unwrap();
352 assert!(UpdateCheck::worth_offering(¤t, &release("v0.5.1")));
353 assert!(UpdateCheck::worth_offering(¤t, &release("v1.0.0")));
354 assert!(!UpdateCheck::worth_offering(¤t, &release("v0.5.0")));
355 assert!(!UpdateCheck::worth_offering(¤t, &release("v0.4.9")));
356 assert!(UpdateCheck::worth_offering(
359 ¤t,
360 &release("v0.6.0-rc.1")
361 ));
362 assert!(!UpdateCheck::worth_offering(
363 ¤t,
364 &release("v0.5.0-rc.2")
365 ));
366 let candidate = Version::parse("v0.5.0-rc.2").unwrap();
368 assert!(UpdateCheck::worth_offering(&candidate, &release("v0.5.0")));
369 }
370
371 #[test]
375 fn the_keys_walk_and_take_the_rows() {
376 let mut keys = ButtonInput::<KeyCode>::default();
377 assert_eq!(pick(&keys, 0), (0, None));
378 keys.press(KeyCode::KeyS);
379 assert_eq!(pick(&keys, 0), (1, None));
380 assert_eq!(pick(&keys, 1), (0, None));
381 keys.clear();
382 keys.press(KeyCode::Enter);
383 assert_eq!(pick(&keys, 0), (0, Some(Choice::Yes)));
384 assert_eq!(pick(&keys, 1), (1, Some(Choice::No)));
385 keys.clear();
386 keys.press(KeyCode::Escape);
387 assert_eq!(pick(&keys, 0), (0, Some(Choice::No)));
388 }
389
390 #[test]
394 fn a_newer_release_takes_the_menu_to_the_page_and_no_brings_it_back() {
395 let mut app = App::new();
396 app.add_plugins(bevy::state::app::StatesPlugin);
397 app.init_state::<Screen>();
398 app.init_resource::<ButtonInput<KeyCode>>();
399 app.insert_resource(GameSettings::default());
400 app.init_resource::<RoundNotice>();
401 app.init_resource::<UpdateCheck>();
402 app.init_resource::<UiScale>();
403 app.add_systems(
405 Update,
406 (
407 poll_check.run_if(in_state(Screen::Menu)),
408 (new_version_input, update_new_version_rows)
409 .chain()
410 .run_if(in_state(Screen::NewVersion)),
411 ),
412 );
413 app.add_systems(OnEnter(Screen::NewVersion), enter_new_version);
414 app.add_systems(OnExit(Screen::NewVersion), exit_new_version);
415 let screen = |app: &App| *app.world().resource::<State<Screen>>().get();
416 let tap = |app: &mut App, key: KeyCode| {
417 let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
418 keys.press(key);
419 app.update();
420 let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
421 keys.release(key);
422 keys.clear();
423 };
424
425 let slot = Arc::new(OnceLock::new());
427 let _ = slot.set(Some(Release {
428 version: Version::parse("v9.0.0").unwrap(),
429 tag: "v9.0.0".to_string(),
430 url: "https://x/y".to_string(),
431 notes: "- one\n- two".to_string(),
432 }));
433 app.world_mut().resource_mut::<UpdateCheck>().reply = Some(slot);
434 app.update();
435 app.update();
436 assert_eq!(screen(&app), Screen::NewVersion);
437 let mut rows = app.world_mut().query::<&NewVersionRow>();
439 assert_eq!(rows.iter(app.world()).count(), Choice::ALL.len());
440 let mut ui = app.world_mut().query::<&NewVersionUi>();
441 assert_eq!(ui.iter(app.world()).count(), 1);
442
443 tap(&mut app, KeyCode::KeyS);
445 assert_eq!(app.world().resource::<UpdateCheck>().selected, 1);
446 tap(&mut app, KeyCode::Enter);
447 app.update();
448 assert_eq!(screen(&app), Screen::Menu);
449 assert!(app.world().resource::<UpdateCheck>().offer.is_none());
450 assert_eq!(ui.iter(app.world()).count(), 0);
451 assert_eq!(app.world().resource::<RoundNotice>().0, "");
452 app.update();
454 app.update();
455 assert_eq!(screen(&app), Screen::Menu);
456
457 let slot = Arc::new(OnceLock::new());
460 let _ = slot.set(Some(Release {
461 version: Version::parse("v9.0.1").unwrap(),
462 tag: "v9.0.1".to_string(),
463 url: "https://x/y".to_string(),
464 notes: String::new(),
465 }));
466 app.world_mut().resource_mut::<UpdateCheck>().reply = Some(slot);
467 app.world_mut().resource_mut::<RoundNotice>().0 = "could not put the round down".into();
468 app.update();
469 app.update();
470 assert_eq!(screen(&app), Screen::NewVersion);
471 app.world_mut().resource_mut::<RoundNotice>().0.clear();
473 tap(&mut app, KeyCode::Escape);
474 app.update();
475 assert_eq!(screen(&app), Screen::Menu);
476 assert_eq!(
477 app.world().resource::<RoundNotice>().0,
478 "could not put the round down"
479 );
480
481 let slot = Arc::new(OnceLock::new());
484 let _ = slot.set(Some(Release {
485 version: Version::parse("v9.0.2").unwrap(),
486 tag: "v9.0.2".to_string(),
487 url: "https://github.com/x/y/releases/tag/v9.0.2".to_string(),
488 notes: String::new(),
489 }));
490 app.world_mut().resource_mut::<UpdateCheck>().reply = Some(slot);
491 app.world_mut().resource_mut::<RoundNotice>().0 = "could not put the round down".into();
492 app.update();
493 app.update();
494 assert_eq!(screen(&app), Screen::NewVersion);
495 app.world_mut().resource_mut::<RoundNotice>().0.clear();
496 let opened_before = OPENED.lock().unwrap().len();
497 tap(&mut app, KeyCode::Enter);
498 app.update();
499 assert_eq!(screen(&app), Screen::Menu);
500 assert_eq!(
501 app.world().resource::<RoundNotice>().0,
502 "could not put the round down"
503 );
504 assert_eq!(
505 OPENED.lock().unwrap()[opened_before..],
506 ["https://github.com/x/y/releases/tag/v9.0.2".to_string()]
507 );
508
509 app.world_mut().resource_mut::<RoundNotice>().0.clear();
512 let slot = Arc::new(OnceLock::new());
513 let _ = slot.set(Some(Release {
514 version: Version::parse("v9.0.3").unwrap(),
515 tag: "v9.0.3".to_string(),
516 url: "https://github.com/x/y/releases/tag/v9.0.3".to_string(),
517 notes: String::new(),
518 }));
519 app.world_mut().resource_mut::<UpdateCheck>().reply = Some(slot);
520 app.update();
521 app.update();
522 assert_eq!(screen(&app), Screen::NewVersion);
523 tap(&mut app, KeyCode::Enter);
524 app.update();
525 assert_eq!(screen(&app), Screen::Menu);
526 let tr = GameSettings::default().tr();
527 assert_eq!(app.world().resource::<RoundNotice>().0, tr.update_opened);
528 assert_eq!(
529 OPENED.lock().unwrap().last().map(String::as_str),
530 Some("https://github.com/x/y/releases/tag/v9.0.3")
531 );
532 }
533
534 #[test]
537 fn switching_the_check_off_drops_an_answer_in_flight() {
538 let mut app = App::new();
539 app.add_plugins(bevy::state::app::StatesPlugin);
540 app.init_state::<Screen>();
541 app.insert_resource(GameSettings {
542 check_updates: false,
543 ..GameSettings::default()
544 });
545 app.init_resource::<RoundNotice>();
546 app.init_resource::<UpdateCheck>();
547 app.add_systems(Update, poll_check.run_if(in_state(Screen::Menu)));
548 let slot = Arc::new(OnceLock::new());
549 let _ = slot.set(Some(demo_release()));
550 app.world_mut().resource_mut::<UpdateCheck>().reply = Some(slot);
551 app.update();
552 app.update();
553 assert_eq!(*app.world().resource::<State<Screen>>().get(), Screen::Menu);
554 let check = app.world().resource::<UpdateCheck>();
555 assert!(check.reply.is_none() && check.offer.is_none());
556 }
557
558 #[test]
561 fn nothing_newer_is_nothing_said() {
562 let mut app = App::new();
563 app.add_plugins(bevy::state::app::StatesPlugin);
564 app.init_state::<Screen>();
565 app.init_resource::<UpdateCheck>();
566 app.insert_resource(GameSettings::default());
567 app.init_resource::<RoundNotice>();
568 app.add_systems(Update, poll_check.run_if(in_state(Screen::Menu)));
569 let screen = |app: &App| *app.world().resource::<State<Screen>>().get();
570 for reply in [
571 None,
572 Some(Release {
573 version: Version::current(),
574 tag: Version::current().to_string(),
575 url: "https://x/y".to_string(),
576 notes: String::new(),
577 }),
578 ] {
579 let slot = Arc::new(OnceLock::new());
580 let _ = slot.set(reply);
581 app.world_mut().resource_mut::<UpdateCheck>().reply = Some(slot);
582 app.update();
583 app.update();
584 assert_eq!(screen(&app), Screen::Menu);
585 assert!(app.world().resource::<UpdateCheck>().reply.is_none());
586 }
587 }
588}