Check for updates at every start; show "Update to X" as a title-bar pill (#1538)

Thanks @bherbruck.
This commit is contained in:
bherbruck authored and GitHub committed 2026-10-16 00:07:41 -04:00
1 parent 4d862cd76c
commit 6c1a898e3d
11 files changed
+268 -50

No files matched your search

+1
View File
@@ -1139,6 +1139,7 @@
"update.available": "SolidCraft {version} ist verfügbar", "update.available": "SolidCraft {version} ist verfügbar",
"update.button": "Update", "update.button": "Update",
"update.button_tip": "SolidCraft {version} ist verfügbar", "update.button_tip": "SolidCraft {version} ist verfügbar",
"update.button_version": "Auf {version} aktualisieren",
"update.check_automatically": "Automatisch nach Updates suchen", "update.check_automatically": "Automatisch nach Updates suchen",
"update.check_now": "Jetzt suchen", "update.check_now": "Jetzt suchen",
"update.checking": "Suche läuft…", "update.checking": "Suche läuft…",
+1
View File
@@ -1409,6 +1409,7 @@
"update.available": "SolidCraft {version} is available", "update.available": "SolidCraft {version} is available",
"update.button": "Update", "update.button": "Update",
"update.button_tip": "SolidCraft {version} is available", "update.button_tip": "SolidCraft {version} is available",
"update.button_version": "Update to {version}",
"update.check_automatically": "Check for updates automatically", "update.check_automatically": "Check for updates automatically",
"update.check_now": "Check now", "update.check_now": "Check now",
"update.checking": "Checking…", "update.checking": "Checking…",
+1
View File
@@ -1235,6 +1235,7 @@
"update.available": "SolidCraft {version} est disponible", "update.available": "SolidCraft {version} est disponible",
"update.button": "Mettre à jour", "update.button": "Mettre à jour",
"update.button_tip": "SolidCraft {version} est disponible", "update.button_tip": "SolidCraft {version} est disponible",
"update.button_version": "Mettre à jour vers {version}",
"update.check_automatically": "Rechercher automatiquement les mises à jour", "update.check_automatically": "Rechercher automatiquement les mises à jour",
"update.check_now": "Vérifier maintenant", "update.check_now": "Vérifier maintenant",
"update.checking": "Vérification…", "update.checking": "Vérification…",
+1
View File
@@ -1409,6 +1409,7 @@
"update.available": "SolidCraft {version} が利用できます", "update.available": "SolidCraft {version} が利用できます",
"update.button": "アップデート", "update.button": "アップデート",
"update.button_tip": "SolidCraft {version} が利用できます", "update.button_tip": "SolidCraft {version} が利用できます",
"update.button_version": "{version} にアップデート",
"update.check_automatically": "アップデートを自動的に確認", "update.check_automatically": "アップデートを自動的に確認",
"update.check_now": "今すぐ確認", "update.check_now": "今すぐ確認",
"update.checking": "確認中…", "update.checking": "確認中…",
+1
View File
@@ -1139,6 +1139,7 @@
"update.available": "SolidCraft {version} 사용 가능", "update.available": "SolidCraft {version} 사용 가능",
"update.button": "업데이트", "update.button": "업데이트",
"update.button_tip": "SolidCraft {version} 사용 가능", "update.button_tip": "SolidCraft {version} 사용 가능",
"update.button_version": "{version}(으)로 업데이트",
"update.check_automatically": "업데이트 자동 확인", "update.check_automatically": "업데이트 자동 확인",
"update.check_now": "지금 확인", "update.check_now": "지금 확인",
"update.checking": "확인 중…", "update.checking": "확인 중…",
+1
View File
@@ -1066,6 +1066,7 @@
"update.available": "Доступна версія SolidCraft {version}", "update.available": "Доступна версія SolidCraft {version}",
"update.button": "Оновити", "update.button": "Оновити",
"update.button_tip": "Доступна версія SolidCraft {version}", "update.button_tip": "Доступна версія SolidCraft {version}",
"update.button_version": "Оновити до {version}",
"update.check_automatically": "Автоматично перевіряти оновлення", "update.check_automatically": "Автоматично перевіряти оновлення",
"update.check_now": "Перевірити зараз", "update.check_now": "Перевірити зараз",
"update.checking": "Перевірка…", "update.checking": "Перевірка…",
+1
View File
@@ -1409,6 +1409,7 @@
"update.available": "SolidCraft {version} 已发布", "update.available": "SolidCraft {version} 已发布",
"update.button": "更新", "update.button": "更新",
"update.button_tip": "SolidCraft {version} 已发布", "update.button_tip": "SolidCraft {version} 已发布",
"update.button_version": "更新到 {version}",
"update.check_automatically": "自动检查更新", "update.check_automatically": "自动检查更新",
"update.check_now": "立即检查", "update.check_now": "立即检查",
"update.checking": "正在检查…", "update.checking": "正在检查…",
+43 -18
View File
@@ -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) (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 /// The height of an application-bar pill ([`offer_pill`]), a little under the bar's 26 px buttons.
/// bar's free space): an accent-framed `icon`, labeled with `text` when `labeled` and it fits. pub(crate) const PILL_HEIGHT: f32 = 22.0;
/// None when even the icon alone doesn't fit: it never covers the bar's other buttons. `handle`
/// names it for scenarios. /// An application-bar pill for something on offer (Update) at the right of `span` (the bar's free
pub(crate) fn offer_button(ui: &mut egui::Ui, span: Rect, labeled: bool, text: &str, icon: &str, handle: &str) -> Option<(Rect, egui::Response)> { /// 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 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 font = FontId::proportional(12.5);
let labeled = labeled && full <= span.width(); let (icon_x, text_x, right_pad) = (16.0, 29.0, 11.0);
let width = if labeled { full } else { 28.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() { if width > span.width() {
return None; return None;
} }
let rect = Rect::from_min_size(pos2(span.right() - width, span.top()), vec2(width, 26.0)); 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_button", handle)), Sense::click()); let response = ui.interact(rect, ui.id().with(("sc_offer_pill", handle)), Sense::click());
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), text)); 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_handle(handle, rect.center());
crate::scenario::publish_rect(handle, rect); crate::scenario::publish_rect(handle, rect);
let fill = if response.hovered() || response.has_focus() { t.hover } else { t.app_bar }; let hot = response.hovered() || response.has_focus();
ui.painter().rect_filled(rect, 4.0, fill); let fill = if hot { t.accent.lerp_to_gamma(t.app_bar_text, 0.18) } else { t.accent };
ui.painter().rect_stroke(rect, 4.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); let radius = PILL_HEIGHT / 2.0;
let glyph = Rect::from_center_size(pos2(rect.left() + 14.0, rect.center().y), vec2(18.0, 18.0)); ui.painter().rect_filled(rect, radius, fill);
icons::paint(ui.painter(), glyph, icon, t.accent, t.accent, t.app_bar); if response.has_focus() {
if labeled { ui.painter().rect_stroke(rect.expand(2.0), radius + 2.0, Stroke::new(1.0, t.app_bar_text), egui::StrokeKind::Outside);
ui.painter().text(pos2(rect.left() + 29.0, rect.center().y), Align2::LEFT_CENTER, text, FontId::proportional(13.0), t.app_bar_text); }
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)) Some((rect, response))
} }
+34 -15
View File
@@ -1,5 +1,5 @@
//! Update notifications (docs/updates.md). At start, and at most once a day after that, a //! Update notifications (docs/book/src/guide/updates.md). At every start, and once a day after
//! background thread asks GitHub for the latest stable release (`releases/latest`; nightlies and //! 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 //! 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, //! 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. //! 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. /// Release pages and assets must be on GitHub, under this repository.
const RELEASE_PREFIX: &str = "https://github.com/bherbruck/solidcraft/releases/"; 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; pub const CHECK_INTERVAL_S: f64 = 24.0 * 3600.0;
/// Remind me later hides the button for three days. /// Remind me later hides the button for three days.
pub const REMIND_DELAY_S: f64 = 3.0 * 24.0 * 3600.0; 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). /// 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; 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; pub const RETRY_AFTER_FAILURE_S: f64 = 3600.0;
/// Caps on what a release response may hold (it comes from the network). /// Caps on what a release response may hold (it comes from the network).
const MAX_NOTES: usize = 64 * 1024; const MAX_NOTES: usize = 64 * 1024;
const MAX_ASSETS: usize = 400; 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. /// The Update window's width, and its release notes' wrap width.
const WINDOW_W: f32 = 460.0; const WINDOW_W: f32 = 460.0;
const NOTES_W: f32 = WINDOW_W - 30.0; const NOTES_W: f32 = WINDOW_W - 30.0;
@@ -407,7 +409,8 @@ impl Release {
pub struct Memo { pub struct Memo {
/// When the last check finished (successfully or not). /// When the last check finished (successfully or not).
pub last_check: f64, 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, pub next_check: f64,
/// GitHub's rate limit was reached: no request at all before this, not even Check now. /// GitHub's rate limit was reached: no request at all before this, not even Check now.
pub rate_limited_until: f64, pub rate_limited_until: f64,
@@ -418,9 +421,13 @@ pub struct Memo {
} }
impl Memo { impl Memo {
/// An automatic check is due (a clock set back by more than a day counts as due). /// An automatic check is due. `launch`: no automatic check has run since SolidCraft started,
pub fn due(&self, now: f64) -> bool { /// so one is due whatever the daily schedule says (someone who started yesterday's version
now >= self.rate_limited_until && (now >= self.next_check || self.next_check - now > CHECK_INTERVAL_S) /// 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`. /// The Update button shows for `release`.
@@ -484,6 +491,10 @@ pub struct Updater {
/// The check in flight, and whether Check now started it. /// The check in flight, and whether Check now started it.
rx: Option<(Receiver<Result<Release, FetchError>>, bool)>, rx: Option<(Receiver<Result<Release, FetchError>>, bool)>,
started: 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). /// When Check now was last pressed (Unix seconds).
last_manual: f64, last_manual: f64,
/// A release given by `SOLIDCRAFT_UPDATE_FEED` (a file), for tests and screenshots. /// 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. /// Checks may run here: a release build with the host's HTTP client, or a test feed.
pub fn available(app: &mut SolveApp) -> bool { 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) { pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
poll_install(app); poll_install(app);
// Quitting to restart or install was cancelled (a "save changes?" answered Cancel). // 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()); 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") { if std::env::var("SOLIDCRAFT_UPDATE_CHECK").is_ok_and(|v| v == "0") {
app.updater.off = true;
return; return;
} }
app.updater.feed = std::env::var_os("SOLIDCRAFT_UPDATE_FEED").map(PathBuf::from); 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); poll(app);
let now = unix_now(); 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); start(app, ctx, false);
} }
} }
@@ -714,11 +729,15 @@ pub fn take_launch(app: &mut SolveApp) -> Option<(PathBuf, Vec<String>)> {
// ---- user interface --------------------------------------------------------------------------- // ---- 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<egui::Rect> { pub fn button(app: &mut SolveApp, ui: &mut egui::Ui, span: egui::Rect, labeled: bool) -> Option<egui::Rect> {
offered(app)?; let version = offered(app)?.version().to_string();
let (rect, response) = crate::toolbar::offer_button(ui, span, labeled, crate::i18n::tr("update.button"), "update", "titlebar:update")?; // "Update to 0.14.0" while the document tabs keep room; a short "Update" when they would be
let version = app.updater.release.as_ref().map(|r| r.version().to_string()).unwrap_or_default(); // 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() { if response.on_hover_text(crate::i18n::format("update.button_tip", &[("version", &version)])).clicked() {
app.updater.open = !app.updater.open; app.updater.open = !app.updater.open;
} }
+172 -10
View File
@@ -217,24 +217,186 @@ fn skip_and_remind_later() {
} }
#[test] #[test]
fn checks_run_daily_and_back_off() { fn every_launch_checks_and_the_open_app_checks_daily() {
let mut m = Memo::default(); let mut m = Memo::default();
let now = 1_800_000_000.0; 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())); m.checked(now, &Ok(release()));
assert!(!m.due(now + 3600.0)); // Started this morning, checked then: the next start checks again at once.
assert!(m.due(now + CHECK_INTERVAL_S)); assert!(m.due(now + 3600.0, true), "every start checks, whatever the daily schedule says");
assert!(m.due(now - 2.0 * CHECK_INTERVAL_S), "a clock set back doesn't block checks for days"); 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()))); m.checked(now, &Err(FetchError::Failed("offline".into())));
assert!(!m.due(now + 60.0)); assert!(!m.due(now + 60.0, false));
assert!(m.due(now + RETRY_AFTER_FAILURE_S)); 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 })); 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); 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. // Absurd waits are capped at a day.
m.checked(now, &Err(FetchError::RateLimited { retry_after_s: u64::MAX })); 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 <version>", 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] #[test]
+12 -7
View File
@@ -5,15 +5,20 @@ the check sends nothing about you or your designs.
## What you see ## What you see
When a newer stable release exists, an **Update** button appears in the title bar next to When a newer stable release exists, a small accent-coloured pill reading **Update to 0.14.0**
**Report a bug**. It opens a small window with the new version, its release notes and three (the new version) appears in the title bar next to **Report a bug**. Nothing pops up by itself:
choices: 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 - **Download** opens the file that fits your installation (see the table below) in your
browser, or the release page when there isn't one. 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. - **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 **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. **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 ## 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 ```text
GET https://api.github.com/repos/bherbruck/solidcraft/releases/latest 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. 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 - 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 - 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. - 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 - Setting the environment variable `SOLIDCRAFT_UPDATE_CHECK=0` turns all checks off, for
example on managed machines. example on managed machines.