rmcl 0.3.2

A fully featured Minecraft TUI launcher
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
// forge mod loader: version discovery via promotions API, download and
// installation. modern forge runs a java installer, old forge (pre-1.13ish)
// doesn't support headless install so we extract directly from the jar.

use std::collections::HashMap;
use std::path::Path;

use serde::Deserialize;

use crate::instance::loader::{GameVersion, InstallError, InstallerError};
use crate::net::{HttpClient, NetError, download_file};
use crate::tui::progress::{set_action, set_sub_action};

const FORGE_PROMOTIONS_URL: &str =
    "https://files.minecraftforge.net/net/minecraftforge/forge/promotions_slim.json";
const FORGE_MAVEN_BASE: &str = "https://maven.minecraftforge.net/net/minecraftforge/forge";

#[derive(Debug, Deserialize)]
struct ForgePromotions {
    promos: HashMap<String, String>,
}

// forge promotions use keys like "1.20.1-recommended", "1.20.1-latest"
// so this filters by game version prefix and extracts the forge version values
pub async fn fetch_forge_versions(
    client: &HttpClient,
    game_version: &str,
) -> Result<Vec<String>, NetError> {
    fetch_forge_versions_from(client, FORGE_PROMOTIONS_URL, game_version).await
}

// same as fetch_forge_versions but lets tests point at a wiremock server.
pub async fn fetch_forge_versions_from(
    client: &HttpClient,
    promotions_url: &str,
    game_version: &str,
) -> Result<Vec<String>, NetError> {
    let promotions: ForgePromotions = client.get_json(promotions_url).await?;

    let prefix = format!("{}-", game_version);
    let mut versions: Vec<String> = promotions
        .promos
        .iter()
        .filter(|(key, _)| key.starts_with(&prefix))
        .map(|(_, value)| value.clone())
        .collect();

    versions.sort();
    versions.dedup();
    tracing::debug!(
        "Resolved {} Forge version(s) for Minecraft {} from promotions",
        versions.len(),
        game_version
    );
    Ok(versions)
}

// extracts unique game versions from the promotion keys by splitting off
// the "-recommended"/"-latest" suffix
pub async fn fetch_forge_game_versions(client: &HttpClient) -> Result<Vec<GameVersion>, NetError> {
    fetch_forge_game_versions_from(client, FORGE_PROMOTIONS_URL).await
}

pub async fn fetch_forge_game_versions_from(
    client: &HttpClient,
    promotions_url: &str,
) -> Result<Vec<GameVersion>, NetError> {
    let promos: ForgePromotions = client.get_json(promotions_url).await?;

    let mut game_versions: Vec<String> = promos
        .promos
        .keys()
        .filter_map(|key| key.rsplit_once('-').map(|(version, _)| version.to_string()))
        .collect();
    game_versions.sort();
    game_versions.dedup();
    game_versions.reverse();
    tracing::debug!(
        "Resolved {} Forge game version(s) from promotions",
        game_versions.len()
    );

    Ok(game_versions
        .into_iter()
        .map(|version| GameVersion {
            id: version,
            stable: true,
        })
        .collect())
}

// forge has used at least three different maven naming conventions over the
// years with no clear cutoff. we just try each one until something works.
pub async fn download_forge_installer(
    client: &HttpClient,
    game_version: &str,
    forge_version: &str,
    dest: &Path,
) -> Result<(), NetError> {
    let mc_no_dots: String = game_version.chars().filter(|c| *c != '.').collect();

    let slugs = [
        format!("{game_version}-{forge_version}"),
        format!("{game_version}-{forge_version}-{game_version}"),
        format!("{game_version}-{forge_version}-mc{mc_no_dots}"),
    ];

    set_action(format!(
        "Downloading Forge {}-{}...",
        game_version, forge_version
    ));

    let mut last_err = None;
    for slug in &slugs {
        let url = format!("{}/{slug}/forge-{slug}-installer.jar", FORGE_MAVEN_BASE,);
        tracing::debug!("Trying Forge installer slug '{}'", slug);
        match download_file(client, &url, dest, |downloaded, total| {
            crate::tui::progress::set_progress(downloaded, total);
        })
        .await
        {
            Ok(()) => {
                tracing::debug!("Downloaded Forge installer using slug '{}'", slug);
                return Ok(());
            }
            Err(e) => {
                tracing::debug!("Forge installer slug '{}' failed: {}", slug, e);
                last_err = Some(e);
            }
        }
    }

    Err(last_err.unwrap_or_else(|| {
        NetError::Parse(format!(
            "No Forge installer found for {game_version}-{forge_version}"
        ))
    }))
}

