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

+1 -1
View File
@@ -38,7 +38,7 @@ API プロバイダー統合、プロトコル変換、認証情報管理、ク
- OpenAI、Claude、Gemini 互換の API エンドポイントをそのままコピー。
- ローカル接続状態の確認。
コアのインストール、バージョン比較、オフラインインストールは **バージョン管理** ページで行えます。
コアのインストール、バージョン比較、オフラインインストールは **バージョン管理** ページで行えます。GitHub 公式、GitCode、GitHub ミラープロキシを切り替えられ、更新に失敗した場合は自動的に別の利用可能なソースへ切り替わります。
### OAuth アカウント認証
+2 -2
View File
@@ -47,8 +47,8 @@ The Home page provides a quick overview of the local proxy runtime and ready-to-
- Check local connectivity and the application/core version at a glance.
Core installation, version comparison, and offline installation are available from the
**Version Management** page. Users in mainland China can manually enable the GitCode
download source there so application and core updates prefer the mirror.
**Version Management** page. You can switch between official GitHub, GitCode, and GitHub
mirror proxies; application and core updates prefer the selected channel and fall back automatically.
### OAuth account authorization
+1 -1
View File
@@ -45,7 +45,7 @@ APIMart 是一个低成本的 AI 图像与视频生成 API 平台——GPT-Image
- 复制可直接使用的 OpenAI、Claude 和 Gemini 兼容 API 地址。
- 查看本地连接状态。
内核安装、版本对比和离线安装功能位于 **版本管理** 页面;国内网络环境可在此手动开启 GitCode 下载源,软件与内核更新会优先使用该镜像。
内核安装、版本对比和离线安装功能位于 **版本管理** 页面;可在此切换 GitHub 官方源、GitCode 国内源或 GitHub 镜像代理,软件与内核更新会优先使用所选渠道,并在失败时自动回退。
### OAuth 账号授权
+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(),
+7 -4
View File
@@ -943,9 +943,8 @@ export const jaOverrides = {
'kernel.versions.appCardTitle': 'デスクトップアプリ',
'kernel.versions.coreCardTitle': 'プロキシコア',
'kernel.versions.catalogCardTitle': 'Codex モデルカタログ',
'kernel.versions.gitcodeEnabled': 'GitCode 国内ミラー有効',
'kernel.versions.gitcodeDisabled': '公式 GitHub ソース',
'kernel.versions.gitcodeSwitchSuccess': 'ダウンロード元を変更しバージョンを再確認しました',
'kernel.versions.sourceAutoSwitched': '現在のダウンロード元で確認できなかったため、{source} に自動切り替えしました',
'kernel.versions.bundledPackage': '内蔵オフラインパッケージ',
'kernel.versions.offlineRecommend': 'ネットワーク制限がありますか?インターネット接続なしで内蔵オフラインコアを直接インストールできます。',
'kernel.versions.autoUpdateSupported': 'アプリ内自動更新対応',
@@ -973,8 +972,12 @@ export const jaOverrides = {
'kernel.versions.reinstall': '再インストール',
'kernel.versions.noBundled': 'このリリースには組み込みコアが含まれていません',
'kernel.versions.offlineInstall': 'オフラインインストール',
'kernel.versions.gitcodeSource': 'GitCode 中国向けダウンロード元',
'kernel.versions.gitcodeSourceHint': '有効にすると、ソフトウェアとコアの更新確認・ダウンロードで GitCode を優先し、失敗時は GitHub に切り替えます。',
'kernel.versions.downloadSource': 'ダウンロード元を切り替える',
'kernel.versions.downloadSourceHint': 'ソフトウェアとコアの更新確認・ダウンロードでは選択した経路を優先し、失敗時は他の利用可能なソースに切り替えます。ミラープロキシは第三者が提供します。',
'kernel.versions.source.github': 'GitHub 公式',
'kernel.versions.source.gitcode': 'GitCode 中国向けミラー',
'kernel.versions.source.ghProxy': 'GitHub ミラー · gh-proxy.com',
'kernel.versions.source.ghFast': 'GitHub ミラー · ghfast.top',
'kernel.versions.gitcodeUnavailable': 'このビルドには完全な GitCode ミラー設定が含まれていません。',
'kernel.versions.gitcodeSaveFailed': 'ダウンロード元の切り替えに失敗しました: {error}',
'kernel.access.firstKey': '最初のキー',
+7 -4
View File
@@ -943,9 +943,8 @@ export const en: Record<MessageKey, string> = {
'kernel.versions.appCardTitle': 'Desktop Application',
'kernel.versions.coreCardTitle': 'Proxy Core Kernel',
'kernel.versions.catalogCardTitle': 'Codex Model Catalog',
'kernel.versions.gitcodeEnabled': 'GitCode China Mirror Active',
'kernel.versions.gitcodeDisabled': 'Official GitHub Source',
'kernel.versions.gitcodeSwitchSuccess': 'Download source updated and versions rechecked',
'kernel.versions.sourceAutoSwitched': 'The current source failed; automatically switched to {source}',
'kernel.versions.bundledPackage': 'Bundled Offline Package',
'kernel.versions.offlineRecommend': 'Network restricted? You can install the bundled offline core directly without an internet connection.',
'kernel.versions.autoUpdateSupported': 'In-app auto-update supported',
@@ -973,8 +972,12 @@ export const en: Record<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.downloadSource': 'Switch Download Source',
'kernel.versions.downloadSourceHint': 'Software and core update checks and downloads prefer the selected channel, then fall back to other available sources. Mirror proxies are third-party services.',
'kernel.versions.source.github': 'Official GitHub',
'kernel.versions.source.gitcode': 'GitCode China Mirror',
'kernel.versions.source.ghProxy': 'GitHub Mirror · gh-proxy.com',
'kernel.versions.source.ghFast': 'GitHub Mirror · ghfast.top',
'kernel.versions.gitcodeUnavailable': 'This build does not include complete GitCode mirror configuration.',
'kernel.versions.gitcodeSaveFailed': 'Failed to switch download source: {error}',
'kernel.access.firstKey': 'First key',
+7 -4
View File
@@ -941,9 +941,8 @@ export const zhCN = {
'kernel.versions.appCardTitle': '桌面客户端',
'kernel.versions.coreCardTitle': '代理内核',
'kernel.versions.catalogCardTitle': 'Codex 模型目录',
'kernel.versions.gitcodeEnabled': '已启用 GitCode 国内加速',
'kernel.versions.gitcodeDisabled': '官方 GitHub 源',
'kernel.versions.gitcodeSwitchSuccess': '已切换下载源并重新检测版本',
'kernel.versions.sourceAutoSwitched': '当前下载源检测失败,已自动切换至 {source}',
'kernel.versions.bundledPackage': '内置离线安装包',
'kernel.versions.offlineRecommend': '网络连接受限?可直接一键安装内置离线内核,无需联网。',
'kernel.versions.autoUpdateSupported': '支持应用内自动升级',
@@ -971,8 +970,12 @@ export const zhCN = {
'kernel.versions.reinstall': '重新安装',
'kernel.versions.noBundled': '当前发行包未包含内置内核',
'kernel.versions.offlineInstall': '离线安装',
'kernel.versions.gitcodeSource': 'GitCode 国内下载源',
'kernel.versions.gitcodeSourceHint': '开启后,软件和内核的版本检查及下载将优先使用 GitCode,失败时回退 GitHub。',
'kernel.versions.downloadSource': '切换下载源',
'kernel.versions.downloadSourceHint': '软件和内核的版本检查及下载将优先使用所选渠道,失败时自动回退到其他可用源。镜像代理由第三方提供。',
'kernel.versions.source.github': 'GitHub 官方源',
'kernel.versions.source.gitcode': 'GitCode 国内源',
'kernel.versions.source.ghProxy': 'GitHub 镜像 · gh-proxy.com',
'kernel.versions.source.ghFast': 'GitHub 镜像 · ghfast.top',
'kernel.versions.gitcodeUnavailable': '当前构建未配置完整的 GitCode 镜像源。',
'kernel.versions.gitcodeSaveFailed': '切换下载源失败:{error}',
'kernel.access.firstKey': '第一个密钥',
+50 -22
View File
@@ -39,10 +39,22 @@ export type CoreInstallTask = {
};
export type VersionSourceSettings = {
preferGitcodeDownloads: boolean;
source: VersionDownloadSource;
gitcodeAvailable: boolean;
};
export type VersionDownloadSource = 'github' | 'gitcode' | 'gh-proxy' | 'gh-fast';
function downloadSourceLabel(source: VersionDownloadSource, t: ReturnType<typeof useI18n>['t']) {
const keys = {
github: 'kernel.versions.source.github',
gitcode: 'kernel.versions.source.gitcode',
'gh-proxy': 'kernel.versions.source.ghProxy',
'gh-fast': 'kernel.versions.source.ghFast',
} as const;
return t(keys[source]);
}
export type MessageType = 'info' | 'success' | 'error';
const APP_RELEASE_URL = 'https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest';
@@ -210,11 +222,11 @@ export function VersionManagementPage() {
}
};
const updateVersionSource = async (enabled: boolean) => {
const updateVersionSource = async (source: VersionDownloadSource) => {
setVersionSourceSaving(true);
setVersionSourceError('');
try {
const settings = await invoke<VersionSourceSettings>('set_prefer_gitcode_downloads', { enabled });
const settings = await invoke<VersionSourceSettings>('set_download_source', { source });
setVersionSource(settings);
cachedLatest = null;
cachedLatestError = '';
@@ -312,6 +324,7 @@ export function VersionManagementPage() {
let disposed = false;
let unlisten: (() => void) | null = null;
let unlistenConfig: (() => void) | null = null;
let unlistenVersionSource: (() => void) | null = null;
listen<CoreInstallTask>('core-install-progress', (event) => {
applyInstallTask(event.payload);
@@ -331,6 +344,18 @@ export function VersionManagementPage() {
else unlistenConfig = stop;
});
void listen<VersionSourceSettings>('version-download-source-changed', (event) => {
if (disposed) return;
setVersionSource(event.payload);
setVersionSourceError('');
showToast(t('kernel.versions.sourceAutoSwitched', {
source: downloadSourceLabel(event.payload.source, t),
}), 'info');
}).then((stop) => {
if (disposed) stop();
else unlistenVersionSource = stop;
});
loadInstallTask();
loadVersionSourceSettings();
@@ -351,6 +376,7 @@ export function VersionManagementPage() {
disposed = true;
unlisten?.();
unlistenConfig?.();
unlistenVersionSource?.();
if (toastTimerRef.current !== null) {
window.clearTimeout(toastTimerRef.current);
}
@@ -471,39 +497,41 @@ export function VersionManagementPage() {
const installDialogActionDisabled = (installing || progress?.running) && (cancellingInstall || !progress?.cancellable);
const isGitcodeActive = Boolean(versionSource?.preferGitcodeDownloads);
return (
<section className="page management-page version-management-page">
<section className="panel version-list">
<div className="version-source-row" aria-label={t('kernel.versions.gitcodeSource')}>
<div className="version-source-row" aria-label={t('kernel.versions.downloadSource')}>
<div className="version-source-copy">
<strong>{t('kernel.versions.gitcodeSource')}</strong>
<span>
{versionSource?.gitcodeAvailable === false
? t('kernel.versions.gitcodeUnavailable')
: t('kernel.versions.gitcodeSourceHint')}
</span>
<strong>{t('kernel.versions.downloadSource')}</strong>
<span>{t('kernel.versions.downloadSourceHint')}</span>
{versionSource?.gitcodeAvailable === false ? (
<span>{t('kernel.versions.gitcodeUnavailable')}</span>
) : null}
{versionSourceError ? (
<span className="version-source-error" role="alert">{versionSourceError}</span>
) : null}
</div>
<div className="version-source-control">
<label className="switch-control" title={t('kernel.versions.gitcodeSource')}>
<input
type="checkbox"
role="switch"
checked={isGitcodeActive}
<label>
<span className="sr-only">{t('kernel.versions.downloadSource')}</span>
<select
value={versionSource?.source ?? 'github'}
disabled={
(!versionSource?.gitcodeAvailable && !versionSource?.preferGitcodeDownloads)
!versionSource
|| versionSourceSaving
|| appUpdateTask.running
|| installing
}
aria-label={t('kernel.versions.gitcodeSource')}
onChange={(event) => void updateVersionSource(event.currentTarget.checked)}
/>
<span className="switch-track" />
aria-label={t('kernel.versions.downloadSource')}
onChange={(event) => void updateVersionSource(event.currentTarget.value as VersionDownloadSource)}
>
<option value="github">{t('kernel.versions.source.github')}</option>
<option value="gitcode" disabled={!versionSource?.gitcodeAvailable}>
{t('kernel.versions.source.gitcode')}
</option>
<option value="gh-proxy">{t('kernel.versions.source.ghProxy')}</option>
<option value="gh-fast">{t('kernel.versions.source.ghFast')}</option>
</select>
</label>
</div>
</div>
+37
View File
@@ -1073,6 +1073,33 @@ textarea:focus-visible {
color: var(--theme-9f2d20);
}
.version-source-control,
.version-source-control label {
display: grid;
}
.version-source-control select {
width: 250px;
max-width: 100%;
height: 42px;
padding: 0 36px 0 12px;
border: 1px solid var(--theme-d5d2cb);
border-radius: 8px;
background: var(--theme-fffdf8);
color: var(--theme-2d2a26);
cursor: pointer;
font-size: var(--font-size-body);
}
.version-source-control select:hover:not(:disabled) {
border-color: var(--theme-9b958c);
}
.version-source-control select:disabled {
cursor: not-allowed;
opacity: 0.58;
}
.version-card-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1639,6 +1666,16 @@ textarea:focus-visible {
}
}
@container workspace (max-width: 560px) {
.version-source-row {
grid-template-columns: minmax(0, 1fr);
}
.version-source-control select {
width: 100%;
}
}
.info-list {
display: grid;
gap: 10px;