From 7df4ceb1cf2a6edc058d28527140598ca90e1f10 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sat, 11 Jul 2026 13:32:48 +0200 Subject: init --- src/downloader.rs | 589 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/gui.rs | 369 ++++++++++++++++++++++++++++++++++ src/main.rs | 102 ++++++++++ 3 files changed, 1060 insertions(+) create mode 100644 src/downloader.rs create mode 100644 src/gui.rs create mode 100644 src/main.rs (limited to 'src') 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 @@ +use std::collections::HashSet; +use std::fs; +use std::io::{Read, Write}; +use std::path::Path; + +use anyhow::{bail, Context, Result}; +use regex::Regex; +use serde::Deserialize; + +// --------------------------------------------------------------------------- +// Public event type for GUI progress reporting +// --------------------------------------------------------------------------- + +#[cfg(feature = "gui")] +use std::sync::mpsc; + +#[cfg(feature = "gui")] +#[derive(Debug, Clone)] +pub enum Event { + Log(String), + DownloadStart { name: String, total_bytes: u64 }, + DownloadProgress { name: String, bytes: u64 }, + DownloadComplete { name: String }, + DownloadError { name: String, error: String }, + Finished { success: bool, message: String }, +} + +// --------------------------------------------------------------------------- +// API response types +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +pub struct ModInfo { + pub releases: Vec, +} + +#[derive(Deserialize, Clone)] +pub struct Release { + pub version: String, + pub info_json: Option, +} + +#[derive(Deserialize, Clone)] +pub struct InfoJson { + #[serde(default)] + pub dependencies: Vec, +} + +// --------------------------------------------------------------------------- +// Dependency model +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq)] +pub enum DepType { + Base, + Required, + Optional, + Incompatible, +} + +#[derive(Debug, Clone)] +pub struct Dependency { + pub dep_type: DepType, + pub name: String, +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +pub const MODINFO_API: &str = "https://re146.dev/factorio/mods/modinfo"; +pub const DOWNLOAD_BASE: &str = "https://mods-storage.re146.dev"; + +fn dep_regex() -> &'static Regex { + static RE: std::sync::LazyLock = std::sync::LazyLock::new(|| { + Regex::new( + r"(?i)^((?:)|(?:\?)|(?:\(\?\))|(?:!)|(?:~))(?:\s?)([%\w\s-]+)(?:\s{0,})(?:((?:=>)|(?:==)|(?:=)|(?:<=)|(?:>=)|(?:<)|(?:>)|(?:))(?:\s{0,})((\d+\.\d+\.\d+)|(\d+\.\d+)))?$", + ) + .unwrap() + }); + &RE +} + +// --------------------------------------------------------------------------- +// Mod name extraction +// --------------------------------------------------------------------------- + +pub fn extract_mod_name(url_or_name: &str) -> Result { + let re = Regex::new(r"(?i)^https?://mods\.factorio\.com/mod/([^\s/#?]{1,})").unwrap(); + if let Some(caps) = re.captures(url_or_name) { + return Ok(caps[1].to_string()); + } + + let re2 = + Regex::new(r"(?i)^https?://mods\.factorio\.com/mods/([^\s/]{1,})/([^\s/#?]{1,})").unwrap(); + if let Some(caps) = re2.captures(url_or_name) { + return Ok(caps[2].to_string()); + } + + if !url_or_name.contains('/') && !url_or_name.starts_with("http") { + return Ok(url_or_name.to_string()); + } + + bail!("Could not extract mod name from: {url_or_name}") +} + +// --------------------------------------------------------------------------- +// API helpers +// --------------------------------------------------------------------------- + +pub fn fetch_modinfo(mod_name: &str) -> Result { + let url = format!("{MODINFO_API}?id={mod_name}"); + let resp = ureq::get(&url) + .header("Accept", "application/json") + .call() + .with_context(|| format!("Failed to fetch mod info for {mod_name}"))?; + + if resp.status().as_u16() == 404 { + bail!("Mod '{mod_name}' not found on the portal"); + } + + let body_text = resp + .into_body() + .read_to_string() + .with_context(|| format!("Failed to read response body for {mod_name}"))?; + + let modinfo: ModInfo = serde_json::from_str(&body_text) + .with_context(|| format!("Failed to parse mod info JSON for {mod_name}"))?; + + if modinfo.releases.is_empty() { + bail!("No releases found for '{mod_name}'"); + } + + Ok(modinfo) +} + +/// Fetch available versions for a mod. Returns (version, display_label) pairs. +#[cfg(feature = "gui")] +pub fn fetch_versions(mod_name: &str) -> Result> { + let modinfo = fetch_modinfo(mod_name)?; + let total = modinfo.releases.len(); + + let mut versions: Vec<(String, String)> = modinfo + .releases + .iter() + .enumerate() + .filter_map(|(i, r)| { + let v = r.version.clone(); + if v.is_empty() { + return None; + } + let is_latest = i == total - 1; + let factorio_ver = r + .info_json + .as_ref() + .and_then(|j| j.dependencies.first()) + .and_then(|d| { + parse_dependency_string(d).and_then(|dep| { + if dep.dep_type == DepType::Base && !dep.name.is_empty() { + Some(dep.name) + } else { + None + } + }) + }) + .unwrap_or_default(); + + let mut label = v.clone(); + if is_latest { + label.push_str(" (latest)"); + } + if !factorio_ver.is_empty() { + label.push_str(&format!(" for {}", factorio_ver)); + } + Some((v, label)) + }) + .collect(); + + versions.reverse(); + Ok(versions) +} + +pub fn find_release<'a>(modinfo: &'a ModInfo, target_version: Option<&str>) -> Option<&'a Release> { + match target_version { + Some(v) => modinfo.releases.iter().find(|r| r.version == v), + None => modinfo.releases.last(), + } +} + +// --------------------------------------------------------------------------- +// Dependency parsing +// --------------------------------------------------------------------------- + +pub fn parse_dependency_string(input: &str) -> Option { + let re = dep_regex(); + let caps = re.captures(input)?; + + let name = caps[2].trim().to_string(); + + if name.eq_ignore_ascii_case("base") { + return Some(Dependency { + dep_type: DepType::Base, + name: "base".into(), + }); + } + + let dep_type = match &caps[1] { + "?" | "(?)" => DepType::Optional, + "!" => DepType::Incompatible, + _ => DepType::Required, + }; + + Some(Dependency { + dep_type, + name: name.trim().to_string(), + }) +} + +pub fn parse_dependencies(deps: &[String]) -> (Vec, Vec, Vec) { + let mut required = Vec::new(); + let mut optional = Vec::new(); + let mut incompatible = Vec::new(); + + for dep_str in deps.iter().rev() { + if let Some(dep) = parse_dependency_string(dep_str) { + match dep.dep_type { + DepType::Base => {} + DepType::Required => required.push(dep), + DepType::Optional => optional.push(dep), + DepType::Incompatible => incompatible.push(dep), + } + } + } + + (required, optional, incompatible) +} + +// --------------------------------------------------------------------------- +// GUI download (channel-based) +// --------------------------------------------------------------------------- + +#[cfg(feature = "gui")] +fn gui_download_file( + url: &str, + file_path: &Path, + display_name: &str, + tx: &mpsc::Sender, +) -> Result<()> { + let resp = ureq::get(url) + .call() + .with_context(|| format!("Failed to download {display_name}"))?; + + let status = resp.status().as_u16(); + if status == 404 { + bail!("File not found on storage: {display_name}"); + } + if !(200..300).contains(&status) { + bail!("HTTP {status} while downloading {display_name}"); + } + + let total_size: u64 = resp + .headers() + .get("content-length") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + + let _ = tx.send(Event::DownloadStart { + name: display_name.to_string(), + total_bytes: total_size, + }); + + let mut reader = resp.into_body().into_reader(); + let mut file = fs::File::create(file_path) + .with_context(|| format!("Failed to create file {}", file_path.display()))?; + + let mut buf = [0u8; 256 * 1024]; + let mut downloaded: u64 = 0; + loop { + let n = reader + .read(&mut buf) + .with_context(|| format!("Read error while downloading {display_name}"))?; + if n == 0 { + break; + } + file.write_all(&buf[..n]) + .with_context(|| format!("Write error for {}", file_path.display()))?; + downloaded += n as u64; + let _ = tx.send(Event::DownloadProgress { + name: display_name.to_string(), + bytes: downloaded, + }); + } + + let _ = tx.send(Event::DownloadComplete { + name: display_name.to_string(), + }); + Ok(()) +} + +#[cfg(feature = "gui")] +fn gui_download_mod_with_deps( + mod_name: &str, + output: &Path, + target_version: Option<&str>, + include_optional: bool, + downloaded: &mut HashSet, + analyzed: &mut HashSet, + tx: &mpsc::Sender, +) -> Result<()> { + if analyzed.contains(mod_name) { + return Ok(()); + } + + if mod_name == "space-age" { + let _ = tx.send(Event::Log(format!( + "Skipping reserved dependency '{mod_name}'" + ))); + return Ok(()); + } + + let modinfo = fetch_modinfo(mod_name)?; + + let release = find_release(&modinfo, target_version) + .with_context(|| format!("Version not found for '{mod_name}'"))?; + + let version = &release.version; + let _ = tx.send(Event::Log(format!("Processing: {mod_name} v{version}"))); + analyzed.insert(mod_name.to_string()); + + let file_name = format!("{mod_name}_{version}.zip"); + let file_path = output.join(&file_name); + + if !file_path.exists() { + fs::create_dir_all(output)?; + let download_url = format!("{DOWNLOAD_BASE}/{mod_name}/{version}.zip"); + + if let Err(e) = gui_download_file(&download_url, &file_path, &file_name, tx) { + let _ = tx.send(Event::DownloadError { + name: file_name.clone(), + error: e.to_string(), + }); + return Err(e); + } + downloaded.insert(mod_name.to_string()); + } else { + let _ = tx.send(Event::Log(format!("Already downloaded: {file_name}"))); + } + + if let Some(info_json) = &release.info_json { + let (required, optional, _incompatible) = parse_dependencies(&info_json.dependencies); + + let mut deps = required; + if include_optional { + deps.extend(optional); + } + + for dep in &deps { + if dep.name == "base" || downloaded.contains(&dep.name) || analyzed.contains(&dep.name) + { + continue; + } + let _ = tx.send(Event::Log(format!("-> Fetching dependency: {}", dep.name))); + if let Err(e) = gui_download_mod_with_deps( + &dep.name, + output, + None, + include_optional, + downloaded, + analyzed, + tx, + ) { + let _ = tx.send(Event::Log(format!( + "Error processing dependency {}: {}", + dep.name, e + ))); + } + } + } + + Ok(()) +} + +/// Entry point for GUI downloads. Runs the full pipeline and sends events via the channel. +#[cfg(feature = "gui")] +pub fn run_gui_download( + mod_url: &str, + output: &Path, + target_version: Option, + include_optional: bool, + tx: mpsc::Sender, +) { + let result = (|| -> Result<()> { + let mod_name = extract_mod_name(mod_url)?; + let _ = tx.send(Event::Log(format!("Starting download: {mod_name}"))); + + let mut downloaded = HashSet::new(); + let mut analyzed = HashSet::new(); + + gui_download_mod_with_deps( + &mod_name, + output, + target_version.as_deref(), + include_optional, + &mut downloaded, + &mut analyzed, + &tx, + )?; + + Ok(()) + })(); + + match result { + Ok(()) => { + let _ = tx.send(Event::Finished { + success: true, + message: "All mods downloaded successfully.".into(), + }); + } + Err(e) => { + let _ = tx.send(Event::Finished { + success: false, + message: format!("Download failed: {e}"), + }); + } + } +} + +// --------------------------------------------------------------------------- +// CLI download (prints progress to stdout) +// --------------------------------------------------------------------------- + +#[cfg(not(feature = "gui"))] +fn cli_download_file(url: &str, file_path: &Path, display_name: &str) -> Result<()> { + let resp = ureq::get(url) + .call() + .with_context(|| format!("Failed to download {display_name}"))?; + + let status = resp.status().as_u16(); + if status == 404 { + bail!("File not found on storage: {display_name}"); + } + if !(200..300).contains(&status) { + bail!("HTTP {status} while downloading {display_name}"); + } + + let total_size: u64 = resp + .headers() + .get("content-length") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + + println!( + " Downloading: {display_name} ({:.1} MB)", + total_size as f64 / 1_048_576.0 + ); + + let mut reader = resp.into_body().into_reader(); + let mut file = fs::File::create(file_path) + .with_context(|| format!("Failed to create file {}", file_path.display()))?; + + let mut buf = [0u8; 256 * 1024]; + let mut downloaded: u64 = 0; + let mut last_pct: u32 = 0; + loop { + let n = reader + .read(&mut buf) + .with_context(|| format!("Read error while downloading {display_name}"))?; + if n == 0 { + break; + } + file.write_all(&buf[..n]) + .with_context(|| format!("Write error for {}", file_path.display()))?; + downloaded += n as u64; + + if total_size > 0 { + let pct = (downloaded * 100 / total_size) as u32; + if pct / 10 != last_pct / 10 { + last_pct = pct; + print!("\r {} {}%", display_name, pct); + let _ = std::io::Write::flush(&mut std::io::stdout()); + } + } + } + println!("\r {} Done.", display_name); + Ok(()) +} + +#[cfg(not(feature = "gui"))] +fn cli_download_mod_with_deps( + mod_name: &str, + output: &Path, + target_version: Option<&str>, + include_optional: bool, + downloaded: &mut HashSet, + analyzed: &mut HashSet, +) -> Result<()> { + if analyzed.contains(mod_name) { + return Ok(()); + } + + if mod_name == "space-age" { + println!("Skipping reserved dependency '{mod_name}'"); + return Ok(()); + } + + let modinfo = fetch_modinfo(mod_name)?; + + let release = find_release(&modinfo, target_version) + .with_context(|| format!("Version not found for '{mod_name}'"))?; + + let version = &release.version; + println!("Processing: {mod_name} v{version}"); + analyzed.insert(mod_name.to_string()); + + let file_name = format!("{mod_name}_{version}.zip"); + let file_path = output.join(&file_name); + + if !file_path.exists() { + fs::create_dir_all(output)?; + let download_url = format!("{DOWNLOAD_BASE}/{mod_name}/{version}.zip"); + + if let Err(e) = cli_download_file(&download_url, &file_path, &file_name) { + eprintln!("Error downloading {file_name}: {e}"); + return Err(e); + } + downloaded.insert(mod_name.to_string()); + } else { + println!("Already downloaded: {file_name}"); + } + + if let Some(info_json) = &release.info_json { + let (required, optional, _incompatible) = parse_dependencies(&info_json.dependencies); + + let mut deps = required; + if include_optional { + deps.extend(optional); + } + + for dep in &deps { + if dep.name == "base" || downloaded.contains(&dep.name) || analyzed.contains(&dep.name) + { + continue; + } + println!("-> Fetching dependency: {}", dep.name); + if let Err(e) = cli_download_mod_with_deps( + &dep.name, + output, + None, + include_optional, + downloaded, + analyzed, + ) { + eprintln!("Error processing dependency {}: {}", dep.name, e); + } + } + } + + Ok(()) +} + +/// Entry point for CLI downloads. Prints progress to stdout. +#[cfg(not(feature = "gui"))] +pub fn run_cli_download( + mod_url: &str, + output: &Path, + target_version: Option<&str>, + include_optional: bool, +) -> Result<()> { + let mod_name = extract_mod_name(mod_url)?; + println!("Starting download: {mod_name}"); + println!("Output directory: {}", output.display()); + + let mut downloaded = HashSet::new(); + let mut analyzed = HashSet::new(); + + cli_download_mod_with_deps( + &mod_name, + output, + target_version, + include_optional, + &mut downloaded, + &mut analyzed, + )?; + + println!("\nAll mods downloaded successfully."); + Ok(()) +} 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 @@ +use std::path::PathBuf; +use std::sync::mpsc; +use std::sync::mpsc::TryRecvError; +use std::sync::{Arc, Mutex}; +use std::thread; + +use egui::{Color32, ProgressBar, ScrollArea, TextEdit}; +use rfd::FileDialog; + +use crate::downloader::{self, Event}; + +pub struct App { + mod_url: String, + output_path: String, + target_version: String, + include_optional: bool, + + versions: Vec<(String, String)>, + loading_versions: bool, + version_handle: Option>>>>, + + downloading: bool, + finished: bool, + + downloads: Vec, + log_entries: Vec, + + receiver: Option>, +} + +struct DownloadState { + name: String, + total_bytes: u64, + downloaded_bytes: u64, + complete: bool, + error: Option, +} + +struct LogEntry { + text: String, + is_error: bool, +} + +impl App { + pub fn new() -> Self { + Self { + mod_url: String::new(), + output_path: std::env::current_dir() + .map(|p| p.display().to_string()) + .unwrap_or_default(), + target_version: String::new(), + include_optional: false, + versions: Vec::new(), + loading_versions: false, + version_handle: None, + downloading: false, + finished: false, + downloads: Vec::new(), + log_entries: Vec::new(), + receiver: None, + } + } + + fn log(&mut self, text: String, is_error: bool) { + self.log_entries.push(LogEntry { text, is_error }); + if self.log_entries.len() > 500 { + self.log_entries.remove(0); + } + } + + fn find_or_create_download(&mut self, name: &str) -> &mut DownloadState { + if let Some(pos) = self.downloads.iter().position(|d| d.name == name) { + &mut self.downloads[pos] + } else { + self.downloads.push(DownloadState { + name: name.to_string(), + total_bytes: 0, + downloaded_bytes: 0, + complete: false, + error: None, + }); + self.downloads.last_mut().unwrap() + } + } + + fn poll_events(&mut self) { + let mut events = Vec::new(); + if let Some(ref rx) = self.receiver { + loop { + match rx.try_recv() { + Ok(event) => events.push(event), + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + self.downloading = false; + self.receiver = None; + break; + } + } + } + } + for event in events { + self.handle_event(event); + } + } + + fn handle_event(&mut self, event: Event) { + match event { + Event::Log(text) => self.log(text, false), + Event::DownloadStart { name, total_bytes } => { + self.log(format!("Downloading: {name}"), false); + let dl = self.find_or_create_download(&name); + dl.total_bytes = total_bytes; + dl.downloaded_bytes = 0; + dl.complete = false; + dl.error = None; + } + Event::DownloadProgress { name, bytes } => { + let dl = self.find_or_create_download(&name); + dl.downloaded_bytes = bytes; + } + Event::DownloadComplete { name } => { + let dl = self.find_or_create_download(&name); + dl.complete = true; + dl.downloaded_bytes = dl.total_bytes; + self.log(format!("Downloaded: {name}"), false); + } + Event::DownloadError { name, error } => { + let dl = self.find_or_create_download(&name); + dl.error = Some(error.clone()); + self.log(format!("Error: {name} - {error}"), true); + } + Event::Finished { success, message } => { + self.log(message.clone(), !success); + self.downloading = false; + self.finished = true; + self.receiver = None; + } + } + } + + fn start_download(&mut self) { + let mod_url = self.mod_url.trim().to_string(); + if mod_url.is_empty() { + return; + } + + let output = PathBuf::from(&self.output_path); + let target_version = if self.target_version.trim().is_empty() { + None + } else { + Some(self.target_version.trim().to_string()) + }; + let include_optional = self.include_optional; + + let (tx, rx) = mpsc::channel(); + self.receiver = Some(rx); + self.downloading = true; + self.finished = false; + self.downloads.clear(); + self.log_entries.clear(); + + self.log("Starting download...".into(), false); + + thread::spawn(move || { + downloader::run_gui_download(&mod_url, &output, target_version, include_optional, tx); + }); + } + + fn load_versions(&mut self) { + let mod_url = self.mod_url.trim().to_string(); + if mod_url.is_empty() { + return; + } + + self.loading_versions = true; + let result: Arc>>> = Arc::new(Mutex::new(None)); + self.version_handle = Some(result.clone()); + + thread::spawn(move || { + let name = downloader::extract_mod_name(&mod_url); + let versions = match name { + Ok(name) => downloader::fetch_versions(&name).ok().unwrap_or_default(), + Err(_) => Vec::new(), + }; + *result.lock().unwrap() = Some(versions); + }); + } + + fn browse_output(&mut self) { + if let Some(path) = FileDialog::new().pick_folder() { + self.output_path = path.display().to_string(); + } + } +} + +impl eframe::App for App { + fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.poll_events(); + + // Check version loading result + if self.loading_versions { + let result = if let Some(ref handle) = self.version_handle { + handle.lock().ok().and_then(|mut g| g.take()) + } else { + None + }; + if let Some(versions) = result { + self.versions = versions; + self.loading_versions = false; + self.version_handle = None; + } + } + + if self.downloading { + ctx.request_repaint(); + } + } + + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + ui.heading("Factorio Mod Downloader"); + ui.separator(); + + // ── Input section ── + ui.horizontal(|ui| { + ui.label("Mod URL:"); + ui.add_sized( + [ui.available_width() - 10.0, 20.0], + TextEdit::singleline(&mut self.mod_url) + .hint_text("https://mods.factorio.com/mod/... or mod name"), + ); + }); + + ui.horizontal(|ui| { + ui.label("Output:"); + ui.add_sized( + [ui.available_width() - 80.0, 20.0], + TextEdit::singleline(&mut self.output_path).hint_text("Download directory"), + ); + if ui.button("Browse").clicked() { + self.browse_output(); + } + }); + + ui.horizontal(|ui| { + ui.label("Version:"); + let version_enabled = !self.downloading && !self.versions.is_empty(); + ui.add_enabled_ui(version_enabled, |ui| { + egui::ComboBox::from_id_salt("version_combo") + .width(200.0) + .selected_text(if self.target_version.is_empty() { + "Latest" + } else { + &self.target_version + }) + .show_ui(ui, |ui| { + if ui.selectable_label(false, "Latest").clicked() { + self.target_version.clear(); + } + for (ver, label) in &self.versions { + if ui.selectable_label(self.target_version == *ver, label).clicked() { + self.target_version = ver.clone(); + } + } + }); + }); + + if ui + .add_enabled( + !self.downloading && !self.loading_versions, + egui::Button::new(if self.loading_versions { + "Loading..." + } else { + "Load Versions" + }), + ) + .clicked() + { + self.load_versions(); + } + + ui.checkbox(&mut self.include_optional, "Include optional deps"); + }); + + ui.add_space(8.0); + + // ── Download button ── + let can_download = !self.downloading + && !self.mod_url.trim().is_empty() + && !self.output_path.is_empty(); + + if ui + .add_enabled( + can_download, + egui::Button::new(if self.downloading { + "Downloading..." + } else if self.finished { + "Download Again" + } else { + "Start Download" + }), + ) + .clicked() + { + self.start_download(); + } + + ui.add_space(8.0); + + // ── Progress section ── + if !self.downloads.is_empty() { + egui::CollapsingHeader::new("Downloads") + .default_open(true) + .show(ui, |ui| { + for dl in &self.downloads { + let percentage = if dl.total_bytes > 0 { + dl.downloaded_bytes as f32 / dl.total_bytes as f32 + } else { + 0.0 + }; + + ui.horizontal(|ui| { + let status = if dl.complete { + "[OK]" + } else if dl.error.is_some() { + "[ERR]" + } else { + "[>>]" + }; + ui.label(format!("{status} {}", dl.name)); + + if let Some(ref err) = dl.error { + ui.colored_label(Color32::RED, format!("Error: {err}")); + } else if dl.total_bytes > 0 { + ui.label(format!( + "{:.1} / {:.1} MB", + dl.downloaded_bytes as f64 / 1_048_576.0, + dl.total_bytes as f64 / 1_048_576.0 + )); + } + }); + + let bar = + ProgressBar::new(percentage).desired_width(ui.available_width()); + ui.add(bar); + } + }); + } + + ui.add_space(8.0); + + // ── Log section ── + egui::CollapsingHeader::new("Log") + .default_open(true) + .show(ui, |ui| { + ScrollArea::vertical() + .max_height(300.0) + .stick_to_bottom(true) + .show(ui, |ui| { + for entry in &self.log_entries { + if entry.is_error { + ui.colored_label(Color32::RED, &entry.text); + } else { + ui.label(&entry.text); + } + } + }); + }); + } +} 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 @@ +mod downloader; + +#[cfg(feature = "gui")] +mod gui; + +// --------------------------------------------------------------------------- +// GUI entry point (when the "gui" feature is enabled) +// --------------------------------------------------------------------------- +#[cfg(feature = "gui")] +fn main() -> eframe::Result<()> { + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_inner_size([800.0, 650.0]) + .with_min_inner_size([600.0, 400.0]), + ..Default::default() + }; + + eframe::run_native( + "Factorio Mod Downloader", + options, + Box::new(|_cc| Ok(Box::new(gui::App::new()))), + ) +} + +// --------------------------------------------------------------------------- +// CLI entry point (when the "gui" feature is disabled) +// --------------------------------------------------------------------------- +#[cfg(not(feature = "gui"))] +fn main() { + let args: Vec = std::env::args().collect(); + + if args.len() < 2 { + eprintln!("Factorio Mod Downloader (CLI)"); + eprintln!(); + eprintln!( + "Usage: {} [output_directory] [options]", + args[0] + ); + eprintln!(); + eprintln!("Options:"); + eprintln!(" --version Download a specific version instead of latest"); + eprintln!(" --include-optional Also download optional dependencies"); + eprintln!(); + eprintln!("Examples:"); + eprintln!(" {} my-cool-mod", args[0]); + eprintln!( + " {} https://mods.factorio.com/mod/my-cool-mod ./mods", + args[0] + ); + eprintln!( + " {} my-cool-mod ./mods --version 2.1.0 --include-optional", + args[0] + ); + std::process::exit(1); + } + + let mod_url = &args[1]; + + let output = if args.len() >= 3 && !args[2].starts_with("--") { + std::path::PathBuf::from(&args[2]) + } else { + std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")) + }; + + let mut target_version: Option<&str> = None; + let mut include_optional = false; + + let mut i = if args.len() >= 3 && !args[2].starts_with("--") { + 3 + } else { + 2 + }; + while i < args.len() { + match args[i].as_str() { + "--version" => { + if i + 1 < args.len() { + target_version = Some(&args[i + 1]); + i += 1; + } else { + eprintln!("Error: --version requires a value"); + std::process::exit(1); + } + } + "--include-optional" => { + include_optional = true; + } + other => { + eprintln!("Unknown option: {other}"); + std::process::exit(1); + } + } + i += 1; + } + + match downloader::run_cli_download(mod_url, &output, target_version, include_optional) { + Ok(()) => {} + Err(e) => { + eprintln!("Error: {:#}", e); + std::process::exit(1); + } + } +} -- cgit v1.2.3