pub async fn run_forge_installer(
    installer_path: &Path,
    instance_dir: &Path,
    java_path: &str,
) -> Result<(), InstallerError> {
    use tokio::process::Command;

    set_action("Running Forge installer...");

    let output = match Command::new(java_path)
        .arg("-jar")
        .arg(installer_path)
        .arg("--installClient")
        .current_dir(instance_dir.join(".minecraft"))
        .output()
        .await
    {
        Ok(o) => o,
        Err(e) => {
            tracing::debug!(
                "Failed to spawn Forge installer {} with Java {}: {}",
                installer_path.display(),
                java_path,
                e
            );
            return Err(InstallerError::Io(e));
        }
    };

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        let detail = if stderr.trim().is_empty() {
            format!("exit code {:?}", output.status.code())
        } else {
            stderr.lines().last().unwrap_or("unknown error").to_string()
        };
        tracing::debug!(
            "Forge installer {} failed with status {:?}: {}",
            installer_path.display(),
            output.status.code(),
            detail
        );
        return Err(InstallerError::ProcessFailed(detail));
    }

    tracing::debug!("Forge installer completed successfully");
    Ok(())
}

// old forge installers have an install_profile.json with a "versionInfo" key
// containing everything needed. modern ones don't have this structure.
pub(crate) fn has_legacy_install_profile(installer_path: &Path) -> bool {
    let file = match std::fs::File::open(installer_path) {
        Ok(f) => f,
        Err(_) => return false,
    };
    let mut archive = match zip::ZipArchive::new(file) {
        Ok(a) => a,
        Err(_) => return false,
    };
    let entry = match archive.by_name("install_profile.json") {
        Ok(e) => e,
        Err(_) => return false,
    };
    let value: serde_json::Value = match serde_json::from_reader(entry) {
        Ok(v) => v,
        Err(_) => return false,
    };
    value.get("versionInfo").is_some()
}

