feat: add multi-source downloads with automatic fallback

- support GitHub, GitCode, gh-proxy, and ghfast sources
- automatically try other sources when version checks fail
- persist the working fallback source and sync the UI immediately
- update translations, configuration migration, and source tests
This commit is contained in:
lzt404 committed 2026-08-26 01:56:46 +08:00
1 parent e8093928c0
commit 03be4acf93
16 files changed
+507 -157

No files matched your search

+109 -47
View File
@@ -9,7 +9,24 @@ pub(crate) fn get_version_source_settings(
) -> Result<VersionSourceSettings, String> {
let config = gui_config_state.snapshot()?;
Ok(VersionSourceSettings {
prefer_gitcode_downloads: config.prefer_gitcode_downloads,
source: config.download_source,
gitcode_available: gitcode_version_source_available(),
})
}
#[tauri::command]
pub(crate) fn set_download_source(
gui_config_state: tauri::State<'_, GuiConfigState>,
source: String,
) -> Result<VersionSourceSettings, String> {
let source =
VersionDownloadSource::from_str(&source).ok_or_else(|| "不支持的下载源".to_string())?;
if source == VersionDownloadSource::Gitcode && !gitcode_version_source_available() {
return Err("当前构建未配置完整的 GitCode 软件与内核镜像源".to_string());
}
let config = gui_config_state.set_download_source(source)?;
Ok(VersionSourceSettings {
source: config.download_source,
gitcode_available: gitcode_version_source_available(),
})
}
@@ -19,12 +36,17 @@ pub(crate) fn set_prefer_gitcode_downloads(
gui_config_state: tauri::State<'_, GuiConfigState>,
enabled: bool,
) -> Result<VersionSourceSettings, String> {
let source = if enabled {
VersionDownloadSource::Gitcode
} else {
VersionDownloadSource::Github
};
if enabled && !gitcode_version_source_available() {
return Err("当前构建未配置完整的 GitCode 软件与内核镜像源".to_string());
}
let config = gui_config_state.set_prefer_gitcode_downloads(enabled)?;
let config = gui_config_state.set_download_source(source)?;
Ok(VersionSourceSettings {
prefer_gitcode_downloads: config.prefer_gitcode_downloads,
source: config.download_source,
gitcode_available: gitcode_version_source_available(),
})
}
@@ -33,6 +55,27 @@ pub(crate) fn gitcode_version_source_available() -> bool {
configured_gitcode_gui_repository().is_some() && configured_gitcode_core_repository().is_some()
}
pub(crate) fn persist_automatic_download_source_switch(
app: &tauri::AppHandle,
gui_config_state: &GuiConfigState,
requested_source: VersionDownloadSource,
resolved_source: VersionDownloadSource,
) -> Result<(), String> {
let Some(config) =
gui_config_state.switch_download_source_after_failure(requested_source, resolved_source)?
else {
return Ok(());
};
app.emit(
VERSION_DOWNLOAD_SOURCE_CHANGED_EVENT,
VersionSourceSettings {
source: config.download_source,
gitcode_available: gitcode_version_source_available(),
},
)
.map_err(|error| format!("通知下载源自动切换失败: {error}"))
}
#[tauri::command]
pub(crate) fn open_external_url(app: tauri::AppHandle, url: String) -> Result<(), String> {
open_external_url_inner(&app, &url)
@@ -40,6 +83,7 @@ pub(crate) fn open_external_url(app: tauri::AppHandle, url: String) -> Result<()
#[tauri::command]
pub(crate) async fn check_app_update(
app: tauri::AppHandle,
state: tauri::State<'_, AppUpdateState>,
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<AppUpdateInfo, String> {
@@ -54,7 +98,14 @@ pub(crate) async fn check_app_update(
&proxy_url,
"创建版本检查客户端失败",
)?;
let manifest = fetch_portable_update_manifest(&client, config.prefer_gitcode_downloads).await?;
let (manifest, resolved_source) =
fetch_portable_update_manifest(&client, config.download_source).await?;
persist_automatic_download_source_switch(
&app,
gui_config_state.inner(),
config.download_source,
resolved_source,
)?;
let latest_version = normalize_version(&manifest.version);
let current_version = normalize_version(env!("CARGO_PKG_VERSION"));
let update_available = is_app_update_available(&current_version, &latest_version)?;
@@ -110,36 +161,32 @@ pub(crate) async fn check_app_update(
pub(crate) async fn fetch_portable_update_manifest(
client: &reqwest::Client,
prefer_gitcode: bool,
) -> Result<PortableUpdateManifest, String> {
let github = || fetch_portable_update_manifest_url(client, APP_UPDATE_MANIFEST_URL);
let Some(repository) = configured_gitcode_gui_repository() else {
return github().await;
};
if prefer_gitcode {
match fetch_portable_update_manifest_from_gitcode(client, repository).await {
Ok(manifest) => Ok(manifest),
Err(gitcode_error) => github().await.map_err(|github_error| {
format!(
"GitCode update source failed: {gitcode_error}; GitHub fallback failed: {github_error}"
source: VersionDownloadSource,
) -> Result<(PortableUpdateManifest, VersionDownloadSource), String> {
let gitcode_repository = configured_gitcode_gui_repository();
let mut failures = Vec::new();
for candidate in version_download_source_candidates(source, gitcode_repository.is_some()) {
let result = match candidate {
VersionDownloadSource::Gitcode => {
fetch_portable_update_manifest_from_gitcode(
client,
gitcode_repository.expect("GitCode candidate requires a configured repository"),
)
}),
}
} else {
match github().await {
Ok(manifest) => Ok(manifest),
Err(github_error) => {
fetch_portable_update_manifest_from_gitcode(client, repository)
.await
.map_err(|gitcode_error| {
format!(
"GitHub update source failed: {github_error}; GitCode fallback failed: {gitcode_error}"
)
})
.await
}
VersionDownloadSource::Github
| VersionDownloadSource::GhProxy
| VersionDownloadSource::GhFast => {
let url = version_source_url(candidate, APP_UPDATE_MANIFEST_URL);
fetch_portable_update_manifest_url(client, &url).await
}
};
match result {
Ok(manifest) => return Ok((manifest, candidate)),
Err(error) => failures.push(format!("{}: {error}", candidate.display_name())),
}
}
Err(format!("所有软件版本检测源均失败: {}", failures.join("; ")))
}
pub(crate) async fn fetch_portable_update_manifest_from_gitcode(
@@ -240,6 +287,8 @@ pub(crate) fn release_https_redirect_policy() -> reqwest::redirect::Policy {
| "api.gitcode.com"
| "gitcode.com"
| "file-cdn.gitcode.com"
| "gh-proxy.com"
| "ghfast.top"
)
);
if url.scheme() == "https"
@@ -518,7 +567,7 @@ pub(crate) async fn start_app_update(
}
let config = gui_config_state.snapshot()?;
let proxy_url = config.proxy_url;
let prefer_gitcode = config.prefer_gitcode_downloads;
let download_source = config.download_source;
let token = CancellationToken::new();
let pending = state.start(token.clone())?;
let task = state.snapshot();
@@ -530,7 +579,7 @@ pub(crate) async fn start_app_update(
&pending,
&token,
&proxy_url,
prefer_gitcode,
download_source,
)
.await;
if let Err(error) = outcome {
@@ -555,11 +604,11 @@ pub(crate) async fn download_and_stage_portable_app_update(
pending: &PendingAppUpdate,
token: &CancellationToken,
proxy_url: &str,
prefer_gitcode: bool,
download_source: VersionDownloadSource,
) -> Result<(), String> {
#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
{
let _ = (app, pending, token, proxy_url, prefer_gitcode);
let _ = (app, pending, token, proxy_url, download_source);
Err("当前平台不支持应用内自动升级".to_string())
}
@@ -585,7 +634,7 @@ pub(crate) async fn download_and_stage_portable_app_update(
token,
&archive_path,
proxy_url,
prefer_gitcode,
download_source,
)
.await?;
if token.is_cancelled() {
@@ -671,7 +720,7 @@ pub(crate) async fn download_and_stage_portable_app_update(
token,
&archive_path,
proxy_url,
prefer_gitcode,
download_source,
)
.await?;
ensure_portable_update_download(token, &archive_path, pending)?;
@@ -743,7 +792,7 @@ pub(crate) async fn download_and_stage_portable_app_update(
token,
&archive_path,
proxy_url,
prefer_gitcode,
download_source,
)
.await?;
ensure_portable_update_download(token, &archive_path, pending)?;
@@ -918,7 +967,7 @@ pub(crate) async fn download_portable_update_archive(
token: &CancellationToken,
destination: &Path,
proxy_url: &str,
prefer_gitcode: bool,
download_source: VersionDownloadSource,
) -> Result<(), String> {
let client = build_http_client_with_proxy(
reqwest::Client::builder()
@@ -929,7 +978,7 @@ pub(crate) async fn download_portable_update_archive(
proxy_url,
"创建应用更新下载客户端失败",
)?;
let urls = portable_update_download_urls(&pending.asset, prefer_gitcode);
let urls = portable_update_download_urls(&pending.asset, download_source);
let mut failures = Vec::new();
for (index, url) in urls.iter().enumerate() {
update_app_task(app, |task| {
@@ -955,24 +1004,37 @@ pub(crate) async fn download_portable_update_archive(
pub(crate) fn portable_update_download_urls(
asset: &PortableUpdateAsset,
prefer_gitcode: bool,
) -> Vec<&str> {
source: VersionDownloadSource,
) -> Vec<String> {
let mut urls = std::iter::once(asset.url.as_str())
.chain(asset.fallback_urls.iter().map(String::as_str))
.map(str::to_string)
.collect::<Vec<_>>();
if prefer_gitcode {
if source == VersionDownloadSource::Gitcode {
urls.sort_by_key(|url| update_download_source_name(url) != "GitCode");
} else if source.github_proxy_prefix().is_some() {
if let Some(github_url) = urls
.iter()
.find(|url| update_download_source_name(url) == "GitHub")
.cloned()
{
urls.insert(0, version_source_url(source, &github_url));
}
}
urls.dedup();
urls
}
pub(crate) fn update_download_source_name(url: &str) -> &'static str {
reqwest::Url::parse(url)
let host = reqwest::Url::parse(url)
.ok()
.and_then(|url| url.host_str().map(str::to_string))
.filter(|host| host == "api.gitcode.com" || host.ends_with(".gitcode.com"))
.map(|_| "GitCode")
.unwrap_or("GitHub")
.and_then(|url| url.host_str().map(str::to_string));
match host.as_deref() {
Some("gh-proxy.com") => "gh-proxy.com",
Some("ghfast.top") => "ghfast.top",
Some(host) if host == "api.gitcode.com" || host.ends_with(".gitcode.com") => "GitCode",
_ => "GitHub",
}
}
#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
+10
View File
@@ -1237,9 +1237,18 @@ pub(crate) fn load_or_create_gui_config() -> Result<GuiConfigFile, String> {
if presence.silent_start.is_none() {
changed = true;
}
if presence.download_source.is_none() {
config.download_source = if config.prefer_gitcode_downloads {
VersionDownloadSource::Gitcode
} else {
VersionDownloadSource::Github
};
changed = true;
}
if presence.prefer_gitcode_downloads.is_none() {
changed = true;
}
config.prefer_gitcode_downloads = config.download_source == VersionDownloadSource::Gitcode;
let management_secret_rotated = ensure_strong_management_secret(&mut config)?;
changed |= management_secret_rotated;
changed |= sanitize_gui_config(&mut config)?;
@@ -1593,6 +1602,7 @@ pub(crate) fn write_gui_config_to_path(
("plugins-enabled", value(config.plugins_enabled)),
("routing-strategy", value(config.routing_strategy.as_str())),
("proxy-url", value(config.proxy_url.as_str())),
("download-source", value(config.download_source.as_str())),
(
"prefer-gitcode-downloads",
value(config.prefer_gitcode_downloads),
+89 -61
View File
@@ -2,13 +2,20 @@ use super::*;
#[tauri::command]
pub(crate) async fn check_latest_core(
app: tauri::AppHandle,
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<CoreLatest, String> {
let platform = current_core_platform()?;
let config = gui_config_state.snapshot()?;
let proxy_url = config.proxy_url;
let client = http_client(&proxy_url)?;
let release = fetch_release(&client, None, config.prefer_gitcode_downloads).await?;
let (release, resolved_source) = fetch_release(&client, None, config.download_source).await?;
persist_automatic_download_source_switch(
&app,
gui_config_state.inner(),
config.download_source,
resolved_source,
)?;
let asset = select_release_asset(&release, &platform)?;
Ok(CoreLatest {
@@ -107,7 +114,7 @@ pub(crate) async fn install_core_version(
token,
version,
&proxy_url,
config.prefer_gitcode_downloads,
config.download_source,
)
.await,
),
@@ -269,13 +276,13 @@ pub(crate) async fn install_core_version_inner(
token: CancellationToken,
version: Option<String>,
proxy_url: &str,
prefer_gitcode: bool,
download_source: VersionDownloadSource,
) -> 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, prefer_gitcode).await?;
let (release, _) =
fetch_release_cancelable(&client, version.as_deref(), &token, download_source).await?;
let asset = select_release_asset(&release, &platform)?;
let install_dir = core_install_dir()?;
@@ -406,56 +413,53 @@ pub(crate) fn install_bundled_core_inner(
pub(crate) async fn fetch_release(
client: &reqwest::Client,
version: Option<&str>,
prefer_gitcode: bool,
) -> Result<GithubRelease, String> {
source: VersionDownloadSource,
) -> Result<(GithubRelease, VersionDownloadSource), String> {
if let Some(version) = version {
return Ok(release_from_tag_for_repositories(
version,
configured_gitcode_core_repository(),
prefer_gitcode,
return Ok((
release_from_tag_for_repositories(
version,
configured_gitcode_core_repository(),
source,
),
source,
));
}
if prefer_gitcode {
if let Some(repository) = configured_gitcode_core_repository() {
return match fetch_release_from_gitcode(client, repository).await {
Ok(release) => Ok(release),
Err(gitcode_error) => fetch_release_from_github(client)
.await
.map_err(|github_error| {
format!(
"GitCode 内核发布源失败: {gitcode_error};GitHub 回退源失败: {github_error}"
)
}),
};
}
}
let github_result = fetch_release_from_github(client).await;
match github_result {
Ok(release) => Ok(release),
Err(github_error) => {
let Some(repository) = configured_gitcode_core_repository() else {
return Err(github_error);
};
fetch_release_from_gitcode(client, repository)
let gitcode_repository = configured_gitcode_core_repository();
let mut failures = Vec::new();
for candidate in version_download_source_candidates(source, gitcode_repository.is_some()) {
let result = match candidate {
VersionDownloadSource::Gitcode => {
fetch_release_from_gitcode(
client,
gitcode_repository.expect("GitCode candidate requires a configured repository"),
)
.await
.map_err(|gitcode_error| {
format!(
"GitHub 内核发布源失败: {github_error};GitCode 回退源失败: {gitcode_error}"
)
})
}
VersionDownloadSource::Github
| VersionDownloadSource::GhProxy
| VersionDownloadSource::GhFast => fetch_release_from_github(client, candidate).await,
};
match result {
Ok(release) => return Ok((release, candidate)),
Err(error) => failures.push(format!("{}: {error}", candidate.display_name())),
}
}
Err(format!("所有内核版本检测源均失败: {}", failures.join(";")))
}
pub(crate) async fn fetch_release_from_github(
client: &reqwest::Client,
source: VersionDownloadSource,
) -> Result<GithubRelease, String> {
let atom_result = fetch_release_from_atom(client).await;
let atom_result = fetch_release_from_atom(client, source).await;
match atom_result {
Ok(release) => Ok(release),
Err(atom_error) => fetch_release_from_page(client).await.map_err(|page_error| {
format!("GitHub 发布源请求失败: {atom_error};release 页面请求失败: {page_error}")
}),
Err(atom_error) => fetch_release_from_page(client, source)
.await
.map_err(|page_error| {
format!("GitHub 发布源请求失败: {atom_error};release 页面请求失败: {page_error}")
}),
}
}
@@ -482,9 +486,11 @@ pub(crate) async fn fetch_release_from_gitcode(
pub(crate) async fn fetch_release_from_page(
client: &reqwest::Client,
source: VersionDownloadSource,
) -> Result<GithubRelease, String> {
let release_page_url = version_source_url(source, RELEASE_PAGE_URL);
let response = client
.get(RELEASE_PAGE_URL)
.get(release_page_url)
.header(reqwest::header::ACCEPT, "text/html,application/xhtml+xml")
.header(reqwest::header::USER_AGENT, USER_AGENT)
.send()
@@ -502,14 +508,20 @@ pub(crate) async fn fetch_release_from_page(
let tag = release_tag_from_url(&final_url)
.ok_or_else(|| "GitHub release 页面没有返回版本标签".to_string())?;
Ok(release_from_tag(&tag))
Ok(release_from_tag_for_repositories(
&tag,
configured_gitcode_core_repository(),
source,
))
}
pub(crate) async fn fetch_release_from_atom(
client: &reqwest::Client,
source: VersionDownloadSource,
) -> Result<GithubRelease, String> {
let release_atom_url = version_source_url(source, RELEASE_ATOM_URL);
let response = client
.get(RELEASE_ATOM_URL)
.get(release_atom_url)
.header(
reqwest::header::ACCEPT,
"application/atom+xml,application/xml,text/xml",
@@ -528,7 +540,11 @@ pub(crate) async fn fetch_release_from_atom(
}
let tag = release_tag_from_atom(&body)
.ok_or_else(|| "GitHub Atom feed 没有返回版本标签".to_string())?;
Ok(release_from_tag(&tag))
Ok(release_from_tag_for_repositories(
&tag,
configured_gitcode_core_repository(),
source,
))
}
pub(crate) fn release_tag_from_atom(xml: &str) -> Option<String> {
@@ -553,17 +569,21 @@ pub(crate) fn release_tag_from_atom(xml: &str) -> Option<String> {
}
pub(crate) fn release_from_tag(tag: &str) -> GithubRelease {
release_from_tag_for_repositories(tag, configured_gitcode_core_repository(), false)
release_from_tag_for_repositories(
tag,
configured_gitcode_core_repository(),
VersionDownloadSource::Github,
)
}
pub(crate) fn release_from_gitcode_tag(tag: &str, repository: &str) -> GithubRelease {
release_from_tag_for_repositories(tag, Some(repository), true)
release_from_tag_for_repositories(tag, Some(repository), VersionDownloadSource::Gitcode)
}
pub(crate) fn release_from_tag_for_repositories(
tag: &str,
gitcode_repository: Option<&str>,
prefer_gitcode: bool,
source: VersionDownloadSource,
) -> GithubRelease {
let tag = normalize_version(tag);
let version = tag.trim_start_matches('v');
@@ -581,13 +601,18 @@ pub(crate) fn release_from_tag_for_repositories(
let github_url = format!("{RELEASE_DOWNLOAD_PREFIX}{tag}/{name}");
let gitcode_url = gitcode_repository
.map(|repository| gitcode_release_attachment_url(repository, &tag, &name));
let (browser_download_url, fallback_download_urls) = if prefer_gitcode {
match gitcode_url {
let (browser_download_url, fallback_download_urls) = match source {
VersionDownloadSource::Gitcode => match gitcode_url {
Some(gitcode_url) => (gitcode_url, vec![github_url]),
None => (github_url, Vec::new()),
},
VersionDownloadSource::GhProxy | VersionDownloadSource::GhFast => {
let mirror_url = version_source_url(source, &github_url);
let mut fallbacks = vec![github_url];
fallbacks.extend(gitcode_url);
(mirror_url, fallbacks)
}
} else {
(github_url, gitcode_url.into_iter().collect())
VersionDownloadSource::Github => (github_url, gitcode_url.into_iter().collect()),
};
GithubAsset {
browser_download_url,
@@ -695,10 +720,10 @@ pub(crate) async fn fetch_release_cancelable(
client: &reqwest::Client,
version: Option<&str>,
token: &CancellationToken,
prefer_gitcode: bool,
) -> Result<GithubRelease, String> {
source: VersionDownloadSource,
) -> Result<(GithubRelease, VersionDownloadSource), String> {
tokio::select! {
result = fetch_release(client, version, prefer_gitcode) => result,
result = fetch_release(client, version, source) => result,
_ = token.cancelled() => Err("已取消下载".to_string()),
}
}
@@ -822,12 +847,15 @@ pub(crate) async fn download_asset(
}
pub(crate) fn core_download_source_name(url: &str) -> &'static str {
reqwest::Url::parse(url)
let host = reqwest::Url::parse(url)
.ok()
.and_then(|url| url.host_str().map(str::to_string))
.filter(|host| host == "api.gitcode.com" || host.ends_with(".gitcode.com"))
.map(|_| "GitCode")
.unwrap_or("GitHub")
.and_then(|url| url.host_str().map(str::to_string));
match host.as_deref() {
Some("gh-proxy.com") => "gh-proxy.com",
Some("ghfast.top") => "ghfast.top",
Some(host) if host == "api.gitcode.com" || host.ends_with(".gitcode.com") => "GitCode",
_ => "GitHub",
}
}
#[allow(clippy::too_many_arguments)]
+110 -4
View File
@@ -110,6 +110,7 @@ const PORTABLE_APP_BINARY: &str = "EasyCLIProxyAPI";
const CORE_INSTALL_PROGRESS_EVENT: &str = "core-install-progress";
const CORE_STATUS_EVENT: &str = "core-status-changed";
const CONFIG_FILES_CHANGED_EVENT: &str = "config-files-changed";
const VERSION_DOWNLOAD_SOURCE_CHANGED_EVENT: &str = "version-download-source-changed";
#[cfg(target_os = "windows")]
const WINDOWS_CLOSE_REQUEST_EVENT: &str = "windows-close-requested";
const CORE_METADATA_FILE: &str = "cpa-gui-meta.json";
@@ -591,6 +592,9 @@ struct GuiConfigFile {
plugins_enabled: bool,
routing_strategy: String,
proxy_url: String,
download_source: VersionDownloadSource,
// Kept for migration compatibility with configurations written before
// multi-source downloads were introduced.
prefer_gitcode_downloads: bool,
routing_session_affinity: bool,
routing_session_affinity_ttl: String,
@@ -600,6 +604,82 @@ struct GuiConfigFile {
streaming_bootstrap_retries: u32,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
enum VersionDownloadSource {
#[default]
Github,
Gitcode,
GhProxy,
GhFast,
}
impl VersionDownloadSource {
fn as_str(self) -> &'static str {
match self {
Self::Github => "github",
Self::Gitcode => "gitcode",
Self::GhProxy => "gh-proxy",
Self::GhFast => "gh-fast",
}
}
fn from_str(value: &str) -> Option<Self> {
match value.trim() {
"github" => Some(Self::Github),
"gitcode" => Some(Self::Gitcode),
"gh-proxy" => Some(Self::GhProxy),
"gh-fast" => Some(Self::GhFast),
_ => None,
}
}
fn display_name(self) -> &'static str {
match self {
Self::Github => "GitHub",
Self::Gitcode => "GitCode",
Self::GhProxy => "gh-proxy.com",
Self::GhFast => "ghfast.top",
}
}
fn github_proxy_prefix(self) -> Option<&'static str> {
match self {
Self::GhProxy => Some("https://gh-proxy.com/"),
Self::GhFast => Some("https://ghfast.top/"),
_ => None,
}
}
}
fn version_source_url(source: VersionDownloadSource, github_url: &str) -> String {
source
.github_proxy_prefix()
.map(|prefix| format!("{prefix}{github_url}"))
.unwrap_or_else(|| github_url.to_string())
}
fn version_download_source_candidates(
preferred: VersionDownloadSource,
gitcode_available: bool,
) -> Vec<VersionDownloadSource> {
[
preferred,
VersionDownloadSource::Github,
VersionDownloadSource::Gitcode,
VersionDownloadSource::GhProxy,
VersionDownloadSource::GhFast,
]
.into_iter()
.filter(|source| gitcode_available || *source != VersionDownloadSource::Gitcode)
.fold(Vec::new(), |mut sources, source| {
if !sources.contains(&source) {
sources.push(source);
}
sources
})
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
enum WindowsCloseAction {
@@ -704,6 +784,7 @@ impl Default for GuiConfigFile {
plugins_enabled: false,
routing_strategy: "round-robin".to_string(),
proxy_url: String::new(),
download_source: VersionDownloadSource::Github,
prefer_gitcode_downloads: false,
routing_session_affinity: false,
routing_session_affinity_ttl: String::new(),
@@ -733,6 +814,7 @@ struct GuiConfigPresence {
plugins_enabled: Option<bool>,
routing_strategy: Option<String>,
proxy_url: Option<String>,
download_source: Option<VersionDownloadSource>,
prefer_gitcode_downloads: Option<bool>,
routing_session_affinity: Option<bool>,
routing_session_affinity_ttl: Option<String>,
@@ -755,7 +837,7 @@ struct GuiSettings {
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct VersionSourceSettings {
prefer_gitcode_downloads: bool,
source: VersionDownloadSource,
gitcode_available: bool,
}
@@ -1743,16 +1825,39 @@ impl GuiConfigState {
})
}
fn set_prefer_gitcode_downloads(
fn set_download_source(
&self,
prefer_gitcode_downloads: bool,
download_source: VersionDownloadSource,
) -> Result<GuiConfigFile, String> {
self.update(|config| {
config.prefer_gitcode_downloads = prefer_gitcode_downloads;
config.download_source = download_source;
config.prefer_gitcode_downloads = download_source == VersionDownloadSource::Gitcode;
Ok(())
})
}
fn switch_download_source_after_failure(
&self,
expected_source: VersionDownloadSource,
fallback_source: VersionDownloadSource,
) -> Result<Option<GuiConfigFile>, String> {
let mut current = self
.inner
.lock()
.map_err(|_| "GUI 配置状态锁已损坏".to_string())?;
if current.download_source != expected_source || expected_source == fallback_source {
return Ok(None);
}
let mut config = current.clone();
config.download_source = fallback_source;
config.prefer_gitcode_downloads = fallback_source == VersionDownloadSource::Gitcode;
sanitize_gui_config(&mut config)?;
write_gui_config(&config)?;
*current = config.clone();
Ok(Some(config))
}
fn set_window_size(&self, size: SavedWindowSize) -> Result<GuiConfigFile, String> {
self.update(|config| {
config.window_width = Some(size.width);
@@ -2208,6 +2313,7 @@ fn main() {
open_oauth_url,
open_external_url,
get_version_source_settings,
set_download_source,
set_prefer_gitcode_downloads,
check_app_update,
get_app_update_task,
+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,
download_source: VersionDownloadSource::Gitcode,
prefer_gitcode_downloads: true,
..GuiConfigFile::default()
};
@@ -32,6 +33,7 @@ fn gui_field_edit_preserves_comments_and_unknown_configuration() {
assert!(content.contains("silent-start = true"));
assert!(content.contains("management-secret-key = \"custom-secret\""));
assert!(content.contains("usage-statistics-enabled = false"));
assert!(content.contains("download-source = \"gitcode\""));
assert!(content.contains("prefer-gitcode-downloads = true"));
assert!(!content.contains("codex-session-repair-on-launch"));
assert!(!content.contains("claude-code-working-directory"));
@@ -91,6 +93,7 @@ fn gui_config_defaults_are_stable() {
assert!(content.contains("management-secret-key = \"\""));
assert!(content.contains("plugins-enabled = false"));
assert!(content.contains("routing-strategy = \"round-robin\""));
assert!(content.contains("download-source = \"github\""));
assert!(content.contains("prefer-gitcode-downloads = false"));
assert!(content.contains("request-retry = 3"));
assert!(content.contains("max-retry-credentials = 0"));
+70 -7
View File
@@ -212,13 +212,42 @@ fn portable_update_download_order_respects_gitcode_preference() {
&filename,
)];
let github_first = portable_update_download_urls(&asset, false);
assert_eq!(update_download_source_name(github_first[0]), "GitHub");
assert_eq!(update_download_source_name(github_first[1]), "GitCode");
let github_first = portable_update_download_urls(&asset, VersionDownloadSource::Github);
assert_eq!(update_download_source_name(&github_first[0]), "GitHub");
assert_eq!(update_download_source_name(&github_first[1]), "GitCode");
let gitcode_first = portable_update_download_urls(&asset, true);
assert_eq!(update_download_source_name(gitcode_first[0]), "GitCode");
assert_eq!(update_download_source_name(gitcode_first[1]), "GitHub");
let gitcode_first = portable_update_download_urls(&asset, VersionDownloadSource::Gitcode);
assert_eq!(update_download_source_name(&gitcode_first[0]), "GitCode");
assert_eq!(update_download_source_name(&gitcode_first[1]), "GitHub");
let mirror_first = portable_update_download_urls(&asset, VersionDownloadSource::GhProxy);
assert_eq!(
update_download_source_name(&mirror_first[0]),
"gh-proxy.com"
);
assert_eq!(update_download_source_name(&mirror_first[1]), "GitHub");
assert_eq!(update_download_source_name(&mirror_first[2]), "GitCode");
}
#[test]
fn version_detection_candidates_try_every_available_source_once() {
assert_eq!(
version_download_source_candidates(VersionDownloadSource::GhFast, true),
[
VersionDownloadSource::GhFast,
VersionDownloadSource::Github,
VersionDownloadSource::Gitcode,
VersionDownloadSource::GhProxy,
]
);
assert_eq!(
version_download_source_candidates(VersionDownloadSource::Gitcode, false),
[
VersionDownloadSource::Github,
VersionDownloadSource::GhProxy,
VersionDownloadSource::GhFast,
]
);
}
#[test]
@@ -592,7 +621,11 @@ fn synthetic_release_uses_official_asset_names_and_urls() {
#[test]
fn synthetic_core_release_uses_gitcode_as_download_fallback() {
let repository = "lzt404/CLIProxyAPI";
let release = release_from_tag_for_repositories("7.2.80", Some(repository), false);
let release = release_from_tag_for_repositories(
"7.2.80",
Some(repository),
VersionDownloadSource::Github,
);
let platform = CorePlatform {
os: "windows".to_string(),
arch: "x86_64".to_string(),
@@ -634,6 +667,36 @@ fn gitcode_discovered_core_release_downloads_from_gitcode_first() {
);
}
#[test]
fn github_proxy_core_release_uses_proxy_then_official_and_gitcode() {
let release = release_from_tag_for_repositories(
"v7.2.80",
Some("lzt404/CLIProxyAPI"),
VersionDownloadSource::GhFast,
);
let platform = CorePlatform {
os: "windows".to_string(),
arch: "x86_64".to_string(),
asset_os: "windows".to_string(),
asset_arch: "amd64".to_string(),
archive_kind: "zip".to_string(),
};
let asset = select_release_asset(&release, &platform).unwrap();
assert_eq!(
core_download_source_name(&asset.browser_download_url),
"ghfast.top"
);
assert_eq!(
core_download_source_name(&asset.fallback_download_urls[0]),
"GitHub"
);
assert_eq!(
core_download_source_name(&asset.fallback_download_urls[1]),
"GitCode"
);
}
#[test]
fn release_asset_names_cover_the_six_supported_gui_targets() {
let targets = [
+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(),
download_source: VersionDownloadSource::Github,
prefer_gitcode_downloads: false,
routing_session_affinity: true,
routing_session_affinity_ttl: "1h".to_string(),
+3
View File
@@ -2,6 +2,8 @@ mod resp;
#[cfg(target_os = "macos")]
use super::executable_dir;
#[cfg(test)]
use super::VersionDownloadSource;
use super::{
apply_configured_proxy, core_base_dir, current_core_status, management_authorization,
management_endpoint, management_http_client, CoreProcessState, GuiConfigFile, GuiConfigState,
@@ -3325,6 +3327,7 @@ mod tests {
plugins_enabled: false,
routing_strategy: "round-robin".to_string(),
proxy_url: String::new(),
download_source: VersionDownloadSource::Github,
prefer_gitcode_downloads: false,
routing_session_affinity: false,
routing_session_affinity_ttl: String::new(),