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:
1 parent
68da51bae0
commit
bb8f23cbc8
17 files changed
+450
-90
No files matched your search
@@ -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
@@ -45,7 +45,7 @@ APIMart 是一个低成本的 AI 图像与视频生成 API 平台——GPT-Image
|
||||
- 复制可直接使用的 OpenAI、Claude 和 Gemini 兼容 API 地址。
|
||||
- 查看本地连接状态。
|
||||
|
||||
内核安装、版本对比和离线安装功能位于 **版本管理** 页面。
|
||||
内核安装、版本对比和离线安装功能位于 **版本管理** 页面;国内网络环境可在此手动开启 GitCode 下载源,软件与内核更新会优先使用该镜像。
|
||||
|
||||
### OAuth 账号授权
|
||||
|
||||
|
||||
Generated
-2
@@ -593,9 +593,7 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
|
||||
|
||||
[[package]]
|
||||
name = "cpa-gui"
|
||||
|
||||
version = "0.2.61"
|
||||
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
|
||||
@@ -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
@@ -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(¤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<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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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': '接続可能',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user