From fb25292cd367a3d5ee1fa70ff0791a8c71d24bbc Mon Sep 17 00:00:00 2001 From: Ali Azam Rana <85216275+alirana01@users.noreply.github.com> Date: Wed, 12 Nov 2025 16:40:29 +0100 Subject: [PATCH 1/9] EIM-112: Added the ping check for mirrors --- src-tauri/Cargo.lock | 18 ++- src-tauri/Cargo.toml | 4 +- src-tauri/src/cli/prompts.rs | 144 +++++++++++++---- src-tauri/src/cli/wizard.rs | 2 +- src-tauri/src/gui/commands/settings.rs | 55 ++++--- src-tauri/src/lib/utils.rs | 154 +++++++++++++++++-- src/components/wizard_steps/MirrorSelect.vue | 126 +++++++++++---- src/locales/cn.json | 3 + src/locales/en.json | 3 + 9 files changed, 404 insertions(+), 105 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8e5a0f00..e85f25d1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1555,6 +1555,7 @@ dependencies = [ "flate2", "fork", "fs_extra", + "futures", "git2", "idf-env", "indicatif", @@ -1592,6 +1593,7 @@ dependencies = [ "thiserror 1.0.69", "tokio", "toml 0.9.5", + "url", "uuid", "winapi", "zip", @@ -1903,9 +1905,9 @@ dependencies = [ [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] @@ -2859,9 +2861,9 @@ dependencies = [ [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -4360,9 +4362,9 @@ dependencies = [ [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" @@ -7683,9 +7685,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.4" +version = "2.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" dependencies = [ "form_urlencoded", "idna", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index dbf600a2..86b482eb 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -50,7 +50,7 @@ required-features = ["offline"] tauri-build = { version = "2.3.1", features = [], optional = true } [dependencies] -reqwest = "0.12.4" +reqwest = { version = "0.12.4", features = ["stream", "json"] } serde = { version = "1.0", features = ["derive"] } serde_derive = "1.0" serde_json = "1.0" @@ -82,6 +82,8 @@ once_cell = "1.21.3" idf-env = { git = "https://github.com/espressif/idf-env", rev="fd69ab4f550ef35647bb32d1584caa6623cbfc4e" } fs_extra = { version = "1.3.0", optional = true } lnk = "0.6.3" +futures = "0.3.31" +url = "2.5.7" # GUI-related dependencies (optional) diff --git a/src-tauri/src/cli/prompts.rs b/src-tauri/src/cli/prompts.rs index 61b84de3..d02bc52d 100644 --- a/src-tauri/src/cli/prompts.rs +++ b/src-tauri/src/cli/prompts.rs @@ -7,6 +7,7 @@ use idf_im_lib::settings::Settings; use idf_im_lib::system_dependencies; use log::{debug, info}; use rust_i18n::t; +// no runtime creation here; we run inside the app's existing Tokio runtime use crate::cli::helpers::generic_confirm_with_default; @@ -176,47 +177,122 @@ pub fn check_and_install_python( Ok(()) } -pub fn select_mirrors(mut config: Settings) -> Result { - if (config.wizard_all_questions.unwrap_or_default() - || config.idf_mirror.is_none() - || config.is_default("idf_mirror")) - && config.non_interactive == Some(false) +pub async fn select_mirrors(mut config: Settings) -> Result { + // Sort mirrors by latency and produce entries (url, score). + async fn sorted_entries(mirrors: Vec) -> Vec<(String, u32)> { + let latency_map = idf_im_lib::utils::calculate_mirror_latency_map(&mirrors).await; + let mut entries: Vec<(String, u32)> = mirrors + .into_iter() + .map(|m| { + let score = *latency_map.get(&m).unwrap_or(&u32::MAX); + (m, score) + }) + .collect(); + entries.sort_by(|a, b| { + let ascore = if a.1 == u32::MAX { u32::MAX } else { a.1 }; + let bscore = if b.1 == u32::MAX { u32::MAX } else { b.1 }; + ascore.cmp(&bscore) + }); + entries + } + fn entries_to_display(entries: &[(String, u32)]) -> Vec { + entries + .iter() + .map(|(u, s)| { + if *s == u32::MAX { + format!("{} (timeout)", u) + } else { + format!("{} ({} ms)", u, s) + } + }) + .collect() + } + + // IDF mirror + if config.non_interactive == Some(false) + && (config.wizard_all_questions.unwrap_or_default() + || config.idf_mirror.is_none() + || config.is_default("idf_mirror")) { - config.idf_mirror = Some(generic_select( - "wizard.idf.mirror", - &idf_im_lib::get_idf_mirrors_list() - .iter() - .map(|&s| s.to_string()) - .collect(), - )?) + let idf_candidates: Vec = idf_im_lib::get_idf_mirrors_list().iter().map(|&s| s.to_string()).collect(); + let entries = sorted_entries(idf_candidates).await; + let display = entries_to_display(&entries); + let selected = generic_select("wizard.idf.mirror", &display)?; + let url = selected + .split(" (") + .next() + .unwrap_or(&selected) + .to_string(); + config.idf_mirror = Some(url); + } else if config.idf_mirror.is_none() || config.is_default("idf_mirror") { + let idf_candidates: Vec = idf_im_lib::get_idf_mirrors_list().iter().map(|&s| s.to_string()).collect(); + let entries = sorted_entries(idf_candidates).await; + if let Some((url, score)) = entries.first() { + if *score == u32::MAX { + info!("Selected IDF mirror: {} (timeout)", url); + } else { + info!("Selected IDF mirror: {} ({} ms)", url, score); + } + config.idf_mirror = Some(url.clone()); + } } - if (config.wizard_all_questions.unwrap_or_default() - || config.mirror.is_none() - || config.is_default("mirror")) - && config.non_interactive == Some(false) + // Tools mirror + if config.non_interactive == Some(false) + && (config.wizard_all_questions.unwrap_or_default() + || config.mirror.is_none() + || config.is_default("mirror")) { - config.mirror = Some(generic_select( - "wizard.tools.mirror", - &idf_im_lib::get_idf_tools_mirrors_list() - .iter() - .map(|&s| s.to_string()) - .collect(), - )?) + let tools_candidates: Vec = idf_im_lib::get_idf_tools_mirrors_list().iter().map(|&s| s.to_string()).collect(); + let entries = sorted_entries(tools_candidates).await; + let display = entries_to_display(&entries); + let selected = generic_select("wizard.tools.mirror", &display)?; + let url = selected + .split(" (") + .next() + .unwrap_or(&selected) + .to_string(); + config.mirror = Some(url); + } else if config.mirror.is_none() || config.is_default("mirror") { + let tools_candidates: Vec = idf_im_lib::get_idf_tools_mirrors_list().iter().map(|&s| s.to_string()).collect(); + let entries = sorted_entries(tools_candidates).await; + if let Some((url, score)) = entries.first() { + if *score == u32::MAX { + info!("Selected Tools mirror: {} (timeout)", url); + } else { + info!("Selected Tools mirror: {} ({} ms)", url, score); + } + config.mirror = Some(url.clone()); + } } - if (config.wizard_all_questions.unwrap_or_default() - || config.pypi_mirror.is_none() - || config.is_default("pypi_mirror")) - && config.non_interactive == Some(false) + // PyPI mirror + if config.non_interactive == Some(false) + && (config.wizard_all_questions.unwrap_or_default() + || config.pypi_mirror.is_none() + || config.is_default("pypi_mirror")) { - config.pypi_mirror = Some(generic_select( - "wizard.pypi.mirror", - &idf_im_lib::get_pypi_mirrors_list() - .iter() - .map(|&s| s.to_string()) - .collect(), - )?) + let pypi_candidates: Vec = idf_im_lib::get_pypi_mirrors_list().iter().map(|&s| s.to_string()).collect(); + let entries = sorted_entries(pypi_candidates).await; + let display = entries_to_display(&entries); + let selected = generic_select("wizard.pypi.mirror", &display)?; + let url = selected + .split(" (") + .next() + .unwrap_or(&selected) + .to_string(); + config.pypi_mirror = Some(url); + } else if config.pypi_mirror.is_none() || config.is_default("pypi_mirror") { + let pypi_candidates: Vec = idf_im_lib::get_pypi_mirrors_list().iter().map(|&s| s.to_string()).collect(); + let entries = sorted_entries(pypi_candidates).await; + if let Some((url, score)) = entries.first() { + if *score == u32::MAX { + info!("Selected PyPI mirror: {} (timeout)", url); + } else { + info!("Selected PyPI mirror: {} ({} ms)", url, score); + } + config.pypi_mirror = Some(url.clone()); + } } Ok(config) diff --git a/src-tauri/src/cli/wizard.rs b/src-tauri/src/cli/wizard.rs index 94ac8e9c..a30a1ba3 100644 --- a/src-tauri/src/cli/wizard.rs +++ b/src-tauri/src/cli/wizard.rs @@ -396,7 +396,7 @@ pub async fn run_wizzard_run(mut config: Settings) -> Result<(), String> { config = select_targets_and_versions(config).await?; // mirrors select - config = select_mirrors(config)?; + config = select_mirrors(config).await?; config = select_installation_path(config)?; diff --git a/src-tauri/src/gui/commands/settings.rs b/src-tauri/src/gui/commands/settings.rs index 0e2123ef..637d3c62 100644 --- a/src-tauri/src/gui/commands/settings.rs +++ b/src-tauri/src/gui/commands/settings.rs @@ -6,7 +6,7 @@ use crate::gui::{ utils::is_path_empty_or_nonexistent, }; -use log::info; +use log::{info, warn}; use serde_json::{json, Value}; use std::{ fs::File, @@ -37,7 +37,7 @@ pub fn load_settings(app_handle: AppHandle, path: &str) { }) .expect("Failed to load settings"); log::debug!("settings after load {:?}", settings); - }); + }).expect("Failed to update settings"); send_message( &app_handle, t!("gui.settings.loaded_successfully", path = path).to_string(), @@ -207,7 +207,7 @@ pub fn set_versions(app_handle: AppHandle, versions: Vec) -> Result<(), /// Gets the list of available IDF mirrors #[tauri::command] -pub fn get_idf_mirror_list(app_handle: AppHandle) -> Value { +pub async fn get_idf_mirror_list(app_handle: AppHandle) -> Value { let settings = match get_settings_non_blocking(&app_handle) { Ok(s) => s, Err(e) => { @@ -220,17 +220,22 @@ pub fn get_idf_mirror_list(app_handle: AppHandle) -> Value { }; let mirror = settings.idf_mirror.clone().unwrap_or_default(); - let mut available_mirrors = idf_im_lib::get_idf_mirrors_list().to_vec(); + let mut available_mirrors: Vec = idf_im_lib::get_idf_mirrors_list() + .iter() + .map(|s| s.to_string()) + .collect(); - if !available_mirrors.contains(&mirror.as_str()) { - let mut new_mirrors = vec![mirror.as_str()]; + if !available_mirrors.iter().any(|m| m == &mirror) { + let mut new_mirrors = vec![mirror.clone()]; new_mirrors.extend(available_mirrors); available_mirrors = new_mirrors; } + // Pick the lowest-latency mirror to present as selected + let mirror_latency_map = idf_im_lib::utils::calculate_mirror_latency_map(&available_mirrors).await; + json!({ - "mirrors": available_mirrors, - "selected": mirror, + "mirrors": mirror_latency_map }) } @@ -252,7 +257,7 @@ pub fn set_idf_mirror(app_handle: AppHandle, mirror: String) -> Result<(), Strin /// Gets the list of available tools mirrors #[tauri::command] -pub fn get_tools_mirror_list(app_handle: AppHandle) -> Value { +pub async fn get_tools_mirror_list(app_handle: AppHandle) -> Value { let settings = match get_settings_non_blocking(&app_handle) { Ok(s) => s, Err(e) => { @@ -265,17 +270,22 @@ pub fn get_tools_mirror_list(app_handle: AppHandle) -> Value { }; let mirror = settings.mirror.clone().unwrap_or_default(); - let mut available_mirrors = idf_im_lib::get_idf_tools_mirrors_list().to_vec(); + let mut available_mirrors: Vec = idf_im_lib::get_idf_tools_mirrors_list() + .iter() + .map(|s| s.to_string()) + .collect(); - if !available_mirrors.contains(&mirror.as_str()) { - let mut new_mirrors = vec![mirror.as_str()]; + if !available_mirrors.iter().any(|m| m == &mirror) { + let mut new_mirrors = vec![mirror.clone()]; new_mirrors.extend(available_mirrors); available_mirrors = new_mirrors; } + // Pick the lowest-latency mirror to present as selected + let mirror_latency_map = idf_im_lib::utils::calculate_mirror_latency_map(&available_mirrors).await; + json!({ - "mirrors": available_mirrors, - "selected": mirror, + "mirrors": mirror_latency_map }) } @@ -297,7 +307,7 @@ pub fn set_tools_mirror(app_handle: AppHandle, mirror: String) -> Result<(), Str /// Gets the list of available tools mirrors #[tauri::command] -pub fn get_pypi_mirror_list(app_handle: AppHandle) -> Value { +pub async fn get_pypi_mirror_list(app_handle: AppHandle) -> Value { let settings = match get_settings_non_blocking(&app_handle) { Ok(s) => s, Err(e) => { @@ -310,17 +320,22 @@ pub fn get_pypi_mirror_list(app_handle: AppHandle) -> Value { }; let mirror = settings.pypi_mirror.clone().unwrap_or_default(); - let mut available_mirrors = idf_im_lib::get_pypi_mirrors_list().to_vec(); + let mut available_mirrors: Vec = idf_im_lib::get_pypi_mirrors_list() + .iter() + .map(|s| s.to_string()) + .collect(); - if !available_mirrors.contains(&mirror.as_str()) { - let mut new_mirrors = vec![mirror.as_str()]; + if !available_mirrors.iter().any(|m| m == &mirror) { + let mut new_mirrors = vec![mirror.clone()]; new_mirrors.extend(available_mirrors); available_mirrors = new_mirrors; } + // Pick the lowest-latency mirror to present as selected + let mirror_latency_map = idf_im_lib::utils::calculate_mirror_latency_map(&available_mirrors).await; + json!({ - "mirrors": available_mirrors, - "selected": mirror, + "mirrors": mirror_latency_map }) } diff --git a/src-tauri/src/lib/utils.rs b/src-tauri/src/lib/utils.rs index 1191f41f..b21008ea 100644 --- a/src-tauri/src/lib/utils.rs +++ b/src-tauri/src/lib/utils.rs @@ -1,17 +1,3 @@ -use crate::{ - command_executor::execute_command, - idf_config::{IdfConfig, IdfInstallation}, - idf_tools::read_and_parse_tools_file, - single_version_post_install, - version_manager::get_default_config_path, -}; -use anyhow::{anyhow, Result, Error}; -use git2::Repository; -use log::{debug, error, info, warn}; -use rust_search::SearchBuilder; -use serde::{Deserialize, Serialize}; -use tar::Archive; -use zstd::{decode_all, Decoder}; #[cfg(not(windows))] use std::os::unix::fs::MetadataExt; use std::{ @@ -19,8 +5,28 @@ use std::{ fs::{self, File}, io::{self, BufReader, Read}, path::{Path, PathBuf}, + time::{Duration, Instant}, }; + +use anyhow::{anyhow, Error, Result}; +use futures::StreamExt; +use git2::Repository; +use log::{debug, error, info, warn}; use regex::Regex; +use reqwest::header::{RANGE, USER_AGENT}; +use rust_search::SearchBuilder; +use serde::{Deserialize, Serialize}; +use tar::Archive; +use url::Url; +use zstd::{decode_all, Decoder}; + +use crate::{ + command_executor::execute_command, + idf_config::{IdfConfig, IdfInstallation}, + idf_tools::read_and_parse_tools_file, + single_version_post_install, + version_manager::get_default_config_path, +}; /// This function retrieves the path to the git executable. /// @@ -751,6 +757,79 @@ fn is_retryable_error(error: &io::Error) -> bool { } } +/// Returns the base domain (scheme + host + optional port) from a full URL. +fn get_base_url(url_str: &str) -> Option { + let url = Url::parse(url_str).ok()?; + Some(format!("{}://{}", url.scheme(), url.host_str()?)) +} + +/// Measures response latency (in ms) for the base domain of a given URL. +/// Returns `Some(latency_ms)` if successful, or `None` if unreachable. +pub async fn measure_url_score(url: &str, timeout: Duration) -> Option { + // Extract base URL (e.g., "https://example.com") + let base_url = get_base_url(url)?; + + // Build the HTTP client + let client = reqwest::Client::builder() + .timeout(timeout) + .redirect(reqwest::redirect::Policy::limited(5)) + .build() + .ok()?; + + // Try HEAD first + let start = Instant::now(); + match client.head(&base_url).send().await { + Ok(resp) if resp.status().is_success() => { + return Some(start.elapsed().as_millis().min(u32::MAX as u128) as u32); + } + _ => { + // Fallback to GET + let start_get = Instant::now(); + match client.get(&base_url).send().await { + Ok(resp) if resp.status().is_success() => { + return Some(start_get.elapsed().as_millis().min(u32::MAX as u128) as u32); + } + Ok(resp) => { + warn!("Mirror ping failed for {}: {:?}", base_url, resp.status()); + } + Err(e) => { + warn!("Mirror ping failed for {}: {:?}", base_url, e); + } + } + } + } + + None +} + +/// Return URL -> score (lower is better). Unreachable mirrors get u32::MAX. +pub async fn calculate_mirror_latency_map(mirrors: &[String]) -> HashMap { + let timeout = Duration::from_millis(3000); + info!( + "Starting mirror latency checks ({} candidates)...", + mirrors.len() + ); + let mut mirror_latency_map = HashMap::new(); + + for m in mirrors { + let url = m.as_str(); + match measure_url_score(url, timeout).await { + Some(score) => { + info!("Mirror score: {} -> {}", url, score); + mirror_latency_map.insert(m.clone(), score); + } + None => { + info!( + "Mirror score: {} -> unreachable (timeout {:?})", + url, timeout + ); + mirror_latency_map.insert(m.clone(), u32::MAX); + } + } + } + mirror_latency_map +} + #[cfg(test)] mod tests { use super::*; @@ -1248,4 +1327,51 @@ set(IDF_VERSION_MAJOR 5) let result = extract_zst_archive(&nonexistent_path, &extract_to); assert!(result.is_err()); } + + #[test] + fn test_get_base_url_basic() { + // Standard HTTP/HTTPS with paths and queries should reduce to scheme + host (no + // port) + let u1 = "https://example.com/path?x=1"; + let u2 = "http://example.org/another/path#frag"; + assert_eq!(get_base_url(u1), Some("https://example.com".to_string())); + assert_eq!(get_base_url(u2), Some("http://example.org".to_string())); + } + + #[test] + fn test_get_base_url_with_port_current_behavior() { + // Current implementation drops the port; assert current behavior + let u = "http://example.com:8080/svc"; + assert_eq!(get_base_url(u), Some("http://example.com".to_string())); + } + + #[test] + fn test_get_base_url_invalid_and_file_scheme() { + // Invalid URL + assert_eq!(get_base_url("not a url"), None); + // File scheme has no host + assert_eq!(get_base_url("file:///tmp/test"), None); + } + + #[tokio::test] + async fn test_measure_url_score_invalid_url() { + // Invalid URL should short-circuit (no network) and return None + let res = measure_url_score("://", std::time::Duration::from_millis(50)).await; + assert!(res.is_none()); + } + + #[tokio::test] + async fn test_calculate_mirror_latency_map_with_invalid_urls() { + // Invalid URLs should be mapped to u32::MAX deterministically + let mirrors = vec![ + "not a url".to_string(), + "://".to_string(), + "file:///not-applicable".to_string(), + ]; + let map = calculate_mirror_latency_map(&mirrors).await; + assert_eq!(map.len(), 3); + for m in mirrors { + assert_eq!(map.get(&m), Some(&u32::MAX)); + } + } } diff --git a/src/components/wizard_steps/MirrorSelect.vue b/src/components/wizard_steps/MirrorSelect.vue index 006a10bb..3145eb44 100644 --- a/src/components/wizard_steps/MirrorSelect.vue +++ b/src/components/wizard_steps/MirrorSelect.vue @@ -17,8 +17,14 @@
{{ mirror.label }} - {{ t('mirrorSelect.tags.default') }} +
+ + {{ mirror.ping + ' ms' }} + + + {{ t('mirrorSelect.status.timeout') }} + +
@@ -37,8 +43,14 @@
{{ mirror.label }} - {{ t('mirrorSelect.tags.default') }} +
+ + {{ mirror.ping + ' ms' }} + + + {{ t('mirrorSelect.status.timeout') }} + +
@@ -57,8 +69,14 @@
{{ mirror.label }} - {{ t('mirrorSelect.tags.default') }} +
+ + {{ mirror.ping + ' ms' }} + + + {{ t('mirrorSelect.status.timeout') }} + +
@@ -114,37 +132,62 @@ export default { methods: { get_available_idf_mirrors: async function () { const idf_mirrors = await invoke("get_idf_mirror_list", {}); - this.idf_mirrors = idf_mirrors.mirrors.map((mirror, index) => { + const entries = Object.entries(idf_mirrors.mirrors || {}); + const list = entries.map(([url, ping]) => { + const numericPing = Number(ping); + const normalizedPing = numericPing === 4294967295 ? 0 : (numericPing || 0); return { - value: mirror, - label: mirror, - } + value: url, + label: url, + ping: normalizedPing + }; }); - this.selected_idf_mirror = idf_mirrors.selected; + // sort by ping ascending; treat 0 as Infinity (unreachable/timeout) + list.sort((a, b) => ((a.ping && a.ping > 0) ? a.ping : Number.POSITIVE_INFINITY) - ((b.ping && b.ping > 0) ? b.ping : Number.POSITIVE_INFINITY)); + this.idf_mirrors = list; + const best = list.find(m => m.ping > 0) || list[0] || null; + this.selected_idf_mirror = best ? best.value : null; + this.defaultMirrors.idf = this.selected_idf_mirror || ''; this.loading_idfs = false; return false; }, get_available_tools_mirrors: async function () { const tools_mirrors = await invoke("get_tools_mirror_list", {}); - this.tools_mirrors = tools_mirrors.mirrors.map((mirror, index) => { + const entries = Object.entries(tools_mirrors.mirrors || {}); + const list = entries.map(([url, ping]) => { + const numericPing = Number(ping); + const normalizedPing = numericPing === 4294967295 ? 0 : (numericPing || 0); return { - value: mirror, - label: mirror, - } + value: url, + label: url, + ping: normalizedPing + }; }); - this.selected_tools_mirror = tools_mirrors.selected; + list.sort((a, b) => ((a.ping && a.ping > 0) ? a.ping : Number.POSITIVE_INFINITY) - ((b.ping && b.ping > 0) ? b.ping : Number.POSITIVE_INFINITY)); + this.tools_mirrors = list; + const best = list.find(m => m.ping > 0) || list[0] || null; + this.selected_tools_mirror = best ? best.value : null; + this.defaultMirrors.tools = this.selected_tools_mirror || ''; this.loading_tools = false; return false; }, get_available_pypi_mirrors: async function () { const pypi_mirrors = await invoke("get_pypi_mirror_list", {}); - this.pypi_mirrors = pypi_mirrors.mirrors.map((mirror, index) => { + const entries = Object.entries(pypi_mirrors.mirrors || {}); + const list = entries.map(([url, ping]) => { + const numericPing = Number(ping); + const normalizedPing = numericPing === 4294967295 ? 0 : (numericPing || 0); return { - value: mirror, - label: mirror, - } + value: url, + label: url, + ping: normalizedPing + }; }); - this.selected_pypi_mirror = pypi_mirrors.selected; + list.sort((a, b) => ((a.ping && a.ping > 0) ? a.ping : Number.POSITIVE_INFINITY) - ((b.ping && b.ping > 0) ? b.ping : Number.POSITIVE_INFINITY)); + this.pypi_mirrors = list; + const best = list.find(m => m.ping > 0) || list[0] || null; + this.selected_pypi_mirror = best ? best.value : null; + this.defaultMirrors.pypi = this.selected_pypi_mirror || ''; this.loading_pypi = false; return false; }, @@ -273,18 +316,47 @@ export default { .mirror-content { display: flex; - align-items: center; - justify-content: space-between; - gap: 0.75rem; + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; pointer-events: none; } .mirror-url { font-size: 0.875rem; color: #374151; - word-break: break-all; - flex: 1; - min-width: 0; + overflow-wrap: anywhere; + width: 100%; +} + +.mirror-ping { + font-size: 0.75rem; + color: #6b7280; + margin-right: 0; +} + +.mirror-subline { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.status-badge { + display: inline-flex; + align-items: center; + height: 20px; + padding: 0 0.5rem; + border-radius: 0.25rem; + font-size: 0.75rem; + font-weight: 500; + line-height: 1; + white-space: nowrap; +} + +.status-badge.timeout { + background-color: #f3f4f6; /* gray-100 */ + color: #6b7280; /* gray-500 */ + border: 1px solid #e5e7eb;/* gray-200 */ } .mirror-tag { diff --git a/src/locales/cn.json b/src/locales/cn.json index 36627009..3a904aba 100644 --- a/src/locales/cn.json +++ b/src/locales/cn.json @@ -489,6 +489,9 @@ "tags": { "default": "默认" }, + "status": { + "timeout": "超时" + }, "continueButton": "使用所选镜像继续" }, "installationPathSelect": { diff --git a/src/locales/en.json b/src/locales/en.json index 6645c464..98ae5698 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -489,6 +489,9 @@ "tags": { "default": "Default" }, + "status": { + "timeout": "Timeout" + }, "continueButton": "Continue with Selected Mirrors" }, "installationPathSelect": { From e1fdabf163c8bffe1e13104f08699583d8f6049b Mon Sep 17 00:00:00 2001 From: Ali Azam Rana <85216275+alirana01@users.noreply.github.com> Date: Tue, 18 Nov 2025 11:09:38 +0100 Subject: [PATCH 2/9] code cleanup and ai review comments --- src-tauri/src/cli/prompts.rs | 201 +++++++++++-------------- src-tauri/src/gui/commands/settings.rs | 2 +- src-tauri/src/lib/utils.rs | 82 ++++++++++ 3 files changed, 173 insertions(+), 112 deletions(-) diff --git a/src-tauri/src/cli/prompts.rs b/src-tauri/src/cli/prompts.rs index d02bc52d..d6572471 100644 --- a/src-tauri/src/cli/prompts.rs +++ b/src-tauri/src/cli/prompts.rs @@ -1,15 +1,18 @@ use std::path::PathBuf; -use crate::cli::helpers::{ - first_defaulted_multiselect, generic_confirm, generic_input, generic_select, run_with_spinner, +use idf_im_lib::{ + settings::Settings, + system_dependencies, + utils::{mirror_entries_to_display, sorted_mirror_entries, url_from_display_line}, }; -use idf_im_lib::settings::Settings; -use idf_im_lib::system_dependencies; use log::{debug, info}; use rust_i18n::t; -// no runtime creation here; we run inside the app's existing Tokio runtime +// no runtime creation here; we run inside the app's existing Tokio runtime use crate::cli::helpers::generic_confirm_with_default; +use crate::cli::helpers::{ + first_defaulted_multiselect, generic_confirm, generic_input, generic_select, run_with_spinner, +}; pub async fn select_target() -> Result, String> { let mut available_targets = idf_im_lib::idf_versions::get_avalible_targets().await?; @@ -177,123 +180,99 @@ pub fn check_and_install_python( Ok(()) } -pub async fn select_mirrors(mut config: Settings) -> Result { - // Sort mirrors by latency and produce entries (url, score). - async fn sorted_entries(mirrors: Vec) -> Vec<(String, u32)> { - let latency_map = idf_im_lib::utils::calculate_mirror_latency_map(&mirrors).await; - let mut entries: Vec<(String, u32)> = mirrors - .into_iter() - .map(|m| { - let score = *latency_map.get(&m).unwrap_or(&u32::MAX); - (m, score) - }) - .collect(); - entries.sort_by(|a, b| { - let ascore = if a.1 == u32::MAX { u32::MAX } else { a.1 }; - let bscore = if b.1 == u32::MAX { u32::MAX } else { b.1 }; - ascore.cmp(&bscore) - }); - entries - } - fn entries_to_display(entries: &[(String, u32)]) -> Vec { - entries - .iter() - .map(|(u, s)| { - if *s == u32::MAX { - format!("{} (timeout)", u) - } else { - format!("{} ({} ms)", u, s) - } - }) - .collect() - } +async fn select_single_mirror( + config: &mut Settings, + field_name: &str, // e.g. "idf_mirror" + get_value: FGet, // e.g. |c: &Settings| &c.idf_mirror + set_value: FSet, // e.g. |c: &mut Settings, v| c.idf_mirror = Some(v) + candidates: Vec, // list of mirror URLs + wizard_key: &str, // e.g. "wizard.idf.mirror" + log_prefix: &str, // e.g. "IDF", "Tools", "PyPI" +) -> Result<(), String> +where + FGet: Fn(&Settings) -> &Option, + FSet: Fn(&mut Settings, String), +{ + let interactive = config.non_interactive == Some(false); + let wizard_all = config.wizard_all_questions.unwrap_or_default(); + let current = get_value(config); + let needs_value = current.is_none() || config.is_default(field_name); - // IDF mirror - if config.non_interactive == Some(false) - && (config.wizard_all_questions.unwrap_or_default() - || config.idf_mirror.is_none() - || config.is_default("idf_mirror")) - { - let idf_candidates: Vec = idf_im_lib::get_idf_mirrors_list().iter().map(|&s| s.to_string()).collect(); - let entries = sorted_entries(idf_candidates).await; - let display = entries_to_display(&entries); - let selected = generic_select("wizard.idf.mirror", &display)?; - let url = selected - .split(" (") - .next() - .unwrap_or(&selected) - .to_string(); - config.idf_mirror = Some(url); - } else if config.idf_mirror.is_none() || config.is_default("idf_mirror") { - let idf_candidates: Vec = idf_im_lib::get_idf_mirrors_list().iter().map(|&s| s.to_string()).collect(); - let entries = sorted_entries(idf_candidates).await; + // Measure and sort mirrors by latency + let entries = sorted_mirror_entries(candidates).await; + + if interactive && (wizard_all || needs_value) { + // Interactive mode: show list and let user pick + let display = mirror_entries_to_display(&entries); + let selected = generic_select(wizard_key, &display)?; + let url = url_from_display_line(&selected); + set_value(config, url); + } else if needs_value { + // Non-interactive or wizard not requesting this: pick best automatically if let Some((url, score)) = entries.first() { if *score == u32::MAX { - info!("Selected IDF mirror: {} (timeout)", url); + info!("Selected {log_prefix} mirror: {url} (timeout)"); } else { - info!("Selected IDF mirror: {} ({} ms)", url, score); + info!("Selected {log_prefix} mirror: {url} ({score} ms)"); } - config.idf_mirror = Some(url.clone()); + set_value(config, url.clone()); } } + Ok(()) +} + +pub async fn select_mirrors(mut config: Settings) -> Result { + // IDF mirror + let idf_candidates: Vec = idf_im_lib::get_idf_mirrors_list() + .iter() + .map(|&s| s.to_string()) + .collect(); + + select_single_mirror( + &mut config, + "idf_mirror", + |c: &Settings| &c.idf_mirror, + |c: &mut Settings, v| c.idf_mirror = Some(v), + idf_candidates, + "wizard.idf.mirror", + "IDF", + ) + .await?; + // Tools mirror - if config.non_interactive == Some(false) - && (config.wizard_all_questions.unwrap_or_default() - || config.mirror.is_none() - || config.is_default("mirror")) - { - let tools_candidates: Vec = idf_im_lib::get_idf_tools_mirrors_list().iter().map(|&s| s.to_string()).collect(); - let entries = sorted_entries(tools_candidates).await; - let display = entries_to_display(&entries); - let selected = generic_select("wizard.tools.mirror", &display)?; - let url = selected - .split(" (") - .next() - .unwrap_or(&selected) - .to_string(); - config.mirror = Some(url); - } else if config.mirror.is_none() || config.is_default("mirror") { - let tools_candidates: Vec = idf_im_lib::get_idf_tools_mirrors_list().iter().map(|&s| s.to_string()).collect(); - let entries = sorted_entries(tools_candidates).await; - if let Some((url, score)) = entries.first() { - if *score == u32::MAX { - info!("Selected Tools mirror: {} (timeout)", url); - } else { - info!("Selected Tools mirror: {} ({} ms)", url, score); - } - config.mirror = Some(url.clone()); - } - } + let tools_candidates: Vec = idf_im_lib::get_idf_tools_mirrors_list() + .iter() + .map(|&s| s.to_string()) + .collect(); + + select_single_mirror( + &mut config, + "mirror", + |c: &Settings| &c.mirror, + |c: &mut Settings, v| c.mirror = Some(v), + tools_candidates, + "wizard.tools.mirror", + "Tools", + ) + .await?; // PyPI mirror - if config.non_interactive == Some(false) - && (config.wizard_all_questions.unwrap_or_default() - || config.pypi_mirror.is_none() - || config.is_default("pypi_mirror")) - { - let pypi_candidates: Vec = idf_im_lib::get_pypi_mirrors_list().iter().map(|&s| s.to_string()).collect(); - let entries = sorted_entries(pypi_candidates).await; - let display = entries_to_display(&entries); - let selected = generic_select("wizard.pypi.mirror", &display)?; - let url = selected - .split(" (") - .next() - .unwrap_or(&selected) - .to_string(); - config.pypi_mirror = Some(url); - } else if config.pypi_mirror.is_none() || config.is_default("pypi_mirror") { - let pypi_candidates: Vec = idf_im_lib::get_pypi_mirrors_list().iter().map(|&s| s.to_string()).collect(); - let entries = sorted_entries(pypi_candidates).await; - if let Some((url, score)) = entries.first() { - if *score == u32::MAX { - info!("Selected PyPI mirror: {} (timeout)", url); - } else { - info!("Selected PyPI mirror: {} ({} ms)", url, score); - } - config.pypi_mirror = Some(url.clone()); - } - } + let pypi_candidates: Vec = idf_im_lib::get_pypi_mirrors_list() + .iter() + .map(|&s| s.to_string()) + .collect(); + + select_single_mirror( + &mut config, + "pypi_mirror", + |c: &Settings| &c.pypi_mirror, + |c: &mut Settings, v| c.pypi_mirror = Some(v), + pypi_candidates, + "wizard.pypi.mirror", + "PyPI", + ) + .await?; Ok(config) } diff --git a/src-tauri/src/gui/commands/settings.rs b/src-tauri/src/gui/commands/settings.rs index 637d3c62..984cb931 100644 --- a/src-tauri/src/gui/commands/settings.rs +++ b/src-tauri/src/gui/commands/settings.rs @@ -37,7 +37,7 @@ pub fn load_settings(app_handle: AppHandle, path: &str) { }) .expect("Failed to load settings"); log::debug!("settings after load {:?}", settings); - }).expect("Failed to update settings"); + }).unwrap_or_else(|e| warn!("Failed to update settings: {}", e)); send_message( &app_handle, t!("gui.settings.loaded_successfully", path = path).to_string(), diff --git a/src-tauri/src/lib/utils.rs b/src-tauri/src/lib/utils.rs index b21008ea..6bf78f3c 100644 --- a/src-tauri/src/lib/utils.rs +++ b/src-tauri/src/lib/utils.rs @@ -28,6 +28,9 @@ use crate::{ version_manager::get_default_config_path, }; +/// A tuple containing a mirror URL and its measured latency. +pub type MirrorEntry = (String, u32); + /// This function retrieves the path to the git executable. /// /// # Purpose @@ -830,6 +833,41 @@ pub async fn calculate_mirror_latency_map(mirrors: &[String]) -> HashMap) -> Vec { + let latency_map = calculate_mirror_latency_map(&mirrors).await; + let mut entries: Vec = mirrors + .into_iter() + .map(|m| { + let score = *latency_map.get(&m).unwrap_or(&u32::MAX); + (m, score) + }) + .collect(); + + entries.sort_by_key(|e| e.1); + entries +} + +/// Turn `(url, latency)` tuples into display strings like `https://... (123 ms)` or `(... timeout)`. +pub fn mirror_entries_to_display(entries: &[MirrorEntry]) -> Vec { + entries + .iter() + .map(|(u, s)| { + if *s == u32::MAX { + format!("{u} (timeout)") + } else { + format!("{u} ({s} ms)") + } + }) + .collect() +} + +/// Strip the latency suffix back to a plain URL. +pub fn url_from_display_line(selected: &str) -> String { + selected.split(" (").next().unwrap_or(selected).to_string() +} + + #[cfg(test)] mod tests { use super::*; @@ -1374,4 +1412,48 @@ set(IDF_VERSION_MAJOR 5) assert_eq!(map.get(&m), Some(&u32::MAX)); } } + + #[tokio::test] + async fn test_sorted_mirror_entries_all_invalid_preserves_order() { + let mirrors = vec![ + "not a url".to_string(), + "file:///not-applicable".to_string(), + "://".to_string(), + ]; + let entries = sorted_mirror_entries(mirrors.clone()).await; + assert_eq!(entries.len(), 3); + // All invalid -> all scores should be u32::MAX + assert!(entries.iter().all(|(_, s)| *s == u32::MAX)); + // Sort is stable for equal keys; order of URLs should match input + let ordered_urls: Vec = entries.into_iter().map(|(u, _)| u).collect(); + assert_eq!(ordered_urls, mirrors); + } + + #[test] + fn test_mirror_entries_to_display_formats() { + let entries: Vec = vec![ + ("https://example.com".to_string(), 123), + ("https://bad.example".to_string(), u32::MAX), + ]; + let display = mirror_entries_to_display(&entries); + assert_eq!(display[0], "https://example.com (123 ms)"); + assert_eq!(display[1], "https://bad.example (timeout)"); + } + + #[test] + fn test_url_from_display_line_roundtrip() { + assert_eq!( + url_from_display_line("https://example.com (123 ms)"), + "https://example.com" + ); + assert_eq!( + url_from_display_line("https://example.com (timeout)"), + "https://example.com" + ); + // If there is no suffix, it should return the whole string unchanged + assert_eq!( + url_from_display_line("https://example.com"), + "https://example.com" + ); + } } From 10b9c32bc243e17915ef6f0664032b577f75f6b4 Mon Sep 17 00:00:00 2001 From: Ali Azam Rana <85216275+alirana01@users.noreply.github.com> Date: Tue, 18 Nov 2025 11:43:20 +0100 Subject: [PATCH 3/9] constant extracted for u32 --- src/components/wizard_steps/MirrorSelect.vue | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/wizard_steps/MirrorSelect.vue b/src/components/wizard_steps/MirrorSelect.vue index 3145eb44..2016ec1e 100644 --- a/src/components/wizard_steps/MirrorSelect.vue +++ b/src/components/wizard_steps/MirrorSelect.vue @@ -130,12 +130,13 @@ export default { } }), methods: { + U32_MAX: 4294967295, get_available_idf_mirrors: async function () { const idf_mirrors = await invoke("get_idf_mirror_list", {}); const entries = Object.entries(idf_mirrors.mirrors || {}); const list = entries.map(([url, ping]) => { const numericPing = Number(ping); - const normalizedPing = numericPing === 4294967295 ? 0 : (numericPing || 0); + const normalizedPing = numericPing === this.U32_MAX ? 0 : (numericPing || 0); return { value: url, label: url, @@ -156,7 +157,7 @@ export default { const entries = Object.entries(tools_mirrors.mirrors || {}); const list = entries.map(([url, ping]) => { const numericPing = Number(ping); - const normalizedPing = numericPing === 4294967295 ? 0 : (numericPing || 0); + const normalizedPing = numericPing === this.U32_MAX ? 0 : (numericPing || 0); return { value: url, label: url, @@ -176,7 +177,7 @@ export default { const entries = Object.entries(pypi_mirrors.mirrors || {}); const list = entries.map(([url, ping]) => { const numericPing = Number(ping); - const normalizedPing = numericPing === 4294967295 ? 0 : (numericPing || 0); + const normalizedPing = numericPing === this.U32_MAX ? 0 : (numericPing || 0); return { value: url, label: url, From 5c82fadcd467255951ab2d93f13a1dd735766aff Mon Sep 17 00:00:00 2001 From: Ali Azam Rana <85216275+alirana01@users.noreply.github.com> Date: Tue, 18 Nov 2025 13:31:19 +0100 Subject: [PATCH 4/9] Revert "constant extracted for u32" This reverts commit 10b9c32bc243e17915ef6f0664032b577f75f6b4. --- src/components/wizard_steps/MirrorSelect.vue | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/components/wizard_steps/MirrorSelect.vue b/src/components/wizard_steps/MirrorSelect.vue index 2016ec1e..3145eb44 100644 --- a/src/components/wizard_steps/MirrorSelect.vue +++ b/src/components/wizard_steps/MirrorSelect.vue @@ -130,13 +130,12 @@ export default { } }), methods: { - U32_MAX: 4294967295, get_available_idf_mirrors: async function () { const idf_mirrors = await invoke("get_idf_mirror_list", {}); const entries = Object.entries(idf_mirrors.mirrors || {}); const list = entries.map(([url, ping]) => { const numericPing = Number(ping); - const normalizedPing = numericPing === this.U32_MAX ? 0 : (numericPing || 0); + const normalizedPing = numericPing === 4294967295 ? 0 : (numericPing || 0); return { value: url, label: url, @@ -157,7 +156,7 @@ export default { const entries = Object.entries(tools_mirrors.mirrors || {}); const list = entries.map(([url, ping]) => { const numericPing = Number(ping); - const normalizedPing = numericPing === this.U32_MAX ? 0 : (numericPing || 0); + const normalizedPing = numericPing === 4294967295 ? 0 : (numericPing || 0); return { value: url, label: url, @@ -177,7 +176,7 @@ export default { const entries = Object.entries(pypi_mirrors.mirrors || {}); const list = entries.map(([url, ping]) => { const numericPing = Number(ping); - const normalizedPing = numericPing === this.U32_MAX ? 0 : (numericPing || 0); + const normalizedPing = numericPing === 4294967295 ? 0 : (numericPing || 0); return { value: url, label: url, From c82dbe90229ecf6a159cb29447e557b06647d648 Mon Sep 17 00:00:00 2001 From: Ali Azam Rana <85216275+alirana01@users.noreply.github.com> Date: Tue, 18 Nov 2025 13:46:19 +0100 Subject: [PATCH 5/9] fix(gui): Latency checks now dont stop the UI interactions --- src-tauri/src/gui/commands/settings.rs | 208 ++++++++++++++++++- src-tauri/src/gui/mod.rs | 6 + src/components/wizard_steps/MirrorSelect.vue | 208 +++++++++++++------ 3 files changed, 342 insertions(+), 80 deletions(-) diff --git a/src-tauri/src/gui/commands/settings.rs b/src-tauri/src/gui/commands/settings.rs index 984cb931..b1d73ff5 100644 --- a/src-tauri/src/gui/commands/settings.rs +++ b/src-tauri/src/gui/commands/settings.rs @@ -1,19 +1,21 @@ -use tauri::{AppHandle, Manager}; -use idf_im_lib::{settings,to_absolute_path, utils::is_valid_idf_directory}; -use crate::gui::{ - app_state::{self, get_locked_settings, get_settings_non_blocking, update_settings, AppState}, - ui::send_message, - utils::is_path_empty_or_nonexistent, +use std::{ + fs::File, + io::Read, + path::{Path, PathBuf}, + time::Duration, }; +use idf_im_lib::{settings, to_absolute_path, utils::is_valid_idf_directory}; use log::{info, warn}; +use rust_i18n::t; use serde_json::{json, Value}; -use std::{ - fs::File, - io::Read, - path::{Path, PathBuf}, +use tauri::{async_runtime, AppHandle, Manager}; + +use crate::gui::{ + app_state::{self, get_locked_settings, get_settings_non_blocking, update_settings, AppState}, + ui::{emit_to_fe, send_message}, + utils::is_path_empty_or_nonexistent, }; -use rust_i18n::t; /// Gets the current settings #[tauri::command] @@ -239,6 +241,37 @@ pub async fn get_idf_mirror_list(app_handle: AppHandle) -> Value { }) } +/// Returns only the available IDF mirror URLs quickly (no latency calculation) +#[tauri::command] +pub fn get_idf_mirror_urls(app_handle: AppHandle) -> Value { + let settings = match get_settings_non_blocking(&app_handle) { + Ok(s) => s, + Err(e) => { + send_message(&app_handle, e, "error".to_string()); + return json!({ + "mirrors": Vec::::new(), + "selected": "", + }); + } + }; + + let selected = settings.idf_mirror.clone().unwrap_or_default(); + let mut available_mirrors: Vec = idf_im_lib::get_idf_mirrors_list() + .iter() + .map(|s| s.to_string()) + .collect(); + if !available_mirrors.iter().any(|m| m == &selected) && !selected.is_empty() { + let mut new_mirrors = vec![selected.clone()]; + new_mirrors.extend(available_mirrors); + available_mirrors = new_mirrors; + } + + json!({ + "mirrors": available_mirrors, + "selected": selected, + }) +} + /// Sets the selected IDF mirror #[tauri::command] pub fn set_idf_mirror(app_handle: AppHandle, mirror: String) -> Result<(), String> { @@ -289,6 +322,38 @@ pub async fn get_tools_mirror_list(app_handle: AppHandle) -> Value { }) } +/// Returns only the available tools mirror URLs quickly (no latency +/// calculation) +#[tauri::command] +pub fn get_tools_mirror_urls(app_handle: AppHandle) -> Value { + let settings = match get_settings_non_blocking(&app_handle) { + Ok(s) => s, + Err(e) => { + send_message(&app_handle, e, "error".to_string()); + return json!({ + "mirrors": Vec::::new(), + "selected": "", + }); + } + }; + + let selected = settings.mirror.clone().unwrap_or_default(); + let mut available_mirrors: Vec = idf_im_lib::get_idf_tools_mirrors_list() + .iter() + .map(|s| s.to_string()) + .collect(); + if !available_mirrors.iter().any(|m| m == &selected) && !selected.is_empty() { + let mut new_mirrors = vec![selected.clone()]; + new_mirrors.extend(available_mirrors); + available_mirrors = new_mirrors; + } + + json!({ + "mirrors": available_mirrors, + "selected": selected, + }) +} + /// Sets the selected tools mirror #[tauri::command] pub fn set_tools_mirror(app_handle: AppHandle, mirror: String) -> Result<(), String> { @@ -339,6 +404,37 @@ pub async fn get_pypi_mirror_list(app_handle: AppHandle) -> Value { }) } +/// Returns only the available PyPI mirror URLs quickly (no latency calculation) +#[tauri::command] +pub fn get_pypi_mirror_urls(app_handle: AppHandle) -> Value { + let settings = match get_settings_non_blocking(&app_handle) { + Ok(s) => s, + Err(e) => { + send_message(&app_handle, e, "error".to_string()); + return json!({ + "mirrors": Vec::::new(), + "selected": "", + }); + } + }; + + let selected = settings.pypi_mirror.clone().unwrap_or_default(); + let mut available_mirrors: Vec = idf_im_lib::get_pypi_mirrors_list() + .iter() + .map(|s| s.to_string()) + .collect(); + if !available_mirrors.iter().any(|m| m == &selected) && !selected.is_empty() { + let mut new_mirrors = vec![selected.clone()]; + new_mirrors.extend(available_mirrors); + available_mirrors = new_mirrors; + } + + json!({ + "mirrors": available_mirrors, + "selected": selected, + }) +} + /// Sets the selected PyPI mirror #[tauri::command] pub fn set_pypi_mirror(app_handle: AppHandle, mirror: String) -> Result<(), String> { @@ -381,6 +477,96 @@ pub async fn is_path_empty_or_nonexistent_command(app_handle: AppHandle, path: S is_path_empty_or_nonexistent(&path, &versions) } +/// Start streaming latency measurements for IDF mirrors via events. +/// Emits per-mirror updates as: event "idf-mirror-latency" with payload { url, +/// latency } Emits completion as: event "idf-mirror-latency-done" +#[tauri::command] +pub fn start_idf_mirror_latency_checks(app_handle: AppHandle) { + async_runtime::spawn(async move { + let mirrors: Vec = idf_im_lib::get_idf_mirrors_list() + .iter() + .map(|s| s.to_string()) + .collect(); + let timeout = Duration::from_millis(3000); + for m in mirrors { + let score = match idf_im_lib::utils::measure_url_score(&m, timeout).await { + Some(s) => s, + None => u32::MAX, + }; + emit_to_fe( + &app_handle, + "idf-mirror-latency", + json!({ "url": m, "latency": score }), + ); + } + emit_to_fe( + &app_handle, + "idf-mirror-latency-done", + json!({ "done": true }), + ); + }); +} + +/// Start streaming latency measurements for Tools mirrors via events. +/// Emits per-mirror updates as: event "tools-mirror-latency" with payload { +/// url, latency } Emits completion as: event "tools-mirror-latency-done" +#[tauri::command] +pub fn start_tools_mirror_latency_checks(app_handle: AppHandle) { + async_runtime::spawn(async move { + let mirrors: Vec = idf_im_lib::get_idf_tools_mirrors_list() + .iter() + .map(|s| s.to_string()) + .collect(); + let timeout = Duration::from_millis(3000); + for m in mirrors { + let score = match idf_im_lib::utils::measure_url_score(&m, timeout).await { + Some(s) => s, + None => u32::MAX, + }; + emit_to_fe( + &app_handle, + "tools-mirror-latency", + json!({ "url": m, "latency": score }), + ); + } + emit_to_fe( + &app_handle, + "tools-mirror-latency-done", + json!({ "done": true }), + ); + }); +} + +/// Start streaming latency measurements for PyPI mirrors via events. +/// Emits per-mirror updates as: event "pypi-mirror-latency" with payload { url, +/// latency } Emits completion as: event "pypi-mirror-latency-done" +#[tauri::command] +pub fn start_pypi_mirror_latency_checks(app_handle: AppHandle) { + async_runtime::spawn(async move { + let mirrors: Vec = idf_im_lib::get_pypi_mirrors_list() + .iter() + .map(|s| s.to_string()) + .collect(); + let timeout = Duration::from_millis(3000); + for m in mirrors { + let score = match idf_im_lib::utils::measure_url_score(&m, timeout).await { + Some(s) => s, + None => u32::MAX, + }; + emit_to_fe( + &app_handle, + "pypi-mirror-latency", + json!({ "url": m, "latency": score }), + ); + } + emit_to_fe( + &app_handle, + "pypi-mirror-latency-done", + json!({ "done": true }), + ); + }); +} + #[tauri::command] pub async fn is_path_idf_directory(_app_handle: AppHandle, path: String) -> bool { is_valid_idf_directory(&path) diff --git a/src-tauri/src/gui/mod.rs b/src-tauri/src/gui/mod.rs index 604b18cb..295ed32c 100644 --- a/src-tauri/src/gui/mod.rs +++ b/src-tauri/src/gui/mod.rs @@ -234,8 +234,12 @@ pub fn run() { get_idf_versions, set_versions, get_idf_mirror_list, + get_idf_mirror_urls, + start_idf_mirror_latency_checks, set_idf_mirror, get_tools_mirror_list, + get_tools_mirror_urls, + start_tools_mirror_latency_checks, set_tools_mirror, load_settings, get_installation_path, @@ -269,6 +273,8 @@ pub fn run() { set_locale, open_terminal_with_script, get_pypi_mirror_list, + get_pypi_mirror_urls, + start_pypi_mirror_latency_checks, set_pypi_mirror, ]) .run(tauri::generate_context!()) diff --git a/src/components/wizard_steps/MirrorSelect.vue b/src/components/wizard_steps/MirrorSelect.vue index 3145eb44..d027cfb0 100644 --- a/src/components/wizard_steps/MirrorSelect.vue +++ b/src/components/wizard_steps/MirrorSelect.vue @@ -9,7 +9,7 @@

{{ t('mirrorSelect.sections.idfMirror') }}

- +
{{ mirror.label }}
- - {{ mirror.ping + ' ms' }} - - - {{ t('mirrorSelect.status.timeout') }} - +
@@ -35,7 +37,7 @@

{{ t('mirrorSelect.sections.toolsMirror') }}

+ data-id="tools-mirror-radio-group" @update:value="onSelectChange('tools')">
{{ mirror.label }}
- - {{ mirror.ping + ' ms' }} - - - {{ t('mirrorSelect.status.timeout') }} - +
@@ -61,7 +65,7 @@

{{ t('mirrorSelect.sections.pypiMirror') }}

+ data-id="pypi-mirror-radio-group" @update:value="onSelectChange('pypi')">
{{ mirror.label }}
- - {{ mirror.ping + ' ms' }} - - - {{ t('mirrorSelect.status.timeout') }} - +
@@ -99,6 +105,7 @@ import { ref } from "vue"; import { useI18n } from 'vue-i18n'; import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; import { NButton, NSpin, NCard, NRadio, NRadioGroup } from 'naive-ui' import loading from "naive-ui/es/_internal/loading"; @@ -127,69 +134,122 @@ export default { idf: '', tools: '', pypi: '' + }, + listeners: [], + autoSelect: { + idf: true, + tools: true, + pypi: true } }), methods: { - get_available_idf_mirrors: async function () { - const idf_mirrors = await invoke("get_idf_mirror_list", {}); - const entries = Object.entries(idf_mirrors.mirrors || {}); - const list = entries.map(([url, ping]) => { - const numericPing = Number(ping); - const normalizedPing = numericPing === 4294967295 ? 0 : (numericPing || 0); - return { - value: url, - label: url, - ping: normalizedPing - }; + sortMirrorsByPing(list) { + list.sort((a, b) => { + const ap = (a.ping !== null && a.ping > 0) ? a.ping : Number.POSITIVE_INFINITY; + const bp = (b.ping !== null && b.ping > 0) ? b.ping : Number.POSITIVE_INFINITY; + return ap - bp; }); - // sort by ping ascending; treat 0 as Infinity (unreachable/timeout) - list.sort((a, b) => ((a.ping && a.ping > 0) ? a.ping : Number.POSITIVE_INFINITY) - ((b.ping && b.ping > 0) ? b.ping : Number.POSITIVE_INFINITY)); + }, + onSelectChange(type) { + // User has manually chosen a mirror for this type; stop auto-selecting + if (this.autoSelect[type]) { + this.autoSelect[type] = false; + } + }, + getBestMirror(list) { + // best = smallest positive ping; ignore null (unknown) and 0 (timeout) + const candidates = list.filter(m => m.ping !== null && m.ping > 0); + if (candidates.length === 0) return null; + let best = candidates[0]; + for (let i = 1; i < candidates.length; i++) { + if (candidates[i].ping < best.ping) best = candidates[i]; + } + return best; + }, + maybeAutoSelectBest(type) { + if (!this.autoSelect[type]) return; + const list = type === 'idf' ? this.idf_mirrors : type === 'tools' ? this.tools_mirrors : this.pypi_mirrors; + const best = this.getBestMirror(list); + if (!best) return; + const selectedKey = type === 'idf' ? 'selected_idf_mirror' : type === 'tools' ? 'selected_tools_mirror' : 'selected_pypi_mirror'; + const current = this[selectedKey]; + // If nothing selected or current is slower (or unknown), switch to best + const currentEntry = list.find(m => m.value === current) || null; + const currentPing = currentEntry ? currentEntry.ping : null; + const currentScore = (currentPing !== null && currentPing > 0) ? currentPing : Number.POSITIVE_INFINITY; + if (best.ping < currentScore) { + this[selectedKey] = best.value; + } + }, + async get_available_idf_mirrors() { + const res = await invoke("get_idf_mirror_urls", {}); + const urls = res.mirrors || []; + const list = urls.map((url) => ({ value: url, label: url, ping: null })); this.idf_mirrors = list; - const best = list.find(m => m.ping > 0) || list[0] || null; - this.selected_idf_mirror = best ? best.value : null; + this.selected_idf_mirror = res.selected || (list[0] ? list[0].value : null); this.defaultMirrors.idf = this.selected_idf_mirror || ''; this.loading_idfs = false; - return false; + // kick off streaming checks + invoke("start_idf_mirror_latency_checks", {}).catch(() => {}); }, - get_available_tools_mirrors: async function () { - const tools_mirrors = await invoke("get_tools_mirror_list", {}); - const entries = Object.entries(tools_mirrors.mirrors || {}); - const list = entries.map(([url, ping]) => { - const numericPing = Number(ping); - const normalizedPing = numericPing === 4294967295 ? 0 : (numericPing || 0); - return { - value: url, - label: url, - ping: normalizedPing - }; - }); - list.sort((a, b) => ((a.ping && a.ping > 0) ? a.ping : Number.POSITIVE_INFINITY) - ((b.ping && b.ping > 0) ? b.ping : Number.POSITIVE_INFINITY)); + async get_available_tools_mirrors() { + const res = await invoke("get_tools_mirror_urls", {}); + const urls = res.mirrors || []; + const list = urls.map((url) => ({ value: url, label: url, ping: null })); this.tools_mirrors = list; - const best = list.find(m => m.ping > 0) || list[0] || null; - this.selected_tools_mirror = best ? best.value : null; + this.selected_tools_mirror = res.selected || (list[0] ? list[0].value : null); this.defaultMirrors.tools = this.selected_tools_mirror || ''; this.loading_tools = false; - return false; + invoke("start_tools_mirror_latency_checks", {}).catch(() => {}); }, - get_available_pypi_mirrors: async function () { - const pypi_mirrors = await invoke("get_pypi_mirror_list", {}); - const entries = Object.entries(pypi_mirrors.mirrors || {}); - const list = entries.map(([url, ping]) => { - const numericPing = Number(ping); - const normalizedPing = numericPing === 4294967295 ? 0 : (numericPing || 0); - return { - value: url, - label: url, - ping: normalizedPing - }; - }); - list.sort((a, b) => ((a.ping && a.ping > 0) ? a.ping : Number.POSITIVE_INFINITY) - ((b.ping && b.ping > 0) ? b.ping : Number.POSITIVE_INFINITY)); + async get_available_pypi_mirrors() { + const res = await invoke("get_pypi_mirror_urls", {}); + const urls = res.mirrors || []; + const list = urls.map((url) => ({ value: url, label: url, ping: null })); this.pypi_mirrors = list; - const best = list.find(m => m.ping > 0) || list[0] || null; - this.selected_pypi_mirror = best ? best.value : null; + this.selected_pypi_mirror = res.selected || (list[0] ? list[0].value : null); this.defaultMirrors.pypi = this.selected_pypi_mirror || ''; this.loading_pypi = false; - return false; + invoke("start_pypi_mirror_latency_checks", {}).catch(() => {}); + }, + async setupLatencyListeners() { + // IDF + const un1 = await listen("idf-mirror-latency", (event) => { + const { url, latency } = event.payload || {}; + const normalized = Number(latency) === 4294967295 ? 0 : (Number(latency) || 0); + const idx = this.idf_mirrors.findIndex(m => m.value === url); + if (idx !== -1) { + this.idf_mirrors[idx].ping = normalized; + this.sortMirrorsByPing(this.idf_mirrors); + this.maybeAutoSelectBest('idf'); + } + }); + const un1d = await listen("idf-mirror-latency-done", () => {}); + // Tools + const un2 = await listen("tools-mirror-latency", (event) => { + const { url, latency } = event.payload || {}; + const normalized = Number(latency) === 4294967295 ? 0 : (Number(latency) || 0); + const idx = this.tools_mirrors.findIndex(m => m.value === url); + if (idx !== -1) { + this.tools_mirrors[idx].ping = normalized; + this.sortMirrorsByPing(this.tools_mirrors); + this.maybeAutoSelectBest('tools'); + } + }); + const un2d = await listen("tools-mirror-latency-done", () => {}); + // PyPI + const un3 = await listen("pypi-mirror-latency", (event) => { + const { url, latency } = event.payload || {}; + const normalized = Number(latency) === 4294967295 ? 0 : (Number(latency) || 0); + const idx = this.pypi_mirrors.findIndex(m => m.value === url); + if (idx !== -1) { + this.pypi_mirrors[idx].ping = normalized; + this.sortMirrorsByPing(this.pypi_mirrors); + this.maybeAutoSelectBest('pypi'); + } + }); + const un3d = await listen("pypi-mirror-latency-done", () => {}); + this.listeners = [un1, un1d, un2, un2d, un3, un3d]; }, isDefaultMirror(mirror, type) { return mirror === this.defaultMirrors[type]; @@ -215,9 +275,19 @@ export default { } }, mounted() { + this.setupLatencyListeners(); this.get_available_idf_mirrors(); this.get_available_tools_mirrors(); this.get_available_pypi_mirrors(); + }, + beforeUnmount() { + // cleanup listeners + if (this.listeners && this.listeners.length) { + this.listeners.forEach(un => { + try { un(); } catch (_) {} + }); + this.listeners = []; + } } } From 84263b043f6b5eb144c3450f5b8046617576d493 Mon Sep 17 00:00:00 2001 From: Ali Azam Rana <85216275+alirana01@users.noreply.github.com> Date: Mon, 24 Nov 2025 15:57:48 +0100 Subject: [PATCH 6/9] review comments and moving some mirror latency code to settings.rs in library --- src-tauri/Cargo.lock | 1 - src-tauri/Cargo.toml | 1 - src-tauri/src/cli/prompts.rs | 4 +- src-tauri/src/cli/wizard.rs | 2 +- src-tauri/src/gui/commands/idf_tools.rs | 30 +++- src-tauri/src/gui/commands/installation.rs | 75 ++++---- src-tauri/src/gui/commands/settings.rs | 180 ++++--------------- src-tauri/src/gui/mod.rs | 3 - src-tauri/src/lib/settings.rs | 47 ++++- src-tauri/src/lib/utils.rs | 99 +++++++--- src-tauri/src/offline_installer_builder.rs | 2 +- src/components/wizard_steps/MirrorSelect.vue | 139 +++++++------- src/main.js | 9 + src/store.js | 151 ++++++++++++++++ 14 files changed, 445 insertions(+), 298 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e85f25d1..0a84be04 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1555,7 +1555,6 @@ dependencies = [ "flate2", "fork", "fs_extra", - "futures", "git2", "idf-env", "indicatif", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 86b482eb..1ccddcd7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -82,7 +82,6 @@ once_cell = "1.21.3" idf-env = { git = "https://github.com/espressif/idf-env", rev="fd69ab4f550ef35647bb32d1584caa6623cbfc4e" } fs_extra = { version = "1.3.0", optional = true } lnk = "0.6.3" -futures = "0.3.31" url = "2.5.7" diff --git a/src-tauri/src/cli/prompts.rs b/src-tauri/src/cli/prompts.rs index d6572471..91de80c2 100644 --- a/src-tauri/src/cli/prompts.rs +++ b/src-tauri/src/cli/prompts.rs @@ -249,8 +249,8 @@ pub async fn select_mirrors(mut config: Settings) -> Result { select_single_mirror( &mut config, "mirror", - |c: &Settings| &c.mirror, - |c: &mut Settings, v| c.mirror = Some(v), + |c: &Settings| &c.tools_mirror, + |c: &mut Settings, v| c.tools_mirror = Some(v), tools_candidates, "wizard.tools.mirror", "Tools", diff --git a/src-tauri/src/cli/wizard.rs b/src-tauri/src/cli/wizard.rs index a30a1ba3..8bb7e5a8 100644 --- a/src-tauri/src/cli/wizard.rs +++ b/src-tauri/src/cli/wizard.rs @@ -321,7 +321,7 @@ async fn download_and_extract_tools( config.target.clone().unwrap(), download_dir, install_dir, - config.mirror.as_deref(), + config.tools_mirror.as_deref(), progress_callback, ) .await diff --git a/src-tauri/src/gui/commands/idf_tools.rs b/src-tauri/src/gui/commands/idf_tools.rs index 7d2e939c..e04b32b0 100644 --- a/src-tauri/src/gui/commands/idf_tools.rs +++ b/src-tauri/src/gui/commands/idf_tools.rs @@ -311,6 +311,19 @@ pub async fn setup_tools( } } }; + let default_mirror = settings.tools_mirror.as_deref().unwrap(); + let mirror_latency_map = settings.get_tools_mirror_latency_map().await.unwrap(); + let best_mirror = mirror_latency_map + .iter() + .min_by_key(|(_, latency)| *latency) + .unwrap() + .0 + .clone(); + let mirror = match best_mirror.is_empty() { + true => default_mirror, + false => best_mirror.as_str(), + }; + // Use the library's setup_tools function let installed_tools_list = idf_tools::setup_tools( @@ -318,7 +331,7 @@ pub async fn setup_tools( settings.target.clone().unwrap_or_default(), &PathBuf::from(&tool_setup.download_dir), &PathBuf::from(&tool_setup.install_dir), - settings.mirror.as_deref(), + Some(mirror), progress_callback, ) .await @@ -353,6 +366,19 @@ pub async fn setup_tools( } }; + let default_pypi_mirror = settings.pypi_mirror.as_deref().unwrap(); + let pypi_mirror_latency_map = settings.get_pypi_mirror_latency_map().await.unwrap(); + let best_pypi_mirror = pypi_mirror_latency_map + .iter() + .min_by_key(|(_, latency)| *latency) + .unwrap() + .0 + .clone(); + let pypi_mirror = match best_pypi_mirror.is_empty() { + true => default_pypi_mirror, + false => best_pypi_mirror.as_str(), + }; + // Install Python environment match idf_im_lib::python_utils::install_python_env( &paths, @@ -361,7 +387,7 @@ pub async fn setup_tools( true, //TODO: actually read from config &settings.idf_features.clone().unwrap_or_default(), offline_archive_dir, // Offline archive directory - &settings.pypi_mirror, // PyPI mirror + &Some(pypi_mirror.to_string()), // PyPI mirror ).await { Ok(_) => { info!("Python environment installed"); diff --git a/src-tauri/src/gui/commands/installation.rs b/src-tauri/src/gui/commands/installation.rs index 23d2c264..28e727af 100644 --- a/src-tauri/src/gui/commands/installation.rs +++ b/src-tauri/src/gui/commands/installation.rs @@ -128,11 +128,11 @@ app_handle: &AppHandle, if value != last_percentage && (value - last_percentage) >= 10 { last_percentage = value; emit_installation_event(&app_handle_clone, InstallationProgress { - stage: InstallationStage::Download, - percentage: (value * 10 / 100) as u32, // Main clone: 0-10% + stage: InstallationStage::Download, + percentage: (value * 10 / 100) as u32, // Main clone: 0-10% message: rust_i18n::t!("gui.installation.cloning_repository", version = version_clone.clone()).to_string(), detail: Some(rust_i18n::t!("gui.installation.repository_progress", percentage = value).to_string()), - version: Some(version_clone.clone()), + version: Some(version_clone.clone()), }); } } @@ -153,14 +153,14 @@ app_handle: &AppHandle, let total_progress = submodule_base_progress + current_submodule_progress as u32 + individual_progress as u32; emit_installation_event(&app_handle_clone, InstallationProgress { - stage: InstallationStage::Download, - percentage: total_progress.min(65), + stage: InstallationStage::Download, + percentage: total_progress.min(65), message: rust_i18n::t!("gui.installation.downloading_submodule", name = name.clone()).to_string(), detail: Some(rust_i18n::t!("gui.installation.submodule_detail", - current = completed_submodules + 1, - total = total_estimated_submodules, + current = completed_submodules + 1, + total = total_estimated_submodules, percentage = value).to_string()), - version: Some(version_clone.clone()), + version: Some(version_clone.clone()), }); } @@ -176,13 +176,13 @@ app_handle: &AppHandle, let name_display = name.split('/').last().unwrap_or(&name).replace("_", " "); emit_installation_event(&app_handle_clone, InstallationProgress { - stage: InstallationStage::Download, - percentage: submodule_progress.min(65) as u32, + stage: InstallationStage::Download, + percentage: submodule_progress.min(65) as u32, message: rust_i18n::t!("gui.installation.completed_submodule", name = name_display).to_string(), detail: Some(rust_i18n::t!("gui.installation.submodule_progress", - completed = completed_submodules, + completed = completed_submodules, total = total_estimated_submodules).to_string()), - version: Some(version_clone.clone()), + version: Some(version_clone.clone()), }); emit_log_message(&app_handle_clone, MessageLevel::Info, @@ -198,11 +198,11 @@ app_handle: &AppHandle, // If no submodules were processed, this means we're done with everything if !has_submodules { emit_installation_event(&app_handle_clone, InstallationProgress { - stage: InstallationStage::Extract, - percentage: 65, + stage: InstallationStage::Extract, + percentage: 65, message: rust_i18n::t!("gui.installation.download_completed_no_submodules").to_string(), detail: Some(rust_i18n::t!("gui.installation.repository_cloned").to_string()), - version: Some(version_clone.clone()), + version: Some(version_clone.clone()), }); break; } @@ -210,22 +210,22 @@ app_handle: &AppHandle, else if completed_submodules > 0 { // We have processed some submodules, likely we're done emit_installation_event(&app_handle_clone, InstallationProgress { - stage: InstallationStage::Extract, - percentage: 65, + stage: InstallationStage::Extract, + percentage: 65, message: rust_i18n::t!("gui.installation.download_completed").to_string(), detail: Some(rust_i18n::t!("gui.installation.repository_and_submodules", count = completed_submodules).to_string()), - version: Some(version_clone.clone()), + version: Some(version_clone.clone()), }); break; } // If we got Finish but haven't seen submodules yet, just note main repo is done else { emit_installation_event(&app_handle_clone, InstallationProgress { - stage: InstallationStage::Download, - percentage: 10, + stage: InstallationStage::Download, + percentage: 10, message: rust_i18n::t!("gui.installation.main_cloned_waiting").to_string(), detail: Some(rust_i18n::t!("gui.installation.waiting_submodules").to_string()), - version: Some(version_clone.clone()), + version: Some(version_clone.clone()), }); // Don't break - keep waiting for submodules } @@ -236,15 +236,15 @@ app_handle: &AppHandle, if main_repo_finished { let final_percentage = if has_submodules { 65 } else { 65 }; emit_installation_event(&app_handle_clone, InstallationProgress { - stage: InstallationStage::Extract, - percentage: final_percentage, + stage: InstallationStage::Extract, + percentage: final_percentage, message: rust_i18n::t!("gui.installation.download_completed").to_string(), - detail: Some(if has_submodules { + detail: Some(if has_submodules { rust_i18n::t!("gui.installation.submodules_processed", count = completed_submodules).to_string() - } else { - rust_i18n::t!("gui.installation.repository_cloned").to_string() - }), - version: Some(version_clone.clone()), + } else { + rust_i18n::t!("gui.installation.repository_cloned").to_string() + }), + version: Some(version_clone.clone()), }); } break; @@ -253,8 +253,23 @@ app_handle: &AppHandle, } }); - let default_mirror = rust_i18n::t!("gui.installation.default_mirror").to_string(); - let mirror = settings.idf_mirror.as_deref().unwrap_or(&default_mirror); + let default_mirror_str = rust_i18n::t!("gui.installation.default_mirror").to_string(); + let default_mirror = settings + .idf_mirror + .as_deref() + .unwrap_or(&default_mirror_str); + let mirror_latency_map = settings.get_idf_mirror_latency_map().await.unwrap(); + let best_mirror = mirror_latency_map + .iter() + .min_by_key(|(_, latency)| *latency) + .unwrap() + .0 + .clone(); + let mirror = match best_mirror.is_empty() { + true => default_mirror, + false => best_mirror.as_str(), + }; + emit_log_message( app_handle, MessageLevel::Info, diff --git a/src-tauri/src/gui/commands/settings.rs b/src-tauri/src/gui/commands/settings.rs index b1d73ff5..b31c2ac6 100644 --- a/src-tauri/src/gui/commands/settings.rs +++ b/src-tauri/src/gui/commands/settings.rs @@ -1,19 +1,14 @@ -use std::{ - fs::File, - io::Read, - path::{Path, PathBuf}, - time::Duration, -}; +use std::path::PathBuf; use idf_im_lib::{settings, to_absolute_path, utils::is_valid_idf_directory}; use log::{info, warn}; use rust_i18n::t; use serde_json::{json, Value}; -use tauri::{async_runtime, AppHandle, Manager}; +use tauri::AppHandle; use crate::gui::{ - app_state::{self, get_locked_settings, get_settings_non_blocking, update_settings, AppState}, - ui::{emit_to_fe, send_message}, + app_state::{get_locked_settings, get_settings_non_blocking, update_settings}, + ui::send_message, utils::is_path_empty_or_nonexistent, }; @@ -221,20 +216,16 @@ pub async fn get_idf_mirror_list(app_handle: AppHandle) -> Value { } }; - let mirror = settings.idf_mirror.clone().unwrap_or_default(); - let mut available_mirrors: Vec = idf_im_lib::get_idf_mirrors_list() - .iter() - .map(|s| s.to_string()) - .collect(); + let mirror = settings.idf_mirror.clone().unwrap_or_default(); + let mut available_mirrors = idf_im_lib::get_idf_mirrors_list().to_vec(); - if !available_mirrors.iter().any(|m| m == &mirror) { - let mut new_mirrors = vec![mirror.clone()]; - new_mirrors.extend(available_mirrors); - available_mirrors = new_mirrors; + if !available_mirrors.contains(&mirror.as_str()) { + let mut new_mirrors = vec![mirror.as_str()]; + new_mirrors.extend(available_mirrors); + available_mirrors = new_mirrors; } - // Pick the lowest-latency mirror to present as selected - let mirror_latency_map = idf_im_lib::utils::calculate_mirror_latency_map(&available_mirrors).await; + let mirror_latency_map = settings.get_idf_mirror_latency_map().await.unwrap(); json!({ "mirrors": mirror_latency_map @@ -256,12 +247,9 @@ pub fn get_idf_mirror_urls(app_handle: AppHandle) -> Value { }; let selected = settings.idf_mirror.clone().unwrap_or_default(); - let mut available_mirrors: Vec = idf_im_lib::get_idf_mirrors_list() - .iter() - .map(|s| s.to_string()) - .collect(); + let mut available_mirrors = idf_im_lib::get_idf_mirrors_list().to_vec(); if !available_mirrors.iter().any(|m| m == &selected) && !selected.is_empty() { - let mut new_mirrors = vec![selected.clone()]; + let mut new_mirrors = vec![selected.as_str()]; new_mirrors.extend(available_mirrors); available_mirrors = new_mirrors; } @@ -302,21 +290,16 @@ pub async fn get_tools_mirror_list(app_handle: AppHandle) -> Value { } }; - let mirror = settings.mirror.clone().unwrap_or_default(); - let mut available_mirrors: Vec = idf_im_lib::get_idf_tools_mirrors_list() - .iter() - .map(|s| s.to_string()) - .collect(); + let mirror = settings.tools_mirror.clone().unwrap_or_default(); + let mut available_mirrors = idf_im_lib::get_idf_tools_mirrors_list().to_vec(); - if !available_mirrors.iter().any(|m| m == &mirror) { - let mut new_mirrors = vec![mirror.clone()]; + if !available_mirrors.contains(&mirror.as_str()) { + let mut new_mirrors = vec![mirror.as_str()]; new_mirrors.extend(available_mirrors); available_mirrors = new_mirrors; } - // Pick the lowest-latency mirror to present as selected - let mirror_latency_map = idf_im_lib::utils::calculate_mirror_latency_map(&available_mirrors).await; - + let mirror_latency_map = settings.get_tools_mirror_latency_map().await.unwrap(); json!({ "mirrors": mirror_latency_map }) @@ -337,13 +320,10 @@ pub fn get_tools_mirror_urls(app_handle: AppHandle) -> Value { } }; - let selected = settings.mirror.clone().unwrap_or_default(); - let mut available_mirrors: Vec = idf_im_lib::get_idf_tools_mirrors_list() - .iter() - .map(|s| s.to_string()) - .collect(); + let selected = settings.tools_mirror.clone().unwrap_or_default(); + let mut available_mirrors = idf_im_lib::get_idf_tools_mirrors_list().to_vec(); if !available_mirrors.iter().any(|m| m == &selected) && !selected.is_empty() { - let mut new_mirrors = vec![selected.clone()]; + let mut new_mirrors = vec![selected.as_str()]; new_mirrors.extend(available_mirrors); available_mirrors = new_mirrors; } @@ -357,9 +337,9 @@ pub fn get_tools_mirror_urls(app_handle: AppHandle) -> Value { /// Sets the selected tools mirror #[tauri::command] pub fn set_tools_mirror(app_handle: AppHandle, mirror: String) -> Result<(), String> { - info!("Setting tools mirror: {}", mirror); - update_settings(&app_handle, |settings| { - settings.mirror = Some(mirror); + info!("Setting tools mirror: {}", mirror); + update_settings(&app_handle, |settings| { + settings.tools_mirror = Some(mirror); })?; send_message( @@ -384,21 +364,16 @@ pub async fn get_pypi_mirror_list(app_handle: AppHandle) -> Value { } }; - let mirror = settings.pypi_mirror.clone().unwrap_or_default(); - let mut available_mirrors: Vec = idf_im_lib::get_pypi_mirrors_list() - .iter() - .map(|s| s.to_string()) - .collect(); + let mirror = settings.pypi_mirror.clone().unwrap_or_default(); + let mut available_mirrors = idf_im_lib::get_pypi_mirrors_list().to_vec(); - if !available_mirrors.iter().any(|m| m == &mirror) { - let mut new_mirrors = vec![mirror.clone()]; + if !available_mirrors.contains(&mirror.as_str()) { + let mut new_mirrors = vec![mirror.as_str()]; new_mirrors.extend(available_mirrors); available_mirrors = new_mirrors; } - // Pick the lowest-latency mirror to present as selected - let mirror_latency_map = idf_im_lib::utils::calculate_mirror_latency_map(&available_mirrors).await; - + let mirror_latency_map = settings.get_pypi_mirror_latency_map().await.unwrap(); json!({ "mirrors": mirror_latency_map }) @@ -419,12 +394,9 @@ pub fn get_pypi_mirror_urls(app_handle: AppHandle) -> Value { }; let selected = settings.pypi_mirror.clone().unwrap_or_default(); - let mut available_mirrors: Vec = idf_im_lib::get_pypi_mirrors_list() - .iter() - .map(|s| s.to_string()) - .collect(); + let mut available_mirrors = idf_im_lib::get_pypi_mirrors_list().to_vec(); if !available_mirrors.iter().any(|m| m == &selected) && !selected.is_empty() { - let mut new_mirrors = vec![selected.clone()]; + let mut new_mirrors = vec![selected.as_str()]; new_mirrors.extend(available_mirrors); available_mirrors = new_mirrors; } @@ -477,96 +449,6 @@ pub async fn is_path_empty_or_nonexistent_command(app_handle: AppHandle, path: S is_path_empty_or_nonexistent(&path, &versions) } -/// Start streaming latency measurements for IDF mirrors via events. -/// Emits per-mirror updates as: event "idf-mirror-latency" with payload { url, -/// latency } Emits completion as: event "idf-mirror-latency-done" -#[tauri::command] -pub fn start_idf_mirror_latency_checks(app_handle: AppHandle) { - async_runtime::spawn(async move { - let mirrors: Vec = idf_im_lib::get_idf_mirrors_list() - .iter() - .map(|s| s.to_string()) - .collect(); - let timeout = Duration::from_millis(3000); - for m in mirrors { - let score = match idf_im_lib::utils::measure_url_score(&m, timeout).await { - Some(s) => s, - None => u32::MAX, - }; - emit_to_fe( - &app_handle, - "idf-mirror-latency", - json!({ "url": m, "latency": score }), - ); - } - emit_to_fe( - &app_handle, - "idf-mirror-latency-done", - json!({ "done": true }), - ); - }); -} - -/// Start streaming latency measurements for Tools mirrors via events. -/// Emits per-mirror updates as: event "tools-mirror-latency" with payload { -/// url, latency } Emits completion as: event "tools-mirror-latency-done" -#[tauri::command] -pub fn start_tools_mirror_latency_checks(app_handle: AppHandle) { - async_runtime::spawn(async move { - let mirrors: Vec = idf_im_lib::get_idf_tools_mirrors_list() - .iter() - .map(|s| s.to_string()) - .collect(); - let timeout = Duration::from_millis(3000); - for m in mirrors { - let score = match idf_im_lib::utils::measure_url_score(&m, timeout).await { - Some(s) => s, - None => u32::MAX, - }; - emit_to_fe( - &app_handle, - "tools-mirror-latency", - json!({ "url": m, "latency": score }), - ); - } - emit_to_fe( - &app_handle, - "tools-mirror-latency-done", - json!({ "done": true }), - ); - }); -} - -/// Start streaming latency measurements for PyPI mirrors via events. -/// Emits per-mirror updates as: event "pypi-mirror-latency" with payload { url, -/// latency } Emits completion as: event "pypi-mirror-latency-done" -#[tauri::command] -pub fn start_pypi_mirror_latency_checks(app_handle: AppHandle) { - async_runtime::spawn(async move { - let mirrors: Vec = idf_im_lib::get_pypi_mirrors_list() - .iter() - .map(|s| s.to_string()) - .collect(); - let timeout = Duration::from_millis(3000); - for m in mirrors { - let score = match idf_im_lib::utils::measure_url_score(&m, timeout).await { - Some(s) => s, - None => u32::MAX, - }; - emit_to_fe( - &app_handle, - "pypi-mirror-latency", - json!({ "url": m, "latency": score }), - ); - } - emit_to_fe( - &app_handle, - "pypi-mirror-latency-done", - json!({ "done": true }), - ); - }); -} - #[tauri::command] pub async fn is_path_idf_directory(_app_handle: AppHandle, path: String) -> bool { is_valid_idf_directory(&path) diff --git a/src-tauri/src/gui/mod.rs b/src-tauri/src/gui/mod.rs index 295ed32c..32f668c3 100644 --- a/src-tauri/src/gui/mod.rs +++ b/src-tauri/src/gui/mod.rs @@ -235,11 +235,9 @@ pub fn run() { set_versions, get_idf_mirror_list, get_idf_mirror_urls, - start_idf_mirror_latency_checks, set_idf_mirror, get_tools_mirror_list, get_tools_mirror_urls, - start_tools_mirror_latency_checks, set_tools_mirror, load_settings, get_installation_path, @@ -274,7 +272,6 @@ pub fn run() { open_terminal_with_script, get_pypi_mirror_list, get_pypi_mirror_urls, - start_pypi_mirror_latency_checks, set_pypi_mirror, ]) .run(tauri::generate_context!()) diff --git a/src-tauri/src/lib/settings.rs b/src-tauri/src/lib/settings.rs index af038c15..88b702b9 100644 --- a/src-tauri/src/lib/settings.rs +++ b/src-tauri/src/lib/settings.rs @@ -2,6 +2,7 @@ use anyhow::{anyhow, Result}; use config::{Config, ConfigError}; use log::warn; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::fs::{self, OpenOptions}; use std::io::Write; use std::path::{Path, PathBuf}; @@ -35,7 +36,7 @@ pub struct Settings { pub config_file_save_path: Option, pub non_interactive: Option, pub wizard_all_questions: Option, - pub mirror: Option, + pub tools_mirror: Option, pub idf_mirror: Option, pub pypi_mirror: Option, pub recurse_submodules: Option, @@ -102,7 +103,7 @@ impl Default for Settings { config_file_save_path: Some(PathBuf::from("eim_config.toml")), non_interactive: Some(true), wizard_all_questions: Some(false), - mirror: Some( + tools_mirror: Some( crate::get_idf_tools_mirrors_list() .first() .unwrap() @@ -210,8 +211,8 @@ impl Settings { { settings.wizard_all_questions = cli_settings_struct.wizard_all_questions; } - if cli_settings_struct.mirror.is_some() && !cli_settings_struct.is_default("mirror") { - settings.mirror = cli_settings_struct.mirror.clone(); + if cli_settings_struct.tools_mirror.is_some() && !cli_settings_struct.is_default("mirror") { + settings.tools_mirror = cli_settings_struct.tools_mirror.clone(); } if cli_settings_struct.idf_mirror.is_some() && !cli_settings_struct.is_default("idf_mirror") @@ -331,7 +332,7 @@ impl Settings { config_file_save_path, non_interactive, wizard_all_questions, - mirror, + tools_mirror, idf_mirror, pypi_mirror, recurse_submodules, @@ -515,4 +516,40 @@ impl Settings { using_existing_idf, }) } + + /// Compute the latency map for the tools mirror + pub async fn get_tools_mirror_latency_map(&self) -> Result> { + let available_mirrors = crate::get_idf_tools_mirrors_list() + .to_vec() + .iter() + .map(|s| s.to_string()) + .collect::>(); + let mirror_latency_map = + crate::utils::calculate_mirror_latency_map(&available_mirrors).await; + Ok(mirror_latency_map) + } + + /// Compute the latency map for the IDF mirror + pub async fn get_idf_mirror_latency_map(&self) -> Result> { + let available_mirrors = crate::get_idf_mirrors_list() + .to_vec() + .iter() + .map(|s| s.to_string()) + .collect::>(); + let mirror_latency_map = + crate::utils::calculate_mirror_latency_map(&available_mirrors).await; + Ok(mirror_latency_map) + } + + /// Compute the latency map for the PyPI mirror + pub async fn get_pypi_mirror_latency_map(&self) -> Result> { + let available_mirrors = crate::get_pypi_mirrors_list() + .to_vec() + .iter() + .map(|s| s.to_string()) + .collect::>(); + let mirror_latency_map = + crate::utils::calculate_mirror_latency_map(&available_mirrors).await; + Ok(mirror_latency_map) + } } diff --git a/src-tauri/src/lib/utils.rs b/src-tauri/src/lib/utils.rs index 6bf78f3c..1c108fe5 100644 --- a/src-tauri/src/lib/utils.rs +++ b/src-tauri/src/lib/utils.rs @@ -9,7 +9,6 @@ use std::{ }; use anyhow::{anyhow, Error, Result}; -use futures::StreamExt; use git2::Repository; use log::{debug, error, info, warn}; use regex::Regex; @@ -759,16 +758,18 @@ fn is_retryable_error(error: &io::Error) -> bool { _ => false, } } - -/// Returns the base domain (scheme + host + optional port) from a full URL. +/// Returns the base domain from a full URL. fn get_base_url(url_str: &str) -> Option { let url = Url::parse(url_str).ok()?; - Some(format!("{}://{}", url.scheme(), url.host_str()?)) + let scheme = url.scheme(); + let host = url.host_str()?; + Some(format!("{}://{}", scheme, host)) } -/// Measures response latency (in ms) for the base domain of a given URL. -/// Returns `Some(latency_ms)` if successful, or `None` if unreachable. -pub async fn measure_url_score(url: &str, timeout: Duration) -> Option { +/// Measures response latency (in ms) for the HEAD request to the base domain of +/// a given URL. Returns `Some(latency_ms)` if successful, or `None` if +/// unreachable. +pub async fn measure_url_score_head(url: &str, timeout: Duration) -> Option { // Extract base URL (e.g., "https://example.com") let base_url = get_base_url(url)?; @@ -805,6 +806,36 @@ pub async fn measure_url_score(url: &str, timeout: Duration) -> Option { None } +/// Measures response latency (in ms) for the GET request to the base domain of +/// a given URL. Returns `Some(latency_ms)` if successful, or `None` if +/// unreachable. +pub async fn measure_url_score_get(url: &str, timeout: Duration) -> Option { + // Extract base URL (e.g., "https://example.com") + let base_url = get_base_url(url)?; + + // Build the HTTP client + let client = reqwest::Client::builder() + .timeout(timeout) + .redirect(reqwest::redirect::Policy::limited(5)) + .build() + .ok()?; + + let start_get = Instant::now(); + match client.get(&base_url).send().await { + Ok(resp) if resp.status().is_success() => { + return Some(start_get.elapsed().as_millis().min(u32::MAX as u128) as u32); + } + Ok(resp) => { + warn!("Mirror ping failed for {}: {:?}", base_url, resp.status()); + } + Err(e) => { + warn!("Mirror ping failed for {}: {:?}", base_url, e); + } + } + + None +} + /// Return URL -> score (lower is better). Unreachable mirrors get u32::MAX. pub async fn calculate_mirror_latency_map(mirrors: &[String]) -> HashMap { let timeout = Duration::from_millis(3000); @@ -813,20 +844,45 @@ pub async fn calculate_mirror_latency_map(mirrors: &[String]) -> HashMap { - info!("Mirror score: {} -> {}", url, score); - mirror_latency_map.insert(m.clone(), score); + if !head_latency_failed { + match measure_url_score_head(url, timeout).await { + Some(score) => { + info!("Mirror score: {} -> {}", url, score); + mirror_latency_map.insert(m.clone(), score); + } + None => { + info!( + "Unable to measure head latency for {}: {:?}", + url, timeout + ); + head_latency_failed = true; + } } - None => { - info!( - "Mirror score: {} -> unreachable (timeout {:?})", - url, timeout - ); - mirror_latency_map.insert(m.clone(), u32::MAX); + } + if head_latency_failed { + break; + } + } + + // if head latency failed, measure get latency for all mirrors + // and we also clear the map to avoid any potential contamination + if head_latency_failed { + mirror_latency_map.clear(); + for m in mirrors { + let url = m.as_str(); + match measure_url_score_get(url, timeout).await { + Some(score) => { + info!("Mirror get score: {} -> {}", url, score); + mirror_latency_map.insert(m.clone(), score); + } + None => { + info!("Unable to measure get latency for {}: {:?}", url, timeout); + mirror_latency_map.insert(m.clone(), u32::MAX); + } } } } @@ -1376,13 +1432,6 @@ set(IDF_VERSION_MAJOR 5) assert_eq!(get_base_url(u2), Some("http://example.org".to_string())); } - #[test] - fn test_get_base_url_with_port_current_behavior() { - // Current implementation drops the port; assert current behavior - let u = "http://example.com:8080/svc"; - assert_eq!(get_base_url(u), Some("http://example.com".to_string())); - } - #[test] fn test_get_base_url_invalid_and_file_scheme() { // Invalid URL @@ -1394,7 +1443,7 @@ set(IDF_VERSION_MAJOR 5) #[tokio::test] async fn test_measure_url_score_invalid_url() { // Invalid URL should short-circuit (no network) and return None - let res = measure_url_score("://", std::time::Duration::from_millis(50)).await; + let res = measure_url_score_head("://", std::time::Duration::from_millis(50)).await; assert!(res.is_none()); } diff --git a/src-tauri/src/offline_installer_builder.rs b/src-tauri/src/offline_installer_builder.rs index 758f7c6d..648c4088 100644 --- a/src-tauri/src/offline_installer_builder.rs +++ b/src-tauri/src/offline_installer_builder.rs @@ -602,7 +602,7 @@ async fn main() { let download_links = get_list_of_tools_to_download( tools.clone(), settings.clone().target.unwrap_or(vec!["all".to_string()]), - settings.mirror.as_deref(), + settings.tools_mirror.as_deref(), ); let tool_path = archive_dir.path().join("dist"); diff --git a/src/components/wizard_steps/MirrorSelect.vue b/src/components/wizard_steps/MirrorSelect.vue index d027cfb0..f2738ea6 100644 --- a/src/components/wizard_steps/MirrorSelect.vue +++ b/src/components/wizard_steps/MirrorSelect.vue @@ -102,10 +102,10 @@