diff options
| author | salmonik <raisersoft@proton.me> | 2026-07-11 13:32:48 +0200 |
|---|---|---|
| committer | salmonik <raisersoft@proton.me> | 2026-07-11 13:32:48 +0200 |
| commit | 7df4ceb1cf2a6edc058d28527140598ca90e1f10 (patch) | |
| tree | c8ade0334e962f0089ef4535cb07d5feaf6b857a /src | |
| download | factorio-mod-downloader-main.tar.gz factorio-mod-downloader-main.zip | |
initmain
Diffstat (limited to '')
| -rw-r--r-- | src/downloader.rs | 589 | ||||
| -rw-r--r-- | src/gui.rs | 369 | ||||
| -rw-r--r-- | src/main.rs | 102 |
3 files changed, 1060 insertions, 0 deletions
diff --git a/src/downloader.rs b/src/downloader.rs new file mode 100644 index 0000000..6d92166 --- /dev/null +++ b/src/downloader.rs | |||
| @@ -0,0 +1,589 @@ | |||
| 1 | use std::collections::HashSet; | ||
| 2 | use std::fs; | ||
| 3 | use std::io::{Read, Write}; | ||
| 4 | use std::path::Path; | ||
| 5 | |||
| 6 | use anyhow::{bail, Context, Result}; | ||
| 7 | use regex::Regex; | ||
| 8 | use serde::Deserialize; | ||
| 9 | |||
| 10 | // --------------------------------------------------------------------------- | ||
| 11 | // Public event type for GUI progress reporting | ||
| 12 | // --------------------------------------------------------------------------- | ||
| 13 | |||
| 14 | #[cfg(feature = "gui")] | ||
| 15 | use std::sync::mpsc; | ||
| 16 | |||
| 17 | #[cfg(feature = "gui")] | ||
| 18 | #[derive(Debug, Clone)] | ||
| 19 | pub enum Event { | ||
| 20 | Log(String), | ||
| 21 | DownloadStart { name: String, total_bytes: u64 }, | ||
| 22 | DownloadProgress { name: String, bytes: u64 }, | ||
| 23 | DownloadComplete { name: String }, | ||
| 24 | DownloadError { name: String, error: String }, | ||
| 25 | Finished { success: bool, message: String }, | ||
| 26 | } | ||
| 27 | |||
| 28 | // --------------------------------------------------------------------------- | ||
| 29 | // API response types | ||
| 30 | // --------------------------------------------------------------------------- | ||
| 31 | |||
| 32 | #[derive(Deserialize)] | ||
| 33 | pub struct ModInfo { | ||
| 34 | pub releases: Vec<Release>, | ||
| 35 | } | ||
| 36 | |||
| 37 | #[derive(Deserialize, Clone)] | ||
| 38 | pub struct Release { | ||
| 39 | pub version: String, | ||
| 40 | pub info_json: Option<InfoJson>, | ||
| 41 | } | ||
| 42 | |||
| 43 | #[derive(Deserialize, Clone)] | ||
| 44 | pub struct InfoJson { | ||
| 45 | #[serde(default)] | ||
| 46 | pub dependencies: Vec<String>, | ||
| 47 | } | ||
| 48 | |||
| 49 | // --------------------------------------------------------------------------- | ||
| 50 | // Dependency model | ||
| 51 | // --------------------------------------------------------------------------- | ||
| 52 | |||
| 53 | #[derive(Debug, Clone, PartialEq)] | ||
| 54 | pub enum DepType { | ||
| 55 | Base, | ||
| 56 | Required, | ||
| 57 | Optional, | ||
| 58 | Incompatible, | ||
| 59 | } | ||
| 60 | |||
| 61 | #[derive(Debug, Clone)] | ||
| 62 | pub struct Dependency { | ||
| 63 | pub dep_type: DepType, | ||
| 64 | pub name: String, | ||
| 65 | } | ||
| 66 | |||
| 67 | // --------------------------------------------------------------------------- | ||
| 68 | // Constants | ||
| 69 | // --------------------------------------------------------------------------- | ||
| 70 | |||
| 71 | pub const MODINFO_API: &str = "https://re146.dev/factorio/mods/modinfo"; | ||
| 72 | pub const DOWNLOAD_BASE: &str = "https://mods-storage.re146.dev"; | ||
| 73 | |||
| 74 | fn dep_regex() -> &'static Regex { | ||
| 75 | static RE: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| { | ||
| 76 | Regex::new( | ||
| 77 | r"(?i)^((?:)|(?:\?)|(?:\(\?\))|(?:!)|(?:~))(?:\s?)([%\w\s-]+)(?:\s{0,})(?:((?:=>)|(?:==)|(?:=)|(?:<=)|(?:>=)|(?:<)|(?:>)|(?:))(?:\s{0,})((\d+\.\d+\.\d+)|(\d+\.\d+)))?$", | ||
| 78 | ) | ||
| 79 | .unwrap() | ||
| 80 | }); | ||
| 81 | &RE | ||
| 82 | } | ||
| 83 | |||
| 84 | // --------------------------------------------------------------------------- | ||
| 85 | // Mod name extraction | ||
| 86 | // --------------------------------------------------------------------------- | ||
| 87 | |||
| 88 | pub fn extract_mod_name(url_or_name: &str) -> Result<String> { | ||
| 89 | let re = Regex::new(r"(?i)^https?://mods\.factorio\.com/mod/([^\s/#?]{1,})").unwrap(); | ||
| 90 | if let Some(caps) = re.captures(url_or_name) { | ||
| 91 | return Ok(caps[1].to_string()); | ||
| 92 | } | ||
| 93 | |||
| 94 | let re2 = | ||
| 95 | Regex::new(r"(?i)^https?://mods\.factorio\.com/mods/([^\s/]{1,})/([^\s/#?]{1,})").unwrap(); | ||
| 96 | if let Some(caps) = re2.captures(url_or_name) { | ||
| 97 | return Ok(caps[2].to_string()); | ||
| 98 | } | ||
| 99 | |||
| 100 | if !url_or_name.contains('/') && !url_or_name.starts_with("http") { | ||
| 101 | return Ok(url_or_name.to_string()); | ||
| 102 | } | ||
| 103 | |||
| 104 | bail!("Could not extract mod name from: {url_or_name}") | ||
| 105 | } | ||
| 106 | |||
| 107 | // --------------------------------------------------------------------------- | ||
| 108 | // API helpers | ||
| 109 | // --------------------------------------------------------------------------- | ||
| 110 | |||
| 111 | pub fn fetch_modinfo(mod_name: &str) -> Result<ModInfo> { | ||
| 112 | let url = format!("{MODINFO_API}?id={mod_name}"); | ||
| 113 | let resp = ureq::get(&url) | ||
| 114 | .header("Accept", "application/json") | ||
| 115 | .call() | ||
| 116 | .with_context(|| format!("Failed to fetch mod info for {mod_name}"))?; | ||
| 117 | |||
| 118 | if resp.status().as_u16() == 404 { | ||
| 119 | bail!("Mod '{mod_name}' not found on the portal"); | ||
| 120 | } | ||
| 121 | |||
| 122 | let body_text = resp | ||
| 123 | .into_body() | ||
| 124 | .read_to_string() | ||
| 125 | .with_context(|| format!("Failed to read response body for {mod_name}"))?; | ||
| 126 | |||
| 127 | let modinfo: ModInfo = serde_json::from_str(&body_text) | ||
| 128 | .with_context(|| format!("Failed to parse mod info JSON for {mod_name}"))?; | ||
| 129 | |||
| 130 | if modinfo.releases.is_empty() { | ||
| 131 | bail!("No releases found for '{mod_name}'"); | ||
| 132 | } | ||
| 133 | |||
| 134 | Ok(modinfo) | ||
| 135 | } | ||
| 136 | |||
| 137 | /// Fetch available versions for a mod. Returns (version, display_label) pairs. | ||
| 138 | #[cfg(feature = "gui")] | ||
| 139 | pub fn fetch_versions(mod_name: &str) -> Result<Vec<(String, String)>> { | ||
| 140 | let modinfo = fetch_modinfo(mod_name)?; | ||
| 141 | let total = modinfo.releases.len(); | ||
| 142 | |||
| 143 | let mut versions: Vec<(String, String)> = modinfo | ||
| 144 | .releases | ||
| 145 | .iter() | ||
| 146 | .enumerate() | ||
| 147 | .filter_map(|(i, r)| { | ||
| 148 | let v = r.version.clone(); | ||
| 149 | if v.is_empty() { | ||
| 150 | return None; | ||
| 151 | } | ||
| 152 | let is_latest = i == total - 1; | ||
| 153 | let factorio_ver = r | ||
| 154 | .info_json | ||
| 155 | .as_ref() | ||
| 156 | .and_then(|j| j.dependencies.first()) | ||
| 157 | .and_then(|d| { | ||
| 158 | parse_dependency_string(d).and_then(|dep| { | ||
| 159 | if dep.dep_type == DepType::Base && !dep.name.is_empty() { | ||
| 160 | Some(dep.name) | ||
| 161 | } else { | ||
| 162 | None | ||
| 163 | } | ||
| 164 | }) | ||
| 165 | }) | ||
| 166 | .unwrap_or_default(); | ||
| 167 | |||
| 168 | let mut label = v.clone(); | ||
| 169 | if is_latest { | ||
| 170 | label.push_str(" (latest)"); | ||
| 171 | } | ||
| 172 | if !factorio_ver.is_empty() { | ||
| 173 | label.push_str(&format!(" for {}", factorio_ver)); | ||
| 174 | } | ||
| 175 | Some((v, label)) | ||
| 176 | }) | ||
| 177 | .collect(); | ||
| 178 | |||
| 179 | versions.reverse(); | ||
| 180 | Ok(versions) | ||
| 181 | } | ||
| 182 | |||
| 183 | pub fn find_release<'a>(modinfo: &'a ModInfo, target_version: Option<&str>) -> Option<&'a Release> { | ||
| 184 | match target_version { | ||
| 185 | Some(v) => modinfo.releases.iter().find(|r| r.version == v), | ||
| 186 | None => modinfo.releases.last(), | ||
| 187 | } | ||
| 188 | } | ||
| 189 | |||
| 190 | // --------------------------------------------------------------------------- | ||
| 191 | // Dependency parsing | ||
| 192 | // --------------------------------------------------------------------------- | ||
| 193 | |||
| 194 | pub fn parse_dependency_string(input: &str) -> Option<Dependency> { | ||
| 195 | let re = dep_regex(); | ||
| 196 | let caps = re.captures(input)?; | ||
| 197 | |||
| 198 | let name = caps[2].trim().to_string(); | ||
| 199 | |||
| 200 | if name.eq_ignore_ascii_case("base") { | ||
| 201 | return Some(Dependency { | ||
| 202 | dep_type: DepType::Base, | ||
| 203 | name: "base".into(), | ||
| 204 | }); | ||
| 205 | } | ||
| 206 | |||
| 207 | let dep_type = match &caps[1] { | ||
| 208 | "?" | "(?)" => DepType::Optional, | ||
| 209 | "!" => DepType::Incompatible, | ||
| 210 | _ => DepType::Required, | ||
| 211 | }; | ||
| 212 | |||
| 213 | Some(Dependency { | ||
| 214 | dep_type, | ||
| 215 | name: name.trim().to_string(), | ||
| 216 | }) | ||
| 217 | } | ||
| 218 | |||
| 219 | pub fn parse_dependencies(deps: &[String]) -> (Vec<Dependency>, Vec<Dependency>, Vec<Dependency>) { | ||
| 220 | let mut required = Vec::new(); | ||
| 221 | let mut optional = Vec::new(); | ||
| 222 | let mut incompatible = Vec::new(); | ||
| 223 | |||
| 224 | for dep_str in deps.iter().rev() { | ||
| 225 | if let Some(dep) = parse_dependency_string(dep_str) { | ||
| 226 | match dep.dep_type { | ||
| 227 | DepType::Base => {} | ||
| 228 | DepType::Required => required.push(dep), | ||
| 229 | DepType::Optional => optional.push(dep), | ||
| 230 | DepType::Incompatible => incompatible.push(dep), | ||
| 231 | } | ||
| 232 | } | ||
| 233 | } | ||
| 234 | |||
| 235 | (required, optional, incompatible) | ||
| 236 | } | ||
| 237 | |||
| 238 | // --------------------------------------------------------------------------- | ||
| 239 | // GUI download (channel-based) | ||
| 240 | // --------------------------------------------------------------------------- | ||
| 241 | |||
| 242 | #[cfg(feature = "gui")] | ||
| 243 | fn gui_download_file( | ||
| 244 | url: &str, | ||
| 245 | file_path: &Path, | ||
| 246 | display_name: &str, | ||
| 247 | tx: &mpsc::Sender<Event>, | ||
| 248 | ) -> Result<()> { | ||
| 249 | let resp = ureq::get(url) | ||
| 250 | .call() | ||
| 251 | .with_context(|| format!("Failed to download {display_name}"))?; | ||
| 252 | |||
| 253 | let status = resp.status().as_u16(); | ||
| 254 | if status == 404 { | ||
| 255 | bail!("File not found on storage: {display_name}"); | ||
| 256 | } | ||
| 257 | if !(200..300).contains(&status) { | ||
| 258 | bail!("HTTP {status} while downloading {display_name}"); | ||
| 259 | } | ||
| 260 | |||
| 261 | let total_size: u64 = resp | ||
| 262 | .headers() | ||
| 263 | .get("content-length") | ||
| 264 | .and_then(|v| v.to_str().ok()) | ||
| 265 | .and_then(|v| v.parse().ok()) | ||
| 266 | .unwrap_or(0); | ||
| 267 | |||
| 268 | let _ = tx.send(Event::DownloadStart { | ||
| 269 | name: display_name.to_string(), | ||
| 270 | total_bytes: total_size, | ||
| 271 | }); | ||
| 272 | |||
| 273 | let mut reader = resp.into_body().into_reader(); | ||
| 274 | let mut file = fs::File::create(file_path) | ||
| 275 | .with_context(|| format!("Failed to create file {}", file_path.display()))?; | ||
| 276 | |||
| 277 | let mut buf = [0u8; 256 * 1024]; | ||
| 278 | let mut downloaded: u64 = 0; | ||
| 279 | loop { | ||
| 280 | let n = reader | ||
| 281 | .read(&mut buf) | ||
| 282 | .with_context(|| format!("Read error while downloading {display_name}"))?; | ||
| 283 | if n == 0 { | ||
| 284 | break; | ||
| 285 | } | ||
| 286 | file.write_all(&buf[..n]) | ||
| 287 | .with_context(|| format!("Write error for {}", file_path.display()))?; | ||
| 288 | downloaded += n as u64; | ||
| 289 | let _ = tx.send(Event::DownloadProgress { | ||
| 290 | name: display_name.to_string(), | ||
| 291 | bytes: downloaded, | ||
| 292 | }); | ||
| 293 | } | ||
| 294 | |||
| 295 | let _ = tx.send(Event::DownloadComplete { | ||
| 296 | name: display_name.to_string(), | ||
| 297 | }); | ||
| 298 | Ok(()) | ||
| 299 | } | ||
| 300 | |||
| 301 | #[cfg(feature = "gui")] | ||
| 302 | fn gui_download_mod_with_deps( | ||
| 303 | mod_name: &str, | ||
| 304 | output: &Path, | ||
| 305 | target_version: Option<&str>, | ||
| 306 | include_optional: bool, | ||
| 307 | downloaded: &mut HashSet<String>, | ||
| 308 | analyzed: &mut HashSet<String>, | ||
| 309 | tx: &mpsc::Sender<Event>, | ||
| 310 | ) -> Result<()> { | ||
| 311 | if analyzed.contains(mod_name) { | ||
| 312 | return Ok(()); | ||
| 313 | } | ||
| 314 | |||
| 315 | if mod_name == "space-age" { | ||
| 316 | let _ = tx.send(Event::Log(format!( | ||
| 317 | "Skipping reserved dependency '{mod_name}'" | ||
| 318 | ))); | ||
| 319 | return Ok(()); | ||
| 320 | } | ||
| 321 | |||
| 322 | let modinfo = fetch_modinfo(mod_name)?; | ||
| 323 | |||
| 324 | let release = find_release(&modinfo, target_version) | ||
| 325 | .with_context(|| format!("Version not found for '{mod_name}'"))?; | ||
| 326 | |||
| 327 | let version = &release.version; | ||
| 328 | let _ = tx.send(Event::Log(format!("Processing: {mod_name} v{version}"))); | ||
| 329 | analyzed.insert(mod_name.to_string()); | ||
| 330 | |||
| 331 | let file_name = format!("{mod_name}_{version}.zip"); | ||
| 332 | let file_path = output.join(&file_name); | ||
| 333 | |||
| 334 | if !file_path.exists() { | ||
| 335 | fs::create_dir_all(output)?; | ||
| 336 | let download_url = format!("{DOWNLOAD_BASE}/{mod_name}/{version}.zip"); | ||
| 337 | |||
| 338 | if let Err(e) = gui_download_file(&download_url, &file_path, &file_name, tx) { | ||
| 339 | let _ = tx.send(Event::DownloadError { | ||
| 340 | name: file_name.clone(), | ||
| 341 | error: e.to_string(), | ||
| 342 | }); | ||
| 343 | return Err(e); | ||
| 344 | } | ||
| 345 | downloaded.insert(mod_name.to_string()); | ||
| 346 | } else { | ||
| 347 | let _ = tx.send(Event::Log(format!("Already downloaded: {file_name}"))); | ||
| 348 | } | ||
| 349 | |||
| 350 | if let Some(info_json) = &release.info_json { | ||
| 351 | let (required, optional, _incompatible) = parse_dependencies(&info_json.dependencies); | ||
| 352 | |||
| 353 | let mut deps = required; | ||
| 354 | if include_optional { | ||
| 355 | deps.extend(optional); | ||
| 356 | } | ||
| 357 | |||
| 358 | for dep in &deps { | ||
| 359 | if dep.name == "base" || downloaded.contains(&dep.name) || analyzed.contains(&dep.name) | ||
| 360 | { | ||
| 361 | continue; | ||
| 362 | } | ||
| 363 | let _ = tx.send(Event::Log(format!("-> Fetching dependency: {}", dep.name))); | ||
| 364 | if let Err(e) = gui_download_mod_with_deps( | ||
| 365 | &dep.name, | ||
| 366 | output, | ||
| 367 | None, | ||
| 368 | include_optional, | ||
| 369 | downloaded, | ||
| 370 | analyzed, | ||
| 371 | tx, | ||
| 372 | ) { | ||
| 373 | let _ = tx.send(Event::Log(format!( | ||
| 374 | "Error processing dependency {}: {}", | ||
| 375 | dep.name, e | ||
| 376 | ))); | ||
| 377 | } | ||
| 378 | } | ||
| 379 | } | ||
| 380 | |||
| 381 | Ok(()) | ||
| 382 | } | ||
| 383 | |||
| 384 | /// Entry point for GUI downloads. Runs the full pipeline and sends events via the channel. | ||
| 385 | #[cfg(feature = "gui")] | ||
| 386 | pub fn run_gui_download( | ||
| 387 | mod_url: &str, | ||
| 388 | output: &Path, | ||
| 389 | target_version: Option<String>, | ||
| 390 | include_optional: bool, | ||
| 391 | tx: mpsc::Sender<Event>, | ||
| 392 | ) { | ||
| 393 | let result = (|| -> Result<()> { | ||
| 394 | let mod_name = extract_mod_name(mod_url)?; | ||
| 395 | let _ = tx.send(Event::Log(format!("Starting download: {mod_name}"))); | ||
| 396 | |||
| 397 | let mut downloaded = HashSet::new(); | ||
| 398 | let mut analyzed = HashSet::new(); | ||
| 399 | |||
| 400 | gui_download_mod_with_deps( | ||
| 401 | &mod_name, | ||
| 402 | output, | ||
| 403 | target_version.as_deref(), | ||
| 404 | include_optional, | ||
| 405 | &mut downloaded, | ||
| 406 | &mut analyzed, | ||
| 407 | &tx, | ||
| 408 | )?; | ||
| 409 | |||
| 410 | Ok(()) | ||
| 411 | })(); | ||
| 412 | |||
| 413 | match result { | ||
| 414 | Ok(()) => { | ||
| 415 | let _ = tx.send(Event::Finished { | ||
| 416 | success: true, | ||
| 417 | message: "All mods downloaded successfully.".into(), | ||
| 418 | }); | ||
| 419 | } | ||
| 420 | Err(e) => { | ||
| 421 | let _ = tx.send(Event::Finished { | ||
| 422 | success: false, | ||
| 423 | message: format!("Download failed: {e}"), | ||
| 424 | }); | ||
| 425 | } | ||
| 426 | } | ||
| 427 | } | ||
| 428 | |||
| 429 | // --------------------------------------------------------------------------- | ||
| 430 | // CLI download (prints progress to stdout) | ||
| 431 | // --------------------------------------------------------------------------- | ||
| 432 | |||
| 433 | #[cfg(not(feature = "gui"))] | ||
| 434 | fn cli_download_file(url: &str, file_path: &Path, display_name: &str) -> Result<()> { | ||
| 435 | let resp = ureq::get(url) | ||
| 436 | .call() | ||
| 437 | .with_context(|| format!("Failed to download {display_name}"))?; | ||
| 438 | |||
| 439 | let status = resp.status().as_u16(); | ||
| 440 | if status == 404 { | ||
| 441 | bail!("File not found on storage: {display_name}"); | ||
| 442 | } | ||
| 443 | if !(200..300).contains(&status) { | ||
| 444 | bail!("HTTP {status} while downloading {display_name}"); | ||
| 445 | } | ||
| 446 | |||
| 447 | let total_size: u64 = resp | ||
| 448 | .headers() | ||
| 449 | .get("content-length") | ||
| 450 | .and_then(|v| v.to_str().ok()) | ||
| 451 | .and_then(|v| v.parse().ok()) | ||
| 452 | .unwrap_or(0); | ||
| 453 | |||
| 454 | println!( | ||
| 455 | " Downloading: {display_name} ({:.1} MB)", | ||
| 456 | total_size as f64 / 1_048_576.0 | ||
| 457 | ); | ||
| 458 | |||
| 459 | let mut reader = resp.into_body().into_reader(); | ||
| 460 | let mut file = fs::File::create(file_path) | ||
| 461 | .with_context(|| format!("Failed to create file {}", file_path.display()))?; | ||
| 462 | |||
| 463 | let mut buf = [0u8; 256 * 1024]; | ||
| 464 | let mut downloaded: u64 = 0; | ||
| 465 | let mut last_pct: u32 = 0; | ||
| 466 | loop { | ||
| 467 | let n = reader | ||
| 468 | .read(&mut buf) | ||
| 469 | .with_context(|| format!("Read error while downloading {display_name}"))?; | ||
| 470 | if n == 0 { | ||
| 471 | break; | ||
| 472 | } | ||
| 473 | file.write_all(&buf[..n]) | ||
| 474 | .with_context(|| format!("Write error for {}", file_path.display()))?; | ||
| 475 | downloaded += n as u64; | ||
| 476 | |||
| 477 | if total_size > 0 { | ||
| 478 | let pct = (downloaded * 100 / total_size) as u32; | ||
| 479 | if pct / 10 != last_pct / 10 { | ||
| 480 | last_pct = pct; | ||
| 481 | print!("\r {} {}%", display_name, pct); | ||
| 482 | let _ = std::io::Write::flush(&mut std::io::stdout()); | ||
| 483 | } | ||
| 484 | } | ||
| 485 | } | ||
| 486 | println!("\r {} Done.", display_name); | ||
| 487 | Ok(()) | ||
| 488 | } | ||
| 489 | |||
| 490 | #[cfg(not(feature = "gui"))] | ||
| 491 | fn cli_download_mod_with_deps( | ||
| 492 | mod_name: &str, | ||
| 493 | output: &Path, | ||
| 494 | target_version: Option<&str>, | ||
| 495 | include_optional: bool, | ||
| 496 | downloaded: &mut HashSet<String>, | ||
| 497 | analyzed: &mut HashSet<String>, | ||
| 498 | ) -> Result<()> { | ||
| 499 | if analyzed.contains(mod_name) { | ||
| 500 | return Ok(()); | ||
| 501 | } | ||
| 502 | |||
| 503 | if mod_name == "space-age" { | ||
| 504 | println!("Skipping reserved dependency '{mod_name}'"); | ||
| 505 | return Ok(()); | ||
| 506 | } | ||
| 507 | |||
| 508 | let modinfo = fetch_modinfo(mod_name)?; | ||
| 509 | |||
| 510 | let release = find_release(&modinfo, target_version) | ||
| 511 | .with_context(|| format!("Version not found for '{mod_name}'"))?; | ||
| 512 | |||
| 513 | let version = &release.version; | ||
| 514 | println!("Processing: {mod_name} v{version}"); | ||
| 515 | analyzed.insert(mod_name.to_string()); | ||
| 516 | |||
| 517 | let file_name = format!("{mod_name}_{version}.zip"); | ||
| 518 | let file_path = output.join(&file_name); | ||
| 519 | |||
| 520 | if !file_path.exists() { | ||
| 521 | fs::create_dir_all(output)?; | ||
| 522 | let download_url = format!("{DOWNLOAD_BASE}/{mod_name}/{version}.zip"); | ||
| 523 | |||
| 524 | if let Err(e) = cli_download_file(&download_url, &file_path, &file_name) { | ||
| 525 | eprintln!("Error downloading {file_name}: {e}"); | ||
| 526 | return Err(e); | ||
| 527 | } | ||
| 528 | downloaded.insert(mod_name.to_string()); | ||
| 529 | } else { | ||
| 530 | println!("Already downloaded: {file_name}"); | ||
| 531 | } | ||
| 532 | |||
| 533 | if let Some(info_json) = &release.info_json { | ||
| 534 | let (required, optional, _incompatible) = parse_dependencies(&info_json.dependencies); | ||
| 535 | |||
| 536 | let mut deps = required; | ||
| 537 | if include_optional { | ||
| 538 | deps.extend(optional); | ||
| 539 | } | ||
| 540 | |||
| 541 | for dep in &deps { | ||
| 542 | if dep.name == "base" || downloaded.contains(&dep.name) || analyzed.contains(&dep.name) | ||
| 543 | { | ||
| 544 | continue; | ||
| 545 | } | ||
| 546 | println!("-> Fetching dependency: {}", dep.name); | ||
| 547 | if let Err(e) = cli_download_mod_with_deps( | ||
| 548 | &dep.name, | ||
| 549 | output, | ||
| 550 | None, | ||
| 551 | include_optional, | ||
| 552 | downloaded, | ||
| 553 | analyzed, | ||
| 554 | ) { | ||
| 555 | eprintln!("Error processing dependency {}: {}", dep.name, e); | ||
| 556 | } | ||
| 557 | } | ||
| 558 | } | ||
| 559 | |||
| 560 | Ok(()) | ||
| 561 | } | ||
| 562 | |||
| 563 | /// Entry point for CLI downloads. Prints progress to stdout. | ||
| 564 | #[cfg(not(feature = "gui"))] | ||
| 565 | pub fn run_cli_download( | ||
| 566 | mod_url: &str, | ||
| 567 | output: &Path, | ||
| 568 | target_version: Option<&str>, | ||
| 569 | include_optional: bool, | ||
| 570 | ) -> Result<()> { | ||
| 571 | let mod_name = extract_mod_name(mod_url)?; | ||
| 572 | println!("Starting download: {mod_name}"); | ||
| 573 | println!("Output directory: {}", output.display()); | ||
| 574 | |||
| 575 | let mut downloaded = HashSet::new(); | ||
| 576 | let mut analyzed = HashSet::new(); | ||
| 577 | |||
| 578 | cli_download_mod_with_deps( | ||
| 579 | &mod_name, | ||
| 580 | output, | ||
| 581 | target_version, | ||
| 582 | include_optional, | ||
| 583 | &mut downloaded, | ||
| 584 | &mut analyzed, | ||
| 585 | )?; | ||
| 586 | |||
| 587 | println!("\nAll mods downloaded successfully."); | ||
| 588 | Ok(()) | ||
| 589 | } | ||
diff --git a/src/gui.rs b/src/gui.rs new file mode 100644 index 0000000..5fe1092 --- /dev/null +++ b/src/gui.rs | |||
| @@ -0,0 +1,369 @@ | |||
| 1 | use std::path::PathBuf; | ||
| 2 | use std::sync::mpsc; | ||
| 3 | use std::sync::mpsc::TryRecvError; | ||
| 4 | use std::sync::{Arc, Mutex}; | ||
| 5 | use std::thread; | ||
| 6 | |||
| 7 | use egui::{Color32, ProgressBar, ScrollArea, TextEdit}; | ||
| 8 | use rfd::FileDialog; | ||
| 9 | |||
| 10 | use crate::downloader::{self, Event}; | ||
| 11 | |||
| 12 | pub struct App { | ||
| 13 | mod_url: String, | ||
| 14 | output_path: String, | ||
| 15 | target_version: String, | ||
| 16 | include_optional: bool, | ||
| 17 | |||
| 18 | versions: Vec<(String, String)>, | ||
| 19 | loading_versions: bool, | ||
| 20 | version_handle: Option<Arc<Mutex<Option<Vec<(String, String)>>>>>, | ||
| 21 | |||
| 22 | downloading: bool, | ||
| 23 | finished: bool, | ||
| 24 | |||
| 25 | downloads: Vec<DownloadState>, | ||
| 26 | log_entries: Vec<LogEntry>, | ||
| 27 | |||
| 28 | receiver: Option<mpsc::Receiver<Event>>, | ||
| 29 | } | ||
| 30 | |||
| 31 | struct DownloadState { | ||
| 32 | name: String, | ||
| 33 | total_bytes: u64, | ||
| 34 | downloaded_bytes: u64, | ||
| 35 | complete: bool, | ||
| 36 | error: Option<String>, | ||
| 37 | } | ||
| 38 | |||
| 39 | struct LogEntry { | ||
| 40 | text: String, | ||
| 41 | is_error: bool, | ||
| 42 | } | ||
| 43 | |||
| 44 | impl App { | ||
| 45 | pub fn new() -> Self { | ||
| 46 | Self { | ||
| 47 | mod_url: String::new(), | ||
| 48 | output_path: std::env::current_dir() | ||
| 49 | .map(|p| p.display().to_string()) | ||
| 50 | .unwrap_or_default(), | ||
| 51 | target_version: String::new(), | ||
| 52 | include_optional: false, | ||
| 53 | versions: Vec::new(), | ||
| 54 | loading_versions: false, | ||
| 55 | version_handle: None, | ||
| 56 | downloading: false, | ||
| 57 | finished: false, | ||
| 58 | downloads: Vec::new(), | ||
| 59 | log_entries: Vec::new(), | ||
| 60 | receiver: None, | ||
| 61 | } | ||
| 62 | } | ||
| 63 | |||
| 64 | fn log(&mut self, text: String, is_error: bool) { | ||
| 65 | self.log_entries.push(LogEntry { text, is_error }); | ||
| 66 | if self.log_entries.len() > 500 { | ||
| 67 | self.log_entries.remove(0); | ||
| 68 | } | ||
| 69 | } | ||
| 70 | |||
| 71 | fn find_or_create_download(&mut self, name: &str) -> &mut DownloadState { | ||
| 72 | if let Some(pos) = self.downloads.iter().position(|d| d.name == name) { | ||
| 73 | &mut self.downloads[pos] | ||
| 74 | } else { | ||
| 75 | self.downloads.push(DownloadState { | ||
| 76 | name: name.to_string(), | ||
| 77 | total_bytes: 0, | ||
| 78 | downloaded_bytes: 0, | ||
| 79 | complete: false, | ||
| 80 | error: None, | ||
| 81 | }); | ||
| 82 | self.downloads.last_mut().unwrap() | ||
| 83 | } | ||
| 84 | } | ||
| 85 | |||
| 86 | fn poll_events(&mut self) { | ||
| 87 | let mut events = Vec::new(); | ||
| 88 | if let Some(ref rx) = self.receiver { | ||
| 89 | loop { | ||
| 90 | match rx.try_recv() { | ||
| 91 | Ok(event) => events.push(event), | ||
| 92 | Err(TryRecvError::Empty) => break, | ||
| 93 | Err(TryRecvError::Disconnected) => { | ||
| 94 | self.downloading = false; | ||
| 95 | self.receiver = None; | ||
| 96 | break; | ||
| 97 | } | ||
| 98 | } | ||
| 99 | } | ||
| 100 | } | ||
| 101 | for event in events { | ||
| 102 | self.handle_event(event); | ||
| 103 | } | ||
| 104 | } | ||
| 105 | |||
| 106 | fn handle_event(&mut self, event: Event) { | ||
| 107 | match event { | ||
| 108 | Event::Log(text) => self.log(text, false), | ||
| 109 | Event::DownloadStart { name, total_bytes } => { | ||
| 110 | self.log(format!("Downloading: {name}"), false); | ||
| 111 | let dl = self.find_or_create_download(&name); | ||
| 112 | dl.total_bytes = total_bytes; | ||
| 113 | dl.downloaded_bytes = 0; | ||
| 114 | dl.complete = false; | ||
| 115 | dl.error = None; | ||
| 116 | } | ||
| 117 | Event::DownloadProgress { name, bytes } => { | ||
| 118 | let dl = self.find_or_create_download(&name); | ||
| 119 | dl.downloaded_bytes = bytes; | ||
| 120 | } | ||
| 121 | Event::DownloadComplete { name } => { | ||
| 122 | let dl = self.find_or_create_download(&name); | ||
| 123 | dl.complete = true; | ||
| 124 | dl.downloaded_bytes = dl.total_bytes; | ||
| 125 | self.log(format!("Downloaded: {name}"), false); | ||
| 126 | } | ||
| 127 | Event::DownloadError { name, error } => { | ||
| 128 | let dl = self.find_or_create_download(&name); | ||
| 129 | dl.error = Some(error.clone()); | ||
| 130 | self.log(format!("Error: {name} - {error}"), true); | ||
| 131 | } | ||
| 132 | Event::Finished { success, message } => { | ||
| 133 | self.log(message.clone(), !success); | ||
| 134 | self.downloading = false; | ||
| 135 | self.finished = true; | ||
| 136 | self.receiver = None; | ||
| 137 | } | ||
| 138 | } | ||
| 139 | } | ||
| 140 | |||
| 141 | fn start_download(&mut self) { | ||
| 142 | let mod_url = self.mod_url.trim().to_string(); | ||
| 143 | if mod_url.is_empty() { | ||
| 144 | return; | ||
| 145 | } | ||
| 146 | |||
| 147 | let output = PathBuf::from(&self.output_path); | ||
| 148 | let target_version = if self.target_version.trim().is_empty() { | ||
| 149 | None | ||
| 150 | } else { | ||
| 151 | Some(self.target_version.trim().to_string()) | ||
| 152 | }; | ||
| 153 | let include_optional = self.include_optional; | ||
| 154 | |||
| 155 | let (tx, rx) = mpsc::channel(); | ||
| 156 | self.receiver = Some(rx); | ||
| 157 | self.downloading = true; | ||
| 158 | self.finished = false; | ||
| 159 | self.downloads.clear(); | ||
| 160 | self.log_entries.clear(); | ||
| 161 | |||
| 162 | self.log("Starting download...".into(), false); | ||
| 163 | |||
| 164 | thread::spawn(move || { | ||
| 165 | downloader::run_gui_download(&mod_url, &output, target_version, include_optional, tx); | ||
| 166 | }); | ||
| 167 | } | ||
| 168 | |||
| 169 | fn load_versions(&mut self) { | ||
| 170 | let mod_url = self.mod_url.trim().to_string(); | ||
| 171 | if mod_url.is_empty() { | ||
| 172 | return; | ||
| 173 | } | ||
| 174 | |||
| 175 | self.loading_versions = true; | ||
| 176 | let result: Arc<Mutex<Option<Vec<(String, String)>>>> = Arc::new(Mutex::new(None)); | ||
| 177 | self.version_handle = Some(result.clone()); | ||
| 178 | |||
| 179 | thread::spawn(move || { | ||
| 180 | let name = downloader::extract_mod_name(&mod_url); | ||
| 181 | let versions = match name { | ||
| 182 | Ok(name) => downloader::fetch_versions(&name).ok().unwrap_or_default(), | ||
| 183 | Err(_) => Vec::new(), | ||
| 184 | }; | ||
| 185 | *result.lock().unwrap() = Some(versions); | ||
| 186 | }); | ||
| 187 | } | ||
| 188 | |||
| 189 | fn browse_output(&mut self) { | ||
| 190 | if let Some(path) = FileDialog::new().pick_folder() { | ||
| 191 | self.output_path = path.display().to_string(); | ||
| 192 | } | ||
| 193 | } | ||
| 194 | } | ||
| 195 | |||
| 196 | impl eframe::App for App { | ||
| 197 | fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { | ||
| 198 | self.poll_events(); | ||
| 199 | |||
| 200 | // Check version loading result | ||
| 201 | if self.loading_versions { | ||
| 202 | let result = if let Some(ref handle) = self.version_handle { | ||
| 203 | handle.lock().ok().and_then(|mut g| g.take()) | ||
| 204 | } else { | ||
| 205 | None | ||
| 206 | }; | ||
| 207 | if let Some(versions) = result { | ||
| 208 | self.versions = versions; | ||
| 209 | self.loading_versions = false; | ||
| 210 | self.version_handle = None; | ||
| 211 | } | ||
| 212 | } | ||
| 213 | |||
| 214 | if self.downloading { | ||
| 215 | ctx.request_repaint(); | ||
| 216 | } | ||
| 217 | } | ||
| 218 | |||
| 219 | fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { | ||
| 220 | ui.heading("Factorio Mod Downloader"); | ||
| 221 | ui.separator(); | ||
| 222 | |||
| 223 | // ── Input section ── | ||
| 224 | ui.horizontal(|ui| { | ||
| 225 | ui.label("Mod URL:"); | ||
| 226 | ui.add_sized( | ||
| 227 | [ui.available_width() - 10.0, 20.0], | ||
| 228 | TextEdit::singleline(&mut self.mod_url) | ||
| 229 | .hint_text("https://mods.factorio.com/mod/... or mod name"), | ||
| 230 | ); | ||
| 231 | }); | ||
| 232 | |||
| 233 | ui.horizontal(|ui| { | ||
| 234 | ui.label("Output:"); | ||
| 235 | ui.add_sized( | ||
| 236 | [ui.available_width() - 80.0, 20.0], | ||
| 237 | TextEdit::singleline(&mut self.output_path).hint_text("Download directory"), | ||
| 238 | ); | ||
| 239 | if ui.button("Browse").clicked() { | ||
| 240 | self.browse_output(); | ||
| 241 | } | ||
| 242 | }); | ||
| 243 | |||
| 244 | ui.horizontal(|ui| { | ||
| 245 | ui.label("Version:"); | ||
| 246 | let version_enabled = !self.downloading && !self.versions.is_empty(); | ||
| 247 | ui.add_enabled_ui(version_enabled, |ui| { | ||
| 248 | egui::ComboBox::from_id_salt("version_combo") | ||
| 249 | .width(200.0) | ||
| 250 | .selected_text(if self.target_version.is_empty() { | ||
| 251 | "Latest" | ||
| 252 | } else { | ||
| 253 | &self.target_version | ||
| 254 | }) | ||
| 255 | .show_ui(ui, |ui| { | ||
| 256 | if ui.selectable_label(false, "Latest").clicked() { | ||
| 257 | self.target_version.clear(); | ||
| 258 | } | ||
| 259 | for (ver, label) in &self.versions { | ||
| 260 | if ui.selectable_label(self.target_version == *ver, label).clicked() { | ||
| 261 | self.target_version = ver.clone(); | ||
| 262 | } | ||
| 263 | } | ||
| 264 | }); | ||
| 265 | }); | ||
| 266 | |||
| 267 | if ui | ||
| 268 | .add_enabled( | ||
| 269 | !self.downloading && !self.loading_versions, | ||
| 270 | egui::Button::new(if self.loading_versions { | ||
| 271 | "Loading..." | ||
| 272 | } else { | ||
| 273 | "Load Versions" | ||
| 274 | }), | ||
| 275 | ) | ||
| 276 | .clicked() | ||
| 277 | { | ||
| 278 | self.load_versions(); | ||
| 279 | } | ||
| 280 | |||
| 281 | ui.checkbox(&mut self.include_optional, "Include optional deps"); | ||
| 282 | }); | ||
| 283 | |||
| 284 | ui.add_space(8.0); | ||
| 285 | |||
| 286 | // ── Download button ── | ||
| 287 | let can_download = !self.downloading | ||
| 288 | && !self.mod_url.trim().is_empty() | ||
| 289 | && !self.output_path.is_empty(); | ||
| 290 | |||
| 291 | if ui | ||
| 292 | .add_enabled( | ||
| 293 | can_download, | ||
| 294 | egui::Button::new(if self.downloading { | ||
| 295 | "Downloading..." | ||
| 296 | } else if self.finished { | ||
| 297 | "Download Again" | ||
| 298 | } else { | ||
| 299 | "Start Download" | ||
| 300 | }), | ||
| 301 | ) | ||
| 302 | .clicked() | ||
| 303 | { | ||
| 304 | self.start_download(); | ||
| 305 | } | ||
| 306 | |||
| 307 | ui.add_space(8.0); | ||
| 308 | |||
| 309 | // ── Progress section ── | ||
| 310 | if !self.downloads.is_empty() { | ||
| 311 | egui::CollapsingHeader::new("Downloads") | ||
| 312 | .default_open(true) | ||
| 313 | .show(ui, |ui| { | ||
| 314 | for dl in &self.downloads { | ||
| 315 | let percentage = if dl.total_bytes > 0 { | ||
| 316 | dl.downloaded_bytes as f32 / dl.total_bytes as f32 | ||
| 317 | } else { | ||
| 318 | 0.0 | ||
| 319 | }; | ||
| 320 | |||
| 321 | ui.horizontal(|ui| { | ||
| 322 | let status = if dl.complete { | ||
| 323 | "[OK]" | ||
| 324 | } else if dl.error.is_some() { | ||
| 325 | "[ERR]" | ||
| 326 | } else { | ||
| 327 | "[>>]" | ||
| 328 | }; | ||
| 329 | ui.label(format!("{status} {}", dl.name)); | ||
| 330 | |||
| 331 | if let Some(ref err) = dl.error { | ||
| 332 | ui.colored_label(Color32::RED, format!("Error: {err}")); | ||
| 333 | } else if dl.total_bytes > 0 { | ||
| 334 | ui.label(format!( | ||
| 335 | "{:.1} / {:.1} MB", | ||
| 336 | dl.downloaded_bytes as f64 / 1_048_576.0, | ||
| 337 | dl.total_bytes as f64 / 1_048_576.0 | ||
| 338 | )); | ||
| 339 | } | ||
| 340 | }); | ||
| 341 | |||
| 342 | let bar = | ||
| 343 | ProgressBar::new(percentage).desired_width(ui.available_width()); | ||
| 344 | ui.add(bar); | ||
| 345 | } | ||
| 346 | }); | ||
| 347 | } | ||
| 348 | |||
| 349 | ui.add_space(8.0); | ||
| 350 | |||
| 351 | // ── Log section ── | ||
| 352 | egui::CollapsingHeader::new("Log") | ||
| 353 | .default_open(true) | ||
| 354 | .show(ui, |ui| { | ||
| 355 | ScrollArea::vertical() | ||
| 356 | .max_height(300.0) | ||
| 357 | .stick_to_bottom(true) | ||
| 358 | .show(ui, |ui| { | ||
| 359 | for entry in &self.log_entries { | ||
| 360 | if entry.is_error { | ||
| 361 | ui.colored_label(Color32::RED, &entry.text); | ||
| 362 | } else { | ||
| 363 | ui.label(&entry.text); | ||
| 364 | } | ||
| 365 | } | ||
| 366 | }); | ||
| 367 | }); | ||
| 368 | } | ||
| 369 | } | ||
diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..78f17f8 --- /dev/null +++ b/src/main.rs | |||
| @@ -0,0 +1,102 @@ | |||
| 1 | mod downloader; | ||
| 2 | |||
| 3 | #[cfg(feature = "gui")] | ||
| 4 | mod gui; | ||
| 5 | |||
| 6 | // --------------------------------------------------------------------------- | ||
| 7 | // GUI entry point (when the "gui" feature is enabled) | ||
| 8 | // --------------------------------------------------------------------------- | ||
| 9 | #[cfg(feature = "gui")] | ||
| 10 | fn main() -> eframe::Result<()> { | ||
| 11 | let options = eframe::NativeOptions { | ||
| 12 | viewport: egui::ViewportBuilder::default() | ||
| 13 | .with_inner_size([800.0, 650.0]) | ||
| 14 | .with_min_inner_size([600.0, 400.0]), | ||
| 15 | ..Default::default() | ||
| 16 | }; | ||
| 17 | |||
| 18 | eframe::run_native( | ||
| 19 | "Factorio Mod Downloader", | ||
| 20 | options, | ||
| 21 | Box::new(|_cc| Ok(Box::new(gui::App::new()))), | ||
| 22 | ) | ||
| 23 | } | ||
| 24 | |||
| 25 | // --------------------------------------------------------------------------- | ||
| 26 | // CLI entry point (when the "gui" feature is disabled) | ||
| 27 | // --------------------------------------------------------------------------- | ||
| 28 | #[cfg(not(feature = "gui"))] | ||
| 29 | fn main() { | ||
| 30 | let args: Vec<String> = std::env::args().collect(); | ||
| 31 | |||
| 32 | if args.len() < 2 { | ||
| 33 | eprintln!("Factorio Mod Downloader (CLI)"); | ||
| 34 | eprintln!(); | ||
| 35 | eprintln!( | ||
| 36 | "Usage: {} <mod_url_or_name> [output_directory] [options]", | ||
| 37 | args[0] | ||
| 38 | ); | ||
| 39 | eprintln!(); | ||
| 40 | eprintln!("Options:"); | ||
| 41 | eprintln!(" --version <VER> Download a specific version instead of latest"); | ||
| 42 | eprintln!(" --include-optional Also download optional dependencies"); | ||
| 43 | eprintln!(); | ||
| 44 | eprintln!("Examples:"); | ||
| 45 | eprintln!(" {} my-cool-mod", args[0]); | ||
| 46 | eprintln!( | ||
| 47 | " {} https://mods.factorio.com/mod/my-cool-mod ./mods", | ||
| 48 | args[0] | ||
| 49 | ); | ||
| 50 | eprintln!( | ||
| 51 | " {} my-cool-mod ./mods --version 2.1.0 --include-optional", | ||
| 52 | args[0] | ||
| 53 | ); | ||
| 54 | std::process::exit(1); | ||
| 55 | } | ||
| 56 | |||
| 57 | let mod_url = &args[1]; | ||
| 58 | |||
| 59 | let output = if args.len() >= 3 && !args[2].starts_with("--") { | ||
| 60 | std::path::PathBuf::from(&args[2]) | ||
| 61 | } else { | ||
| 62 | std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")) | ||
| 63 | }; | ||
| 64 | |||
| 65 | let mut target_version: Option<&str> = None; | ||
| 66 | let mut include_optional = false; | ||
| 67 | |||
| 68 | let mut i = if args.len() >= 3 && !args[2].starts_with("--") { | ||
| 69 | 3 | ||
| 70 | } else { | ||
| 71 | 2 | ||
| 72 | }; | ||
| 73 | while i < args.len() { | ||
| 74 | match args[i].as_str() { | ||
| 75 | "--version" => { | ||
| 76 | if i + 1 < args.len() { | ||
| 77 | target_version = Some(&args[i + 1]); | ||
| 78 | i += 1; | ||
| 79 | } else { | ||
| 80 | eprintln!("Error: --version requires a value"); | ||
| 81 | std::process::exit(1); | ||
| 82 | } | ||
| 83 | } | ||
| 84 | "--include-optional" => { | ||
| 85 | include_optional = true; | ||
| 86 | } | ||
| 87 | other => { | ||
| 88 | eprintln!("Unknown option: {other}"); | ||
| 89 | std::process::exit(1); | ||
| 90 | } | ||
| 91 | } | ||
| 92 | i += 1; | ||
| 93 | } | ||
| 94 | |||
| 95 | match downloader::run_cli_download(mod_url, &output, target_version, include_optional) { | ||
| 96 | Ok(()) => {} | ||
| 97 | Err(e) => { | ||
| 98 | eprintln!("Error: {:#}", e); | ||
| 99 | std::process::exit(1); | ||
| 100 | } | ||
| 101 | } | ||
| 102 | } | ||
