@thomasfarineau/anvil 0.0.3 → 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 CHANGED
@@ -25,6 +25,10 @@ npm run dev
25
25
 
26
26
  `create` is interactive: pick **TypeScript** or **JavaScript**, then **Vanilla**, **React**, **Vue** or **Solid**. Skip the prompts with `--template <id>` — see [`examples/`](./examples) for a ready-made project of each template.
27
27
 
28
+ ## Anvil Server
29
+
30
+ [`server/`](./server) is an optional self-hosted companion: it hosts your instances (mods, config files) and player accounts, so launchers can fetch them remotely instead of bundling everything at build time. Deploy it with `docker compose up -d` — see the [Anvil Server docs](https://thomasfarineau.github.io/anvil/server) for details.
31
+
28
32
  ## 📖 Documentation
29
33
 
30
34
  Full docs, guides and API reference: **[thomasfarineau.github.io/anvil](https://thomasfarineau.github.io/anvil/)** ([français](https://thomasfarineau.github.io/anvil/fr/))
@@ -35,6 +39,7 @@ Full docs, guides and API reference: **[thomasfarineau.github.io/anvil](https://
35
39
  - [config.json reference](https://thomasfarineau.github.io/anvil/config/)
36
40
  - [Mods per instance](https://thomasfarineau.github.io/anvil/config/mods)
37
41
  - [Session (offline / custom auth)](https://thomasfarineau.github.io/anvil/session)
42
+ - [Anvil Server](https://thomasfarineau.github.io/anvil/server)
38
43
  - [JavaScript API](https://thomasfarineau.github.io/anvil/api)
39
44
  - [App icon generation](https://thomasfarineau.github.io/anvil/icons)
40
45
  - [Build & distribution](https://thomasfarineau.github.io/anvil/build)
package/dist/cli.cjs CHANGED
@@ -35,7 +35,7 @@ var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports,
35
35
  var require_package = __commonJS((exports2, module2) => {
36
36
  module2.exports = {
37
37
  name: "@thomasfarineau/anvil",
38
- version: "0.0.3",
38
+ version: "0.0.4",
39
39
  description: "Zero-config Minecraft launcher framework built on Tauri",
40
40
  keywords: [
41
41
  "launcher",
@@ -47,6 +47,9 @@ var require_package = __commonJS((exports2, module2) => {
47
47
  anvil: "dist/cli.cjs",
48
48
  "create-anvil": "dist/cli.cjs"
49
49
  },
50
+ workspaces: [
51
+ "server"
52
+ ],
50
53
  files: [
51
54
  "dist/",
52
55
  "src/template/",
@@ -67,16 +70,16 @@ var require_package = __commonJS((exports2, module2) => {
67
70
  "docs:preview": "vitepress preview docs"
68
71
  },
69
72
  dependencies: {
70
- "@clack/prompts": "^1.6.0",
71
- "@tauri-apps/cli": "^2",
73
+ "@clack/prompts": "^1.7.0",
74
+ "@tauri-apps/cli": "^2.11.4",
72
75
  commander: "^15.0.0",
73
- sharp: "^0.33.0"
76
+ sharp: "^0.35.3"
74
77
  },
75
78
  devDependencies: {
76
- "@types/node": "^26.1.0",
79
+ "@types/node": "^26.1.1",
77
80
  bun: "^1.3.14",
78
- oxfmt: "^0.57.0",
79
- oxlint: "^1.72.0",
81
+ oxfmt: "^0.58.0",
82
+ oxlint: "^1.73.0",
80
83
  vitepress: "^1.6.4"
81
84
  },
82
85
  engines: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thomasfarineau/anvil",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "Zero-config Minecraft launcher framework built on Tauri",
5
5
  "keywords": [
6
6
  "launcher",
@@ -12,6 +12,9 @@
12
12
  "anvil": "dist/cli.cjs",
13
13
  "create-anvil": "dist/cli.cjs"
14
14
  },
15
+ "workspaces": [
16
+ "server"
17
+ ],
15
18
  "files": [
16
19
  "dist/",
17
20
  "src/template/",
@@ -32,16 +35,16 @@
32
35
  "docs:preview": "vitepress preview docs"
33
36
  },
34
37
  "dependencies": {
35
- "@clack/prompts": "^1.6.0",
36
- "@tauri-apps/cli": "^2",
38
+ "@clack/prompts": "^1.7.0",
39
+ "@tauri-apps/cli": "^2.11.4",
37
40
  "commander": "^15.0.0",
38
- "sharp": "^0.33.0"
41
+ "sharp": "^0.35.3"
39
42
  },
40
43
  "devDependencies": {
41
- "@types/node": "^26.1.0",
44
+ "@types/node": "^26.1.1",
42
45
  "bun": "^1.3.14",
43
- "oxfmt": "^0.57.0",
44
- "oxlint": "^1.72.0",
46
+ "oxfmt": "^0.58.0",
47
+ "oxlint": "^1.73.0",
45
48
  "vitepress": "^1.6.4"
46
49
  },
47
50
  "engines": {
@@ -4,7 +4,7 @@
4
4
  "title": "Anvil Launcher Config",
5
5
  "description": "Configuration file for an Anvil Minecraft launcher (config.json)",
6
6
  "type": "object",
7
- "required": ["instances"],
7
+ "anyOf": [{ "required": ["instances"] }, { "required": ["anvil-server"] }],
8
8
  "additionalProperties": false,
9
9
  "properties": {
10
10
  "$schema": {
@@ -48,10 +48,20 @@
48
48
  },
49
49
  "session": {
50
50
  "type": "string",
51
- "enum": ["none", "mojang", "custom"],
52
- "description": "Authentication mode. 'none': offline. 'custom': client calls MC.setSession() before launch.",
51
+ "enum": ["none", "mojang", "custom", "anvil-session"],
52
+ "description": "Authentication mode. 'none': offline. 'custom': client calls MC.setSession() before launch. 'anvil-session': login (username/password/2FA) handled by the anvil-server declared in 'anvil-server'.",
53
53
  "default": "none"
54
54
  },
55
+ "anvil-server": {
56
+ "type": "string",
57
+ "description": "Base URL of an anvil-server instance (e.g. https://mods.example.com). When set, the launcher fetches the list of enabled instances from this server at startup (the 'instances' field becomes unnecessary), and 'anvil-session' authentication is validated against it.",
58
+ "default": ""
59
+ },
60
+ "anvil-key": {
61
+ "type": "string",
62
+ "description": "API key generated in the anvil-server web UI (Clés API). Required to query the server: instance list, sessions and file downloads are refused without a valid key.",
63
+ "default": ""
64
+ },
55
65
  "window_decorations": {
56
66
  "type": "boolean",
57
67
  "description": "Show native window title bar and borders",
@@ -64,11 +74,11 @@
64
74
  },
65
75
  "instances": {
66
76
  "type": "array",
67
- "description": "List of Minecraft instances available in the launcher",
77
+ "description": "List of Minecraft instances available in the launcher. Ignored (replaced) when 'anvil-server' is set: the server's enabled instances are used instead.",
68
78
  "minItems": 1,
69
79
  "items": {
70
80
  "type": "object",
71
- "required": ["id", "name", "mc_version"],
81
+ "required": ["id"],
72
82
  "additionalProperties": false,
73
83
  "properties": {
74
84
  "id": {
@@ -128,6 +138,25 @@
128
138
  }
129
139
  }
130
140
  }
141
+ },
142
+ "files": {
143
+ "type": "array",
144
+ "description": "Extra files (mod configs, options.txt…) downloaded into the instance folder during setup. Always overwritten: the source (usually an anvil-server) is authoritative.",
145
+ "items": {
146
+ "type": "object",
147
+ "required": ["path", "url"],
148
+ "additionalProperties": false,
149
+ "properties": {
150
+ "path": {
151
+ "type": "string",
152
+ "description": "Relative target path inside the instance folder (e.g. \"config/mymod.toml\")"
153
+ },
154
+ "url": {
155
+ "type": "string",
156
+ "description": "Direct download URL of the file"
157
+ }
158
+ }
159
+ }
131
160
  }
132
161
  }
133
162
  }
@@ -4,6 +4,11 @@ export interface ModConfig {
4
4
  file_name?: string;
5
5
  }
6
6
 
7
+ export interface FileConfig {
8
+ path: string;
9
+ url: string;
10
+ }
11
+
7
12
  export interface InstanceConfig {
8
13
  id: string;
9
14
  name: string;
@@ -13,6 +18,7 @@ export interface InstanceConfig {
13
18
  server_ip: string;
14
19
  server_port: number;
15
20
  mods: ModConfig[];
21
+ files: FileConfig[];
16
22
  }
17
23
 
18
24
  export interface LauncherConfig {
@@ -23,9 +29,11 @@ export interface LauncherConfig {
23
29
  java_version: number;
24
30
  update_url: string;
25
31
  logo: string;
26
- session: 'none' | 'mojang' | 'custom';
32
+ session: 'none' | 'mojang' | 'custom' | 'anvil-session';
27
33
  window_decorations: boolean;
28
34
  window_resizable: boolean;
35
+ 'anvil-server': string;
36
+ 'anvil-key': string;
29
37
  instances: InstanceConfig[];
30
38
  }
31
39
 
@@ -87,6 +95,12 @@ export interface UpdateInfo {
87
95
  notes: string;
88
96
  }
89
97
 
98
+ export interface AnvilLoginResult {
99
+ status: 'ok' | 'totp_required';
100
+ username: string;
101
+ uuid: string;
102
+ }
103
+
90
104
  export declare const MC: {
91
105
  getConfig(): Promise<LauncherConfig>;
92
106
  getSettings(): Promise<Settings>;
@@ -117,6 +131,15 @@ export declare const MC: {
117
131
  doUpdate(url: string): Promise<void>;
118
132
  setSession(session: CustomSession): Promise<void>;
119
133
  clearSession(): Promise<void>;
134
+ anvilSession: {
135
+ login(
136
+ username: string,
137
+ password: string,
138
+ code?: string | null,
139
+ ): Promise<AnvilLoginResult>;
140
+ restore(): Promise<AnvilLoginResult | null>;
141
+ logout(): Promise<void>;
142
+ };
120
143
  close(): Promise<void>;
121
144
  minimize(): Promise<void>;
122
145
  toggleMaximize(): Promise<void>;
@@ -22,16 +22,27 @@ pub struct ModConfig {
22
22
  #[serde(default)] pub file_name: String,
23
23
  }
24
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
+
25
33
  #[derive(Serialize, Deserialize, Clone, Debug)]
26
34
  pub struct InstanceConfig {
27
35
  pub id: String,
28
- pub name: String,
29
- pub mc_version: String,
36
+ // name / mc_version sont optionnels dans config.json : une instance
37
+ // `{ "id": "..." }` est résolue au démarrage auprès du anvil-server.
38
+ #[serde(default)] pub name: String,
39
+ #[serde(default)] pub mc_version: String,
30
40
  #[serde(default)] pub loader: String,
31
41
  #[serde(default)] pub loader_version: String,
32
42
  #[serde(default)] pub server_ip: String,
33
43
  #[serde(default = "default_port")] pub server_port: u16,
34
44
  #[serde(default)] pub mods: Vec<ModConfig>,
45
+ #[serde(default)] pub files: Vec<FileConfig>,
35
46
  }
36
47
 
37
48
  #[derive(Serialize, Deserialize, Clone, Debug)]
@@ -45,6 +56,8 @@ pub struct LauncherConfig {
45
56
  #[serde(default = "default_true")] pub window_resizable: bool,
46
57
  #[serde(default)] pub logo: String,
47
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,
48
61
  #[serde(default)] pub instances: Vec<InstanceConfig>,
49
62
  }
50
63
 
@@ -67,6 +80,8 @@ impl Default for LauncherConfig {
67
80
  window_resizable: true,
68
81
  logo: String::new(),
69
82
  session: default_session(),
83
+ anvil_server: String::new(),
84
+ anvil_key: String::new(),
70
85
  instances: Vec::new(),
71
86
  }
72
87
  }
@@ -356,6 +371,80 @@ fn find_config_json(app: &AppHandle) -> LauncherConfig {
356
371
  LauncherConfig::default()
357
372
  }
358
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
+
359
448
  // ── Utilitaires JSON ──────────────────────────────────────────────────────────
360
449
 
361
450
  fn load_json<T: serde::de::DeserializeOwned + Default>(path: &Path) -> T {
@@ -432,6 +521,32 @@ async fn save_if_missing(client: &reqwest::Client, url: &str, dest: &Path) -> Re
432
521
  std::fs::write(dest, &bytes).map_err(|e| e.to_string())
433
522
  }
434
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
+
435
550
  fn unzip_natives(zip_bytes: &[u8], dest: &Path) -> Result<(), String> {
436
551
  use std::io::Read;
437
552
  let mut archive = zip::ZipArchive::new(std::io::Cursor::new(zip_bytes))
@@ -729,6 +844,7 @@ async fn install_fabric(
729
844
  async fn sync_mods(
730
845
  app: &AppHandle,
731
846
  client: &reqwest::Client,
847
+ cfg: &LauncherConfig,
732
848
  launcher_dir: &Path,
733
849
  inst: &InstanceConfig,
734
850
  ) -> Result<(), String> {
@@ -749,12 +865,51 @@ async fn sync_mods(
749
865
  label: format!("Mod {label} ({}/{total})", i + 1),
750
866
  error: false,
751
867
  });
752
- save_if_missing(client, &m.url, &dir.join(&file)).await
868
+ save_if_missing_keyed(client, cfg, &m.url, &dir.join(&file)).await
753
869
  .map_err(|e| format!("Mod '{label}': {e}"))?;
754
870
  }
755
871
  Ok(())
756
872
  }
757
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
+
758
913
  fn find_instance<'a>(cfg: &'a LauncherConfig, instance_id: &str) -> Result<&'a InstanceConfig, String> {
759
914
  cfg.instances.iter().find(|i| i.id == instance_id)
760
915
  .ok_or_else(|| format!("Instance '{instance_id}' introuvable dans config.json"))
@@ -816,7 +971,7 @@ async fn add_mod(
816
971
 
817
972
  let client = reqwest::Client::builder().user_agent("HomeLauncher/1.0").build()
818
973
  .map_err(|e| e.to_string())?;
819
- let bytes = http_bytes(&client, &m.url).await?;
974
+ let bytes = http_bytes_keyed(&client, &state.config, &m.url).await?;
820
975
  std::fs::write(&dest, &bytes).map_err(|e| e.to_string())?;
821
976
 
822
977
  Ok(ModInfo {
@@ -909,11 +1064,20 @@ async fn run_setup(app: AppHandle, state: State<'_, AppState>) -> Result<(), Str
909
1064
  .map_err(|e| e.to_string())?;
910
1065
 
911
1066
  for inst in &cfg.instances {
912
- let result = if is_instance_installed(&launcher_dir, inst) {
1067
+ let result = if inst.mc_version.is_empty() {
1068
+ // Instance déclarée par {id} mais jamais résolue : serveur anvil
1069
+ // injoignable et aucun cache disponible.
1070
+ Err(format!(
1071
+ "Instance '{}' non résolue — anvil-server injoignable ?", inst.id
1072
+ ))
1073
+ } else if is_instance_installed(&launcher_dir, inst) {
913
1074
  launcher_log(&logs, &format!("Instance '{}' déjà installée", inst.id));
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
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
+ }
917
1081
  } else {
918
1082
  launcher_log(&logs, &format!("Installation instance '{}' ({})", inst.id, inst.mc_version));
919
1083
  let r = if inst.loader == "fabric" {
@@ -922,7 +1086,10 @@ async fn run_setup(app: AppHandle, state: State<'_, AppState>) -> Result<(), Str
922
1086
  install_vanilla(&app, &client, &game_dir, inst).await
923
1087
  };
924
1088
  match r {
925
- Ok(_) => sync_mods(&app, &client, &launcher_dir, inst).await,
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
+ },
926
1093
  Err(e) => Err(e),
927
1094
  }
928
1095
  };
@@ -1124,6 +1291,15 @@ async fn launch_game(
1124
1291
  None => offline_auth(&fallback_name),
1125
1292
  }
1126
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
+ },
1127
1303
  _ => offline_auth(&fallback_name),
1128
1304
  };
1129
1305
 
@@ -1346,6 +1522,135 @@ async fn do_update(app: AppHandle, url: String) -> Result<(), String> {
1346
1522
  Ok(())
1347
1523
  }
1348
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
+
1349
1654
  // ── Entry point ───────────────────────────────────────────────────────────────
1350
1655
 
1351
1656
  #[tauri::command]
@@ -1361,14 +1666,17 @@ pub fn run() {
1361
1666
  let data_dir = app.path().app_data_dir()
1362
1667
  .unwrap_or_else(|_| dirs::data_local_dir().unwrap_or_default().join("HomeLauncher"));
1363
1668
  std::fs::create_dir_all(&data_dir)?;
1364
- let config: LauncherConfig = find_config_json(app.handle());
1365
- let settings: Settings = load_json(&data_dir.join("settings.json"));
1669
+ let mut config: LauncherConfig = find_config_json(app.handle());
1670
+ let settings: Settings = load_json(&data_dir.join("settings.json"));
1366
1671
 
1367
1672
  // Log du démarrage dans le launcher_dir final
1368
1673
  let launcher_dir = get_launcher_dir(&settings, &config);
1369
1674
  launcher_log(&log_dir(&launcher_dir),
1370
1675
  &format!("HomeLauncher démarré — {} instance(s)", config.instances.len()));
1371
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
+
1372
1680
  // Appliquer les options de fenêtre depuis config.json
1373
1681
  if let Some(win) = app.get_webview_window("main") {
1374
1682
  let _ = win.set_title(&config.app_name);
@@ -1400,6 +1708,7 @@ pub fn run() {
1400
1708
  open_mods_folder, open_instance_folder,
1401
1709
  check_update, do_update,
1402
1710
  set_custom_session,
1711
+ anvil_session_login, anvil_session_restore, anvil_session_logout,
1403
1712
  get_launcher_version,
1404
1713
  ])
1405
1714
  .run(tauri::generate_context!())
@@ -52,6 +52,17 @@ export const MC = {
52
52
  setSession: (session) => _invoke('set_custom_session', { session }),
53
53
  clearSession: () => _invoke('set_custom_session', { session: null }),
54
54
 
55
+ // ── Session anvil-server ("session": "anvil-session") ─────
56
+ anvilSession: {
57
+ // Résout en { status: 'ok' | 'totp_required', username, uuid }.
58
+ // Rejette avec un message d'erreur si identifiants/code invalides.
59
+ login: (username, password, code = null) =>
60
+ _invoke('anvil_session_login', { username, password, code }),
61
+ // Restaure la session persistée (null si aucune/expirée).
62
+ restore: () => _invoke('anvil_session_restore'),
63
+ logout: () => _invoke('anvil_session_logout'),
64
+ },
65
+
55
66
  // ── Window ─────────────────────────────────────────────────
56
67
  close: () => _window.getCurrentWindow().close(),
57
68
  minimize: () => _window.getCurrentWindow().minimize(),