@thomasfarineau/anvil 0.0.1

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.
@@ -0,0 +1,1104 @@
1
+ use std::collections::HashMap;
2
+ use std::io::{BufRead, BufReader, Write};
3
+ use std::path::{Path, PathBuf};
4
+ use std::sync::Mutex;
5
+
6
+ use chrono::Local;
7
+ use futures_util::StreamExt;
8
+ use serde::{Deserialize, Serialize};
9
+ use tauri::{AppHandle, Emitter, Manager, State};
10
+
11
+ // ── URLs ──────────────────────────────────────────────────────────────────────
12
+
13
+ const MANIFEST_URL: &str = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json";
14
+ const RESOURCES_URL: &str = "https://resources.download.minecraft.net/";
15
+
16
+ // ── Config (config.json) ──────────────────────────────────────────────────────
17
+
18
+ #[derive(Serialize, Deserialize, Clone, Debug)]
19
+ pub struct InstanceConfig {
20
+ pub id: String,
21
+ pub name: String,
22
+ pub mc_version: String,
23
+ #[serde(default)] pub loader: String,
24
+ #[serde(default)] pub loader_version: String,
25
+ #[serde(default)] pub server_ip: String,
26
+ #[serde(default = "default_port")] pub server_port: u16,
27
+ }
28
+
29
+ #[derive(Serialize, Deserialize, Clone, Debug)]
30
+ pub struct LauncherConfig {
31
+ #[serde(default)] pub identifier: String,
32
+ #[serde(default = "default_data_folder")] pub data_folder: String,
33
+ #[serde(default = "default_java")] pub java_version: u8,
34
+ #[serde(default)] pub update_url: String,
35
+ #[serde(default = "default_app_name")] pub app_name: String,
36
+ #[serde(default = "default_true")] pub window_decorations: bool,
37
+ #[serde(default = "default_true")] pub window_resizable: bool,
38
+ #[serde(default)] pub logo: String,
39
+ #[serde(default = "default_session")] pub session: String,
40
+ #[serde(default)] pub instances: Vec<InstanceConfig>,
41
+ }
42
+
43
+ fn default_data_folder() -> String { "HomeLauncher".into() }
44
+ fn default_app_name() -> String { "HomeLauncher".into() }
45
+ fn default_java() -> u8 { 21 }
46
+ fn default_port() -> u16 { 25565 }
47
+ fn default_true() -> bool { true }
48
+ fn default_session() -> String { "none".into() }
49
+
50
+ impl Default for LauncherConfig {
51
+ fn default() -> Self {
52
+ Self {
53
+ identifier: String::new(),
54
+ data_folder: default_data_folder(),
55
+ java_version: default_java(),
56
+ update_url: String::new(),
57
+ app_name: default_app_name(),
58
+ window_decorations: true,
59
+ window_resizable: true,
60
+ logo: String::new(),
61
+ session: default_session(),
62
+ instances: Vec::new(),
63
+ }
64
+ }
65
+ }
66
+
67
+ // ── Settings utilisateur ──────────────────────────────────────────────────────
68
+
69
+ #[derive(Serialize, Deserialize, Clone, Debug)]
70
+ pub struct Settings {
71
+ #[serde(default)] pub username: String,
72
+ #[serde(default)] pub launcher_dir: Option<String>,
73
+ #[serde(default = "default_memory")] pub max_memory: u32,
74
+ }
75
+
76
+ fn default_memory() -> u32 { 2048 }
77
+
78
+ impl Default for Settings {
79
+ fn default() -> Self {
80
+ Self { username: String::new(), launcher_dir: None, max_memory: default_memory() }
81
+ }
82
+ }
83
+
84
+ // ── Session custom ────────────────────────────────────────────────────────────
85
+
86
+ #[derive(Serialize, Deserialize, Clone, Debug)]
87
+ pub struct CustomSession {
88
+ pub username: String,
89
+ pub uuid: String,
90
+ pub access_token: String,
91
+ }
92
+
93
+ // ── État global ───────────────────────────────────────────────────────────────
94
+
95
+ pub struct AppState {
96
+ pub config: LauncherConfig,
97
+ pub settings: Mutex<Settings>,
98
+ pub data_dir: PathBuf,
99
+ pub custom_session: Mutex<Option<CustomSession>>,
100
+ }
101
+
102
+ // ── Types Mojang (internes) ───────────────────────────────────────────────────
103
+
104
+ #[derive(Deserialize)]
105
+ struct ManifestJson { versions: Vec<ManifestEntry> }
106
+
107
+ #[derive(Deserialize)]
108
+ struct ManifestEntry { id: String, url: String }
109
+
110
+ #[derive(Deserialize, Default)]
111
+ struct VersionJson {
112
+ #[serde(rename = "mainClass", default)] main_class: String,
113
+ #[serde(default)] assets: String,
114
+ #[serde(rename = "assetIndex")] asset_index: Option<AssetIndexRef>,
115
+ #[serde(default)] downloads: HashMap<String, FileDownload>,
116
+ #[serde(default)] libraries: Vec<LibEntry>,
117
+ arguments: Option<GameArgs>,
118
+ #[serde(rename = "minecraftArguments")] legacy_args: Option<String>,
119
+ #[serde(rename = "inheritsFrom")] inherits_from: Option<String>,
120
+ }
121
+
122
+ #[derive(Deserialize, Clone)]
123
+ struct AssetIndexRef { id: String, url: String }
124
+
125
+ #[derive(Deserialize, Clone)]
126
+ struct FileDownload {
127
+ #[serde(default)] url: String,
128
+ path: Option<String>,
129
+ }
130
+
131
+ #[derive(Deserialize, Clone)]
132
+ struct LibEntry {
133
+ name: String,
134
+ downloads: Option<LibDownloads>,
135
+ rules: Option<Vec<OsRule>>,
136
+ url: Option<String>,
137
+ }
138
+
139
+ #[derive(Deserialize, Clone)]
140
+ struct LibDownloads {
141
+ artifact: Option<FileDownload>,
142
+ classifiers: Option<HashMap<String, FileDownload>>,
143
+ }
144
+
145
+ #[derive(Deserialize, Clone)]
146
+ struct OsRule { action: String, os: Option<OsSpec> }
147
+
148
+ #[derive(Deserialize, Clone)]
149
+ struct OsSpec { name: Option<String> }
150
+
151
+ #[derive(Deserialize, Clone, Default)]
152
+ struct GameArgs {
153
+ #[serde(default)] game: Vec<serde_json::Value>,
154
+ #[serde(default)] jvm: Vec<serde_json::Value>,
155
+ }
156
+
157
+ #[derive(Deserialize)]
158
+ struct AssetIndex { objects: HashMap<String, AssetObj> }
159
+
160
+ #[derive(Deserialize)]
161
+ struct AssetObj { hash: String }
162
+
163
+ // ── Types Adoptium ────────────────────────────────────────────────────────────
164
+
165
+ #[derive(Deserialize)]
166
+ struct AdoptiumEntry { binary: AdoptiumBinary }
167
+ #[derive(Deserialize)]
168
+ struct AdoptiumBinary { package: AdoptiumPackage }
169
+ #[derive(Deserialize)]
170
+ struct AdoptiumPackage { link: String }
171
+
172
+ // ── Types updater ─────────────────────────────────────────────────────────────
173
+
174
+ #[derive(Deserialize)]
175
+ struct UpdateManifest {
176
+ version: String,
177
+ #[serde(default)] windows: String,
178
+ #[serde(default)] linux: String,
179
+ #[serde(default)] macos: String,
180
+ #[serde(default)] notes: String,
181
+ }
182
+
183
+ // ── Events Tauri ──────────────────────────────────────────────────────────────
184
+
185
+ #[derive(Serialize, Clone)]
186
+ struct SetupProgress { step: String, current: usize, total: usize, label: String, #[serde(default)] error: bool }
187
+
188
+ #[derive(Serialize, Clone)]
189
+ struct GameOutput { instance_id: String, text: String, stderr: bool }
190
+
191
+ #[derive(Serialize, Clone)]
192
+ pub struct InstanceStatus {
193
+ pub id: String,
194
+ pub name: String,
195
+ pub installed: bool,
196
+ }
197
+
198
+ #[derive(Serialize, Clone)]
199
+ pub struct InitStatus {
200
+ pub launcher_dir: String,
201
+ pub java_ok: bool,
202
+ pub instances: Vec<InstanceStatus>,
203
+ }
204
+
205
+ #[derive(Serialize, Clone)]
206
+ pub struct UpdateInfo {
207
+ pub version: String,
208
+ pub url: String,
209
+ pub notes: String,
210
+ }
211
+
212
+ // ── Utilitaires OS ────────────────────────────────────────────────────────────
213
+
214
+ fn os_name() -> &'static str {
215
+ if cfg!(windows) { "windows" } else if cfg!(target_os = "macos") { "osx" } else { "linux" }
216
+ }
217
+
218
+ fn native_classifier() -> &'static str {
219
+ if cfg!(windows) { "natives-windows" } else if cfg!(target_os = "macos") { "natives-osx" } else { "natives-linux" }
220
+ }
221
+
222
+ // ── Chemins ───────────────────────────────────────────────────────────────────
223
+
224
+ fn default_launcher_dir(cfg: &LauncherConfig) -> PathBuf {
225
+ dirs::data_dir()
226
+ .unwrap_or_else(|| dirs::home_dir().unwrap_or_default())
227
+ .join(&cfg.data_folder)
228
+ }
229
+
230
+ fn get_launcher_dir(s: &Settings, cfg: &LauncherConfig) -> PathBuf {
231
+ s.launcher_dir.as_deref().map(PathBuf::from).unwrap_or_else(|| default_launcher_dir(cfg))
232
+ }
233
+
234
+ /// Dossier partagé : versions, libraries, assets
235
+ fn shared_game_dir(launcher_dir: &Path) -> PathBuf { launcher_dir.join("game") }
236
+
237
+ /// Dossier propre à l'instance : saves, options.txt, etc.
238
+ fn instance_data_dir(launcher_dir: &Path, instance: &InstanceConfig) -> PathBuf {
239
+ launcher_dir.join("instances").join(&instance.id)
240
+ }
241
+
242
+ fn log_dir(launcher_dir: &Path) -> PathBuf { launcher_dir.join("logs") }
243
+
244
+ fn find_bundled_java(launcher_dir: &Path) -> Option<PathBuf> {
245
+ let java_dir = launcher_dir.join("java");
246
+ let bin = if cfg!(windows) { "javaw.exe" } else { "java" };
247
+ let direct = java_dir.join("bin").join(bin);
248
+ if direct.exists() { return Some(direct); }
249
+ if let Ok(entries) = std::fs::read_dir(&java_dir) {
250
+ for e in entries.flatten() {
251
+ if e.path().is_dir() {
252
+ let c = e.path().join("bin").join(bin);
253
+ if c.exists() { return Some(c); }
254
+ }
255
+ }
256
+ }
257
+ None
258
+ }
259
+
260
+ fn version_folder(inst: &InstanceConfig) -> String {
261
+ if !inst.loader.is_empty() {
262
+ format!("{}-loader-{}-{}", inst.loader, inst.loader_version, inst.mc_version)
263
+ } else {
264
+ inst.mc_version.clone()
265
+ }
266
+ }
267
+
268
+ fn is_instance_installed(launcher_dir: &Path, inst: &InstanceConfig) -> bool {
269
+ let ver_id = version_folder(inst);
270
+ shared_game_dir(launcher_dir)
271
+ .join("versions").join(&ver_id).join(format!("{ver_id}.json"))
272
+ .exists()
273
+ }
274
+
275
+ // ── Logs ──────────────────────────────────────────────────────────────────────
276
+
277
+ fn launcher_log(log_dir: &Path, msg: &str) {
278
+ let _ = std::fs::create_dir_all(log_dir);
279
+ if let Ok(mut f) = std::fs::OpenOptions::new()
280
+ .create(true).append(true).open(log_dir.join("launcher.log"))
281
+ {
282
+ let ts = Local::now().format("%Y-%m-%d %H:%M:%S");
283
+ let _ = writeln!(f, "[{ts}] {msg}");
284
+ }
285
+ }
286
+
287
+ // ── Chargement config.json ────────────────────────────────────────────────────
288
+
289
+ fn find_config_json(app: &AppHandle) -> LauncherConfig {
290
+ if let Ok(res) = app.path().resource_dir() {
291
+ let p = res.join("config.json");
292
+ if let Ok(s) = std::fs::read_to_string(&p) {
293
+ if let Ok(cfg) = serde_json::from_str::<LauncherConfig>(&s) { return cfg; }
294
+ }
295
+ }
296
+ if let Ok(exe) = std::env::current_exe() {
297
+ let mut dir = exe.parent().map(PathBuf::from);
298
+ for _ in 0..8 {
299
+ if let Some(d) = dir.take() {
300
+ let p = d.join("config.json");
301
+ if let Ok(s) = std::fs::read_to_string(&p) {
302
+ if let Ok(cfg) = serde_json::from_str::<LauncherConfig>(&s) { return cfg; }
303
+ }
304
+ dir = d.parent().map(PathBuf::from);
305
+ }
306
+ }
307
+ }
308
+ LauncherConfig::default()
309
+ }
310
+
311
+ // ── Utilitaires JSON ──────────────────────────────────────────────────────────
312
+
313
+ fn load_json<T: serde::de::DeserializeOwned + Default>(path: &Path) -> T {
314
+ std::fs::read_to_string(path).ok()
315
+ .and_then(|s| serde_json::from_str(&s).ok())
316
+ .unwrap_or_default()
317
+ }
318
+
319
+ fn save_json<T: Serialize>(path: &Path, value: &T) -> Result<(), String> {
320
+ if let Some(p) = path.parent() { std::fs::create_dir_all(p).map_err(|e| e.to_string())?; }
321
+ std::fs::write(path, serde_json::to_string_pretty(value).map_err(|e| e.to_string())?)
322
+ .map_err(|e| e.to_string())
323
+ }
324
+
325
+ // ── Utilitaires Minecraft ─────────────────────────────────────────────────────
326
+
327
+ fn lib_applies(lib: &LibEntry) -> bool {
328
+ let Some(rules) = &lib.rules else { return true };
329
+ let mut allow = false;
330
+ for rule in rules {
331
+ let os_match = rule.os.as_ref().and_then(|o| o.name.as_deref())
332
+ .map_or(true, |n| n == os_name());
333
+ if os_match { allow = rule.action == "allow"; }
334
+ }
335
+ allow
336
+ }
337
+
338
+ fn maven_path(name: &str) -> String {
339
+ let p: Vec<&str> = name.splitn(3, ':').collect();
340
+ if p.len() < 3 { return name.replace(':', "/"); }
341
+ format!("{}/{}/{}/{}-{}.jar", p[0].replace('.', "/"), p[1], p[2], p[1], p[2])
342
+ }
343
+
344
+ async fn http_bytes(client: &reqwest::Client, url: &str) -> Result<Vec<u8>, String> {
345
+ client.get(url).send().await.map_err(|e| format!("GET {url}: {e}"))?
346
+ .bytes().await.map_err(|e| e.to_string()).map(|b| b.to_vec())
347
+ }
348
+
349
+ async fn save_if_missing(client: &reqwest::Client, url: &str, dest: &Path) -> Result<(), String> {
350
+ if dest.exists() { return Ok(()); }
351
+ if let Some(p) = dest.parent() { std::fs::create_dir_all(p).map_err(|e| e.to_string())?; }
352
+ let bytes = http_bytes(client, url).await?;
353
+ std::fs::write(dest, &bytes).map_err(|e| e.to_string())
354
+ }
355
+
356
+ fn unzip_natives(zip_bytes: &[u8], dest: &Path) -> Result<(), String> {
357
+ use std::io::Read;
358
+ let mut archive = zip::ZipArchive::new(std::io::Cursor::new(zip_bytes))
359
+ .map_err(|e| e.to_string())?;
360
+ for i in 0..archive.len() {
361
+ let mut file = archive.by_index(i).map_err(|e| e.to_string())?;
362
+ if file.is_dir() || file.name().starts_with("META-INF") { continue; }
363
+ let out = dest.join(file.name());
364
+ if let Some(p) = out.parent() { std::fs::create_dir_all(p).map_err(|e| e.to_string())?; }
365
+ let mut buf = Vec::new();
366
+ file.read_to_end(&mut buf).map_err(|e| e.to_string())?;
367
+ std::fs::write(&out, &buf).map_err(|e| e.to_string())?;
368
+ }
369
+ Ok(())
370
+ }
371
+
372
+ fn extract_zip_file(archive: &Path, dest: &Path) -> Result<(), String> {
373
+ use std::io::Read;
374
+ let file = std::fs::File::open(archive).map_err(|e| e.to_string())?;
375
+ let mut zip = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;
376
+ for i in 0..zip.len() {
377
+ let mut entry = zip.by_index(i).map_err(|e| e.to_string())?;
378
+ if entry.is_dir() { continue; }
379
+ let out = dest.join(entry.name());
380
+ if let Some(p) = out.parent() { std::fs::create_dir_all(p).map_err(|e| e.to_string())?; }
381
+ let mut buf = Vec::new();
382
+ entry.read_to_end(&mut buf).map_err(|e| e.to_string())?;
383
+ std::fs::write(&out, &buf).map_err(|e| e.to_string())?;
384
+ }
385
+ Ok(())
386
+ }
387
+
388
+ fn extract_tgz(archive: &Path, dest: &Path) -> Result<(), String> {
389
+ let file = std::fs::File::open(archive).map_err(|e| e.to_string())?;
390
+ let gz = flate2::read::GzDecoder::new(file);
391
+ let mut tar = tar::Archive::new(gz);
392
+ tar.unpack(dest).map_err(|e| e.to_string())
393
+ }
394
+
395
+ fn fnv64(s: &str) -> u64 {
396
+ let mut h: u64 = 0xcbf29ce484222325;
397
+ for b in s.bytes() { h ^= b as u64; h = h.wrapping_mul(0x100000001b3); }
398
+ h
399
+ }
400
+
401
+ // ── Commandes Tauri ───────────────────────────────────────────────────────────
402
+
403
+ #[tauri::command]
404
+ fn get_server_config(state: State<AppState>) -> LauncherConfig { state.config.clone() }
405
+
406
+ #[tauri::command]
407
+ fn get_settings(state: State<AppState>) -> Settings { state.settings.lock().unwrap().clone() }
408
+
409
+ #[tauri::command]
410
+ fn save_settings(state: State<AppState>, settings: Settings) -> Result<(), String> {
411
+ save_json(&state.data_dir.join("settings.json"), &settings)?;
412
+ *state.settings.lock().unwrap() = settings;
413
+ Ok(())
414
+ }
415
+
416
+ #[tauri::command]
417
+ fn get_default_launcher_dir(state: State<AppState>) -> String {
418
+ default_launcher_dir(&state.config).to_string_lossy().into()
419
+ }
420
+
421
+ #[tauri::command]
422
+ fn get_init_status(state: State<AppState>) -> InitStatus {
423
+ let settings = state.settings.lock().unwrap().clone();
424
+ let launcher_dir = get_launcher_dir(&settings, &state.config);
425
+ let instances = state.config.instances.iter().map(|inst| InstanceStatus {
426
+ id: inst.id.clone(),
427
+ name: inst.name.clone(),
428
+ installed: is_instance_installed(&launcher_dir, inst),
429
+ }).collect();
430
+ InitStatus {
431
+ launcher_dir: launcher_dir.to_string_lossy().into(),
432
+ java_ok: find_bundled_java(&launcher_dir).is_some(),
433
+ instances,
434
+ }
435
+ }
436
+
437
+ // ── Streaming download ────────────────────────────────────────────────────────
438
+
439
+ async fn download_to_file(
440
+ app: &AppHandle,
441
+ client: &reqwest::Client,
442
+ url: &str,
443
+ dest: &Path,
444
+ step: &str,
445
+ label: &str,
446
+ ) -> Result<(), String> {
447
+ if let Some(p) = dest.parent() { std::fs::create_dir_all(p).map_err(|e| e.to_string())?; }
448
+ let resp = client.get(url).send().await.map_err(|e| e.to_string())?;
449
+ let total = resp.content_length().unwrap_or(0) as usize;
450
+ let mut file = std::fs::File::create(dest).map_err(|e| e.to_string())?;
451
+ let mut stream = resp.bytes_stream();
452
+ let mut downloaded = 0usize;
453
+ let mut last_emit = 0usize;
454
+
455
+ while let Some(chunk) = stream.next().await {
456
+ let chunk = chunk.map_err(|e| e.to_string())?;
457
+ file.write_all(&chunk).map_err(|e| e.to_string())?;
458
+ downloaded += chunk.len();
459
+ if downloaded - last_emit > 256 * 1024 || (total > 0 && downloaded >= total) {
460
+ last_emit = downloaded;
461
+ let pct = if total > 0 { downloaded * 100 / total } else { 0 };
462
+ let mb = downloaded / (1024 * 1024);
463
+ let total_mb = total / (1024 * 1024);
464
+ let _ = app.emit("setup:progress", SetupProgress {
465
+ step: step.to_string(),
466
+ current: pct,
467
+ total: 100,
468
+ label: format!("{label} ({mb}/{total_mb} MB)"),
469
+ error: false,
470
+ });
471
+ }
472
+ }
473
+ Ok(())
474
+ }
475
+
476
+ // ── Installation Java ─────────────────────────────────────────────────────────
477
+
478
+ async fn install_java_bundled(app: &AppHandle, launcher_dir: &Path, java_major: u8) -> Result<(), String> {
479
+ let client = reqwest::Client::builder().user_agent("HomeLauncher/1.0").build()
480
+ .map_err(|e| e.to_string())?;
481
+ let os = if cfg!(windows) { "windows" } else if cfg!(target_os = "macos") { "mac" } else { "linux" };
482
+ let arch = if cfg!(target_arch = "aarch64") { "aarch64" } else { "x64" };
483
+ let api = format!(
484
+ "https://api.adoptium.net/v3/assets/latest/{java_major}/hotspot?os={os}&arch={arch}&image_type=jre"
485
+ );
486
+
487
+ let _ = app.emit("setup:progress", SetupProgress {
488
+ step: "java".into(), current: 0, total: 100,
489
+ label: "Récupération des infos Java...".into(), error: false,
490
+ });
491
+
492
+ let entries: Vec<AdoptiumEntry> = client.get(&api).send().await
493
+ .map_err(|e| e.to_string())?.json().await.map_err(|e| e.to_string())?;
494
+ let url = &entries.first().ok_or("Aucune version Java sur Adoptium")?.binary.package.link;
495
+ let ext = if cfg!(windows) { "java_jre.zip" } else { "java_jre.tar.gz" };
496
+ let archive = launcher_dir.join(ext);
497
+
498
+ download_to_file(app, &client, url, &archive, "java", "Java JRE").await?;
499
+
500
+ let _ = app.emit("setup:progress", SetupProgress {
501
+ step: "java".into(), current: 95, total: 100, label: "Extraction...".into(), error: false,
502
+ });
503
+ let java_dir = launcher_dir.join("java");
504
+ std::fs::create_dir_all(&java_dir).map_err(|e| e.to_string())?;
505
+ if cfg!(windows) { extract_zip_file(&archive, &java_dir)?; } else { extract_tgz(&archive, &java_dir)?; }
506
+ std::fs::remove_file(&archive).ok();
507
+
508
+ let _ = app.emit("setup:progress", SetupProgress {
509
+ step: "java".into(), current: 100, total: 100, label: "Java installé.".into(), error: false,
510
+ });
511
+ Ok(())
512
+ }
513
+
514
+ // ── Installation Minecraft ────────────────────────────────────────────────────
515
+
516
+ async fn install_vanilla(
517
+ app: &AppHandle,
518
+ client: &reqwest::Client,
519
+ game_dir: &Path,
520
+ inst: &InstanceConfig,
521
+ ) -> Result<(), String> {
522
+ macro_rules! prog {
523
+ ($c:expr, $l:expr) => {
524
+ let _ = app.emit("setup:progress", SetupProgress {
525
+ step: inst.id.clone(), current: $c, total: 100, label: $l.to_string(), error: false,
526
+ });
527
+ };
528
+ }
529
+
530
+ prog!(0, "Manifeste Minecraft...");
531
+ let manifest: ManifestJson = client.get(MANIFEST_URL).send().await
532
+ .map_err(|e| e.to_string())?.json().await.map_err(|e| e.to_string())?;
533
+ let entry = manifest.versions.iter().find(|v| v.id == inst.mc_version)
534
+ .ok_or_else(|| format!("Version {} introuvable", inst.mc_version))?;
535
+
536
+ prog!(3, "Métadonnées...");
537
+ let ver_dir = game_dir.join("versions").join(&inst.mc_version);
538
+ std::fs::create_dir_all(&ver_dir).map_err(|e| e.to_string())?;
539
+ let json_path = ver_dir.join(format!("{}.json", inst.mc_version));
540
+ if !json_path.exists() {
541
+ let raw = http_bytes(client, &entry.url).await?;
542
+ std::fs::write(&json_path, &raw).map_err(|e| e.to_string())?;
543
+ }
544
+ let ver: VersionJson = serde_json::from_str(
545
+ &std::fs::read_to_string(&json_path).map_err(|e| e.to_string())?
546
+ ).map_err(|e| e.to_string())?;
547
+
548
+ prog!(6, "Client Minecraft...");
549
+ if let Some(dl) = ver.downloads.get("client") {
550
+ save_if_missing(client, &dl.url, &ver_dir.join(format!("{}.jar", inst.mc_version))).await?;
551
+ }
552
+
553
+ let libs_dir = game_dir.join("libraries");
554
+ let natives_dir = ver_dir.join("natives");
555
+ std::fs::create_dir_all(&natives_dir).map_err(|e| e.to_string())?;
556
+ let libs: Vec<&LibEntry> = ver.libraries.iter().filter(|l| lib_applies(l)).collect();
557
+ for (i, lib) in libs.iter().enumerate() {
558
+ if i % 5 == 0 {
559
+ prog!(6 + i * 44 / libs.len().max(1), format!("Bibliothèques ({}/{})", i + 1, libs.len()));
560
+ }
561
+ if let Some(dls) = &lib.downloads {
562
+ if let Some(art) = &dls.artifact {
563
+ if !art.url.is_empty() {
564
+ let rel = art.path.as_deref().filter(|p| !p.is_empty())
565
+ .map(|p| p.to_string()).unwrap_or_else(|| maven_path(&lib.name));
566
+ save_if_missing(client, &art.url, &libs_dir.join(&rel)).await?;
567
+ }
568
+ }
569
+ if let Some(cls) = &dls.classifiers {
570
+ if let Some(dl) = cls.get(native_classifier()) {
571
+ if !dl.url.is_empty() {
572
+ unzip_natives(&http_bytes(client, &dl.url).await?, &natives_dir)?;
573
+ }
574
+ }
575
+ }
576
+ }
577
+ }
578
+
579
+ prog!(50, "Index des assets...");
580
+ let assets_dir = game_dir.join("assets");
581
+ std::fs::create_dir_all(assets_dir.join("indexes")).map_err(|e| e.to_string())?;
582
+ if let Some(ai) = &ver.asset_index {
583
+ let idx_path = assets_dir.join("indexes").join(format!("{}.json", ai.id));
584
+ save_if_missing(client, &ai.url, &idx_path).await?;
585
+ let index: AssetIndex = serde_json::from_str(
586
+ &std::fs::read_to_string(&idx_path).map_err(|e| e.to_string())?
587
+ ).map_err(|e| e.to_string())?;
588
+ let objs: Vec<_> = index.objects.values().collect();
589
+ for (i, obj) in objs.iter().enumerate() {
590
+ if i % 100 == 0 {
591
+ prog!(50 + i * 29 / objs.len().max(1), format!("Assets ({}/{})", i, objs.len()));
592
+ }
593
+ let prefix = &obj.hash[..2];
594
+ save_if_missing(client,
595
+ &format!("{RESOURCES_URL}{prefix}/{}", obj.hash),
596
+ &assets_dir.join("objects").join(prefix).join(&obj.hash),
597
+ ).await?;
598
+ }
599
+ }
600
+ Ok(())
601
+ }
602
+
603
+ async fn install_fabric(
604
+ app: &AppHandle,
605
+ client: &reqwest::Client,
606
+ game_dir: &Path,
607
+ inst: &InstanceConfig,
608
+ ) -> Result<(), String> {
609
+ install_vanilla(app, client, game_dir, inst).await?;
610
+
611
+ let _ = app.emit("setup:progress", SetupProgress {
612
+ step: inst.id.clone(), current: 80, total: 100, label: "Profil Fabric...".into(), error: false,
613
+ });
614
+
615
+ let url = format!(
616
+ "https://meta.fabricmc.net/v2/versions/loader/{}/{}/profile/json",
617
+ inst.mc_version, inst.loader_version
618
+ );
619
+ let raw = http_bytes(client, &url).await?;
620
+ let profile: VersionJson = serde_json::from_slice(&raw).map_err(|e| e.to_string())?;
621
+ let fabric_id = format!("fabric-loader-{}-{}", inst.loader_version, inst.mc_version);
622
+ let fabric_dir = game_dir.join("versions").join(&fabric_id);
623
+ std::fs::create_dir_all(&fabric_dir).map_err(|e| e.to_string())?;
624
+ std::fs::write(fabric_dir.join(format!("{fabric_id}.json")), &raw)
625
+ .map_err(|e| e.to_string())?;
626
+
627
+ let libs_dir = game_dir.join("libraries");
628
+ for (i, lib) in profile.libraries.iter().enumerate() {
629
+ let _ = app.emit("setup:progress", SetupProgress {
630
+ step: inst.id.clone(),
631
+ current: 80 + i * 19 / profile.libraries.len().max(1),
632
+ total: 100,
633
+ label: format!("Fabric libs ({}/{})", i + 1, profile.libraries.len()),
634
+ error: false,
635
+ });
636
+ let base_raw = lib.url.as_deref().unwrap_or("https://maven.fabricmc.net/");
637
+ let base = if base_raw.ends_with('/') { base_raw.to_string() } else { format!("{base_raw}/") };
638
+ let rel = maven_path(&lib.name);
639
+ save_if_missing(client, &format!("{base}{rel}"), &libs_dir.join(&rel)).await?;
640
+ }
641
+ Ok(())
642
+ }
643
+
644
+ #[tauri::command]
645
+ async fn run_setup(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
646
+ let settings = state.settings.lock().unwrap().clone();
647
+ let launcher_dir = get_launcher_dir(&settings, &state.config);
648
+ let cfg = state.config.clone();
649
+ std::fs::create_dir_all(&launcher_dir).map_err(|e| e.to_string())?;
650
+
651
+ let logs = log_dir(&launcher_dir);
652
+ launcher_log(&logs, "Démarrage de la configuration initiale");
653
+
654
+ // Java
655
+ if find_bundled_java(&launcher_dir).is_none() {
656
+ launcher_log(&logs, &format!("Téléchargement Java {}", cfg.java_version));
657
+ install_java_bundled(&app, &launcher_dir, cfg.java_version).await?;
658
+ launcher_log(&logs, "Java installé");
659
+ } else {
660
+ let _ = app.emit("setup:progress", SetupProgress {
661
+ step: "java".into(), current: 100, total: 100, label: "Java déjà installé.".into(), error: false,
662
+ });
663
+ }
664
+
665
+ // Instances
666
+ let game_dir = shared_game_dir(&launcher_dir);
667
+ let client = reqwest::Client::builder().user_agent("HomeLauncher/1.0").build()
668
+ .map_err(|e| e.to_string())?;
669
+
670
+ for inst in &cfg.instances {
671
+ if is_instance_installed(&launcher_dir, inst) {
672
+ 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
682
+ } else {
683
+ install_vanilla(&app, &client, &game_dir, inst).await
684
+ };
685
+ match result {
686
+ Ok(_) => {
687
+ let _ = app.emit("setup:progress", SetupProgress {
688
+ step: inst.id.clone(), current: 100, total: 100,
689
+ label: format!("{} installé.", inst.name), error: false,
690
+ });
691
+ launcher_log(&logs, &format!("Instance '{}' installée", inst.id));
692
+ }
693
+ Err(e) => {
694
+ launcher_log(&logs, &format!("Erreur instance '{}': {e}", inst.id));
695
+ let _ = app.emit("setup:progress", SetupProgress {
696
+ step: inst.id.clone(), current: 0, total: 100,
697
+ label: format!("Erreur : {e}"), error: true,
698
+ });
699
+ }
700
+ }
701
+ }
702
+
703
+ let _ = app.emit("setup:done", ());
704
+ launcher_log(&logs, "Configuration initiale terminée");
705
+ Ok(())
706
+ }
707
+
708
+ // ── Vérification ──────────────────────────────────────────────────────────────
709
+
710
+ fn read_version_chain(game_dir: &Path, version_id: &str) -> Result<VersionJson, String> {
711
+ let text = std::fs::read_to_string(
712
+ game_dir.join("versions").join(version_id).join(format!("{version_id}.json"))
713
+ ).map_err(|_| format!("Version '{version_id}' non installée"))?;
714
+ let mut ver: VersionJson = serde_json::from_str(&text).map_err(|e| e.to_string())?;
715
+ if let Some(parent_id) = ver.inherits_from.take() {
716
+ let parent = read_version_chain(game_dir, &parent_id)?;
717
+ if ver.main_class.is_empty() { ver.main_class = parent.main_class; }
718
+ if ver.assets.is_empty() { ver.assets = parent.assets; }
719
+ if ver.asset_index.is_none() { ver.asset_index = parent.asset_index; }
720
+ if ver.downloads.is_empty() { ver.downloads = parent.downloads; }
721
+ if ver.legacy_args.is_none() { ver.legacy_args = parent.legacy_args; }
722
+ match (ver.arguments.as_mut(), parent.arguments) {
723
+ (Some(ca), Some(pa)) => {
724
+ let mut jvm = pa.jvm; jvm.extend(ca.jvm.drain(..)); ca.jvm = jvm;
725
+ ca.game.extend(pa.game);
726
+ }
727
+ (None, Some(pa)) => ver.arguments = Some(pa),
728
+ _ => {}
729
+ }
730
+ ver.libraries = { let mut m = parent.libraries; m.extend(std::mem::take(&mut ver.libraries)); m };
731
+ }
732
+ Ok(ver)
733
+ }
734
+
735
+ fn verify_instance(launcher_dir: &Path, inst: &InstanceConfig) -> Result<(), String> {
736
+ if find_bundled_java(launcher_dir).is_none() {
737
+ return Err("Java non installé — relancez la configuration initiale.".into());
738
+ }
739
+ let game_dir = shared_game_dir(launcher_dir);
740
+ let ver_id = version_folder(inst);
741
+ let ver_json = game_dir.join("versions").join(&ver_id).join(format!("{ver_id}.json"));
742
+ if !ver_json.exists() {
743
+ return Err(format!("Fichier de version introuvable : {ver_id}.json\nRelancez la configuration initiale."));
744
+ }
745
+ let ver = read_version_chain(&game_dir, &ver_id)?;
746
+ let libs_dir = game_dir.join("libraries");
747
+ let mut missing = Vec::new();
748
+
749
+ let client_jar = game_dir.join("versions").join(&inst.mc_version)
750
+ .join(format!("{}.jar", inst.mc_version));
751
+ if !client_jar.exists() { missing.push(format!("JAR client : {}.jar", inst.mc_version)); }
752
+
753
+ for lib in ver.libraries.iter().filter(|l| lib_applies(l)) {
754
+ let path = if let Some(dls) = &lib.downloads {
755
+ if let Some(art) = &dls.artifact {
756
+ let rel = art.path.as_deref().filter(|p| !p.is_empty())
757
+ .map(|p| p.to_string()).unwrap_or_else(|| maven_path(&lib.name));
758
+ libs_dir.join(rel)
759
+ } else { continue; }
760
+ } else {
761
+ libs_dir.join(maven_path(&lib.name))
762
+ };
763
+ if !path.exists() { missing.push(format!("lib : {}", lib.name)); }
764
+ }
765
+
766
+ if missing.is_empty() { Ok(()) } else {
767
+ Err(format!(
768
+ "{} fichier(s) manquant(s) dans '{}' :\n{}\n\nRelancez la configuration initiale.",
769
+ missing.len(), inst.name, missing.join("\n")
770
+ ))
771
+ }
772
+ }
773
+
774
+ #[tauri::command]
775
+ fn verify_game(state: State<AppState>, instance_id: String) -> Result<(), String> {
776
+ let settings = state.settings.lock().unwrap().clone();
777
+ let launcher_dir = get_launcher_dir(&settings, &state.config);
778
+ let inst = state.config.instances.iter().find(|i| i.id == instance_id)
779
+ .ok_or_else(|| format!("Instance '{instance_id}' introuvable dans config.json"))?;
780
+ verify_instance(&launcher_dir, inst)
781
+ }
782
+
783
+ // ── Lancement ─────────────────────────────────────────────────────────────────
784
+
785
+ fn mc_version_gte(version: &str, major: u32, minor: u32) -> bool {
786
+ let p: Vec<u32> = version.split('.').filter_map(|s| s.parse().ok()).collect();
787
+ (p.first().copied().unwrap_or(0), p.get(1).copied().unwrap_or(0)) >= (major, minor)
788
+ }
789
+
790
+ fn extract_args(args: &[serde_json::Value], vars: &HashMap<&str, String>) -> Vec<String> {
791
+ let mut out = Vec::new();
792
+ for arg in args {
793
+ match arg {
794
+ serde_json::Value::String(s) => out.push(subst(s, vars)),
795
+ serde_json::Value::Object(obj) => {
796
+ let mut allow = true;
797
+ if let Some(rules) = obj.get("rules").and_then(|r| r.as_array()) {
798
+ allow = false;
799
+ for rule in rules {
800
+ // Les règles "features" (demo, quick-play, custom resolution…) sont ignorées
801
+ // car nous n'activons aucune de ces fonctionnalités optionnelles.
802
+ if rule.get("features").is_some() { continue; }
803
+ let action = rule.get("action").and_then(|a| a.as_str()).unwrap_or("allow");
804
+ let os_ok = rule.get("os").and_then(|o| o.get("name"))
805
+ .and_then(|n| n.as_str()).map_or(true, |n| n == os_name());
806
+ if os_ok { allow = action == "allow"; }
807
+ }
808
+ }
809
+ if allow {
810
+ match obj.get("value") {
811
+ Some(serde_json::Value::Array(vs)) => {
812
+ for v in vs { if let serde_json::Value::String(s) = v { out.push(subst(s, vars)); } }
813
+ }
814
+ Some(serde_json::Value::String(s)) => out.push(subst(s, vars)),
815
+ _ => {}
816
+ }
817
+ }
818
+ }
819
+ _ => {}
820
+ }
821
+ }
822
+ out
823
+ }
824
+
825
+ fn subst(t: &str, vars: &HashMap<&str, String>) -> String {
826
+ let mut s = t.to_string();
827
+ for (k, v) in vars { s = s.replace(&format!("${{{k}}}"), v); }
828
+ s
829
+ }
830
+
831
+ #[tauri::command]
832
+ async fn launch_game(
833
+ app: AppHandle,
834
+ state: State<'_, AppState>,
835
+ instance_id: String,
836
+ ) -> Result<(), String> {
837
+ let settings = state.settings.lock().unwrap().clone();
838
+ let launcher_dir = get_launcher_dir(&settings, &state.config);
839
+ let inst = state.config.instances.iter().find(|i| i.id == instance_id)
840
+ .ok_or_else(|| format!("Instance '{instance_id}' introuvable"))?
841
+ .clone();
842
+
843
+ verify_instance(&launcher_dir, &inst)?;
844
+
845
+ let java = find_bundled_java(&launcher_dir).unwrap();
846
+ let game_dir = shared_game_dir(&launcher_dir);
847
+ let ver_id = version_folder(&inst);
848
+ let ver = read_version_chain(&game_dir, &ver_id)?;
849
+
850
+ let offline_auth = |name: &str| -> (String, String, String, &'static str) {
851
+ let h = fnv64(name);
852
+ let uid = format!("{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
853
+ (h >> 32) as u32, (h >> 16) as u16, h as u16 & 0x0fff,
854
+ 0x8000u16 | ((h >> 48) as u16 & 0x3fff), h & 0x0000_ffff_ffff);
855
+ (name.to_string(), uid, "0".into(), "offline")
856
+ };
857
+
858
+ let fallback_name = if settings.username.is_empty() { "Player".to_string() } else { settings.username.clone() };
859
+ let (username, uuid, access_token, user_type) = match state.config.session.as_str() {
860
+ "custom" => {
861
+ let guard = state.custom_session.lock().unwrap();
862
+ match &*guard {
863
+ Some(s) => (s.username.clone(), s.uuid.clone(), s.access_token.clone(), "msa"),
864
+ None => offline_auth(&fallback_name),
865
+ }
866
+ },
867
+ _ => offline_auth(&fallback_name),
868
+ };
869
+
870
+ let mc_ver_dir = game_dir.join("versions").join(&inst.mc_version);
871
+ let natives_dir = mc_ver_dir.join("natives");
872
+ let assets_dir = game_dir.join("assets");
873
+ let libs_dir = game_dir.join("libraries");
874
+ let inst_dir = instance_data_dir(&launcher_dir, &inst);
875
+ std::fs::create_dir_all(&inst_dir).map_err(|e| e.to_string())?;
876
+ let cp_sep = if cfg!(windows) { ";" } else { ":" };
877
+
878
+ let mut cp: Vec<String> = Vec::new();
879
+ for lib in ver.libraries.iter().filter(|l| lib_applies(l)) {
880
+ if let Some(dls) = &lib.downloads {
881
+ if let Some(art) = &dls.artifact {
882
+ let rel = art.path.as_deref().filter(|p| !p.is_empty())
883
+ .map(|p| p.to_string()).unwrap_or_else(|| maven_path(&lib.name));
884
+ let full = libs_dir.join(&rel);
885
+ if full.exists() { cp.push(full.to_string_lossy().into()); }
886
+ }
887
+ } else {
888
+ let full = libs_dir.join(maven_path(&lib.name));
889
+ if full.exists() { cp.push(full.to_string_lossy().into()); }
890
+ }
891
+ }
892
+ let client_jar = mc_ver_dir.join(format!("{}.jar", inst.mc_version));
893
+ if client_jar.exists() { cp.push(client_jar.to_string_lossy().into()); }
894
+ let classpath = cp.join(cp_sep);
895
+
896
+ let vars: HashMap<&str, String> = HashMap::from([
897
+ ("natives_directory", natives_dir.to_string_lossy().into()),
898
+ ("launcher_name", "HomeLauncher".into()),
899
+ ("launcher_version", "1.0".into()),
900
+ ("classpath", classpath.clone()),
901
+ ("auth_player_name", username.clone()),
902
+ ("version_name", ver_id.clone()),
903
+ ("game_directory", inst_dir.to_string_lossy().into()),
904
+ ("assets_root", assets_dir.to_string_lossy().into()),
905
+ ("assets_index_name", ver.assets.clone()),
906
+ ("auth_uuid", uuid.clone()),
907
+ ("auth_access_token", access_token.clone()),
908
+ ("user_type", user_type.into()),
909
+ ("version_type", "release".into()),
910
+ ("resolution_width", "854".into()),
911
+ ("resolution_height", "480".into()),
912
+ ]);
913
+
914
+ // Seul arg non fourni par le JSON de version
915
+ let mut cmd: Vec<String> = vec![format!("-Xmx{}M", settings.max_memory)];
916
+
917
+ if let Some(args) = &ver.arguments {
918
+ cmd.extend(extract_args(&args.jvm, &vars));
919
+ if !cmd.contains(&"-cp".to_string()) {
920
+ cmd.push("-cp".into()); cmd.push(classpath);
921
+ }
922
+ cmd.push(ver.main_class.clone());
923
+ cmd.extend(extract_args(&args.game, &vars));
924
+ } else {
925
+ cmd.extend([
926
+ format!("-Djava.library.path={}", natives_dir.display()),
927
+ "-cp".into(), classpath,
928
+ ]);
929
+ cmd.push(ver.main_class.clone());
930
+ if let Some(legacy) = &ver.legacy_args {
931
+ cmd.extend(legacy.split_whitespace().map(|p| subst(p, &vars)));
932
+ }
933
+ }
934
+
935
+ if !inst.server_ip.is_empty() {
936
+ if mc_version_gte(&inst.mc_version, 1, 20) {
937
+ // MC 1.20+ : --server/--port ont été remplacés par --quickPlayMultiplayer host:port
938
+ cmd.push("--quickPlayMultiplayer".into());
939
+ cmd.push(format!("{}:{}", inst.server_ip, inst.server_port));
940
+ } else {
941
+ cmd.push("--server".into());
942
+ cmd.push(inst.server_ip.clone());
943
+ cmd.push("--port".into());
944
+ cmd.push(inst.server_port.to_string());
945
+ }
946
+ }
947
+
948
+ // Log launcher
949
+ let logs = log_dir(&launcher_dir);
950
+ launcher_log(&logs, &format!(
951
+ "Lancement instance '{}' ({} {}) — Java: {}",
952
+ inst.id, inst.mc_version, inst.loader, java.display()
953
+ ));
954
+
955
+ let _ = app.emit("game:starting", &instance_id);
956
+
957
+ // Log de session (stdout + stderr)
958
+ let ts = Local::now().format("%Y%m%d_%H%M%S");
959
+ let log_path = logs.join(format!("{}-{ts}.log", inst.id));
960
+ std::fs::create_dir_all(&logs).ok();
961
+
962
+ // Log la commande dans la console UI
963
+ let _ = app.emit("game:output", GameOutput {
964
+ instance_id: instance_id.clone(),
965
+ text: format!("[HomeLauncher] Lancement : {} {}", java.display(), cmd.first().unwrap_or(&String::new())),
966
+ stderr: false,
967
+ });
968
+
969
+ let mut child = std::process::Command::new(&java)
970
+ .args(&cmd)
971
+ .current_dir(&inst_dir)
972
+ .stdout(std::process::Stdio::piped())
973
+ .stderr(std::process::Stdio::piped())
974
+ .spawn()
975
+ .map_err(|e| format!("Impossible de lancer Java: {e}"))?;
976
+
977
+ let app2 = app.clone();
978
+ let iid2 = instance_id.clone();
979
+ let log_path2 = log_path.clone();
980
+ if let Some(stdout) = child.stdout.take() {
981
+ std::thread::spawn(move || {
982
+ let mut log = std::fs::OpenOptions::new()
983
+ .create(true).append(true).open(&log_path2).ok();
984
+ for line in BufReader::new(stdout).lines().flatten() {
985
+ if let Some(ref mut f) = log { let _ = writeln!(f, "[OUT] {line}"); }
986
+ let _ = app2.emit("game:output", GameOutput {
987
+ instance_id: iid2.clone(), text: line, stderr: false,
988
+ });
989
+ }
990
+ });
991
+ }
992
+ let app3 = app.clone();
993
+ let iid3 = instance_id.clone();
994
+ if let Some(stderr) = child.stderr.take() {
995
+ std::thread::spawn(move || {
996
+ let mut log = std::fs::OpenOptions::new()
997
+ .create(true).append(true).open(&log_path).ok();
998
+ for line in BufReader::new(stderr).lines().flatten() {
999
+ if let Some(ref mut f) = log { let _ = writeln!(f, "[ERR] {line}"); }
1000
+ let _ = app3.emit("game:output", GameOutput {
1001
+ instance_id: iid3.clone(), text: line, stderr: true,
1002
+ });
1003
+ }
1004
+ });
1005
+ }
1006
+ let iid_exit = instance_id.clone();
1007
+ let logs2 = logs.clone();
1008
+ std::thread::spawn(move || {
1009
+ let code = child.wait().ok().and_then(|s| s.code()).unwrap_or(-1);
1010
+ 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 }));
1012
+ });
1013
+
1014
+ Ok(())
1015
+ }
1016
+
1017
+ // ── Mise à jour ───────────────────────────────────────────────────────────────
1018
+
1019
+ #[tauri::command]
1020
+ async fn check_update(state: State<'_, AppState>) -> Result<Option<UpdateInfo>, String> {
1021
+ let url = state.config.update_url.trim().to_string();
1022
+ if url.is_empty() { return Ok(None); }
1023
+ let client = reqwest::Client::builder().user_agent("HomeLauncher/1.0").build()
1024
+ .map_err(|e| e.to_string())?;
1025
+ let manifest: UpdateManifest = client.get(&url).send().await
1026
+ .map_err(|e| format!("Erreur réseau updater: {e}"))?
1027
+ .json().await.map_err(|e| format!("Réponse updater invalide: {e}"))?;
1028
+ let current = env!("CARGO_PKG_VERSION");
1029
+ if manifest.version.trim() <= current { return Ok(None); }
1030
+ let dl_url = if cfg!(windows) { &manifest.windows }
1031
+ else if cfg!(target_os = "macos") { &manifest.macos }
1032
+ else { &manifest.linux };
1033
+ if dl_url.is_empty() { return Ok(None); }
1034
+ Ok(Some(UpdateInfo { version: manifest.version, url: dl_url.clone(), notes: manifest.notes }))
1035
+ }
1036
+
1037
+ #[tauri::command]
1038
+ async fn do_update(app: AppHandle, url: String) -> Result<(), String> {
1039
+ let client = reqwest::Client::builder().user_agent("HomeLauncher/1.0").build()
1040
+ .map_err(|e| e.to_string())?;
1041
+ let ext = if cfg!(windows) { ".exe" } else if cfg!(target_os = "macos") { ".dmg" } else { ".AppImage" };
1042
+ let dest = std::env::temp_dir().join(format!("HomeLauncher-update{ext}"));
1043
+ let bytes = client.get(&url).send().await.map_err(|e| e.to_string())?
1044
+ .bytes().await.map_err(|e| e.to_string())?;
1045
+ std::fs::write(&dest, &bytes).map_err(|e| e.to_string())?;
1046
+ #[cfg(unix)] {
1047
+ use std::os::unix::fs::PermissionsExt;
1048
+ std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o755)).ok();
1049
+ }
1050
+ std::process::Command::new(&dest).spawn()
1051
+ .map_err(|e| format!("Impossible de lancer la mise à jour: {e}"))?;
1052
+ app.exit(0);
1053
+ Ok(())
1054
+ }
1055
+
1056
+ // ── Entry point ───────────────────────────────────────────────────────────────
1057
+
1058
+ #[cfg_attr(mobile, tauri::mobile_entry_point)]
1059
+ #[tauri::command]
1060
+ fn set_custom_session(state: State<'_, AppState>, session: Option<CustomSession>) -> Result<(), String> {
1061
+ *state.custom_session.lock().unwrap() = session;
1062
+ Ok(())
1063
+ }
1064
+
1065
+ pub fn run() {
1066
+ tauri::Builder::default()
1067
+ .setup(|app| {
1068
+ let data_dir = app.path().app_data_dir()
1069
+ .unwrap_or_else(|_| dirs::data_local_dir().unwrap_or_default().join("HomeLauncher"));
1070
+ 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"));
1073
+
1074
+ // Log du démarrage dans le launcher_dir final
1075
+ let launcher_dir = get_launcher_dir(&settings, &config);
1076
+ launcher_log(&log_dir(&launcher_dir),
1077
+ &format!("HomeLauncher démarré — {} instance(s)", config.instances.len()));
1078
+
1079
+ // Appliquer les options de fenêtre depuis config.json
1080
+ if let Some(win) = app.get_webview_window("main") {
1081
+ let _ = win.set_title(&config.app_name);
1082
+ let _ = win.set_decorations(config.window_decorations);
1083
+ let _ = win.set_resizable(config.window_resizable);
1084
+ }
1085
+
1086
+ app.manage(AppState { config, settings: Mutex::new(settings), data_dir, custom_session: Mutex::new(None) });
1087
+ if cfg!(debug_assertions) {
1088
+ app.handle().plugin(
1089
+ tauri_plugin_log::Builder::default().level(log::LevelFilter::Info).build()
1090
+ )?;
1091
+ }
1092
+ Ok(())
1093
+ })
1094
+ .invoke_handler(tauri::generate_handler![
1095
+ get_server_config,
1096
+ get_settings, save_settings,
1097
+ get_default_launcher_dir, get_init_status,
1098
+ run_setup, verify_game, launch_game,
1099
+ check_update, do_update,
1100
+ set_custom_session,
1101
+ ])
1102
+ .run(tauri::generate_context!())
1103
+ .expect("error while running tauri application");
1104
+ }