// handles old-style forge installation by extracting the universal jar and
// library info directly from the installer, bypassing the GUI-only installer
pub(crate) async fn install_forge_from_profile(
    client: &HttpClient,
    installer_path: &Path,
    meta_dir: &Path,
    profile_filename: &str,
) -> Result<(), InstallError> {
    use std::io::Read;

    set_action("Installing legacy Forge from profile...");
    tracing::debug!(
        "Installing legacy Forge from {} into {}",
        installer_path.display(),
        meta_dir.display()
    );

    let file = std::fs::File::open(installer_path)
        .map_err(|e| InstallError::Installer(InstallerError::Io(e)))?;
    let mut archive = zip::ZipArchive::new(file)
        .map_err(|e| InstallError::Installer(InstallerError::Profile(format!("Failed to open installer as ZIP: {e}"))))?;

    let profile_data: serde_json::Value = {
        let entry = archive.by_name("install_profile.json").map_err(|e| {
            InstallError::Installer(InstallerError::Profile(format!("install_profile.json not found in installer: {e}")))
        })?;
        serde_json::from_reader(entry)
            .map_err(|e| InstallError::Installer(InstallerError::Profile(format!("Failed to parse install_profile.json: {e}"))))?
    };

    let version_info = profile_data
        .get("versionInfo")
        .ok_or_else(|| InstallError::Installer(InstallerError::Profile("install_profile.json missing versionInfo".into())))?;
    let install_info = profile_data
        .get("install")
        .ok_or_else(|| InstallError::Installer(InstallerError::Profile("install_profile.json missing install section".into())))?;

    let libraries = version_info
        .get("libraries")
        .and_then(|v| v.as_array())
        .ok_or_else(|| InstallError::Installer(InstallerError::Profile("missing versionInfo.libraries".into())))?;

    let file_path = install_info
        .get("filePath")
        .and_then(|v| v.as_str())
        .ok_or_else(|| InstallError::Installer(InstallerError::Profile("missing install.filePath".into())))?;

    let install_path_coord = install_info
        .get("path")
        .and_then(|v| v.as_str())
        .ok_or_else(|| InstallError::Installer(InstallerError::Profile("missing install.path".into())))?;

    // extract the universal jar to the correct maven location
    let universal_maven_path =
        crate::net::maven_coord_to_path(install_path_coord).ok_or_else(|| {
            InstallError::Installer(InstallerError::Profile(format!(
                "Invalid maven coord in install.path: {install_path_coord}"
            )))
        })?;

    set_sub_action("Extracting universal JAR...");
    let universal_dest = meta_dir.join("libraries").join(&universal_maven_path);
    if let Some(parent) = universal_dest.parent() {
        std::fs::create_dir_all(parent)
            .map_err(|e| InstallError::Installer(InstallerError::Io(e)))?;
    }

    {
        let mut entry = archive.by_name(file_path).map_err(|e| {
            InstallError::Installer(InstallerError::Profile(format!(
                "Universal JAR '{file_path}' not found in installer: {e}"
            )))
        })?;
        let mut buf = Vec::new();
        entry
            .read_to_end(&mut buf)
            .map_err(|e| InstallError::Installer(InstallerError::Io(e)))?;
        std::fs::write(&universal_dest, &buf)
            .map_err(|e| InstallError::Installer(InstallerError::Io(e)))?;
        tracing::debug!(
            "Extracted legacy Forge universal JAR to {} ({} bytes)",
            universal_dest.display(),
            buf.len()
        );
    }

    // download libraries needed by this forge version. libs with a url field
    // are forge-hosted, libs without one are typically from mojang's library
    // server. old forge versions reference libs like launchwrapper that aren't
    // in mojang's modern version metadata, so we fetch those too.
    let libraries_dir = meta_dir.join("libraries");
    for lib in libraries {
            let name = lib.get("name").and_then(|v| v.as_str()).unwrap_or_default();

        let maven_path = match crate::net::maven_coord_to_path(name) {
            Some(p) => p,
            None => {
                return Err(InstallError::Installer(InstallerError::Profile(format!("Invalid Maven coordinate: {name}"))));
            }
        };

        let dest = libraries_dir.join(&maven_path);
        if dest.exists() {
            tracing::trace!("Legacy Forge library already cached: {}", name);
            continue;
        }

        let base_url = lib
            .get("url")
            .and_then(|v| v.as_str())
            .unwrap_or("https://libraries.minecraft.net/")
            .trim_end_matches('/');
        let download_url = format!("{base_url}/{maven_path}");

        set_sub_action(name);
        tracing::debug!("Downloading legacy Forge library {}", name);
        download_file(client, &download_url, &dest, |_, _| {}).await?;
    }

    set_action("Saving Forge profile...");
    // write the installer's versionInfo as compact JSON. it already has the
    // mainClass, the full library list (with name + url for forge-hosted
    // libs), and minecraftArguments (the legacy --tweakClass etc). the
    // launch flow parses this as a LaunchProfile and - if there's no
    // inheritsFrom field - implicitly inherits from the configured game
    // version so vanilla libraries layer in via resolve().
    //
    // we use serde_json::to_vec (not the pretty-print variant via
    // save_profile_json) so the written file is content-faithful: every
    // field present in the installer's versionInfo round-trips. key order
    // and whitespace may differ from the original installer JSON because
    // the source is a serde_json::Value (which doesn't preserve order),
    // but no field is silently dropped.
    let serialized = serde_json::to_vec(version_info)
        .map_err(|e| InstallError::Installer(InstallerError::Profile(format!("Failed to serialize Forge profile: {e}"))))?;
    crate::instance::loader::save_profile_bytes(meta_dir, profile_filename, &serialized)
        .map_err(|e| InstallError::Installer(InstallerError::Io(e)))?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::net::HttpClient;

    #[tokio::test]
    #[ignore = "hits live Forge API"]
    async fn test_fetch_versions() {
        let client = HttpClient::new();
        match fetch_forge_versions(&client, "1.20.1").await {
            Ok(versions) => {
                assert!(
                    !versions.is_empty(),
                    "Should have Forge versions for 1.20.1"
                );
            }
            Err(e) => panic!("fetch_forge_versions failed: {}", e),
        }
    }

    #[tokio::test]
    #[ignore = "hits live Forge API"]
    async fn test_fetch_game_versions() {
        let client = HttpClient::new();
        match fetch_forge_game_versions(&client).await {
            Ok(versions) => {
                assert!(!versions.is_empty(), "Should have Forge game versions");
                assert!(versions.iter().any(|version| version.id == "1.20.1"));
            }
            Err(e) => panic!("fetch_forge_game_versions failed: {}", e),
        }
    }

    // builds an in-memory zip in a tempdir with the given json as
    // install_profile.json. lets the legacy-install-profile detector be
    // tested without an actual forge installer.
    fn make_installer_zip(tmp: &std::path::Path, json: &serde_json::Value) -> std::path::PathBuf {
        use std::io::Write;
        let path = tmp.join("installer.jar");
        let file = std::fs::File::create(&path).unwrap();
        let mut zip = zip::ZipWriter::new(file);
        let opts: zip::write::SimpleFileOptions = Default::default();
        zip.start_file("install_profile.json", opts).unwrap();
        zip.write_all(serde_json::to_string(json).unwrap().as_bytes())
            .unwrap();
        zip.finish().unwrap();
        path
    }

    #[test]
    fn has_legacy_install_profile_true_when_version_info_present() {
        let tmp = tempfile::tempdir().unwrap();
        let jar = make_installer_zip(
            tmp.path(),
            &serde_json::json!({
                "install": {},
                "versionInfo": {
                    "id": "1.7.10-Forge10.13.4.1614-1.7.10",
                    "mainClass": "net.minecraft.launchwrapper.Launch"
                }
            }),
        );
        assert!(has_legacy_install_profile(&jar));
    }

    #[test]
    fn has_legacy_install_profile_false_when_version_info_absent() {
        let tmp = tempfile::tempdir().unwrap();
        let jar = make_installer_zip(
            tmp.path(),
            &serde_json::json!({
                "spec": 1,
                "minecraft": "1.20.1",
                "data": {}
            }),
        );
        assert!(!has_legacy_install_profile(&jar));
    }

    #[test]
    fn has_legacy_install_profile_false_for_missing_jar() {
        assert!(!has_legacy_install_profile(std::path::Path::new(
            "/nonexistent/installer.jar"
        )));
    }
}