@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 +5 -0
- package/dist/cli.cjs +10 -7
- package/package.json +10 -7
- package/src/client/config.schema.json +34 -5
- package/src/client/index.d.ts +24 -1
- package/src/rust/src/lib.rs +320 -11
- package/src/template/shared/api.js +11 -0
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.
|
|
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.
|
|
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.
|
|
76
|
+
sharp: "^0.35.3"
|
|
74
77
|
},
|
|
75
78
|
devDependencies: {
|
|
76
|
-
"@types/node": "^26.1.
|
|
79
|
+
"@types/node": "^26.1.1",
|
|
77
80
|
bun: "^1.3.14",
|
|
78
|
-
oxfmt: "^0.
|
|
79
|
-
oxlint: "^1.
|
|
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
|
+
"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.
|
|
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.
|
|
41
|
+
"sharp": "^0.35.3"
|
|
39
42
|
},
|
|
40
43
|
"devDependencies": {
|
|
41
|
-
"@types/node": "^26.1.
|
|
44
|
+
"@types/node": "^26.1.1",
|
|
42
45
|
"bun": "^1.3.14",
|
|
43
|
-
"oxfmt": "^0.
|
|
44
|
-
"oxlint": "^1.
|
|
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"
|
|
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
|
}
|
package/src/client/index.d.ts
CHANGED
|
@@ -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>;
|
package/src/rust/src/lib.rs
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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:
|
|
1365
|
-
let settings:
|
|
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(),
|