diff --git a/README.md b/README.md index 7fc4047..0098de6 100644 --- a/README.md +++ b/README.md @@ -47,7 +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. +**Version Management** page. Users in mainland China can manually enable the GitCode +download source there so application and core updates prefer the mirror. ### OAuth account authorization diff --git a/README.zh-CN.md b/README.zh-CN.md index 103adae..1ba8669 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 下载源,软件与内核更新会优先使用该镜像。 ### OAuth 账号授权 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9562875..9d74eac 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -593,9 +593,7 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" [[package]] name = "cpa-gui" - version = "0.2.61" - dependencies = [ "base64 0.22.1", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4cf8d3b..cf58c8e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,9 +1,7 @@ [package] name = "cpa-gui" # Single source of truth for the desktop app version. - version = "0.2.61" - description = "CPA desktop management shell" authors = ["lzt404"] edition = "2021" diff --git a/src-tauri/src/app_update.rs b/src-tauri/src/app_update.rs index 00de815..2d5d156 100644 --- a/src-tauri/src/app_update.rs +++ b/src-tauri/src/app_update.rs @@ -3,6 +3,36 @@ use super::*; pub(crate) const PORTABLE_UPDATE_HELPER_ACK_FILE: &str = "update-helper-started.ack"; const PORTABLE_UPDATE_HELPER_START_TIMEOUT: Duration = Duration::from_secs(10); +#[tauri::command] +pub(crate) fn get_version_source_settings( + gui_config_state: tauri::State<'_, GuiConfigState>, +) -> Result { + let config = gui_config_state.snapshot()?; + Ok(VersionSourceSettings { + prefer_gitcode_downloads: config.prefer_gitcode_downloads, + gitcode_available: gitcode_version_source_available(), + }) +} + +#[tauri::command] +pub(crate) fn set_prefer_gitcode_downloads( + gui_config_state: tauri::State<'_, GuiConfigState>, + enabled: bool, +) -> Result { + if enabled && !gitcode_version_source_available() { + return Err("当前构建未配置完整的 GitCode 软件与内核镜像源".to_string()); + } + let config = gui_config_state.set_prefer_gitcode_downloads(enabled)?; + Ok(VersionSourceSettings { + prefer_gitcode_downloads: config.prefer_gitcode_downloads, + gitcode_available: gitcode_version_source_available(), + }) +} + +pub(crate) fn gitcode_version_source_available() -> bool { + configured_gitcode_gui_repository().is_some() && configured_gitcode_core_repository().is_some() +} + #[tauri::command] pub(crate) fn open_external_url(app: tauri::AppHandle, url: String) -> Result<(), String> { open_external_url_inner(&app, &url) @@ -13,7 +43,8 @@ pub(crate) async fn check_app_update( state: tauri::State<'_, AppUpdateState>, gui_config_state: tauri::State<'_, GuiConfigState>, ) -> Result { - let proxy_url = gui_config_state.snapshot()?.proxy_url; + let config = gui_config_state.snapshot()?; + let proxy_url = config.proxy_url; let client = build_http_client_with_proxy( reqwest::Client::builder() .redirect(release_https_redirect_policy()) @@ -23,7 +54,7 @@ pub(crate) async fn check_app_update( &proxy_url, "创建版本检查客户端失败", )?; - let manifest = fetch_portable_update_manifest(&client).await?; + let manifest = fetch_portable_update_manifest(&client, config.prefer_gitcode_downloads).await?; 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)?; @@ -79,46 +110,61 @@ pub(crate) async fn check_app_update( pub(crate) async fn fetch_portable_update_manifest( client: &reqwest::Client, + prefer_gitcode: bool, ) -> Result { - match fetch_portable_update_manifest_url(client, APP_UPDATE_MANIFEST_URL).await { - Ok(manifest) => Ok(manifest), - Err(github_error) => { - let Some(repository) = configured_gitcode_gui_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::() - .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| { + 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!( - "GitHub update source failed: {github_error}; GitCode fallback failed: {gitcode_error}" + "GitCode update source failed: {gitcode_error}; GitHub fallback failed: {github_error}" ) - }) + }), + } + } 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}" + ) + }) + } } } } +pub(crate) async fn fetch_portable_update_manifest_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, 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::() + .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 +} + pub(crate) async fn fetch_portable_update_manifest_url( client: &reqwest::Client, url: &str, @@ -470,15 +516,23 @@ pub(crate) async fn start_app_update( if portable_update_platform_key().is_none() { return Err("当前平台不支持应用内自动升级".to_string()); } - let proxy_url = gui_config_state.snapshot()?.proxy_url; + let config = gui_config_state.snapshot()?; + let proxy_url = config.proxy_url; + let prefer_gitcode = config.prefer_gitcode_downloads; let token = CancellationToken::new(); let pending = state.start(token.clone())?; let task = state.snapshot(); let _ = app.emit(APP_UPDATE_PROGRESS_EVENT, task); let update_app = app.clone(); tauri::async_runtime::spawn(async move { - let outcome = - download_and_stage_portable_app_update(&update_app, &pending, &token, &proxy_url).await; + let outcome = download_and_stage_portable_app_update( + &update_app, + &pending, + &token, + &proxy_url, + prefer_gitcode, + ) + .await; if let Err(error) = outcome { let state = update_app.state::(); let cancelled = token.is_cancelled(); @@ -501,10 +555,11 @@ pub(crate) async fn download_and_stage_portable_app_update( pending: &PendingAppUpdate, token: &CancellationToken, proxy_url: &str, + prefer_gitcode: bool, ) -> Result<(), String> { #[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] { - let _ = (app, pending, token, proxy_url); + let _ = (app, pending, token, proxy_url, prefer_gitcode); Err("当前平台不支持应用内自动升级".to_string()) } @@ -524,7 +579,15 @@ pub(crate) async fn download_and_stage_portable_app_update( .map_err(|error| format!("创建应用更新临时目录失败: {error}"))?; let archive_path = work_dir.join("update.zip"); let result = async { - download_portable_update_archive(app, pending, token, &archive_path, proxy_url).await?; + download_portable_update_archive( + app, + pending, + token, + &archive_path, + proxy_url, + prefer_gitcode, + ) + .await?; if token.is_cancelled() { return Err("应用更新下载已取消".to_string()); } @@ -602,7 +665,15 @@ pub(crate) async fn download_and_stage_portable_app_update( .map_err(|error| format!("创建应用更新临时目录失败: {error}"))?; let archive_path = work_dir.join("update.tar.gz"); let result = async { - download_portable_update_archive(app, pending, token, &archive_path, proxy_url).await?; + download_portable_update_archive( + app, + pending, + token, + &archive_path, + proxy_url, + prefer_gitcode, + ) + .await?; ensure_portable_update_download(token, &archive_path, pending)?; update_app_task(app, |task| { task.cancellable = false; @@ -666,7 +737,15 @@ pub(crate) async fn download_and_stage_portable_app_update( .map_err(|error| format!("创建应用更新临时目录失败: {error}"))?; let archive_path = work_dir.join("update.dmg"); let result = async { - download_portable_update_archive(app, pending, token, &archive_path, proxy_url).await?; + download_portable_update_archive( + app, + pending, + token, + &archive_path, + proxy_url, + prefer_gitcode, + ) + .await?; ensure_portable_update_download(token, &archive_path, pending)?; update_app_task(app, |task| { task.cancellable = false; @@ -839,6 +918,7 @@ pub(crate) async fn download_portable_update_archive( token: &CancellationToken, destination: &Path, proxy_url: &str, + prefer_gitcode: bool, ) -> Result<(), String> { let client = build_http_client_with_proxy( reqwest::Client::builder() @@ -849,16 +929,17 @@ pub(crate) async fn download_portable_update_archive( proxy_url, "创建应用更新下载客户端失败", )?; - let urls = std::iter::once(&pending.asset.url) - .chain(pending.asset.fallback_urls.iter()) - .collect::>(); + let urls = portable_update_download_urls(&pending.asset, prefer_gitcode); 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()); + task.message = Some(format!( + "下载失败,正在切换到 {}", + update_download_source_name(url) + )); } }); match download_portable_update_archive_url(app, pending, token, destination, &client, url) @@ -872,6 +953,28 @@ pub(crate) async fn download_portable_update_archive( Err(format!("所有应用更新下载源均失败: {}", failures.join("; "))) } +pub(crate) fn portable_update_download_urls( + asset: &PortableUpdateAsset, + prefer_gitcode: bool, +) -> Vec<&str> { + let mut urls = std::iter::once(asset.url.as_str()) + .chain(asset.fallback_urls.iter().map(String::as_str)) + .collect::>(); + if prefer_gitcode { + urls.sort_by_key(|url| update_download_source_name(url) != "GitCode"); + } + urls +} + +pub(crate) fn update_download_source_name(url: &str) -> &'static str { + 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") +} + #[cfg(any(windows, target_os = "linux", target_os = "macos"))] pub(crate) async fn download_portable_update_archive_url( app: &tauri::AppHandle, diff --git a/src-tauri/src/core_config/settings.rs b/src-tauri/src/core_config/settings.rs index c1e458c..50bd8b5 100644 --- a/src-tauri/src/core_config/settings.rs +++ b/src-tauri/src/core_config/settings.rs @@ -1237,6 +1237,9 @@ pub(crate) fn load_or_create_gui_config() -> Result { if presence.silent_start.is_none() { changed = true; } + if presence.prefer_gitcode_downloads.is_none() { + changed = true; + } let management_secret_rotated = ensure_strong_management_secret(&mut config)?; changed |= management_secret_rotated; changed |= sanitize_gui_config(&mut config)?; @@ -1590,6 +1593,10 @@ 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())), + ( + "prefer-gitcode-downloads", + value(config.prefer_gitcode_downloads), + ), ( "routing-session-affinity", value(config.routing_session_affinity), diff --git a/src-tauri/src/core_runtime.rs b/src-tauri/src/core_runtime.rs index 5f80e3e..d26e275 100644 --- a/src-tauri/src/core_runtime.rs +++ b/src-tauri/src/core_runtime.rs @@ -5,9 +5,10 @@ pub(crate) async fn check_latest_core( gui_config_state: tauri::State<'_, GuiConfigState>, ) -> Result { let platform = current_core_platform()?; - let proxy_url = gui_config_state.snapshot()?.proxy_url; + let config = gui_config_state.snapshot()?; + let proxy_url = config.proxy_url; let client = http_client(&proxy_url)?; - let release = fetch_release(&client, None).await?; + let release = fetch_release(&client, None, config.prefer_gitcode_downloads).await?; let asset = select_release_asset(&release, &platform)?; Ok(CoreLatest { @@ -82,11 +83,19 @@ pub(crate) async fn install_core_version( gui_config_state: tauri::State<'_, GuiConfigState>, version: Option, ) -> Result { - let proxy_url = gui_config_state.snapshot()?.proxy_url; + let config = gui_config_state.snapshot()?; + let proxy_url = config.proxy_url; let token = CancellationToken::new(); state.start(token.clone(), version.clone())?; - let result = - install_core_version_inner(&window, state.inner(), token, version, &proxy_url).await; + let result = install_core_version_inner( + &window, + state.inner(), + token, + version, + &proxy_url, + config.prefer_gitcode_downloads, + ) + .await; if result.is_err() { let _ = cleanup_core_work_dirs(); } @@ -170,11 +179,13 @@ pub(crate) async fn install_core_version_inner( token: CancellationToken, version: Option, proxy_url: &str, + prefer_gitcode: bool, ) -> 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).await?; + let release = + fetch_release_cancelable(&client, version.as_deref(), &token, prefer_gitcode).await?; let asset = select_release_asset(&release, &platform)?; let install_dir = core_install_dir()?; @@ -305,17 +316,30 @@ pub(crate) fn install_bundled_core_inner( pub(crate) async fn fetch_release( client: &reqwest::Client, version: Option<&str>, + prefer_gitcode: bool, ) -> Result { if let Some(version) = version { - return Ok(release_from_tag(version)); + return Ok(release_from_tag_for_repositories( + version, + configured_gitcode_core_repository(), + prefer_gitcode, + )); } - let atom_result = fetch_release_from_atom(client).await; - 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}") - }), - }; + 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) => { @@ -333,6 +357,18 @@ pub(crate) async fn fetch_release( } } +pub(crate) async fn fetch_release_from_github( + client: &reqwest::Client, +) -> Result { + let atom_result = fetch_release_from_atom(client).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}") + }), + } +} + pub(crate) async fn fetch_release_from_gitcode( client: &reqwest::Client, repository: &str, @@ -456,10 +492,10 @@ pub(crate) fn release_from_tag_for_repositories( 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(), - ) + match gitcode_url { + Some(gitcode_url) => (gitcode_url, vec![github_url]), + None => (github_url, Vec::new()), + } } else { (github_url, gitcode_url.into_iter().collect()) }; @@ -569,9 +605,10 @@ pub(crate) async fn fetch_release_cancelable( client: &reqwest::Client, version: Option<&str>, token: &CancellationToken, + prefer_gitcode: bool, ) -> Result { tokio::select! { - result = fetch_release(client, version) => result, + result = fetch_release(client, version, prefer_gitcode) => result, _ = token.cancelled() => Err("已取消下载".to_string()), } } @@ -658,7 +695,7 @@ pub(crate) async fn download_asset( if index > 0 { state.progress( window, - "GitHub 下载失败,正在切换到 GitCode", + &format!("下载失败,正在切换到 {}", core_download_source_name(url)), 0, asset.size, true, @@ -694,6 +731,15 @@ pub(crate) async fn download_asset( Err(format!("所有内核下载源均失败: {}", failures.join(";"))) } +pub(crate) fn core_download_source_name(url: &str) -> &'static str { + 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") +} + #[allow(clippy::too_many_arguments)] pub(crate) async fn download_asset_inner( client: &reqwest::Client, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index a37612a..0f8f55c 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -582,6 +582,7 @@ struct GuiConfigFile { plugins_enabled: bool, routing_strategy: String, proxy_url: String, + prefer_gitcode_downloads: bool, routing_session_affinity: bool, routing_session_affinity_ttl: String, request_retry: u32, @@ -694,6 +695,7 @@ impl Default for GuiConfigFile { plugins_enabled: false, routing_strategy: "round-robin".to_string(), proxy_url: String::new(), + prefer_gitcode_downloads: false, routing_session_affinity: false, routing_session_affinity_ttl: String::new(), request_retry: DEFAULT_REQUEST_RETRY, @@ -722,6 +724,7 @@ struct GuiConfigPresence { plugins_enabled: Option, routing_strategy: Option, proxy_url: Option, + prefer_gitcode_downloads: Option, routing_session_affinity: Option, routing_session_affinity_ttl: Option, request_retry: Option, @@ -740,6 +743,13 @@ struct GuiSettings { close_behavior: WindowsCloseBehavior, } +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct VersionSourceSettings { + prefer_gitcode_downloads: bool, + gitcode_available: bool, +} + #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] struct SoftwareSettings { @@ -1661,6 +1671,16 @@ impl GuiConfigState { }) } + fn set_prefer_gitcode_downloads( + &self, + prefer_gitcode_downloads: bool, + ) -> Result { + self.update(|config| { + config.prefer_gitcode_downloads = prefer_gitcode_downloads; + Ok(()) + }) + } + fn set_window_size(&self, size: SavedWindowSize) -> Result { self.update(|config| { config.window_width = Some(size.width); @@ -2090,6 +2110,8 @@ fn main() { list_oauth_browsers, open_oauth_url, open_external_url, + get_version_source_settings, + set_prefer_gitcode_downloads, check_app_update, get_app_update_task, start_app_update, diff --git a/src-tauri/src/tests/app_settings.rs b/src-tauri/src/tests/app_settings.rs index 281f1d9..4c64726 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, + prefer_gitcode_downloads: true, ..GuiConfigFile::default() }; @@ -31,6 +32,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("prefer-gitcode-downloads = true")); assert!(!content.contains("codex-session-repair-on-launch")); assert!(!content.contains("claude-code-working-directory")); fs::remove_dir_all(home).unwrap(); @@ -89,6 +91,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("prefer-gitcode-downloads = false")); assert!(content.contains("request-retry = 3")); assert!(content.contains("max-retry-credentials = 0")); assert!(content.contains("max-retry-interval = 30")); diff --git a/src-tauri/src/tests/app_update.rs b/src-tauri/src/tests/app_update.rs index e32a6a6..6481eef 100644 --- a/src-tauri/src/tests/app_update.rs +++ b/src-tauri/src/tests/app_update.rs @@ -201,6 +201,26 @@ fn portable_update_asset_accepts_only_the_configured_gitcode_fallback() { .is_err()); } +#[test] +fn portable_update_download_order_respects_gitcode_preference() { + let mut asset = portable_update_test_asset("1.2.3", "amd64"); + let (_, display, suffix) = portable_update_asset_platform().unwrap(); + let filename = format!("EasyCLIProxyAPI-v1.2.3-{display}-amd64.{suffix}"); + asset.fallback_urls = vec![gitcode_release_attachment_url( + "mirror-owner/EasyCLIProxyAPI", + "v1.2.3", + &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 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"); +} + #[test] fn portable_update_state_supports_cancellation_and_snapshot_recovery() { let state = AppUpdateState::default(); @@ -608,7 +628,10 @@ fn gitcode_discovered_core_release_downloads_from_gitcode_first() { 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()); + assert_eq!( + asset.fallback_download_urls, + ["https://github.com/router-for-me/CLIProxyAPI/releases/download/v7.2.80/CLIProxyAPI_7.2.80_linux_aarch64.tar.gz"] + ); } #[test] diff --git a/src-tauri/src/tests/core_config.rs b/src-tauri/src/tests/core_config.rs index c671799..81ad30b 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(), + prefer_gitcode_downloads: false, routing_session_affinity: true, routing_session_affinity_ttl: "1h".to_string(), request_retry: 1, diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index caab1ea..cfbd9f4 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -3325,6 +3325,7 @@ mod tests { plugins_enabled: false, routing_strategy: "round-robin".to_string(), proxy_url: String::new(), + prefer_gitcode_downloads: false, routing_session_affinity: false, routing_session_affinity_ttl: String::new(), request_retry: crate::DEFAULT_REQUEST_RETRY, diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index ad8668f..d27814a 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -951,6 +951,10 @@ export const jaOverrides = { 'kernel.versions.reinstall': '再インストール', 'kernel.versions.noBundled': 'このリリースには組み込みコアが含まれていません', 'kernel.versions.offlineInstall': 'オフラインインストール', + 'kernel.versions.gitcodeSource': 'GitCode 中国向けダウンロード元', + 'kernel.versions.gitcodeSourceHint': '有効にすると、ソフトウェアとコアの更新確認・ダウンロードで GitCode を優先し、失敗時は GitHub に切り替えます。', + 'kernel.versions.gitcodeUnavailable': 'このビルドには完全な GitCode ミラー設定が含まれていません。', + 'kernel.versions.gitcodeSaveFailed': 'ダウンロード元の切り替えに失敗しました: {error}', 'kernel.access.firstKey': '最初のキー', 'kernel.access.noConfiguredKey': 'キーが設定されていないため、現在はすべてのキーがデフォルトで許可されます', 'kernel.access.connectable': '接続可能', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 4fd4477..ee94889 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -951,6 +951,10 @@ 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.gitcodeUnavailable': 'This build does not include complete GitCode mirror configuration.', + 'kernel.versions.gitcodeSaveFailed': 'Failed to switch download source: {error}', 'kernel.access.firstKey': 'First key', 'kernel.access.noConfiguredKey': 'No key is configured; all keys are currently allowed by default', 'kernel.access.connectable': 'Connectable', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index fde2413..08d9a0b 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -949,6 +949,10 @@ export const zhCN = { 'kernel.versions.reinstall': '重新安装', 'kernel.versions.noBundled': '当前发行包未包含内置内核', 'kernel.versions.offlineInstall': '离线安装', + 'kernel.versions.gitcodeSource': 'GitCode 国内下载源', + 'kernel.versions.gitcodeSourceHint': '开启后,软件和内核的版本检查及下载将优先使用 GitCode,失败时回退 GitHub。', + 'kernel.versions.gitcodeUnavailable': '当前构建未配置完整的 GitCode 镜像源。', + 'kernel.versions.gitcodeSaveFailed': '切换下载源失败:{error}', 'kernel.access.firstKey': '第一个密钥', 'kernel.access.noConfiguredKey': '未设置密钥,当前默认放行所有密钥', 'kernel.access.connectable': '可连接', diff --git a/src/pages/Kernel.tsx b/src/pages/Kernel.tsx index 9ef5fb3..54b28df 100644 --- a/src/pages/Kernel.tsx +++ b/src/pages/Kernel.tsx @@ -61,6 +61,11 @@ type GuiSettings = { runOnStartup: boolean; }; +type VersionSourceSettings = { + preferGitcodeDownloads: boolean; + gitcodeAvailable: boolean; +}; + type CoreConfigSummary = { apiKeys: Array<{ apiKey: string }>; }; @@ -77,33 +82,42 @@ let latestAutoCheckStarted = false; let cachedLatest: CoreLatest | null = null; let cachedLatestError = ''; let latestCheckPromise: Promise | null = null; +let latestRequestEpoch = 0; function displayAppVersion(version: string) { const resolvedVersion = version.trim(); return resolvedVersion.startsWith('v') ? resolvedVersion : `v${resolvedVersion}`; } -function requestLatestCore() { - if (latestCheckPromise) { +function requestLatestCore(force = false) { + if (!force && latestCheckPromise) { return latestCheckPromise; } - latestCheckPromise = invoke('check_latest_core') + const requestEpoch = ++latestRequestEpoch; + const request = invoke('check_latest_core') .then((result) => { - cachedLatest = result; - cachedLatestError = ''; + if (requestEpoch === latestRequestEpoch) { + cachedLatest = result; + cachedLatestError = ''; + } return result; }) .catch((error) => { - cachedLatest = null; - cachedLatestError = String(error); + if (requestEpoch === latestRequestEpoch) { + cachedLatest = null; + cachedLatestError = String(error); + } throw error; }) .finally(() => { - latestCheckPromise = null; + if (latestCheckPromise === request) { + latestCheckPromise = null; + } }); + latestCheckPromise = request; - return latestCheckPromise; + return request; } export type KernelView = 'home' | 'versions'; @@ -153,7 +167,11 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { const [homeApiKeyError, setHomeApiKeyError] = useState(false); const [showHomeApiKey, setShowHomeApiKey] = useState(false); const [tlsEnabled, setTlsEnabled] = useState(false); + const [versionSource, setVersionSource] = useState(null); + const [versionSourceSaving, setVersionSourceSaving] = useState(false); + const [versionSourceError, setVersionSourceError] = useState(''); const installDialogRef = useRef(null); + const latestCheckEpochRef = useRef(0); const savedPortRef = useRef(8317); const processNoticeTimerRef = useRef(null); const copiedApiTimerRef = useRef(null); @@ -230,6 +248,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { void loadGuiSettings(); void loadTlsSettings(); void refreshStatus(); + if (view === 'versions') void loadVersionSourceSettings(); if (view === 'home') void loadHomeApiKey(); }).then((stop) => { if (disposed) stop(); @@ -240,6 +259,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { loadBundledCore(); loadInstallTask(); loadGuiSettings(); + if (view === 'versions') void loadVersionSourceSettings(); void loadTlsSettings(); void getVersion() .then((version) => { @@ -334,6 +354,16 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { } catch {} }; + const loadVersionSourceSettings = async () => { + try { + const settings = await invoke('get_version_source_settings'); + setVersionSource(settings); + setVersionSourceError(''); + } catch (error) { + setVersionSourceError(String(error)); + } + }; + const loadHomeApiKey = async () => { try { const settings = await invoke('get_core_config_settings'); @@ -376,20 +406,41 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { } }; - const checkLatest = async () => { + const checkLatest = async (force = false) => { + const checkEpoch = ++latestCheckEpochRef.current; setCheckingLatest(true); setLatestError(''); setMessage(''); setMessageType('info'); try { - const result = await requestLatestCore(); - setLatest(result); + const result = await requestLatestCore(force); + if (checkEpoch === latestCheckEpochRef.current) setLatest(result); } catch (error) { - setLatest(null); - setLatestError(String(error)); + if (checkEpoch === latestCheckEpochRef.current) { + setLatest(null); + setLatestError(String(error)); + } } finally { - setCheckingLatest(false); + if (checkEpoch === latestCheckEpochRef.current) setCheckingLatest(false); + } + }; + + const updateVersionSource = async (enabled: boolean) => { + setVersionSourceSaving(true); + setVersionSourceError(''); + try { + const settings = await invoke('set_prefer_gitcode_downloads', { enabled }); + setVersionSource(settings); + cachedLatest = null; + cachedLatestError = ''; + setLatest(null); + await Promise.all([checkAppUpdate(), checkLatest(true)]); + } catch (error) { + await loadVersionSourceSettings(); + setVersionSourceError(t('kernel.versions.gitcodeSaveFailed', { error: String(error) })); + } finally { + setVersionSourceSaving(false); } }; @@ -770,6 +821,39 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { ) : null} + {view === 'versions' ? ( +
+
+
+ + {versionSourceError ?

{versionSourceError}

: null} +
+ ) : null} + {view === 'versions' ? (
@@ -909,7 +993,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { type="button" className="secondary-button" disabled={busy} - onClick={checkLatest} + onClick={() => void checkLatest()} > {checkingLatest ? t('kernel.update.checking') : t('kernel.versions.check')} diff --git a/src/styles.css b/src/styles.css index f52c001..33de52b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1025,6 +1025,51 @@ textarea:focus-visible { grid-column: auto; } +.version-source-panel { + display: flex; + min-height: 68px; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 20px; + padding-block: 14px; +} + +.version-source-copy { + display: flex; + min-width: 0; + align-items: center; + gap: 11px; +} + +.version-source-copy > svg { + flex: none; + color: var(--ui-accent); +} + +.version-source-copy > div { + display: grid; + min-width: 0; + gap: 3px; +} + +.version-source-copy strong { + color: var(--theme-39352f); + font-size: var(--font-size-body); +} + +.version-source-copy span { + color: var(--theme-7a746c); + font-size: var(--font-size-meta); + line-height: var(--line-height-body); +} + +.version-source-error { + width: 100%; + margin: 0; + font-size: var(--font-size-meta); +} + .version-heading-inline { display: flex; min-width: 0; @@ -1457,10 +1502,15 @@ textarea:focus-visible { .version-management-layout { grid-template-columns: repeat(2, minmax(0, 1fr)); - grid-auto-rows: 1fr; + grid-template-rows: auto minmax(0, 1fr); align-items: stretch; } + .version-management-layout > .version-source-panel { + grid-column: 1 / -1; + grid-row: 1; + } + .version-management-layout > .panel { height: 100%; align-self: stretch; @@ -1468,6 +1518,17 @@ textarea:focus-visible { row-gap: clamp(12px, 2vh, 22px); } + .version-management-layout > .version-source-panel { + height: auto; + grid-template-rows: none; + row-gap: 8px; + } + + .version-management-layout > .software-update-panel, + .version-management-layout > .version-panel { + grid-row: 2; + } + .home-page > .home-layout > .control-panel > .panel-detail-grid, .version-management-layout > .panel > .panel-detail-grid, .home-page > .client-api-panel > .client-api-grid {