use super::modal;
use super::update_check::{self, ReleaseInfo, ReleaseStatus};
use super::App;
impl App {
pub(super) fn spawn_startup_update_check(&mut self) {
if !self.startup_update_check_due {
return;
}
if self.modal_stack.contains::<modal::WelcomeModal>() {
return;
}
self.startup_update_check_due = false;
if !self.config.editor.check_for_updates {
return;
}
if !self.spawn_release_check_tracked(true) {
return;
}
self.state.last_update_check = update_check::now_unix();
self.save_state_bookkeeping("last-update-check timestamp");
}
fn spawn_release_check_tracked(&mut self, is_startup: bool) -> bool {
if self.release_check_in_flight {
return false;
}
let Some(tx) = self.app_tx.clone() else {
return false;
};
update_check::spawn_release_check(tx);
self.release_check_in_flight = true;
self.update_check_is_startup = is_startup;
true
}
pub(super) fn handle_release_check_result(&mut self, result: Result<ReleaseInfo, String>) {
self.release_check_in_flight = false;
let was_startup = std::mem::take(&mut self.update_check_is_startup);
if let Err(msg) = &result {
tracing::debug!(target: "update_check", %msg, "release check failed");
}
let status = ReleaseStatus::from_fetch(result);
self.latest_release = Some(status.clone());
let mut on_screen = false;
if let Some(open) = self.modal_stack.find_first_mut::<modal::UpdateModal>() {
open.set_status(status.clone());
on_screen = true;
}
if on_screen {
self.mark_update_notified(&status);
} else if was_startup {
if let Some(info) = update_check::notice_due(&status, &self.state.update_notified_for) {
self.pending_update_notice = Some(info.clone());
}
}
self.needs_draw = true;
}
pub(super) fn tick_update_notice(&mut self) {
if self.pending_update_notice.is_none() || !self.modal_stack.is_empty() {
return;
}
let Some(info) = self.pending_update_notice.take() else {
return;
};
let status = ReleaseStatus::Available(info);
self.mark_update_notified(&status);
self.modal_stack
.push(Box::new(modal::UpdateModal::new(status)));
self.needs_draw = true;
}
pub fn open_update_modal(&mut self) {
if self.modal_stack.contains::<modal::UpdateModal>() {
return;
}
self.spawn_release_check_tracked(false);
self.pending_update_notice = None;
let status = match self.latest_release.clone() {
Some(
cached @ (ReleaseStatus::UpToDate { .. }
| ReleaseStatus::Available(_)
| ReleaseStatus::Inconclusive { .. }),
) => cached,
_ => ReleaseStatus::Pending,
};
self.mark_update_notified(&status);
self.modal_stack
.push(Box::new(modal::UpdateModal::new(status)));
self.needs_draw = true;
}
fn mark_update_notified(&mut self, status: &ReleaseStatus) {
let ReleaseStatus::Available(info) = status else {
return;
};
if self.state.update_notified_for == info.tag {
return;
}
self.state.update_notified_for = info.tag.clone();
self.save_state_bookkeeping("update-notified tag");
}
pub(super) fn save_state_bookkeeping(&mut self, what: &str) {
if let Err(e) = self.state.save() {
tracing::warn!(
target: "update_check",
error = %e,
field = what,
"failed to persist state bookkeeping",
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::test_utils::make_app;
use crate::app::AppEvent;
fn isolated_app() -> (crate::test_env::ConfigIsolation, App) {
let iso = crate::test_env::config_isolation();
let app = make_app();
(iso, app)
}
fn info(tag: &str) -> ReleaseInfo {
ReleaseInfo {
tag: tag.to_owned(),
notes: vec!["- a thing".to_owned()],
}
}
const NEWER: &str = "v999.0.0";
#[test]
fn a_startup_result_with_a_newer_release_queues_a_notice() {
let (_iso, mut app) = isolated_app();
app.update_check_is_startup = true;
app.handle_release_check_result(Ok(info(NEWER)));
assert_eq!(app.pending_update_notice, Some(info(NEWER)));
assert!(!app.modal_stack.contains::<modal::UpdateModal>());
}
#[test]
fn an_explicit_result_never_queues_a_notice() {
let (_iso, mut app) = isolated_app();
app.update_check_is_startup = false;
app.handle_release_check_result(Ok(info(NEWER)));
assert_eq!(app.pending_update_notice, None);
assert!(app.latest_release.is_some(), "still cached for reuse");
}
#[test]
fn an_up_to_date_or_failed_startup_result_stays_silent() {
let (_iso, mut app) = isolated_app();
app.update_check_is_startup = true;
app.handle_release_check_result(Ok(info("v0.0.1")));
assert_eq!(app.pending_update_notice, None);
app.update_check_is_startup = true;
app.handle_release_check_result(Err("offline".to_owned()));
assert_eq!(app.pending_update_notice, None);
}
#[test]
fn a_tag_already_notified_about_does_not_queue_again() {
let (_iso, mut app) = isolated_app();
app.state.update_notified_for = NEWER.to_owned();
app.update_check_is_startup = true;
app.handle_release_check_result(Ok(info(NEWER)));
assert_eq!(app.pending_update_notice, None);
}
fn clear_modals(app: &mut App) {
while !app.modal_stack.is_empty() {
app.modal_stack.pop();
}
}
#[test]
fn the_notice_waits_for_an_empty_modal_stack() {
let (_iso, mut app) = isolated_app();
assert!(
app.modal_stack.contains::<modal::WelcomeModal>(),
"a default config puts the welcome modal on the stack"
);
app.pending_update_notice = Some(info(NEWER));
app.tick_update_notice();
assert!(
!app.modal_stack.contains::<modal::UpdateModal>(),
"must not stack on top of the welcome modal"
);
assert!(app.pending_update_notice.is_some(), "still parked");
clear_modals(&mut app);
app.tick_update_notice();
assert!(app.modal_stack.contains::<modal::UpdateModal>());
assert_eq!(app.pending_update_notice, None);
}
#[test]
fn pushing_the_notice_records_the_tag_so_it_fires_once() {
let (_iso, mut app) = isolated_app();
clear_modals(&mut app);
app.pending_update_notice = Some(info(NEWER));
app.tick_update_notice();
assert_eq!(app.state.update_notified_for, NEWER);
}
#[test]
fn a_result_seen_in_an_open_modal_counts_as_told() {
let (_iso, mut app) = isolated_app();
app.open_update_modal();
assert!(app.modal_stack.contains::<modal::UpdateModal>());
app.handle_release_check_result(Ok(info(NEWER)));
assert_eq!(
app.state.update_notified_for, NEWER,
"an explicit check the user watched should not re-notify next launch"
);
assert_eq!(app.pending_update_notice, None);
}
#[test]
fn opening_the_modal_supersedes_a_queued_notice() {
let (_iso, mut app) = isolated_app();
app.pending_update_notice = Some(info(NEWER));
app.open_update_modal();
assert_eq!(app.pending_update_notice, None);
clear_modals(&mut app);
app.tick_update_notice();
assert!(!app.modal_stack.contains::<modal::UpdateModal>());
}
#[test]
fn opening_the_modal_twice_does_not_stack_it() {
let (_iso, mut app) = isolated_app();
app.open_update_modal();
app.open_update_modal();
assert_eq!(app.modal_stack.count::<modal::UpdateModal>(), 1);
}
#[test]
fn a_cached_failure_reopens_as_pending_rather_than_stale() {
let (_iso, mut app) = isolated_app();
app.latest_release = Some(ReleaseStatus::Failed);
app.open_update_modal();
let m = app
.modal_stack
.find_first_mut::<modal::UpdateModal>()
.expect("modal");
assert_eq!(m.status(), &ReleaseStatus::Pending);
}
#[test]
fn a_cached_success_renders_while_the_refetch_runs() {
let (_iso, mut app) = isolated_app();
let cached = ReleaseStatus::UpToDate {
tag: "v0.1.0".to_owned(),
};
app.latest_release = Some(cached.clone());
app.open_update_modal();
let m = app
.modal_stack
.find_first_mut::<modal::UpdateModal>()
.expect("modal");
assert_eq!(m.status(), &cached);
}
#[test]
fn the_release_event_routes_through_the_handler() {
let (_iso, mut app) = isolated_app();
app.handle_async_event(AppEvent::ReleaseCheckResult(Ok(info(NEWER))));
assert_eq!(
app.latest_release,
Some(ReleaseStatus::Available(info(NEWER)))
);
}
#[test]
fn a_startup_check_that_is_not_due_never_spawns() {
let (_iso, mut app) = isolated_app();
app.startup_update_check_due = false;
app.spawn_startup_update_check();
assert!(!app.release_check_in_flight);
}
#[test]
fn the_startup_check_waits_for_the_first_run_welcome() {
let (_iso, mut app) = isolated_app();
assert!(app.modal_stack.contains::<modal::WelcomeModal>());
app.startup_update_check_due = true;
app.spawn_startup_update_check();
assert!(
app.startup_update_check_due,
"still parked behind the welcome"
);
assert!(!app.release_check_in_flight);
}
#[test]
fn declining_on_the_welcome_cancels_the_startup_check() {
let (_iso, mut app) = isolated_app();
app.startup_update_check_due = true;
clear_modals(&mut app);
app.config.editor.check_for_updates = false;
app.spawn_startup_update_check();
assert!(!app.startup_update_check_due, "decision consumed");
assert!(!app.release_check_in_flight, "and nothing was requested");
assert_eq!(
app.state.last_update_check, 0,
"an un-run check stamps no clock"
);
}
}