From 03be4acf936f1a2dd6ff54510dc24030c70331ca Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Tue, 25 Aug 2026 15:21:43 +0800 Subject: [PATCH] feat: add multi-source downloads with automatic fallback - support GitHub, GitCode, gh-proxy, and ghfast sources - automatically try other sources when version checks fail - persist the working fallback source and sync the UI immediately - update translations, configuration migration, and source tests --- README.ja.md | 2 +- README.md | 4 +- README.zh-CN.md | 2 +- src-tauri/src/app_update.rs | 156 ++++++++++++++++++-------- src-tauri/src/core_config/settings.rs | 10 ++ src-tauri/src/core_runtime.rs | 150 +++++++++++++++---------- src-tauri/src/main.rs | 114 ++++++++++++++++++- src-tauri/src/tests/app_settings.rs | 3 + src-tauri/src/tests/app_update.rs | 77 +++++++++++-- src-tauri/src/tests/core_config.rs | 1 + src-tauri/src/usage.rs | 3 + src/i18n/ja.ts | 11 +- src/i18n/locales/en.ts | 11 +- src/i18n/locales/zh-CN.ts | 11 +- src/pages/VersionManagementPage.tsx | 72 ++++++++---- src/styles.css | 37 ++++++ 16 files changed, 507 insertions(+), 157 deletions(-) diff --git a/README.ja.md b/README.ja.md index ebf9ffd..f7579b6 100644 --- a/README.ja.md +++ b/README.ja.md @@ -38,7 +38,7 @@ API プロバイダー統合、プロトコル変換、認証情報管理、ク - OpenAI、Claude、Gemini 互換の API エンドポイントをそのままコピー。 - ローカル接続状態の確認。 -コアのインストール、バージョン比較、オフラインインストールは **バージョン管理** ページで行えます。 +コアのインストール、バージョン比較、オフラインインストールは **バージョン管理** ページで行えます。GitHub 公式、GitCode、GitHub ミラープロキシを切り替えられ、更新に失敗した場合は自動的に別の利用可能なソースへ切り替わります。 ### OAuth アカウント認証 diff --git a/README.md b/README.md index 0098de6..be6628a 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,8 @@ The Home page provides a quick overview of the local proxy runtime and ready-to- - Check local connectivity and the application/core version at a glance. Core installation, version comparison, and offline installation are available from the -**Version Management** page. Users in mainland China can manually enable the GitCode -download source there so application and core updates prefer the mirror. +**Version Management** page. You can switch between official GitHub, GitCode, and GitHub +mirror proxies; application and core updates prefer the selected channel and fall back automatically. ### OAuth account authorization diff --git a/README.zh-CN.md b/README.zh-CN.md index 1ba8669..c10c6ae 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -45,7 +45,7 @@ APIMart 是一个低成本的 AI 图像与视频生成 API 平台——GPT-Image - 复制可直接使用的 OpenAI、Claude 和 Gemini 兼容 API 地址。 - 查看本地连接状态。 -内核安装、版本对比和离线安装功能位于 **版本管理** 页面;国内网络环境可在此手动开启 GitCode 下载源,软件与内核更新会优先使用该镜像。 +内核安装、版本对比和离线安装功能位于 **版本管理** 页面;可在此切换 GitHub 官方源、GitCode 国内源或 GitHub 镜像代理,软件与内核更新会优先使用所选渠道,并在失败时自动回退。 ### OAuth 账号授权 diff --git a/src-tauri/src/app_update.rs b/src-tauri/src/app_update.rs index 2d5d156..3298400 100644 --- a/src-tauri/src/app_update.rs +++ b/src-tauri/src/app_update.rs @@ -9,7 +9,24 @@ pub(crate) fn get_version_source_settings( ) -> Result { let config = gui_config_state.snapshot()?; Ok(VersionSourceSettings { - prefer_gitcode_downloads: config.prefer_gitcode_downloads, + source: config.download_source, + gitcode_available: gitcode_version_source_available(), + }) +} + +#[tauri::command] +pub(crate) fn set_download_source( + gui_config_state: tauri::State<'_, GuiConfigState>, + source: String, +) -> Result { + let source = + VersionDownloadSource::from_str(&source).ok_or_else(|| "不支持的下载源".to_string())?; + if source == VersionDownloadSource::Gitcode && !gitcode_version_source_available() { + return Err("当前构建未配置完整的 GitCode 软件与内核镜像源".to_string()); + } + let config = gui_config_state.set_download_source(source)?; + Ok(VersionSourceSettings { + source: config.download_source, gitcode_available: gitcode_version_source_available(), }) } @@ -19,12 +36,17 @@ pub(crate) fn set_prefer_gitcode_downloads( gui_config_state: tauri::State<'_, GuiConfigState>, enabled: bool, ) -> Result { + let source = if enabled { + VersionDownloadSource::Gitcode + } else { + VersionDownloadSource::Github + }; if enabled && !gitcode_version_source_available() { return Err("当前构建未配置完整的 GitCode 软件与内核镜像源".to_string()); } - let config = gui_config_state.set_prefer_gitcode_downloads(enabled)?; + let config = gui_config_state.set_download_source(source)?; Ok(VersionSourceSettings { - prefer_gitcode_downloads: config.prefer_gitcode_downloads, + source: config.download_source, gitcode_available: gitcode_version_source_available(), }) } @@ -33,6 +55,27 @@ pub(crate) fn gitcode_version_source_available() -> bool { configured_gitcode_gui_repository().is_some() && configured_gitcode_core_repository().is_some() } +pub(crate) fn persist_automatic_download_source_switch( + app: &tauri::AppHandle, + gui_config_state: &GuiConfigState, + requested_source: VersionDownloadSource, + resolved_source: VersionDownloadSource, +) -> Result<(), String> { + let Some(config) = + gui_config_state.switch_download_source_after_failure(requested_source, resolved_source)? + else { + return Ok(()); + }; + app.emit( + VERSION_DOWNLOAD_SOURCE_CHANGED_EVENT, + VersionSourceSettings { + source: config.download_source, + gitcode_available: gitcode_version_source_available(), + }, + ) + .map_err(|error| format!("通知下载源自动切换失败: {error}")) +} + #[tauri::command] pub(crate) fn open_external_url(app: tauri::AppHandle, url: String) -> Result<(), String> { open_external_url_inner(&app, &url) @@ -40,6 +83,7 @@ pub(crate) fn open_external_url(app: tauri::AppHandle, url: String) -> Result<() #[tauri::command] pub(crate) async fn check_app_update( + app: tauri::AppHandle, state: tauri::State<'_, AppUpdateState>, gui_config_state: tauri::State<'_, GuiConfigState>, ) -> Result { @@ -54,7 +98,14 @@ pub(crate) async fn check_app_update( &proxy_url, "创建版本检查客户端失败", )?; - let manifest = fetch_portable_update_manifest(&client, config.prefer_gitcode_downloads).await?; + let (manifest, resolved_source) = + fetch_portable_update_manifest(&client, config.download_source).await?; + persist_automatic_download_source_switch( + &app, + gui_config_state.inner(), + config.download_source, + resolved_source, + )?; let latest_version = normalize_version(&manifest.version); let current_version = normalize_version(env!("CARGO_PKG_VERSION")); let update_available = is_app_update_available(¤t_version, &latest_version)?; @@ -110,36 +161,32 @@ pub(crate) async fn check_app_update( pub(crate) async fn fetch_portable_update_manifest( client: &reqwest::Client, - prefer_gitcode: bool, -) -> Result { - let github = || fetch_portable_update_manifest_url(client, APP_UPDATE_MANIFEST_URL); - let Some(repository) = configured_gitcode_gui_repository() else { - return github().await; - }; - - if prefer_gitcode { - match fetch_portable_update_manifest_from_gitcode(client, repository).await { - Ok(manifest) => Ok(manifest), - Err(gitcode_error) => github().await.map_err(|github_error| { - format!( - "GitCode update source failed: {gitcode_error}; GitHub fallback failed: {github_error}" + source: VersionDownloadSource, +) -> Result<(PortableUpdateManifest, VersionDownloadSource), String> { + let gitcode_repository = configured_gitcode_gui_repository(); + let mut failures = Vec::new(); + for candidate in version_download_source_candidates(source, gitcode_repository.is_some()) { + let result = match candidate { + VersionDownloadSource::Gitcode => { + fetch_portable_update_manifest_from_gitcode( + client, + gitcode_repository.expect("GitCode candidate requires a configured repository"), ) - }), - } - } else { - match github().await { - Ok(manifest) => Ok(manifest), - Err(github_error) => { - fetch_portable_update_manifest_from_gitcode(client, repository) - .await - .map_err(|gitcode_error| { - format!( - "GitHub update source failed: {github_error}; GitCode fallback failed: {gitcode_error}" - ) - }) + .await } + VersionDownloadSource::Github + | VersionDownloadSource::GhProxy + | VersionDownloadSource::GhFast => { + let url = version_source_url(candidate, APP_UPDATE_MANIFEST_URL); + fetch_portable_update_manifest_url(client, &url).await + } + }; + match result { + Ok(manifest) => return Ok((manifest, candidate)), + Err(error) => failures.push(format!("{}: {error}", candidate.display_name())), } } + Err(format!("所有软件版本检测源均失败: {}", failures.join("; "))) } pub(crate) async fn fetch_portable_update_manifest_from_gitcode( @@ -240,6 +287,8 @@ pub(crate) fn release_https_redirect_policy() -> reqwest::redirect::Policy { | "api.gitcode.com" | "gitcode.com" | "file-cdn.gitcode.com" + | "gh-proxy.com" + | "ghfast.top" ) ); if url.scheme() == "https" @@ -518,7 +567,7 @@ pub(crate) async fn start_app_update( } let config = gui_config_state.snapshot()?; let proxy_url = config.proxy_url; - let prefer_gitcode = config.prefer_gitcode_downloads; + let download_source = config.download_source; let token = CancellationToken::new(); let pending = state.start(token.clone())?; let task = state.snapshot(); @@ -530,7 +579,7 @@ pub(crate) async fn start_app_update( &pending, &token, &proxy_url, - prefer_gitcode, + download_source, ) .await; if let Err(error) = outcome { @@ -555,11 +604,11 @@ pub(crate) async fn download_and_stage_portable_app_update( pending: &PendingAppUpdate, token: &CancellationToken, proxy_url: &str, - prefer_gitcode: bool, + download_source: VersionDownloadSource, ) -> Result<(), String> { #[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] { - let _ = (app, pending, token, proxy_url, prefer_gitcode); + let _ = (app, pending, token, proxy_url, download_source); Err("当前平台不支持应用内自动升级".to_string()) } @@ -585,7 +634,7 @@ pub(crate) async fn download_and_stage_portable_app_update( token, &archive_path, proxy_url, - prefer_gitcode, + download_source, ) .await?; if token.is_cancelled() { @@ -671,7 +720,7 @@ pub(crate) async fn download_and_stage_portable_app_update( token, &archive_path, proxy_url, - prefer_gitcode, + download_source, ) .await?; ensure_portable_update_download(token, &archive_path, pending)?; @@ -743,7 +792,7 @@ pub(crate) async fn download_and_stage_portable_app_update( token, &archive_path, proxy_url, - prefer_gitcode, + download_source, ) .await?; ensure_portable_update_download(token, &archive_path, pending)?; @@ -918,7 +967,7 @@ pub(crate) async fn download_portable_update_archive( token: &CancellationToken, destination: &Path, proxy_url: &str, - prefer_gitcode: bool, + download_source: VersionDownloadSource, ) -> Result<(), String> { let client = build_http_client_with_proxy( reqwest::Client::builder() @@ -929,7 +978,7 @@ pub(crate) async fn download_portable_update_archive( proxy_url, "创建应用更新下载客户端失败", )?; - let urls = portable_update_download_urls(&pending.asset, prefer_gitcode); + let urls = portable_update_download_urls(&pending.asset, download_source); let mut failures = Vec::new(); for (index, url) in urls.iter().enumerate() { update_app_task(app, |task| { @@ -955,24 +1004,37 @@ pub(crate) async fn download_portable_update_archive( pub(crate) fn portable_update_download_urls( asset: &PortableUpdateAsset, - prefer_gitcode: bool, -) -> Vec<&str> { + source: VersionDownloadSource, +) -> Vec { let mut urls = std::iter::once(asset.url.as_str()) .chain(asset.fallback_urls.iter().map(String::as_str)) + .map(str::to_string) .collect::>(); - if prefer_gitcode { + if source == VersionDownloadSource::Gitcode { urls.sort_by_key(|url| update_download_source_name(url) != "GitCode"); + } else if source.github_proxy_prefix().is_some() { + if let Some(github_url) = urls + .iter() + .find(|url| update_download_source_name(url) == "GitHub") + .cloned() + { + urls.insert(0, version_source_url(source, &github_url)); + } } + urls.dedup(); urls } pub(crate) fn update_download_source_name(url: &str) -> &'static str { - reqwest::Url::parse(url) + let host = reqwest::Url::parse(url) .ok() - .and_then(|url| url.host_str().map(str::to_string)) - .filter(|host| host == "api.gitcode.com" || host.ends_with(".gitcode.com")) - .map(|_| "GitCode") - .unwrap_or("GitHub") + .and_then(|url| url.host_str().map(str::to_string)); + match host.as_deref() { + Some("gh-proxy.com") => "gh-proxy.com", + Some("ghfast.top") => "ghfast.top", + Some(host) if host == "api.gitcode.com" || host.ends_with(".gitcode.com") => "GitCode", + _ => "GitHub", + } } #[cfg(any(windows, target_os = "linux", target_os = "macos"))] diff --git a/src-tauri/src/core_config/settings.rs b/src-tauri/src/core_config/settings.rs index 50bd8b5..fd512d2 100644 --- a/src-tauri/src/core_config/settings.rs +++ b/src-tauri/src/core_config/settings.rs @@ -1237,9 +1237,18 @@ pub(crate) fn load_or_create_gui_config() -> Result { if presence.silent_start.is_none() { changed = true; } + if presence.download_source.is_none() { + config.download_source = if config.prefer_gitcode_downloads { + VersionDownloadSource::Gitcode + } else { + VersionDownloadSource::Github + }; + changed = true; + } if presence.prefer_gitcode_downloads.is_none() { changed = true; } + config.prefer_gitcode_downloads = config.download_source == VersionDownloadSource::Gitcode; let management_secret_rotated = ensure_strong_management_secret(&mut config)?; changed |= management_secret_rotated; changed |= sanitize_gui_config(&mut config)?; @@ -1593,6 +1602,7 @@ pub(crate) fn write_gui_config_to_path( ("plugins-enabled", value(config.plugins_enabled)), ("routing-strategy", value(config.routing_strategy.as_str())), ("proxy-url", value(config.proxy_url.as_str())), + ("download-source", value(config.download_source.as_str())), ( "prefer-gitcode-downloads", value(config.prefer_gitcode_downloads), diff --git a/src-tauri/src/core_runtime.rs b/src-tauri/src/core_runtime.rs index 115747e..93f6e3c 100644 --- a/src-tauri/src/core_runtime.rs +++ b/src-tauri/src/core_runtime.rs @@ -2,13 +2,20 @@ use super::*; #[tauri::command] pub(crate) async fn check_latest_core( + app: tauri::AppHandle, gui_config_state: tauri::State<'_, GuiConfigState>, ) -> Result { let platform = current_core_platform()?; let config = gui_config_state.snapshot()?; let proxy_url = config.proxy_url; let client = http_client(&proxy_url)?; - let release = fetch_release(&client, None, config.prefer_gitcode_downloads).await?; + let (release, resolved_source) = fetch_release(&client, None, config.download_source).await?; + persist_automatic_download_source_switch( + &app, + gui_config_state.inner(), + config.download_source, + resolved_source, + )?; let asset = select_release_asset(&release, &platform)?; Ok(CoreLatest { @@ -107,7 +114,7 @@ pub(crate) async fn install_core_version( token, version, &proxy_url, - config.prefer_gitcode_downloads, + config.download_source, ) .await, ), @@ -269,13 +276,13 @@ pub(crate) async fn install_core_version_inner( token: CancellationToken, version: Option, proxy_url: &str, - prefer_gitcode: bool, + download_source: VersionDownloadSource, ) -> Result { let platform = current_core_platform()?; let client = http_client(proxy_url)?; state.progress(window, "检查版本", 0, None, true); - let release = - fetch_release_cancelable(&client, version.as_deref(), &token, prefer_gitcode).await?; + let (release, _) = + fetch_release_cancelable(&client, version.as_deref(), &token, download_source).await?; let asset = select_release_asset(&release, &platform)?; let install_dir = core_install_dir()?; @@ -406,56 +413,53 @@ pub(crate) fn install_bundled_core_inner( pub(crate) async fn fetch_release( client: &reqwest::Client, version: Option<&str>, - prefer_gitcode: bool, -) -> Result { + source: VersionDownloadSource, +) -> Result<(GithubRelease, VersionDownloadSource), String> { if let Some(version) = version { - return Ok(release_from_tag_for_repositories( - version, - configured_gitcode_core_repository(), - prefer_gitcode, + return Ok(( + release_from_tag_for_repositories( + version, + configured_gitcode_core_repository(), + source, + ), + source, )); } - if prefer_gitcode { - if let Some(repository) = configured_gitcode_core_repository() { - return match fetch_release_from_gitcode(client, repository).await { - Ok(release) => Ok(release), - Err(gitcode_error) => fetch_release_from_github(client) - .await - .map_err(|github_error| { - format!( - "GitCode 内核发布源失败: {gitcode_error};GitHub 回退源失败: {github_error}" - ) - }), - }; - } - } - let github_result = fetch_release_from_github(client).await; - 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) + let gitcode_repository = configured_gitcode_core_repository(); + let mut failures = Vec::new(); + for candidate in version_download_source_candidates(source, gitcode_repository.is_some()) { + let result = match candidate { + VersionDownloadSource::Gitcode => { + fetch_release_from_gitcode( + client, + gitcode_repository.expect("GitCode candidate requires a configured repository"), + ) .await - .map_err(|gitcode_error| { - format!( - "GitHub 内核发布源失败: {github_error};GitCode 回退源失败: {gitcode_error}" - ) - }) + } + VersionDownloadSource::Github + | VersionDownloadSource::GhProxy + | VersionDownloadSource::GhFast => fetch_release_from_github(client, candidate).await, + }; + match result { + Ok(release) => return Ok((release, candidate)), + Err(error) => failures.push(format!("{}: {error}", candidate.display_name())), } } + Err(format!("所有内核版本检测源均失败: {}", failures.join(";"))) } pub(crate) async fn fetch_release_from_github( client: &reqwest::Client, + source: VersionDownloadSource, ) -> Result { - let atom_result = fetch_release_from_atom(client).await; + let atom_result = fetch_release_from_atom(client, source).await; 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}") - }), + Err(atom_error) => fetch_release_from_page(client, source) + .await + .map_err(|page_error| { + format!("GitHub 发布源请求失败: {atom_error};release 页面请求失败: {page_error}") + }), } } @@ -482,9 +486,11 @@ pub(crate) async fn fetch_release_from_gitcode( pub(crate) async fn fetch_release_from_page( client: &reqwest::Client, + source: VersionDownloadSource, ) -> Result { + let release_page_url = version_source_url(source, RELEASE_PAGE_URL); let response = client - .get(RELEASE_PAGE_URL) + .get(release_page_url) .header(reqwest::header::ACCEPT, "text/html,application/xhtml+xml") .header(reqwest::header::USER_AGENT, USER_AGENT) .send() @@ -502,14 +508,20 @@ pub(crate) async fn fetch_release_from_page( let tag = release_tag_from_url(&final_url) .ok_or_else(|| "GitHub release 页面没有返回版本标签".to_string())?; - Ok(release_from_tag(&tag)) + Ok(release_from_tag_for_repositories( + &tag, + configured_gitcode_core_repository(), + source, + )) } pub(crate) async fn fetch_release_from_atom( client: &reqwest::Client, + source: VersionDownloadSource, ) -> Result { + let release_atom_url = version_source_url(source, RELEASE_ATOM_URL); let response = client - .get(RELEASE_ATOM_URL) + .get(release_atom_url) .header( reqwest::header::ACCEPT, "application/atom+xml,application/xml,text/xml", @@ -528,7 +540,11 @@ pub(crate) async fn fetch_release_from_atom( } let tag = release_tag_from_atom(&body) .ok_or_else(|| "GitHub Atom feed 没有返回版本标签".to_string())?; - Ok(release_from_tag(&tag)) + Ok(release_from_tag_for_repositories( + &tag, + configured_gitcode_core_repository(), + source, + )) } pub(crate) fn release_tag_from_atom(xml: &str) -> Option { @@ -553,17 +569,21 @@ pub(crate) fn release_tag_from_atom(xml: &str) -> Option { } pub(crate) fn release_from_tag(tag: &str) -> GithubRelease { - release_from_tag_for_repositories(tag, configured_gitcode_core_repository(), false) + release_from_tag_for_repositories( + tag, + configured_gitcode_core_repository(), + VersionDownloadSource::Github, + ) } pub(crate) fn release_from_gitcode_tag(tag: &str, repository: &str) -> GithubRelease { - release_from_tag_for_repositories(tag, Some(repository), true) + release_from_tag_for_repositories(tag, Some(repository), VersionDownloadSource::Gitcode) } pub(crate) fn release_from_tag_for_repositories( tag: &str, gitcode_repository: Option<&str>, - prefer_gitcode: bool, + source: VersionDownloadSource, ) -> GithubRelease { let tag = normalize_version(tag); let version = tag.trim_start_matches('v'); @@ -581,13 +601,18 @@ pub(crate) fn release_from_tag_for_repositories( 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 { - match gitcode_url { + let (browser_download_url, fallback_download_urls) = match source { + VersionDownloadSource::Gitcode => match gitcode_url { Some(gitcode_url) => (gitcode_url, vec![github_url]), None => (github_url, Vec::new()), + }, + VersionDownloadSource::GhProxy | VersionDownloadSource::GhFast => { + let mirror_url = version_source_url(source, &github_url); + let mut fallbacks = vec![github_url]; + fallbacks.extend(gitcode_url); + (mirror_url, fallbacks) } - } else { - (github_url, gitcode_url.into_iter().collect()) + VersionDownloadSource::Github => (github_url, gitcode_url.into_iter().collect()), }; GithubAsset { browser_download_url, @@ -695,10 +720,10 @@ pub(crate) async fn fetch_release_cancelable( client: &reqwest::Client, version: Option<&str>, token: &CancellationToken, - prefer_gitcode: bool, -) -> Result { + source: VersionDownloadSource, +) -> Result<(GithubRelease, VersionDownloadSource), String> { tokio::select! { - result = fetch_release(client, version, prefer_gitcode) => result, + result = fetch_release(client, version, source) => result, _ = token.cancelled() => Err("已取消下载".to_string()), } } @@ -822,12 +847,15 @@ pub(crate) async fn download_asset( } pub(crate) fn core_download_source_name(url: &str) -> &'static str { - reqwest::Url::parse(url) + let host = reqwest::Url::parse(url) .ok() - .and_then(|url| url.host_str().map(str::to_string)) - .filter(|host| host == "api.gitcode.com" || host.ends_with(".gitcode.com")) - .map(|_| "GitCode") - .unwrap_or("GitHub") + .and_then(|url| url.host_str().map(str::to_string)); + match host.as_deref() { + Some("gh-proxy.com") => "gh-proxy.com", + Some("ghfast.top") => "ghfast.top", + Some(host) if host == "api.gitcode.com" || host.ends_with(".gitcode.com") => "GitCode", + _ => "GitHub", + } } #[allow(clippy::too_many_arguments)] diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 8e5acfe..c7f180e 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -110,6 +110,7 @@ const PORTABLE_APP_BINARY: &str = "EasyCLIProxyAPI"; const CORE_INSTALL_PROGRESS_EVENT: &str = "core-install-progress"; const CORE_STATUS_EVENT: &str = "core-status-changed"; const CONFIG_FILES_CHANGED_EVENT: &str = "config-files-changed"; +const VERSION_DOWNLOAD_SOURCE_CHANGED_EVENT: &str = "version-download-source-changed"; #[cfg(target_os = "windows")] const WINDOWS_CLOSE_REQUEST_EVENT: &str = "windows-close-requested"; const CORE_METADATA_FILE: &str = "cpa-gui-meta.json"; @@ -591,6 +592,9 @@ struct GuiConfigFile { plugins_enabled: bool, routing_strategy: String, proxy_url: String, + download_source: VersionDownloadSource, + // Kept for migration compatibility with configurations written before + // multi-source downloads were introduced. prefer_gitcode_downloads: bool, routing_session_affinity: bool, routing_session_affinity_ttl: String, @@ -600,6 +604,82 @@ struct GuiConfigFile { streaming_bootstrap_retries: u32, } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +enum VersionDownloadSource { + #[default] + Github, + Gitcode, + GhProxy, + GhFast, +} + +impl VersionDownloadSource { + fn as_str(self) -> &'static str { + match self { + Self::Github => "github", + Self::Gitcode => "gitcode", + Self::GhProxy => "gh-proxy", + Self::GhFast => "gh-fast", + } + } + + fn from_str(value: &str) -> Option { + match value.trim() { + "github" => Some(Self::Github), + "gitcode" => Some(Self::Gitcode), + "gh-proxy" => Some(Self::GhProxy), + "gh-fast" => Some(Self::GhFast), + _ => None, + } + } + + fn display_name(self) -> &'static str { + match self { + Self::Github => "GitHub", + Self::Gitcode => "GitCode", + Self::GhProxy => "gh-proxy.com", + Self::GhFast => "ghfast.top", + } + } + + fn github_proxy_prefix(self) -> Option<&'static str> { + match self { + Self::GhProxy => Some("https://gh-proxy.com/"), + Self::GhFast => Some("https://ghfast.top/"), + _ => None, + } + } +} + +fn version_source_url(source: VersionDownloadSource, github_url: &str) -> String { + source + .github_proxy_prefix() + .map(|prefix| format!("{prefix}{github_url}")) + .unwrap_or_else(|| github_url.to_string()) +} + +fn version_download_source_candidates( + preferred: VersionDownloadSource, + gitcode_available: bool, +) -> Vec { + [ + preferred, + VersionDownloadSource::Github, + VersionDownloadSource::Gitcode, + VersionDownloadSource::GhProxy, + VersionDownloadSource::GhFast, + ] + .into_iter() + .filter(|source| gitcode_available || *source != VersionDownloadSource::Gitcode) + .fold(Vec::new(), |mut sources, source| { + if !sources.contains(&source) { + sources.push(source); + } + sources + }) +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] #[serde(rename_all = "kebab-case")] enum WindowsCloseAction { @@ -704,6 +784,7 @@ impl Default for GuiConfigFile { plugins_enabled: false, routing_strategy: "round-robin".to_string(), proxy_url: String::new(), + download_source: VersionDownloadSource::Github, prefer_gitcode_downloads: false, routing_session_affinity: false, routing_session_affinity_ttl: String::new(), @@ -733,6 +814,7 @@ struct GuiConfigPresence { plugins_enabled: Option, routing_strategy: Option, proxy_url: Option, + download_source: Option, prefer_gitcode_downloads: Option, routing_session_affinity: Option, routing_session_affinity_ttl: Option, @@ -755,7 +837,7 @@ struct GuiSettings { #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] struct VersionSourceSettings { - prefer_gitcode_downloads: bool, + source: VersionDownloadSource, gitcode_available: bool, } @@ -1743,16 +1825,39 @@ impl GuiConfigState { }) } - fn set_prefer_gitcode_downloads( + fn set_download_source( &self, - prefer_gitcode_downloads: bool, + download_source: VersionDownloadSource, ) -> Result { self.update(|config| { - config.prefer_gitcode_downloads = prefer_gitcode_downloads; + config.download_source = download_source; + config.prefer_gitcode_downloads = download_source == VersionDownloadSource::Gitcode; Ok(()) }) } + fn switch_download_source_after_failure( + &self, + expected_source: VersionDownloadSource, + fallback_source: VersionDownloadSource, + ) -> Result, String> { + let mut current = self + .inner + .lock() + .map_err(|_| "GUI 配置状态锁已损坏".to_string())?; + if current.download_source != expected_source || expected_source == fallback_source { + return Ok(None); + } + + let mut config = current.clone(); + config.download_source = fallback_source; + config.prefer_gitcode_downloads = fallback_source == VersionDownloadSource::Gitcode; + sanitize_gui_config(&mut config)?; + write_gui_config(&config)?; + *current = config.clone(); + Ok(Some(config)) + } + fn set_window_size(&self, size: SavedWindowSize) -> Result { self.update(|config| { config.window_width = Some(size.width); @@ -2208,6 +2313,7 @@ fn main() { open_oauth_url, open_external_url, get_version_source_settings, + set_download_source, set_prefer_gitcode_downloads, check_app_update, get_app_update_task, diff --git a/src-tauri/src/tests/app_settings.rs b/src-tauri/src/tests/app_settings.rs index 4c64726..61dd066 100644 --- a/src-tauri/src/tests/app_settings.rs +++ b/src-tauri/src/tests/app_settings.rs @@ -18,6 +18,7 @@ fn gui_field_edit_preserves_comments_and_unknown_configuration() { auth_dir: path_to_string(&home.join("custom-auth")), management_secret_key: "custom-secret".to_string(), usage_statistics_enabled: false, + download_source: VersionDownloadSource::Gitcode, prefer_gitcode_downloads: true, ..GuiConfigFile::default() }; @@ -32,6 +33,7 @@ fn gui_field_edit_preserves_comments_and_unknown_configuration() { assert!(content.contains("silent-start = true")); assert!(content.contains("management-secret-key = \"custom-secret\"")); assert!(content.contains("usage-statistics-enabled = false")); + assert!(content.contains("download-source = \"gitcode\"")); assert!(content.contains("prefer-gitcode-downloads = true")); assert!(!content.contains("codex-session-repair-on-launch")); assert!(!content.contains("claude-code-working-directory")); @@ -91,6 +93,7 @@ fn gui_config_defaults_are_stable() { assert!(content.contains("management-secret-key = \"\"")); assert!(content.contains("plugins-enabled = false")); assert!(content.contains("routing-strategy = \"round-robin\"")); + assert!(content.contains("download-source = \"github\"")); assert!(content.contains("prefer-gitcode-downloads = false")); assert!(content.contains("request-retry = 3")); assert!(content.contains("max-retry-credentials = 0")); diff --git a/src-tauri/src/tests/app_update.rs b/src-tauri/src/tests/app_update.rs index 6481eef..1202cc7 100644 --- a/src-tauri/src/tests/app_update.rs +++ b/src-tauri/src/tests/app_update.rs @@ -212,13 +212,42 @@ fn portable_update_download_order_respects_gitcode_preference() { &filename, )]; - let github_first = portable_update_download_urls(&asset, false); - assert_eq!(update_download_source_name(github_first[0]), "GitHub"); - assert_eq!(update_download_source_name(github_first[1]), "GitCode"); + let github_first = portable_update_download_urls(&asset, VersionDownloadSource::Github); + assert_eq!(update_download_source_name(&github_first[0]), "GitHub"); + assert_eq!(update_download_source_name(&github_first[1]), "GitCode"); - let gitcode_first = portable_update_download_urls(&asset, true); - assert_eq!(update_download_source_name(gitcode_first[0]), "GitCode"); - assert_eq!(update_download_source_name(gitcode_first[1]), "GitHub"); + let gitcode_first = portable_update_download_urls(&asset, VersionDownloadSource::Gitcode); + assert_eq!(update_download_source_name(&gitcode_first[0]), "GitCode"); + assert_eq!(update_download_source_name(&gitcode_first[1]), "GitHub"); + + let mirror_first = portable_update_download_urls(&asset, VersionDownloadSource::GhProxy); + assert_eq!( + update_download_source_name(&mirror_first[0]), + "gh-proxy.com" + ); + assert_eq!(update_download_source_name(&mirror_first[1]), "GitHub"); + assert_eq!(update_download_source_name(&mirror_first[2]), "GitCode"); +} + +#[test] +fn version_detection_candidates_try_every_available_source_once() { + assert_eq!( + version_download_source_candidates(VersionDownloadSource::GhFast, true), + [ + VersionDownloadSource::GhFast, + VersionDownloadSource::Github, + VersionDownloadSource::Gitcode, + VersionDownloadSource::GhProxy, + ] + ); + assert_eq!( + version_download_source_candidates(VersionDownloadSource::Gitcode, false), + [ + VersionDownloadSource::Github, + VersionDownloadSource::GhProxy, + VersionDownloadSource::GhFast, + ] + ); } #[test] @@ -592,7 +621,11 @@ fn synthetic_release_uses_official_asset_names_and_urls() { #[test] fn synthetic_core_release_uses_gitcode_as_download_fallback() { let repository = "lzt404/CLIProxyAPI"; - let release = release_from_tag_for_repositories("7.2.80", Some(repository), false); + let release = release_from_tag_for_repositories( + "7.2.80", + Some(repository), + VersionDownloadSource::Github, + ); let platform = CorePlatform { os: "windows".to_string(), arch: "x86_64".to_string(), @@ -634,6 +667,36 @@ fn gitcode_discovered_core_release_downloads_from_gitcode_first() { ); } +#[test] +fn github_proxy_core_release_uses_proxy_then_official_and_gitcode() { + let release = release_from_tag_for_repositories( + "v7.2.80", + Some("lzt404/CLIProxyAPI"), + VersionDownloadSource::GhFast, + ); + 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!( + core_download_source_name(&asset.browser_download_url), + "ghfast.top" + ); + assert_eq!( + core_download_source_name(&asset.fallback_download_urls[0]), + "GitHub" + ); + assert_eq!( + core_download_source_name(&asset.fallback_download_urls[1]), + "GitCode" + ); +} + #[test] fn release_asset_names_cover_the_six_supported_gui_targets() { let targets = [ diff --git a/src-tauri/src/tests/core_config.rs b/src-tauri/src/tests/core_config.rs index 81ad30b..b711419 100644 --- a/src-tauri/src/tests/core_config.rs +++ b/src-tauri/src/tests/core_config.rs @@ -1039,6 +1039,7 @@ fn startup_preserves_all_user_owned_yaml_and_only_applies_gui_managed_values() { plugins_enabled: true, routing_strategy: "fill-first".to_string(), proxy_url: "socks5://127.0.0.1:7890".to_string(), + download_source: VersionDownloadSource::Github, prefer_gitcode_downloads: false, routing_session_affinity: true, routing_session_affinity_ttl: "1h".to_string(), diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index cfbd9f4..9dd2bdb 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -2,6 +2,8 @@ mod resp; #[cfg(target_os = "macos")] use super::executable_dir; +#[cfg(test)] +use super::VersionDownloadSource; use super::{ apply_configured_proxy, core_base_dir, current_core_status, management_authorization, management_endpoint, management_http_client, CoreProcessState, GuiConfigFile, GuiConfigState, @@ -3325,6 +3327,7 @@ mod tests { plugins_enabled: false, routing_strategy: "round-robin".to_string(), proxy_url: String::new(), + download_source: VersionDownloadSource::Github, prefer_gitcode_downloads: false, routing_session_affinity: false, routing_session_affinity_ttl: String::new(), diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index bf3fcac..de6962a 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -943,9 +943,8 @@ export const jaOverrides = { 'kernel.versions.appCardTitle': 'デスクトップアプリ', 'kernel.versions.coreCardTitle': 'プロキシコア', 'kernel.versions.catalogCardTitle': 'Codex モデルカタログ', - 'kernel.versions.gitcodeEnabled': 'GitCode 国内ミラー有効', - 'kernel.versions.gitcodeDisabled': '公式 GitHub ソース', 'kernel.versions.gitcodeSwitchSuccess': 'ダウンロード元を変更しバージョンを再確認しました', + 'kernel.versions.sourceAutoSwitched': '現在のダウンロード元で確認できなかったため、{source} に自動切り替えしました', 'kernel.versions.bundledPackage': '内蔵オフラインパッケージ', 'kernel.versions.offlineRecommend': 'ネットワーク制限がありますか?インターネット接続なしで内蔵オフラインコアを直接インストールできます。', 'kernel.versions.autoUpdateSupported': 'アプリ内自動更新対応', @@ -973,8 +972,12 @@ export const jaOverrides = { 'kernel.versions.reinstall': '再インストール', 'kernel.versions.noBundled': 'このリリースには組み込みコアが含まれていません', 'kernel.versions.offlineInstall': 'オフラインインストール', - 'kernel.versions.gitcodeSource': 'GitCode 中国向けダウンロード元', - 'kernel.versions.gitcodeSourceHint': '有効にすると、ソフトウェアとコアの更新確認・ダウンロードで GitCode を優先し、失敗時は GitHub に切り替えます。', + 'kernel.versions.downloadSource': 'ダウンロード元を切り替える', + 'kernel.versions.downloadSourceHint': 'ソフトウェアとコアの更新確認・ダウンロードでは選択した経路を優先し、失敗時は他の利用可能なソースに切り替えます。ミラープロキシは第三者が提供します。', + 'kernel.versions.source.github': 'GitHub 公式', + 'kernel.versions.source.gitcode': 'GitCode 中国向けミラー', + 'kernel.versions.source.ghProxy': 'GitHub ミラー · gh-proxy.com', + 'kernel.versions.source.ghFast': 'GitHub ミラー · ghfast.top', 'kernel.versions.gitcodeUnavailable': 'このビルドには完全な GitCode ミラー設定が含まれていません。', 'kernel.versions.gitcodeSaveFailed': 'ダウンロード元の切り替えに失敗しました: {error}', 'kernel.access.firstKey': '最初のキー', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index eaea89e..9830fc9 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -943,9 +943,8 @@ export const en: Record = { 'kernel.versions.appCardTitle': 'Desktop Application', 'kernel.versions.coreCardTitle': 'Proxy Core Kernel', 'kernel.versions.catalogCardTitle': 'Codex Model Catalog', - 'kernel.versions.gitcodeEnabled': 'GitCode China Mirror Active', - 'kernel.versions.gitcodeDisabled': 'Official GitHub Source', 'kernel.versions.gitcodeSwitchSuccess': 'Download source updated and versions rechecked', + 'kernel.versions.sourceAutoSwitched': 'The current source failed; automatically switched to {source}', 'kernel.versions.bundledPackage': 'Bundled Offline Package', 'kernel.versions.offlineRecommend': 'Network restricted? You can install the bundled offline core directly without an internet connection.', 'kernel.versions.autoUpdateSupported': 'In-app auto-update supported', @@ -973,8 +972,12 @@ export const en: Record = { 'kernel.versions.reinstall': 'Reinstall', 'kernel.versions.noBundled': 'This release does not include a bundled core', 'kernel.versions.offlineInstall': 'Offline Install', - 'kernel.versions.gitcodeSource': 'GitCode China Download Source', - 'kernel.versions.gitcodeSourceHint': 'When enabled, software and core update checks and downloads prefer GitCode, with GitHub as fallback.', + 'kernel.versions.downloadSource': 'Switch Download Source', + 'kernel.versions.downloadSourceHint': 'Software and core update checks and downloads prefer the selected channel, then fall back to other available sources. Mirror proxies are third-party services.', + 'kernel.versions.source.github': 'Official GitHub', + 'kernel.versions.source.gitcode': 'GitCode China Mirror', + 'kernel.versions.source.ghProxy': 'GitHub Mirror · gh-proxy.com', + 'kernel.versions.source.ghFast': 'GitHub Mirror · ghfast.top', 'kernel.versions.gitcodeUnavailable': 'This build does not include complete GitCode mirror configuration.', 'kernel.versions.gitcodeSaveFailed': 'Failed to switch download source: {error}', 'kernel.access.firstKey': 'First key', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 87b6f18..350344d 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -941,9 +941,8 @@ export const zhCN = { 'kernel.versions.appCardTitle': '桌面客户端', 'kernel.versions.coreCardTitle': '代理内核', 'kernel.versions.catalogCardTitle': 'Codex 模型目录', - 'kernel.versions.gitcodeEnabled': '已启用 GitCode 国内加速', - 'kernel.versions.gitcodeDisabled': '官方 GitHub 源', 'kernel.versions.gitcodeSwitchSuccess': '已切换下载源并重新检测版本', + 'kernel.versions.sourceAutoSwitched': '当前下载源检测失败,已自动切换至 {source}', 'kernel.versions.bundledPackage': '内置离线安装包', 'kernel.versions.offlineRecommend': '网络连接受限?可直接一键安装内置离线内核,无需联网。', 'kernel.versions.autoUpdateSupported': '支持应用内自动升级', @@ -971,8 +970,12 @@ export const zhCN = { 'kernel.versions.reinstall': '重新安装', 'kernel.versions.noBundled': '当前发行包未包含内置内核', 'kernel.versions.offlineInstall': '离线安装', - 'kernel.versions.gitcodeSource': 'GitCode 国内下载源', - 'kernel.versions.gitcodeSourceHint': '开启后,软件和内核的版本检查及下载将优先使用 GitCode,失败时回退 GitHub。', + 'kernel.versions.downloadSource': '切换下载源', + 'kernel.versions.downloadSourceHint': '软件和内核的版本检查及下载将优先使用所选渠道,失败时自动回退到其他可用源。镜像代理由第三方提供。', + 'kernel.versions.source.github': 'GitHub 官方源', + 'kernel.versions.source.gitcode': 'GitCode 国内源', + 'kernel.versions.source.ghProxy': 'GitHub 镜像 · gh-proxy.com', + 'kernel.versions.source.ghFast': 'GitHub 镜像 · ghfast.top', 'kernel.versions.gitcodeUnavailable': '当前构建未配置完整的 GitCode 镜像源。', 'kernel.versions.gitcodeSaveFailed': '切换下载源失败:{error}', 'kernel.access.firstKey': '第一个密钥', diff --git a/src/pages/VersionManagementPage.tsx b/src/pages/VersionManagementPage.tsx index 6921491..d96388a 100644 --- a/src/pages/VersionManagementPage.tsx +++ b/src/pages/VersionManagementPage.tsx @@ -39,10 +39,22 @@ export type CoreInstallTask = { }; export type VersionSourceSettings = { - preferGitcodeDownloads: boolean; + source: VersionDownloadSource; gitcodeAvailable: boolean; }; +export type VersionDownloadSource = 'github' | 'gitcode' | 'gh-proxy' | 'gh-fast'; + +function downloadSourceLabel(source: VersionDownloadSource, t: ReturnType['t']) { + const keys = { + github: 'kernel.versions.source.github', + gitcode: 'kernel.versions.source.gitcode', + 'gh-proxy': 'kernel.versions.source.ghProxy', + 'gh-fast': 'kernel.versions.source.ghFast', + } as const; + return t(keys[source]); +} + export type MessageType = 'info' | 'success' | 'error'; const APP_RELEASE_URL = 'https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest'; @@ -210,11 +222,11 @@ export function VersionManagementPage() { } }; - const updateVersionSource = async (enabled: boolean) => { + const updateVersionSource = async (source: VersionDownloadSource) => { setVersionSourceSaving(true); setVersionSourceError(''); try { - const settings = await invoke('set_prefer_gitcode_downloads', { enabled }); + const settings = await invoke('set_download_source', { source }); setVersionSource(settings); cachedLatest = null; cachedLatestError = ''; @@ -312,6 +324,7 @@ export function VersionManagementPage() { let disposed = false; let unlisten: (() => void) | null = null; let unlistenConfig: (() => void) | null = null; + let unlistenVersionSource: (() => void) | null = null; listen('core-install-progress', (event) => { applyInstallTask(event.payload); @@ -331,6 +344,18 @@ export function VersionManagementPage() { else unlistenConfig = stop; }); + void listen('version-download-source-changed', (event) => { + if (disposed) return; + setVersionSource(event.payload); + setVersionSourceError(''); + showToast(t('kernel.versions.sourceAutoSwitched', { + source: downloadSourceLabel(event.payload.source, t), + }), 'info'); + }).then((stop) => { + if (disposed) stop(); + else unlistenVersionSource = stop; + }); + loadInstallTask(); loadVersionSourceSettings(); @@ -351,6 +376,7 @@ export function VersionManagementPage() { disposed = true; unlisten?.(); unlistenConfig?.(); + unlistenVersionSource?.(); if (toastTimerRef.current !== null) { window.clearTimeout(toastTimerRef.current); } @@ -471,39 +497,41 @@ export function VersionManagementPage() { const installDialogActionDisabled = (installing || progress?.running) && (cancellingInstall || !progress?.cancellable); - const isGitcodeActive = Boolean(versionSource?.preferGitcodeDownloads); - return (
-
+
- {t('kernel.versions.gitcodeSource')} - - {versionSource?.gitcodeAvailable === false - ? t('kernel.versions.gitcodeUnavailable') - : t('kernel.versions.gitcodeSourceHint')} - + {t('kernel.versions.downloadSource')} + {t('kernel.versions.downloadSourceHint')} + {versionSource?.gitcodeAvailable === false ? ( + {t('kernel.versions.gitcodeUnavailable')} + ) : null} {versionSourceError ? ( {versionSourceError} ) : null}
-
diff --git a/src/styles.css b/src/styles.css index 3cf9228..27738a2 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1073,6 +1073,33 @@ textarea:focus-visible { color: var(--theme-9f2d20); } +.version-source-control, +.version-source-control label { + display: grid; +} + +.version-source-control select { + width: 250px; + max-width: 100%; + height: 42px; + padding: 0 36px 0 12px; + border: 1px solid var(--theme-d5d2cb); + border-radius: 8px; + background: var(--theme-fffdf8); + color: var(--theme-2d2a26); + cursor: pointer; + font-size: var(--font-size-body); +} + +.version-source-control select:hover:not(:disabled) { + border-color: var(--theme-9b958c); +} + +.version-source-control select:disabled { + cursor: not-allowed; + opacity: 0.58; +} + .version-card-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -1639,6 +1666,16 @@ textarea:focus-visible { } } +@container workspace (max-width: 560px) { + .version-source-row { + grid-template-columns: minmax(0, 1fr); + } + + .version-source-control select { + width: 100%; + } +} + .info-list { display: grid; gap: 10px;