Skip to main content

pinch_points/app/update/
mod.rs

1//! Is there a newer release? Start-up asks GitHub, off-thread, and if the
2//! answer is yes the game steps off the menu onto a page of its own, the
3//! way a first run steps onto the language picker: the new version's
4//! number, its release notes, and one question - update? Yes opens the
5//! release page in the browser; no is no, until next start-up.
6//!
7//! Open, not install: the game is one binary that came from the release
8//! page or from `cargo install`, and the honest thing a running binary can
9//! do about a newer one is show the player where it is. The page is
10//! reached from the menu and nowhere else, so an answer arriving mid-round
11//! never lands on top of a beach.
12//!
13//! The check is the one thing the game says to the wider internet, so it
14//! is a setting (on by default). Nothing waits on it: a machine with no
15//! network, or a slow one, finds that out on the check thread inside a
16//! short timeout, and the menu never hears of it.
17
18pub 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/// The page's two answers.
35#[derive(Clone, Copy, PartialEq, Eq, Debug)]
36pub enum Choice {
37    /// Hand the release page to the browser.
38    Yes,
39    /// Not now. Asked again next start-up, if it is still out there:
40    /// on a shared network a version behind is a version that cannot
41    /// join the others' beach, which is worth being reminded of.
42    No,
43}
44
45impl Choice {
46    pub const ALL: [Choice; 2] = [Choice::Yes, Choice::No];
47}
48
49/// The check, from the thread that runs it to the page that reports it.
50#[derive(Resource, Default)]
51pub struct UpdateCheck {
52    /// The check thread's answer, filled in once it has one. `None` when
53    /// no check is running: never started, or already read.
54    reply: Option<Arc<OnceLock<Option<Release>>>>,
55    /// A newer release, from the moment the answer is in until the page
56    /// has been shown and left. The menu goes to the page while this
57    /// stands, so an answer that lands on the same frame as a keypress
58    /// is shown on the way back rather than lost.
59    pub offer: Option<Release>,
60    /// Which row of the page the cursor is on.
61    pub selected: usize,
62    /// The menu's notice as it stood when the page was stepped onto,
63    /// put back on the way home.
64    stashed_notice: String,
65}
66
67impl UpdateCheck {
68    /// Take the thread's answer, if it is in.
69    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    /// Whether a release is worth the page: newer than this build.
76    pub fn worth_offering(current: &Version, release: &Release) -> bool {
77        release.version > *current
78    }
79}
80
81/// Everything the page spawns.
82#[derive(Component)]
83pub struct NewVersionUi;
84
85#[derive(Component)]
86pub struct NewVersionRow(usize);
87
88/// Kick the check off, on its own thread. Startup.
89pub 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
118/// Watch for the check's answer and, on a newer release, step onto the
119/// page. Menu only, so the answer waits for the menu rather than
120/// interrupting a round or the first-run language picker.
121///
122/// The setting is read here as well as at start-up: a player who went
123/// straight to Settings and turned the check off has answered the
124/// question before it was asked, and a reply that landed meanwhile is
125/// dropped rather than shown.
126pub 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        // The page is a detour off the menu, and leaving the menu clears
145        // its notice slot: a save that failed a moment ago must still be
146        // on the menu when the detour is over. Carried across by hand.
147        check.stashed_notice = std::mem::take(&mut notice.0);
148        next_screen.set(Screen::NewVersion);
149    }
150}
151
152/// The page: the version and its notes on a card, the question under
153/// them, and the note that the asking can be turned off.
154pub 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    // The frame between the bars, in UI pixels: what the notes may fill.
166    // Read the applied scale, as the camera does, since a small window
167    // shrinks the interface as well.
168    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                // The notes: a left-aligned block on a card whose rows are
205                // centred, so it gets a column of its own.
206                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
254/// Read the page's keys: where the cursor is now, and what was chosen, if
255/// anything. Enter takes the row; Escape is no, as leaving is on every
256/// other screen.
257pub 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
269/// Walk the page and answer it. Either answer goes back to the menu.
270pub 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    // Yes opens the page whatever the menu was saying. What the menu had
283    // to say before the detour then comes first: a round that failed to
284    // save outranks news that a browser was handed a page. A browser
285    // that could not be opened still wins the line, though, since that
286    // notice carries the address to type by hand and nothing else does.
287    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
305/// Paint the rows around the selection.
306pub 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
323/// Leaving the page is the end of the offer: it comes back only with a
324/// fresh answer, and there is one per start-up.
325pub 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    /// The page goes up for a newer release only: the same version, or an
342    /// older one still on the release page, is nothing to say.
343    #[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(&current, &release("v0.5.1")));
353        assert!(UpdateCheck::worth_offering(&current, &release("v1.0.0")));
354        assert!(!UpdateCheck::worth_offering(&current, &release("v0.5.0")));
355        assert!(!UpdateCheck::worth_offering(&current, &release("v0.4.9")));
356        // A release candidate of the next version is news; one of this
357        // version is old news.
358        assert!(UpdateCheck::worth_offering(
359            &current,
360            &release("v0.6.0-rc.1")
361        ));
362        assert!(!UpdateCheck::worth_offering(
363            &current,
364            &release("v0.5.0-rc.2")
365        ));
366        // And a candidate build hears when its release lands.
367        let candidate = Version::parse("v0.5.0-rc.2").unwrap();
368        assert!(UpdateCheck::worth_offering(&candidate, &release("v0.5.0")));
369    }
370
371    /// The keys, read the way the page reads them: W/S walk the two rows,
372    /// Enter takes the one under the cursor, Escape is no wherever the
373    /// cursor is.
374    #[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    /// The whole detour, headless: the answer lands, the menu steps onto
391    /// the page, no steps back to the menu, and the offer is spent - the
392    /// next visit to the menu stays on the menu.
393    #[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        // No window: the page falls back to the design height.
404        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        // The answer arrives: a release newer than this build.
426        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        // The page is spawned, notes and all.
438        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        // Down to "no", and take it.
444        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        // Spent: the menu stays the menu.
453        app.update();
454        app.update();
455        assert_eq!(screen(&app), Screen::Menu);
456
457        // A notice standing on the menu survives the detour: the page is
458        // not what the menu was talking about.
459        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        // As the schedule does on the way out of the menu.
472        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        // Yes with a notice standing: the browser is still asked, and
482        // the notice still has the line.
483        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        // Yes with nothing standing says the page was handed over. (The
510        // menu is left with its notice read, as it is in the schedule.)
511        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    /// Turning the check off in Settings, on the same run it was started,
535    /// drops the answer rather than showing it.
536    #[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    /// An answer that is not newer never leaves the menu, and neither does
559    /// no answer at all: the check failing is the check saying nothing.
560    #[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}