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:
1 parent
e8093928c0
commit
03be4acf93
16 files changed
+507
-157
No files matched your search
+109
-47
@@ -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(¤t_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"))]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user