feat(update): OTA 兜底发现与系统代理支持

- check_latest 加免 API 配额的兜底路径:api.github.com 被限流(60 次/
  小时/IP,共享出口 IP 实测已打满)时,跟踪 github.com/releases/latest
  的 302 从最终 URL 提取 tag,下载与 .sha256 按命名约定构造;notes 置空、
  大小置 0(进度未知、对账跳过),sha256 校验仍强制执行
- ureq 启用 win-system-proxy:Windows 系统代理注册表设置自动生效;
  HTTP(S)_PROXY 环境变量代理 ureq 默认就读取,无需配置
- 实测:github.com 直连与代理均可达(302 正常),api.github.com 直连
  与代理出口 IP 的未认证配额均耗尽 —— 兜底路径为必需品而非锦上添花
This commit is contained in:
2026-09-30 12:54:57 +08:00
parent 68273e8e63
commit 7b41e7f13d
4 changed files with 105 additions and 3 deletions
Generated
+12 -1
View File
@@ -1679,7 +1679,7 @@ dependencies = [
"rustc_version",
"toml 1.1.6+spec-1.1.0",
"vswhom",
"winreg",
"winreg 0.55.0",
]
[[package]]
@@ -7890,6 +7890,7 @@ dependencies = [
"ureq-proto",
"utf8-zero",
"webpki-roots",
"winreg 0.56.0",
]
[[package]]
@@ -9182,6 +9183,16 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "winreg"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
"windows-sys 0.59.0",
]
[[package]]
name = "wio"
version = "0.2.2"
+3 -2
View File
@@ -41,8 +41,9 @@ rustls-native-certs = "0.8"
# 原生文件对话框(连接导入/导出);Windows 走内建 COM 后端,Linux 用默认 xdg-portal
rfd = "0.17"
# OTA 自更新:Gitea Release 检查/下载(ureq 用 rustls,与 rumqttc 依赖同源)
ureq = { version = "3", features = ["json"] }
# OTA 自更新:GitHub Release 检查/下载(ureq 用 rustls,与 rumqttc 依赖同源;
# win-system-proxy 使 Windows 系统代理注册表设置自动生效,环境变量代理本就默认支持)
ureq = { version = "3", features = ["json", "win-system-proxy"] }
sha2 = "0.11"
# Release 附件为 7z 压缩包,纯 Rust 解压(客户端免装 7-Zip)
sevenz-rust = "0.6"
+86
View File
@@ -69,6 +69,19 @@ pub fn pick_assets(rel: &serde_json::Value) -> Option<(String, String, u64, Opti
/// 检查更新。返回 Ok(None) 表示已是最新。
pub fn check_latest() -> Result<Option<UpdateInfo>, String> {
// API 主路径信息最全(notes/资产清单/大小),但 api.github.com 未认证配额
// 仅 60 次/小时/IP,共享出口 IP(VPN/办公 NAT)经常打满 —— 打满或不可达时
// 退到 releases 页面重定向发现(无配额),完整性校验不受影响。
match check_latest_via_api() {
Ok(v) => Ok(v),
Err(api_err) => match check_latest_via_redirect() {
Ok(v) => Ok(v),
Err(_) => Err(api_err),
},
}
}
fn check_latest_via_api() -> Result<Option<UpdateInfo>, String> {
let base = RELEASES_API;
// 优先 latest 端点(GitHub 恒可用);回退到列表取第一个非草稿/预发布,作容错
let rel = match http_get_json(&format!("{base}/latest")) {
@@ -110,6 +123,48 @@ pub fn check_latest() -> Result<Option<UpdateInfo>, String> {
}))
}
/// 兜底:跟踪 `releases/latest` 的 302,从最终 URL 提取最新 tag。
///
/// github.com 页面不受 API 配额限制。代价是拿不到 Release notes 与资产清单:
/// notes 置空、大小置 0(进度条显示未知大小,大小对账自动跳过),
/// 下载 URL 与 `.sha256` 侧车按命名约定构造,sha256 校验仍然强制执行。
fn check_latest_via_redirect() -> Result<Option<UpdateInfo>, String> {
use ureq::ResponseExt as _;
let resp = agent()
.get(&format!("{RELEASES_PAGE}/latest"))
.call()
.map_err(|e| format!("页面请求失败: {e}"))?;
let uri = resp.get_uri().to_string();
let Some(tag) = tag_from_releases_url(&uri) else {
return Err(format!("页面 URL 异常: {uri}"));
};
if !is_newer(tag, current_version()) {
return Ok(None);
}
let version = tag.trim_start_matches('v');
let triple = platform_triple();
let bin = if cfg!(windows) { "mqttx.exe" } else { "mqttx" };
// 与 publish 脚本/工作流的产物命名约定一致(pick_assets 同款匹配规则)
let asset_base = format!("mqttx-v{version}-{triple}-{bin}");
let asset = format!("{asset_base}.7z");
Ok(Some(UpdateInfo {
version: version.to_string(),
notes: String::new(),
asset_name: asset.clone(),
asset_url: format!("{RELEASES_PAGE}/download/{tag}/{asset}"),
sha256_url: Some(format!("{RELEASES_PAGE}/download/{tag}/{asset}.sha256")),
size: 0,
}))
}
/// 从 Releases 页面 URL 提取 tag(如 `.../releases/tag/v1.0.1` → `v1.0.1`)。
fn tag_from_releases_url(uri: &str) -> Option<&str> {
let tag = uri.split("/releases/tag/").nth(1)?;
let tag = tag.trim_end_matches('/');
// 防御:tag 不含路径段,且必须以 v 开头(约定)
(tag.starts_with('v') && !tag.contains('/')).then_some(tag)
}
/// 返回 Ok(None) 表示已是最新;错误仅记录、不打扰用户。
pub fn check_and_download() -> Result<Option<StagedUpdate>, String> {
let Some(info) = check_latest()? else {
@@ -123,3 +178,34 @@ pub fn check_and_download() -> Result<Option<StagedUpdate>, String> {
}
download_and_stage(&info, &|_, _| {}).map(Some)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tag_extraction_from_releases_url() {
// /releases/latest 302 后的最终 URL 形态
assert_eq!(
tag_from_releases_url("https://github.com/LiJoeAllen/mqttx/releases/tag/v1.0.1"),
Some("v1.0.1")
);
assert_eq!(
tag_from_releases_url("https://github.com/LiJoeAllen/mqttx/releases/tag/v1.2.3-rc.1/"),
Some("v1.2.3-rc.1")
);
// 非 tag 页面 / 异常形态一律拒绝
assert_eq!(
tag_from_releases_url("https://github.com/LiJoeAllen/mqttx/releases"),
None
);
assert_eq!(
tag_from_releases_url("https://github.com/LiJoeAllen/mqttx/releases/tag/1.0.0"),
None
);
assert_eq!(
tag_from_releases_url("https://github.com/LiJoeAllen/mqttx/releases/tag/v1.0.0/extra"),
None
);
}
}
+4
View File
@@ -26,6 +26,10 @@ pub use install::{
/// - v1.0.x 老客户端的 OTA 源仍是自建 Gitea,CI 的 mirror-gitea job 会把每次
/// 发布镜像回 Gitea,老客户端升级完毕后可移除该 job。
pub const RELEASES_API: &str = "https://api.github.com/repos/LiJoeAllen/mqttx/releases";
/// Releases 页面(github.com,非 API、无配额限制):api.github.com 被限流时
/// 兜底发现最新 tag —— 跟踪 `{RELEASES_PAGE}/latest` 的 302,从最终 URL 取 tag,
/// 下载与校验按命名约定构造 URL,完整性(sha256)不受影响。
pub const RELEASES_PAGE: &str = "https://github.com/LiJoeAllen/mqttx/releases";
/// 更新源展示名(设置对话框「关于」)。
pub const UPDATE_SOURCE: &str = "GitHub";