@thomasfarineau/anvil 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +21 -191
  2. package/dist/cli.cjs +557 -0
  3. package/package.json +26 -10
  4. package/src/client/config.schema.json +58 -6
  5. package/src/client/index.d.ts +60 -1
  6. package/src/rust/src/lib.rs +633 -21
  7. package/src/template/_gitignore +1 -0
  8. package/src/template/capabilities/default.json +7 -1
  9. package/src/template/config.json +2 -2
  10. package/src/template/frontends/react-js/src/App.jsx +183 -0
  11. package/src/template/frontends/react-js/src/index.html +12 -0
  12. package/src/template/frontends/react-js/src/main.jsx +5 -0
  13. package/src/template/frontends/react-js/vite.config.js +10 -0
  14. package/src/template/frontends/react-ts/src/App.tsx +190 -0
  15. package/src/template/frontends/react-ts/src/index.html +12 -0
  16. package/src/template/frontends/react-ts/src/main.tsx +5 -0
  17. package/src/template/frontends/react-ts/tsconfig.json +14 -0
  18. package/src/template/frontends/react-ts/vite.config.ts +10 -0
  19. package/src/template/frontends/solid-js/src/App.jsx +190 -0
  20. package/src/template/frontends/solid-js/src/index.html +12 -0
  21. package/src/template/frontends/solid-js/src/main.jsx +5 -0
  22. package/src/template/frontends/solid-js/vite.config.js +10 -0
  23. package/src/template/frontends/solid-ts/src/App.tsx +193 -0
  24. package/src/template/frontends/solid-ts/src/index.html +12 -0
  25. package/src/template/frontends/solid-ts/src/main.tsx +5 -0
  26. package/src/template/frontends/solid-ts/tsconfig.json +15 -0
  27. package/src/template/frontends/solid-ts/vite.config.ts +10 -0
  28. package/src/template/{src → frontends/vanilla-js/src}/index.html +110 -178
  29. package/src/template/frontends/vanilla-ts/src/index.html +51 -0
  30. package/src/template/frontends/vanilla-ts/src/main.ts +193 -0
  31. package/src/template/frontends/vanilla-ts/tsconfig.json +13 -0
  32. package/src/template/frontends/vanilla-ts/vite.config.ts +8 -0
  33. package/src/template/frontends/vue-js/src/App.vue +155 -0
  34. package/src/template/frontends/vue-js/src/index.html +12 -0
  35. package/src/template/frontends/vue-js/src/main.js +5 -0
  36. package/src/template/frontends/vue-js/vite.config.js +10 -0
  37. package/src/template/frontends/vue-ts/src/App.vue +158 -0
  38. package/src/template/frontends/vue-ts/src/index.html +12 -0
  39. package/src/template/frontends/vue-ts/src/main.ts +5 -0
  40. package/src/template/frontends/vue-ts/tsconfig.json +13 -0
  41. package/src/template/frontends/vue-ts/vite.config.ts +10 -0
  42. package/src/template/{src → shared}/api.js +38 -1
  43. package/src/template/shared/logo.svg +6 -0
  44. package/src/template/shared/style.css +226 -0
  45. package/src/cli.cjs +0 -352
@@ -15,15 +15,34 @@ const RESOURCES_URL: &str = "https://resources.download.minecraft.net/";
15
15
 
16
16
  // ── Config (config.json) ──────────────────────────────────────────────────────
17
17
 
18
+ #[derive(Serialize, Deserialize, Clone, Debug)]
19
+ pub struct ModConfig {
20
+ pub url: String,
21
+ #[serde(default)] pub name: String,
22
+ #[serde(default)] pub file_name: String,
23
+ }
24
+
25
+ /// Fichier arbitraire (config de mod, options.txt…) déployé dans le dossier
26
+ /// de l'instance pendant le setup. Servi par un anvil-server en général.
27
+ #[derive(Serialize, Deserialize, Clone, Debug)]
28
+ pub struct FileConfig {
29
+ pub path: String,
30
+ pub url: String,
31
+ }
32
+
18
33
  #[derive(Serialize, Deserialize, Clone, Debug)]
19
34
  pub struct InstanceConfig {
20
35
  pub id: String,
21
- pub name: String,
22
- pub mc_version: String,
36
+ // name / mc_version sont optionnels dans config.json : une instance
37
+ // `{ "id": "..." }` est résolue au démarrage auprès du anvil-server.
38
+ #[serde(default)] pub name: String,
39
+ #[serde(default)] pub mc_version: String,
23
40
  #[serde(default)] pub loader: String,
24
41
  #[serde(default)] pub loader_version: String,
25
42
  #[serde(default)] pub server_ip: String,
26
43
  #[serde(default = "default_port")] pub server_port: u16,
44
+ #[serde(default)] pub mods: Vec<ModConfig>,
45
+ #[serde(default)] pub files: Vec<FileConfig>,
27
46
  }
28
47
 
29
48
  #[derive(Serialize, Deserialize, Clone, Debug)]
@@ -37,6 +56,8 @@ pub struct LauncherConfig {
37
56
  #[serde(default = "default_true")] pub window_resizable: bool,
38
57
  #[serde(default)] pub logo: String,
39
58
  #[serde(default = "default_session")] pub session: String,
59
+ #[serde(rename = "anvil-server", default)] pub anvil_server: String,
60
+ #[serde(rename = "anvil-key", default)] pub anvil_key: String,
40
61
  #[serde(default)] pub instances: Vec<InstanceConfig>,
41
62
  }
42
63
 
@@ -59,6 +80,8 @@ impl Default for LauncherConfig {
59
80
  window_resizable: true,
60
81
  logo: String::new(),
61
82
  session: default_session(),
83
+ anvil_server: String::new(),
84
+ anvil_key: String::new(),
62
85
  instances: Vec::new(),
63
86
  }
64
87
  }
