1use crate::error::ThermiteError;
2use crate::model::EnabledMods;
3use crate::model::InstalledMod;
4use crate::model::Manifest;
5use crate::model::Mod;
6
7use regex::Regex;
8use std::fmt::Debug;
9use std::fs;
10use std::ops::Deref;
11use std::path::Path;
12use std::path::PathBuf;
13use std::sync::LazyLock;
14
15use tracing::trace;
16use tracing::{debug, error};
17
18pub(crate) type ModString = (String, String, String);
19
20#[derive(Debug, Clone)]
21pub(crate) struct TempDir {
22 pub(crate) path: PathBuf,
23}
24
25impl TempDir {
26 pub fn create(path: impl AsRef<Path>) -> Result<Self, std::io::Error> {
29 fs::create_dir_all(path.as_ref())?;
30 Ok(TempDir {
31 path: path.as_ref().to_path_buf(),
32 })
33 }
34}
35
36impl AsRef<Path> for TempDir {
37 fn as_ref(&self) -> &Path {
38 &self.path
39 }
40}
41
42impl Deref for TempDir {
43 type Target = Path;
44
45 fn deref(&self) -> &Self::Target {
46 &self.path
47 }
48}
49
50impl Drop for TempDir {
51 fn drop(&mut self) {
52 if let Err(e) = fs::remove_dir_all(&self.path) {
53 error!(
54 "Error removing temp directory at '{}': {}",
55 self.path.display(),
56 e
57 );
58 }
59 }
60}
61
62pub fn resolve_deps(deps: &[impl AsRef<str>], index: &[Mod]) -> Result<Vec<Mod>, ThermiteError> {
69 let mut valid = vec![];
70 for dep in deps {
71 let dep_name = dep
72 .as_ref()
73 .split('-')
74 .nth(1)
75 .ok_or_else(|| ThermiteError::Dep(dep.as_ref().into()))?;
76
77 if dep_name.to_lowercase() == "northstar" {
78 debug!("Skip unfiltered Northstar dependency");
79 continue;
80 }
81
82 if let Some(d) = index.iter().find(|f| f.name == dep_name) {
83 valid.push(d.clone());
84 } else {
85 return Err(ThermiteError::Dep(dep.as_ref().into()));
86 }
87 }
88 Ok(valid)
89}
90
91pub fn get_enabled_mods(dir: impl AsRef<Path>) -> Result<EnabledMods, ThermiteError> {
98 let path = dir.as_ref().canonicalize()?.join("enabledmods.json");
99 if path.exists() {
100 let raw = fs::read_to_string(&path)?;
101 let mut mods: EnabledMods = serde_json::from_str(&raw)?;
102 mods.set_path(path);
103 Ok(mods)
104 } else {
105 Err(ThermiteError::MissingFile(Box::new(path)))
106 }
107}
108
109pub fn find_mods(dir: impl AsRef<Path>) -> Result<Vec<InstalledMod>, ThermiteError> {
118 let mut res = vec![];
119 let dir = dir.as_ref().canonicalize()?;
120 debug!("Finding mods in '{}'", dir.display());
121 for child in dir.read_dir()? {
122 let child = child?;
123 if !child.file_type()?.is_dir() {
124 debug!("Skipping file {}", child.path().display());
125 continue;
126 }
127
128 let path = child.path().join("manifest.json");
129 let manifest = if path.try_exists()? {
130 let raw = fs::read_to_string(&path)?;
131 let Ok(parsed) = serde_json::from_str(&raw) else {
132 error!("Error parsing {}", path.display());
133 continue;
134 };
135 parsed
136 } else {
137 continue;
138 };
139
140 if let Some(submods) = get_submods(&manifest, child.path()) {
141 debug!(
142 "Found {} submods in {}",
143 submods.len(),
144 child.path().display()
145 );
146 trace!("{:#?}", submods);
147 let modstring =
148 parse_modstring(child.file_name().to_str().ok_or(ThermiteError::UTF8)?)?;
149 res.append(
150 &mut submods
151 .into_iter()
152 .map(|mut m| {
153 m.author.clone_from(&modstring.0);
154
155 m
156 })
157 .collect(),
158 );
159 } else {
160 debug!("No mods in {}", child.path().display());
161 }
162 }
163
164 Ok(res)
165}
166
167fn get_submods(manifest: &Manifest, dir: impl AsRef<Path>) -> Option<Vec<InstalledMod>> {
168 let dir = dir.as_ref();
169 debug!("Searching for submods in {}", dir.display());
170 if !dir.is_dir() {
171 debug!("Wasn't a directory, aborting");
172 return None;
173 }
174
175 let mut mods = vec![];
176 for child in dir.read_dir().ok()? {
177 let Ok(child) = child else { continue };
178 match child.file_type() {
179 Ok(ty) => {
180 if ty.is_dir() {
181 let Some(mut next) = get_submods(manifest, child.path()) else {
182 continue;
183 };
184 mods.append(&mut next);
185 } else {
186 trace!("Is file {:?} mod.json?", child.file_name());
187 if child.file_name() == "mod.json" {
188 trace!("Yes");
189 let Ok(file) = fs::read_to_string(child.path()) else {
190 continue;
191 };
192 match json5::from_str(&file) {
193 Ok(mod_json) => mods.push(InstalledMod {
194 author: String::new(),
195 manifest: manifest.clone(),
196 mod_json,
197 path: dir.to_path_buf(),
198 }),
199 Err(e) => {
200 error!("Error parsing JSON in {}: {e}", child.path().display());
201 }
202 }
203 } else {
204 trace!("No");
205 }
206 }
207 }
208 Err(e) => {
209 error!("Error {e}");
210 }
211 }
212 }
213
214 if mods.is_empty() {
215 None
216 } else {
217 Some(
218 mods.into_iter()
219 .map(|mut m| {
220 if m.path.ends_with("/mods") {
221 m.path.pop();
222 }
223
224 m
225 })
226 .collect(),
227 )
228 }
229}
230
231pub static RE: LazyLock<Regex> =
232 LazyLock::new(|| Regex::new(r"^(\w+)-(\w+)-(\d+\.\d+\.\d+)$").expect("regex"));
233
234pub fn parse_modstring(input: impl AsRef<str>) -> Result<ModString, ThermiteError> {
240 debug!("Parsing modstring {}", input.as_ref());
241 if let Some(captures) = RE.captures(input.as_ref()) {
242 let author = captures
243 .get(1)
244 .ok_or_else(|| ThermiteError::Name(input.as_ref().into()))?
245 .as_str()
246 .to_owned();
247
248 let name = captures
249 .get(2)
250 .ok_or_else(|| ThermiteError::Name(input.as_ref().into()))?
251 .as_str()
252 .to_owned();
253
254 let version = captures
255 .get(3)
256 .ok_or_else(|| ThermiteError::Name(input.as_ref().into()))?
257 .as_str()
258 .to_owned();
259
260 Ok((author, name, version))
261 } else {
262 Err(ThermiteError::Name(input.as_ref().into()))
263 }
264}
265
266#[inline]
268#[must_use]
269pub fn validate_modstring(input: impl AsRef<str>) -> bool {
270 RE.is_match(input.as_ref())
271}
272
273#[cfg(feature = "steam")]
274pub(crate) mod steam {
275 use std::path::PathBuf;
276 use steamlocate::SteamDir;
277
278 use crate::TITANFALL2_STEAM_ID;
279
280 #[must_use]
282 #[inline]
283 pub fn steam_dir() -> Result<PathBuf, steamlocate::Error> {
284 SteamDir::locate().map(|dir| dir.path().to_path_buf())
285 }
286
287 #[must_use]
289 pub fn steam_libraries() -> Result<Vec<PathBuf>, steamlocate::Error> {
290 SteamDir::locate()?.library_paths()
291 }
292
293 #[must_use]
295 pub fn titanfall2_dir() -> Result<PathBuf, steamlocate::Error> {
296 let steamdir = SteamDir::locate()?;
297 let Some((app, lib)) = steamdir.find_app(TITANFALL2_STEAM_ID)? else {
298 return Err(steamlocate::Error::MissingExpectedApp {
299 app_id: TITANFALL2_STEAM_ID,
300 });
301 };
302
303 Ok(lib.resolve_app_dir(&app))
304 }
305}
306
307#[cfg(all(target_os = "linux", feature = "proton"))]
308pub(crate) mod proton {
310 use flate2::read::GzDecoder;
311 use std::{
312 io::{Read, Write},
313 path::Path,
314 };
315 use tar::Archive;
316 use tracing::debug;
317 use ureq::ResponseExt;
318
319 use crate::{
320 core::manage::download,
321 error::{Result, ThermiteError},
322 };
323 const BASE_URL: &str = "https://github.com/R2NorthstarTools/NorthstarProton/releases/";
324
325 pub fn latest_release() -> Result<String> {
331 let url = format!("{}latest", BASE_URL);
332 let res = ureq::get(&url).call()?;
333 let location = res.get_uri();
334 debug!("{url} redirected to {location}");
335
336 Ok(location
337 .path()
338 .split('/')
339 .last()
340 .ok_or_else(|| ThermiteError::Unknown("Malformed location URL".into()))?
341 .to_owned())
342 }
343
344 pub fn download_ns_proton(tag: impl AsRef<str>, output: impl Write) -> Result<u64> {
347 let url = format!(
348 "{}download/{}/NorthstarProton{}.tar.gz",
349 BASE_URL,
350 tag.as_ref(),
351 tag.as_ref().trim_matches('v')
352 );
353 download(output, url)
354 }
355
356 pub fn install_ns_proton(archive: impl Read, dest: impl AsRef<Path>) -> Result<()> {
362 let mut tarball = Archive::new(GzDecoder::new(archive));
363 tarball.unpack(dest)?;
364
365 Ok(())
366 }
367
368 #[cfg(test)]
369 mod test {
370 use std::io::Cursor;
371
372 use crate::core::utils::TempDir;
373
374 use super::latest_release;
375
376 #[test]
377 fn get_latest_proton_version() {
378 let res = latest_release();
379 assert!(res.is_ok());
380 }
381
382 #[test]
383 fn extract_proton() {
384 let dir =
385 TempDir::create(std::env::temp_dir().join("NSPROTON_TEST")).expect("temp dir");
386 let archive = include_bytes!("test_media/NorthstarProton8-28.tar.gz");
387 let cursor = Cursor::new(archive);
388 let res = super::install_ns_proton(cursor, &dir);
389 assert!(res.is_ok());
390
391 let extracted = dir.join("NorthstarProton8-28.txt");
392 assert!(extracted.exists());
393 assert_eq!(
394 std::fs::read_to_string(extracted).expect("read file"),
395 "The real proton was too big to use as test media\n"
396 );
397 }
398 }
399}
400
401#[cfg(test)]
402mod test {
403 use std::{
404 collections::BTreeMap,
405 fs,
406 path::{Path, PathBuf},
407 };
408
409 use crate::{error::ThermiteError, model::Mod};
410
411 use super::{
412 find_mods, get_enabled_mods, parse_modstring, resolve_deps, validate_modstring, TempDir,
413 };
414
415 #[test]
416 fn temp_dir_deletes_on_drop() {
417 let test_folder = "temp_dir";
418 {
419 let temp_dir = TempDir::create(test_folder);
420 assert!(temp_dir.is_ok());
421
422 if let Ok(dir) = temp_dir {
423 let exists = dir
424 .try_exists()
425 .expect("Unable to check if temp dir exists");
426 assert!(exists);
427 }
428 }
429
430 let path = PathBuf::from(test_folder);
431 let exists = path
432 .try_exists()
433 .expect("Unable to check if temp dir exists");
434 assert!(!exists);
435 }
436
437 #[test]
438 fn fail_find_enabledmods() {
439 let test_folder = "fail_enabled_mods_test";
440 let temp_dir = TempDir::create(test_folder).unwrap();
441 if let Err(ThermiteError::MissingFile(path)) = get_enabled_mods(&temp_dir) {
442 assert_eq!(
443 *path,
444 temp_dir.canonicalize().unwrap().join("enabledmods.json")
445 );
446 } else {
447 panic!("enabledmods.json should not exist");
448 }
449 }
450
451 #[test]
452 fn fail_parse_enabledmods() {
453 let test_folder = "parse_enabled_mods_test";
454 let temp_dir = TempDir::create(test_folder).unwrap();
455 fs::write(temp_dir.join("enabledmods.json"), b"invalid json").unwrap();
456 if let Err(ThermiteError::Json(_)) = get_enabled_mods(temp_dir) {
457 } else {
458 panic!("enabledmods.json should not be valid json");
459 }
460 }
461
462 #[test]
463 fn pass_get_enabledmods() {
464 let test_folder = "pass_enabled_mods_test";
465 let temp_dir = TempDir::create(test_folder).unwrap();
466 fs::write(temp_dir.join("enabledmods.json"), b"{}").unwrap();
467 if let Ok(mods) = get_enabled_mods(temp_dir) {
468 assert!(mods.client);
469 assert!(mods.custom);
470 assert!(mods.servers);
471 assert!(mods.mods.is_empty());
472 } else {
473 panic!("enabledmods.json should be valid but empty");
474 }
475 }
476
477 #[test]
478 fn reolve_dependencies() {
479 let test_index: &[Mod] = &[Mod {
480 name: "test".into(),
481 latest: "0.1.0".into(),
482 upgradable: false,
483 global: false,
484 installed: false,
485 versions: BTreeMap::new(),
486 author: "Foo".into(),
487 }];
488
489 let test_deps = &["foo-test-0.1.0"];
490
491 let res = resolve_deps(test_deps, test_index);
492
493 assert!(res.is_ok());
494 assert_eq!(res.unwrap()[0], test_index[0]);
495 }
496
497 #[test]
498 fn dont_resolve_northstar_as_dependency() {
499 let test_index: &[Mod] = &[Mod {
500 name: "Northstar".into(),
501 latest: "0.1.0".into(),
502 upgradable: false,
503 global: false,
504 installed: false,
505 versions: BTreeMap::new(),
506 author: "Northstar".into(),
507 }];
508
509 let test_deps = &["Northstar-Northstar-0.1.0"];
510
511 let res = resolve_deps(test_deps, test_index);
512
513 assert!(res.is_ok());
514 assert!(res.unwrap().is_empty());
515 }
516
517 #[test]
518 fn fail_resolve_bad_deps() {
519 let test_index: &[Mod] = &[Mod {
520 name: "test".into(),
521 latest: "0.1.0".into(),
522 upgradable: false,
523 global: false,
524 installed: false,
525 versions: BTreeMap::new(),
526 author: "Foo".into(),
527 }];
528
529 let test_deps = &["foo-test@0.1.0"];
530
531 let res = resolve_deps(test_deps, test_index);
532
533 assert!(res.is_err());
534
535 let test_deps = &["foo-bar-0.1.0"];
536
537 let res = resolve_deps(test_deps, test_index);
538
539 assert!(res.is_err());
540 }
541
542 #[test]
543 fn sucessfully_validate_modstring() {
544 let test_string = "author-mod-0.1.0";
545 assert!(validate_modstring(test_string));
546 }
547
548 #[test]
549 fn fail_validate_modstring() {
550 let test_string = "invalid";
551 assert!(!validate_modstring(test_string));
552 }
553
554 #[test]
555 fn successfully_parse_modstring() {
556 let test_string = "author-mod-0.1.0";
557 let res = parse_modstring(test_string);
558
559 if let Ok(parsed) = res {
560 assert_eq!(parsed, ("author".into(), "mod".into(), "0.1.0".into()));
561 } else {
562 panic!("Valid mod string failed to be parsed");
563 }
564 }
565
566 #[test]
567 fn fail_parse_modstring() {
568 let test_string = "invalid";
569 let res = parse_modstring(test_string);
570
571 if let Err(ThermiteError::Name(name)) = res {
572 assert_eq!(name, test_string);
573 } else {
574 panic!("Invalid mod string didn't error");
575 }
576 }
577
578 const MANIFEST: &str = r#"{
579 "namespace": "northstar",
580 "name": "Northstar",
581 "description": "Titanfall 2 modding and custom server framework.",
582 "version_number": "1.22.0",
583 "dependencies": [],
584 "website_url": ""
585 }"#;
586
587 const MOD_JSON: &str = r#"{
588 "Name": "Yourname.Modname",
589 "Description": "Woo yeah wooo!",
590 "Version": "1.2.3",
591
592 "LoadPriority": 0,
593 "ConVars": [],
594 "Scripts": [],
595 "Localisation": []
596 }"#;
597
598 fn setup_mods(path: impl AsRef<Path>) {
599 let root = path.as_ref().join("northstar-mod-1.2.3");
600 fs::create_dir_all(&root).expect("create dir");
601 fs::write(root.join("manifest.json"), MANIFEST).expect("write manifest");
602 let _mod = root.join("RealMod");
603 fs::create_dir_all(&_mod).expect("create dir");
604 fs::write(_mod.join("mod.json"), MOD_JSON).expect("write mod.json");
605 }
606
607 #[test]
608 fn discover_mods() {
609 let dir = TempDir::create("./mod_discovery").expect("Temp dir");
610 setup_mods(&dir);
611 let res = find_mods(dir);
612
613 if let Ok(mods) = res {
614 assert_eq!(mods.len(), 1, "Should be one mod");
615 assert_eq!(mods[0].manifest.name, "Northstar");
616 assert_eq!(mods[0].author, "northstar");
617 assert_eq!(mods[0].mod_json.name, "Yourname.Modname");
618 } else {
619 panic!("Mod discovery failed: {res:?}");
620 }
621 }
622}