From e8b2ced3f87499e3da38bf2261792fe11c96b3bb Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Fri, 7 Aug 2026 15:20:30 +0800 Subject: [PATCH] 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 --- .github/workflows/release.yml | 5 +- build.ps1 | 32 ++++-- build.sh | 8 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/build.rs | 2 + src-tauri/src/main.rs | 200 ++++++++++++++++++++++++++++------ 7 files changed, 200 insertions(+), 51 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d42b2d0..e9066f1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/build.ps1 b/build.ps1 index afab82e..914fe6c 100644 --- a/build.ps1 +++ b/build.ps1 @@ -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 } } diff --git a/build.sh b/build.sh index bb91c99..9ecabcf 100755 --- a/build.sh +++ b/build.sh @@ -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" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b00fbcc..b490e1e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -582,7 +582,7 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" [[package]] name = "cpa-gui" -version = "0.2.14" +version = "0.2.16" dependencies = [ "base64 0.22.1", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 83b3518..0bc8bbf 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index d860e1e..d6dc042 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -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() } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index d9571c0..a1e5ad9 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1563,6 +1563,8 @@ struct GithubRelease { struct GithubAsset { name: String, browser_download_url: String, + #[serde(default)] + fallback_download_urls: Vec, size: Option, digest: Option, } @@ -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 { + 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::() + .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 { let response = client .get(RELEASE_PAGE_URL) @@ -12010,6 +12046,18 @@ fn release_tag_from_atom(xml: &str) -> Option { } 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 { 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::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, - expected_digest: Option<&str>, window: &tauri::Window, state: &CoreDownloadState, token: &CancellationToken, ) -> Result { - 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 = [