feat: add customizable download sources with automatic fallback

- support built-in and user-defined GitHub mirror sources
- add a modal for managing custom HTTPS mirror prefixes
- automatically switch sources when version checks fail
- persist the working source and sync the selector in real time
- update translations, configuration migration, and source tests
This commit is contained in:
lzt404 committed 2026-08-26 01:56:46 +08:00
1 parent 03be4acf93
commit 6d7da20e4e
15 files changed
+820 -134

No files matched your search

+1 -1
View File
@@ -38,7 +38,7 @@ API プロバイダー統合、プロトコル変換、認証情報管理、ク
- OpenAI、Claude、Gemini 互換の API エンドポイントをそのままコピー。
- ローカル接続状態の確認。
コアのインストール、バージョン比較、オフラインインストールは **バージョン管理** ページで行えます。GitHub 公式、GitCode、GitHub ミラープロキシを切り替えられ、更新に失敗した場合は自動的に別の利用可能なソースへ切り替わります。
コアのインストール、バージョン比較、オフラインインストールは **バージョン管理** ページで行えます。GitHub 公式、GitCode、GitHub ミラープロキシを切り替えたり、カスタム HTTPS ミラープレフィックスを追加したりできます。更新に失敗した場合は自動的に別の利用可能なソースへ切り替わります。
### OAuth アカウント認証
+2 -1
View File
@@ -48,7 +48,8 @@ The Home page provides a quick overview of the local proxy runtime and ready-to-
Core installation, version comparison, and offline installation are available from the
**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.
mirror proxies or add custom HTTPS mirror prefixes; 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 地址。
- 查看本地连接状态。
内核安装、版本对比和离线安装功能位于 **版本管理** 页面;可在此切换 GitHub 官方源、GitCode 国内源或 GitHub 镜像代理,软件与内核更新会优先使用所选渠道,并在失败时自动回退。
内核安装、版本对比和离线安装功能位于 **版本管理** 页面;可在此切换 GitHub 官方源、GitCode 国内源或 GitHub 镜像代理,也可以添加自定义 HTTPS 镜像前缀。软件与内核更新会优先使用所选渠道,并在失败时自动回退。
### OAuth 账号授权
+129 -51
View File
@@ -8,10 +8,15 @@ pub(crate) fn get_version_source_settings(
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<VersionSourceSettings, String> {
let config = gui_config_state.snapshot()?;
Ok(VersionSourceSettings {
source: config.download_source,
Ok(version_source_settings(&config))
}
fn version_source_settings(config: &GuiConfigFile) -> VersionSourceSettings {
VersionSourceSettings {
source: config.selected_download_candidate().key(),
gitcode_available: gitcode_version_source_available(),
})
custom_mirrors: config.custom_download_mirrors.clone(),
}
}
#[tauri::command]
@@ -19,16 +24,60 @@ 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() {
let candidate = if let Some(url) = source.strip_prefix("custom:") {
VersionDownloadCandidate::custom(&normalize_custom_download_mirror_url(url)?)
} else {
VersionDownloadCandidate::builtin(
VersionDownloadSource::from_str(&source)
.filter(|source| *source != VersionDownloadSource::Custom)
.ok_or_else(|| "不支持的下载源".to_string())?,
)
};
if candidate.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(),
})
let config = gui_config_state.set_download_candidate(candidate)?;
Ok(version_source_settings(&config))
}
#[tauri::command]
pub(crate) fn add_custom_download_mirror(
gui_config_state: tauri::State<'_, GuiConfigState>,
url: String,
) -> Result<VersionSourceSettings, String> {
let url = normalize_custom_download_mirror_url(&url)?;
let config = gui_config_state.update(|config| {
if !config.custom_download_mirrors.contains(&url) {
if config.custom_download_mirrors.len() >= 12 {
return Err("最多可添加 12 个自定义镜像".to_string());
}
config.custom_download_mirrors.push(url.clone());
}
config.download_source = VersionDownloadSource::Custom;
config.active_custom_download_mirror = url.clone();
config.prefer_gitcode_downloads = false;
Ok(())
})?;
Ok(version_source_settings(&config))
}
#[tauri::command]
pub(crate) fn remove_custom_download_mirror(
gui_config_state: tauri::State<'_, GuiConfigState>,
url: String,
) -> Result<VersionSourceSettings, String> {
let url = normalize_custom_download_mirror_url(&url)?;
let config = gui_config_state.update(|config| {
config.custom_download_mirrors.retain(|item| item != &url);
if config.download_source == VersionDownloadSource::Custom
&& config.active_custom_download_mirror == url
{
config.download_source = VersionDownloadSource::Github;
config.active_custom_download_mirror.clear();
}
Ok(())
})?;
Ok(version_source_settings(&config))
}
#[tauri::command]
@@ -45,10 +94,7 @@ pub(crate) fn set_prefer_gitcode_downloads(
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(),
})
Ok(version_source_settings(&config))
}
pub(crate) fn gitcode_version_source_available() -> bool {
@@ -58,8 +104,8 @@ pub(crate) fn gitcode_version_source_available() -> bool {
pub(crate) fn persist_automatic_download_source_switch(
app: &tauri::AppHandle,
gui_config_state: &GuiConfigState,
requested_source: VersionDownloadSource,
resolved_source: VersionDownloadSource,
requested_source: &VersionDownloadCandidate,
resolved_source: &VersionDownloadCandidate,
) -> Result<(), String> {
let Some(config) =
gui_config_state.switch_download_source_after_failure(requested_source, resolved_source)?
@@ -68,10 +114,7 @@ pub(crate) fn persist_automatic_download_source_switch(
};
app.emit(
VERSION_DOWNLOAD_SOURCE_CHANGED_EVENT,
VersionSourceSettings {
source: config.download_source,
gitcode_available: gitcode_version_source_available(),
},
version_source_settings(&config),
)
.map_err(|error| format!("通知下载源自动切换失败: {error}"))
}
@@ -88,23 +131,30 @@ pub(crate) async fn check_app_update(
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<AppUpdateInfo, String> {
let config = gui_config_state.snapshot()?;
let proxy_url = config.proxy_url;
let proxy_url = config.proxy_url.clone();
let client = build_http_client_with_proxy(
reqwest::Client::builder()
.redirect(release_https_redirect_policy())
.redirect(release_https_redirect_policy_with_mirrors(
&config.custom_download_mirrors,
))
.connect_timeout(Duration::from_secs(8))
.read_timeout(Duration::from_secs(15))
.timeout(Duration::from_secs(20)),
&proxy_url,
"创建版本检查客户端失败",
)?;
let (manifest, resolved_source) =
fetch_portable_update_manifest(&client, config.download_source).await?;
let requested_source = config.selected_download_candidate();
let (manifest, resolved_source) = fetch_portable_update_manifest(
&client,
requested_source.clone(),
&config.custom_download_mirrors,
)
.await?;
persist_automatic_download_source_switch(
&app,
gui_config_state.inner(),
config.download_source,
resolved_source,
&requested_source,
&resolved_source,
)?;
let latest_version = normalize_version(&manifest.version);
let current_version = normalize_version(env!("CARGO_PKG_VERSION"));
@@ -161,12 +211,15 @@ pub(crate) async fn check_app_update(
pub(crate) async fn fetch_portable_update_manifest(
client: &reqwest::Client,
source: VersionDownloadSource,
) -> Result<(PortableUpdateManifest, VersionDownloadSource), String> {
source: VersionDownloadCandidate,
custom_mirrors: &[String],
) -> Result<(PortableUpdateManifest, VersionDownloadCandidate), 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 {
for candidate in
version_download_source_candidates(source, gitcode_repository.is_some(), custom_mirrors)
{
let result = match candidate.source {
VersionDownloadSource::Gitcode => {
fetch_portable_update_manifest_from_gitcode(
client,
@@ -177,7 +230,11 @@ pub(crate) async fn fetch_portable_update_manifest(
VersionDownloadSource::Github
| VersionDownloadSource::GhProxy
| VersionDownloadSource::GhFast => {
let url = version_source_url(candidate, APP_UPDATE_MANIFEST_URL);
let url = version_source_url(&candidate, APP_UPDATE_MANIFEST_URL);
fetch_portable_update_manifest_url(client, &url).await
}
VersionDownloadSource::Custom => {
let url = version_source_url(&candidate, APP_UPDATE_MANIFEST_URL);
fetch_portable_update_manifest_url(client, &url).await
}
};
@@ -275,8 +332,15 @@ pub(crate) fn gitcode_release_attachment_url(
)
}
pub(crate) fn release_https_redirect_policy() -> reqwest::redirect::Policy {
reqwest::redirect::Policy::custom(|attempt| {
pub(crate) fn release_https_redirect_policy_with_mirrors(
custom_mirrors: &[String],
) -> reqwest::redirect::Policy {
let custom_hosts = custom_mirrors
.iter()
.filter_map(|value| reqwest::Url::parse(value).ok())
.filter_map(|url| url.host_str().map(str::to_string))
.collect::<HashSet<_>>();
reqwest::redirect::Policy::custom(move |attempt| {
let url = attempt.url();
let trusted_host = matches!(
url.host_str(),
@@ -290,7 +354,9 @@ pub(crate) fn release_https_redirect_policy() -> reqwest::redirect::Policy {
| "gh-proxy.com"
| "ghfast.top"
)
);
) || url
.host_str()
.is_some_and(|host| custom_hosts.contains(host));
if url.scheme() == "https"
&& url.port().is_none()
&& url.username().is_empty()
@@ -566,8 +632,8 @@ pub(crate) async fn start_app_update(
return Err("当前平台不支持应用内自动升级".to_string());
}
let config = gui_config_state.snapshot()?;
let proxy_url = config.proxy_url;
let download_source = config.download_source;
let proxy_url = config.proxy_url.clone();
let download_source = config.selected_download_candidate();
let token = CancellationToken::new();
let pending = state.start(token.clone())?;
let task = state.snapshot();
@@ -604,7 +670,7 @@ pub(crate) async fn download_and_stage_portable_app_update(
pending: &PendingAppUpdate,
token: &CancellationToken,
proxy_url: &str,
download_source: VersionDownloadSource,
download_source: VersionDownloadCandidate,
) -> Result<(), String> {
#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
{
@@ -634,7 +700,7 @@ pub(crate) async fn download_and_stage_portable_app_update(
token,
&archive_path,
proxy_url,
download_source,
&download_source,
)
.await?;
if token.is_cancelled() {
@@ -720,7 +786,7 @@ pub(crate) async fn download_and_stage_portable_app_update(
token,
&archive_path,
proxy_url,
download_source,
&download_source,
)
.await?;
ensure_portable_update_download(token, &archive_path, pending)?;
@@ -792,7 +858,7 @@ pub(crate) async fn download_and_stage_portable_app_update(
token,
&archive_path,
proxy_url,
download_source,
&download_source,
)
.await?;
ensure_portable_update_download(token, &archive_path, pending)?;
@@ -967,11 +1033,17 @@ pub(crate) async fn download_portable_update_archive(
token: &CancellationToken,
destination: &Path,
proxy_url: &str,
download_source: VersionDownloadSource,
download_source: &VersionDownloadCandidate,
) -> Result<(), String> {
let client = build_http_client_with_proxy(
reqwest::Client::builder()
.redirect(release_https_redirect_policy())
.redirect(release_https_redirect_policy_with_mirrors(
&download_source
.custom_url
.clone()
.into_iter()
.collect::<Vec<_>>(),
))
.connect_timeout(Duration::from_secs(15))
.read_timeout(Duration::from_secs(30))
.timeout(Duration::from_secs(15 * 60)),
@@ -1004,15 +1076,15 @@ pub(crate) async fn download_portable_update_archive(
pub(crate) fn portable_update_download_urls(
asset: &PortableUpdateAsset,
source: VersionDownloadSource,
source: &VersionDownloadCandidate,
) -> 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 source == VersionDownloadSource::Gitcode {
if source.source == VersionDownloadSource::Gitcode {
urls.sort_by_key(|url| update_download_source_name(url) != "GitCode");
} else if source.github_proxy_prefix().is_some() {
} else if source.proxy_prefix().is_some() {
if let Some(github_url) = urls
.iter()
.find(|url| update_download_source_name(url) == "GitHub")
@@ -1025,15 +1097,21 @@ pub(crate) fn portable_update_download_urls(
urls
}
pub(crate) fn update_download_source_name(url: &str) -> &'static str {
pub(crate) fn update_download_source_name(url: &str) -> String {
let host = reqwest::Url::parse(url)
.ok()
.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",
Some("gh-proxy.com") => "gh-proxy.com".to_string(),
Some("ghfast.top") => "ghfast.top".to_string(),
Some(host) if host == "api.gitcode.com" || host.ends_with(".gitcode.com") => {
"GitCode".to_string()
}
Some(
"github.com" | "objects.githubusercontent.com" | "release-assets.githubusercontent.com",
) => "GitHub".to_string(),
Some(host) => host.to_string(),
None => "GitHub".to_string(),
}
}
+67
View File
@@ -1245,6 +1245,11 @@ pub(crate) fn load_or_create_gui_config() -> Result<GuiConfigFile, String> {
};
changed = true;
}
if presence.custom_download_mirrors.is_none()
|| presence.active_custom_download_mirror.is_none()
{
changed = true;
}
if presence.prefer_gitcode_downloads.is_none() {
changed = true;
}
@@ -1538,6 +1543,42 @@ pub(crate) fn sanitize_gui_config(config: &mut GuiConfigFile) -> Result<bool, St
config.proxy_url = proxy_url;
changed = true;
}
let original_custom_mirrors = config.custom_download_mirrors.clone();
config.custom_download_mirrors = config
.custom_download_mirrors
.iter()
.filter_map(|url| normalize_custom_download_mirror_url(url).ok())
.fold(Vec::new(), |mut mirrors, url| {
if !mirrors.contains(&url) {
mirrors.push(url);
}
mirrors
});
if config.custom_download_mirrors != original_custom_mirrors {
changed = true;
}
if !config.active_custom_download_mirror.is_empty() {
match normalize_custom_download_mirror_url(&config.active_custom_download_mirror) {
Ok(normalized) if normalized != config.active_custom_download_mirror => {
config.active_custom_download_mirror = normalized;
changed = true;
}
Err(_) => {
config.active_custom_download_mirror.clear();
changed = true;
}
_ => {}
}
}
if config.download_source == VersionDownloadSource::Custom
&& !config
.custom_download_mirrors
.contains(&config.active_custom_download_mirror)
{
config.download_source = VersionDownloadSource::Github;
config.active_custom_download_mirror.clear();
changed = true;
}
let routing_session_affinity_ttl = config.routing_session_affinity_ttl.trim().to_string();
if config.routing_session_affinity_ttl != routing_session_affinity_ttl {
config.routing_session_affinity_ttl = routing_session_affinity_ttl;
@@ -1656,6 +1697,20 @@ pub(crate) fn write_gui_config_to_path(
api_keys.push(Value::InlineTable(table));
}
set_codex_table_item(root, "api-keys", Item::Value(Value::Array(api_keys)));
let mut custom_download_mirrors = Array::new();
for url in &config.custom_download_mirrors {
custom_download_mirrors.push(url.as_str());
}
set_codex_table_item(
root,
"custom-download-mirrors",
Item::Value(Value::Array(custom_download_mirrors)),
);
set_codex_table_item(
root,
"active-custom-download-mirror",
value(config.active_custom_download_mirror.as_str()),
);
let mut api_access_remarks = Array::new();
for entry in &config.api_access_remarks {
let mut table = InlineTable::new();
@@ -1720,6 +1775,18 @@ pub(crate) fn validate_gui_config(config: &GuiConfigFile) -> Result<(), String>
if config.proxy_url.chars().any(char::is_control) {
return Err("代理 URL 不能包含控制字符".to_string());
}
for url in &config.custom_download_mirrors {
if normalize_custom_download_mirror_url(url).as_deref() != Ok(url.as_str()) {
return Err(format!("自定义下载镜像地址无效: {url}"));
}
}
if config.download_source == VersionDownloadSource::Custom
&& !config
.custom_download_mirrors
.contains(&config.active_custom_download_mirror)
{
return Err("当前选择的自定义下载镜像不存在".to_string());
}
if config
.routing_session_affinity_ttl
.chars()
+73 -33
View File
@@ -7,14 +7,21 @@ pub(crate) async fn check_latest_core(
) -> 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, resolved_source) = fetch_release(&client, None, config.download_source).await?;
let proxy_url = config.proxy_url.clone();
let client = http_client(&proxy_url, &config.custom_download_mirrors)?;
let requested_source = config.selected_download_candidate();
let (release, resolved_source) = fetch_release(
&client,
None,
requested_source.clone(),
&config.custom_download_mirrors,
)
.await?;
persist_automatic_download_source_switch(
&app,
gui_config_state.inner(),
config.download_source,
resolved_source,
&requested_source,
&resolved_source,
)?;
let asset = select_release_asset(&release, &platform)?;
@@ -114,7 +121,8 @@ pub(crate) async fn install_core_version(
token,
version,
&proxy_url,
config.download_source,
config.selected_download_candidate(),
config.custom_download_mirrors.clone(),
)
.await,
),
@@ -276,13 +284,20 @@ pub(crate) async fn install_core_version_inner(
token: CancellationToken,
version: Option<String>,
proxy_url: &str,
download_source: VersionDownloadSource,
download_source: VersionDownloadCandidate,
custom_mirrors: Vec<String>,
) -> Result<CoreInstallResult, String> {
let platform = current_core_platform()?;
let client = http_client(proxy_url)?;
let client = http_client(proxy_url, &custom_mirrors)?;
state.progress(window, "检查版本", 0, None, true);
let (release, _) =
fetch_release_cancelable(&client, version.as_deref(), &token, download_source).await?;
let (release, _) = fetch_release_cancelable(
&client,
version.as_deref(),
&token,
download_source,
&custom_mirrors,
)
.await?;
let asset = select_release_asset(&release, &platform)?;
let install_dir = core_install_dir()?;
@@ -413,22 +428,25 @@ pub(crate) fn install_bundled_core_inner(
pub(crate) async fn fetch_release(
client: &reqwest::Client,
version: Option<&str>,
source: VersionDownloadSource,
) -> Result<(GithubRelease, VersionDownloadSource), String> {
source: VersionDownloadCandidate,
custom_mirrors: &[String],
) -> Result<(GithubRelease, VersionDownloadCandidate), String> {
if let Some(version) = version {
return Ok((
release_from_tag_for_repositories(
version,
configured_gitcode_core_repository(),
source,
&source,
),
source,
));
}
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 {
for candidate in
version_download_source_candidates(source, gitcode_repository.is_some(), custom_mirrors)
{
let result = match candidate.source {
VersionDownloadSource::Gitcode => {
fetch_release_from_gitcode(
client,
@@ -438,7 +456,8 @@ pub(crate) async fn fetch_release(
}
VersionDownloadSource::Github
| VersionDownloadSource::GhProxy
| VersionDownloadSource::GhFast => fetch_release_from_github(client, candidate).await,
| VersionDownloadSource::GhFast
| VersionDownloadSource::Custom => fetch_release_from_github(client, &candidate).await,
};
match result {
Ok(release) => return Ok((release, candidate)),
@@ -450,7 +469,7 @@ pub(crate) async fn fetch_release(
pub(crate) async fn fetch_release_from_github(
client: &reqwest::Client,
source: VersionDownloadSource,
source: &VersionDownloadCandidate,
) -> Result<GithubRelease, String> {
let atom_result = fetch_release_from_atom(client, source).await;
match atom_result {
@@ -486,7 +505,7 @@ pub(crate) async fn fetch_release_from_gitcode(
pub(crate) async fn fetch_release_from_page(
client: &reqwest::Client,
source: VersionDownloadSource,
source: &VersionDownloadCandidate,
) -> Result<GithubRelease, String> {
let release_page_url = version_source_url(source, RELEASE_PAGE_URL);
let response = client
@@ -517,7 +536,7 @@ pub(crate) async fn fetch_release_from_page(
pub(crate) async fn fetch_release_from_atom(
client: &reqwest::Client,
source: VersionDownloadSource,
source: &VersionDownloadCandidate,
) -> Result<GithubRelease, String> {
let release_atom_url = version_source_url(source, RELEASE_ATOM_URL);
let response = client
@@ -568,22 +587,27 @@ pub(crate) fn release_tag_from_atom(xml: &str) -> Option<String> {
(!title.is_empty()).then(|| normalize_version(title))
}
#[cfg(test)]
pub(crate) fn release_from_tag(tag: &str) -> GithubRelease {
release_from_tag_for_repositories(
tag,
configured_gitcode_core_repository(),
VersionDownloadSource::Github,
&VersionDownloadCandidate::builtin(VersionDownloadSource::Github),
)
}
pub(crate) fn release_from_gitcode_tag(tag: &str, repository: &str) -> GithubRelease {
release_from_tag_for_repositories(tag, Some(repository), VersionDownloadSource::Gitcode)
release_from_tag_for_repositories(
tag,
Some(repository),
&VersionDownloadCandidate::builtin(VersionDownloadSource::Gitcode),
)
}
pub(crate) fn release_from_tag_for_repositories(
tag: &str,
gitcode_repository: Option<&str>,
source: VersionDownloadSource,
source: &VersionDownloadCandidate,
) -> GithubRelease {
let tag = normalize_version(tag);
let version = tag.trim_start_matches('v');
@@ -601,7 +625,7 @@ 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) = match source {
let (browser_download_url, fallback_download_urls) = match source.source {
VersionDownloadSource::Gitcode => match gitcode_url {
Some(gitcode_url) => (gitcode_url, vec![github_url]),
None => (github_url, Vec::new()),
@@ -613,6 +637,12 @@ pub(crate) fn release_from_tag_for_repositories(
(mirror_url, fallbacks)
}
VersionDownloadSource::Github => (github_url, gitcode_url.into_iter().collect()),
VersionDownloadSource::Custom => {
let mirror_url = version_source_url(source, &github_url);
let mut fallbacks = vec![github_url];
fallbacks.extend(gitcode_url);
(mirror_url, fallbacks)
}
};
GithubAsset {
browser_download_url,
@@ -720,10 +750,11 @@ pub(crate) async fn fetch_release_cancelable(
client: &reqwest::Client,
version: Option<&str>,
token: &CancellationToken,
source: VersionDownloadSource,
) -> Result<(GithubRelease, VersionDownloadSource), String> {
source: VersionDownloadCandidate,
custom_mirrors: &[String],
) -> Result<(GithubRelease, VersionDownloadCandidate), String> {
tokio::select! {
result = fetch_release(client, version, source) => result,
result = fetch_release(client, version, source, custom_mirrors) => result,
_ = token.cancelled() => Err("已取消下载".to_string()),
}
}
@@ -752,10 +783,13 @@ pub(crate) fn build_http_client_with_proxy(
.map_err(|error| format!("{error_prefix}: {error}"))
}
pub(crate) fn http_client(proxy_url: &str) -> Result<reqwest::Client, String> {
pub(crate) fn http_client(
proxy_url: &str,
custom_mirrors: &[String],
) -> Result<reqwest::Client, String> {
build_http_client_with_proxy(
reqwest::Client::builder()
.redirect(release_https_redirect_policy())
.redirect(release_https_redirect_policy_with_mirrors(custom_mirrors))
.connect_timeout(Duration::from_secs(15))
.read_timeout(Duration::from_secs(30))
.timeout(Duration::from_secs(600)),
@@ -846,15 +880,21 @@ pub(crate) async fn download_asset(
Err(format!("所有内核下载源均失败: {}", failures.join(";")))
}
pub(crate) fn core_download_source_name(url: &str) -> &'static str {
pub(crate) fn core_download_source_name(url: &str) -> String {
let host = reqwest::Url::parse(url)
.ok()
.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",
Some("gh-proxy.com") => "gh-proxy.com".to_string(),
Some("ghfast.top") => "ghfast.top".to_string(),
Some(host) if host == "api.gitcode.com" || host.ends_with(".gitcode.com") => {
"GitCode".to_string()
}
Some(
"github.com" | "objects.githubusercontent.com" | "release-assets.githubusercontent.com",
) => "GitHub".to_string(),
Some(host) => host.to_string(),
None => "GitHub".to_string(),
}
}
+143 -25
View File
@@ -593,6 +593,8 @@ struct GuiConfigFile {
routing_strategy: String,
proxy_url: String,
download_source: VersionDownloadSource,
custom_download_mirrors: Vec<String>,
active_custom_download_mirror: String,
// Kept for migration compatibility with configurations written before
// multi-source downloads were introduced.
prefer_gitcode_downloads: bool,
@@ -612,6 +614,7 @@ enum VersionDownloadSource {
Gitcode,
GhProxy,
GhFast,
Custom,
}
impl VersionDownloadSource {
@@ -621,6 +624,7 @@ impl VersionDownloadSource {
Self::Gitcode => "gitcode",
Self::GhProxy => "gh-proxy",
Self::GhFast => "gh-fast",
Self::Custom => "custom",
}
}
@@ -630,6 +634,7 @@ impl VersionDownloadSource {
"gitcode" => Some(Self::Gitcode),
"gh-proxy" => Some(Self::GhProxy),
"gh-fast" => Some(Self::GhFast),
"custom" => Some(Self::Custom),
_ => None,
}
}
@@ -640,6 +645,7 @@ impl VersionDownloadSource {
Self::Gitcode => "GitCode",
Self::GhProxy => "gh-proxy.com",
Self::GhFast => "ghfast.top",
Self::Custom => "自定义镜像",
}
}
@@ -647,37 +653,124 @@ impl VersionDownloadSource {
match self {
Self::GhProxy => Some("https://gh-proxy.com/"),
Self::GhFast => Some("https://ghfast.top/"),
Self::Custom => None,
_ => None,
}
}
}
fn version_source_url(source: VersionDownloadSource, github_url: &str) -> String {
#[derive(Clone, Debug, Eq, PartialEq)]
struct VersionDownloadCandidate {
source: VersionDownloadSource,
custom_url: Option<String>,
}
impl VersionDownloadCandidate {
fn builtin(source: VersionDownloadSource) -> Self {
Self {
source,
custom_url: None,
}
}
fn custom(url: &str) -> Self {
Self {
source: VersionDownloadSource::Custom,
custom_url: Some(url.to_string()),
}
}
fn key(&self) -> String {
self.custom_url
.as_ref()
.map(|url| format!("custom:{url}"))
.unwrap_or_else(|| self.source.as_str().to_string())
}
fn display_name(&self) -> String {
self.custom_url
.as_ref()
.and_then(|url| reqwest::Url::parse(url).ok())
.and_then(|url| url.host_str().map(str::to_string))
.unwrap_or_else(|| self.source.display_name().to_string())
}
fn proxy_prefix(&self) -> Option<&str> {
self.custom_url
.as_deref()
.or_else(|| self.source.github_proxy_prefix())
}
}
fn version_source_url(source: &VersionDownloadCandidate, github_url: &str) -> String {
source
.github_proxy_prefix()
.proxy_prefix()
.map(|prefix| format!("{prefix}{github_url}"))
.unwrap_or_else(|| github_url.to_string())
}
fn version_download_source_candidates(
preferred: VersionDownloadSource,
preferred: VersionDownloadCandidate,
gitcode_available: bool,
) -> Vec<VersionDownloadSource> {
[
custom_mirrors: &[String],
) -> Vec<VersionDownloadCandidate> {
let mut candidates = vec![
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);
VersionDownloadCandidate::builtin(VersionDownloadSource::Github),
VersionDownloadCandidate::builtin(VersionDownloadSource::Gitcode),
VersionDownloadCandidate::builtin(VersionDownloadSource::GhProxy),
VersionDownloadCandidate::builtin(VersionDownloadSource::GhFast),
];
candidates.retain(|candidate| {
gitcode_available || candidate.source != VersionDownloadSource::Gitcode
});
candidates.extend(
custom_mirrors
.iter()
.map(|url| VersionDownloadCandidate::custom(url)),
);
candidates
.into_iter()
.fold(Vec::new(), |mut sources, source| {
if !sources.contains(&source) {
sources.push(source);
}
sources
})
}
fn normalize_custom_download_mirror_url(value: &str) -> Result<String, String> {
let mut url =
reqwest::Url::parse(value.trim()).map_err(|error| format!("镜像地址无效: {error}"))?;
if url.scheme() != "https"
|| url.host_str().is_none()
|| url.port().is_some()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
{
return Err("镜像地址必须是无认证、端口、查询参数和片段的 HTTPS 地址".to_string());
}
if !url.path().ends_with('/') {
let path = format!("{}/", url.path());
url.set_path(&path);
}
Ok(url.to_string())
}
impl GuiConfigFile {
fn selected_download_candidate(&self) -> VersionDownloadCandidate {
if self.download_source == VersionDownloadSource::Custom
&& self
.custom_download_mirrors
.contains(&self.active_custom_download_mirror)
{
VersionDownloadCandidate::custom(&self.active_custom_download_mirror)
} else {
VersionDownloadCandidate::builtin(self.download_source)
}
sources
})
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
@@ -785,6 +878,8 @@ impl Default for GuiConfigFile {
routing_strategy: "round-robin".to_string(),
proxy_url: String::new(),
download_source: VersionDownloadSource::Github,
custom_download_mirrors: Vec::new(),
active_custom_download_mirror: String::new(),
prefer_gitcode_downloads: false,
routing_session_affinity: false,
routing_session_affinity_ttl: String::new(),
@@ -815,6 +910,8 @@ struct GuiConfigPresence {
routing_strategy: Option<String>,
proxy_url: Option<String>,
download_source: Option<VersionDownloadSource>,
custom_download_mirrors: Option<Vec<String>>,
active_custom_download_mirror: Option<String>,
prefer_gitcode_downloads: Option<bool>,
routing_session_affinity: Option<bool>,
routing_session_affinity_ttl: Option<String>,
@@ -837,8 +934,9 @@ struct GuiSettings {
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct VersionSourceSettings {
source: VersionDownloadSource,
source: String,
gitcode_available: bool,
custom_mirrors: Vec<String>,
}
#[derive(Clone, Serialize)]
@@ -1828,30 +1926,48 @@ impl GuiConfigState {
fn set_download_source(
&self,
download_source: VersionDownloadSource,
) -> Result<GuiConfigFile, String> {
self.set_download_candidate(VersionDownloadCandidate::builtin(download_source))
}
fn set_download_candidate(
&self,
candidate: VersionDownloadCandidate,
) -> Result<GuiConfigFile, String> {
self.update(|config| {
config.download_source = download_source;
config.prefer_gitcode_downloads = download_source == VersionDownloadSource::Gitcode;
if let Some(url) = &candidate.custom_url {
if !config.custom_download_mirrors.contains(url) {
return Err("自定义镜像不存在".to_string());
}
config.active_custom_download_mirror = url.clone();
}
config.download_source = candidate.source;
config.prefer_gitcode_downloads = candidate.source == VersionDownloadSource::Gitcode;
Ok(())
})
}
fn switch_download_source_after_failure(
&self,
expected_source: VersionDownloadSource,
fallback_source: VersionDownloadSource,
expected_source: &VersionDownloadCandidate,
fallback_source: &VersionDownloadCandidate,
) -> 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 {
if current.selected_download_candidate() != *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;
config.download_source = fallback_source.source;
if let Some(url) = &fallback_source.custom_url {
config.active_custom_download_mirror = url.clone();
}
config.prefer_gitcode_downloads = fallback_source.source == VersionDownloadSource::Gitcode;
sanitize_gui_config(&mut config)?;
write_gui_config(&config)?;
*current = config.clone();
@@ -2314,6 +2430,8 @@ fn main() {
open_external_url,
get_version_source_settings,
set_download_source,
add_custom_download_mirror,
remove_custom_download_mirror,
set_prefer_gitcode_downloads,
check_app_update,
get_app_update_task,
+73 -14
View File
@@ -212,15 +212,24 @@ fn portable_update_download_order_respects_gitcode_preference() {
&filename,
)];
let github_first = portable_update_download_urls(&asset, VersionDownloadSource::Github);
let github_first = portable_update_download_urls(
&asset,
&VersionDownloadCandidate::builtin(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, VersionDownloadSource::Gitcode);
let gitcode_first = portable_update_download_urls(
&asset,
&VersionDownloadCandidate::builtin(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);
let mirror_first = portable_update_download_urls(
&asset,
&VersionDownloadCandidate::builtin(VersionDownloadSource::GhProxy),
);
assert_eq!(
update_download_source_name(&mirror_first[0]),
"gh-proxy.com"
@@ -232,24 +241,74 @@ fn portable_update_download_order_respects_gitcode_preference() {
#[test]
fn version_detection_candidates_try_every_available_source_once() {
assert_eq!(
version_download_source_candidates(VersionDownloadSource::GhFast, true),
version_download_source_candidates(
VersionDownloadCandidate::builtin(VersionDownloadSource::GhFast),
true,
&[],
),
[
VersionDownloadSource::GhFast,
VersionDownloadSource::Github,
VersionDownloadSource::Gitcode,
VersionDownloadSource::GhProxy,
VersionDownloadCandidate::builtin(VersionDownloadSource::GhFast),
VersionDownloadCandidate::builtin(VersionDownloadSource::Github),
VersionDownloadCandidate::builtin(VersionDownloadSource::Gitcode),
VersionDownloadCandidate::builtin(VersionDownloadSource::GhProxy),
]
);
assert_eq!(
version_download_source_candidates(VersionDownloadSource::Gitcode, false),
version_download_source_candidates(
VersionDownloadCandidate::builtin(VersionDownloadSource::Gitcode),
false,
&[],
),
[
VersionDownloadSource::Github,
VersionDownloadSource::GhProxy,
VersionDownloadSource::GhFast,
VersionDownloadCandidate::builtin(VersionDownloadSource::Github),
VersionDownloadCandidate::builtin(VersionDownloadSource::GhProxy),
VersionDownloadCandidate::builtin(VersionDownloadSource::GhFast),
]
);
}
#[test]
fn custom_mirror_urls_are_normalized_and_join_the_fallback_chain() {
assert_eq!(
normalize_custom_download_mirror_url(" https://mirror.example.com/base ").unwrap(),
"https://mirror.example.com/base/"
);
assert!(normalize_custom_download_mirror_url("http://mirror.example.com/").is_err());
assert!(normalize_custom_download_mirror_url("https://mirror.example.com/?token=x").is_err());
let mirrors = vec![
"https://first.example.com/".to_string(),
"https://second.example.com/".to_string(),
];
let candidates = version_download_source_candidates(
VersionDownloadCandidate::custom(&mirrors[1]),
true,
&mirrors,
);
assert_eq!(candidates[0], VersionDownloadCandidate::custom(&mirrors[1]));
assert_eq!(
candidates.last(),
Some(&VersionDownloadCandidate::custom(&mirrors[0]))
);
assert_eq!(
candidates
.iter()
.filter(|candidate| candidate.custom_url.is_some())
.count(),
2
);
}
#[test]
fn custom_mirror_is_used_for_portable_update_downloads() {
let asset = portable_update_test_asset("1.2.3", "amd64");
let source = VersionDownloadCandidate::custom("https://mirror.example.com/");
let urls = portable_update_download_urls(&asset, &source);
assert!(urls[0].starts_with("https://mirror.example.com/https://github.com/"));
assert_eq!(update_download_source_name(&urls[0]), "mirror.example.com");
assert_eq!(update_download_source_name(&urls[1]), "GitHub");
}
#[test]
fn portable_update_state_supports_cancellation_and_snapshot_recovery() {
let state = AppUpdateState::default();
@@ -624,7 +683,7 @@ fn synthetic_core_release_uses_gitcode_as_download_fallback() {
let release = release_from_tag_for_repositories(
"7.2.80",
Some(repository),
VersionDownloadSource::Github,
&VersionDownloadCandidate::builtin(VersionDownloadSource::Github),
);
let platform = CorePlatform {
os: "windows".to_string(),
@@ -672,7 +731,7 @@ 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,
&VersionDownloadCandidate::builtin(VersionDownloadSource::GhFast),
);
let platform = CorePlatform {
os: "windows".to_string(),
+2
View File
@@ -1040,6 +1040,8 @@ fn startup_preserves_all_user_owned_yaml_and_only_applies_gui_managed_values() {
routing_strategy: "fill-first".to_string(),
proxy_url: "socks5://127.0.0.1:7890".to_string(),
download_source: VersionDownloadSource::Github,
custom_download_mirrors: Vec::new(),
active_custom_download_mirror: String::new(),
prefer_gitcode_downloads: false,
routing_session_affinity: true,
routing_session_affinity_ttl: "1h".to_string(),
+2
View File
@@ -3328,6 +3328,8 @@ mod tests {
routing_strategy: "round-robin".to_string(),
proxy_url: String::new(),
download_source: VersionDownloadSource::Github,
custom_download_mirrors: Vec::new(),
active_custom_download_mirror: String::new(),
prefer_gitcode_downloads: false,
routing_session_affinity: false,
routing_session_affinity_ttl: String::new(),
+13 -1
View File
@@ -973,11 +973,23 @@ export const jaOverrides = {
'kernel.versions.noBundled': 'このリリースには組み込みコアが含まれていません',
'kernel.versions.offlineInstall': 'オフラインインストール',
'kernel.versions.downloadSource': 'ダウンロード元を切り替える',
'kernel.versions.downloadSourceHint': 'ソフトウェアとコアの更新確認・ダウンロードでは選択した経路を優先し、失敗時は他の利用可能なソースに切り替えます。ミラープロキシは第三者が提供します。',
'kernel.versions.downloadSourceHint': '組み込み経路を選択するか、カスタム HTTPS ミラープレフィックスを追加できます。確認失敗時は自動的に切り替えます。ミラープロキシは第三者が提供します。',
'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.source.custom': 'カスタムミラー',
'kernel.versions.customMirrorPlaceholder': 'ミラープレフィックス(例: https://mirror.example.com/)',
'kernel.versions.customMirrorAdd': 'カスタムソースを追加',
'kernel.versions.customMirrorDialogTitle': 'カスタムダウンロード元を追加',
'kernel.versions.customMirrorDialogDescription': 'GitHub ミラープロキシの HTTPS プレフィックスを入力してください。ダウンロード時に元の GitHub URL を末尾へ追加します。',
'kernel.versions.customMirrorConfirm': '追加して切り替え',
'kernel.versions.customMirrorAdded': 'カスタムミラーを追加して切り替えました',
'kernel.versions.customMirrorAddFailed': 'ミラーの追加に失敗しました: {error}',
'kernel.versions.customMirrorRemove': 'カスタムソースを削除',
'kernel.versions.customMirrorSaved': '追加済みのカスタムソース',
'kernel.versions.customMirrorRemoved': 'カスタムミラーを削除しました',
'kernel.versions.customMirrorRemoveFailed': 'ミラーの削除に失敗しました: {error}',
'kernel.versions.gitcodeUnavailable': 'このビルドには完全な GitCode ミラー設定が含まれていません。',
'kernel.versions.gitcodeSaveFailed': 'ダウンロード元の切り替えに失敗しました: {error}',
'kernel.access.firstKey': '最初のキー',
+13 -1
View File
@@ -973,11 +973,23 @@ export const en: Record<MessageKey, string> = {
'kernel.versions.noBundled': 'This release does not include a bundled core',
'kernel.versions.offlineInstall': 'Offline Install',
'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.downloadSourceHint': 'Choose a built-in channel or add a custom HTTPS mirror prefix. Failed checks automatically fall back. 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.source.custom': 'Custom Mirror',
'kernel.versions.customMirrorPlaceholder': 'Mirror prefix, e.g. https://mirror.example.com/',
'kernel.versions.customMirrorAdd': 'Add Custom Source',
'kernel.versions.customMirrorDialogTitle': 'Add Custom Download Source',
'kernel.versions.customMirrorDialogDescription': 'Enter the HTTPS prefix of a GitHub mirror proxy. The original GitHub URL is appended when downloading.',
'kernel.versions.customMirrorConfirm': 'Add and Switch',
'kernel.versions.customMirrorAdded': 'Custom mirror added and selected',
'kernel.versions.customMirrorAddFailed': 'Failed to add mirror: {error}',
'kernel.versions.customMirrorRemove': 'Remove custom source',
'kernel.versions.customMirrorSaved': 'Added custom sources',
'kernel.versions.customMirrorRemoved': 'Custom mirror removed',
'kernel.versions.customMirrorRemoveFailed': 'Failed to remove mirror: {error}',
'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',
+13 -1
View File
@@ -971,11 +971,23 @@ export const zhCN = {
'kernel.versions.noBundled': '当前发行包未包含内置内核',
'kernel.versions.offlineInstall': '离线安装',
'kernel.versions.downloadSource': '切换下载源',
'kernel.versions.downloadSourceHint': '软件和内核的版本检查及下载将优先使用所选渠道,失败时自动回退到其他可用源。镜像代理由第三方提供。',
'kernel.versions.downloadSourceHint': '可选择内置渠道或添加自定义 HTTPS 镜像前缀;检测失败时会自动回退。镜像代理由第三方提供。',
'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.source.custom': '自定义镜像',
'kernel.versions.customMirrorPlaceholder': '镜像前缀,例如 https://mirror.example.com/',
'kernel.versions.customMirrorAdd': '添加自定义源',
'kernel.versions.customMirrorDialogTitle': '添加自定义下载源',
'kernel.versions.customMirrorDialogDescription': '填写 GitHub 镜像代理的 HTTPS 前缀。下载时会在该地址后拼接原始 GitHub URL。',
'kernel.versions.customMirrorConfirm': '添加并切换',
'kernel.versions.customMirrorAdded': '已添加并切换到自定义镜像',
'kernel.versions.customMirrorAddFailed': '添加镜像失败:{error}',
'kernel.versions.customMirrorRemove': '删除自定义源',
'kernel.versions.customMirrorSaved': '已添加的自定义源',
'kernel.versions.customMirrorRemoved': '已删除自定义镜像',
'kernel.versions.customMirrorRemoveFailed': '删除镜像失败:{error}',
'kernel.versions.gitcodeUnavailable': '当前构建未配置完整的 GitCode 镜像源。',
'kernel.versions.gitcodeSaveFailed': '切换下载源失败:{error}',
'kernel.access.firstKey': '第一个密钥',
+173 -4
View File
@@ -10,6 +10,7 @@ import {
Info,
RefreshCw,
RotateCcw,
Trash2,
} from 'lucide-react';
import { useCoreRuntime } from '../coreRuntime';
import { useI18n } from '../i18n';
@@ -41,18 +42,27 @@ export type CoreInstallTask = {
export type VersionSourceSettings = {
source: VersionDownloadSource;
gitcodeAvailable: boolean;
customMirrors: string[];
};
export type VersionDownloadSource = 'github' | 'gitcode' | 'gh-proxy' | 'gh-fast';
export type VersionDownloadSource = string;
function downloadSourceLabel(source: VersionDownloadSource, t: ReturnType<typeof useI18n>['t']) {
const keys = {
const keys: Record<string, Parameters<typeof t>[0]> = {
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]);
};
if (source.startsWith('custom:')) {
const url = source.slice('custom:'.length);
try {
return `${t('kernel.versions.source.custom')} · ${new URL(url).host}`;
} catch {
return t('kernel.versions.source.custom');
}
}
return t(keys[source] ?? 'kernel.versions.source.github');
}
export type MessageType = 'info' | 'success' | 'error';
@@ -131,6 +141,8 @@ export function VersionManagementPage() {
const [versionSource, setVersionSource] = useState<VersionSourceSettings | null>(null);
const [versionSourceSaving, setVersionSourceSaving] = useState(false);
const [versionSourceError, setVersionSourceError] = useState('');
const [customMirrorDraft, setCustomMirrorDraft] = useState('');
const [customMirrorDialogOpen, setCustomMirrorDialogOpen] = useState(false);
const [toastNotice, setToastNotice] = useState<{
message: string;
@@ -138,6 +150,7 @@ export function VersionManagementPage() {
} | null>(null);
const installDialogRef = useRef<HTMLDivElement>(null);
const customMirrorInputRef = useRef<HTMLInputElement>(null);
const latestCheckEpochRef = useRef(0);
const toastTimerRef = useRef<number | null>(null);
@@ -242,6 +255,52 @@ export function VersionManagementPage() {
}
};
const addCustomMirror = async () => {
const url = customMirrorDraft.trim();
if (!url) return;
setVersionSourceSaving(true);
setVersionSourceError('');
try {
const settings = await invoke<VersionSourceSettings>('add_custom_download_mirror', { url });
setVersionSource(settings);
setCustomMirrorDraft('');
setCustomMirrorDialogOpen(false);
cachedLatest = null;
cachedLatestError = '';
setLatest(null);
showToast(t('kernel.versions.customMirrorAdded'), 'success');
await Promise.allSettled([checkAppUpdate(), checkLatest(true)]);
} catch (error) {
const message = t('kernel.versions.customMirrorAddFailed', { error: String(error) });
setVersionSourceError(message);
showToast(message, 'error');
} finally {
setVersionSourceSaving(false);
}
};
const removeCustomMirror = async (url: string) => {
const wasSelected = versionSource?.source === `custom:${url}`;
setVersionSourceSaving(true);
setVersionSourceError('');
try {
const settings = await invoke<VersionSourceSettings>('remove_custom_download_mirror', {
url,
});
setVersionSource(settings);
showToast(t('kernel.versions.customMirrorRemoved'), 'success');
if (wasSelected) {
await Promise.allSettled([checkAppUpdate(), checkLatest(true)]);
}
} catch (error) {
const message = t('kernel.versions.customMirrorRemoveFailed', { error: String(error) });
setVersionSourceError(message);
showToast(message, 'error');
} finally {
setVersionSourceSaving(false);
}
};
const installVersion = async (version: string) => {
setInstalling(true);
setCancellingInstall(false);
@@ -403,6 +462,25 @@ export function VersionManagementPage() {
};
}, [installDialogOpen]);
useEffect(() => {
if (!customMirrorDialogOpen) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
window.setTimeout(() => customMirrorInputRef.current?.focus(), 0);
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !versionSourceSaving) {
setCustomMirrorDialogOpen(false);
}
};
document.addEventListener('keydown', closeOnEscape);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', closeOnEscape);
};
}, [customMirrorDialogOpen, versionSourceSaving]);
// Derived state calculations
const latestVersion = latest?.version ?? '';
const currentVersion = coreStatus?.currentVersion ?? '';
@@ -531,8 +609,24 @@ export function VersionManagementPage() {
</option>
<option value="gh-proxy">{t('kernel.versions.source.ghProxy')}</option>
<option value="gh-fast">{t('kernel.versions.source.ghFast')}</option>
{versionSource?.customMirrors.map((url) => (
<option key={url} value={`custom:${url}`}>
{downloadSourceLabel(`custom:${url}`, t)}
</option>
))}
</select>
</label>
<button
type="button"
className="version-source-add-button"
disabled={versionSourceSaving || appUpdateTask.running || installing}
onClick={() => {
setVersionSourceError('');
setCustomMirrorDialogOpen(true);
}}
>
<span>{t('kernel.versions.customMirrorAdd')}</span>
</button>
</div>
</div>
@@ -680,6 +774,81 @@ export function VersionManagementPage() {
</div>
</section>
{customMirrorDialogOpen ? (
<div className="install-dialog-backdrop custom-mirror-dialog-backdrop">
<form
className="install-dialog app-update-dialog custom-mirror-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="custom-mirror-dialog-title"
onSubmit={(event) => {
event.preventDefault();
void addCustomMirror();
}}
>
<div className="install-dialog-heading">
<span>{t('kernel.versions.downloadSource')}</span>
<h2 id="custom-mirror-dialog-title">{t('kernel.versions.customMirrorDialogTitle')}</h2>
</div>
<p className="custom-mirror-dialog-description">
{t('kernel.versions.customMirrorDialogDescription')}
</p>
<input
ref={customMirrorInputRef}
type="url"
value={customMirrorDraft}
disabled={versionSourceSaving}
placeholder={t('kernel.versions.customMirrorPlaceholder')}
aria-label={t('kernel.versions.customMirrorPlaceholder')}
onChange={(event) => setCustomMirrorDraft(event.currentTarget.value)}
/>
{versionSourceError ? (
<span className="custom-mirror-dialog-error" role="alert">{versionSourceError}</span>
) : null}
{versionSource?.customMirrors.length ? (
<div className="custom-mirror-dialog-list">
<span>{t('kernel.versions.customMirrorSaved')}</span>
{versionSource.customMirrors.map((url) => (
<div key={url}>
<span title={url}>{url}</span>
<button
type="button"
disabled={versionSourceSaving}
title={t('kernel.versions.customMirrorRemove')}
aria-label={t('kernel.versions.customMirrorRemove')}
onClick={() => void removeCustomMirror(url)}
>
<Trash2 size={15} aria-hidden="true" />
</button>
</div>
))}
</div>
) : null}
<div className="app-update-dialog-actions">
<button
type="button"
className="secondary-button"
disabled={versionSourceSaving}
onClick={() => {
setCustomMirrorDialogOpen(false);
setCustomMirrorDraft('');
setVersionSourceError('');
}}
>
{t('common.cancel')}
</button>
<button
type="submit"
className="secondary-button"
disabled={!customMirrorDraft.trim() || versionSourceSaving}
>
<span>{t('kernel.versions.customMirrorConfirm')}</span>
</button>
</div>
</form>
</div>
) : null}
{/* Core Update Confirmation Dialog */}
{confirmUpdateOpen ? (
<div className="install-dialog-backdrop app-update-dialog-backdrop">
+115 -1
View File
@@ -1073,11 +1073,18 @@ textarea:focus-visible {
color: var(--theme-9f2d20);
}
.version-source-control,
.version-source-control label {
display: grid;
}
.version-source-control {
display: grid;
grid-template-columns: minmax(0, 250px) auto;
width: max-content;
max-width: 100%;
gap: 8px;
}
.version-source-control select {
width: 250px;
max-width: 100%;
@@ -1100,6 +1107,29 @@ textarea:focus-visible {
opacity: 0.58;
}
.version-source-add-button {
display: inline-flex;
min-height: 42px;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 12px;
border: 1px solid var(--theme-d5d2cb);
border-radius: 8px;
background: var(--theme-fffdf8);
color: var(--theme-4b4742);
}
.version-source-add-button:hover:not(:disabled) {
border-color: var(--theme-9b958c);
background: var(--theme-faf8f2);
}
.version-source-add-button:disabled {
cursor: not-allowed;
opacity: 0.58;
}
.version-card-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1674,6 +1704,15 @@ textarea:focus-visible {
.version-source-control select {
width: 100%;
}
.version-source-control {
grid-template-columns: minmax(0, 1fr) auto;
width: 100%;
}
.version-source-add-button {
grid-column: 1 / -1;
}
}
.info-list {
@@ -8301,6 +8340,81 @@ body.table-col-resizing * {
gap: 10px;
}
.custom-mirror-dialog {
width: min(500px, 100%);
}
.custom-mirror-dialog-description {
margin: 0;
color: var(--theme-6f6961);
font-size: var(--font-size-body);
line-height: var(--line-height-relaxed);
}
.custom-mirror-dialog > input {
width: 100%;
height: 44px;
min-width: 0;
padding: 0 12px;
border: 1px solid var(--theme-d5d2cb);
border-radius: 8px;
background: var(--theme-fffdf8);
color: var(--theme-2d2a26);
}
.custom-mirror-dialog-error {
color: var(--theme-9f2d20);
font-size: var(--font-size-meta);
line-height: var(--line-height-body);
}
.custom-mirror-dialog-list {
display: grid;
max-height: 150px;
gap: 6px;
overflow-y: auto;
}
.custom-mirror-dialog-list > span {
color: var(--theme-7a746c);
font-size: var(--font-size-meta);
}
.custom-mirror-dialog-list > div {
display: grid;
grid-template-columns: minmax(0, 1fr) 34px;
align-items: center;
gap: 8px;
padding: 6px 6px 6px 10px;
border: 1px solid var(--theme-e3e1db);
border-radius: 7px;
background: var(--theme-faf8f2);
}
.custom-mirror-dialog-list > div > span {
overflow: hidden;
color: var(--theme-4b4742);
font-size: var(--font-size-meta);
text-overflow: ellipsis;
white-space: nowrap;
}
.custom-mirror-dialog-list button {
display: grid;
width: 30px;
height: 30px;
place-items: center;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--theme-9f2d20);
}
.custom-mirror-dialog-list button:hover:not(:disabled) {
background: rgba(159, 45, 32, 0.08);
}
.app-update-dialog .install-dialog-message {
display: flex;
align-items: center;