@@ -97,6 +120,7 @@ pub struct AppState {
97
120
  pub settings: Mutex<Settings>,
98
121
  pub data_dir: PathBuf,
99
122
  pub custom_session: Mutex<Option<CustomSession>>,
123
+ pub running: Mutex<HashMap<String, std::sync::Arc<Mutex<std::process::Child>>>>,
100
124
  }
101
125
 
102
126
  // ── Types Mojang (internes) ───────────────────────────────────────────────────
@@ -193,6 +217,16 @@ pub struct InstanceStatus {
193
217
  pub id: String,
194
218
  pub name: String,
195
219
  pub installed: bool,
220
+ pub running: bool,
221
+ }
222
+
223
+ #[derive(Serialize, Clone)]
224
+ pub struct ModInfo {
225
+ pub file_name: String,
226
+ pub name: String,
227
+ pub enabled: bool,
228
+ pub size: u64,
229
+ pub managed: bool,
196
230
  }
197
231
 
198
232
  #[derive(Serialize, Clone)]
@@ -241,6 +275,35 @@ fn instance_data_dir(launcher_dir: &Path, instance: &InstanceConfig) -> PathBuf
241
275
 
242
276
  fn log_dir(launcher_dir: &Path) -> PathBuf { launcher_dir.join("logs") }
243
277
 
278
+ /// Dossier mods/ de l'instance (lu par Fabric/Forge/Quilt depuis le game dir)
279
+ fn mods_dir(launcher_dir: &Path, inst: &InstanceConfig) -> PathBuf {
280
+ instance_data_dir(launcher_dir, inst).join("mods")
281
+ }
282
+
283
+ fn mod_file_name(m: &ModConfig) -> String {
284
+ if !m.file_name.is_empty() { return m.file_name.clone(); }
285
+ let base = m.url.split(['?', '#']).next().unwrap_or(&m.url);
286
+ let name = base.rsplit('/').next().filter(|n| !n.is_empty()).unwrap_or("mod.jar");
287
+ if name.ends_with(".jar") { name.to_string() } else { format!("{name}.jar") }
288
+ }
289
+
290
+ fn check_file_name(name: &str) -> Result<(), String> {
291
+ if name.is_empty() || name.contains('/') || name.contains('\\') || name.contains("..") {
292
+ return Err(format!("Nom de fichier invalide : '{name}'"));
293
+ }
294
+ Ok(())
295
+ }
296
+
297
+ fn open_in_file_manager(path: &Path) -> Result<(), String> {
298
+ std::fs::create_dir_all(path).map_err(|e| e.to_string())?;
299
+ let cmd = if cfg!(windows) { "explorer" }
300
+ else if cfg!(target_os = "macos") { "open" }
301
+ else { "xdg-open" };
302
+ std::process::Command::new(cmd).arg(path).spawn()
303
+ .map(|_| ())
304
+ .map_err(|e| format!("Impossible d'ouvrir le dossier : {e}"))
305
+ }
306
+
244
307
  fn find_bundled_java(launcher_dir: &Path) -> Option<PathBuf> {
245
308
  let java_dir = launcher_dir.join("java");
246
309
  let bin = if cfg!(windows) { "javaw.exe" } else { "java" };
@@ -308,6 +371,80 @@ fn find_config_json(app: &AppHandle) -> LauncherConfig {
308
371
  LauncherConfig::default()
309
372
  }
310
373
 
374
+ // ── anvil-server ──────────────────────────────────────────────────────────────
375
+
376
+ fn anvil_server_url(cfg: &LauncherConfig) -> Result<String, String> {
377
+ let url = cfg.anvil_server.trim().trim_end_matches('/');
378
+ if url.is_empty() {
379
+ return Err("Aucun serveur anvil configuré (champ 'anvil-server' de config.json).".into());
380
+ }
381
+ Ok(url.to_string())
382
+ }
383
+
384
+ fn anvil_cache_file(data_dir: &Path) -> PathBuf {
385
+ data_dir.join("anvil-server-cache").join("instances.json")
386
+ }
387
+
388
+ /// Clé d'API à joindre si l'URL cible le anvil-server configuré.
389
+ /// Les URLs externes (CDN de mods…) ne reçoivent jamais la clé.
390
+ fn anvil_key_for<'a>(cfg: &'a LauncherConfig, url: &str) -> Option<&'a str> {
391
+ if cfg.anvil_key.is_empty() { return None; }
392
+ match anvil_server_url(cfg) {
393
+ Ok(server) if url.starts_with(&server) => Some(cfg.anvil_key.as_str()),
394
+ _ => None,
395
+ }
396
+ }
397
+
398
+ async fn fetch_remote_instances(
399
+ client: &reqwest::Client,
400
+ cfg: &LauncherConfig,
401
+ server: &str,
402
+ ) -> Result<Vec<InstanceConfig>, String> {
403
+ let mut req = client.get(format!("{server}/api/launcher/instances"));
404
+ if !cfg.anvil_key.is_empty() { req = req.header("x-anvil-key", &cfg.anvil_key); }
405
+ let resp = req.send().await.map_err(|e| e.to_string())?;
406
+ if !resp.status().is_success() {
407
+ return Err(format!("HTTP {}", resp.status()));
408
+ }
409
+ resp.json::<Vec<InstanceConfig>>().await.map_err(|e| e.to_string())
410
+ }
411
+
412
+ /// Récupère la liste des instances actives auprès du anvil-server (le
413
+ /// config.json ne déclare plus les instances, seulement le serveur et la
414
+ /// clé d'API). La liste est mise en cache sur disque afin que le launcher
415
+ /// reste utilisable hors-ligne.
416
+ fn resolve_remote_instances(config: &mut LauncherConfig, data_dir: &Path, logs: &Path) {
417
+ let Ok(server) = anvil_server_url(config) else { return };
418
+ let cache_file = anvil_cache_file(data_dir);
419
+
420
+ tauri::async_runtime::block_on(async {
421
+ let Ok(client) = reqwest::Client::builder()
422
+ .user_agent("HomeLauncher/1.0")
423
+ .timeout(std::time::Duration::from_secs(8))
424
+ .build() else { return };
425
+
426
+ match fetch_remote_instances(&client, config, &server).await {
427
+ Ok(remote) => {
428
+ launcher_log(logs, &format!(
429
+ "anvil-server: {} instance(s) résolue(s)", remote.len()
430
+ ));
431
+ let _ = save_json(&cache_file, &remote);
432
+ config.instances = remote;
433
+ }
434
+ Err(e) => {
435
+ launcher_log(logs, &format!(
436
+ "anvil-server: échec ({e}) — utilisation du cache"
437
+ ));
438
+ if let Ok(text) = std::fs::read_to_string(&cache_file) {
439
+ if let Ok(cached) = serde_json::from_str::<Vec<InstanceConfig>>(&text) {
440
+ config.instances = cached;
441
+ }
442
+ }
443
+ }
444
+ }
445
+ });
446
+ }
447
+
311
448
  // ── Utilitaires JSON ──────────────────────────────────────────────────────────
