diff --git a/crates/op-host-desktop/src/update_check.rs b/crates/op-host-desktop/src/update_check.rs index 27e0c8cf6..ac3e5e474 100644 --- a/crates/op-host-desktop/src/update_check.rs +++ b/crates/op-host-desktop/src/update_check.rs @@ -40,6 +40,20 @@ fn latest_release_api() -> String { format!("https://api.github.com/repos/{GITHUB_OWNER}/{GITHUB_REPO}/releases?per_page=20") } +/// Releases Atom feed — the fallback when the JSON API is unavailable. +/// +/// The anonymous API allows 60 requests per hour *per source IP*, so every +/// user behind a shared egress (corporate NAT, a VPN, most China-region +/// proxies) can find it already exhausted by strangers and see the probe fail +/// with "check the network" while the network is perfectly fine. The Atom +/// feed is served by github.com rather than api.github.com and is not on that +/// quota, so it answers when the API will not. Drafts never appear in it, +/// which is the same rule `select_latest_release_tag` applies to the API +/// response. +fn latest_release_atom() -> String { + format!("https://github.com/{GITHUB_OWNER}/{GITHUB_REPO}/releases.atom") +} + /// Background release-API probe. One request per `spawn`; the /// runner re-spawns to re-check (e.g. from the "Check for Updates" /// menu item). @@ -133,20 +147,60 @@ fn fetch_latest_tag() -> Option { .user_agent(concat!("openpencil-desktop/", env!("CARGO_PKG_VERSION"))) .build() .ok()?; - let resp = client - .get(latest_release_api()) - .header("Accept", "application/vnd.github+json") - .send() - .await - .ok()?; - if !resp.status().is_success() { - return None; + // The API carries the richer response, so it stays the first choice; + // the feed only has to cover the case where the API refuses to answer + // at all. A rate-limited probe must not read as "you are offline". + if let Some(tag) = fetch_tag_from_api(&client).await { + return Some(tag); } - let json: serde_json::Value = resp.json().await.ok()?; - select_latest_release_tag(&json) + fetch_tag_from_atom(&client).await }) } +async fn fetch_tag_from_api(client: &reqwest::Client) -> Option { + let resp = client + .get(latest_release_api()) + .header("Accept", "application/vnd.github+json") + .send() + .await + .ok()?; + if !resp.status().is_success() { + return None; + } + let json: serde_json::Value = resp.json().await.ok()?; + select_latest_release_tag(&json) +} + +async fn fetch_tag_from_atom(client: &reqwest::Client) -> Option { + let resp = client + .get(latest_release_atom()) + .header("Accept", "application/atom+xml") + .send() + .await + .ok()?; + if !resp.status().is_success() { + return None; + } + select_latest_release_tag_from_atom(&resp.text().await.ok()?) +} + +/// Pull the newest release tag out of the releases Atom feed. +/// +/// Entries are newest-first and each carries +/// `tag:github.com,2008:Repository//`, so the tag is +/// the last path segment of the first entry id. Only that one field is read — +/// this is deliberately a targeted extraction rather than an XML parse, so a +/// feed that grows unrelated markup cannot change the result. +fn select_latest_release_tag_from_atom(feed: &str) -> Option { + // The feed itself opens with an `` for the repository, which has no + // `Repository//` shape; requiring the entry marker skips it. + const ENTRY_ID_MARKER: &str = "tag:github.com,2008:Repository/"; + let rest = feed.split_once(ENTRY_ID_MARKER)?.1; + let id_body = rest.split_once("")?.0; + let tag = id_body.rsplit_once('/')?.1.trim(); + (!tag.is_empty() && !tag.contains('<')).then(|| tag.to_string()) +} + /// Select the newest published release tag from GitHub's releases list. /// GitHub returns newest releases first. Drafts are not visible to users and /// must not drive update prompts; prereleases are visible and are intentionally @@ -335,6 +389,44 @@ pub fn open_url(url: &str) { mod tests { use super::*; + /// Shaped like the live feed: a repository-level `` comes first and + /// must not be mistaken for a release. + const ATOM_FEED: &str = concat!( + "tag:github.com,2008:https://github.com/ZSeven-W/openpencil/releases", + "tag:github.com,2008:Repository/1159938129/v0.8.3", + "tag:github.com,2008:Repository/1159938129/v0.8.2", + ); + + #[test] + fn atom_fallback_reads_the_newest_release_tag() { + assert_eq!( + select_latest_release_tag_from_atom(ATOM_FEED).as_deref(), + Some("v0.8.3") + ); + } + + #[test] + fn atom_fallback_yields_nothing_without_a_release_entry() { + // A repo with no releases still serves a feed, and its only `` is + // the repository one. Returning its trailing path segment there would + // invent a version out of the URL. + for feed in [ + "tag:github.com,2008:https://github.com/o/r/releases", + "", + "", + // Truncated mid-entry: no closing tag to bound the id. + "tag:github.com,2008:Repository/1/v0.8.3", + // Present but empty tag segment. + "tag:github.com,2008:Repository/1/", + ] { + assert_eq!( + select_latest_release_tag_from_atom(feed), + None, + "{feed:?} must not yield a version" + ); + } + } + #[test] fn windows_start_args_quotes_urls_with_query_params() { assert_eq!(