fix(updater): use GitHub atom feed for Beta lookup, dodge API rate limit (#350)
用户报「获取 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 出来后用户 装上即生效。 Co-authored-by: baiqing <lbx12309@icloud.com>
This commit is contained in:
1 parent
8b55469d8e
commit
e358c20b20
1 file changed
+98
-43
@@ -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<Option<LatestBetaRelease>, 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<serde_json::Value> = 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 库。每个 `<entry>...</entry>` 内含一行
|
||||
/// `<link rel="alternate" type="text/html" href=".../releases/tag/<tag>"/>`,
|
||||
/// 用 `/releases/tag/` 这个唯一锚点抓 tag。
|
||||
fn parse_latest_beta_from_atom(body: &str) -> Option<LatestBetaRelease> {
|
||||
for entry in body.split("<entry>").skip(1) {
|
||||
let entry_body = entry.split_once("</entry>").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, "<updated>", "</updated>")
|
||||
.unwrap_or_default();
|
||||
return Some(LatestBetaRelease {
|
||||
tag_name,
|
||||
html_url,
|
||||
published_at,
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_between(haystack: &str, open: &str, close: &str) -> Option<String> {
|
||||
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#"<?xml version="1.0"?>
|
||||
<feed>
|
||||
<entry>
|
||||
<id>tag:github.com,2008:Repository/X/v1.2.23-tauri</id>
|
||||
<updated>2026-05-07T09:05:00Z</updated>
|
||||
<link rel="alternate" type="text/html" href="https://github.com/appergb/openless/releases/tag/v1.2.23-tauri"/>
|
||||
<title>OpenLess v1.2.23-tauri</title>
|
||||
</entry>
|
||||
<entry>
|
||||
<id>tag:github.com,2008:Repository/X/v1.2.24-2-beta-tauri</id>
|
||||
<updated>2026-05-08T01:27:23Z</updated>
|
||||
<link rel="alternate" type="text/html" href="https://github.com/appergb/openless/releases/tag/v1.2.24-2-beta-tauri"/>
|
||||
<title>OpenLess v1.2.24-2-beta-tauri</title>
|
||||
</entry>
|
||||
</feed>"#;
|
||||
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#"<feed>
|
||||
<entry>
|
||||
<link rel="alternate" type="text/html" href="https://github.com/appergb/openless/releases/tag/v1.2.23-tauri"/>
|
||||
<updated>2026-05-07T09:05:00Z</updated>
|
||||
</entry>
|
||||
</feed>"#;
|
||||
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();
|
||||
|
||||
Reference in new issue
Block a user