From 6c1a898e3d48421027e02bf724b274ccc269ca02 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Fri, 16 Oct 2026 00:07:41 -0400 Subject: [PATCH] Check for updates at every start; show "Update to X" as a title-bar pill (#1538) Thanks @bherbruck. --- assets/locales/de.json | 1 + assets/locales/en.json | 1 + assets/locales/fr.json | 1 + assets/locales/ja.json | 1 + assets/locales/ko.json | 1 + assets/locales/uk.json | 1 + assets/locales/zh.json | 1 + crates/ui-egui/src/toolbar.rs | 61 +++++++--- crates/ui-egui/src/update.rs | 49 +++++--- crates/ui-egui/src/update/tests.rs | 182 +++++++++++++++++++++++++++-- docs/book/src/guide/updates.md | 19 +-- 11 files changed, 268 insertions(+), 50 deletions(-) diff --git a/assets/locales/de.json b/assets/locales/de.json index d291fa09..37247a44 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -1139,6 +1139,7 @@ "update.available": "SolidCraft {version} ist verfügbar", "update.button": "Update", "update.button_tip": "SolidCraft {version} ist verfügbar", + "update.button_version": "Auf {version} aktualisieren", "update.check_automatically": "Automatisch nach Updates suchen", "update.check_now": "Jetzt suchen", "update.checking": "Suche läuft…", diff --git a/assets/locales/en.json b/assets/locales/en.json index fab0d438..97a63780 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -1409,6 +1409,7 @@ "update.available": "SolidCraft {version} is available", "update.button": "Update", "update.button_tip": "SolidCraft {version} is available", + "update.button_version": "Update to {version}", "update.check_automatically": "Check for updates automatically", "update.check_now": "Check now", "update.checking": "Checking…", diff --git a/assets/locales/fr.json b/assets/locales/fr.json index acb46e48..be28647c 100644 --- a/assets/locales/fr.json +++ b/assets/locales/fr.json @@ -1235,6 +1235,7 @@ "update.available": "SolidCraft {version} est disponible", "update.button": "Mettre à jour", "update.button_tip": "SolidCraft {version} est disponible", + "update.button_version": "Mettre à jour vers {version}", "update.check_automatically": "Rechercher automatiquement les mises à jour", "update.check_now": "Vérifier maintenant", "update.checking": "Vérification…", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 676c707b..1a454e41 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -1409,6 +1409,7 @@ "update.available": "SolidCraft {version} が利用できます", "update.button": "アップデート", "update.button_tip": "SolidCraft {version} が利用できます", + "update.button_version": "{version} にアップデート", "update.check_automatically": "アップデートを自動的に確認", "update.check_now": "今すぐ確認", "update.checking": "確認中…", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 877fbe9c..29fd3eb0 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -1139,6 +1139,7 @@ "update.available": "SolidCraft {version} 사용 가능", "update.button": "업데이트", "update.button_tip": "SolidCraft {version} 사용 가능", + "update.button_version": "{version}(으)로 업데이트", "update.check_automatically": "업데이트 자동 확인", "update.check_now": "지금 확인", "update.checking": "확인 중…", diff --git a/assets/locales/uk.json b/assets/locales/uk.json index b8c77211..ce842bcb 100644 --- a/assets/locales/uk.json +++ b/assets/locales/uk.json @@ -1066,6 +1066,7 @@ "update.available": "Доступна версія SolidCraft {version}", "update.button": "Оновити", "update.button_tip": "Доступна версія SolidCraft {version}", + "update.button_version": "Оновити до {version}", "update.check_automatically": "Автоматично перевіряти оновлення", "update.check_now": "Перевірити зараз", "update.checking": "Перевірка…", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 8a4115b7..b4082881 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -1409,6 +1409,7 @@ "update.available": "SolidCraft {version} 已发布", "update.button": "更新", "update.button_tip": "SolidCraft {version} 已发布", + "update.button_version": "更新到 {version}", "update.check_automatically": "自动检查更新", "update.check_now": "立即检查", "update.checking": "正在检查…", diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index effc2bfe..8407fc22 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -166,30 +166,55 @@ fn report_rect(bar: Rect, captions: f32, tabs_start: f32, label_width: f32) -> ( (Rect::from_min_size(pos2(right - width, bar.top() + 4.0), vec2(width, 26.0)), labeled) } -/// An application-bar button for something on offer (Update, Install) at the right of `span` (the -/// bar's free space): an accent-framed `icon`, labeled with `text` when `labeled` and it fits. -/// None when even the icon alone doesn't fit: it never covers the bar's other buttons. `handle` -/// names it for scenarios. -pub(crate) fn offer_button(ui: &mut egui::Ui, span: Rect, labeled: bool, text: &str, icon: &str, handle: &str) -> Option<(Rect, egui::Response)> { +/// The height of an application-bar pill ([`offer_pill`]), a little under the bar's 26 px buttons. +pub(crate) const PILL_HEIGHT: f32 = 22.0; + +/// An application-bar pill for something on offer (Update) at the right of `span` (the bar's free +/// space): accent-filled, with `icon` and the first of `labels` that fits (the icon alone when +/// none does or `labeled` is false). Every label but the last also leaves `keep` points of the +/// span free (for the document tabs). None when even the icon doesn't fit: it never covers the +/// bar's other buttons. `handle` names it for scenarios. +pub(crate) fn offer_pill( + ui: &mut egui::Ui, + span: Rect, + labeled: bool, + labels: &[&str], + keep: f32, + icon: &str, + handle: &str, +) -> Option<(Rect, egui::Response)> { let t = Tokens::get(); - let full = 30.0 + ui.fonts_mut(|f| f.layout_no_wrap(text.to_string(), FontId::proportional(13.0), t.app_bar_text).size().x) + 10.0; - let labeled = labeled && full <= span.width(); - let width = if labeled { full } else { 28.0 }; + let font = FontId::proportional(12.5); + let (icon_x, text_x, right_pad) = (16.0, 29.0, 11.0); + let last = labels.len().saturating_sub(1); + let label = labels.iter().enumerate().filter(|_| labeled).find_map(|(i, text)| { + let galley = ui.fonts_mut(|f| f.layout_no_wrap(text.to_string(), font.clone(), t.app_bar_text)); + let width = text_x + galley.size().x + right_pad; + let room = if i < last { span.width() - keep } else { span.width() }; + (width <= room).then_some((*text, width)) + }); + let width = label.map_or(PILL_HEIGHT + 4.0, |(_, w)| w); if width > span.width() { return None; } - let rect = Rect::from_min_size(pos2(span.right() - width, span.top()), vec2(width, 26.0)); - let response = ui.interact(rect, ui.id().with(("sc_offer_button", handle)), Sense::click()); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), text)); + let rect = Rect::from_min_size(pos2(span.right() - width, span.center().y - PILL_HEIGHT / 2.0), vec2(width, PILL_HEIGHT)); + let response = ui.interact(rect, ui.id().with(("sc_offer_pill", handle)), Sense::click()); + let name = label.map(|(text, _)| text).or(labels.first().copied()).unwrap_or_default().to_string(); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &name)); crate::scenario::publish_handle(handle, rect.center()); crate::scenario::publish_rect(handle, rect); - let fill = if response.hovered() || response.has_focus() { t.hover } else { t.app_bar }; - ui.painter().rect_filled(rect, 4.0, fill); - ui.painter().rect_stroke(rect, 4.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); - let glyph = Rect::from_center_size(pos2(rect.left() + 14.0, rect.center().y), vec2(18.0, 18.0)); - icons::paint(ui.painter(), glyph, icon, t.accent, t.accent, t.app_bar); - if labeled { - ui.painter().text(pos2(rect.left() + 29.0, rect.center().y), Align2::LEFT_CENTER, text, FontId::proportional(13.0), t.app_bar_text); + let hot = response.hovered() || response.has_focus(); + let fill = if hot { t.accent.lerp_to_gamma(t.app_bar_text, 0.18) } else { t.accent }; + let radius = PILL_HEIGHT / 2.0; + ui.painter().rect_filled(rect, radius, fill); + if response.has_focus() { + ui.painter().rect_stroke(rect.expand(2.0), radius + 2.0, Stroke::new(1.0, t.app_bar_text), egui::StrokeKind::Outside); + } + let glyph_x = if label.is_some() { rect.left() + icon_x } else { rect.center().x }; + let glyph = Rect::from_center_size(pos2(glyph_x, rect.center().y), vec2(16.0, 16.0)); + icons::paint(ui.painter(), glyph, icon, t.app_bar_text, fill, t.app_bar_text); + if let Some((text, _)) = label { + ui.painter().text(pos2(rect.left() + text_x, rect.center().y), Align2::LEFT_CENTER, text, font, t.app_bar_text); } Some((rect, response)) } diff --git a/crates/ui-egui/src/update.rs b/crates/ui-egui/src/update.rs index b49c6003..508f72b4 100644 --- a/crates/ui-egui/src/update.rs +++ b/crates/ui-egui/src/update.rs @@ -1,5 +1,5 @@ -//! Update notifications (docs/updates.md). At start, and at most once a day after that, a -//! background thread asks GitHub for the latest stable release (`releases/latest`; nightlies and +//! Update notifications (docs/book/src/guide/updates.md). At every start, and once a day after +//! that while the app stays open, a background thread asks GitHub for the latest stable release (`releases/latest`; nightlies and //! release candidates are never "latest"). When it is newer than this build, an Update button //! appears in the application bar beside Report a bug; it opens a small window with the version, //! the release notes and Download, Skip this version and Remind me later. @@ -32,17 +32,19 @@ pub const RELEASES_PAGE: &str = "https://github.com/bherbruck/solidcraft/release /// Release pages and assets must be on GitHub, under this repository. const RELEASE_PREFIX: &str = "https://github.com/bherbruck/solidcraft/releases/"; -/// Automatic checks: at most one a day. +/// While the app stays open, automatic checks run once a day (every start checks too). pub const CHECK_INTERVAL_S: f64 = 24.0 * 3600.0; /// Remind me later hides the button for three days. pub const REMIND_DELAY_S: f64 = 3.0 * 24.0 * 3600.0; /// Check now can't be pressed more often than this (GitHub allows 60 anonymous requests an hour). pub const MANUAL_COOLDOWN_S: f64 = 30.0; -/// After a failed automatic check, try again after this long (an hour), not every start. +/// After a failed automatic check, the open app tries again after this long (an hour). pub const RETRY_AFTER_FAILURE_S: f64 = 3600.0; /// Caps on what a release response may hold (it comes from the network). const MAX_NOTES: usize = 64 * 1024; const MAX_ASSETS: usize = 400; +/// The room the Update pill's long label leaves for the document tabs. +const TAB_ROOM: f32 = 300.0; /// The Update window's width, and its release notes' wrap width. const WINDOW_W: f32 = 460.0; const NOTES_W: f32 = WINDOW_W - 30.0; @@ -407,7 +409,8 @@ impl Release { pub struct Memo { /// When the last check finished (successfully or not). pub last_check: f64, - /// The next automatic check: a day after a successful one, an hour after a failed one. + /// The next automatic check while the app stays open: a day after a successful one, an hour + /// after a failed one. A new start doesn't wait for it. pub next_check: f64, /// GitHub's rate limit was reached: no request at all before this, not even Check now. pub rate_limited_until: f64, @@ -418,9 +421,13 @@ pub struct Memo { } impl Memo { - /// An automatic check is due (a clock set back by more than a day counts as due). - pub fn due(&self, now: f64) -> bool { - now >= self.rate_limited_until && (now >= self.next_check || self.next_check - now > CHECK_INTERVAL_S) + /// An automatic check is due. `launch`: no automatic check has run since SolidCraft started, + /// so one is due whatever the daily schedule says (someone who started yesterday's version + /// this morning hears about today's release at the next start). GitHub's rate limit always + /// holds. A clock set back by more than a day counts as due. + pub fn due(&self, now: f64, launch: bool) -> bool { + let limited = now < self.rate_limited_until && self.rate_limited_until - now <= CHECK_INTERVAL_S; + !limited && (launch || now >= self.next_check || self.next_check - now > CHECK_INTERVAL_S) } /// The Update button shows for `release`. @@ -484,6 +491,10 @@ pub struct Updater { /// The check in flight, and whether Check now started it. rx: Option<(Receiver>, bool)>, started: bool, + /// An automatic check has started since launch (until then one is due at once). + launch_checked: bool, + /// `SOLIDCRAFT_UPDATE_CHECK=0`: no checks at all. + off: bool, /// When Check now was last pressed (Unix seconds). last_manual: f64, /// A release given by `SOLIDCRAFT_UPDATE_FEED` (a file), for tests and screenshots. @@ -511,10 +522,11 @@ pub fn offered(app: &SolveApp) -> Option<&Release> { /// Checks may run here: a release build with the host's HTTP client, or a test feed. pub fn available(app: &mut SolveApp) -> bool { - app.updater.feed.is_some() || (app.services.http_get.is_some() && app.updater.kind().checks()) + !app.updater.off && (app.updater.feed.is_some() || (app.services.http_get.is_some() && app.updater.kind().checks())) } -/// Per frame: the first frame starts a due automatic check; a finished check is collected. +/// Per frame: the first frame of each launch starts an automatic check, the open app checks +/// again once a day (an hour after a failure), and a finished check is collected. pub fn tick(app: &mut SolveApp, ctx: &egui::Context) { poll_install(app); // Quitting to restart or install was cancelled (a "save changes?" answered Cancel). @@ -527,6 +539,7 @@ pub fn tick(app: &mut SolveApp, ctx: &egui::Context) { { install::clean_up_after_update(std::env::current_exe().ok().as_deref()); if std::env::var("SOLIDCRAFT_UPDATE_CHECK").is_ok_and(|v| v == "0") { + app.updater.off = true; return; } app.updater.feed = std::env::var_os("SOLIDCRAFT_UPDATE_FEED").map(PathBuf::from); @@ -534,7 +547,9 @@ pub fn tick(app: &mut SolveApp, ctx: &egui::Context) { } poll(app); let now = unix_now(); - if app.updater.rx.is_none() && app.preferences.check_updates && app.preferences.update.due(now) && available(app) { + let launch = !app.updater.launch_checked; + if app.updater.rx.is_none() && app.preferences.check_updates && app.preferences.update.due(now, launch) && available(app) { + app.updater.launch_checked = true; start(app, ctx, false); } } @@ -714,11 +729,15 @@ pub fn take_launch(app: &mut SolveApp) -> Option<(PathBuf, Vec)> { // ---- user interface --------------------------------------------------------------------------- -/// The application bar's Update button, at the right of `span`; returns its rect (None: not shown). +/// The application bar's Update pill ("Update to 0.14.0"), at the right of `span`: it opens and +/// closes the Update window. Returns its rect (None: not shown). pub fn button(app: &mut SolveApp, ui: &mut egui::Ui, span: egui::Rect, labeled: bool) -> Option { - offered(app)?; - let (rect, response) = crate::toolbar::offer_button(ui, span, labeled, crate::i18n::tr("update.button"), "update", "titlebar:update")?; - let version = app.updater.release.as_ref().map(|r| r.version().to_string()).unwrap_or_default(); + let version = offered(app)?.version().to_string(); + // "Update to 0.14.0" while the document tabs keep room; a short "Update" when they would be + // squeezed; the icon alone in a narrow bar. + let full = crate::i18n::format("update.button_version", &[("version", &version)]); + let labels = [full.as_str(), crate::i18n::tr("update.button")]; + let (rect, response) = crate::toolbar::offer_pill(ui, span, labeled, &labels, TAB_ROOM, "update", "titlebar:update")?; if response.on_hover_text(crate::i18n::format("update.button_tip", &[("version", &version)])).clicked() { app.updater.open = !app.updater.open; } diff --git a/crates/ui-egui/src/update/tests.rs b/crates/ui-egui/src/update/tests.rs index 94eb05f3..9bcab45a 100644 --- a/crates/ui-egui/src/update/tests.rs +++ b/crates/ui-egui/src/update/tests.rs @@ -217,24 +217,186 @@ fn skip_and_remind_later() { } #[test] -fn checks_run_daily_and_back_off() { +fn every_launch_checks_and_the_open_app_checks_daily() { let mut m = Memo::default(); let now = 1_800_000_000.0; - assert!(m.due(now), "the first start checks"); + assert!(m.due(now, true), "the first start checks"); m.checked(now, &Ok(release())); - assert!(!m.due(now + 3600.0)); - assert!(m.due(now + CHECK_INTERVAL_S)); - assert!(m.due(now - 2.0 * CHECK_INTERVAL_S), "a clock set back doesn't block checks for days"); + // Started this morning, checked then: the next start checks again at once. + assert!(m.due(now + 3600.0, true), "every start checks, whatever the daily schedule says"); + assert!(m.due(now + 5.0, true)); + // While it stays open: once a day. + assert!(!m.due(now + 3600.0, false)); + assert!(m.due(now + CHECK_INTERVAL_S, false)); + assert!(m.due(now - 2.0 * CHECK_INTERVAL_S, false), "a clock set back doesn't block checks for days"); + // A failed check: the open app tries again an hour later; a new start checks anyway. m.checked(now, &Err(FetchError::Failed("offline".into()))); - assert!(!m.due(now + 60.0)); - assert!(m.due(now + RETRY_AFTER_FAILURE_S)); + assert!(!m.due(now + 60.0, false)); + assert!(m.due(now + RETRY_AFTER_FAILURE_S, false)); + assert!(m.due(now + 60.0, true)); +} + +#[test] +fn the_rate_limit_holds_at_launch_too() { + let mut m = Memo::default(); + let now = 1_800_000_000.0; m.checked(now, &Err(FetchError::RateLimited { retry_after_s: 1800 })); - assert!(!m.due(now + 1700.0)); - assert!(m.due(now + 1800.0)); assert_eq!(m.rate_limited_until, now + 1800.0); + for launch in [false, true] { + assert!(!m.due(now + 1700.0, launch), "GitHub said to wait (launch: {launch})"); + assert!(m.due(now + 1800.0, launch)); + } // Absurd waits are capped at a day. m.checked(now, &Err(FetchError::RateLimited { retry_after_s: u64::MAX })); - assert!(m.due(now + CHECK_INTERVAL_S)); + assert!(!m.due(now + 3600.0, true)); + assert!(m.due(now + CHECK_INTERVAL_S, false)); + // A clock set back more than a day doesn't keep the limit for days. + assert!(m.due(now - 2.0 * CHECK_INTERVAL_S, true)); +} + +/// A fresh folder for a test's feed file, removed with it. +struct Scratch(PathBuf); + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn scratch() -> Scratch { + static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("solidcraft-update-test-{}-{n}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + Scratch(dir) +} + +/// A release one minor version past this build, as the `SOLIDCRAFT_UPDATE_FEED` file gives it. +fn next_release_feed(dir: &Path) -> (PathBuf, String) { + let mut v = parse_version(current_version()).unwrap(); + v = semver::Version::new(v.major, v.minor + 1, 0); + let path = dir.join("latest.json"); + let body = json!({ + "tag_name": format!("v{v}"), + "name": format!("SolidCraft {v}"), + "body": "## What's new\n\n- Faster rebuilds.", + "html_url": format!("https://github.com/bherbruck/solidcraft/releases/tag/v{v}"), + "assets": [] + }); + std::fs::write(&path, body.to_string()).unwrap(); + (path, v.to_string()) +} + +/// Frames until the check in flight has been collected. +fn wait_for_check(h: &mut crate::scenario::Harness) { + for _ in 0..500 { + h.frame(); + if h.app.updater.rx.is_none() && h.app.updater.status != Status::Checking { + return; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + panic!("the update check didn't finish"); +} + +#[test] +fn a_new_launch_checks_even_when_the_last_check_was_today() { + let dir = scratch(); + let (feed, version) = next_release_feed(&dir.0); + let mut h = crate::scenario::Harness::new(); + // Yesterday's version was started and checked this morning: no check due until tomorrow. + let now = unix_now(); + h.app.preferences.update.last_check = now - 3600.0; + h.app.preferences.update.next_check = now + CHECK_INTERVAL_S - 3600.0; + h.app.updater.feed = Some(feed); + h.frame(); + assert_eq!(h.app.updater.status, Status::Checking, "the launch checks at once"); + wait_for_check(&mut h); + assert_eq!(h.app.updater.release.as_ref().map(|r| r.version().to_string()), Some(version.clone())); + assert!(h.app.preferences.update.next_check > now + CHECK_INTERVAL_S - 60.0, "the next check is a day later"); + // While the app stays open it doesn't ask again before then. + h.frames(5); + assert!(h.app.updater.rx.is_none() && h.app.updater.status == Status::Idle); +} + +#[test] +fn turning_off_automatic_checks_stops_the_launch_check() { + let dir = scratch(); + let (feed, _) = next_release_feed(&dir.0); + let mut h = crate::scenario::Harness::new(); + h.app.preferences.check_updates = false; + h.app.updater.feed = Some(feed); + h.frames(3); + assert_eq!(h.app.updater.status, Status::Idle); + assert!(h.app.updater.release.is_none()); +} + +#[test] +fn the_title_bar_offers_the_new_version_and_opens_the_update_window() { + let dir = scratch(); + let (feed, version) = next_release_feed(&dir.0); + let mut h = crate::scenario::Harness::new(); + let report = crate::scenario::rect_of("titlebar:report").unwrap(); + assert!(crate::scenario::rect_of("titlebar:update").is_none(), "nothing offered before a check"); + h.app.updater.feed = Some(feed); + h.frame(); + wait_for_check(&mut h); + h.frames(2); + // An accent pill reading "Update to ", just left of Report a bug, on the bar. + let label = format!("Update to {version}"); + let at = h.widget(&label).unwrap_or_else(|| panic!("no `{label}` in the title bar")); + let pill = crate::scenario::rect_of("titlebar:update").unwrap(); + assert!(pill.contains(egui::pos2(at[0] as f32, at[1] as f32))); + assert!(pill.right() <= report.left() && report.left() - pill.right() < 12.0, "beside Report a bug: {pill:?} {report:?}"); + assert!((pill.center().y - report.center().y).abs() < 1.0 && pill.height() <= report.height()); + assert_eq!(crate::scenario::rect_of("titlebar:report"), Some(report), "Report a bug stays where it was"); + assert!(!h.app.updater.open, "no window pops up by itself"); + assert!(crate::scenario::rect_of("update:window").is_none()); + // Clicking it opens the Update window (not a modal: the bar stays usable). + h.call("ui.click", json!({"x": pill.center().x, "y": pill.center().y})); + h.frames(2); + assert!(h.app.updater.open); + assert!(h.widget("Skip this version").is_some() && h.widget("Remind me later").is_some()); + // Remind me later hides the pill. + let later = h.widget("Remind me later").unwrap(); + h.call("ui.click", json!({"x": later[0], "y": later[1]})); + h.frames(2); + assert!(!h.app.updater.open); + assert!(h.widget(&label).is_none(), "Remind me later hides it"); +} + +#[test] +fn the_pill_shortens_in_a_narrow_title_bar() { + let dir = scratch(); + let (feed, version) = next_release_feed(&dir.0); + let mut h = crate::scenario::Harness::new(); + h.app.updater.feed = Some(feed); + h.frame(); + wait_for_check(&mut h); + let wide = crate::scenario::rect_of("titlebar:update").unwrap(); + let label = format!("Update to {version}"); + assert!(h.widget(&label).is_some()); + // Narrower and narrower: the pill never grows, never reaches Report a bug, and its label gives + // way (to "Update", then the icon alone) before the document tabs run out of room. + let mut last = wide.width(); + let mut shortened = false; + for width in [1000.0, 800.0, 700.0, 600.0, 520.0, 440.0] { + h.call("ui.resize", json!({"width": width, "height": 600})); + h.frames(3); + let report = crate::scenario::rect_of("titlebar:report").unwrap(); + // Icon alone, it keeps the full text for screen readers. + let pill = crate::scenario::rect_of("titlebar:update").filter(|_| h.widget(&label).is_some() || h.widget("Update").is_some()); + if let Some(p) = pill { + assert!(p.width() <= last + 0.5, "at {width}: {p:?} grew from {last}"); + assert!(p.right() <= report.left(), "at {width}: {p:?} overlaps Report a bug {report:?}"); + last = p.width(); + } + shortened |= pill.is_none_or(|p| p.width() < wide.width()); + if width == 800.0 { + assert!(h.widget("Update").is_some() && h.widget(&label).is_none(), "the short label keeps room for the tabs"); + } + } + assert!(shortened, "the long label gives way in a narrow bar"); } #[test] diff --git a/docs/book/src/guide/updates.md b/docs/book/src/guide/updates.md index 9dd8aec3..45307590 100644 --- a/docs/book/src/guide/updates.md +++ b/docs/book/src/guide/updates.md @@ -5,15 +5,20 @@ the check sends nothing about you or your designs. ## What you see -When a newer stable release exists, an **Update** button appears in the title bar next to -**Report a bug**. It opens a small window with the new version, its release notes and three -choices: +When a newer stable release exists, a small accent-coloured pill reading **Update to 0.14.0** +(the new version) appears in the title bar next to **Report a bug**. Nothing pops up by itself: +clicking the pill opens a small window with the new version, its release notes and three +choices. In a narrower window the pill shortens to **Update** so the document tabs keep their +room, then to its icon alone. - **Download** opens the file that fits your installation (see the table below) in your browser, or the release page when there isn't one. -- **Skip this version** hides the button until an even newer release comes out. +- **Skip this version** hides the pill until an even newer release comes out. - **Remind me later** hides it for three days. +SolidCraft checks every time it starts, so a release that came out since you last opened it shows +up straight away, and again once a day while it stays open. + **Preferences ▸ General ▸ Updates** has **Check for updates automatically** (on by default) and **Check now**, which checks right away and shows the window even for a version you skipped. @@ -43,7 +48,7 @@ fails with a message, and **Download** is the way to update it. ## What is sent -One HTTPS request, at start and at most once a day while SolidCraft runs: +One HTTPS request each time SolidCraft starts, and once a day while it stays open: ```text GET https://api.github.com/repos/bherbruck/solidcraft/releases/latest @@ -60,9 +65,9 @@ sees the request like any other visit to the API (your IP address, the time); se SolidCraft compares the answer with its own version on your computer. - Offline, or GitHub doesn't answer within 15 seconds: nothing is shown, and the check is tried - again an hour later. + again an hour later (or at the next start, whichever comes first). - GitHub's rate limit for anonymous requests (60 an hour per address) is respected: when GitHub - says to wait, SolidCraft waits that long, Check now included. + says to wait, SolidCraft waits that long, Check now and the check at start included. - Turning off **Check for updates automatically** stops the checks; only **Check now** asks. - Setting the environment variable `SOLIDCRAFT_UPDATE_CHECK=0` turns all checks off, for example on managed machines.