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); } } }); }); } }