diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8e5a0f00..0a84be04 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1592,6 +1592,7 @@ dependencies = [ "thiserror 1.0.69", "tokio", "toml 0.9.5", + "url", "uuid", "winapi", "zip", @@ -1903,9 +1904,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 +2860,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 +4361,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 +7684,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..1ccddcd7 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,7 @@ 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" +url = "2.5.7" # GUI-related dependencies (optional) diff --git a/src-tauri/src/cli/cli_args.rs b/src-tauri/src/cli/cli_args.rs index 50a26a71..d5a50c41 100644 --- a/src-tauri/src/cli/cli_args.rs +++ b/src-tauri/src/cli/cli_args.rs @@ -312,7 +312,8 @@ impl IntoIterator for InstallArgs { "tools_json_file".to_string(), self.tools_json_file.map(Into::into), ), - ("mirror".to_string(), self.mirror.map(Into::into)), + // map CLI flag `-m|--mirror` to settings field `tools_mirror` + ("tools_mirror".to_string(), self.mirror.map(Into::into)), ("idf_mirror".to_string(), self.idf_mirror.map(Into::into)), ("pypi_mirror".to_string(), self.pypi_mirror.map(Into::into)), ( diff --git a/src-tauri/src/cli/helpers.rs b/src-tauri/src/cli/helpers.rs index 4cf6e90f..a8ae4f1a 100644 --- a/src-tauri/src/cli/helpers.rs +++ b/src-tauri/src/cli/helpers.rs @@ -8,6 +8,10 @@ use std::{ fmt::Write, time::{Duration, Instant}, }; +use idf_im_lib::utils::{calculate_mirror_latency_map}; + +/// A tuple containing a mirror URL and its measured latency. +pub type MirrorEntry = (String, Option); pub fn run_with_spinner(func: F) -> T where @@ -140,3 +144,34 @@ pub async fn track_cli_event(event_name: &str, additional_data: Option Vec { + let latency_map = calculate_mirror_latency_map(&mirrors.to_vec()).await; + let mut entries: Vec = Vec::new(); + for (key, value) in latency_map.iter() { + entries.push((key.clone(), value.clone())); + } + + 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.is_none() { + format!("{u} (timeout)") + } else { + format!("{u} ({:?} ms)", s.unwrap()) + } + }) + .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() +} \ No newline at end of file diff --git a/src-tauri/src/cli/prompts.rs b/src-tauri/src/cli/prompts.rs index 61b84de3..f6ee0068 100644 --- a/src-tauri/src/cli/prompts.rs +++ b/src-tauri/src/cli/prompts.rs @@ -9,6 +9,9 @@ use log::{debug, info}; use rust_i18n::t; use crate::cli::helpers::generic_confirm_with_default; +use crate::cli::helpers::sorted_mirror_entries; +use crate::cli::helpers::mirror_entries_to_display; +use crate::cli::helpers::url_from_display_line; pub async fn select_target() -> Result, String> { let mut available_targets = idf_im_lib::idf_versions::get_avalible_targets().await?; @@ -176,48 +179,89 @@ 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) - { - config.idf_mirror = Some(generic_select( - "wizard.idf.mirror", - &idf_im_lib::get_idf_mirrors_list() - .iter() - .map(|&s| s.to_string()) - .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: &[&str], // 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), +{ + // Interactive by default when non_interactive is None + let interactive = !config.non_interactive.unwrap_or_default(); + 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); - if (config.wizard_all_questions.unwrap_or_default() - || config.mirror.is_none() - || config.is_default("mirror")) - && config.non_interactive == Some(false) - { - config.mirror = Some(generic_select( - "wizard.tools.mirror", - &idf_im_lib::get_idf_tools_mirrors_list() - .iter() - .map(|&s| s.to_string()) - .collect(), - )?) + // Only measure mirror latency if we actually need a value (or wizard wants to ask) + if interactive && (wizard_all || needs_value) { + let entries = sorted_mirror_entries(candidates).await; + 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 { + let entries = sorted_mirror_entries(candidates).await; + if let Some((url, score)) = entries.first() { + if score.is_none() { + info!("Selected {log_prefix} mirror: {url} (timeout)"); + } else { + info!("Selected {log_prefix} mirror: {url} ({:?} ms)", score.unwrap()); + } + set_value(config, 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) - { - config.pypi_mirror = Some(generic_select( - "wizard.pypi.mirror", - &idf_im_lib::get_pypi_mirrors_list() - .iter() - .map(|&s| s.to_string()) - .collect(), - )?) - } + Ok(()) +} + +pub async fn select_mirrors(mut config: Settings) -> Result { + // IDF mirror + let idf_candidates = idf_im_lib::get_idf_mirrors_list(); + + 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 + let tools_candidates = idf_im_lib::get_idf_tools_mirrors_list(); + + select_single_mirror( + &mut config, + "tools_mirror", + |c: &Settings| &c.tools_mirror, + |c: &mut Settings, v| c.tools_mirror = Some(v), + tools_candidates, + "wizard.tools.mirror", + "Tools", + ) + .await?; + + // PyPI mirror + let pypi_candidates = idf_im_lib::get_pypi_mirrors_list(); + + 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/cli/wizard.rs b/src-tauri/src/cli/wizard.rs index 94ac8e9c..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 @@ -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/app_state.rs b/src-tauri/src/gui/app_state.rs index e3a983ab..d00c08ed 100644 --- a/src-tauri/src/gui/app_state.rs +++ b/src-tauri/src/gui/app_state.rs @@ -20,6 +20,29 @@ pub struct AppState { wizard_data: Mutex, settings: Mutex, is_installing: Mutex, + is_simple_installation: Mutex, +} + +pub fn set_is_simple_installation(app_handle: &AppHandle, is_simple: bool) -> Result<(), String> { + let app_state = app_handle.state::(); + let mut simple_installation = app_state + .is_simple_installation + .lock() + .map_err(|_| "Lock error".to_string())?; + *simple_installation = is_simple; + Ok(()) +} + +pub fn is_simple_installation(app_handle: &AppHandle) -> bool { + let app_state = app_handle.state::(); + app_state + .is_simple_installation + .lock() + .map(|guard| *guard) + .unwrap_or_else(|_| { + error!("Failed to acquire is_simple_installation lock, assuming false"); + false + }) } /// Gets the current settings from the app state diff --git a/src-tauri/src/gui/commands/idf_tools.rs b/src-tauri/src/gui/commands/idf_tools.rs index 7d2e939c..59b86eb4 100644 --- a/src-tauri/src/gui/commands/idf_tools.rs +++ b/src-tauri/src/gui/commands/idf_tools.rs @@ -1,4 +1,4 @@ -use crate::gui::ui::{emit_installation_event, emit_log_message, send_message, send_tools_message, InstallationProgress, InstallationStage, MessageLevel, ProgressBar}; +use crate::gui::{ui::{InstallationProgress, InstallationStage, MessageLevel, ProgressBar, emit_installation_event, emit_log_message, send_message, send_tools_message}, utils::get_best_mirror}; use anyhow::{anyhow, Context, Result}; use idf_im_lib::{ @@ -95,6 +95,7 @@ pub async fn setup_tools( offline_archive_dir: Option<&Path>, ) -> Result> { info!("Setting up tools..."); + let is_simple_installation = crate::gui::app_state::is_simple_installation(&app_handle); let version_path = idf_path .parent() @@ -312,13 +313,23 @@ pub async fn setup_tools( } }; + let tools_mirror = settings.tools_mirror.as_deref().unwrap(); + let mut tools_mirror_to_use: Option = Some(tools_mirror.to_string()); + if is_simple_installation && settings.is_default("tools_mirror") { + let mirror_latency_map = idf_im_lib::utils::calculate_mirror_latency_map(&idf_im_lib::get_idf_tools_mirrors_list().to_vec()).await; + let best_mirror = get_best_mirror(&mirror_latency_map).await; + if best_mirror.is_some() { + tools_mirror_to_use = Some(best_mirror.unwrap()); + } + } + // Use the library's setup_tools function let installed_tools_list = idf_tools::setup_tools( &tools, settings.target.clone().unwrap_or_default(), &PathBuf::from(&tool_setup.download_dir), &PathBuf::from(&tool_setup.install_dir), - settings.mirror.as_deref(), + tools_mirror_to_use.as_deref(), progress_callback, ) .await @@ -353,6 +364,16 @@ pub async fn setup_tools( } }; + let pypi_mirror = settings.pypi_mirror.as_deref().unwrap(); + let mut pypi_mirror_to_use: Option = Some(pypi_mirror.to_string()); + if is_simple_installation && settings.is_default("pypi_mirror") { + let pypi_mirror_latency_map = idf_im_lib::utils::calculate_mirror_latency_map(&idf_im_lib::get_pypi_mirrors_list().to_vec()).await; + let best_pypi_mirror = get_best_mirror(&pypi_mirror_latency_map).await; + if best_pypi_mirror.is_some() { + pypi_mirror_to_use = Some(best_pypi_mirror.unwrap()); + } + } + // Install Python environment match idf_im_lib::python_utils::install_python_env( &paths, @@ -361,7 +382,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 + &pypi_mirror_to_use, // 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..ef09e882 100644 --- a/src-tauri/src/gui/commands/installation.rs +++ b/src-tauri/src/gui/commands/installation.rs @@ -1,6 +1,6 @@ use tauri::{AppHandle, Emitter, Manager}; use tempfile::TempDir; -use crate::gui::{app_state::{self, update_settings}, commands::idf_tools::setup_tools, get_installed_versions, ui::{emit_installation_event, emit_log_message, InstallationProgress, InstallationStage, MessageLevel}, utils::is_path_empty_or_nonexistent}; +use crate::gui::{app_state::{self, update_settings}, commands::idf_tools::setup_tools, get_installed_versions, ui::{InstallationProgress, InstallationStage, MessageLevel, emit_installation_event, emit_log_message}, utils::{get_best_mirror, is_path_empty_or_nonexistent}}; use std::{ fs, io::{BufRead, BufReader}, @@ -17,7 +17,7 @@ use serde_json::json; use idf_im_lib::settings::Settings; use crate::gui::{ - app_state::{get_locked_settings, get_settings_non_blocking, set_installation_status}, + app_state::{get_locked_settings, get_settings_non_blocking, set_installation_status, set_is_simple_installation}, commands, ui::{ send_install_progress_message, send_message, send_simple_setup_message, @@ -253,21 +253,32 @@ 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 is_simple_installation = app_state::is_simple_installation(&app_handle); + let mirror = settings.idf_mirror.as_deref().unwrap_or(&default_mirror_str); + let mut mirror_to_use: String = mirror.to_string(); + + if is_simple_installation && mirror == &default_mirror_str { + let mirror_latency_map = idf_im_lib::utils::calculate_mirror_latency_map(&idf_im_lib::get_idf_mirrors_list().to_vec()).await; + let best_mirror = get_best_mirror(&mirror_latency_map).await; + if best_mirror.is_some() { + mirror_to_use = best_mirror.unwrap(); + } + } + emit_log_message( app_handle, MessageLevel::Info, rust_i18n::t!("gui.installation.cloning_from_mirror", version = version, - mirror = mirror).to_string(), + mirror = mirror_to_use.as_str()).to_string(), ); let result = idf_im_lib::get_esp_idf( idf_path.to_str().unwrap(), settings.repo_stub.as_deref(), version, - settings.idf_mirror.as_deref(), + Some(mirror_to_use.as_str()), settings.recurse_submodules.unwrap_or_default(), tx, ); @@ -353,6 +364,7 @@ pub async fn install_single_version( #[tauri::command] pub async fn start_installation(app_handle: AppHandle) -> Result<(), String> { let app_state = app_handle.state::(); + set_is_simple_installation(&app_handle, false)?; // Set installation flag if let Err(e) = set_installation_status(&app_handle, true) { @@ -774,7 +786,8 @@ fn is_process_running(pid: u32) -> bool { #[tauri::command] pub async fn start_installation(app_handle: AppHandle) -> Result<(), String> { info!("Starting installation"); - + let app_state = app_handle.state::(); + set_is_simple_installation(&app_handle, false)?; // Set installation flag if let Err(e) = set_installation_status(&app_handle, true) { return Err(e); @@ -973,6 +986,8 @@ pub async fn start_installation(app_handle: AppHandle) -> Result<(), String> { /// Starts a simple setup process that automates the installation #[tauri::command] pub async fn start_simple_setup(app_handle: tauri::AppHandle) -> Result<(), String> { + let app_state = app_handle.state::(); + app_state::set_is_simple_installation(&app_handle, true)?; println!("Starting simple setup"); let settings = match get_locked_settings(&app_handle) { Ok(s) => s, diff --git a/src-tauri/src/gui/commands/settings.rs b/src-tauri/src/gui/commands/settings.rs index 0e2123ef..b492df76 100644 --- a/src-tauri/src/gui/commands/settings.rs +++ b/src-tauri/src/gui/commands/settings.rs @@ -1,19 +1,16 @@ -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::path::PathBuf; -use log::info; +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::AppHandle; + +use crate::gui::{ + app_state::{get_locked_settings, get_settings_non_blocking, update_settings}, + ui::send_message, + utils::is_path_empty_or_nonexistent, }; -use rust_i18n::t; /// Gets the current settings #[tauri::command] @@ -37,7 +34,7 @@ pub fn load_settings(app_handle: AppHandle, path: &str) { }) .expect("Failed to load settings"); log::debug!("settings after load {:?}", settings); - }); + }).unwrap_or_else(|e| warn!("Failed to update settings: {}", e)); send_message( &app_handle, t!("gui.settings.loaded_successfully", path = path).to_string(), @@ -207,7 +204,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) => { @@ -223,17 +220,46 @@ pub fn get_idf_mirror_list(app_handle: AppHandle) -> Value { let mut available_mirrors = idf_im_lib::get_idf_mirrors_list().to_vec(); if !available_mirrors.contains(&mirror.as_str()) { - let mut new_mirrors = vec![mirror.as_str()]; - new_mirrors.extend(available_mirrors); - available_mirrors = new_mirrors; + let mut new_mirrors = vec![mirror.as_str()]; + new_mirrors.extend(available_mirrors); + available_mirrors = new_mirrors; } + 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 }) } +/// 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 = 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.as_str()]; + 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> { @@ -252,7 +278,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) => { @@ -264,7 +290,7 @@ pub fn get_tools_mirror_list(app_handle: AppHandle) -> Value { } }; - let mirror = settings.mirror.clone().unwrap_or_default(); + 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.contains(&mirror.as_str()) { @@ -273,18 +299,47 @@ pub fn get_tools_mirror_list(app_handle: AppHandle) -> Value { available_mirrors = new_mirrors; } + 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 }) } +/// 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.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.as_str()]; + 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> { info!("Setting tools mirror: {}", mirror); update_settings(&app_handle, |settings| { - settings.mirror = Some(mirror); + settings.tools_mirror = Some(mirror); })?; send_message( @@ -297,7 +352,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) => { @@ -318,12 +373,40 @@ pub fn get_pypi_mirror_list(app_handle: AppHandle) -> Value { available_mirrors = new_mirrors; } + 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 }) } +/// 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 = 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.as_str()]; + 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> { diff --git a/src-tauri/src/gui/commands/utils_commands.rs b/src-tauri/src/gui/commands/utils_commands.rs index 5ca05ed7..66ca79ab 100644 --- a/src-tauri/src/gui/commands/utils_commands.rs +++ b/src-tauri/src/gui/commands/utils_commands.rs @@ -13,7 +13,7 @@ use tauri::AppHandle; use num_cpus; use anyhow::{Result}; -use crate::gui; +use crate::gui::{self, app_state}; use crate::gui::utils::is_path_empty_or_nonexistent; #[cfg(windows)] @@ -472,4 +472,4 @@ pub fn open_terminal_with_script(script_path: String) -> Result { } Ok(true) -} +} \ No newline at end of file diff --git a/src-tauri/src/gui/mod.rs b/src-tauri/src/gui/mod.rs index 604b18cb..32f668c3 100644 --- a/src-tauri/src/gui/mod.rs +++ b/src-tauri/src/gui/mod.rs @@ -234,8 +234,10 @@ pub fn run() { get_idf_versions, set_versions, get_idf_mirror_list, + get_idf_mirror_urls, set_idf_mirror, get_tools_mirror_list, + get_tools_mirror_urls, set_tools_mirror, load_settings, get_installation_path, @@ -269,6 +271,7 @@ pub fn run() { set_locale, open_terminal_with_script, get_pypi_mirror_list, + get_pypi_mirror_urls, set_pypi_mirror, ]) .run(tauri::generate_context!()) diff --git a/src-tauri/src/gui/utils.rs b/src-tauri/src/gui/utils.rs index 772d0049..e1a87139 100644 --- a/src-tauri/src/gui/utils.rs +++ b/src-tauri/src/gui/utils.rs @@ -1,4 +1,5 @@ use std::{ fs, path::Path}; +use std::collections::HashMap; /// Checks if a path is empty or doesn't exist /// @@ -40,6 +41,28 @@ pub fn is_path_empty_or_nonexistent(path: &str, versions: &[String]) -> bool { } } +pub async fn get_best_mirror(mirror_latency_map: &HashMap>) -> Option { + log::info!("Selecting best mirror from latency map: {:?}", mirror_latency_map); + let mut best_mirror: Option = None; + let mut lowest_latency: Option = None; + + for (mirror, latency) in mirror_latency_map { + if latency.is_none() { + log::warn!("Mirror {} has no latency data, skipping", mirror); + continue; + } + let latency_value = latency.unwrap(); + log::info!("Mirror: {}, Latency: {}", mirror, latency_value); + if lowest_latency.is_none() || latency_value < lowest_latency.unwrap() { + lowest_latency = Some(latency_value); + best_mirror = Some(mirror.clone()); + } + } + + log::info!("Best mirror selected: {:?}", best_mirror); + best_mirror +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/lib/settings.rs b/src-tauri/src/lib/settings.rs index af038c15..a048c3e3 100644 --- a/src-tauri/src/lib/settings.rs +++ b/src-tauri/src/lib/settings.rs @@ -35,7 +35,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 +102,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 +210,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("tools_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 +331,7 @@ impl Settings { config_file_save_path, non_interactive, wizard_all_questions, - mirror, + tools_mirror, idf_mirror, pypi_mirror, recurse_submodules, diff --git a/src-tauri/src/lib/utils.rs b/src-tauri/src/lib/utils.rs index 1191f41f..53022f3b 100644 --- a/src-tauri/src/lib/utils.rs +++ b/src-tauri/src/lib/utils.rs @@ -19,8 +19,10 @@ use std::{ fs::{self, File}, io::{self, BufReader, Read}, path::{Path, PathBuf}, + time::{Duration, Instant}, }; use regex::Regex; +use url::Url; /// This function retrieves the path to the git executable. /// @@ -750,6 +752,127 @@ fn is_retryable_error(error: &io::Error) -> bool { _ => false, } } +/// Returns the base domain including port if present from a full URL. +fn get_base_url(url_str: &str) -> Option { + let url = Url::parse(url_str).ok()?; + let scheme = url.scheme(); + let host = url.host_str()?; + let port = url.port(); + if port.is_some() { + return Some(format!("{}://{}:{}", scheme, host, port.unwrap())); + } + + Some(format!("{}://{}", scheme, host)) +} + +/// 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) -> Result { + // 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 request + let start = Instant::now(); + if base_url.is_none() { + return Err(anyhow!("Invalid base URL: {}", url)); + } + + match client.unwrap().head(&base_url.unwrap()).send().await { + Ok(resp) if resp.status().is_success() => { + return Ok(start.elapsed().as_millis().min(u32::MAX as u128) as u32); + } + _ => { + return Err(anyhow!("Mirror ping failed with HEAD for {}", url)); + } + } +} + +/// 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() 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 None. +pub async fn calculate_mirror_latency_map(mirrors: &Vec<&str>) -> HashMap> { + let timeout = Duration::from_millis(3000); + info!( + "Starting mirror latency checks ({} candidates)...", + mirrors.len() + ); + let mut mirror_latency_map = HashMap::new(); + let mut head_latency_failed = false; + + for url in mirrors.iter() { + if !head_latency_failed { + match measure_url_score_head(url, timeout).await { + Ok(score) => { + info!("Mirror score: {} -> {}", url, score); + mirror_latency_map.insert(url.to_string(), Some(score)); + } + Err(e) => { + warn!("{}", e.to_string()); + head_latency_failed = true; + } + } + } + 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 url in mirrors.iter() { + match measure_url_score_get(url, timeout).await { + Some(score) => { + info!("Mirror get score: {} -> {}", url, score); + mirror_latency_map.insert(url.to_string(), Some(score)); + } + None => { + info!("Unable to measure get latency for {}: {:?}", url, timeout); + mirror_latency_map.insert(url.to_string(), None); + } + } + } + } + mirror_latency_map +} + #[cfg(test)] mod tests { @@ -1248,4 +1371,45 @@ 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_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_head("://", std::time::Duration::from_millis(50)).await; + assert!(res.is_err()); + } + + #[tokio::test] + async fn test_calculate_mirror_latency_map_with_invalid_urls() { + // Invalid URLs should be mapped to u32::MAX deterministically + let mirrors: &'static [&'static str] = &[ + "not a url", + "://", + "file:///not-applicable", + ]; + + let map = calculate_mirror_latency_map(&mirrors.to_vec()).await; + assert_eq!(map.len(), 3); + for m in mirrors.iter() { + assert_eq!(map.get(&m.to_string()), Some(&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 006a10bb..f624c6e3 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 }} - {{ t('mirrorSelect.tags.default') }} +
+ +
@@ -29,7 +37,7 @@

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

+ data-id="tools-mirror-radio-group" @update:value="onSelectChange('tools')">
{{ mirror.label }} - {{ t('mirrorSelect.tags.default') }} +
+ +
@@ -49,7 +65,7 @@

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

+ data-id="pypi-mirror-radio-group" @update:value="onSelectChange('pypi')">
{{ mirror.label }} - {{ t('mirrorSelect.tags.default') }} +
+ +
@@ -78,8 +102,9 @@ @@ -273,18 +365,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": { diff --git a/src/main.js b/src/main.js index c91f16e7..2d2e469f 100644 --- a/src/main.js +++ b/src/main.js @@ -5,6 +5,7 @@ import App from "./App.vue"; import router from "./router"; import naive from "naive-ui"; import "./assets/main.css"; // Import the CSS file +import { useMirrorsStore } from "./store"; // Translation files import en from "./locales/en.json"; @@ -25,4 +26,12 @@ app.use(i18n); app.use(createPinia()); app.use(router); app.use(naive); + +// Bootstrap background mirror latency on app launch +try { + const mirrorsStore = useMirrorsStore(); + mirrorsStore.bootstrapMirrors(); +} catch (_) { + // ignore bootstrap errors at startup; UI can still fetch lazily +} app.mount("#app"); diff --git a/src/store.js b/src/store.js index c485c54d..0134ba7c 100644 --- a/src/store.js +++ b/src/store.js @@ -1,4 +1,5 @@ import { defineStore } from "pinia"; +import { invoke } from "@tauri-apps/api/core"; export const useAppStore = defineStore("app", { state: () => ({ @@ -331,3 +332,155 @@ export const useWizardStore = defineStore("wizard", { }, }, }); + +export const useMirrorsStore = defineStore("mirrors", { + state: () => ({ + // URL lists + idf_urls: [], + tools_urls: [], + pypi_urls: [], + + // Latency maps (url -> ms; 0 means timeout/unreachable; undefined means not yet measured) + idf_latency_map: {}, + tools_latency_map: {}, + pypi_latency_map: {}, + + // Selected (from backend quick URL endpoints) + selected_idf: "", + selected_tools: "", + selected_pypi: "", + + // Loading flags + loading_idf_urls: false, + loading_tools_urls: false, + loading_pypi_urls: false, + loading_idf_latency: false, + loading_tools_latency: false, + loading_pypi_latency: false, + + // Last updated timestamps (ms epoch) + idf_last_updated: 0, + tools_last_updated: 0, + pypi_last_updated: 0, + + // TTL for latency cache (15 minutes) + latency_ttl_ms: 15 * 60 * 1000, + }), + getters: { + idfUrls: (state) => state.idf_urls, + toolsUrls: (state) => state.tools_urls, + pypiUrls: (state) => state.pypi_urls, + idfLatencyMap: (state) => state.idf_latency_map, + toolsLatencyMap: (state) => state.tools_latency_map, + pypiLatencyMap: (state) => state.pypi_latency_map, + }, + actions: { + // Backend uses Option for latency values; Timedout values are represented as None. + // We normalize to 0 for timeout and the value for the latency. If the value is undefined, we return undefined as it means the mirror is not yet measured. + normalizeLatencyValue(value) { + if (value === undefined) return undefined; + if (value == null) return 0; + return Number(value); + }, + + ttlValid(lastUpdated) { + if (!lastUpdated) return false; + const now = Date.now(); + return now - lastUpdated < this.latency_ttl_ms; + }, + + async bootstrapMirrors() { + // Fetch quick URL lists + defaults for all types in parallel + this.loading_idf_urls = true; + this.loading_tools_urls = true; + this.loading_pypi_urls = true; + try { + const pIdf = invoke("get_idf_mirror_urls", {}); + const pTools = invoke("get_tools_mirror_urls", {}); + const pPypi = invoke("get_pypi_mirror_urls", {}); + + const [idf, tools, pypi] = await Promise.allSettled([pIdf, pTools, pPypi]); + + if (idf.status === "fulfilled") { + const res = idf.value || {}; + this.idf_urls = Array.isArray(res.mirrors) ? res.mirrors : []; + this.selected_idf = typeof res.selected === "string" ? res.selected : ""; + } + if (tools.status === "fulfilled") { + const res = tools.value || {}; + this.tools_urls = Array.isArray(res.mirrors) ? res.mirrors : []; + this.selected_tools = typeof res.selected === "string" ? res.selected : ""; + } + if (pypi.status === "fulfilled") { + const res = pypi.value || {}; + this.pypi_urls = Array.isArray(res.mirrors) ? res.mirrors : []; + this.selected_pypi = typeof res.selected === "string" ? res.selected : ""; + } + } finally { + this.loading_idf_urls = false; + this.loading_tools_urls = false; + this.loading_pypi_urls = false; + } + + // Kick off progressive per-type background latency calculations + this.computeLatencyInBackground(); + }, + + computeLatencyInBackground() { + const now = Date.now(); + // IDF + if (!this.ttlValid(this.idf_last_updated) && !this.loading_idf_latency) { + this.loading_idf_latency = true; + invoke("get_idf_mirror_list", {}) + .then((res) => { + const map = (res && res.mirrors) || {}; + const normalizedMap = {}; + Object.keys(map || {}).forEach((url) => { + normalizedMap[url] = this.normalizeLatencyValue(map[url]); + }); + this.idf_latency_map = normalizedMap; + this.idf_last_updated = now; + }) + .finally(() => { + this.loading_idf_latency = false; + }); + } + + // Tools + if (!this.ttlValid(this.tools_last_updated) && !this.loading_tools_latency) { + this.loading_tools_latency = true; + invoke("get_tools_mirror_list", {}) + .then((res) => { + const map = (res && res.mirrors) || {}; + const normalizedMap = {}; + Object.keys(map || {}).forEach((url) => { + normalizedMap[url] = this.normalizeLatencyValue(map[url]); + }); + this.tools_latency_map = normalizedMap; + this.tools_last_updated = now; + }) + .finally(() => { + this.loading_tools_latency = false; + }); + } + + // PyPI + if (!this.ttlValid(this.pypi_last_updated) && !this.loading_pypi_latency) { + this.loading_pypi_latency = true; + invoke("get_pypi_mirror_list", {}) + .then((res) => { + const map = (res && res.mirrors) || {}; + const normalizedMap = {}; + Object.keys(map || {}).forEach((url) => { + normalizedMap[url] = this.normalizeLatencyValue(map[url]); + }); + this.pypi_latency_map = normalizedMap; + this.pypi_last_updated = now; + }) + .finally(() => { + this.loading_pypi_latency = false; + }); + } + }, + }, +}); \ No newline at end of file