From 4d1a7b403746dc37e8a24c64209d5b9e486cb6ce Mon Sep 17 00:00:00 2001 From: baiqing Date: Fri, 8 May 2026 09:46:53 +0800 Subject: [PATCH] fix(updater): use GitHub atom feed for Beta lookup, dodge API rate limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户报「获取 Beta 版本信息失败」根因:fetch_latest_beta_release 调 api.github.com/repos/.../releases,未认证 60 req/h/IP。多次切 Beta toggle + 同 IP 共享配额(公司/家庭网络后),轻易撞 403 rate limit exceeded。 现场实测 IP 已耗尽:x-ratelimit-remaining: 0。 修法:换成 GitHub releases.atom(公开 RSS feed,CDN cache,无 API rate limit)。 具体改动: - fetch_latest_beta_release 端点从 api.github.com 改成 github.com/.../releases.atom - 提取一个纯函数 parse_latest_beta_from_atom 做字符串解析(不引 XML 库—— feed 格式稳定,找 /releases/tag/ 锚点抓 tag 即可),方便单测 - atom feed 不显式标 prerelease,但项目约定 tag 后缀 `-beta-tauri` 就是 Beta, 过滤后缀已足够 - timeout 从 10s 提到 15s,给跨境网络更多余地 - 加 2 个 unit test:(1) 混合 stable+beta 时返回 Beta entry;(2) 全 stable 时返回 None。两个 test 都已 pass 不改前端:JS 端调用签名 / 错误展示路径不变,下次 Beta-3 release 出来后用户 装上即生效。 --- openless-all/app/src-tauri/src/commands.rs | 141 ++++++++++++++------- 1 file changed, 98 insertions(+), 43 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 136c0340..62af5d8d 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -198,58 +198,74 @@ pub struct LatestBetaRelease { pub published_at: String, } -/// 调 GitHub Releases API 拿最近 20 条 release,找出第一条 `prerelease=true` 且 -/// tag 以 `-beta-tauri` 结尾的。返回 `Ok(None)` 表示当前没有发布过 Beta 版。 -/// 网络/解析错误以 `Err(String)` 上报,让前端展示具体原因。 +/// 拉 GitHub Releases atom feed 找最新 Beta release(tag 以 `-beta-tauri` 结尾)。 +/// +/// 历史:之前用 `api.github.com/repos/.../releases` REST 端点,**未认证 60 req/h/IP**, +/// 多人多次切 Beta toggle 很容易撞 403 rate limit(用户报"获取 Beta 版本信息失败" +/// 即是这个)。换成 `releases.atom` 后是公开页面 + CDN cache,没有同等 rate 限制。 +/// Atom feed 不显式标 prerelease,但项目约定 tag 后缀 `-beta-tauri` 必为 Beta, +/// 所以只用 tag 后缀过滤就够了。 +/// +/// 返回 `Ok(None)` = 当前没发过 Beta 版;`Err(String)` = 网络/解析故障。 #[tauri::command] pub async fn fetch_latest_beta_release() -> Result, String> { let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) + .timeout(std::time::Duration::from_secs(15)) .user_agent(concat!("OpenLess/", env!("CARGO_PKG_VERSION"))) .build() .map_err(|e| format!("build http client: {e}"))?; let resp = client - .get("https://api.github.com/repos/appergb/openless/releases?per_page=20") - .header("Accept", "application/vnd.github+json") + .get("https://github.com/appergb/openless/releases.atom") .send() .await - .map_err(|e| format!("fetch releases: {e}"))?; + .map_err(|e| format!("fetch releases.atom: {e}"))?; if !resp.status().is_success() { - return Err(format!("GitHub API status {}", resp.status())); + return Err(format!("releases.atom status {}", resp.status())); } - let releases: Vec = resp - .json() + let body = resp + .text() .await - .map_err(|e| format!("parse releases json: {e}"))?; - let latest = releases.into_iter().find(|r| { - let is_pre = r - .get("prerelease") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let tag_ok = r - .get("tag_name") - .and_then(|v| v.as_str()) - .map(|s| s.ends_with("-beta-tauri")) - .unwrap_or(false); - is_pre && tag_ok - }); - Ok(latest.map(|r| LatestBetaRelease { - tag_name: r - .get("tag_name") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_string(), - html_url: r - .get("html_url") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_string(), - published_at: r - .get("published_at") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_string(), - })) + .map_err(|e| format!("read atom body: {e}"))?; + Ok(parse_latest_beta_from_atom(&body)) +} + +/// 简单字符串解析 atom feed,避免引 XML 库。每个 `...` 内含一行 +/// ``, +/// 用 `/releases/tag/` 这个唯一锚点抓 tag。 +fn parse_latest_beta_from_atom(body: &str) -> Option { + for entry in body.split("").skip(1) { + let entry_body = entry.split_once("").map(|(b, _)| b).unwrap_or(entry); + let needle = "/releases/tag/"; + let tag_start = match entry_body.find(needle) { + Some(i) => i + needle.len(), + None => continue, + }; + let tag_after = &entry_body[tag_start..]; + let tag_end = tag_after + .find(|c: char| c == '"' || c == '<' || c == ' ' || c == '/') + .unwrap_or(tag_after.len()); + let tag_name = tag_after[..tag_end].to_string(); + if !tag_name.ends_with("-beta-tauri") { + continue; + } + let html_url = format!( + "https://github.com/appergb/openless/releases/tag/{tag_name}" + ); + let published_at = extract_between(entry_body, "", "") + .unwrap_or_default(); + return Some(LatestBetaRelease { + tag_name, + html_url, + published_at, + }); + } + None +} + +fn extract_between(haystack: &str, open: &str, close: &str) -> Option { + let start = haystack.find(open)? + open.len(); + let end = haystack[start..].find(close)?; + Some(haystack[start..start + end].to_string()) } #[tauri::command] @@ -1684,9 +1700,9 @@ mod tests { active_asr_is_keyless_for_validation, active_foundry_model_from_prefs, asr_configured_for_provider, asr_transcriptions_url, fetch_provider_models, llm_configured_for_provider, local_asr_release_plan_for_provider, models_url, - normalize_foundry_language_hint, parse_model_ids, persist_settings, - release_foundry_runtime_if_inactive, validate_foundry_model_alias, ProviderConfig, - SettingsWriter, + normalize_foundry_language_hint, parse_latest_beta_from_atom, parse_model_ids, + persist_settings, release_foundry_runtime_if_inactive, validate_foundry_model_alias, + ProviderConfig, SettingsWriter, }; use crate::persistence::CredentialsSnapshot; use crate::types::{ @@ -2238,6 +2254,45 @@ mod tests { assert!(writer.saved.lock().unwrap().is_none()); } + #[test] + fn parse_latest_beta_from_atom_picks_first_beta_tagged_entry() { + // Fixture trimmed from real `releases.atom`:包含一条 stable + 一条 Beta。 + // 解析必须跳过 stable(tag 不以 -beta-tauri 结尾),返回 Beta。 + let body = r#" + + + tag:github.com,2008:Repository/X/v1.2.23-tauri + 2026-05-07T09:05:00Z + + OpenLess v1.2.23-tauri + + + tag:github.com,2008:Repository/X/v1.2.24-2-beta-tauri + 2026-05-08T01:27:23Z + + OpenLess v1.2.24-2-beta-tauri + +"#; + let got = parse_latest_beta_from_atom(body).expect("must find a Beta entry"); + assert_eq!(got.tag_name, "v1.2.24-2-beta-tauri"); + assert_eq!( + got.html_url, + "https://github.com/appergb/openless/releases/tag/v1.2.24-2-beta-tauri" + ); + assert_eq!(got.published_at, "2026-05-08T01:27:23Z"); + } + + #[test] + fn parse_latest_beta_from_atom_returns_none_when_only_stable_releases() { + let body = r#" + + + 2026-05-07T09:05:00Z + +"#; + assert!(parse_latest_beta_from_atom(body).is_none()); + } + #[tokio::test] async fn fetch_provider_models_omits_authorization_when_api_key_is_empty() { let listener = TcpListener::bind("127.0.0.1:0").unwrap();