1use serde::{Deserialize, Serialize};
2use serde_json::{self, Value};
3use std::{
4 collections::{BTreeMap, HashMap},
5 hash::{Hash, Hasher},
6};
7use std::{
8 fs,
9 path::{Path, PathBuf},
10};
11
12use crate::{error::ThermiteError, CORE_MODS};
13
14#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
15#[serde(rename_all = "PascalCase")]
16pub struct ModJSON {
17 pub name: String,
18 pub description: String,
19 pub version: String,
20 pub load_priority: Option<i32>,
21 pub required_on_client: Option<bool>,
22 #[serde(default)]
23 pub con_vars: Vec<Value>,
24 #[serde(default)]
25 pub scripts: Vec<Value>,
26 #[serde(default)]
27 pub localisation: Vec<String>,
28 #[serde(flatten)]
29 pub _extra: HashMap<String, Value>,
30}
31
32#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
33pub struct Mod {
34 pub name: String,
35 pub latest: String,
37 #[serde(default)]
38 pub installed: bool,
39 #[serde(default)]
40 pub upgradable: bool,
41 #[serde(default)]
42 pub global: bool,
43 pub versions: BTreeMap<String, ModVersion>,
45 pub author: String,
46}
47
48impl Mod {
49 #[must_use]
50 pub fn get_latest(&self) -> Option<&ModVersion> {
51 self.versions.get(&self.latest)
52 }
53
54 #[must_use]
55 pub fn get_version(&self, version: impl AsRef<str>) -> Option<&ModVersion> {
56 self.versions.get(version.as_ref())
57 }
58}
59
60#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
61pub struct ModVersion {
62 pub name: String,
63 pub full_name: String,
64 pub version: String,
65 pub url: String,
66 pub desc: String,
67 pub deps: Vec<String>,
68 pub installed: bool,
69 pub global: bool,
70 pub file_size: u64,
71}
72
73impl ModVersion {
74 #[must_use]
75 pub fn file_size_string(&self) -> String {
76 if self.file_size / 1_000_000 >= 1 {
77 let size = self.file_size / 1_048_576;
78
79 format!("{size:.2} MB")
80 } else {
81 let size = self.file_size / 1024;
82 format!("{size:.2} KB")
83 }
84 }
85}
86
87impl From<&Self> for ModVersion {
88 fn from(value: &Self) -> Self {
89 value.clone()
90 }
91}
92
93impl AsRef<Self> for ModVersion {
94 fn as_ref(&self) -> &Self {
95 self
96 }
97}
98
99#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
100pub struct Manifest {
101 pub name: String,
102 pub version_number: String,
103 pub website_url: String,
104 pub description: String,
105 pub dependencies: Vec<String>,
106}
107
108#[derive(Clone, Debug, Deserialize, Serialize)]
112pub struct EnabledMods {
113 #[serde(rename = "Northstar.Client", default = "default_mod_state")]
114 pub client: bool,
115 #[serde(rename = "Northstar.Custom", default = "default_mod_state")]
116 pub custom: bool,
117 #[serde(rename = "Northstar.CustomServers", default = "default_mod_state")]
118 pub servers: bool,
119 #[serde(flatten)]
120 pub mods: BTreeMap<String, bool>,
121 #[serde(skip)]
123 path: Option<PathBuf>,
124}
125
126fn default_mod_state() -> bool {
127 true
128}
129
130impl Hash for EnabledMods {
131 fn hash<H: Hasher>(&self, state: &mut H) {
132 self.client.hash(state);
133 self.custom.hash(state);
134 self.servers.hash(state);
135 self.mods.hash(state);
136 }
137}
138
139impl Default for EnabledMods {
140 fn default() -> Self {
141 Self {
142 client: true,
143 custom: true,
144 servers: true,
145 mods: BTreeMap::new(),
146 path: None,
147 }
148 }
149}
150
151impl EnabledMods {
152 pub fn load(path: impl AsRef<Path>) -> Result<Self, ThermiteError> {
158 let raw = fs::read_to_string(path)?;
159
160 json5::from_str(&raw).map_err(Into::into)
161 }
162
163 pub fn default_with_path(path: impl AsRef<Path>) -> Self {
165 Self {
166 path: Some(path.as_ref().to_path_buf()),
167 ..Default::default()
168 }
169 }
170 pub fn save(&self) -> Result<(), ThermiteError> {
176 let parsed = serde_json::to_string_pretty(self)?;
177 if let Some(path) = &self.path {
178 if let Some(p) = path.parent() {
179 fs::create_dir_all(p)?;
180 }
181
182 fs::write(path, parsed)?;
183 Ok(())
184 } else {
185 Err(ThermiteError::MissingPath)
186 }
187 }
188
189 #[deprecated(
194 since = "0.9",
195 note = "prefer explicitly setting the path and then saving"
196 )]
197 pub fn save_with_path(&mut self, path: impl AsRef<Path>) -> Result<(), ThermiteError> {
198 self.path = Some(path.as_ref().to_owned());
199 self.save()
200 }
201
202 #[must_use]
204 pub const fn path(&self) -> Option<&PathBuf> {
205 self.path.as_ref()
206 }
207
208 pub fn set_path(&mut self, path: impl Into<Option<PathBuf>>) {
209 self.path = path.into();
210 }
211
212 pub fn is_enabled(&self, name: impl AsRef<str>) -> bool {
217 self.mods.get(name.as_ref()).copied().unwrap_or(true)
218 }
219
220 pub fn get(&self, name: impl AsRef<str>) -> Option<bool> {
222 if CORE_MODS.contains(&name.as_ref()) {
223 Some(match name.as_ref() {
224 "Northstar.Client" => self.client,
225 "Northstar.Custom" => self.custom,
226 "Northstar.CustomServers" => self.servers,
227 _ => unimplemented!(),
228 })
229 } else {
230 self.mods.get(name.as_ref()).copied()
231 }
232 }
233
234 pub fn set(&mut self, name: impl AsRef<str>, val: bool) -> Option<bool> {
236 if CORE_MODS.contains(&name.as_ref().to_lowercase().as_str()) {
237 let prev = self.get(&name);
238 match name.as_ref().to_lowercase().as_str() {
239 "northstar.client" => self.client = val,
240 "northstar.custom" => self.custom = val,
241 "northstar.customservers" => self.servers = val,
242 _ => unimplemented!(),
243 }
244 prev
245 } else {
246 self.mods.insert(name.as_ref().to_string(), val)
247 }
248 }
249}
250
251#[derive(Debug, Clone, PartialEq, Eq, Default)]
253pub struct InstalledMod {
254 pub manifest: Manifest,
255 pub mod_json: ModJSON,
256 pub author: String,
257 pub path: PathBuf,
258}
259
260impl PartialOrd for InstalledMod {
261 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
262 Some(self.cmp(other))
263 }
264}
265
266impl Ord for InstalledMod {
268 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
269 match self.author.cmp(&other.author) {
270 std::cmp::Ordering::Equal => match self.manifest.name.cmp(&other.manifest.name) {
271 std::cmp::Ordering::Equal => self.mod_json.name.cmp(&other.mod_json.name),
272 ord => ord,
273 },
274 ord => ord,
275 }
276 }
277}
278
279#[cfg(test)]
280mod test {
281 use std::collections::HashMap;
282
283 use crate::core::utils::TempDir;
284
285 use super::{EnabledMods, InstalledMod, Manifest, ModJSON};
286
287 const TEST_MOD_JSON: &str = r#"{
288 "Name": "Test",
289 "Description": "Test",
290 "Version": "0.1.0",
291 "LoadPriority": 1,
292 "RequiredOnClient": false,
293 "ConVars": [],
294 "Scripts": [],
295 "Localisation": []
296 }"#;
297
298 #[test]
299 fn serialize_mod_json() {
300 let test_data = ModJSON {
301 name: "Test".into(),
302 description: "Test".into(),
303 version: "0.1.0".into(),
304 load_priority: 1.into(),
305 required_on_client: false.into(),
306 con_vars: vec![],
307 scripts: vec![],
308 localisation: vec![],
309 _extra: HashMap::new(),
310 };
311
312 let ser = json5::to_string(&test_data);
313
314 assert!(ser.is_ok());
315 }
316
317 #[test]
318 fn deserialize_mod_json() {
319 let test_data = ModJSON {
320 name: "Test".into(),
321 description: "Test".into(),
322 version: "0.1.0".into(),
323 load_priority: 1.into(),
324 required_on_client: false.into(),
325 con_vars: vec![],
326 scripts: vec![],
327 localisation: vec![],
328 _extra: HashMap::new(),
329 };
330
331 let de = json5::from_str::<ModJSON>(TEST_MOD_JSON);
332
333 assert!(de.is_ok());
334 assert_eq!(test_data, de.unwrap());
335 }
336
337 const TEST_MANIFEST: &str = r#"{
338 "name": "Test",
339 "version_number": "0.1.0",
340 "website_url": "https://example.com",
341 "description": "Test",
342 "dependencies": []
343 }"#;
344
345 #[test]
346 fn deserialize_manifest() {
347 let expected = Manifest {
348 name: "Test".into(),
349 version_number: "0.1.0".into(),
350 website_url: "https://example.com".into(),
351 description: "Test".into(),
352 dependencies: vec![],
353 };
354
355 let de = json5::from_str(TEST_MANIFEST);
356
357 assert!(de.is_ok());
358 assert_eq!(expected, de.unwrap());
359 }
360
361 #[test]
362 fn save_enabled_mods() {
363 let dir =
364 TempDir::create("./test_autosave_enabled_mods").expect("Unable to create temp dir");
365 let path = dir.join("enabled_mods.json");
366 {
367 let mut mods = EnabledMods::default_with_path(&path);
368 mods.set("TestMod", false);
369 mods.save().expect("Write enabledmods.json");
370 }
371
372 let mods = EnabledMods::load(&path);
373
374 if let Err(e) = mods {
375 panic!("Failed to load enabled_mods: {e}");
376 }
377
378 let test_mod = mods.unwrap().get("TestMod");
379 assert!(test_mod.is_some());
380 assert!(!test_mod.unwrap());
382 }
383
384 #[test]
385 fn mod_ordering_by_author() {
386 let author1 = "hello".to_string();
387 let author2 = "world".to_string();
388
389 let expected = author1.cmp(&author2);
390
391 let mod1 = InstalledMod {
392 author: author1,
393 ..Default::default()
394 };
395
396 let mod2 = InstalledMod {
397 author: author2,
398 ..Default::default()
399 };
400
401 assert_eq!(expected, mod1.cmp(&mod2));
402 }
403
404 #[test]
405 fn mod_ordering_by_manifest_name() {
406 let author = "foo".to_string();
407
408 let name1 = "hello".to_string();
409 let name2 = "world".to_string();
410
411 let expected = name1.cmp(&name2);
412
413 let mod1 = InstalledMod {
414 author: author.clone(),
415 manifest: Manifest {
416 name: name1,
417 ..Default::default()
418 },
419 ..Default::default()
420 };
421
422 let mod2 = InstalledMod {
423 author: author.clone(),
424 manifest: Manifest {
425 name: name2,
426 ..Default::default()
427 },
428 ..Default::default()
429 };
430
431 assert_eq!(expected, mod1.cmp(&mod2));
432 }
433
434 #[test]
435 fn mod_ordering_by_mod_json_name() {
436 let author = "foo".to_string();
437 let manifest = Manifest {
438 name: "bar".to_string(),
439 ..Default::default()
440 };
441
442 let name1 = "hello".to_string();
443 let name2 = "world".to_string();
444
445 let expected = name1.cmp(&name2);
446
447 let mod1 = InstalledMod {
448 author: author.clone(),
449 manifest: manifest.clone(),
450 mod_json: ModJSON {
451 name: name1,
452 ..Default::default()
453 },
454 ..Default::default()
455 };
456
457 let mod2 = InstalledMod {
458 author: author.clone(),
459 manifest: manifest,
460 mod_json: ModJSON {
461 name: name2,
462 ..Default::default()
463 },
464 ..Default::default()
465 };
466
467 assert_eq!(expected, mod1.cmp(&mod2));
468 }
469}