feat(version): add manual GitCode download source option

- Add a GitCode source toggle to Version Management
- Persist the preferred download source
- Prefer GitCode for app and core updates with GitHub fallback
- Add localized copy, documentation, and download-order tests
This commit is contained in:
lzt404 committed 2026-08-26 01:56:46 +08:00
1 parent 68da51bae0
commit bb8f23cbc8
17 files changed
+450 -90

No files matched your search

+2 -1
View File
@@ -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
+1 -1
View File
@@ -45,7 +45,7 @@ APIMart 是一个低成本的 AI 图像与视频生成 API 平台——GPT-Image
- 复制可直接使用的 OpenAI、Claude 和 Gemini 兼容 API 地址。
- 查看本地连接状态。
内核安装、版本对比和离线安装功能位于 **版本管理** 页面。
内核安装、版本对比和离线安装功能位于 **版本管理** 页面;国内网络环境可在此手动开启 GitCode 下载源,软件与内核更新会优先使用该镜像。
### OAuth 账号授权
-2
View File
@@ -593,9 +593,7 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
[[package]]
name = "cpa-gui"
version = "0.2.61"
dependencies = [
"base64 0.22.1",
"chrono",
-2
View File
@@ -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"
+149 -46
View File
@@ -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<VersionSourceSettings, String> {
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<VersionSourceSettings, String> {
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<AppUpdateInfo, String> {
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(&current_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<PortableUpdateManifest, String> {
match fetch_portable_update_manifest_url(client, APP_UPDATE_MANIFEST_URL).await {
Ok(manifest) => Ok(manifest),
Err(github_error) => {
let Some(repository) = configured_gitcode_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::<GitcodeRelease>()
.await
.map_err(|error| format!("parse GitCode latest release: {error}"))?;
validate_release_tag(&release.tag_name)?;
let manifest_url = gitcode_release_attachment_url(
repository,
&release.tag_name,
APP_UPDATE_MANIFEST_NAME,
);
fetch_portable_update_manifest_url(client, &manifest_url).await
}
.await;
fallback.map_err(|gitcode_error| {
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<PortableUpdateManifest, String> {
let release_url = format!("https://api.gitcode.com/api/v5/repos/{repository}/releases/latest");
let release = client
.get(release_url)
.header(reqwest::header::ACCEPT, "application/json")
.header(reqwest::header::USER_AGENT, APP_USER_AGENT)
.send()
.await
.map_err(|error| format!("query GitCode latest release: {error}"))?
.error_for_status()
.map_err(|error| format!("read GitCode latest release: {error}"))?
.json::<GitcodeRelease>()
.await
.map_err(|error| format!("parse GitCode latest release: {error}"))?;
validate_release_tag(&release.tag_name)?;
let manifest_url =
gitcode_release_attachment_url(repository, &release.tag_name, APP_UPDATE_MANIFEST_NAME);
fetch_portable_update_manifest_url(client, &manifest_url).await
}
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::<AppUpdateState>();
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::<Vec<_>>();
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::<Vec<_>>();
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,
+7
View File
@@ -1237,6 +1237,9 @@ pub(crate) fn load_or_create_gui_config() -> Result<GuiConfigFile, String> {
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),
+66 -20
View File
@@ -5,9 +5,10 @@ pub(crate) async fn check_latest_core(
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<CoreLatest, String> {
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<String>,
) -> Result<CoreInstallResult, String> {
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<String>,
proxy_url: &str,
prefer_gitcode: bool,
) -> Result<CoreInstallResult, String> {
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<GithubRelease, String> {
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<GithubRelease, String> {
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<GithubRelease, String> {
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,
+22
View File
@@ -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<bool>,
routing_strategy: Option<String>,
proxy_url: Option<String>,
prefer_gitcode_downloads: Option<bool>,
routing_session_affinity: Option<bool>,
routing_session_affinity_ttl: Option<String>,
request_retry: Option<u32>,
@@ -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<GuiConfigFile, String> {
self.update(|config| {
config.prefer_gitcode_downloads = prefer_gitcode_downloads;
Ok(())
})
}
fn set_window_size(&self, size: SavedWindowSize) -> Result<GuiConfigFile, String> {
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,
+3
View File
@@ -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"));
+24 -1
View File
@@ -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]
+1
View File
@@ -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,
+1
View File
@@ -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,
+4
View File
@@ -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': '接続可能',
+4
View File
@@ -951,6 +951,10 @@ export const en: Record<MessageKey, string> = {
'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',
+4
View File
@@ -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': '可连接',
+100 -16
View File
@@ -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<CoreLatest> | 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<CoreLatest>('check_latest_core')
const requestEpoch = ++latestRequestEpoch;
const request = invoke<CoreLatest>('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<VersionSourceSettings | null>(null);
const [versionSourceSaving, setVersionSourceSaving] = useState(false);
const [versionSourceError, setVersionSourceError] = useState('');
const installDialogRef = useRef<HTMLDivElement>(null);
const latestCheckEpochRef = useRef(0);
const savedPortRef = useRef(8317);
const processNoticeTimerRef = useRef<number | null>(null);
const copiedApiTimerRef = useRef<number | null>(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<VersionSourceSettings>('get_version_source_settings');
setVersionSource(settings);
setVersionSourceError('');
} catch (error) {
setVersionSourceError(String(error));
}
};
const loadHomeApiKey = async () => {
try {
const settings = await invoke<CoreConfigSummary>('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<VersionSourceSettings>('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 }) {
</div>
) : null}
{view === 'versions' ? (
<div className="panel version-source-panel">
<div className="version-source-copy">
<Info size={18} aria-hidden="true" />
<div>
<strong>{t('kernel.versions.gitcodeSource')}</strong>
<span>
{versionSource?.gitcodeAvailable === false
? t('kernel.versions.gitcodeUnavailable')
: t('kernel.versions.gitcodeSourceHint')}
</span>
</div>
</div>
<label className="switch-control" title={t('kernel.versions.gitcodeSource')}>
<input
type="checkbox"
role="switch"
checked={versionSource?.preferGitcodeDownloads ?? false}
disabled={
(!versionSource?.gitcodeAvailable && !versionSource?.preferGitcodeDownloads)
|| versionSourceSaving
|| appUpdateTask.running
|| installing
}
aria-label={t('kernel.versions.gitcodeSource')}
onChange={(event) => void updateVersionSource(event.currentTarget.checked)}
/>
<span className="switch-track" />
</label>
{versionSourceError ? <p className="error version-source-error" role="alert">{versionSourceError}</p> : null}
</div>
) : null}
{view === 'versions' ? (
<div className="panel software-update-panel">
<div className="panel-heading">
@@ -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')}
</button>
+62 -1
View File
@@ -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 {