@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.
- package/README.md +21 -191
- package/dist/cli.cjs +557 -0
- package/package.json +26 -10
- package/src/client/config.schema.json +58 -6
- package/src/client/index.d.ts +60 -1
- package/src/rust/src/lib.rs +633 -21
- package/src/template/_gitignore +1 -0
- package/src/template/capabilities/default.json +7 -1
- package/src/template/config.json +2 -2
- package/src/template/frontends/react-js/src/App.jsx +183 -0
- package/src/template/frontends/react-js/src/index.html +12 -0
- package/src/template/frontends/react-js/src/main.jsx +5 -0
- package/src/template/frontends/react-js/vite.config.js +10 -0
- package/src/template/frontends/react-ts/src/App.tsx +190 -0
- package/src/template/frontends/react-ts/src/index.html +12 -0
- package/src/template/frontends/react-ts/src/main.tsx +5 -0
- package/src/template/frontends/react-ts/tsconfig.json +14 -0
- package/src/template/frontends/react-ts/vite.config.ts +10 -0
- package/src/template/frontends/solid-js/src/App.jsx +190 -0
- package/src/template/frontends/solid-js/src/index.html +12 -0
- package/src/template/frontends/solid-js/src/main.jsx +5 -0
- package/src/template/frontends/solid-js/vite.config.js +10 -0
- package/src/template/frontends/solid-ts/src/App.tsx +193 -0
- package/src/template/frontends/solid-ts/src/index.html +12 -0
- package/src/template/frontends/solid-ts/src/main.tsx +5 -0
- package/src/template/frontends/solid-ts/tsconfig.json +15 -0
- package/src/template/frontends/solid-ts/vite.config.ts +10 -0
- package/src/template/{src → frontends/vanilla-js/src}/index.html +110 -178
- package/src/template/frontends/vanilla-ts/src/index.html +51 -0
- package/src/template/frontends/vanilla-ts/src/main.ts +193 -0
- package/src/template/frontends/vanilla-ts/tsconfig.json +13 -0
- package/src/template/frontends/vanilla-ts/vite.config.ts +8 -0
- package/src/template/frontends/vue-js/src/App.vue +155 -0
- package/src/template/frontends/vue-js/src/index.html +12 -0
- package/src/template/frontends/vue-js/src/main.js +5 -0
- package/src/template/frontends/vue-js/vite.config.js +10 -0
- package/src/template/frontends/vue-ts/src/App.vue +158 -0
- package/src/template/frontends/vue-ts/src/index.html +12 -0
- package/src/template/frontends/vue-ts/src/main.ts +5 -0
- package/src/template/frontends/vue-ts/tsconfig.json +13 -0
- package/src/template/frontends/vue-ts/vite.config.ts +10 -0
- package/src/template/{src → shared}/api.js +38 -1
- package/src/template/shared/logo.svg +6 -0
- package/src/template/shared/style.css +226 -0
- package/src/cli.cjs +0 -352
package/src/rust/src/lib.rs
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
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
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
-
|
|
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 '{}'
|
|
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 = {
|
|
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 =
|
|
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 _ =
|
|
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:
|
|
1072
|
-
let settings:
|
|
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 {
|
|
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");
|