feat(release): add GitCode fallback mirror

This commit is contained in:
lzt404 committed 2026-08-05 16:23:17 +08:00
1 parent 391fc877a7
commit 06f024abde
6 files changed
+475 -20

No files matched your search

+236 -17
View File
@@ -52,6 +52,7 @@ const RELEASE_DOWNLOAD_PREFIX: &str =
const APP_UPDATE_MANIFEST_URL: &str = "https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest/download/portable-update-windows.json";
const APP_RELEASE_DOWNLOAD_PREFIX: &str =
"https://github.com/router-for-me/EasyCLIProxyAPI/releases/download/";
const APP_UPDATE_MANIFEST_NAME: &str = "portable-update-windows.json";
const CODEX_MODEL_CATALOG_URL: &str = "https://raw.githubusercontent.com/router-for-me/EasyCLIProxyAPI/main/src-tauri/resources/codex_models/model-catalog.json";
const CODEX_MODEL_CATALOG_OVERRIDE_DIR: &str = "codex_models";
const CODEX_MODEL_CATALOG_SOURCE_FILE: &str = "model-catalog.json";
@@ -365,10 +366,17 @@ struct PortableUpdateManifest {
#[serde(rename_all = "camelCase")]
struct PortableUpdateAsset {
url: String,
#[serde(default)]
fallback_urls: Vec<String>,
sha256: String,
size_bytes: u64,
}
#[derive(Debug, Deserialize)]
struct GitcodeRelease {
tag_name: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PortableAppManifest {
@@ -10708,26 +10716,13 @@ async fn check_app_update(
state: tauri::State<'_, AppUpdateState>,
) -> Result<AppUpdateInfo, String> {
let client = reqwest::Client::builder()
.redirect(github_https_redirect_policy())
.redirect(release_https_redirect_policy())
.connect_timeout(Duration::from_secs(8))
.read_timeout(Duration::from_secs(15))
.timeout(Duration::from_secs(20))
.build()
.map_err(|error| format!("创建版本检查客户端失败: {error}"))?;
let manifest = client
.get(APP_UPDATE_MANIFEST_URL)
.header(reqwest::header::ACCEPT, "application/json")
.header(reqwest::header::USER_AGENT, APP_USER_AGENT)
.send()
.await
.map_err(|error| format!("检查软件更新失败: {error}"))?
.error_for_status()
.map_err(|error| format!("读取软件更新清单失败: {error}"))?
.json::<PortableUpdateManifest>()
.await
.map_err(|error| format!("解析软件更新清单失败: {error}"))?;
validate_portable_update_manifest(&manifest)?;
let manifest = fetch_portable_update_manifest(&client).await?;
let latest_version = normalize_version(&manifest.version);
let current_version = normalize_version(env!("CARGO_PKG_VERSION"));
let update_available = is_app_update_available(&current_version, &latest_version)?;
@@ -10783,6 +10778,124 @@ async fn check_app_update(
})
}
async fn fetch_portable_update_manifest(
client: &reqwest::Client,
) -> Result<PortableUpdateManifest, String> {
match fetch_portable_update_manifest_url(client, APP_UPDATE_MANIFEST_URL).await {
Ok(manifest) => Ok(manifest),
Err(github_error) => {
let Some(repository) = configured_gitcode_repository() else {
return Err(github_error);
};
let fallback = async {
let release_url =
format!("https://api.gitcode.com/api/v5/repos/{repository}/releases/latest");
let release = client
.get(release_url)
.header(reqwest::header::ACCEPT, "application/json")
.header(reqwest::header::USER_AGENT, APP_USER_AGENT)
.send()
.await
.map_err(|error| format!("query GitCode latest release: {error}"))?
.error_for_status()
.map_err(|error| format!("read GitCode latest release: {error}"))?
.json::<GitcodeRelease>()
.await
.map_err(|error| format!("parse GitCode latest release: {error}"))?;
validate_release_tag(&release.tag_name)?;
let manifest_url = gitcode_release_attachment_url(
repository,
&release.tag_name,
APP_UPDATE_MANIFEST_NAME,
);
fetch_portable_update_manifest_url(client, &manifest_url).await
}
.await;
fallback.map_err(|gitcode_error| {
format!(
"GitHub update source failed: {github_error}; GitCode fallback failed: {gitcode_error}"
)
})
}
}
}
async fn fetch_portable_update_manifest_url(
client: &reqwest::Client,
url: &str,
) -> Result<PortableUpdateManifest, String> {
let manifest = client
.get(url)
.header(reqwest::header::ACCEPT, "application/json")
.header(reqwest::header::USER_AGENT, APP_USER_AGENT)
.send()
.await
.map_err(|error| format!("request update manifest: {error}"))?
.error_for_status()
.map_err(|error| format!("read update manifest: {error}"))?
.json::<PortableUpdateManifest>()
.await
.map_err(|error| format!("parse update manifest: {error}"))?;
validate_portable_update_manifest(&manifest)?;
Ok(manifest)
}
fn configured_gitcode_repository() -> Option<&'static str> {
let repository = option_env!("GITCODE_REPOSITORY")?.trim();
let mut parts = repository.split('/');
let valid_part = |part: &str| {
!part.is_empty()
&& part
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
};
match (parts.next(), parts.next(), parts.next()) {
(Some(owner), Some(repo), None) if valid_part(owner) && valid_part(repo) => {
Some(repository)
}
_ => None,
}
}
fn validate_release_tag(tag: &str) -> Result<(), String> {
semver::Version::parse(tag.strip_prefix('v').unwrap_or(tag))
.map(|_| ())
.map_err(|error| format!("invalid release tag {tag}: {error}"))
}
fn gitcode_release_attachment_url(repository: &str, tag: &str, filename: &str) -> String {
format!(
"https://api.gitcode.com/api/v5/repos/{repository}/releases/{tag}/attach_files/{filename}/download"
)
}
fn release_https_redirect_policy() -> reqwest::redirect::Policy {
reqwest::redirect::Policy::custom(|attempt| {
let url = attempt.url();
let trusted_host = matches!(
url.host_str(),
Some(
"github.com"
| "objects.githubusercontent.com"
| "release-assets.githubusercontent.com"
| "api.gitcode.com"
| "gitcode.com"
| "file-cdn.gitcode.com"
)
);
if url.scheme() == "https"
&& url.port().is_none()
&& url.username().is_empty()
&& url.password().is_none()
&& trusted_host
{
attempt.follow()
} else {
attempt.stop()
}
})
}
fn github_https_redirect_policy() -> reqwest::redirect::Policy {
reqwest::redirect::Policy::custom(|attempt| {
let url = attempt.url();
@@ -10864,6 +10977,11 @@ fn validate_portable_update_manifest(manifest: &PortableUpdateManifest) -> Resul
if asset.url != full_package_url && asset.url != legacy_package_url {
return Err(format!("软件更新资产名称与 {key} 不匹配"));
}
validate_portable_update_asset_fallbacks(
asset,
&format!("v{}", manifest.version.trim().trim_start_matches('v')),
&[&full_package_name, &legacy_package_name],
)?;
}
if let Some(full_assets) = &manifest.full_assets {
if full_assets.len() != 2 {
@@ -10888,6 +11006,11 @@ fn validate_portable_update_manifest(manifest: &PortableUpdateManifest) -> Resul
if asset.url != expected_url {
return Err(format!("Full update asset name does not match {key}"));
}
validate_portable_update_asset_fallbacks(
asset,
&format!("v{}", manifest.version.trim().trim_start_matches('v')),
&[&package_name],
)?;
}
}
Ok(())
@@ -10916,6 +11039,43 @@ fn validate_portable_update_asset(asset: &PortableUpdateAsset) -> Result<(), Str
Ok(())
}
fn validate_portable_update_asset_fallbacks(
asset: &PortableUpdateAsset,
tag: &str,
expected_filenames: &[&str],
) -> Result<(), String> {
validate_portable_update_asset_fallbacks_for_repository(
asset,
tag,
expected_filenames,
configured_gitcode_repository(),
)
}
fn validate_portable_update_asset_fallbacks_for_repository(
asset: &PortableUpdateAsset,
tag: &str,
expected_filenames: &[&str],
repository: Option<&str>,
) -> Result<(), String> {
if asset.fallback_urls.is_empty() {
return Ok(());
}
let repository = repository
.ok_or_else(|| "GitCode fallback is not configured for this build".to_string())?;
if asset.fallback_urls.len() != 1 {
return Err("Software update assets may define only one fallback URL".to_string());
}
let fallback = &asset.fallback_urls[0];
let trusted = expected_filenames
.iter()
.any(|filename| fallback == &gitcode_release_attachment_url(repository, tag, filename));
if !trusted {
return Err("Software update fallback URL is not trusted".to_string());
}
Ok(())
}
fn portable_update_target() -> Option<(&'static str, &'static str)> {
if !cfg!(windows) {
return None;
@@ -11124,14 +11284,46 @@ async fn download_portable_update_archive(
destination: &Path,
) -> Result<(), String> {
let client = reqwest::Client::builder()
.redirect(github_https_redirect_policy())
.redirect(release_https_redirect_policy())
.connect_timeout(Duration::from_secs(15))
.read_timeout(Duration::from_secs(30))
.timeout(Duration::from_secs(15 * 60))
.build()
.map_err(|error| format!("创建应用更新下载客户端失败: {error}"))?;
let urls = std::iter::once(&pending.asset.url)
.chain(pending.asset.fallback_urls.iter())
.collect::<Vec<_>>();
let mut failures = Vec::new();
for (index, url) in urls.iter().enumerate() {
update_app_task(app, |task| {
task.downloaded_bytes = 0;
task.percent = Some(0.0);
if index > 0 {
task.message = Some("GitHub 下载失败,正在切换到 GitCode".to_string());
}
});
match download_portable_update_archive_url(app, pending, token, destination, &client, url)
.await
{
Ok(()) => return Ok(()),
Err(error) if token.is_cancelled() => return Err(error),
Err(error) => failures.push(error),
}
}
Err(format!("所有应用更新下载源均失败: {}", failures.join("; ")))
}
#[cfg(windows)]
async fn download_portable_update_archive_url(
app: &tauri::AppHandle,
pending: &PendingAppUpdate,
token: &CancellationToken,
destination: &Path,
client: &reqwest::Client,
url: &str,
) -> Result<(), String> {
let response = client
.get(&pending.asset.url)
.get(url)
.header(reqwest::header::ACCEPT, "application/octet-stream")
.header(reqwest::header::USER_AGENT, APP_USER_AGENT)
.send()
@@ -23435,6 +23627,7 @@ custom_option = "keep-original"
let name = format!("EasyCLIProxyAPI-v{version}-Windows-{arch}.zip");
PortableUpdateAsset {
url: format!("{APP_RELEASE_DOWNLOAD_PREFIX}v{version}/{name}"),
fallback_urls: Vec::new(),
sha256: "ab".repeat(32),
size_bytes: 1024,
}
@@ -23444,6 +23637,7 @@ custom_option = "keep-original"
let name = format!("EasyCLIProxyAPI-update-v{version}-Windows-{arch}.zip");
PortableUpdateAsset {
url: format!("{APP_RELEASE_DOWNLOAD_PREFIX}v{version}/{name}"),
fallback_urls: Vec::new(),
sha256: "ab".repeat(32),
size_bytes: 1024,
}
@@ -23540,6 +23734,31 @@ custom_option = "keep-original"
assert!(validate_portable_update_manifest(&mismatched_tag).is_err());
}
#[test]
fn portable_update_asset_accepts_only_the_configured_gitcode_fallback() {
let mut asset = portable_update_test_asset("1.2.3", "amd64");
let filename = "EasyCLIProxyAPI-v1.2.3-Windows-amd64.zip";
asset.fallback_urls = vec![gitcode_release_attachment_url(
"mirror-owner/EasyCLIProxyAPI",
"v1.2.3",
filename,
)];
assert!(validate_portable_update_asset_fallbacks_for_repository(
&asset,
"v1.2.3",
&[filename],
Some("mirror-owner/EasyCLIProxyAPI"),
)
.is_ok());
assert!(validate_portable_update_asset_fallbacks_for_repository(
&asset,
"v1.2.3",
&[filename],
Some("another-owner/EasyCLIProxyAPI"),
)
.is_err());
}
#[test]
fn portable_update_state_supports_cancellation_and_snapshot_recovery() {
let state = AppUpdateState::default();