| 1 | //! The startup update check: resolving a latest-release tag from the |
| 2 | //! configured source and turning it into a version hint. |
| 3 | //! |
| 4 | //! Moved verbatim out of `ui.rs`. |
| 5 | |
| 6 | use super::*; |
| 7 | |
| 8 | pub(crate) fn startup_version_check_source(config: &UpdateConfig) -> StartupVersionCheckSource { |
| 9 | resolve_version_check_source(config, codewhale_release::suppression_reason()) |
| 10 | } |
| 11 | |
| 12 | /// Pure form of [`startup_version_check_source`]: the environment is read once |
| 13 | /// by the caller and passed in, so the decision itself is testable without |
| 14 | /// mutating process-global state (and so this crate's own CI run does not |
| 15 | /// change the answer). |
| 16 | pub(crate) fn resolve_version_check_source( |
| 17 | config: &UpdateConfig, |
| 18 | suppression: Option<codewhale_release::SuppressionReason>, |
| 19 | ) -> StartupVersionCheckSource { |
| 20 | if !config.check_for_updates { |
| 21 | return StartupVersionCheckSource::Disabled; |
| 22 | } |
| 23 | // CI runners and explicit opt-outs never reach the network: there is |
| 24 | // nobody at the terminal to read the notice, and an unexplained outbound |
| 25 | // request from a build agent is a support ticket waiting to happen. |
| 26 | if let Some(reason) = suppression { |
| 27 | tracing::debug!( |
| 28 | variable = reason.variable(), |
| 29 | "skipping startup update check" |
| 30 | ); |
| 31 | return StartupVersionCheckSource::Disabled; |
| 32 | } |
| 33 | if let Some(update_uri) = config.update_uri() { |
| 34 | return StartupVersionCheckSource::ConfiguredUrl(update_uri.to_string()); |
| 35 | } |
| 36 | StartupVersionCheckSource::ReleaseResolver |
| 37 | } |
| 38 | |
| 39 | /// Where the throttling cache for the startup check lives. |
| 40 | /// |
| 41 | /// `None` when the CodeWhale home cannot be resolved — the check still runs, |
| 42 | /// it just cannot be throttled, which is the right tradeoff for a homeless |
| 43 | /// install (rare, and better than never checking). |
| 44 | pub(crate) fn update_check_cache_path() -> Option<PathBuf> { |
| 45 | codewhale_config::codewhale_home() |
| 46 | .ok() |
| 47 | .map(|home| codewhale_release::check::cache_path_in(&home)) |
| 48 | } |
| 49 | |
| 50 | pub(crate) fn spawn_startup_version_check( |
| 51 | config: UpdateConfig, |
| 52 | ) -> Option<tokio::task::JoinHandle<Option<UpdateNotice>>> { |
| 53 | let source = startup_version_check_source(&config); |
| 54 | if source == StartupVersionCheckSource::Disabled { |
| 55 | return None; |
| 56 | } |
| 57 | |
| 58 | let current = env!("CARGO_PKG_VERSION").to_string(); |
| 59 | let cache_path = update_check_cache_path(); |
| 60 | let interval_hours = config.check_interval_hours; |
| 61 | Some(tokio::spawn(async move { |
| 62 | cached_version_hint(source, ¤t, cache_path.as_deref(), interval_hours).await |
| 63 | })) |
| 64 | } |
| 65 | |
| 66 | /// Resolve the update notice, reaching for the network at most once per |
| 67 | /// configured interval. |
| 68 | /// |
| 69 | /// The cache holds the *tag we last saw*, not a "checked recently" flag, so a |
| 70 | /// user who relaunches all afternoon still sees the notice every time while |
| 71 | /// GitHub is asked once. A cached `None` means "we asked, there was nothing" — |
| 72 | /// also a valid answer worth not re-asking for. |
| 73 | pub(crate) async fn cached_version_hint( |
| 74 | source: StartupVersionCheckSource, |
| 75 | current: &str, |
| 76 | cache_path: Option<&Path>, |
| 77 | interval_hours: u64, |
| 78 | ) -> Option<UpdateNotice> { |
| 79 | let now = codewhale_release::check::now_unix(); |
| 80 | if let Some(path) = cache_path |
| 81 | && let Some(entry) = codewhale_release::UpdateCheckCache::load(path) |
| 82 | && entry.is_fresh(now, interval_hours) |
| 83 | { |
| 84 | return entry |
| 85 | .latest_tag |
| 86 | .as_deref() |
| 87 | .and_then(|tag| version_hint_from_latest_tag(tag, current)); |
| 88 | } |
| 89 | |
| 90 | let latest_tag = latest_tag_from_startup_source(source).await; |
| 91 | |
| 92 | // Only record a completed check. A network failure leaves the cache |
| 93 | // untouched so the next launch retries instead of caching an outage for a |
| 94 | // whole day. |
| 95 | if let Some(path) = cache_path |
| 96 | && latest_tag.is_some() |
| 97 | && let Err(err) = codewhale_release::UpdateCheckCache::now(latest_tag.clone()).store(path) |
| 98 | { |
| 99 | tracing::debug!(error = %err, "failed to persist update-check cache"); |
| 100 | } |
| 101 | |
| 102 | latest_tag |
| 103 | .as_deref() |
| 104 | .and_then(|tag| version_hint_from_latest_tag(tag, current)) |
| 105 | } |
| 106 | |
| 107 | /// The latest publishable release tag for this source, or `None` when the |
| 108 | /// lookup failed or the release is not one we would offer. |
| 109 | pub(crate) async fn latest_tag_from_startup_source( |
| 110 | source: StartupVersionCheckSource, |
| 111 | ) -> Option<String> { |
| 112 | match source { |
| 113 | StartupVersionCheckSource::Disabled => None, |
| 114 | StartupVersionCheckSource::ConfiguredUrl(url) => { |
| 115 | match latest_tag_from_configured_update_uri(&url).await { |
| 116 | Ok(tag) => tag, |
| 117 | Err(_) => latest_tag_from_release_mirror_env().await, |
| 118 | } |
| 119 | } |
| 120 | StartupVersionCheckSource::ReleaseResolver => { |
| 121 | if release_mirror_env_configured() { |
| 122 | return latest_tag_from_release_mirror_env().await; |
| 123 | } |
| 124 | |
| 125 | let body = codewhale_release::fetch_release_json_async( |
| 126 | codewhale_release::LATEST_RELEASE_URL, |
| 127 | "latest release", |
| 128 | ) |
| 129 | .await |
| 130 | .ok()?; |
| 131 | let json: serde_json::Value = serde_json::from_str(&body).ok()?; |
| 132 | publishable_release_tag(&json).map(str::to_string) |
| 133 | } |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | pub(crate) async fn latest_tag_from_release_mirror_env() -> Option<String> { |
| 138 | if !release_mirror_env_configured() { |
| 139 | return None; |
| 140 | } |
| 141 | codewhale_release::latest_release_tag_async(codewhale_release::ReleaseChannel::Stable) |
| 142 | .await |
| 143 | .ok() |
| 144 | } |
| 145 | |
| 146 | pub(crate) fn release_mirror_env_configured() -> bool { |
| 147 | let version = codewhale_release::update_version_from_env() |
| 148 | .unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string()); |
| 149 | codewhale_release::release_base_url_from_env(&version).is_some() |
| 150 | } |
| 151 | |
| 152 | pub(crate) async fn latest_tag_from_configured_update_uri( |
| 153 | update_uri: &str, |
| 154 | ) -> Result<Option<String>> { |
| 155 | let body = codewhale_release::fetch_release_json_async(update_uri, "configured latest release") |
| 156 | .await?; |
| 157 | let json: serde_json::Value = serde_json::from_str(&body).with_context(|| { |
| 158 | format!("failed to parse release JSON from configured URI {update_uri}") |
| 159 | })?; |
| 160 | Ok(custom_release_tag(&json).map(str::to_string)) |
| 161 | } |
| 162 | |
| 163 | /// Tag of a GitHub release we would actually offer: published, and with every |
| 164 | /// asset the updater needs already uploaded. |
| 165 | pub(crate) fn publishable_release_tag(json: &serde_json::Value) -> Option<&str> { |
| 166 | if !release_has_required_assets(json) { |
| 167 | return None; |
| 168 | } |
| 169 | json["tag_name"].as_str() |
| 170 | } |
| 171 | |
| 172 | /// Tag from a user-configured release endpoint. Asset completeness is only |
| 173 | /// enforced when the payload advertises assets at all, since a custom mirror |
| 174 | /// may legitimately publish metadata in a different shape. |
| 175 | pub(crate) fn custom_release_tag(json: &serde_json::Value) -> Option<&str> { |
| 176 | if !release_is_publishable(json) { |
| 177 | return None; |
| 178 | } |
| 179 | if json.get("assets").is_some() && !release_has_required_assets(json) { |
| 180 | return None; |
| 181 | } |
| 182 | json["tag_name"].as_str() |
| 183 | } |
| 184 | |
| 185 | /// Test-only shorthand pairing tag extraction with the newness comparison. |
| 186 | /// Production code splits the two so the tag can be cached independently of |
| 187 | /// the version we happen to be running. |
| 188 | #[cfg(test)] |
| 189 | pub(crate) fn version_hint_from_release_json( |
| 190 | json: &serde_json::Value, |
| 191 | current: &str, |
| 192 | ) -> Option<UpdateNotice> { |
| 193 | version_hint_from_latest_tag(publishable_release_tag(json)?, current) |
| 194 | } |
| 195 | |
| 196 | #[cfg(test)] |
| 197 | pub(crate) fn version_hint_from_custom_release_json( |
| 198 | json: &serde_json::Value, |
| 199 | current: &str, |
| 200 | ) -> Option<UpdateNotice> { |
| 201 | version_hint_from_latest_tag(custom_release_tag(json)?, current) |
| 202 | } |
| 203 | |
| 204 | pub(crate) fn version_hint_from_latest_tag(tag: &str, current: &str) -> Option<UpdateNotice> { |
| 205 | let latest = tag.trim_start_matches('v'); |
| 206 | if !is_newer_version(latest, current) { |
| 207 | return None; |
| 208 | } |
| 209 | |
| 210 | Some(UpdateNotice { |
| 211 | current: current.to_string(), |
| 212 | latest: latest.to_string(), |
| 213 | }) |
| 214 | } |
| 215 | |
| 216 | pub(crate) fn release_has_required_assets(json: &serde_json::Value) -> bool { |
| 217 | if !release_is_publishable(json) { |
| 218 | return false; |
| 219 | } |
| 220 | |
| 221 | REQUIRED_RELEASE_ASSETS |
| 222 | .iter() |
| 223 | .all(|required| release_has_uploaded_asset(json, required)) |
| 224 | } |
| 225 | |
| 226 | pub(crate) fn release_is_publishable(json: &serde_json::Value) -> bool { |
| 227 | !json |
| 228 | .get("draft") |
| 229 | .and_then(serde_json::Value::as_bool) |
| 230 | .unwrap_or(false) |
| 231 | && !json |
| 232 | .get("prerelease") |
| 233 | .and_then(serde_json::Value::as_bool) |
| 234 | .unwrap_or(false) |
| 235 | } |
| 236 | |
| 237 | pub(crate) fn release_has_uploaded_asset(json: &serde_json::Value, required: &str) -> bool { |
| 238 | let Some(assets) = json.get("assets").and_then(serde_json::Value::as_array) else { |
| 239 | return false; |
| 240 | }; |
| 241 | assets.iter().any(|asset| { |
| 242 | asset.get("name").and_then(serde_json::Value::as_str) == Some(required) |
| 243 | && asset.get("state").and_then(serde_json::Value::as_str) == Some("uploaded") |
| 244 | }) |
| 245 | } |
| 246 | |
| 247 | pub(crate) fn is_newer_version(latest: &str, current: &str) -> bool { |
| 248 | // Compare semver so dev builds (e.g. "0.8.46-pre") don't trigger false |
| 249 | // hints. Falls back to string compare on unparseable versions. |
| 250 | match (parse_semver(latest), parse_semver(current)) { |
| 251 | (Some(l), Some(c)) => l > c, |
| 252 | _ => latest != current, |
| 253 | } |
| 254 | } |
| 255 | |
| 256 | /// Parse a `major.minor.patch` version string into a comparable tuple. |
| 257 | /// Returns `None` on any parse failure (non-semver, dev suffixes, etc.). |
| 258 | pub(crate) fn parse_semver(v: &str) -> Option<(u32, u32, u32)> { |
| 259 | let mut parts = v.splitn(3, '.'); |
| 260 | let major = parts.next()?.parse::<u32>().ok()?; |
| 261 | let minor = parts.next()?.parse::<u32>().ok()?; |
| 262 | let patch = parts.next().unwrap_or("0").parse::<u32>().ok()?; |
| 263 | Some((major, minor, patch)) |
| 264 | } |
| 265 |