@thomasfarineau/anvil 0.0.2 → 0.0.3

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 +18 -193
  2. package/dist/cli.cjs +554 -0
  3. package/package.json +19 -6
  4. package/src/client/config.schema.json +24 -1
  5. package/src/client/index.d.ts +36 -0
  6. package/src/rust/src/lib.rs +320 -17
  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 +27 -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
@@ -1,3 +1,9 @@
1
+ export interface ModConfig {
2
+ url: string;
3
+ name?: string;
4
+ file_name?: string;
5
+ }
6
+
1
7
  export interface InstanceConfig {
2
8
  id: string;
3
9
  name: string;
@@ -6,6 +12,7 @@ export interface InstanceConfig {
6
12
  loader_version: string;
7
13
  server_ip: string;
8
14
  server_port: number;
15
+ mods: ModConfig[];
9
16
  }
10
17
 
11
18
  export interface LauncherConfig {
@@ -38,6 +45,7 @@ export interface InstanceStatus {
38
45
  id: string;
39
46
  name: string;
40
47
  installed: boolean;
48
+ running: boolean;
41
49
  }
42
50
 
43
51
  export interface InitStatus {
@@ -46,6 +54,14 @@ export interface InitStatus {
46
54
  instances: InstanceStatus[];
47
55
  }
48
56
 
57
+ export interface ModInfo {
58
+ file_name: string;
59
+ name: string;
60
+ enabled: boolean;
61
+ size: number;
62
+ managed: boolean;
63
+ }
64
+
49
65
  export interface SetupProgress {
50
66
  step: string;
51
67
  current: number;
@@ -76,15 +92,35 @@ export declare const MC: {
76
92
  getSettings(): Promise<Settings>;
77
93
  saveSettings(s: Settings): Promise<void>;
78
94
  getDefaultDir(): Promise<string>;
95
+ getVersion(): Promise<string>;
79
96
  getInitStatus(): Promise<InitStatus>;
80
97
  runSetup(): Promise<void>;
81
98
  verify(instanceId: string): Promise<void>;
82
99
  play(instanceId: string): Promise<void>;
100
+ stop(instanceId: string): Promise<void>;
101
+ getRunning(): Promise<string[]>;
102
+ isRunning(instanceId: string): Promise<boolean>;
103
+ mods: {
104
+ list(instanceId: string): Promise<ModInfo[]>;
105
+ add(
106
+ instanceId: string,
107
+ url: string,
108
+ fileName?: string | null,
109
+ ): Promise<ModInfo>;
110
+ remove(instanceId: string, fileName: string): Promise<void>;
111
+ enable(instanceId: string, fileName: string): Promise<void>;
112
+ disable(instanceId: string, fileName: string): Promise<void>;
113
+ openFolder(instanceId: string): Promise<void>;
114
+ };
115
+ openInstanceFolder(instanceId: string): Promise<void>;
83
116
  checkUpdate(): Promise<UpdateInfo | null>;
84
117
  doUpdate(url: string): Promise<void>;
85
118
  setSession(session: CustomSession): Promise<void>;
86
119
  clearSession(): Promise<void>;
87
120
  close(): Promise<void>;
121
+ minimize(): Promise<void>;
122
+ toggleMaximize(): Promise<void>;
123
+ startDrag(): Promise<void>;
88
124
  on: {
89
125
  setupProgress(cb: (p: SetupProgress) => void): Promise<() => void>;
90
126
  setupDone(cb: () => void): Promise<() => void>;
@@ -15,6 +15,13 @@ 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
+
18
25
  #[derive(Serialize, Deserialize, Clone, Debug)]
19
26
  pub struct InstanceConfig {
20
27
  pub id: String,
@@ -24,6 +31,7 @@ pub struct InstanceConfig {
24
31
  #[serde(default)] pub loader_version: String,
25
32
  #[serde(default)] pub server_ip: String,
26
33
  #[serde(default = "default_port")] pub server_port: u16,
34
+ #[serde(default)] pub mods: Vec<ModConfig>,
27
35
  }
28
36
 
29
37
  #[derive(Serialize, Deserialize, Clone, Debug)]
@@ -97,6 +105,7 @@ pub struct AppState {
97
105
  pub settings: Mutex<Settings>,
98
106
  pub data_dir: PathBuf,
99
107
  pub custom_session: Mutex<Option<CustomSession>>,
108
+ pub running: Mutex<HashMap<String, std::sync::Arc<Mutex<std::process::Child>>>>,
100
109
  }
101
110
 
102
111
  // ── Types Mojang (internes) ───────────────────────────────────────────────────
@@ -193,6 +202,16 @@ pub struct InstanceStatus {
193
202
  pub id: String,
194
203
  pub name: String,
195
204
  pub installed: bool,
205
+ pub running: bool,
206
+ }
207
+
208
+ #[derive(Serialize, Clone)]
209
+ pub struct ModInfo {
210
+ pub file_name: String,
211
+ pub name: String,
212
+ pub enabled: bool,
213
+ pub size: u64,
214
+ pub managed: bool,
196
215
  }
197
216
 
198
217
  #[derive(Serialize, Clone)]
@@ -241,6 +260,35 @@ fn instance_data_dir(launcher_dir: &Path, instance: &InstanceConfig) -> PathBuf
241
260
 
242
261
  fn log_dir(launcher_dir: &Path) -> PathBuf { launcher_dir.join("logs") }
243
262
 
263
+ /// Dossier mods/ de l'instance (lu par Fabric/Forge/Quilt depuis le game dir)
264
+ fn mods_dir(launcher_dir: &Path, inst: &InstanceConfig) -> PathBuf {
265
+ instance_data_dir(launcher_dir, inst).join("mods")
266
+ }
267
+
268
+ fn mod_file_name(m: &ModConfig) -> String {
269
+ if !m.file_name.is_empty() { return m.file_name.clone(); }
270
+ let base = m.url.split(['?', '#']).next().unwrap_or(&m.url);
271
+ let name = base.rsplit('/').next().filter(|n| !n.is_empty()).unwrap_or("mod.jar");
272
+ if name.ends_with(".jar") { name.to_string() } else { format!("{name}.jar") }
273
+ }
274
+
275
+ fn check_file_name(name: &str) -> Result<(), String> {
276
+ if name.is_empty() || name.contains('/') || name.contains('\\') || name.contains("..") {
277
+ return Err(format!("Nom de fichier invalide : '{name}'"));
278
+ }
279
+ Ok(())
280
+ }
281
+
282
+ fn open_in_file_manager(path: &Path) -> Result<(), String> {
283
+ std::fs::create_dir_all(path).map_err(|e| e.to_string())?;
284
+ let cmd = if cfg!(windows) { "explorer" }
285
+ else if cfg!(target_os = "macos") { "open" }
286
+ else { "xdg-open" };
287
+ std::process::Command::new(cmd).arg(path).spawn()
288
+ .map(|_| ())
289
+ .map_err(|e| format!("Impossible d'ouvrir le dossier : {e}"))
290
+ }
291
+
244
292
  fn find_bundled_java(launcher_dir: &Path) -> Option<PathBuf> {
245
293
  let java_dir = launcher_dir.join("java");
246
294
  let bin = if cfg!(windows) { "javaw.exe" } else { "java" };
@@ -341,6 +389,37 @@ fn maven_path(name: &str) -> String {
341
389
  format!("{}/{}/{}/{}-{}.jar", p[0].replace('.', "/"), p[1], p[2], p[1], p[2])
342
390
  }
343
391
 
392
+ /// Maven coordinate without its version: "group:artifact[:classifier]".
393
+ /// Used to dedupe libraries so a single ASM/Guava/etc. survives on the
394
+ /// classpath — Fabric aborts if two versions of the same jar are present.
395
+ fn lib_key(name: &str) -> String {
396
+ let p: Vec<&str> = name.split(':').collect();
397
+ if p.len() < 3 { return name.to_string(); }
398
+ // Keep group:artifact plus any classifier (index 3+), drop the version.
399
+ let mut key = format!("{}:{}", p[0], p[1]);
400
+ for extra in &p[3..] { key.push(':'); key.push_str(extra); }
401
+ key
402
+ }
403
+
404
+ /// Collapse a merged (vanilla + loader) library list so each coordinate
405
+ /// appears once. The later entry wins — loader libraries follow the parent's
406
+ /// in the merge, so Fabric's versions override vanilla's.
407
+ fn dedup_libraries(libs: Vec<LibEntry>) -> Vec<LibEntry> {
408
+ let mut seen: HashMap<String, usize> = HashMap::new();
409
+ let mut out: Vec<LibEntry> = Vec::new();
410
+ for lib in libs {
411
+ let key = lib_key(&lib.name);
412
+ match seen.get(&key) {
413
+ Some(&i) => out[i] = lib,
414
+ None => {
415
+ seen.insert(key, out.len());
416
+ out.push(lib);
417
+ }
418
+ }
419
+ }
420
+ out
421
+ }
422
+
344
423
  async fn http_bytes(client: &reqwest::Client, url: &str) -> Result<Vec<u8>, String> {
345
424
  client.get(url).send().await.map_err(|e| format!("GET {url}: {e}"))?
346
425
  .bytes().await.map_err(|e| e.to_string()).map(|b| b.to_vec())
@@ -422,10 +501,12 @@ fn get_default_launcher_dir(state: State<AppState>) -> String {
422
501
  fn get_init_status(state: State<AppState>) -> InitStatus {
423
502
  let settings = state.settings.lock().unwrap().clone();
424
503
  let launcher_dir = get_launcher_dir(&settings, &state.config);
504
+ let running = state.running.lock().unwrap();
425
505
  let instances = state.config.instances.iter().map(|inst| InstanceStatus {
426
506
  id: inst.id.clone(),
427
507
  name: inst.name.clone(),
428
508
  installed: is_instance_installed(&launcher_dir, inst),
509
+ running: running.contains_key(&inst.id),
429
510
  }).collect();
430
511
  InitStatus {
431
512
  launcher_dir: launcher_dir.to_string_lossy().into(),
@@ -641,6 +722,166 @@ async fn install_fabric(
641
722
  Ok(())
642
723
  }
643
724
 
725
+ // ── Mods ──────────────────────────────────────────────────────────────────────
726
+
727
+ /// Télécharge les mods déclarés dans config.json qui manquent dans mods/.
728
+ /// Un mod désactivé (.jar.disabled) n'est pas re-téléchargé.
729
+ async fn sync_mods(
730
+ app: &AppHandle,
731
+ client: &reqwest::Client,
732
+ launcher_dir: &Path,
733
+ inst: &InstanceConfig,
734
+ ) -> Result<(), String> {
735
+ if inst.mods.is_empty() { return Ok(()); }
736
+ let dir = mods_dir(launcher_dir, inst);
737
+ std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
738
+
739
+ let total = inst.mods.len();
740
+ for (i, m) in inst.mods.iter().enumerate() {
741
+ let file = mod_file_name(m);
742
+ check_file_name(&file)?;
743
+ if dir.join(&file).exists() || dir.join(format!("{file}.disabled")).exists() { continue; }
744
+ let label = if m.name.is_empty() { file.clone() } else { m.name.clone() };
745
+ let _ = app.emit("setup:progress", SetupProgress {
746
+ step: inst.id.clone(),
747
+ current: 100 * i / total.max(1),
748
+ total: 100,
749
+ label: format!("Mod {label} ({}/{total})", i + 1),
750
+ error: false,
751
+ });
752
+ save_if_missing(client, &m.url, &dir.join(&file)).await
753
+ .map_err(|e| format!("Mod '{label}': {e}"))?;
754
+ }
755
+ Ok(())
756
+ }
757
+
758
+ fn find_instance<'a>(cfg: &'a LauncherConfig, instance_id: &str) -> Result<&'a InstanceConfig, String> {
759
+ cfg.instances.iter().find(|i| i.id == instance_id)
760
+ .ok_or_else(|| format!("Instance '{instance_id}' introuvable dans config.json"))
761
+ }
762
+
763
+ #[tauri::command]
764
+ fn get_mods(state: State<AppState>, instance_id: String) -> Result<Vec<ModInfo>, String> {
765
+ let settings = state.settings.lock().unwrap().clone();
766
+ let launcher_dir = get_launcher_dir(&settings, &state.config);
767
+ let inst = find_instance(&state.config, &instance_id)?;
768
+ let dir = mods_dir(&launcher_dir, inst);
769
+
770
+ let managed: HashMap<String, &ModConfig> = inst.mods.iter()
771
+ .map(|m| (mod_file_name(m), m)).collect();
772
+
773
+ let mut mods = Vec::new();
774
+ if let Ok(entries) = std::fs::read_dir(&dir) {
775
+ for e in entries.flatten() {
776
+ let raw = e.file_name().to_string_lossy().to_string();
777
+ let (file, enabled) = match raw.strip_suffix(".disabled") {
778
+ Some(base) => (base.to_string(), false),
779
+ None => (raw.clone(), true),
780
+ };
781
+ if !file.ends_with(".jar") { continue; }
782
+ let decl = managed.get(&file);
783
+ mods.push(ModInfo {
784
+ name: decl.map(|m| m.name.clone()).filter(|n| !n.is_empty())
785
+ .unwrap_or_else(|| file.trim_end_matches(".jar").to_string()),
786
+ file_name: file,
787
+ enabled,
788
+ size: e.metadata().map(|m| m.len()).unwrap_or(0),
789
+ managed: decl.is_some(),
790
+ });
791
+ }
792
+ }
793
+ mods.sort_by(|a, b| a.file_name.cmp(&b.file_name));
794
+ Ok(mods)
795
+ }
796
+
797
+ #[tauri::command]
798
+ async fn add_mod(
799
+ state: State<'_, AppState>,
800
+ instance_id: String,
801
+ url: String,
802
+ file_name: Option<String>,
803
+ ) -> Result<ModInfo, String> {
804
+ let settings = state.settings.lock().unwrap().clone();
805
+ let launcher_dir = get_launcher_dir(&settings, &state.config);
806
+ let inst = find_instance(&state.config, &instance_id)?;
807
+
808
+ let m = ModConfig { url, name: String::new(), file_name: file_name.unwrap_or_default() };
809
+ let file = mod_file_name(&m);
810
+ check_file_name(&file)?;
811
+
812
+ let dir = mods_dir(&launcher_dir, inst);
813
+ std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
814
+ let dest = dir.join(&file);
815
+ if dest.exists() { return Err(format!("Le mod '{file}' existe déjà.")); }
816
+
817
+ let client = reqwest::Client::builder().user_agent("HomeLauncher/1.0").build()
818
+ .map_err(|e| e.to_string())?;
819
+ let bytes = http_bytes(&client, &m.url).await?;
820
+ std::fs::write(&dest, &bytes).map_err(|e| e.to_string())?;
821
+
822
+ Ok(ModInfo {
823
+ name: file.trim_end_matches(".jar").to_string(),
824
+ file_name: file,
825
+ enabled: true,
826
+ size: bytes.len() as u64,
827
+ managed: false,
828
+ })
829
+ }
830
+
831
+ #[tauri::command]
832
+ fn remove_mod(state: State<AppState>, instance_id: String, file_name: String) -> Result<(), String> {
833
+ check_file_name(&file_name)?;
834
+ let settings = state.settings.lock().unwrap().clone();
835
+ let launcher_dir = get_launcher_dir(&settings, &state.config);
836
+ let inst = find_instance(&state.config, &instance_id)?;
837
+ let dir = mods_dir(&launcher_dir, inst);
838
+ for candidate in [dir.join(&file_name), dir.join(format!("{file_name}.disabled"))] {
839
+ if candidate.exists() {
840
+ return std::fs::remove_file(&candidate).map_err(|e| e.to_string());
841
+ }
842
+ }
843
+ Err(format!("Mod '{file_name}' introuvable."))
844
+ }
845
+
846
+ #[tauri::command]
847
+ fn set_mod_enabled(
848
+ state: State<AppState>,
849
+ instance_id: String,
850
+ file_name: String,
851
+ enabled: bool,
852
+ ) -> Result<(), String> {
853
+ check_file_name(&file_name)?;
854
+ let settings = state.settings.lock().unwrap().clone();
855
+ let launcher_dir = get_launcher_dir(&settings, &state.config);
856
+ let inst = find_instance(&state.config, &instance_id)?;
857
+ let dir = mods_dir(&launcher_dir, inst);
858
+ let active = dir.join(&file_name);
859
+ let disabled = dir.join(format!("{file_name}.disabled"));
860
+ match (enabled, active.exists(), disabled.exists()) {
861
+ (true, true, _) => Ok(()),
862
+ (true, false, true) => std::fs::rename(&disabled, &active).map_err(|e| e.to_string()),
863
+ (false, true, _) => std::fs::rename(&active, &disabled).map_err(|e| e.to_string()),
864
+ (false, false, true) => Ok(()),
865
+ _ => Err(format!("Mod '{file_name}' introuvable.")),
866
+ }
867
+ }
868
+
869
+ #[tauri::command]
870
+ fn open_mods_folder(state: State<AppState>, instance_id: String) -> Result<(), String> {
871
+ let settings = state.settings.lock().unwrap().clone();
872
+ let launcher_dir = get_launcher_dir(&settings, &state.config);
873
+ let inst = find_instance(&state.config, &instance_id)?;
874
+ open_in_file_manager(&mods_dir(&launcher_dir, inst))
875
+ }
876
+
877
+ #[tauri::command]
878
+ fn open_instance_folder(state: State<AppState>, instance_id: String) -> Result<(), String> {
879
+ let settings = state.settings.lock().unwrap().clone();
880
+ let launcher_dir = get_launcher_dir(&settings, &state.config);
881
+ let inst = find_instance(&state.config, &instance_id)?;
882
+ open_in_file_manager(&instance_data_dir(&launcher_dir, inst))
883
+ }
884
+
644
885
  #[tauri::command]
645
886
  async fn run_setup(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
646
887
  let settings = state.settings.lock().unwrap().clone();
@@ -668,19 +909,22 @@ async fn run_setup(app: AppHandle, state: State<'_, AppState>) -> Result<(), Str
668
909
  .map_err(|e| e.to_string())?;
669
910
 
670
911
  for inst in &cfg.instances {
671
- if is_instance_installed(&launcher_dir, inst) {
912
+ let result = if is_instance_installed(&launcher_dir, inst) {
672
913
  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
914
+ // On synchronise quand même les mods : un ajout dans config.json
915
+ // doit être téléchargé sans réinstaller l'instance.
916
+ sync_mods(&app, &client, &launcher_dir, inst).await
682
917
  } else {
683
- install_vanilla(&app, &client, &game_dir, inst).await
918
+ launcher_log(&logs, &format!("Installation instance '{}' ({})", inst.id, inst.mc_version));
919
+ let r = if inst.loader == "fabric" {
920
+ install_fabric(&app, &client, &game_dir, inst).await
921
+ } else {
922
+ install_vanilla(&app, &client, &game_dir, inst).await
923
+ };
924
+ match r {
925
+ Ok(_) => sync_mods(&app, &client, &launcher_dir, inst).await,
926
+ Err(e) => Err(e),
927
+ }
684
928
  };
685
929
  match result {
686
930
  Ok(_) => {
@@ -688,7 +932,7 @@ async fn run_setup(app: AppHandle, state: State<'_, AppState>) -> Result<(), Str
688
932
  step: inst.id.clone(), current: 100, total: 100,
689
933
  label: format!("{} installé.", inst.name), error: false,
690
934
  });
691
- launcher_log(&logs, &format!("Instance '{}' installée", inst.id));
935
+ launcher_log(&logs, &format!("Instance '{}' prête", inst.id));
692
936
  }
693
937
  Err(e) => {
694
938
  launcher_log(&logs, &format!("Erreur instance '{}': {e}", inst.id));
@@ -727,7 +971,11 @@ fn read_version_chain(game_dir: &Path, version_id: &str) -> Result<VersionJson,
727
971
  (None, Some(pa)) => ver.arguments = Some(pa),
728
972
  _ => {}
729
973
  }
730
- ver.libraries = { let mut m = parent.libraries; m.extend(std::mem::take(&mut ver.libraries)); m };
974
+ ver.libraries = {
975
+ let mut m = parent.libraries;
976
+ m.extend(std::mem::take(&mut ver.libraries));
977
+ dedup_libraries(m)
978
+ };
731
979
  }
732
980
  Ok(ver)
733
981
  }
@@ -763,6 +1011,14 @@ fn verify_instance(launcher_dir: &Path, inst: &InstanceConfig) -> Result<(), Str
763
1011
  if !path.exists() { missing.push(format!("lib : {}", lib.name)); }
764
1012
  }
765
1013
 
1014
+ let m_dir = mods_dir(launcher_dir, inst);
1015
+ for m in &inst.mods {
1016
+ let file = mod_file_name(m);
1017
+ if !m_dir.join(&file).exists() && !m_dir.join(format!("{file}.disabled")).exists() {
1018
+ missing.push(format!("mod : {file}"));
1019
+ }
1020
+ }
1021
+
766
1022
  if missing.is_empty() { Ok(()) } else {
767
1023
  Err(format!(
768
1024
  "{} fichier(s) manquant(s) dans '{}' :\n{}\n\nRelancez la configuration initiale.",
@@ -840,6 +1096,10 @@ async fn launch_game(
840
1096
  .ok_or_else(|| format!("Instance '{instance_id}' introuvable"))?
841
1097
  .clone();
842
1098
 
1099
+ if state.running.lock().unwrap().contains_key(&instance_id) {
1100
+ return Err(format!("L'instance '{}' est déjà en cours d'exécution.", inst.name));
1101
+ }
1102
+
843
1103
  verify_instance(&launcher_dir, &inst)?;
844
1104
 
845
1105
  let java = find_bundled_java(&launcher_dir).unwrap();
@@ -1003,17 +1263,50 @@ async fn launch_game(
1003
1263
  }
1004
1264
  });
1005
1265
  }
1266
+ // Enregistre le process pour stop_game / get_running_instances.
1267
+ // Le thread de fin utilise try_wait en boucle pour ne pas garder le lock.
1268
+ let child = std::sync::Arc::new(Mutex::new(child));
1269
+ state.running.lock().unwrap().insert(instance_id.clone(), child.clone());
1270
+
1006
1271
  let iid_exit = instance_id.clone();
1007
1272
  let logs2 = logs.clone();
1273
+ let app_exit = app.clone();
1008
1274
  std::thread::spawn(move || {
1009
- let code = child.wait().ok().and_then(|s| s.code()).unwrap_or(-1);
1275
+ let code = loop {
1276
+ match child.lock().unwrap().try_wait() {
1277
+ Ok(Some(status)) => break status.code().unwrap_or(-1),
1278
+ Ok(None) => {}
1279
+ Err(_) => break -1,
1280
+ }
1281
+ std::thread::sleep(std::time::Duration::from_millis(400));
1282
+ };
1283
+ let state = app_exit.state::<AppState>();
1284
+ state.running.lock().unwrap().remove(&iid_exit);
1010
1285
  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 }));
1286
+ let _ = app_exit.emit("game:exit", serde_json::json!({ "instance_id": iid_exit, "code": code }));
1012
1287
  });
1013
1288
 
1014
1289
  Ok(())
1015
1290
  }
1016
1291
 
1292
+ #[tauri::command]
1293
+ fn stop_game(state: State<AppState>, instance_id: String) -> Result<(), String> {
1294
+ let child = state.running.lock().unwrap().get(&instance_id).cloned()
1295
+ .ok_or_else(|| format!("Instance '{instance_id}' non lancée."))?;
1296
+ let result = child.lock().unwrap().kill().map_err(|e| format!("Impossible d'arrêter le jeu : {e}"));
1297
+ result
1298
+ }
1299
+
1300
+ #[tauri::command]
1301
+ fn get_running_instances(state: State<AppState>) -> Vec<String> {
1302
+ state.running.lock().unwrap().keys().cloned().collect()
1303
+ }
1304
+
1305
+ #[tauri::command]
1306
+ fn get_launcher_version() -> String {
1307
+ env!("CARGO_PKG_VERSION").into()
1308
+ }
1309
+
1017
1310
  // ── Mise à jour ───────────────────────────────────────────────────────────────
1018
1311
 
1019
1312
  #[tauri::command]
@@ -1055,13 +1348,13 @@ async fn do_update(app: AppHandle, url: String) -> Result<(), String> {
1055
1348
 
1056
1349
  // ── Entry point ───────────────────────────────────────────────────────────────
1057
1350
 
1058
- #[cfg_attr(mobile, tauri::mobile_entry_point)]
1059
1351
  #[tauri::command]
1060
1352
  fn set_custom_session(state: State<'_, AppState>, session: Option<CustomSession>) -> Result<(), String> {
1061
1353
  *state.custom_session.lock().unwrap() = session;
1062
1354
  Ok(())
1063
1355
  }
1064
1356
 
1357
+ #[cfg_attr(mobile, tauri::mobile_entry_point)]
1065
1358
  pub fn run() {
1066
1359
  tauri::Builder::default()
1067
1360
  .setup(|app| {
@@ -1083,7 +1376,13 @@ pub fn run() {
1083
1376
  let _ = win.set_resizable(config.window_resizable);
1084
1377
  }
1085
1378
 
1086
- app.manage(AppState { config, settings: Mutex::new(settings), data_dir, custom_session: Mutex::new(None) });
1379
+ app.manage(AppState {
1380
+ config,
1381
+ settings: Mutex::new(settings),
1382
+ data_dir,
1383
+ custom_session: Mutex::new(None),
1384
+ running: Mutex::new(HashMap::new()),
1385
+ });
1087
1386
  if cfg!(debug_assertions) {
1088
1387
  app.handle().plugin(
1089
1388
  tauri_plugin_log::Builder::default().level(log::LevelFilter::Info).build()
@@ -1096,8 +1395,12 @@ pub fn run() {
1096
1395
  get_settings, save_settings,
1097
1396
  get_default_launcher_dir, get_init_status,
1098
1397
  run_setup, verify_game, launch_game,
1398
+ stop_game, get_running_instances,
1399
+ get_mods, add_mod, remove_mod, set_mod_enabled,
1400
+ open_mods_folder, open_instance_folder,
1099
1401
  check_update, do_update,
1100
1402
  set_custom_session,
1403
+ get_launcher_version,
1101
1404
  ])
1102
1405
  .run(tauri::generate_context!())
1103
1406
  .expect("error while running tauri application");
@@ -1,3 +1,4 @@
1
1
  node_modules/
2
+ dist/
2
3
  src-anvil/target/
3
4
  src-anvil/gen/
@@ -3,5 +3,11 @@
3
3
  "identifier": "default",
4
4
  "description": "enables the default permissions",
5
5
  "windows": ["main"],
6
- "permissions": ["core:default"]
6
+ "permissions": [
7
+ "core:default",
8
+ "core:window:allow-close",
9
+ "core:window:allow-minimize",
10
+ "core:window:allow-toggle-maximize",
11
+ "core:window:allow-start-dragging"
12
+ ]
7
13
  }
@@ -1,12 +1,12 @@
1
1
  {
2
- "$schema": "node_modules/anvil/src/client/config.schema.json",
2
+ "$schema": "node_modules/@thomasfarineau/anvil/src/client/config.schema.json",
3
3
  "identifier": "",
4
4
  "target": "",
5
5
  "app_name": "My Launcher",
6
6
  "data_folder": ".my-launcher",
7
7
  "java_version": 21,
8
8
  "update_url": "",
9
- "logo": "",
9
+ "logo": "logo.svg",
10
10
  "session": "none",
11
11
  "window_decorations": true,
12
12
  "window_resizable": false,