diff --git a/README.ja.md b/README.ja.md index f7579b6..ca1df60 100644 --- a/README.ja.md +++ b/README.ja.md @@ -38,7 +38,7 @@ API プロバイダー統合、プロトコル変換、認証情報管理、ク - OpenAI、Claude、Gemini 互換の API エンドポイントをそのままコピー。 - ローカル接続状態の確認。 -コアのインストール、バージョン比較、オフラインインストールは **バージョン管理** ページで行えます。GitHub 公式、GitCode、GitHub ミラープロキシを切り替えられ、更新に失敗した場合は自動的に別の利用可能なソースへ切り替わります。 +コアのインストール、バージョン比較、オフラインインストールは **バージョン管理** ページで行えます。GitHub 公式、GitCode、GitHub ミラープロキシを切り替えたり、カスタム HTTPS ミラープレフィックスを追加したりできます。更新に失敗した場合は自動的に別の利用可能なソースへ切り替わります。 ### OAuth アカウント認証 diff --git a/README.md b/README.md index be6628a..e2728e3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README.zh-CN.md b/README.zh-CN.md index c10c6ae..91da69e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -45,7 +45,7 @@ APIMart 是一个低成本的 AI 图像与视频生成 API 平台——GPT-Image - 复制可直接使用的 OpenAI、Claude 和 Gemini 兼容 API 地址。 - 查看本地连接状态。 -内核安装、版本对比和离线安装功能位于 **版本管理** 页面;可在此切换 GitHub 官方源、GitCode 国内源或 GitHub 镜像代理,软件与内核更新会优先使用所选渠道,并在失败时自动回退。 +内核安装、版本对比和离线安装功能位于 **版本管理** 页面;可在此切换 GitHub 官方源、GitCode 国内源或 GitHub 镜像代理,也可以添加自定义 HTTPS 镜像前缀。软件与内核更新会优先使用所选渠道,并在失败时自动回退。 ### OAuth 账号授权 diff --git a/src-tauri/src/app_update.rs b/src-tauri/src/app_update.rs index 3298400..d2d4208 100644 --- a/src-tauri/src/app_update.rs +++ b/src-tauri/src/app_update.rs @@ -8,10 +8,15 @@ pub(crate) fn get_version_source_settings( gui_config_state: tauri::State<'_, GuiConfigState>, ) -> Result { 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 { - 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 { + 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 { + 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 { 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::>(); + 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::>(), + )) .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 { let mut urls = std::iter::once(asset.url.as_str()) .chain(asset.fallback_urls.iter().map(String::as_str)) .map(str::to_string) .collect::>(); - 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(), } } diff --git a/src-tauri/src/core_config/settings.rs b/src-tauri/src/core_config/settings.rs index fd512d2..3ace3d2 100644 --- a/src-tauri/src/core_config/settings.rs +++ b/src-tauri/src/core_config/settings.rs @@ -1245,6 +1245,11 @@ pub(crate) fn load_or_create_gui_config() -> Result { }; 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 { + 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() diff --git a/src-tauri/src/core_runtime.rs b/src-tauri/src/core_runtime.rs index 93f6e3c..3e8b646 100644 --- a/src-tauri/src/core_runtime.rs +++ b/src-tauri/src/core_runtime.rs @@ -7,14 +7,21 @@ pub(crate) async fn check_latest_core( ) -> Result { 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, proxy_url: &str, - download_source: VersionDownloadSource, + download_source: VersionDownloadCandidate, + custom_mirrors: Vec, ) -> Result { 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 { 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 { 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 { 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 { (!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 { +pub(crate) fn http_client( + proxy_url: &str, + custom_mirrors: &[String], +) -> Result { 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(), } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index c7f180e..14e79a5 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -593,6 +593,8 @@ struct GuiConfigFile { routing_strategy: String, proxy_url: String, download_source: VersionDownloadSource, + custom_download_mirrors: Vec, + 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, +} + +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 { - [ + custom_mirrors: &[String], +) -> Vec { + 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 { + 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, proxy_url: Option, download_source: Option, + custom_download_mirrors: Option>, + active_custom_download_mirror: Option, prefer_gitcode_downloads: Option, routing_session_affinity: Option, routing_session_affinity_ttl: Option, @@ -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, } #[derive(Clone, Serialize)] @@ -1828,30 +1926,48 @@ impl GuiConfigState { fn set_download_source( &self, download_source: VersionDownloadSource, + ) -> Result { + self.set_download_candidate(VersionDownloadCandidate::builtin(download_source)) + } + + fn set_download_candidate( + &self, + candidate: VersionDownloadCandidate, ) -> Result { 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, 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, diff --git a/src-tauri/src/tests/app_update.rs b/src-tauri/src/tests/app_update.rs index 1202cc7..92b446c 100644 --- a/src-tauri/src/tests/app_update.rs +++ b/src-tauri/src/tests/app_update.rs @@ -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(), diff --git a/src-tauri/src/tests/core_config.rs b/src-tauri/src/tests/core_config.rs index b711419..b8e98f7 100644 --- a/src-tauri/src/tests/core_config.rs +++ b/src-tauri/src/tests/core_config.rs @@ -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(), diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index 9dd2bdb..298bbab 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -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(), diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index de6962a..a698bd8 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -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': '最初のキー', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 9830fc9..e460de3 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -973,11 +973,23 @@ export const en: Record = { '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', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 350344d..b61a87d 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -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': '第一个密钥', diff --git a/src/pages/VersionManagementPage.tsx b/src/pages/VersionManagementPage.tsx index d96388a..1664187 100644 --- a/src/pages/VersionManagementPage.tsx +++ b/src/pages/VersionManagementPage.tsx @@ -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['t']) { - const keys = { + const keys: Record[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(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(null); + const customMirrorInputRef = useRef(null); const latestCheckEpochRef = useRef(0); const toastTimerRef = useRef(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('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('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() { + {versionSource?.customMirrors.map((url) => ( + + ))} + @@ -680,6 +774,81 @@ export function VersionManagementPage() { + {customMirrorDialogOpen ? ( +
+
{ + event.preventDefault(); + void addCustomMirror(); + }} + > +
+ {t('kernel.versions.downloadSource')} +

{t('kernel.versions.customMirrorDialogTitle')}

+
+

+ {t('kernel.versions.customMirrorDialogDescription')} +

+ setCustomMirrorDraft(event.currentTarget.value)} + /> + {versionSourceError ? ( + {versionSourceError} + ) : null} + {versionSource?.customMirrors.length ? ( +
+ {t('kernel.versions.customMirrorSaved')} + {versionSource.customMirrors.map((url) => ( +
+ {url} + +
+ ))} +
+ ) : null} +
+ + +
+
+
+ ) : null} + {/* Core Update Confirmation Dialog */} {confirmUpdateOpen ? (
diff --git a/src/styles.css b/src/styles.css index 27738a2..ce1b15c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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;