diff --git a/src/update_check.rs b/src/update_check.rs index e65b05e8c..0966784c3 100644 --- a/src/update_check.rs +++ b/src/update_check.rs @@ -11,6 +11,13 @@ const RELEASES_API: &str = pub const RELEASES_PAGE: &str = "https://github.com/HakanSeven12/OpenCADStudio/releases/latest"; +/// Minimum age before a freshly-published release is offered to the user. +/// GitHub Actions takes ~15 min to build and attach the platform binaries +/// after a tag is pushed, so suppressing notifications for the first 30 +/// minutes prevents users from clicking through to a release page whose +/// asset list is still empty. +const MIN_RELEASE_AGE_SECS: u64 = 30 * 60; + /// What `check_for_update` reports when a newer release exists. #[derive(Debug, Clone)] pub struct UpdateInfo { @@ -48,11 +55,70 @@ fn fetch_latest_if_outdated() -> Option { if latest == env!("CARGO_PKG_VERSION") { return None; } + // Suppress the notification until the release is old enough for the + // Actions build to have published binaries. + if let Some(published) = extract_string_field(&body, "published_at") + .as_deref() + .and_then(parse_iso8601_utc) + { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok()? + .as_secs(); + if now.saturating_sub(published) < MIN_RELEASE_AGE_SECS { + return None; + } + } // Release notes are optional; treat missing as empty. let notes = extract_string_field(&body, "body").unwrap_or_default(); Some(UpdateInfo { version: latest, body: notes }) } +/// Parse a GitHub timestamp like `2026-05-29T12:34:56Z` into UNIX seconds. +/// Only handles the fixed `YYYY-MM-DDTHH:MM:SSZ` format the GitHub API +/// emits; returns `None` for anything else. +fn parse_iso8601_utc(s: &str) -> Option { + let b = s.as_bytes(); + if b.len() != 20 || b[4] != b'-' || b[7] != b'-' || b[10] != b'T' + || b[13] != b':' || b[16] != b':' || b[19] != b'Z' + { + return None; + } + let year: i32 = s.get(0..4)?.parse().ok()?; + let month: u32 = s.get(5..7)?.parse().ok()?; + let day: u32 = s.get(8..10)?.parse().ok()?; + let hour: u32 = s.get(11..13)?.parse().ok()?; + let minute: u32 = s.get(14..16)?.parse().ok()?; + let second: u32 = s.get(17..19)?.parse().ok()?; + if !(1..=12).contains(&month) + || !(1..=31).contains(&day) + || hour >= 24 + || minute >= 60 + || second >= 60 + { + return None; + } + // Howard Hinnant's days_from_civil — converts a proleptic-Gregorian + // (Y, M, D) to a count of days since 1970-01-01. + let y = if month <= 2 { year - 1 } else { year }; + let era = y.div_euclid(400); + let yoe = (y - era * 400) as u32; + let m = month as i32; + let d = day as i32; + let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy as u32; + let days_since_epoch = era as i64 * 146097 + doe as i64 - 719468; + if days_since_epoch < 0 { + return None; + } + Some( + days_since_epoch as u64 * 86_400 + + hour as u64 * 3_600 + + minute as u64 * 60 + + second as u64, + ) +} + /// Minimal extractor for a top-level string field in the releases JSON. /// Avoids pulling in `serde_json` for two fields. Handles standard JSON /// string escapes (`\"`, `\\`, `\n`, `\r`, `\t`, `\/`) which are all the