summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/downloader.rs589
-rw-r--r--src/gui.rs369
-rw-r--r--src/main.rs102
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 @@
1use std::collections::HashSet;
2use std::fs;
3use std::io::{Read, Write};
4use std::path::Path;
5
6use anyhow::{bail, Context, Result};
7use regex::Regex;
8use serde::Deserialize;
9
10// ---------------------------------------------------------------------------
11// Public event type for GUI progress reporting
12// ---------------------------------------------------------------------------
13
14#[cfg(feature = "gui")]
15use std::sync::mpsc;
16
17#[cfg(feature = "gui")]
18#[derive(Debug, Clone)]
19pub 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)]
33pub struct ModInfo {
34 pub releases: Vec<Release>,
35}
36
37#[derive(Deserialize, Clone)]
38pub struct Release {
39 pub version: String,
40 pub info_json: Option<InfoJson>,
41}
42
43#[derive(Deserialize, Clone)]
44pub struct InfoJson {
45 #[serde(default)]
46 pub dependencies: Vec<String>,
47}
48
49// ---------------------------------------------------------------------------
50// Dependency model
51// ---------------------------------------------------------------------------
52
53#[derive(Debug, Clone, PartialEq)]
54pub enum DepType {
55 Base,
56 Required,
57 Optional,
58 Incompatible,
59}
60
61#[derive(Debug, Clone)]
62pub struct Dependency {
63 pub dep_type: DepType,
64 pub name: String,
65}
66
67// ---------------------------------------------------------------------------
68// Constants
69// ---------------------------------------------------------------------------
70
71pub const MODINFO_API: &str = "https://re146.dev/factorio/mods/modinfo";
72pub const DOWNLOAD_BASE: &str = "https://mods-storage.re146.dev";
73
74fn 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
88pub 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
111pub 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")]
139pub 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
183pub 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
194pub 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
219pub 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")]
243fn 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")]
302fn 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")]
386pub 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"))]
434fn 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"))]
491fn 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"))]
565pub 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 @@
1use std::path::PathBuf;
2use std::sync::mpsc;
3use std::sync::mpsc::TryRecvError;
4use std::sync::{Arc, Mutex};
5use std::thread;
6
7use egui::{Color32, ProgressBar, ScrollArea, TextEdit};
8use rfd::FileDialog;
9
10use crate::downloader::{self, Event};
11
12pub 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
31struct DownloadState {
32 name: String,
33 total_bytes: u64,
34 downloaded_bytes: u64,
35 complete: bool,
36 error: Option<String>,
37}
38
39struct LogEntry {
40 text: String,
41 is_error: bool,
42}
43
44impl 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
196impl 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 @@
1mod downloader;
2
3#[cfg(feature = "gui")]
4mod gui;
5
6// ---------------------------------------------------------------------------
7// GUI entry point (when the "gui" feature is enabled)
8// ---------------------------------------------------------------------------
9#[cfg(feature = "gui")]
10fn 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"))]
29fn 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}