返回 CodeWhale
release_check.rs
根目录 / crates / tui / src / tui / ui / release_check.rs
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, &current, 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
265 lines RUST