feat(updater): add GitCode fallback for GUI and CLIProxyAPI core releases

- split GitCode repositories into GUI and core build variables
- query GitCode latest release when GitHub version discovery fails
- construct GitCode attachment URLs from repository, tag, and asset filename
- retry core downloads through GitCode when GitHub downloads fail
This commit is contained in:
lzt404 committed 2026-08-07 15:20:30 +08:00
1 parent dd86eabefa
commit e8b2ced3f8
7 files changed
+200 -51

No files matched your search

+3 -2
View File
@@ -166,7 +166,8 @@ jobs:
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: Build GUI
env:
GITCODE_REPOSITORY: ${{ vars.GITCODE_REPOSITORY }}
GITCODE_GUI_REPOSITORY: ${{ vars.GITCODE_GUI_REPOSITORY }}
GITCODE_CORE_REPOSITORY: ${{ vars.GITCODE_CORE_REPOSITORY }}
RUSTFLAGS: -D warnings
run: |
bun install --frozen-lockfile
@@ -418,7 +419,7 @@ jobs:
--directory artifacts
--output artifacts/portable-update-windows.json
--repository "${{ github.repository }}"
--gitcode-repository "${{ vars.GITCODE_REPOSITORY }}"
--gitcode-repository "${{ vars.GITCODE_GUI_REPOSITORY }}"
--tag "$RELEASE_TAG"
- name: Publish release
shell: bash
+23 -9
View File
@@ -2,7 +2,9 @@
param(
[int]$BuildJobs,
[string]$GitCodeRepository = 'lzt404/EasyCLIProxyAPI'
[string]$GitCodeGuiRepository = 'lzt404/EasyCLIProxyAPI',
[string]$GitCodeCoreRepository = 'lzt404/CLIProxyAPI'
)
Set-StrictMode -Version Latest
@@ -27,8 +29,11 @@ if (-not $PSBoundParameters.ContainsKey('BuildJobs')) {
if ($BuildJobs -lt 1 -or $BuildJobs -gt 256) {
throw 'BuildJobs must be between 1 and 256.'
}
if ($GitCodeRepository -notmatch '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$') {
throw 'GitCodeRepository must use the owner/repository format.'
if ($GitCodeGuiRepository -notmatch '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$') {
throw 'GitCodeGuiRepository must use the owner/repository format.'
}
if ($GitCodeCoreRepository -notmatch '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$') {
throw 'GitCodeCoreRepository must use the owner/repository format.'
}
if (-not (Get-Command bun -ErrorAction SilentlyContinue)) {
@@ -36,7 +41,8 @@ if (-not (Get-Command bun -ErrorAction SilentlyContinue)) {
}
Write-Host "Cargo build jobs: $BuildJobs"
Write-Host "GitCode fallback repository: $GitCodeRepository"
Write-Host "GitCode GUI fallback repository: $GitCodeGuiRepository"
Write-Host "GitCode core fallback repository: $GitCodeCoreRepository"
& bun install
if ($LASTEXITCODE -ne 0) {
@@ -44,10 +50,12 @@ if ($LASTEXITCODE -ne 0) {
}
$PreviousBuildJobs = $env:CARGO_BUILD_JOBS
$PreviousGitCodeRepository = $env:GITCODE_REPOSITORY
$PreviousGitCodeGuiRepository = $env:GITCODE_GUI_REPOSITORY
$PreviousGitCodeCoreRepository = $env:GITCODE_CORE_REPOSITORY
try {
$env:CARGO_BUILD_JOBS = [string]$BuildJobs
$env:GITCODE_REPOSITORY = $GitCodeRepository
$env:GITCODE_GUI_REPOSITORY = $GitCodeGuiRepository
$env:GITCODE_CORE_REPOSITORY = $GitCodeCoreRepository
& bun tauri build --no-bundle
if ($LASTEXITCODE -ne 0) {
throw "Tauri build failed with exit code $LASTEXITCODE."
@@ -60,11 +68,17 @@ finally {
else {
$env:CARGO_BUILD_JOBS = $PreviousBuildJobs
}
if ($null -eq $PreviousGitCodeRepository) {
Remove-Item Env:GITCODE_REPOSITORY -ErrorAction SilentlyContinue
if ($null -eq $PreviousGitCodeGuiRepository) {
Remove-Item Env:GITCODE_GUI_REPOSITORY -ErrorAction SilentlyContinue
}
else {
$env:GITCODE_REPOSITORY = $PreviousGitCodeRepository
$env:GITCODE_GUI_REPOSITORY = $PreviousGitCodeGuiRepository
}
if ($null -eq $PreviousGitCodeCoreRepository) {
Remove-Item Env:GITCODE_CORE_REPOSITORY -ErrorAction SilentlyContinue
}
else {
$env:GITCODE_CORE_REPOSITORY = $PreviousGitCodeCoreRepository
}
}
+5 -3
View File
@@ -5,7 +5,8 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT_DIR"
BUILD_JOBS="${CARGO_BUILD_JOBS:-20}"
GITCODE_REPOSITORY="${GITCODE_REPOSITORY:-lzt404/EasyCLIProxyAPI}"
GITCODE_GUI_REPOSITORY="${GITCODE_GUI_REPOSITORY:-lzt404/EasyCLIProxyAPI}"
GITCODE_CORE_REPOSITORY="${GITCODE_CORE_REPOSITORY:-lzt404/CLIProxyAPI}"
APP_BIN="$ROOT_DIR/src-tauri/target/release/cpa-gui"
BIN_DIR="$ROOT_DIR/bin-work"
BIN_OUT="$BIN_DIR/EasyCLIProxyAPI"
@@ -16,9 +17,10 @@ if ! command -v bun >/dev/null 2>&1; then
fi
echo "Cargo build jobs: $BUILD_JOBS"
echo "GitCode fallback repository: $GITCODE_REPOSITORY"
echo "GitCode GUI fallback repository: $GITCODE_GUI_REPOSITORY"
echo "GitCode core fallback repository: $GITCODE_CORE_REPOSITORY"
bun install
CARGO_BUILD_JOBS="$BUILD_JOBS" GITCODE_REPOSITORY="$GITCODE_REPOSITORY" bun tauri build --no-bundle
CARGO_BUILD_JOBS="$BUILD_JOBS" GITCODE_GUI_REPOSITORY="$GITCODE_GUI_REPOSITORY" GITCODE_CORE_REPOSITORY="$GITCODE_CORE_REPOSITORY" bun tauri build --no-bundle
if [ ! -x "$APP_BIN" ]; then
echo "Build finished, but executable not found: $APP_BIN"
+1 -1
View File
@@ -582,7 +582,7 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
[[package]]
name = "cpa-gui"
version = "0.2.14"
version = "0.2.16"
dependencies = [
"base64 0.22.1",
"chrono",
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "cpa-gui"
# Single source of truth for the desktop app version.
version = "0.2.14"
version = "0.2.16"
description = "CPA desktop management shell"
authors = ["lzt404"]
edition = "2021"
+2
View File
@@ -1,3 +1,5 @@
fn main() {
println!("cargo:rerun-if-env-changed=GITCODE_GUI_REPOSITORY");
println!("cargo:rerun-if-env-changed=GITCODE_CORE_REPOSITORY");
tauri_build::build()
}
+165 -35
View File
@@ -1563,6 +1563,8 @@ struct GithubRelease {
struct GithubAsset {
name: String,
browser_download_url: String,
#[serde(default)]
fallback_download_urls: Vec<String>,
size: Option<u64>,
digest: Option<String>,
}
@@ -10784,7 +10786,7 @@ async fn fetch_portable_update_manifest(
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 {
let Some(repository) = configured_gitcode_gui_repository() else {
return Err(github_error);
};
let fallback = async {
@@ -10840,8 +10842,16 @@ async fn fetch_portable_update_manifest_url(
Ok(manifest)
}
fn configured_gitcode_repository() -> Option<&'static str> {
let repository = option_env!("GITCODE_REPOSITORY")?.trim();
fn configured_gitcode_gui_repository() -> Option<&'static str> {
configured_gitcode_repository(option_env!("GITCODE_GUI_REPOSITORY"))
}
fn configured_gitcode_core_repository() -> Option<&'static str> {
configured_gitcode_repository(option_env!("GITCODE_CORE_REPOSITORY"))
}
fn configured_gitcode_repository(repository: Option<&'static str>) -> Option<&'static str> {
let repository = repository?.trim();
let mut parts = repository.split('/');
let valid_part = |part: &str| {
!part.is_empty()
@@ -11048,7 +11058,7 @@ fn validate_portable_update_asset_fallbacks(
asset,
tag,
expected_filenames,
configured_gitcode_repository(),
configured_gitcode_gui_repository(),
)
}
@@ -11807,17 +11817,7 @@ async fn install_core_version_inner(
.ok_or_else(|| format!("非法 asset 文件名: {}", asset.name))?;
let archive_path = download_dir.join(archive_file_name);
let downloaded = download_asset(
&client,
&asset.browser_download_url,
&archive_path,
asset.size,
asset.digest.as_deref(),
window,
state,
&token,
)
.await?;
let downloaded = download_asset(&client, asset, &archive_path, window, state, &token).await?;
validate_downloaded_asset(asset, &downloaded)?;
ensure_not_cancelled(&token, Some(&archive_path))?;
@@ -11933,14 +11933,50 @@ async fn fetch_release(
return Ok(release_from_tag(version));
}
let atom_result = fetch_release_from_atom(client).await;
match atom_result {
let github_result = match atom_result {
Ok(release) => Ok(release),
Err(atom_error) => fetch_release_from_page(client).await.map_err(|page_error| {
format!("GitHub 发布源请求失败: {atom_error};release 页面请求失败: {page_error}")
}),
};
match github_result {
Ok(release) => Ok(release),
Err(github_error) => {
let Some(repository) = configured_gitcode_core_repository() else {
return Err(github_error);
};
fetch_release_from_gitcode(client, repository)
.await
.map_err(|gitcode_error| {
format!(
"GitHub 内核发布源失败: {github_error};GitCode 回退源失败: {gitcode_error}"
)
})
}
}
}
async fn fetch_release_from_gitcode(
client: &reqwest::Client,
repository: &str,
) -> Result<GithubRelease, String> {
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, USER_AGENT)
.send()
.await
.map_err(|error| format!("查询 GitCode 最新内核发行版失败: {error}"))?
.error_for_status()
.map_err(|error| format!("读取 GitCode 最新内核发行版失败: {error}"))?
.json::<GitcodeRelease>()
.await
.map_err(|error| format!("解析 GitCode 最新内核发行版失败: {error}"))?;
validate_release_tag(&release.tag_name)?;
Ok(release_from_gitcode_tag(&release.tag_name, repository))
}
async fn fetch_release_from_page(client: &reqwest::Client) -> Result<GithubRelease, String> {
let response = client
.get(RELEASE_PAGE_URL)
@@ -12010,6 +12046,18 @@ fn release_tag_from_atom(xml: &str) -> Option<String> {
}
fn release_from_tag(tag: &str) -> GithubRelease {
release_from_tag_for_repositories(tag, configured_gitcode_core_repository(), false)
}
fn release_from_gitcode_tag(tag: &str, repository: &str) -> GithubRelease {
release_from_tag_for_repositories(tag, Some(repository), true)
}
fn release_from_tag_for_repositories(
tag: &str,
gitcode_repository: Option<&str>,
prefer_gitcode: bool,
) -> GithubRelease {
let tag = normalize_version(tag);
let version = tag.trim_start_matches('v');
let assets = [
@@ -12023,8 +12071,20 @@ fn release_from_tag(tag: &str) -> GithubRelease {
.into_iter()
.map(|(os, arch, extension)| {
let name = format!("CLIProxyAPI_{version}_{os}_{arch}.{extension}");
let github_url = format!("{RELEASE_DOWNLOAD_PREFIX}{tag}/{name}");
let gitcode_url = gitcode_repository
.map(|repository| gitcode_release_attachment_url(repository, &tag, &name));
let (browser_download_url, fallback_download_urls) = if prefer_gitcode {
(
gitcode_url.unwrap_or_else(|| github_url.clone()),
Vec::new(),
)
} else {
(github_url, gitcode_url.into_iter().collect())
};
GithubAsset {
browser_download_url: format!("{RELEASE_DOWNLOAD_PREFIX}{tag}/{name}"),
browser_download_url,
fallback_download_urls,
name,
size: None,
digest: None,
@@ -12099,6 +12159,7 @@ fn parse_release_assets(html: &str) -> Vec<GithubAsset> {
assets.push(GithubAsset {
name: name.to_string(),
browser_download_url,
fallback_download_urls: Vec::new(),
size: None,
digest,
});
@@ -12136,6 +12197,7 @@ async fn fetch_release_cancelable(
fn http_client() -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.redirect(release_https_redirect_policy())
.connect_timeout(Duration::from_secs(15))
.read_timeout(Duration::from_secs(30))
.timeout(Duration::from_secs(600))
@@ -12176,30 +12238,53 @@ fn core_release_asset_name(version: &str, platform: &CorePlatform) -> String {
#[allow(clippy::too_many_arguments)]
async fn download_asset(
client: &reqwest::Client,
url: &str,
asset: &GithubAsset,
archive_path: &Path,
expected_total: Option<u64>,
expected_digest: Option<&str>,
window: &tauri::Window,
state: &CoreDownloadState,
token: &CancellationToken,
) -> Result<DownloadedArchive, String> {
let result = download_asset_inner(
client,
url,
archive_path,
expected_total,
expected_digest,
window,
state,
token,
)
.await;
if result.is_err() {
let _ = fs::remove_file(archive_path);
let urls =
std::iter::once(&asset.browser_download_url).chain(asset.fallback_download_urls.iter());
let mut failures = Vec::new();
for (index, url) in urls.enumerate() {
if index > 0 {
state.progress(
window,
"GitHub 下载失败,正在切换到 GitCode",
0,
asset.size,
true,
);
}
let result = download_asset_inner(
client,
url,
archive_path,
asset.size,
asset.digest.as_deref(),
window,
state,
token,
)
.await;
match result {
Ok(downloaded) => return Ok(downloaded),
Err(error) if token.is_cancelled() => {
let _ = fs::remove_file(archive_path);
return Err(error);
}
Err(error) => {
let _ = fs::remove_file(archive_path);
failures.push(error);
}
}
}
result
if failures.is_empty() {
let _ = fs::remove_file(archive_path);
return Err("内核发行版没有可用的下载地址".to_string());
}
Err(format!("所有内核下载源均失败: {}", failures.join(";")))
}
#[allow(clippy::too_many_arguments)]
@@ -24027,6 +24112,51 @@ custom_option = "keep-original"
);
}
#[test]
fn synthetic_core_release_uses_gitcode_as_download_fallback() {
let release = release_from_tag_for_repositories(
"7.2.80",
Some("lzt404/CLIProxyAPI"),
false,
);
let platform = CorePlatform {
os: "windows".to_string(),
arch: "x86_64".to_string(),
asset_os: "windows".to_string(),
asset_arch: "amd64".to_string(),
archive_kind: "zip".to_string(),
};
let asset = select_release_asset(&release, &platform).unwrap();
assert_eq!(
asset.browser_download_url,
"https://github.com/router-for-me/CLIProxyAPI/releases/download/v7.2.80/CLIProxyAPI_7.2.80_windows_amd64.zip"
);
assert_eq!(
asset.fallback_download_urls,
["https://api.gitcode.com/api/v5/repos/lzt404/CLIProxyAPI/releases/v7.2.80/attach_files/CLIProxyAPI_7.2.80_windows_amd64.zip/download"]
);
}
#[test]
fn gitcode_discovered_core_release_downloads_from_gitcode_first() {
let release = release_from_gitcode_tag("v7.2.80", "lzt404/CLIProxyAPI");
let platform = CorePlatform {
os: "linux".to_string(),
arch: "aarch64".to_string(),
asset_os: "linux".to_string(),
asset_arch: "aarch64".to_string(),
archive_kind: "tar.gz".to_string(),
};
let asset = select_release_asset(&release, &platform).unwrap();
assert_eq!(
asset.browser_download_url,
"https://api.gitcode.com/api/v5/repos/lzt404/CLIProxyAPI/releases/v7.2.80/attach_files/CLIProxyAPI_7.2.80_linux_aarch64.tar.gz/download"
);
assert!(asset.fallback_download_urls.is_empty());
}
#[test]
fn release_asset_names_cover_the_six_supported_gui_targets() {
let targets = [