312
449
 
313
450
  fn load_json<T: serde::de::DeserializeOwned + Default>(path: &Path) -> T {
@@ -341,6 +478,37 @@ fn maven_path(name: &str) -> String {
341
478
  format!("{}/{}/{}/{}-{}.jar", p[0].replace('.', "/"), p[1], p[2], p[1], p[2])
342
479
  }
343
480
 
481
+ /// Maven coordinate without its version: "group:artifact[:classifier]".
482
+ /// Used to dedupe libraries so a single ASM/Guava/etc. survives on the
483
+ /// classpath — Fabric aborts if two versions of the same jar are present.
484
+ fn lib_key(name: &str) -> String {
485
+ let p: Vec<&str> = name.split(':').collect();
486
+ if p.len() < 3 { return name.to_string(); }
487
+ // Keep group:artifact plus any classifier (index 3+), drop the version.
488
+ let mut key = format!("{}:{}", p[0], p[1]);
489
+ for extra in &p[3..] { key.push(':'); key.push_str(extra); }
490
+ key
491
+ }
492
+
493
+ /// Collapse a merged (vanilla + loader) library list so each coordinate
494
+ /// appears once. The later entry wins — loader libraries follow the parent's
495
+ /// in the merge, so Fabric's versions override vanilla's.
496
+ fn dedup_libraries(libs: Vec<LibEntry>) -> Vec<LibEntry> {
497
+ let mut seen: HashMap<String, usize> = HashMap::new();
498
+ let mut out: Vec<LibEntry> = Vec::new();
499
+ for lib in libs {
500
+ let key = lib_key(&lib.name);
501
+ match seen.get(&key) {
502
+ Some(&i) => out[i] = lib,
503
+ None => {
504
+ seen.insert(key, out.len());
505
+ out.push(lib);
506
+ }
507
+ }
508
+ }
509
+ out
510
+ }
511
+
344
512
  async fn http_bytes(client: &reqwest::Client, url: &str) -> Result<Vec<u8>, String> {
345
513
  client.get(url).send().await.map_err(|e| format!("GET {url}: {e}"))?
346
514
  .bytes().await.map_err(|e| e.to_string()).map(|b| b.to_vec())
@@ -353,6 +521,32 @@ async fn save_if_missing(client: &reqwest::Client, url: &str, dest: &Path) -> Re
353
521
  std::fs::write(dest, &bytes).map_err(|e| e.to_string())
354
522
  }
355
523
 
524
+ /// Variante de http_bytes qui joint la clé d'API si l'URL cible le
525
+ /// anvil-server configuré (mods hébergés, fichiers de config).
526
+ async fn http_bytes_keyed(
527
+ client: &reqwest::Client,
528
+ cfg: &LauncherConfig,
529
+ url: &str,
530
+ ) -> Result<Vec<u8>, String> {
531
+ let mut req = client.get(url);
532
+ if let Some(key) = anvil_key_for(cfg, url) { req = req.header("x-anvil-key", key); }
533
+ req.send().await.map_err(|e| format!("GET {url}: {e}"))?
534
+ .error_for_status().map_err(|e| format!("GET {url}: {e}"))?
535
+ .bytes().await.map_err(|e| e.to_string()).map(|b| b.to_vec())
536
+ }
537
+
538
+ async fn save_if_missing_keyed(
539
+ client: &reqwest::Client,
540
+ cfg: &LauncherConfig,
541
+ url: &str,
542
+ dest: &Path,
543
+ ) -> Result<(), String> {
544
+ if dest.exists() { return Ok(()); }
545
+ if let Some(p) = dest.parent() { std::fs::create_dir_all(p).map_err(|e| e.to_string())?; }
546
+ let bytes = http_bytes_keyed(client, cfg, url).await?;
547
+ std::fs::write(dest, &bytes).map_err(|e| e.to_string())
548
+ }
549
+
356
550
  fn unzip_natives(zip_bytes: &[u8], dest: &Path) -> Result<(), String> {
357
551
  use std::io::Read;
358
552
  let mut archive = zip::ZipArchive::new(std::io::Cursor::new(zip_bytes))
@@ -422,10 +616,12 @@ fn get_default_launcher_dir(state: State<AppState>) -> String {
422
616
  fn get_init_status(state: State<AppState>) -> InitStatus {
423
617
  let settings = state.settings.lock().unwrap().clone();
424
618
  let launcher_dir = get_launcher_dir(&settings, &state.config);
619
+ let running = state.running.lock().unwrap();
425
620
  let instances = state.config.instances.iter().map(|inst| InstanceStatus {
426
621
  id: inst.id.clone(),
427
622
  name: inst.name.clone(),
428
623
  installed: is_instance_installed(&launcher_dir, inst),
624
+ running: running.contains_key(&inst.id),
429
625
  }).collect();
430
626
  InitStatus {
431
627
  launcher_dir: launcher_dir.to_string_lossy().into(),
@@ -641,6 +837,206 @@ async fn install_fabric(
641
837
  Ok(())
642
838
  }
643
839
 
840
+ // ── Mods ──────────────────────────────────────────────────────────────────────
841
+
842
+ /// Télécharge les mods déclarés dans config.json qui manquent dans mods/.
843
+ /// Un mod désactivé (.jar.disabled) n'est pas re-téléchargé.
844
+ async fn sync_mods(
845
+ app: &AppHandle,
846
+ client: &reqwest::Client,
847
+ cfg: &LauncherConfig,
848
+ launcher_dir: &Path,
849
+ inst: &InstanceConfig,
850
+ ) -> Result<(), String> {
851
+ if inst.mods.is_empty() { return Ok(()); }
852
+ let dir = mods_dir(launcher_dir, inst);
853
+ std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
854
+
855
+ let total = inst.mods.len();
856
+ for (i, m) in inst.mods.iter().enumerate() {
857
+ let file = mod_file_name(m);
858
+ check_file_name(&file)?;
859
+ if dir.join(&file).exists() || dir.join(format!("{file}.disabled")).exists() { continue; }
860
+ let label = if m.name.is_empty() { file.clone() } else { m.name.clone() };
861
+ let _ = app.emit("setup:progress", SetupProgress {
862
+ step: inst.id.clone(),
863
+ current: 100 * i / total.max(1),
864
+ total: 100,
865
+ label: format!("Mod {label} ({}/{total})", i + 1),
866
+ error: false,
867
+ });
868
+ save_if_missing_keyed(client, cfg, &m.url, &dir.join(&file)).await
869
+ .map_err(|e| format!("Mod '{label}': {e}"))?;
870
+ }
871
+ Ok(())
872
+ }
873
+
874
+ fn check_rel_path(path: &str) -> Result<(), String> {
875
+ if path.is_empty() || path.contains("..") || path.starts_with('/')
876
+ || path.contains('\\') || path.contains(':')
877
+ {
878
+ return Err(format!("Chemin de fichier invalide : '{path}'"));
879
+ }
880
+ Ok(())
881
+ }
882
+
883
+ /// Télécharge les fichiers déclarés (configs de mods…) dans le dossier de
884
+ /// l'instance. Toujours écrasés : le anvil-server est la source de vérité.
885
+ async fn sync_files(
886
+ app: &AppHandle,
887
+ client: &reqwest::Client,
888
+ cfg: &LauncherConfig,
889
+ launcher_dir: &Path,
890
+ inst: &InstanceConfig,
891
+ ) -> Result<(), String> {
892
+ if inst.files.is_empty() { return Ok(()); }
893
+ let base = instance_data_dir(launcher_dir, inst);
894
+ let total = inst.files.len();
895
+ for (i, f) in inst.files.iter().enumerate() {
896
+ check_rel_path(&f.path)?;
897
+ let _ = app.emit("setup:progress", SetupProgress {
898
+ step: inst.id.clone(),
899
+ current: 100 * i / total.max(1),
900
+ total: 100,
901
+ label: format!("Fichier {} ({}/{total})", f.path, i + 1),
902
+ error: false,
903
+ });
904
+ let dest = base.join(&f.path);
905
+ if let Some(p) = dest.parent() { std::fs::create_dir_all(p).map_err(|e| e.to_string())?; }
906
+ let bytes = http_bytes_keyed(client, cfg, &f.url).await
907
+ .map_err(|e| format!("Fichier '{}': {e}", f.path))?;
908
+ std::fs::write(&dest, &bytes).map_err(|e| e.to_string())?;
909
+ }
910
+ Ok(())
911
+ }
912
+
913
+ fn find_instance<'a>(cfg: &'a LauncherConfig, instance_id: &str) -> Result<&'a InstanceConfig, String> {
914
+ cfg.instances.iter().find(|i| i.id == instance_id)
915
+ .ok_or_else(|| format!("Instance '{instance_id}' introuvable dans config.json"))
916
+ }
917
+
918
+ #[tauri::command]
919
+ fn get_mods(state: State<AppState>, instance_id: String) -> Result<Vec<ModInfo>, String> {
920
+ let settings = state.settings.lock().unwrap().clone();
921
+ let launcher_dir = get_launcher_dir(&settings, &state.config);
922
+ let inst = find_instance(&state.config, &instance_id)?;
923
+ let dir = mods_dir(&launcher_dir, inst);
924
+
925
+ let managed: HashMap<String, &ModConfig> = inst.mods.iter()
926
+ .map(|m| (mod_file_name(m), m)).collect();
927
+
928
+ let mut mods = Vec::new();
929
+ if let Ok(entries) = std::fs::read_dir(&dir) {
930
+ for e in entries.flatten() {
931
+ let raw = e.file_name().to_string_lossy().to_string();
932
+ let (file, enabled) = match raw.strip_suffix(".disabled") {
933
+ Some(base) => (base.to_string(), false),
934
+ None => (raw.clone(), true),
935
+ };
936
+ if !file.ends_with(".jar") { continue; }
937
+ let decl = managed.get(&file);
938
+ mods.push(ModInfo {
939
+ name: decl.map(|m| m.name.clone()).filter(|n| !n.is_empty())
940
+ .unwrap_or_else(|| file.trim_end_matches(".jar").to_string()),
941
+ file_name: file,
942
+ enabled,
943
+ size: e.metadata().map(|m| m.len()).unwrap_or(0),
944
+ managed: decl.is_some(),
945
+ });
946
+ }
947
+ }
948
+ mods.sort_by(|a, b| a.file_name.cmp(&b.file_name));
949
+ Ok(mods)
950
+ }
951
+
952
+ #[tauri::command]
953
+ async fn add_mod(
954
+ state: State<'_, AppState>,
955
+ instance_id: String,
956
+ url: String,
957
+ file_name: Option<String>,
958
+ ) -> Result<ModInfo, String> {
959
+ let settings = state.settings.lock().unwrap().clone();
960
+ let launcher_dir = get_launcher_dir(&settings, &state.config);
961
+ let inst = find_instance(&state.config, &instance_id)?;
962
+
963
+ let m = ModConfig { url, name: String::new(), file_name: file_name.unwrap_or_default() };
964
+ let file = mod_file_name(&m);
965
+ check_file_name(&file)?;
966
+
967
+ let dir = mods_dir(&launcher_dir, inst);
968
+ std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
969
+ let dest = dir.join(&file);
970
+ if dest.exists() { return Err(format!("Le mod '{file}' existe déjà.")); }
971
+
972
+ let client = reqwest::Client::builder().user_agent("HomeLauncher/1.0").build()
973
+ .map_err(|e| e.to_string())?;
974
+ let bytes = http_bytes_keyed(&client, &state.config, &m.url).await?;
975
+ std::fs::write(&dest, &bytes).map_err(|e| e.to_string())?;
976
+
977
+ Ok(ModInfo {
978
+ name: file.trim_end_matches(".jar").to_string(),
979
+ file_name: file,
980
+ enabled: true,
981
+ size: bytes.len() as u64,
982
+ managed: false,
983
+ })
984
+ }
985
+
986
+ #[tauri::command]
987
+ fn remove_mod(state: State<AppState>, instance_id: String, file_name: String) -> Result<(), String> {
988
+ check_file_name(&file_name)?;
989
+ let settings = state.settings.lock().unwrap().clone();
990
+ let launcher_dir = get_launcher_dir(&settings, &state.config);
991
+ let inst = find_instance(&state.config, &instance_id)?;
992
+ let dir = mods_dir(&launcher_dir, inst);
993
+ for candidate in [dir.join(&file_name), dir.join(format!("{file_name}.disabled"))] {
994
+ if candidate.exists() {
995
+ return std::fs::remove_file(&candidate).map_err(|e| e.to_string());
996
+ }
997
+ }
998
+ Err(format!("Mod '{file_name}' introuvable."))
999
+ }
1000
+
1001
+ #[tauri::command]
1002
+ fn set_mod_enabled(
1003
+ state: State<AppState>,
1004
+ instance_id: String,
1005
+ file_name: String,
1006
+ enabled: bool,
1007
+ ) -> Result<(), String> {
1008
+ check_file_name(&file_name)?;
1009
+ let settings = state.settings.lock().unwrap().clone();
1010
+ let launcher_dir = get_launcher_dir(&settings, &state.config);
1011
+ let inst = find_instance(&state.config, &instance_id)?;
1012
+ let dir = mods_dir(&launcher_dir, inst);
1013
+ let active = dir.join(&file_name);
1014
+ let disabled = dir.join(format!("{file_name}.disabled"));
1015
+ match (enabled, active.exists(), disabled.exists()) {
1016
+ (true, true, _) => Ok(()),
1017
+ (true, false, true) => std::fs::rename(&disabled, &active).map_err(|e| e.to_string()),
1018
+ (false, true, _) => std::fs::rename(&active, &disabled).map_err(|e| e.to_string()),
1019
+ (false, false, true) => Ok(()),
1020
+ _ => Err(format!("Mod '{file_name}' introuvable.")),
1021
+ }
1022
+ }
1023
+
1024
+ #[tauri::command]
1025
+ fn open_mods_folder(state: State<AppState>, instance_id: String) -> Result<(), String> {
1026
+ let settings = state.settings.lock().unwrap().clone();
1027
+ let launcher_dir = get_launcher_dir(&settings, &state.config);
1028
+ let inst = find_instance(&state.config, &instance_id)?;
1029
+ open_in_file_manager(&mods_dir(&launcher_dir, inst))
1030
+ }
1031
+
1032
+ #[tauri::command]
1033
+ fn open_instance_folder(state: State<AppState>, instance_id: String) -> Result<(), String> {
1034
+ let settings = state.settings.lock().unwrap().clone();
1035
+ let launcher_dir = get_launcher_dir(&settings, &state.config);
1036
+ let inst = find_instance(&state.config, &instance_id)?;
1037
+ open_in_file_manager(&instance_data_dir(&launcher_dir, inst))
1038
+ }
1039
+
644
1040
  #[tauri::command]
645
1041
  async fn run_setup(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
646
1042
  let settings = state.settings.lock().unwrap().clone();
@@ -668,19 +1064,34 @@ async fn run_setup(app: AppHandle, state: State<'_, AppState>) -> Result<(), Str
668
1064
  .map_err(|e| e.to_string())?;
669
1065
 
670
1066
  for inst in &cfg.instances {
671
- if is_instance_installed(&launcher_dir, inst) {
1067
+ let result = if inst.mc_version.is_empty() {
1068
+ // Instance déclarée par {id} mais jamais résolue : serveur anvil
1069
+ // injoignable et aucun cache disponible.
1070
+ Err(format!(
1071
+ "Instance '{}' non résolue — anvil-server injoignable ?", inst.id
1072
+ ))
1073
+ } else if is_instance_installed(&launcher_dir, inst) {
672
1074
  launcher_log(&logs, &format!("Instance '{}' déjà installée", inst.id));
673
- let _ = app.emit("setup:progress", SetupProgress {
674
- step: inst.id.clone(), current: 100, total: 100,
675
- label: format!("{} déjà installé.", inst.name), error: false,
676
- });
677
- continue;
678
- }
679
- launcher_log(&logs, &format!("Installation instance '{}' ({})", inst.id, inst.mc_version));
680
- let result = if inst.loader == "fabric" {
681
- install_fabric(&app, &client, &game_dir, inst).await
1075
+ // On synchronise quand même mods et fichiers : un ajout côté
1076
+ // config/serveur doit être téléchargé sans réinstaller l'instance.
1077
+ match sync_mods(&app, &client, &cfg, &launcher_dir, inst).await {
1078
+ Ok(_) => sync_files(&app, &client, &cfg, &launcher_dir, inst).await,
1079
+ Err(e) => Err(e),
1080
+ }
682
1081
  } else {
683
- install_vanilla(&app, &client, &game_dir, inst).await
1082
+ launcher_log(&logs, &format!("Installation instance '{}' ({})", inst.id, inst.mc_version));
1083
+ let r = if inst.loader == "fabric" {
1084
+ install_fabric(&app, &client, &game_dir, inst).await
1085
+ } else {
1086
+ install_vanilla(&app, &client, &game_dir, inst).await
1087
+ };
1088
+ match r {
1089
+ Ok(_) => match sync_mods(&app, &client, &cfg, &launcher_dir, inst).await {
1090
+ Ok(_) => sync_files(&app, &client, &cfg, &launcher_dir, inst).await,
1091
+ Err(e) => Err(e),
1092
+ },
1093
+ Err(e) => Err(e),
1094
+ }
684
1095
  };
685
1096
  match result {
686
1097
  Ok(_) => {
@@ -688,7 +1099,7 @@ async fn run_setup(app: AppHandle, state: State<'_, AppState>) -> Result<(), Str
688
1099
  step: inst.id.clone(), current: 100, total: 100,
689
1100
  label: format!("{} installé.", inst.name), error: false,
690
1101
  });
691
- launcher_log(&logs, &format!("Instance '{}' installée", inst.id));
1102
+ launcher_log(&logs, &format!("Instance '{}' prête", inst.id));
692
1103
  }
693
1104
  Err(e) => {
694
1105
  launcher_log(&logs, &format!("Erreur instance '{}': {e}", inst.id));
@@ -727,7 +1138,11 @@ fn read_version_chain(game_dir: &Path, version_id: &str) -> Result<VersionJson,
727
1138
  (None, Some(pa)) => ver.arguments = Some(pa),
728
1139
  _ => {}
729
1140
  }
730
- ver.libraries = { let mut m = parent.libraries; m.extend(std::mem::take(&mut ver.libraries)); m };
1141
+ ver.libraries = {
1142
+ let mut m = parent.libraries;
1143
+ m.extend(std::mem::take(&mut ver.libraries));
1144
+ dedup_libraries(m)
1145
+ };
731
1146
  }
732
1147
  Ok(ver)
733
1148
  }
@@ -763,6 +1178,14 @@ fn verify_instance(launcher_dir: &Path, inst: &InstanceConfig) -> Result<(), Str
763
1178
  if !path.exists() { missing.push(format!("lib : {}", lib.name)); }
764
1179
  }
765
1180
 
1181
+ let m_dir = mods_dir(launcher_dir, inst);
1182
+ for m in &inst.mods {
1183
+ let file = mod_file_name(m);
1184
+ if !m_dir.join(&file).exists() && !m_dir.join(format!("{file}.disabled")).exists() {
1185
+ missing.push(format!("mod : {file}"));
1186
+ }
1187
+ }
1188
+
766
1189
  if missing.is_empty() { Ok(()) } else {
767
1190
  Err(format!(
768
1191
  "{} fichier(s) manquant(s) dans '{}' :\n{}\n\nRelancez la configuration initiale.",
@@ -840,6 +1263,10 @@ async fn launch_game(
840
1263
  .ok_or_else(|| format!("Instance '{instance_id}' introuvable"))?
841
1264
  .clone();
842
1265
 
1266
+ if state.running.lock().unwrap().contains_key(&instance_id) {
1267
+ return Err(format!("L'instance '{}' est déjà en cours d'exécution.", inst.name));
1268
+ }
1269
+
843
1270
  verify_instance(&launcher_dir, &inst)?;
844
1271
 
845
1272
  let java = find_bundled_java(&launcher_dir).unwrap();
@@ -864,6 +1291,15 @@ async fn launch_game(
864
1291
  None => offline_auth(&fallback_name),
865
1292
  }
866
1293
  },
1294
+ // La session est gérée par le anvil-server : pas de fallback offline,
1295
+ // le joueur doit être connecté.
1296
+ "anvil-session" => {
1297
+ let guard = state.custom_session.lock().unwrap();
1298
+ match &*guard {
1299
+ Some(s) => (s.username.clone(), s.uuid.clone(), s.access_token.clone(), "msa"),
1300
+ None => return Err("Aucune session active — connectez-vous d'abord.".into()),
1301
+ }
1302
+ },
867
1303
  _ => offline_auth(&fallback_name),
868
1304
  };
869
1305
 
@@ -1003,17 +1439,50 @@ async fn launch_game(
1003
1439
  }
1004
1440
  });
1005
1441
  }
1442
+ // Enregistre le process pour stop_game / get_running_instances.
1443
+ // Le thread de fin utilise try_wait en boucle pour ne pas garder le lock.
1444
+ let child = std::sync::Arc::new(Mutex::new(child));
1445
+ state.running.lock().unwrap().insert(instance_id.clone(), child.clone());
1446
+
1006
1447
  let iid_exit = instance_id.clone();
1007
1448
  let logs2 = logs.clone();
1449
+ let app_exit = app.clone();
1008
1450
  std::thread::spawn(move || {
1009
- let code = child.wait().ok().and_then(|s| s.code()).unwrap_or(-1);
1451
+ let code = loop {
1452
+ match child.lock().unwrap().try_wait() {
1453
+ Ok(Some(status)) => break status.code().unwrap_or(-1),
1454
+ Ok(None) => {}
1455
+ Err(_) => break -1,
1456
+ }
1457
+ std::thread::sleep(std::time::Duration::from_millis(400));
1458
+ };
1459
+ let state = app_exit.state::<AppState>();
1460
+ state.running.lock().unwrap().remove(&iid_exit);
1010
1461
  launcher_log(&logs2, &format!("Instance '{}' terminée (code {code})", iid_exit));
1011
- let _ = app.emit("game:exit", serde_json::json!({ "instance_id": iid_exit, "code": code }));
1462
+ let _ = app_exit.emit("game:exit", serde_json::json!({ "instance_id": iid_exit, "code": code }));
1012
1463
  });
1013
1464
 
1014
1465
  Ok(())
1015
1466
  }
1016
1467
 
1468
+ #[tauri::command]
1469
+ fn stop_game(state: State<AppState>, instance_id: String) -> Result<(), String> {
1470
+ let child = state.running.lock().unwrap().get(&instance_id).cloned()
1471
+ .ok_or_else(|| format!("Instance '{instance_id}' non lancée."))?;
1472
+ let result = child.lock().unwrap().kill().map_err(|e| format!("Impossible d'arrêter le jeu : {e}"));
1473
+ result
1474
+ }
1475
+
1476
+ #[tauri::command]
1477
+ fn get_running_instances(state: State<AppState>) -> Vec<String> {
1478
+ state.running.lock().unwrap().keys().cloned().collect()
1479
+ }
1480
+
1481
+ #[tauri::command]
1482
+ fn get_launcher_version() -> String {
1483
+ env!("CARGO_PKG_VERSION").into()
1484
+ }
1485
+
1017
1486
  // ── Mise à jour ───────────────────────────────────────────────────────────────
1018
1487
 
1019
1488
  #[tauri::command]
@@ -1053,29 +1522,161 @@ async fn do_update(app: AppHandle, url: String) -> Result<(), String> {
1053
1522
  Ok(())
1054
1523
  }
1055
1524
 
1525
+ // ── Session anvil-server ──────────────────────────────────────────────────────
1526
+
1527
+ #[derive(Serialize, Clone)]
1528
+ pub struct AnvilLoginResult {
1529
+ pub status: String, // "ok" | "totp_required"
1530
+ pub username: String,
1531
+ pub uuid: String,
1532
+ }
1533
+
1534
+ fn anvil_session_path(data_dir: &Path) -> PathBuf { data_dir.join("anvil_session.json") }
1535
+
1536
+ fn anvil_http_client() -> Result<reqwest::Client, String> {
1537
+ reqwest::Client::builder()
1538
+ .user_agent("HomeLauncher/1.0")
1539
+ .timeout(std::time::Duration::from_secs(10))
1540
+ .build()
1541
+ .map_err(|e| e.to_string())
1542
+ }
1543
+
1544
+ /// POST vers le anvil-server avec la clé d'API du config.json.
1545
+ fn anvil_post(
1546
+ client: &reqwest::Client,
1547
+ cfg: &LauncherConfig,
1548
+ url: String,
1549
+ ) -> reqwest::RequestBuilder {
1550
+ let mut req = client.post(url);
1551
+ if !cfg.anvil_key.is_empty() { req = req.header("x-anvil-key", &cfg.anvil_key); }
1552
+ req
1553
+ }
1554
+
1555
+ /// Connexion au anvil-server (mode session "anvil-session").
1556
+ /// Retourne status "totp_required" si le compte exige un code 2FA.
1557
+ #[tauri::command]
1558
+ async fn anvil_session_login(
1559
+ state: State<'_, AppState>,
1560
+ username: String,
1561
+ password: String,
1562
+ code: Option<String>,
1563
+ ) -> Result<AnvilLoginResult, String> {
1564
+ let server = anvil_server_url(&state.config)?;
1565
+ let client = anvil_http_client()?;
1566
+ let resp = anvil_post(&client, &state.config, format!("{server}/api/launcher/session"))
1567
+ .json(&serde_json::json!({ "username": username, "password": password, "code": code }))
1568
+ .send().await.map_err(|e| format!("Erreur réseau : {e}"))?;
1569
+
1570
+ let status = resp.status();
1571
+ let body: serde_json::Value = resp.json().await.unwrap_or_default();
1572
+
1573
+ if status.is_success() {
1574
+ let session = CustomSession {
1575
+ username: body["username"].as_str().unwrap_or(&username).to_string(),
1576
+ uuid: body["uuid"].as_str().unwrap_or_default().to_string(),
1577
+ access_token: body["access_token"].as_str().unwrap_or_default().to_string(),
1578
+ };
1579
+ let _ = save_json(&anvil_session_path(&state.data_dir), &session);
1580
+ let result = AnvilLoginResult {
1581
+ status: "ok".into(), username: session.username.clone(), uuid: session.uuid.clone(),
1582
+ };
1583
+ *state.custom_session.lock().unwrap() = Some(session);
1584
+ Ok(result)
1585
+ } else {
1586
+ match body["error"].as_str() {
1587
+ Some("totp_required") => Ok(AnvilLoginResult {
1588
+ status: "totp_required".into(), username: String::new(), uuid: String::new(),
1589
+ }),
1590
+ Some("invalid_code") => Err("Code 2FA invalide.".into()),
1591
+ Some("missing_api_key") | Some("invalid_api_key") =>
1592
+ Err("Clé d'API invalide — vérifiez 'anvil-key' dans config.json.".into()),
1593
+ _ => Err("Identifiants invalides.".into()),
1594
+ }
1595
+ }
1596
+ }
1597
+
1598
+ /// Restaure la session persistée si elle est encore valide côté serveur.
1599
+ /// Hors-ligne, la session en cache est conservée pour permettre de jouer.
1600
+ #[tauri::command]
1601
+ async fn anvil_session_restore(state: State<'_, AppState>) -> Result<Option<AnvilLoginResult>, String> {
1602
+ let path = anvil_session_path(&state.data_dir);
1603
+ let saved: Option<CustomSession> = std::fs::read_to_string(&path).ok()
1604
+ .and_then(|s| serde_json::from_str(&s).ok());
1605
+ let Some(saved) = saved else { return Ok(None) };
1606
+ let Ok(server) = anvil_server_url(&state.config) else { return Ok(None) };
1607
+ let client = anvil_http_client()?;
1608
+
1609
+ let session = match anvil_post(&client, &state.config, format!("{server}/api/launcher/session/validate"))
1610
+ .json(&serde_json::json!({ "token": saved.access_token }))
1611
+ .send().await
1612
+ {
1613
+ Ok(resp) if resp.status().is_success() => {
1614
+ let body: serde_json::Value = resp.json().await.unwrap_or_default();
1615
+ CustomSession {
1616
+ username: body["username"].as_str().unwrap_or(&saved.username).to_string(),
1617
+ uuid: body["uuid"].as_str().unwrap_or(&saved.uuid).to_string(),
1618
+ access_token: saved.access_token,
1619
+ }
1620
+ }
1621
+ Ok(resp) => {
1622
+ let body: serde_json::Value = resp.json().await.unwrap_or_default();
1623
+ if body["error"].as_str() == Some("invalid_token") {
1624
+ // Token révoqué ou expiré : on oublie la session.
1625
+ std::fs::remove_file(&path).ok();
1626
+ return Ok(None);
1627
+ }
1628
+ saved // autre erreur (clé d'API…) : on garde la session locale
1629
+ }
1630
+ Err(_) => saved, // serveur injoignable : session hors-ligne
1631
+ };
1632
+
1633
+ let result = AnvilLoginResult {
1634
+ status: "ok".into(), username: session.username.clone(), uuid: session.uuid.clone(),
1635
+ };
1636
+ *state.custom_session.lock().unwrap() = Some(session);
1637
+ Ok(Some(result))
1638
+ }
1639
+
1640
+ #[tauri::command]
1641
+ async fn anvil_session_logout(state: State<'_, AppState>) -> Result<(), String> {
1642
+ let saved = state.custom_session.lock().unwrap().take();
1643
+ std::fs::remove_file(anvil_session_path(&state.data_dir)).ok();
1644
+ if let (Ok(server), Some(session)) = (anvil_server_url(&state.config), saved) {
1645
+ if let Ok(client) = anvil_http_client() {
1646
+ let _ = anvil_post(&client, &state.config, format!("{server}/api/launcher/session/logout"))
1647
+ .json(&serde_json::json!({ "token": session.access_token }))
1648
+ .send().await;
1649
+ }
1650
+ }
1651
+ Ok(())
1652
+ }
1653
+
1056
1654
  // ── Entry point ───────────────────────────────────────────────────────────────
1057
1655
 
1058
- #[cfg_attr(mobile, tauri::mobile_entry_point)]
1059
1656
  #[tauri::command]
1060
1657
  fn set_custom_session(state: State<'_, AppState>, session: Option<CustomSession>) -> Result<(), String> {
1061
1658
  *state.custom_session.lock().unwrap() = session;
1062
1659
  Ok(())
1063
1660
  }
1064
1661
 
1662
+ #[cfg_attr(mobile, tauri::mobile_entry_point)]
1065
1663
  pub fn run() {
1066
1664
  tauri::Builder::default()
1067
1665
  .setup(|app| {
1068
1666
  let data_dir = app.path().app_data_dir()
1069
1667
  .unwrap_or_else(|_| dirs::data_local_dir().unwrap_or_default().join("HomeLauncher"));
1070
1668
  std::fs::create_dir_all(&data_dir)?;
1071
- let config: LauncherConfig = find_config_json(app.handle());
1072
- let settings: Settings = load_json(&data_dir.join("settings.json"));
1669
+ let mut config: LauncherConfig = find_config_json(app.handle());
1670
+ let settings: Settings = load_json(&data_dir.join("settings.json"));
1073
1671
 
1074
1672
  // Log du démarrage dans le launcher_dir final
1075
1673
  let launcher_dir = get_launcher_dir(&settings, &config);
1076
1674
  launcher_log(&log_dir(&launcher_dir),
1077
1675
  &format!("HomeLauncher démarré — {} instance(s)", config.instances.len()));
1078
1676
 
1677
+ // Résolution des instances déclarées par id auprès du anvil-server
1678
+ resolve_remote_instances(&mut config, &data_dir, &log_dir(&launcher_dir));
1679
+
1079
1680
  // Appliquer les options de fenêtre depuis config.json
1080
1681
  if let Some(win) = app.get_webview_window("main") {
1081
1682
  let _ = win.set_title(&config.app_name);
@@ -1083,7 +1684,13 @@ pub fn run() {
1083
1684
  let _ = win.set_resizable(config.window_resizable);
1084
1685
  }
1085
1686
 
1086
- app.manage(AppState { config, settings: Mutex::new(settings), data_dir, custom_session: Mutex::new(None) });
1687
+ app.manage(AppState {
1688
+ config,
1689
+ settings: Mutex::new(settings),
1690
+ data_dir,
1691
+ custom_session: Mutex::new(None),
1692
+ running: Mutex::new(HashMap::new()),
1693
+ });
1087
1694
  if cfg!(debug_assertions) {
1088
1695
  app.handle().plugin(
1089
1696
  tauri_plugin_log::Builder::default().level(log::LevelFilter::Info).build()
@@ -1096,8 +1703,13 @@ pub fn run() {
1096
1703
  get_settings, save_settings,
1097
1704
  get_default_launcher_dir, get_init_status,
1098
1705
  run_setup, verify_game, launch_game,
1706
+ stop_game, get_running_instances,
1707
+ get_mods, add_mod, remove_mod, set_mod_enabled,
1708
+ open_mods_folder, open_instance_folder,
1099
1709
  check_update, do_update,
1100
1710
  set_custom_session,
1711
+ anvil_session_login, anvil_session_restore, anvil_session_logout,
1712
+ get_launcher_version,
1101
1713
  ])
1102
1714
  .run(tauri::generate_context!())
1103
1715
  .expect("error while running tauri application");