[player] – Join a server");
+ }
+}
diff --git a/src/main/java/com/osiris/autoplug/client/minecraft/launcher/AssetDownloader.java b/src/main/java/com/osiris/autoplug/client/minecraft/launcher/AssetDownloader.java
new file mode 100644
index 00000000..c90e0845
--- /dev/null
+++ b/src/main/java/com/osiris/autoplug/client/minecraft/launcher/AssetDownloader.java
@@ -0,0 +1,203 @@
+/*
+ * Copyright (c) 2024 Osiris-Team.
+ * All rights reserved.
+ *
+ * This software is copyrighted work, licensed under the terms
+ * of the MIT-License. Consult the "LICENSE" file for details.
+ * Implements GitHub issue #296 – Minecraft Client Launcher.
+ */
+
+package com.osiris.autoplug.client.minecraft.launcher;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.osiris.jlib.logger.AL;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+import okhttp3.ResponseBody;
+import org.apache.commons.codec.digest.DigestUtils;
+import org.jetbrains.annotations.NotNull;
+
+import java.io.*;
+import java.nio.file.*;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Downloads and caches Minecraft assets (index JSON + individual object files),
+ * libraries, native libraries, and the client JAR.
+ *
+ * Each file is verified against its expected SHA-1 before being accepted.
+ * Already-correct files are skipped. Thread-safe; uses an internal thread pool.
+ */
+public class AssetDownloader {
+
+ private static final String RESOURCES_BASE = "https://resources.download.minecraft.net/";
+ private static final String LIBRARIES_BASE = "https://libraries.minecraft.net/";
+ private static final int PARALLELISM = 8;
+
+ private final OkHttpClient http;
+ private final File assetsDir; // .minecraft/assets (contains indexes/ and objects/)
+ private final File librariesDir;// .minecraft/libraries
+ private final File nativesDir; // .minecraft/natives/
+ private final File clientJar; // .minecraft/versions//.jar
+
+ /**
+ * @param assetsDir root assets directory (indexes/ and objects/ will be created inside)
+ * @param librariesDir root libraries directory
+ * @param nativesDir per-version directory where native zips are extracted
+ * @param clientJar destination for the versioned client.jar
+ */
+ public AssetDownloader(@NotNull File assetsDir, @NotNull File librariesDir,
+ @NotNull File nativesDir, @NotNull File clientJar) {
+ this.assetsDir = assetsDir;
+ this.librariesDir = librariesDir;
+ this.nativesDir = nativesDir;
+ this.clientJar = clientJar;
+ this.http = new OkHttpClient.Builder()
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(5, TimeUnit.MINUTES)
+ .followRedirects(true)
+ .build();
+ }
+
+ /**
+ * Downloads everything required by the given version detail.
+ *
+ * @param detail parsed version info from {@link MojangManifest}
+ * @param progressListener called with (downloadedCount, totalCount) after each file completes
+ */
+ public void downloadAll(@NotNull MojangManifest.VersionDetail detail,
+ ProgressListener progressListener) throws Exception {
+ // 1. Client JAR
+ clientJar.getParentFile().mkdirs();
+ downloadIfNeeded(detail.clientJarUrl, clientJar, detail.clientJarSha1, detail.clientJarSize);
+
+ // 2. Libraries + natives
+ for (MojangManifest.Library lib : detail.libraries) {
+ if (!lib.appliesOnCurrentOs()) continue;
+
+ // Main artifact
+ if (lib.url != null) {
+ File dest = new File(librariesDir, lib.toRelativePath());
+ dest.getParentFile().mkdirs();
+ downloadIfNeeded(lib.url, dest, lib.sha1, lib.size);
+ }
+
+ // Native artifact – download then extract
+ if (lib.nativesUrl != null) {
+ String relPath = lib.name.replace(':', '/') + "-natives.jar";
+ File nativeJar = new File(nativesDir, relPath);
+ nativeJar.getParentFile().mkdirs();
+ downloadIfNeeded(lib.nativesUrl, nativeJar, lib.nativesSha1, 0);
+ extractNatives(nativeJar, nativesDir);
+ }
+ }
+
+ // 3. Asset index JSON
+ File indexFile = new File(assetsDir, "indexes/" + detail.assetIndex + ".json");
+ indexFile.getParentFile().mkdirs();
+ downloadIfNeeded(detail.assetIndexUrl, indexFile, detail.assetIndexSha1, 0);
+
+ // 4. Asset objects (parallel)
+ downloadAssetObjects(indexFile, progressListener);
+ }
+
+ private void downloadAssetObjects(File indexFile, ProgressListener listener) throws Exception {
+ JsonObject objects = new Gson()
+ .fromJson(new FileReader(indexFile), JsonObject.class)
+ .getAsJsonObject("objects");
+
+ int total = objects.size();
+ AtomicInteger done = new AtomicInteger(0);
+ ExecutorService pool = Executors.newFixedThreadPool(PARALLELISM);
+ CompletionService cs = new ExecutorCompletionService<>(pool);
+ int submitted = 0;
+
+ for (java.util.Map.Entry entry : objects.entrySet()) {
+ JsonObject obj = entry.getValue().getAsJsonObject();
+ String hash = obj.get("hash").getAsString();
+ long size = obj.get("size").getAsLong();
+ String prefix = hash.substring(0, 2);
+ File dest = new File(assetsDir, "objects/" + prefix + "/" + hash);
+ String url = RESOURCES_BASE + prefix + "/" + hash;
+
+ cs.submit(() -> {
+ try {
+ dest.getParentFile().mkdirs();
+ downloadIfNeeded(url, dest, hash, size);
+ } catch (Exception e) {
+ AL.warn("Asset download failed: " + url, e);
+ }
+ int n = done.incrementAndGet();
+ if (listener != null) listener.onProgress(n, total);
+ return null;
+ });
+ submitted++;
+ }
+
+ pool.shutdown();
+ for (int i = 0; i < submitted; i++) cs.take(); // wait for all
+ pool.awaitTermination(30, TimeUnit.MINUTES);
+ }
+
+ /** Downloads {@code url} to {@code dest} only if missing or SHA-1 mismatch. */
+ void downloadIfNeeded(String url, File dest, String expectedSha1, long expectedSize)
+ throws IOException {
+ if (dest.exists() && dest.length() > 0) {
+ if (expectedSha1 == null || expectedSha1.equalsIgnoreCase(sha1(dest))) return;
+ }
+ File tmp = new File(dest.getAbsolutePath() + ".tmp");
+ Request req = new Request.Builder().url(url)
+ .header("User-Agent","AutoPlug-Client - https://autoplug.one").build();
+ try (Response resp = http.newCall(req).execute()) {
+ if (!resp.isSuccessful())
+ throw new IOException("HTTP " + resp.code() + " downloading " + url);
+ ResponseBody body = resp.body();
+ if (body == null) throw new IOException("Null body for " + url);
+ try (InputStream in = body.byteStream();
+ OutputStream out = new BufferedOutputStream(new FileOutputStream(tmp))) {
+ byte[] buf = new byte[65536];
+ int n;
+ while ((n = in.read(buf)) != -1) out.write(buf, 0, n);
+ }
+ }
+ // Verify
+ if (expectedSha1 != null && !expectedSha1.equalsIgnoreCase(sha1(tmp))) {
+ tmp.delete();
+ throw new IOException("SHA-1 mismatch for " + dest.getName() + " (url: " + url + ")");
+ }
+ Files.move(tmp.toPath(), dest.toPath(), StandardCopyOption.REPLACE_EXISTING);
+ }
+
+ private void extractNatives(File nativeJar, File nativesDir) throws IOException {
+ try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(nativeJar)) {
+ java.util.Enumeration extends java.util.zip.ZipEntry> entries = zf.entries();
+ while (entries.hasMoreElements()) {
+ java.util.zip.ZipEntry entry = entries.nextElement();
+ if (entry.getName().endsWith("/") || entry.getName().contains("META-INF")) continue;
+ File out = new File(nativesDir, new File(entry.getName()).getName());
+ out.getParentFile().mkdirs();
+ try (InputStream in = zf.getInputStream(entry);
+ OutputStream os = new FileOutputStream(out)) {
+ byte[] buf = new byte[65536]; int n;
+ while ((n = in.read(buf)) != -1) os.write(buf, 0, n);
+ }
+ }
+ }
+ }
+
+ private static String sha1(File f) throws IOException {
+ try (InputStream in = new BufferedInputStream(new FileInputStream(f))) {
+ return DigestUtils.sha1Hex(in);
+ }
+ }
+
+ /** Callback for reporting download progress. */
+ @FunctionalInterface
+ public interface ProgressListener {
+ void onProgress(int downloaded, int total);
+ }
+}
diff --git a/src/main/java/com/osiris/autoplug/client/minecraft/launcher/LaunchArgBuilder.java b/src/main/java/com/osiris/autoplug/client/minecraft/launcher/LaunchArgBuilder.java
new file mode 100644
index 00000000..da8b7219
--- /dev/null
+++ b/src/main/java/com/osiris/autoplug/client/minecraft/launcher/LaunchArgBuilder.java
@@ -0,0 +1,154 @@
+/*
+ * Copyright (c) 2024 Osiris-Team.
+ * All rights reserved.
+ *
+ * This software is copyrighted work, licensed under the terms
+ * of the MIT-License. Consult the "LICENSE" file for details.
+ * Implements GitHub issue #296 – Minecraft Client Launcher.
+ */
+
+package com.osiris.autoplug.client.minecraft.launcher;
+
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.io.File;
+import java.util.*;
+
+/**
+ * Builds the JVM + game command-line arguments for launching a Minecraft client.
+ * Supports both pre-1.13 (minecraftArguments) and 1.13+ (arguments.*) JSON formats.
+ * Pure MIT – no HMCL code used.
+ */
+public class LaunchArgBuilder {
+
+ private final MojangManifest.VersionDetail detail;
+ private final File nativesDir;
+ private final List classPath;
+ private final File gameDir;
+ private final File assetsDir;
+ private final String playerName;
+ private final String uuid;
+ private final String accessToken;
+ private final String versionType;
+ private final List extraJvmArgs;
+ private final int maxMemMb;
+
+ /**
+ * @param detail parsed version detail from {@link MojangManifest}
+ * @param nativesDir where extracted native libs live (e.g. .minecraft/natives/1.21.1)
+ * @param classPath ordered list of jars (client jar + libraries)
+ * @param gameDir .minecraft or profile game dir
+ * @param assetsDir .minecraft/assets (contains indexes/ and objects/)
+ * @param playerName display name
+ * @param uuid player UUID (dashes stripped for offline; with dashes for online)
+ * @param accessToken MSA access token, or "null" for offline
+ * @param versionType "release" / "snapshot" / "modified"
+ * @param extraJvmArgs additional JVM flags from profile config
+ * @param maxMemMb -Xmx in megabytes
+ */
+ public LaunchArgBuilder(
+ @NotNull MojangManifest.VersionDetail detail,
+ @NotNull File nativesDir,
+ @NotNull List classPath,
+ @NotNull File gameDir,
+ @NotNull File assetsDir,
+ @NotNull String playerName,
+ @NotNull String uuid,
+ @NotNull String accessToken,
+ @NotNull String versionType,
+ @Nullable List extraJvmArgs,
+ int maxMemMb) {
+ this.detail = detail;
+ this.nativesDir = nativesDir;
+ this.classPath = classPath;
+ this.gameDir = gameDir;
+ this.assetsDir = assetsDir;
+ this.playerName = playerName;
+ this.uuid = uuid;
+ this.accessToken = accessToken;
+ this.versionType = versionType;
+ this.extraJvmArgs = extraJvmArgs != null ? extraJvmArgs : new ArrayList<>();
+ this.maxMemMb = maxMemMb;
+ }
+
+ /**
+ * Builds the full command-line argument list (everything after the java executable).
+ * Order: JVM args → main class → game args.
+ */
+ @NotNull
+ public List build() {
+ List cmd = new ArrayList<>();
+
+ // Memory
+ cmd.add("-Xmx" + maxMemMb + "m");
+ cmd.add("-Xms256m");
+
+ // Natives path (always needed)
+ cmd.add("-Djava.library.path=" + nativesDir.getAbsolutePath());
+
+ // JVM args from manifest
+ if (!detail.jvmArguments.isEmpty()) {
+ for (MojangManifest.Argument a : detail.jvmArguments) {
+ if (!a.appliesOnCurrentOs()) continue;
+ if (a.value != null) cmd.add(substituteVars(a.value));
+ else if (a.values != null) for (String v : a.values) cmd.add(substituteVars(v));
+ }
+ } else {
+ // Legacy: provide minimal JVM args that Mojang would have expected
+ cmd.add("-cp");
+ cmd.add(buildClassPathString());
+ }
+
+ // Extra profile JVM args
+ cmd.addAll(extraJvmArgs);
+
+ // Main class
+ cmd.add(detail.mainClass);
+
+ // Game args
+ if (detail.minecraftArguments != null) {
+ // Pre-1.13 flat string – split on spaces and substitute
+ for (String token : detail.minecraftArguments.split(" ")) {
+ cmd.add(substituteVars(token));
+ }
+ } else {
+ for (MojangManifest.Argument a : detail.gameArguments) {
+ if (!a.appliesOnCurrentOs()) continue;
+ if (a.value != null) cmd.add(substituteVars(a.value));
+ else if (a.values != null) for (String v : a.values) cmd.add(substituteVars(v));
+ }
+ }
+
+ return cmd;
+ }
+
+ // ── Variable substitution ────────────────────────────────────────────────
+
+ private String substituteVars(String template) {
+ return template
+ .replace("${auth_player_name}", playerName)
+ .replace("${version_name}", detail.id)
+ .replace("${game_directory}", gameDir.getAbsolutePath())
+ .replace("${assets_root}", assetsDir.getAbsolutePath())
+ .replace("${assets_index_name}", detail.assetIndex)
+ .replace("${auth_uuid}", uuid)
+ .replace("${auth_access_token}", accessToken)
+ .replace("${user_type}", "mojang")
+ .replace("${version_type}", versionType)
+ .replace("${natives_directory}", nativesDir.getAbsolutePath())
+ .replace("${launcher_name}", "AutoPlug")
+ .replace("${launcher_version}", "1.0")
+ .replace("${classpath}", buildClassPathString());
+ }
+
+ private String buildClassPathString() {
+ StringBuilder sb = new StringBuilder();
+ String sep = File.pathSeparator;
+ for (int i = 0; i < classPath.size(); i++) {
+ if (i > 0) sb.append(sep);
+ sb.append(classPath.get(i).getAbsolutePath());
+ }
+ return sb.toString();
+ }
+}
diff --git a/src/main/java/com/osiris/autoplug/client/minecraft/launcher/MinecraftLauncher.java b/src/main/java/com/osiris/autoplug/client/minecraft/launcher/MinecraftLauncher.java
new file mode 100644
index 00000000..73c8a216
--- /dev/null
+++ b/src/main/java/com/osiris/autoplug/client/minecraft/launcher/MinecraftLauncher.java
@@ -0,0 +1,176 @@
+/*
+ * Copyright (c) 2024 Osiris-Team.
+ * All rights reserved.
+ *
+ * This software is copyrighted work, licensed under the terms
+ * of the MIT-License. Consult the "LICENSE" file for details.
+ * Implements GitHub issue #296 – Minecraft Client Launcher.
+ */
+
+package com.osiris.autoplug.client.minecraft.launcher;
+
+import com.osiris.autoplug.client.minecraft.profile.LaunchProfile;
+import com.osiris.autoplug.client.utils.GD;
+import com.osiris.autoplug.client.utils.io.AsyncInputStream;
+import com.osiris.jlib.logger.AL;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+
+/**
+ * Orchestrates downloading Minecraft assets/libraries and launching the game process.
+ *
+ * Offline mode: UUID is derived from player name (consistent with the vanilla launcher).
+ * Online mode: pass a valid MSA access-token and the authenticated UUID.
+ *
+ * Pure MIT – no HMCL code used or referenced.
+ */
+public class MinecraftLauncher {
+
+ /** Global cache root – mirrors vanilla .minecraft layout under autoplug/mc-cache/ */
+ public static File cacheRoot() {
+ return new File(GD.WORKING_DIR, "autoplug/mc-cache");
+ }
+
+ // ── Public API ──────────────────────────────────────────────────────────
+
+ /**
+ * Launches Minecraft for the given profile.
+ *
+ * @param profile the profile to launch (contains MC version, game dir, JVM args, etc.)
+ * @param playerName display name
+ * @param uuid player UUID (with dashes); pass null to generate an offline UUID
+ * @param accessToken MSA access token; pass null for offline mode
+ * @param progressListener may be null
+ * @return the launched {@link Process}; callers should call {@link Process#waitFor()} or destroy
+ */
+ @NotNull
+ public static Process launch(
+ @NotNull LaunchProfile profile,
+ @NotNull String playerName,
+ @Nullable String uuid,
+ @Nullable String accessToken,
+ @Nullable AssetDownloader.ProgressListener progressListener) throws Exception {
+
+ String version = profile.minecraftVersion;
+ AL.info("[Launcher] Fetching version detail for " + version);
+ MojangManifest.VersionDetail detail = MojangManifest.fetchVersionDetail(version);
+
+ // Directories
+ File cacheRoot = cacheRoot();
+ File assetsDir = new File(cacheRoot, "assets");
+ File librariesDir= new File(cacheRoot, "libraries");
+ File nativesDir = new File(cacheRoot, "natives/" + version);
+ File clientJar = new File(cacheRoot, "versions/" + version + "/" + version + ".jar");
+ File gameDir = profile.gameDir != null ? profile.gameDir
+ : new File(GD.WORKING_DIR, "autoplug/profiles/" + profile.name + "/game");
+
+ // Ensure dirs exist
+ for (File d : new File[]{assetsDir, librariesDir, nativesDir, clientJar.getParentFile(), gameDir})
+ d.mkdirs();
+
+ // Download everything (skips files already cached with correct SHA-1)
+ AL.info("[Launcher] Downloading/verifying assets and libraries...");
+ AssetDownloader dl = new AssetDownloader(assetsDir, librariesDir, nativesDir, clientJar);
+ dl.downloadAll(detail, progressListener);
+
+ // Build classpath: libraries (main artifact) + client jar
+ List classPath = new ArrayList<>();
+ for (MojangManifest.Library lib : detail.libraries) {
+ if (!lib.appliesOnCurrentOs()) continue;
+ if (lib.url != null) classPath.add(new File(librariesDir, lib.toRelativePath()));
+ }
+ classPath.add(clientJar);
+
+ // Auth
+ if (uuid == null) uuid = offlineUUID(playerName);
+ if (accessToken == null) accessToken = "null";
+
+ // Build args
+ LaunchArgBuilder argBuilder = new LaunchArgBuilder(
+ detail, nativesDir, classPath, gameDir, assetsDir,
+ playerName, uuid, accessToken,
+ profile.versionType != null ? profile.versionType : "release",
+ profile.jvmArgs,
+ profile.maxMemoryMb > 0 ? profile.maxMemoryMb : 2048);
+ List args = argBuilder.build();
+
+ // Find java executable
+ String javaExe = resolveJavaExecutable(detail.javaVersion);
+
+ // Assemble full command
+ List cmd = new ArrayList<>();
+ cmd.add(javaExe);
+ cmd.addAll(args);
+
+ AL.info("[Launcher] Launching: " + cmd.get(0) + " " + detail.mainClass);
+ ProcessBuilder pb = new ProcessBuilder(cmd);
+ pb.directory(gameDir);
+ pb.redirectErrorStream(false);
+
+ Process proc = pb.start();
+
+ // Stream stdout/stderr via AsyncInputStream so they appear in the AutoPlug console
+ AsyncInputStream stdout = new AsyncInputStream(proc.getInputStream());
+ stdout.listeners.add(line -> AL.info("[MC] " + line));
+ AsyncInputStream stderr = new AsyncInputStream(proc.getErrorStream());
+ stderr.listeners.add(line -> AL.warn("[MC] " + line));
+
+ return proc;
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────────
+
+ /**
+ * Generates a consistent offline UUID from a player name.
+ * Uses the same algorithm as the vanilla launcher (UUID v3 / name-based).
+ */
+ @NotNull
+ public static String offlineUUID(@NotNull String playerName) {
+ UUID uuid = UUID.nameUUIDFromBytes(
+ ("OfflinePlayer:" + playerName).getBytes(StandardCharsets.UTF_8));
+ return uuid.toString();
+ }
+
+ /**
+ * Resolves the java executable to use. Prefers AutoPlug's bundled JRE if present
+ * and its major version satisfies the version requirement.
+ */
+ @NotNull
+ static String resolveJavaExecutable(int requiredMajor) {
+ // 1. AutoPlug-managed JRE
+ File apJre = new File(GD.WORKING_DIR, "autoplug/system/jre");
+ if (apJre.exists()) {
+ File[] bins = apJre.listFiles(f -> f.isDirectory() && new File(f,"bin/java").exists()
+ || new File(f,"bin/java.exe").exists());
+ if (bins == null || bins.length == 0) {
+ // Try one level deeper (JRE extracted into a single sub-dir)
+ File candidate = findJreInSubdirs(apJre);
+ if (candidate != null) return candidate.getAbsolutePath();
+ } else {
+ String exe = System.getProperty("os.name","").toLowerCase().contains("win")
+ ? "java.exe" : "java";
+ return new File(bins[0], "bin/" + exe).getAbsolutePath();
+ }
+ }
+ // 2. System java
+ return "java";
+ }
+
+ @Nullable
+ private static File findJreInSubdirs(File root) {
+ File[] subs = root.listFiles(File::isDirectory);
+ if (subs == null) return null;
+ String exe = System.getProperty("os.name","").toLowerCase().contains("win")
+ ? "java.exe" : "java";
+ for (File sub : subs) {
+ File bin = new File(sub, "bin/" + exe);
+ if (bin.exists()) return bin;
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/osiris/autoplug/client/minecraft/launcher/MojangManifest.java b/src/main/java/com/osiris/autoplug/client/minecraft/launcher/MojangManifest.java
new file mode 100644
index 00000000..4f0c5c7f
--- /dev/null
+++ b/src/main/java/com/osiris/autoplug/client/minecraft/launcher/MojangManifest.java
@@ -0,0 +1,340 @@
+/*
+ * Copyright (c) 2024 Osiris-Team.
+ * All rights reserved.
+ *
+ * This software is copyrighted work, licensed under the terms
+ * of the MIT-License. Consult the "LICENSE" file for details.
+ * Implements GitHub issue #296 – Minecraft Client Launcher.
+ */
+
+package com.osiris.autoplug.client.minecraft.launcher;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+import okhttp3.ResponseBody;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Reads the Mojang version manifest v2 and per-version JSON files.
+ * Pure MIT-licensed – no HMCL code used or referenced.
+ */
+public class MojangManifest {
+
+ public static final String MANIFEST_URL =
+ "https://launchermeta.mojang.com/mc/game/version_manifest_v2.json";
+
+ static final OkHttpClient HTTP = new OkHttpClient.Builder()
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(120, TimeUnit.SECONDS)
+ .followRedirects(true)
+ .build();
+
+ // ── DTOs ────────────────────────────────────────────────────────────────
+
+ /** Lightweight entry from the top-level version list. */
+ public static class VersionEntry {
+ public final String id;
+ public final String type; // "release","snapshot","old_beta","old_alpha"
+ public final String url;
+ @Nullable public final String sha1;
+
+ public VersionEntry(String id, String type, String url, @Nullable String sha1) {
+ this.id = id; this.type = type; this.url = url; this.sha1 = sha1;
+ }
+ @Override public String toString() { return id + " (" + type + ")"; }
+ }
+
+ /** Full version descriptor resolved from the per-version JSON. */
+ public static class VersionDetail {
+ public final String id;
+ public final String mainClass;
+ public final String assetIndex;
+ public final String assetIndexUrl;
+ @Nullable public final String assetIndexSha1;
+ public final String clientJarUrl;
+ public final String clientJarSha1;
+ public final long clientJarSize;
+ /** Pre-1.13 flat arg string; null for versions that use "arguments". */
+ @Nullable public final String minecraftArguments;
+ public final List jvmArguments;
+ public final List gameArguments;
+ public final List libraries;
+ /** Recommended Java major version (e.g. 17 for 1.18+). */
+ public final int javaVersion;
+
+ VersionDetail(String id, String mainClass, String assetIndex, String assetIndexUrl,
+ @Nullable String assetIndexSha1, String clientJarUrl, String clientJarSha1,
+ long clientJarSize, @Nullable String minecraftArguments,
+ List jvmArguments, List gameArguments,
+ List libraries, int javaVersion) {
+ this.id = id; this.mainClass = mainClass;
+ this.assetIndex = assetIndex; this.assetIndexUrl = assetIndexUrl;
+ this.assetIndexSha1 = assetIndexSha1;
+ this.clientJarUrl = clientJarUrl; this.clientJarSha1 = clientJarSha1;
+ this.clientJarSize = clientJarSize;
+ this.minecraftArguments = minecraftArguments;
+ this.jvmArguments = jvmArguments; this.gameArguments = gameArguments;
+ this.libraries = libraries; this.javaVersion = javaVersion;
+ }
+ }
+
+ /** A JVM or game argument that may have OS/feature rules. */
+ public static class Argument {
+ @Nullable public final String value;
+ @Nullable public final List values;
+ public final List rules;
+
+ Argument(@Nullable String value, @Nullable List values, List rules) {
+ this.value = value; this.values = values; this.rules = rules;
+ }
+
+ /** True when this argument should be emitted on the running OS. */
+ public boolean appliesOnCurrentOs() {
+ if (rules.isEmpty()) return true;
+ for (Rule r : rules) {
+ boolean m = r.os == null || r.os.equalsIgnoreCase(currentOs());
+ if ("allow".equals(r.action) && m) return true;
+ if ("disallow".equals(r.action) && m) return false;
+ }
+ return false;
+ }
+
+ /** Returns "windows", "osx", or "linux". */
+ public static String currentOs() {
+ String n = System.getProperty("os.name", "").toLowerCase();
+ if (n.contains("win")) return "windows";
+ if (n.contains("mac")) return "osx";
+ return "linux";
+ }
+ }
+
+ /** A single allow/disallow rule from the JSON. */
+ public static class Rule {
+ public final String action;
+ @Nullable public final String os;
+ Rule(String action, @Nullable String os) { this.action = action; this.os = os; }
+ }
+
+ /** A Maven library or native library required by the version. */
+ public static class Library {
+ public final String name;
+ @Nullable public final String url;
+ @Nullable public final String sha1;
+ public final long size;
+ @Nullable public final String nativeClassifier; // e.g. "natives-linux"
+ @Nullable public final String nativesUrl;
+ @Nullable public final String nativesSha1;
+ public final List rules;
+
+ Library(String name, @Nullable String url, @Nullable String sha1, long size,
+ @Nullable String nativeClassifier, @Nullable String nativesUrl,
+ @Nullable String nativesSha1, List rules) {
+ this.name = name; this.url = url; this.sha1 = sha1; this.size = size;
+ this.nativeClassifier = nativeClassifier; this.nativesUrl = nativesUrl;
+ this.nativesSha1 = nativesSha1; this.rules = rules;
+ }
+
+ public boolean appliesOnCurrentOs() {
+ if (rules.isEmpty()) return true;
+ String os = Argument.currentOs();
+ for (Rule r : rules) {
+ boolean m = r.os == null || r.os.equalsIgnoreCase(os);
+ if ("allow".equals(r.action) && m) return true;
+ if ("disallow".equals(r.action) && m) return false;
+ }
+ return false;
+ }
+
+ /**
+ * Converts maven coords to a Maven-layout relative path.
+ * "com.example:foo:1.0" → "com/example/foo/1.0/foo-1.0[-classifier].jar"
+ */
+ public String toRelativePath() {
+ String[] p = name.split(":");
+ if (p.length < 3) return name.replace(':', '/');
+ String g = p[0].replace('.', '/'), a = p[1], v = p[2];
+ String c = nativeClassifier != null ? "-" + nativeClassifier : "";
+ return g + "/" + a + "/" + v + "/" + a + "-" + v + c + ".jar";
+ }
+ }
+
+ // ── Public API ──────────────────────────────────────────────────────────
+
+ /**
+ * Downloads and parses the top-level version manifest.
+ * @return all version entries, newest-release first (Mojang order)
+ */
+ @NotNull
+ public static List fetchVersionList() throws IOException {
+ String body = getString(MANIFEST_URL);
+ JsonArray arr = new Gson().fromJson(body, JsonObject.class).getAsJsonArray("versions");
+ List list = new ArrayList<>();
+ for (JsonElement el : arr) {
+ JsonObject o = el.getAsJsonObject();
+ list.add(new VersionEntry(o.get("id").getAsString(), o.get("type").getAsString(),
+ o.get("url").getAsString(),
+ o.has("sha1") ? o.get("sha1").getAsString() : null));
+ }
+ return list;
+ }
+
+ /** @return matching {@link VersionEntry} or null if not found. */
+ @Nullable
+ public static VersionEntry findVersion(String version) throws IOException {
+ for (VersionEntry e : fetchVersionList())
+ if (e.id.equals(version)) return e;
+ return null;
+ }
+
+ @NotNull
+ public static VersionDetail fetchVersionDetail(@NotNull VersionEntry entry) throws IOException {
+ return parseVersionJson(getString(entry.url));
+ }
+
+ @NotNull
+ public static VersionDetail fetchVersionDetail(String version) throws IOException {
+ VersionEntry e = findVersion(version);
+ if (e == null) throw new IOException("Version '" + version + "' not found in manifest.");
+ return fetchVersionDetail(e);
+ }
+
+ // ── JSON parsing (package-visible for unit tests) ───────────────────────
+
+ @NotNull
+ public static VersionDetail parseVersionJson(String json) {
+ JsonObject root = new Gson().fromJson(json, JsonObject.class);
+ String id = root.get("id").getAsString();
+ String mainClass = root.get("mainClass").getAsString();
+
+ JsonObject ai = root.getAsJsonObject("assetIndex");
+ String aiId = ai.get("id").getAsString();
+ String aiUrl = ai.get("url").getAsString();
+ String aiSha1 = ai.has("sha1") ? ai.get("sha1").getAsString() : null;
+
+ JsonObject cl = root.getAsJsonObject("downloads").getAsJsonObject("client");
+ String cUrl = cl.get("url").getAsString();
+ String cSha1 = cl.get("sha1").getAsString();
+ long cSize = cl.get("size").getAsLong();
+
+ int jv = 8;
+ if (root.has("javaVersion")) {
+ JsonObject jo = root.getAsJsonObject("javaVersion");
+ if (jo.has("majorVersion")) jv = jo.get("majorVersion").getAsInt();
+ }
+
+ String legacy = null;
+ List jvmA = new ArrayList<>(), gameA = new ArrayList<>();
+ if (root.has("minecraftArguments")) {
+ legacy = root.get("minecraftArguments").getAsString();
+ } else if (root.has("arguments")) {
+ JsonObject ao = root.getAsJsonObject("arguments");
+ if (ao.has("jvm")) jvmA = parseArgList(ao.getAsJsonArray("jvm"));
+ if (ao.has("game")) gameA = parseArgList(ao.getAsJsonArray("game"));
+ }
+
+ List libs = new ArrayList<>();
+ if (root.has("libraries"))
+ for (JsonElement le : root.getAsJsonArray("libraries")) {
+ Library l = parseLibrary(le.getAsJsonObject());
+ if (l != null) libs.add(l);
+ }
+
+ return new VersionDetail(id, mainClass, aiId, aiUrl, aiSha1, cUrl, cSha1, cSize,
+ legacy, jvmA, gameA, libs, jv);
+ }
+
+ private static List parseArgList(JsonArray arr) {
+ List r = new ArrayList<>();
+ for (JsonElement el : arr) {
+ if (el.isJsonPrimitive()) {
+ r.add(new Argument(el.getAsString(), null, new ArrayList<>()));
+ } else {
+ JsonObject o = el.getAsJsonObject();
+ List rules = parseRules(o);
+ JsonElement v = o.get("value");
+ if (v == null) continue;
+ if (v.isJsonPrimitive()) {
+ r.add(new Argument(v.getAsString(), null, rules));
+ } else {
+ List vals = new ArrayList<>();
+ for (JsonElement x : v.getAsJsonArray()) vals.add(x.getAsString());
+ r.add(new Argument(null, vals, rules));
+ }
+ }
+ }
+ return r;
+ }
+
+ private static List parseRules(JsonObject o) {
+ List r = new ArrayList<>();
+ if (!o.has("rules")) return r;
+ for (JsonElement re : o.getAsJsonArray("rules")) {
+ JsonObject ro = re.getAsJsonObject();
+ String action = ro.get("action").getAsString();
+ String os = null;
+ if (ro.has("os")) {
+ JsonObject osObj = ro.getAsJsonObject("os");
+ if (osObj.has("name")) os = osObj.get("name").getAsString();
+ }
+ r.add(new Rule(action, os));
+ }
+ return r;
+ }
+
+ @Nullable
+ private static Library parseLibrary(JsonObject o) {
+ if (!o.has("name")) return null;
+ String name = o.get("name").getAsString();
+ List rules = parseRules(o);
+ String url = null, sha1 = null; long size = 0;
+ if (o.has("downloads")) {
+ JsonObject dl = o.getAsJsonObject("downloads");
+ if (dl.has("artifact")) {
+ JsonObject a = dl.getAsJsonObject("artifact");
+ url = a.has("url") ? a.get("url").getAsString() : null;
+ sha1 = a.has("sha1") ? a.get("sha1").getAsString() : null;
+ size = a.has("size") ? a.get("size").getAsLong() : 0;
+ }
+ }
+ String nCls = null, nUrl = null, nSha1 = null;
+ if (o.has("natives")) {
+ String osKey = Argument.currentOs();
+ JsonObject nm = o.getAsJsonObject("natives");
+ if (nm.has(osKey)) {
+ nCls = nm.get(osKey).getAsString().replace("${arch}",
+ System.getProperty("os.arch","x64").contains("64") ? "64" : "32");
+ if (o.has("downloads")) {
+ JsonObject classif = o.getAsJsonObject("downloads").getAsJsonObject("classifiers");
+ if (classif != null && classif.has(nCls)) {
+ JsonObject nc = classif.getAsJsonObject(nCls);
+ nUrl = nc.has("url") ? nc.get("url").getAsString() : null;
+ nSha1 = nc.has("sha1") ? nc.get("sha1").getAsString() : null;
+ }
+ }
+ }
+ }
+ return new Library(name, url, sha1, size, nCls, nUrl, nSha1, rules);
+ }
+
+ static String getString(String url) throws IOException {
+ Request req = new Request.Builder().url(url)
+ .header("User-Agent","AutoPlug-Client - https://autoplug.one").build();
+ try (Response resp = HTTP.newCall(req).execute()) {
+ if (!resp.isSuccessful()) throw new IOException("HTTP " + resp.code() + " for " + url);
+ ResponseBody b = resp.body();
+ if (b == null) throw new IOException("Null body for " + url);
+ return b.string();
+ }
+ }
+}
diff --git a/src/main/java/com/osiris/autoplug/client/minecraft/profile/LaunchProfile.java b/src/main/java/com/osiris/autoplug/client/minecraft/profile/LaunchProfile.java
new file mode 100644
index 00000000..f885478f
--- /dev/null
+++ b/src/main/java/com/osiris/autoplug/client/minecraft/profile/LaunchProfile.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright (c) 2024 Osiris-Team.
+ * All rights reserved.
+ *
+ * This software is copyrighted work, licensed under the terms
+ * of the MIT-License. Consult the "LICENSE" file for details.
+ * Implements GitHub issue #296 – Minecraft Client Launcher.
+ */
+
+package com.osiris.autoplug.client.minecraft.profile;
+
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * In-memory representation of a named launch profile.
+ * Persistence is handled by {@link ProfileManager}.
+ */
+public class LaunchProfile {
+
+ /** Unique profile name (used as directory name). */
+ @NotNull public String name;
+
+ /** Minecraft version string, e.g. "1.21.1". */
+ @NotNull public String minecraftVersion;
+
+ /**
+ * Mod loader type: "vanilla", "fabric", "quilt", "forge", "neoforge".
+ * Default is "vanilla".
+ */
+ @NotNull public String loaderType = "vanilla";
+
+ /**
+ * Override game directory. If null the default
+ * {@code autoplug/profiles//game} is used.
+ */
+ @Nullable public File gameDir;
+
+ /** Extra JVM flags, e.g. ["-Dfml.ignoreInvalidMinecraftCertificates=true"]. */
+ @NotNull public List jvmArgs = new ArrayList<>();
+
+ /**
+ * Maximum JVM heap in megabytes (-Xmx). 0 means use the launcher's default (2048).
+ */
+ public int maxMemoryMb = 0;
+
+ /**
+ * Mojang version type reported to the game: "release" or "snapshot".
+ * Usually matches the version type from the manifest.
+ */
+ @Nullable public String versionType;
+
+ /**
+ * Optional association with a server address (host:port).
+ * When set, launching this profile will auto-connect to the server.
+ */
+ @Nullable public String serverAddress;
+
+ /** List of mod file names in the profile's mods directory. Informational only. */
+ @NotNull public List mods = new ArrayList<>();
+
+ public LaunchProfile(@NotNull String name, @NotNull String minecraftVersion) {
+ this.name = name;
+ this.minecraftVersion = minecraftVersion;
+ }
+
+ /** Returns the profile root directory: {@code autoplug/profiles//}. */
+ @NotNull
+ public File profileDir(File workingDir) {
+ return new File(workingDir, "autoplug/profiles/" + name);
+ }
+
+ /** Returns the mods directory for this profile. */
+ @NotNull
+ public File modsDir(File workingDir) {
+ return new File(profileDir(workingDir), "mods");
+ }
+
+ @Override public String toString() {
+ return name + " [" + minecraftVersion + "/" + loaderType + "]";
+ }
+}
diff --git a/src/main/java/com/osiris/autoplug/client/minecraft/profile/ModVersionAdapter.java b/src/main/java/com/osiris/autoplug/client/minecraft/profile/ModVersionAdapter.java
new file mode 100644
index 00000000..1bc4d534
--- /dev/null
+++ b/src/main/java/com/osiris/autoplug/client/minecraft/profile/ModVersionAdapter.java
@@ -0,0 +1,168 @@
+/*
+ * Copyright (c) 2024 Osiris-Team.
+ * All rights reserved.
+ *
+ * This software is copyrighted work, licensed under the terms
+ * of the MIT-License. Consult the "LICENSE" file for details.
+ * Implements GitHub issue #296 – Minecraft Client Launcher.
+ */
+
+package com.osiris.autoplug.client.minecraft.profile;
+
+import com.osiris.autoplug.client.tasks.updater.mods.MinecraftMod;
+import com.osiris.autoplug.client.tasks.updater.mods.ModrinthAPI;
+import com.osiris.autoplug.client.tasks.updater.mods.InstalledModLoader;
+import com.osiris.autoplug.client.tasks.updater.search.SearchResult;
+import com.osiris.jlib.logger.AL;
+import org.jetbrains.annotations.NotNull;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Upgrades or downgrades mods in a profile's mods directory to match a target
+ * Minecraft version.
+ *
+ * Mods that have no release for the target version are renamed to
+ * {@code .jar.disabled} so the game still starts cleanly.
+ */
+public class ModVersionAdapter {
+
+ /**
+ * Adapts all mods in {@code modsDir} for {@code targetMcVersion}.
+ *
+ * @param modsDir directory containing {@code .jar} (and optionally {@code .jar.disabled}) files
+ * @param targetMcVersion e.g. "1.21.1"
+ * @param loaderType "fabric", "forge", etc. – used when querying Modrinth
+ * @return list of human-readable result messages (one per mod)
+ */
+ @NotNull
+ public static List adapt(@NotNull File modsDir,
+ @NotNull String targetMcVersion,
+ @NotNull String loaderType) {
+ List results = new ArrayList<>();
+ File[] jars = modsDir.listFiles(f ->
+ f.getName().endsWith(".jar") || f.getName().endsWith(".jar.disabled"));
+ if (jars == null) {
+ results.add("No mods directory or no mods found.");
+ return results;
+ }
+
+ InstalledModLoader loaderInfo = new InstalledModLoader(
+ "fabric".equalsIgnoreCase(loaderType), "quilt".equalsIgnoreCase(loaderType));
+
+ for (File jar : jars) {
+ String fileName = jar.getName().replace(".jar.disabled", "").replace(".jar","");
+ try {
+ // Re-enable disabled mod so we can inspect it
+ File active = jar;
+ if (jar.getName().endsWith(".jar.disabled")) {
+ active = new File(jar.getParent(), fileName + ".jar");
+ Files.copy(jar.toPath(), active.toPath(),
+ java.nio.file.StandardCopyOption.REPLACE_EXISTING);
+ jar.delete();
+ }
+
+ // Build a minimal MinecraftMod from MANIFEST / fabric.mod.json in the jar
+ MinecraftMod mod = readModMeta(active);
+ if (mod == null) {
+ results.add("[SKIP] " + fileName + " – cannot read metadata");
+ continue;
+ }
+
+ // Query Modrinth for the target version
+ SearchResult sr = new ModrinthAPI().searchUpdateMod(loaderInfo, mod, targetMcVersion);
+
+ if (sr.type == SearchResult.Type.API_ERROR || sr.getDownloadUrl() == null) {
+ // No release for this MC version – disable
+ File disabled = new File(active.getParent(), active.getName() + ".disabled");
+ Files.move(active.toPath(), disabled.toPath(),
+ java.nio.file.StandardCopyOption.REPLACE_EXISTING);
+ results.add("[DISABLED] " + fileName + " – no release for " + targetMcVersion);
+ } else {
+ // Download and replace
+ File downloaded = downloadMod(sr.getDownloadUrl(), active);
+ if (downloaded != null) {
+ results.add("[UPDATED] " + fileName + " → " + sr.getLatestVersion());
+ } else {
+ results.add("[FAILED] " + fileName + " – download error");
+ }
+ }
+ } catch (Exception e) {
+ results.add("[ERROR] " + fileName + ": " + e.getMessage());
+ AL.warn("[ModAdapter] Error processing " + fileName, e);
+ }
+ }
+ return results;
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────────
+
+ @org.jetbrains.annotations.Nullable
+ private static MinecraftMod readModMeta(File jar) {
+ try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(jar)) {
+ // Try fabric.mod.json first
+ java.util.zip.ZipEntry fe = zf.getEntry("fabric.mod.json");
+ if (fe != null) {
+ com.google.gson.JsonObject o = new com.google.gson.Gson().fromJson(
+ new java.io.InputStreamReader(zf.getInputStream(fe)),
+ com.google.gson.JsonObject.class);
+ String id = o.has("id") ? o.get("id").getAsString() : jar.getName();
+ String version = o.has("version") ? o.get("version").getAsString() : "0";
+ String author = "unknown";
+ if (o.has("authors") && o.getAsJsonArray("authors").size() > 0) {
+ com.google.gson.JsonElement a = o.getAsJsonArray("authors").get(0);
+ author = a.isJsonPrimitive() ? a.getAsString()
+ : a.getAsJsonObject().get("name").getAsString();
+ }
+ return new MinecraftMod(jar.getAbsolutePath(), id, version, author, id, null, null);
+ }
+ // Try MANIFEST.MF Name attribute
+ java.util.zip.ZipEntry mf = zf.getEntry("META-INF/MANIFEST.MF");
+ if (mf != null) {
+ java.util.Properties props = new java.util.Properties();
+ props.load(zf.getInputStream(mf));
+ String name = props.getProperty("Implementation-Title",
+ props.getProperty("Specification-Title", jar.getName().replace(".jar","")));
+ String ver = props.getProperty("Implementation-Version", "0");
+ return new MinecraftMod(jar.getAbsolutePath(), name, ver, "unknown", null, null, null);
+ }
+ } catch (Exception ignored) {}
+ // Fallback: use filename
+ return new MinecraftMod(jar.getAbsolutePath(),
+ jar.getName().replace(".jar",""), "0", "unknown", null, null, null);
+ }
+
+ @org.jetbrains.annotations.Nullable
+ private static File downloadMod(String url, File dest) {
+ try {
+ okhttp3.OkHttpClient http = new okhttp3.OkHttpClient.Builder()
+ .connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
+ .readTimeout(5, java.util.concurrent.TimeUnit.MINUTES)
+ .followRedirects(true).build();
+ okhttp3.Request req = new okhttp3.Request.Builder()
+ .url(url).header("User-Agent","AutoPlug-Client").build();
+ try (okhttp3.Response resp = http.newCall(req).execute()) {
+ if (!resp.isSuccessful()) return null;
+ okhttp3.ResponseBody body = resp.body();
+ if (body == null) return null;
+ File tmp = new File(dest.getAbsolutePath() + ".tmp");
+ try (java.io.InputStream in = body.byteStream();
+ java.io.OutputStream out = new java.io.BufferedOutputStream(
+ new java.io.FileOutputStream(tmp))) {
+ byte[] buf = new byte[65536]; int n;
+ while ((n = in.read(buf)) != -1) out.write(buf, 0, n);
+ }
+ Files.move(tmp.toPath(), dest.toPath(),
+ java.nio.file.StandardCopyOption.REPLACE_EXISTING);
+ return dest;
+ }
+ } catch (Exception e) {
+ AL.warn("[ModAdapter] Download failed: " + url, e);
+ return null;
+ }
+ }
+}
diff --git a/src/main/java/com/osiris/autoplug/client/minecraft/profile/ProfileConfig.java b/src/main/java/com/osiris/autoplug/client/minecraft/profile/ProfileConfig.java
new file mode 100644
index 00000000..0b73f320
--- /dev/null
+++ b/src/main/java/com/osiris/autoplug/client/minecraft/profile/ProfileConfig.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright (c) 2024 Osiris-Team.
+ * All rights reserved.
+ *
+ * This software is copyrighted work, licensed under the terms
+ * of the MIT-License. Consult the "LICENSE" file for details.
+ * Implements GitHub issue #296 – Minecraft Client Launcher.
+ */
+
+package com.osiris.autoplug.client.minecraft.profile;
+
+import com.osiris.autoplug.client.configs.MyYaml;
+import com.osiris.dyml.YamlSection;
+import com.osiris.dyml.exceptions.*;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Dream-YAML backed persistence for a single {@link LaunchProfile}.
+ * File location: {@code autoplug/profiles//profile.yml}
+ */
+public class ProfileConfig extends MyYaml {
+
+ public YamlSection profileName;
+ public YamlSection minecraftVersion;
+ public YamlSection loaderType;
+ public YamlSection gameDir;
+ public YamlSection jvmArgs;
+ public YamlSection maxMemoryMb;
+ public YamlSection versionType;
+ public YamlSection serverAddress;
+ public YamlSection mods;
+
+ public ProfileConfig(File profileYml)
+ throws IOException, DuplicateKeyException, YamlReaderException,
+ IllegalListException, NotLoadedException, IllegalKeyException, YamlWriterException {
+ super(profileYml.getAbsolutePath());
+ lockFile();
+ load();
+ String n = "profile";
+ profileName = put(n, "name").setDefValues("");
+ minecraftVersion = put(n, "minecraft-version").setDefValues("1.21.1");
+ loaderType = put(n, "loader-type").setDefValues("vanilla");
+ gameDir = put(n, "game-dir").setDefValues("");
+ jvmArgs = put(n, "jvm-args").setDefValues();
+ maxMemoryMb = put(n, "max-memory-mb").setDefValues("0");
+ versionType = put(n, "version-type").setDefValues("release");
+ serverAddress = put(n, "server-address").setDefValues("");
+ mods = put(n, "mods").setDefValues();
+ save();
+ unlockFile();
+ }
+
+ /** Converts this config to an in-memory {@link LaunchProfile}. */
+ public LaunchProfile toProfile() {
+ LaunchProfile p = new LaunchProfile(
+ profileName.asString() != null && !profileName.asString().isEmpty()
+ ? profileName.asString()
+ : new File(this.file.getParent()).getName(),
+ minecraftVersion.asString() != null ? minecraftVersion.asString() : "1.21.1");
+ p.loaderType = loaderType.asString() != null ? loaderType.asString() : "vanilla";
+ String gd = gameDir.asString();
+ if (gd != null && !gd.isEmpty()) p.gameDir = new File(gd);
+ List args = jvmArgs.asStringList();
+ if (args != null) p.jvmArgs = args;
+ p.maxMemoryMb = maxMemoryMb.asInt();
+ p.versionType = versionType.asString();
+ String sa = serverAddress.asString();
+ if (sa != null && !sa.isEmpty()) p.serverAddress = sa;
+ List modList = mods.asStringList();
+ if (modList != null) p.mods = modList;
+ return p;
+ }
+
+ /** Writes a {@link LaunchProfile} into this config and saves. */
+ public void fromProfile(LaunchProfile p)
+ throws IOException, DuplicateKeyException, YamlReaderException,
+ IllegalListException, YamlWriterException {
+ profileName.setValues(p.name);
+ minecraftVersion.setValues(p.minecraftVersion);
+ loaderType.setValues(p.loaderType);
+ gameDir.setValues(p.gameDir != null ? p.gameDir.getAbsolutePath() : "");
+ jvmArgs.setValues(p.jvmArgs.toArray(new String[0]));
+ maxMemoryMb.setValues(String.valueOf(p.maxMemoryMb));
+ versionType.setValues(p.versionType != null ? p.versionType : "release");
+ serverAddress.setValues(p.serverAddress != null ? p.serverAddress : "");
+ mods.setValues(p.mods.toArray(new String[0]));
+ save();
+ }
+
+ @Override
+ public com.osiris.dyml.Yaml validateValues() {
+ // Nothing to validate beyond defaults
+ return this;
+ }
+}
diff --git a/src/main/java/com/osiris/autoplug/client/minecraft/profile/ProfileManager.java b/src/main/java/com/osiris/autoplug/client/minecraft/profile/ProfileManager.java
new file mode 100644
index 00000000..c6b0896d
--- /dev/null
+++ b/src/main/java/com/osiris/autoplug/client/minecraft/profile/ProfileManager.java
@@ -0,0 +1,134 @@
+/*
+ * Copyright (c) 2024 Osiris-Team.
+ * All rights reserved.
+ *
+ * This software is copyrighted work, licensed under the terms
+ * of the MIT-License. Consult the "LICENSE" file for details.
+ * Implements GitHub issue #296 – Minecraft Client Launcher.
+ */
+
+package com.osiris.autoplug.client.minecraft.profile;
+
+import com.osiris.autoplug.client.utils.GD;
+import com.osiris.jlib.logger.AL;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Creates, loads, lists, and deletes {@link LaunchProfile} instances.
+ * Profiles are stored under {@code autoplug/profiles//profile.yml}.
+ */
+public class ProfileManager {
+
+ private static final File PROFILES_ROOT = new File(GD.WORKING_DIR, "autoplug/profiles");
+
+ // ── CRUD ─────────────────────────────────────────────────────────────────
+
+ /**
+ * Creates a new profile with the given name and Minecraft version.
+ * Fails if a profile with that name already exists.
+ *
+ * @throws IllegalArgumentException if name is blank or already taken
+ */
+ @NotNull
+ public static LaunchProfile create(@NotNull String name, @NotNull String mcVersion)
+ throws Exception {
+ if (name.trim().isEmpty()) throw new IllegalArgumentException("Profile name must not be blank.");
+ File dir = profileDir(name);
+ if (dir.exists()) throw new IllegalArgumentException("Profile '" + name + "' already exists.");
+ dir.mkdirs();
+
+ LaunchProfile p = new LaunchProfile(name, mcVersion);
+ save(p);
+ try { AL.info("[Profiles] Created profile: " + p); } catch (Exception ignored) {}
+ return p;
+ }
+
+ /**
+ * Loads a profile by name from disk.
+ *
+ * @return the profile, or null if it does not exist
+ */
+ @Nullable
+ public static LaunchProfile load(@NotNull String name) throws Exception {
+ File yml = profileYml(name);
+ if (!yml.exists()) return null;
+ ProfileConfig cfg = new ProfileConfig(yml);
+ LaunchProfile p = cfg.toProfile();
+ p.name = name; // ensure name matches directory
+ return p;
+ }
+
+ /**
+ * Persists a profile to disk, overwriting any existing config.
+ */
+ public static void save(@NotNull LaunchProfile p) throws Exception {
+ File dir = profileDir(p.name);
+ dir.mkdirs();
+ new ProfileConfig(profileYml(p.name)).fromProfile(p);
+ }
+
+ /**
+ * Deletes a profile and its entire directory.
+ *
+ * @return true if the directory was successfully deleted
+ */
+ public static boolean delete(@NotNull String name) {
+ File dir = profileDir(name);
+ if (!dir.exists()) return false;
+ return deleteRecursive(dir);
+ }
+
+ /**
+ * Lists all profile names present in the profiles root directory.
+ */
+ @NotNull
+ public static List listNames() {
+ List names = new ArrayList<>();
+ File[] dirs = PROFILES_ROOT.listFiles(File::isDirectory);
+ if (dirs == null) return names;
+ for (File d : dirs) {
+ if (new File(d, "profile.yml").exists()) names.add(d.getName());
+ }
+ return names;
+ }
+
+ /**
+ * Loads all existing profiles from disk.
+ */
+ @NotNull
+ public static List loadAll() {
+ List profiles = new ArrayList<>();
+ for (String name : listNames()) {
+ try {
+ LaunchProfile p = load(name);
+ if (p != null) profiles.add(p);
+ } catch (Exception e) {
+ try { AL.warn("[Profiles] Failed to load profile '" + name + "': " + e.getMessage(), e); } catch (Exception ignored) {}
+ }
+ }
+ return profiles;
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────────
+
+ @NotNull public static File profileDir(@NotNull String name) {
+ return new File(PROFILES_ROOT, name);
+ }
+
+ @NotNull public static File profileYml(@NotNull String name) {
+ return new File(profileDir(name), "profile.yml");
+ }
+
+ private static boolean deleteRecursive(File f) {
+ if (f.isDirectory()) {
+ File[] children = f.listFiles();
+ if (children != null) for (File c : children) deleteRecursive(c);
+ }
+ return f.delete();
+ }
+}
diff --git a/src/main/java/com/osiris/autoplug/client/minecraft/server/ServerBrowser.java b/src/main/java/com/osiris/autoplug/client/minecraft/server/ServerBrowser.java
new file mode 100644
index 00000000..e52057fe
--- /dev/null
+++ b/src/main/java/com/osiris/autoplug/client/minecraft/server/ServerBrowser.java
@@ -0,0 +1,198 @@
+/*
+ * Copyright (c) 2024 Osiris-Team.
+ * All rights reserved.
+ *
+ * This software is copyrighted work, licensed under the terms
+ * of the MIT-License. Consult the "LICENSE" file for details.
+ * Implements GitHub issue #296 – Minecraft Client Launcher.
+ */
+
+package com.osiris.autoplug.client.minecraft.server;
+
+import com.osiris.autoplug.client.minecraft.launcher.MinecraftLauncher;
+import com.osiris.autoplug.client.minecraft.profile.LaunchProfile;
+import com.osiris.autoplug.client.minecraft.profile.ModVersionAdapter;
+import com.osiris.autoplug.client.minecraft.profile.ProfileManager;
+import com.osiris.autoplug.client.utils.GD;
+import com.osiris.jlib.logger.AL;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+
+/**
+ * High-level API for the AutoPlug Server Browser feature.
+ *
+ * - Read / write {@code servers.dat} (NBT)
+ * - Add / remove / edit server entries
+ * - Ping servers asynchronously
+ * - Join a server: detect required version → select / create profile → adapt mods → launch
+ *
+ */
+public class ServerBrowser {
+
+ private static final Executor ASYNC = Executors.newCachedThreadPool(r -> {
+ Thread t = new Thread(r, "ServerBrowser-worker");
+ t.setDaemon(true);
+ return t;
+ });
+
+ private final File serversDatFile;
+
+ /**
+ * @param serversDatFile path to {@code servers.dat} (typically inside the profile's game dir)
+ */
+ public ServerBrowser(@NotNull File serversDatFile) {
+ this.serversDatFile = serversDatFile;
+ }
+
+ // ── servers.dat CRUD ─────────────────────────────────────────────────────
+
+ /** Reads all server entries from {@code servers.dat}. Returns empty list if file absent. */
+ @NotNull
+ public List readServers() throws java.io.IOException {
+ return ServersDat.read(serversDatFile);
+ }
+
+ /** Persists the full server list to {@code servers.dat}. */
+ public void writeServers(@NotNull List servers) throws java.io.IOException {
+ ServersDat.write(serversDatFile, servers);
+ }
+
+ /** Adds a server and saves. */
+ public void addServer(@NotNull ServerEntry entry) throws java.io.IOException {
+ List list = readServers();
+ list.add(entry);
+ writeServers(list);
+ }
+
+ /** Removes the first server whose IP matches and saves. */
+ public boolean removeServer(@NotNull String ip) throws java.io.IOException {
+ List list = readServers();
+ boolean removed = list.removeIf(e -> e.ip.equalsIgnoreCase(ip));
+ if (removed) writeServers(list);
+ return removed;
+ }
+
+ // ── Ping ─────────────────────────────────────────────────────────────────
+
+ /**
+ * Pings all known servers asynchronously, writing results into each {@link ServerEntry}.
+ *
+ * @param onDone called (possibly from a background thread) when all pings complete
+ */
+ public CompletableFuture> pingAllAsync() throws java.io.IOException {
+ List servers = readServers();
+ List> futures = new ArrayList<>();
+ for (ServerEntry s : servers) {
+ futures.add(CompletableFuture.runAsync(() -> ServerPinger.ping(s), ASYNC));
+ }
+ return CompletableFuture
+ .allOf(futures.toArray(new CompletableFuture[0]))
+ .thenApply(v -> servers);
+ }
+
+ /**
+ * Pings a single server synchronously.
+ *
+ * @return true if the ping succeeded
+ */
+ public boolean ping(@NotNull ServerEntry entry) {
+ return ServerPinger.ping(entry);
+ }
+
+ // ── Join flow ────────────────────────────────────────────────────────────
+
+ /**
+ * Joins a server:
+ *
+ * - Pings the server to detect the required Minecraft version.
+ * - Finds an existing profile matching that version, or clones the base template.
+ * - Runs {@link ModVersionAdapter} to upgrade/downgrade mods for that MC version.
+ * - Launches the game process pointing at the server.
+ *
+ *
+ * @param entry server to join
+ * @param playerName Minecraft player name
+ * @param uuid player UUID (null = offline)
+ * @param accessToken MSA access token (null = offline)
+ * @return the launched {@link Process}
+ */
+ @NotNull
+ public Process join(
+ @NotNull ServerEntry entry,
+ @NotNull String playerName,
+ @Nullable String uuid,
+ @Nullable String accessToken) throws Exception {
+
+ // 1. Ping for version
+ AL.info("[ServerBrowser] Pinging " + entry.ip + "...");
+ ServerPinger.ping(entry);
+ String detectedVersion = entry.detectedVersion;
+ if (detectedVersion == null) {
+ AL.warn("[ServerBrowser] Could not detect MC version from ping; attempting join with latest known profile.");
+ }
+
+ // 2. Find or create a suitable profile
+ LaunchProfile profile = findOrCreateProfile(detectedVersion);
+
+ // 3. Adapt mods for target version
+ File modsDir = profile.modsDir(GD.WORKING_DIR);
+ modsDir.mkdirs();
+ if (modsDir.list() != null && modsDir.list().length > 0 && detectedVersion != null) {
+ AL.info("[ServerBrowser] Adapting mods for Minecraft " + detectedVersion + "...");
+ List results = ModVersionAdapter.adapt(modsDir, detectedVersion, profile.loaderType);
+ for (String r : results) AL.info("[Mods] " + r);
+ }
+
+ // 4. Set server address in profile so vanilla auto-connects
+ profile.serverAddress = entry.ip;
+ ProfileManager.save(profile);
+
+ // 5. Launch
+ AL.info("[ServerBrowser] Launching profile '" + profile.name + "' → " + entry.ip);
+ return MinecraftLauncher.launch(profile, playerName, uuid, accessToken,
+ (done, total) -> AL.info("[Download] " + done + "/" + total));
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────────
+
+ @NotNull
+ private LaunchProfile findOrCreateProfile(@Nullable String mcVersion) throws Exception {
+ if (mcVersion != null) {
+ // Strip extra info like "1.21.1 Paper" → "1.21.1"
+ String ver = extractCleanVersion(mcVersion);
+ for (LaunchProfile p : ProfileManager.loadAll()) {
+ if (p.minecraftVersion.equals(ver)) return p;
+ }
+ // Clone default template if it exists
+ LaunchProfile template = ProfileManager.load("default");
+ String name = "auto-" + ver;
+ LaunchProfile p;
+ if (template != null) {
+ p = new LaunchProfile(name, ver);
+ p.loaderType = template.loaderType;
+ p.jvmArgs = new ArrayList<>(template.jvmArgs);
+ p.maxMemoryMb = template.maxMemoryMb;
+ } else {
+ p = new LaunchProfile(name, ver);
+ }
+ ProfileManager.save(p);
+ return p;
+ }
+ // Fallback: use "default" profile or create one for the latest release
+ LaunchProfile def = ProfileManager.load("default");
+ if (def != null) return def;
+ return ProfileManager.create("default", "1.21.1");
+ }
+
+ private static String extractCleanVersion(String raw) {
+ // Strip pre/post info – keep only major.minor.patch
+ return raw.replaceAll("[^0-9.].*", "").trim();
+ }
+}
diff --git a/src/main/java/com/osiris/autoplug/client/minecraft/server/ServerEntry.java b/src/main/java/com/osiris/autoplug/client/minecraft/server/ServerEntry.java
new file mode 100644
index 00000000..09e89f96
--- /dev/null
+++ b/src/main/java/com/osiris/autoplug/client/minecraft/server/ServerEntry.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright (c) 2024 Osiris-Team.
+ * All rights reserved.
+ *
+ * This software is copyrighted work, licensed under the terms
+ * of the MIT-License. Consult the "LICENSE" file for details.
+ * Implements GitHub issue #296 – Minecraft Client Launcher.
+ */
+
+package com.osiris.autoplug.client.minecraft.server;
+
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Represents a single entry in {@code servers.dat}.
+ * Preserves any unknown NBT string tags so round-tripping never loses data.
+ */
+public class ServerEntry {
+
+ /** Display name shown in the multiplayer list. */
+ @NotNull public String name;
+
+ /** IP address or hostname (optionally with port, e.g. "play.example.com:25565"). */
+ @NotNull public String ip;
+
+ /** Icon as a Base64-encoded PNG data URI (may be null). */
+ @Nullable public String icon;
+
+ /** Accepts-texture-pack flag (0 = prompt, 1 = always, 2 = never). */
+ public byte acceptTextures = 0;
+
+ /** Pinged version string – populated by {@link ServerPinger}. */
+ @Nullable public String detectedVersion;
+
+ /** Current online players – populated by {@link ServerPinger}. */
+ public int onlinePlayers = -1;
+
+ /** Max players – populated by {@link ServerPinger}. */
+ public int maxPlayers = -1;
+
+ /** MOTD – populated by {@link ServerPinger}. */
+ @Nullable public String motd;
+
+ /** Ping latency in ms – populated by {@link ServerPinger}. */
+ public long latencyMs = -1;
+
+ /**
+ * Extra string tags read from the NBT that this model does not natively handle.
+ * Preserved verbatim on write so no data is lost.
+ */
+ public final Map extraTags = new LinkedHashMap<>();
+
+ public ServerEntry(@NotNull String name, @NotNull String ip) {
+ this.name = name; this.ip = ip;
+ }
+
+ /** Returns host portion of the IP (without port). */
+ @NotNull public String host() {
+ int colon = ip.lastIndexOf(':');
+ return colon > 0 ? ip.substring(0, colon) : ip;
+ }
+
+ /** Returns port, or 25565 if not specified. */
+ public int port() {
+ int colon = ip.lastIndexOf(':');
+ if (colon <= 0) return 25565;
+ try { return Integer.parseInt(ip.substring(colon + 1)); }
+ catch (NumberFormatException e) { return 25565; }
+ }
+
+ @Override public String toString() { return name + " (" + ip + ")"; }
+}
diff --git a/src/main/java/com/osiris/autoplug/client/minecraft/server/ServerPinger.java b/src/main/java/com/osiris/autoplug/client/minecraft/server/ServerPinger.java
new file mode 100644
index 00000000..2e704fe2
--- /dev/null
+++ b/src/main/java/com/osiris/autoplug/client/minecraft/server/ServerPinger.java
@@ -0,0 +1,125 @@
+/*
+ * Copyright (c) 2024 Osiris-Team.
+ * All rights reserved.
+ *
+ * This software is copyrighted work, licensed under the terms
+ * of the MIT-License. Consult the "LICENSE" file for details.
+ * Implements GitHub issue #296 – Minecraft Client Launcher.
+ */
+
+package com.osiris.autoplug.client.minecraft.server;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonObject;
+import com.osiris.jlib.logger.AL;
+import org.jetbrains.annotations.NotNull;
+
+import java.io.*;
+import java.net.*;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * Pings a Minecraft server using the 1.7+ SLP (Server List Ping) protocol
+ * and populates a {@link ServerEntry} with MOTD, player counts, version, and latency.
+ *
+ * Falls back to the legacy (pre-1.7) ping if the modern protocol fails.
+ */
+public class ServerPinger {
+
+ private static final int TIMEOUT_MS = 5_000;
+
+ /**
+ * Pings the server specified by {@code entry} and writes the result fields
+ * ({@link ServerEntry#detectedVersion}, {@link ServerEntry#onlinePlayers}, etc.)
+ * into the same entry.
+ *
+ * @param entry server to ping (reads host/port, writes status fields)
+ * @return true if the ping succeeded
+ */
+ public static boolean ping(@NotNull ServerEntry entry) {
+ long start = System.currentTimeMillis();
+ try {
+ try (Socket socket = new Socket()) {
+ socket.connect(new InetSocketAddress(entry.host(), entry.port()), TIMEOUT_MS);
+ socket.setSoTimeout(TIMEOUT_MS);
+
+ DataOutputStream out = new DataOutputStream(
+ new BufferedOutputStream(socket.getOutputStream()));
+ DataInputStream in = new DataInputStream(
+ new BufferedInputStream(socket.getInputStream()));
+
+ // ── Handshake packet (0x00) ────────────────────────────────
+ byte[] host = entry.host().getBytes(StandardCharsets.UTF_8);
+ ByteArrayOutputStream handshakeBuf = new ByteArrayOutputStream();
+ DataOutputStream hs = new DataOutputStream(handshakeBuf);
+ writeVarInt(hs, 0x00); // packet id
+ writeVarInt(hs, 754); // protocol version (doesn't need to match)
+ writeVarInt(hs, host.length);
+ hs.write(host);
+ hs.writeShort(entry.port());
+ writeVarInt(hs, 1); // next state = STATUS
+
+ writeVarInt(out, handshakeBuf.size());
+ out.write(handshakeBuf.toByteArray());
+
+ // ── Status Request (0x00) ──────────────────────────────────
+ writeVarInt(out, 1); // length
+ writeVarInt(out, 0x00);
+ out.flush();
+
+ // ── Read Status Response ───────────────────────────────────
+ int length = readVarInt(in);
+ if (length < 1) throw new IOException("Empty status response");
+ readVarInt(in); // packet id (0x00)
+
+ int jsonLen = readVarInt(in);
+ byte[] jsonBytes = new byte[jsonLen];
+ in.readFully(jsonBytes);
+ String json = new String(jsonBytes, StandardCharsets.UTF_8);
+
+ entry.latencyMs = System.currentTimeMillis() - start;
+
+ JsonObject root = new Gson().fromJson(json, JsonObject.class);
+ if (root.has("version")) {
+ JsonObject ver = root.getAsJsonObject("version");
+ entry.detectedVersion = ver.has("name") ? ver.get("name").getAsString() : null;
+ }
+ if (root.has("players")) {
+ JsonObject pl = root.getAsJsonObject("players");
+ entry.onlinePlayers = pl.has("online") ? pl.get("online").getAsInt() : -1;
+ entry.maxPlayers = pl.has("max") ? pl.get("max").getAsInt() : -1;
+ }
+ if (root.has("description")) {
+ JsonObject desc = root.getAsJsonObject("description");
+ entry.motd = desc.has("text") ? desc.get("text").getAsString() : root.get("description").getAsString();
+ }
+ }
+ return true;
+ } catch (Exception e) {
+ AL.debug(ServerPinger.class, "Ping failed for " + entry.ip + ": " + e.getMessage());
+ return false;
+ }
+ }
+
+ // ── VarInt helpers (Minecraft protocol) ─────────────────────────────────
+
+ private static void writeVarInt(OutputStream out, int value) throws IOException {
+ while ((value & ~0x7F) != 0) {
+ out.write((value & 0x7F) | 0x80);
+ value >>>= 7;
+ }
+ out.write(value);
+ }
+
+ private static int readVarInt(DataInputStream in) throws IOException {
+ int result = 0, shift = 0;
+ byte b;
+ do {
+ b = in.readByte();
+ result |= (b & 0x7F) << shift;
+ shift += 7;
+ if (shift > 35) throw new IOException("VarInt too big");
+ } while ((b & 0x80) != 0);
+ return result;
+ }
+}
diff --git a/src/main/java/com/osiris/autoplug/client/minecraft/server/ServersDat.java b/src/main/java/com/osiris/autoplug/client/minecraft/server/ServersDat.java
new file mode 100644
index 00000000..89729464
--- /dev/null
+++ b/src/main/java/com/osiris/autoplug/client/minecraft/server/ServersDat.java
@@ -0,0 +1,212 @@
+/*
+ * Copyright (c) 2024 Osiris-Team.
+ * All rights reserved.
+ *
+ * This software is copyrighted work, licensed under the terms
+ * of the MIT-License. Consult the "LICENSE" file for details.
+ * Implements GitHub issue #296 – Minecraft Client Launcher.
+ */
+
+package com.osiris.autoplug.client.minecraft.server;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.io.*;
+import java.nio.file.*;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Reads and writes the Minecraft {@code servers.dat} file in its native NBT format.
+ *
+ * The format is a compressed (gzip) or uncompressed NBT TAG_Compound containing
+ * a TAG_List named "servers" where each entry is a TAG_Compound with at least
+ * "name" and "ip" TAG_String fields.
+ *
+ * This implementation handles the file directly using Java's {@link DataInputStream}/
+ * {@link DataOutputStream} to stay dependency-free (no third-party NBT library).
+ * Unknown tags are read verbatim and preserved on write.
+ */
+public class ServersDat {
+
+ // NBT type constants
+ private static final byte TAG_END = 0;
+ private static final byte TAG_BYTE = 1;
+ private static final byte TAG_SHORT = 2;
+ private static final byte TAG_INT = 3;
+ private static final byte TAG_LONG = 4;
+ private static final byte TAG_FLOAT = 5;
+ private static final byte TAG_DOUBLE = 6;
+ private static final byte TAG_BYTE_ARRAY= 7;
+ private static final byte TAG_STRING = 8;
+ private static final byte TAG_LIST = 9;
+ private static final byte TAG_COMPOUND = 10;
+ private static final byte TAG_INT_ARRAY = 11;
+ private static final byte TAG_LONG_ARRAY= 12;
+
+ // ── Public API ──────────────────────────────────────────────────────────
+
+ /**
+ * Reads {@code servers.dat} and returns a mutable list of server entries.
+ * The file may be gzip-compressed or plain NBT.
+ */
+ @NotNull
+ public static List read(@NotNull File serversDat) throws IOException {
+ List servers = new ArrayList<>();
+ if (!serversDat.exists()) return servers;
+
+ InputStream raw = new BufferedInputStream(new FileInputStream(serversDat));
+ // Detect gzip magic bytes
+ raw.mark(2);
+ boolean isGzip = (raw.read() == 0x1F && raw.read() == 0x8B);
+ raw.reset();
+ DataInputStream din = new DataInputStream(
+ isGzip ? new java.util.zip.GZIPInputStream(raw) : raw);
+
+ // Root TAG_Compound
+ byte rootType = din.readByte();
+ if (rootType != TAG_COMPOUND)
+ throw new IOException("servers.dat: expected root TAG_Compound, got " + rootType);
+ skipString(din); // root name (usually empty)
+ readCompoundInto(din, servers);
+ din.close();
+ return servers;
+ }
+
+ /**
+ * Writes the list of server entries back to {@code servers.dat}.
+ * The file is written uncompressed (vanilla reads both).
+ * A temp-then-rename strategy ensures the file is never left half-written.
+ */
+ public static void write(@NotNull File serversDat, @NotNull List servers)
+ throws IOException {
+ serversDat.getParentFile().mkdirs();
+ File tmp = new File(serversDat.getAbsolutePath() + ".tmp");
+ try (DataOutputStream dout = new DataOutputStream(
+ new BufferedOutputStream(new FileOutputStream(tmp)))) {
+ // Root TAG_Compound
+ dout.writeByte(TAG_COMPOUND);
+ writeString(dout, ""); // root name
+
+ // TAG_List "servers"
+ dout.writeByte(TAG_LIST);
+ writeString(dout, "servers");
+ dout.writeByte(TAG_COMPOUND); // list element type
+ dout.writeInt(servers.size());
+
+ for (ServerEntry s : servers) {
+ writeString(dout, s.name, "name");
+ writeString(dout, s.ip, "ip");
+ if (s.icon != null) writeString(dout, s.icon, "icon");
+ dout.writeByte(TAG_BYTE);
+ writeString(dout, "acceptTextures");
+ dout.writeByte(s.acceptTextures);
+ // extra unknown tags
+ for (java.util.Map.Entry e : s.extraTags.entrySet())
+ writeString(dout, e.getValue(), e.getKey());
+ dout.writeByte(TAG_END); // end of compound
+ }
+
+ dout.writeByte(TAG_END); // end root compound
+ }
+ Files.move(tmp.toPath(), serversDat.toPath(), StandardCopyOption.REPLACE_EXISTING);
+ }
+
+ // ── NBT parsing helpers ──────────────────────────────────────────────────
+
+ private static void readCompoundInto(DataInputStream din, List servers)
+ throws IOException {
+ // Read the "servers" TAG_List inside the root compound
+ byte tagType;
+ while ((tagType = din.readByte()) != TAG_END) {
+ String tagName = readString(din);
+ if (tagType == TAG_LIST && "servers".equals(tagName)) {
+ byte elemType = din.readByte();
+ int count = din.readInt();
+ for (int i = 0; i < count; i++) {
+ if (elemType == TAG_COMPOUND) servers.add(readServerEntry(din));
+ else skipTag(din, elemType);
+ }
+ } else {
+ skipTag(din, tagType);
+ }
+ }
+ }
+
+ private static ServerEntry readServerEntry(DataInputStream din) throws IOException {
+ String name = "", ip = "", icon = null;
+ byte acceptTextures = 0;
+ java.util.Map extra = new java.util.LinkedHashMap<>();
+
+ byte tagType;
+ while ((tagType = din.readByte()) != TAG_END) {
+ String key = readString(din);
+ if (tagType == TAG_STRING) {
+ String val = readString(din);
+ switch (key) {
+ case "name": name = val; break;
+ case "ip": ip = val; break;
+ case "icon": icon = val; break;
+ default: extra.put(key, val); break;
+ }
+ } else if (tagType == TAG_BYTE && "acceptTextures".equals(key)) {
+ acceptTextures = din.readByte();
+ } else {
+ skipTag(din, tagType);
+ }
+ }
+ ServerEntry e = new ServerEntry(name, ip);
+ e.icon = icon;
+ e.acceptTextures = acceptTextures;
+ e.extraTags.putAll(extra);
+ return e;
+ }
+
+ // ── Skip helpers ─────────────────────────────────────────────────────────
+
+ private static void skipTag(DataInputStream din, byte type) throws IOException {
+ switch (type) {
+ case TAG_BYTE: din.readByte(); break;
+ case TAG_SHORT: din.readShort(); break;
+ case TAG_INT: din.readInt(); break;
+ case TAG_LONG: din.readLong(); break;
+ case TAG_FLOAT: din.readFloat(); break;
+ case TAG_DOUBLE: din.readDouble(); break;
+ case TAG_BYTE_ARRAY: din.skipBytes(din.readInt()); break;
+ case TAG_STRING: readString(din); break;
+ case TAG_LIST: {
+ byte elem = din.readByte(); int cnt = din.readInt();
+ for (int i = 0; i < cnt; i++) skipTag(din, elem);
+ break;
+ }
+ case TAG_COMPOUND: while (din.readByte() != TAG_END) { readString(din); } break;
+ case TAG_INT_ARRAY: din.skipBytes(din.readInt() * 4); break;
+ case TAG_LONG_ARRAY: din.skipBytes(din.readInt() * 8); break;
+ }
+ }
+
+ // ── Read/write primitives ────────────────────────────────────────────────
+
+ private static String readString(DataInputStream din) throws IOException {
+ int len = din.readUnsignedShort();
+ byte[] buf = new byte[len];
+ din.readFully(buf);
+ return new String(buf, java.nio.charset.StandardCharsets.UTF_8);
+ }
+
+ private static void skipString(DataInputStream din) throws IOException { readString(din); }
+
+ private static void writeString(DataOutputStream dout, String s) throws IOException {
+ byte[] buf = s.getBytes(java.nio.charset.StandardCharsets.UTF_8);
+ dout.writeShort(buf.length);
+ dout.write(buf);
+ }
+
+ /** Writes a TAG_String tag with given name and value. */
+ private static void writeString(DataOutputStream dout, String value, String tagName)
+ throws IOException {
+ dout.writeByte(TAG_STRING);
+ writeString(dout, tagName);
+ writeString(dout, value);
+ }
+}
diff --git a/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/InstalledModLoader.java b/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/InstalledModLoader.java
index 8f02dcfb..6ca653ea 100644
--- a/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/InstalledModLoader.java
+++ b/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/InstalledModLoader.java
@@ -33,4 +33,10 @@ public InstalledModLoader() {
isForge = true;
}
}
+
+ public InstalledModLoader(boolean isFabric, boolean isQuilt) {
+ this.isFabric = isFabric;
+ this.isQuilt = isQuilt;
+ this.isForge = !isFabric && !isQuilt;
+ }
}
diff --git a/src/main/java/com/osiris/autoplug/client/ui/ClientPanel.java b/src/main/java/com/osiris/autoplug/client/ui/ClientPanel.java
index 4c14b8ef..331f765e 100644
--- a/src/main/java/com/osiris/autoplug/client/ui/ClientPanel.java
+++ b/src/main/java/com/osiris/autoplug/client/ui/ClientPanel.java
@@ -33,14 +33,11 @@ public ClientPanel(Container parent) throws Exception {
tabs.setTabLayoutPolicy(JTabbedPane.SCROLL_TAB_LAYOUT);
if (GD.TARGET == Target.MINECRAFT_CLIENT || GD.TARGET == Target.MINECRAFT_SERVER) {
- SettingsPanel servers = new SettingsPanel(tabs);
- tabs.addTab("Servers", servers); // TODO list of favorite servers with play/join button which auto-selects
- // (TODO) and launches the correct minecraft client version
+ MinecraftServersPanel servers = new MinecraftServersPanel(tabs);
+ tabs.addTab("Servers", servers);
- SettingsPanel profiles = new SettingsPanel(tabs);
- tabs.addTab("Profiles", profiles); // TODO game profile containing settings and mods
- // (TODO) is not tied to a minecraft version, meaning internally
- // (TODO) mods/settings will be down/upgraded to match the launched version
+ MinecraftProfilesPanel profiles = new MinecraftProfilesPanel(tabs);
+ tabs.addTab("Profiles", profiles);
MinecraftModsPanel minecraftMods = new MinecraftModsPanel(tabs);
tabs.addTab("Mods", minecraftMods);
diff --git a/src/main/java/com/osiris/autoplug/client/ui/MinecraftProfilesPanel.java b/src/main/java/com/osiris/autoplug/client/ui/MinecraftProfilesPanel.java
new file mode 100644
index 00000000..00e524ba
--- /dev/null
+++ b/src/main/java/com/osiris/autoplug/client/ui/MinecraftProfilesPanel.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright (c) 2024 Osiris-Team.
+ * All rights reserved.
+ *
+ * This software is copyrighted work, licensed under the terms
+ * of the MIT-License. Consult the "LICENSE" file for details.
+ * Implements GitHub issue #296.
+ */
+
+package com.osiris.autoplug.client.ui;
+
+import com.osiris.autoplug.client.minecraft.launcher.MinecraftLauncher;
+import com.osiris.autoplug.client.minecraft.profile.LaunchProfile;
+import com.osiris.autoplug.client.minecraft.profile.ProfileManager;
+import com.osiris.autoplug.client.ui.utils.MyMouseListener;
+import com.osiris.betterlayout.BLayout;
+import com.osiris.jlib.logger.AL;
+
+import javax.swing.*;
+import javax.swing.table.DefaultTableModel;
+import java.awt.*;
+import java.util.List;
+
+public class MinecraftProfilesPanel extends BLayout {
+ public JButton btnRefresh = new JButton("Refresh");
+ public JButton btnCreate = new JButton("Create Profile");
+ public JButton btnLaunch = new JButton("Launch Profile");
+ public JButton btnDelete = new JButton("Delete");
+ public JTable table = new JTable();
+
+ private List profileList;
+
+ public MinecraftProfilesPanel(Container parent) throws Exception {
+ super(parent);
+
+ JPanel btnPanel = new JPanel(new FlowLayout(FlowLayout.LEFT));
+ btnPanel.add(btnRefresh);
+ btnPanel.add(btnCreate);
+ btnPanel.add(btnLaunch);
+ btnPanel.add(btnDelete);
+ this.addV(btnPanel);
+
+ JScrollPane scrollPane = new JScrollPane(table);
+ this.addV(scrollPane);
+
+ updateData();
+
+ btnRefresh.addMouseListener(new MyMouseListener().onClick(e -> updateData()));
+
+ btnCreate.addMouseListener(new MyMouseListener().onClick(e -> {
+ String name = JOptionPane.showInputDialog(this, "Profile Name:", "Create Profile", JOptionPane.PLAIN_MESSAGE);
+ if (name == null || name.trim().isEmpty()) return;
+ String ver = JOptionPane.showInputDialog(this, "Minecraft Version (e.g. 1.21.1):", "1.21.1");
+ if (ver == null || ver.trim().isEmpty()) ver = "1.21.1";
+ try {
+ ProfileManager.create(name.trim(), ver.trim());
+ updateData();
+ } catch (Exception ex) {
+ AL.warn(ex);
+ JOptionPane.showMessageDialog(this, "Failed to create profile: " + ex.getMessage(), "Error", JOptionPane.ERROR_MESSAGE);
+ }
+ }));
+
+ btnLaunch.addMouseListener(new MyMouseListener().onClick(e -> {
+ int row = table.getSelectedRow();
+ if (row < 0 || profileList == null || row >= profileList.size()) {
+ JOptionPane.showMessageDialog(this, "Please select a profile to launch.", "Info", JOptionPane.INFORMATION_MESSAGE);
+ return;
+ }
+ LaunchProfile profile = profileList.get(row);
+ String player = JOptionPane.showInputDialog(this, "Enter Player Name:", "Player");
+ if (player == null || player.trim().isEmpty()) player = "Player";
+ final String finalPlayer = player.trim();
+ new Thread(() -> {
+ try {
+ AL.info("Launching profile " + profile.name + " as " + finalPlayer + "...");
+ Process proc = MinecraftLauncher.launch(profile, finalPlayer, null, null,
+ (done, total) -> AL.info("[Download] " + done + "/" + total));
+ proc.waitFor();
+ } catch (Exception ex) {
+ AL.warn(ex);
+ SwingUtilities.invokeLater(() ->
+ JOptionPane.showMessageDialog(this, "Launch failed: " + ex.getMessage(), "Error", JOptionPane.ERROR_MESSAGE));
+ }
+ }, "launch-" + profile.name).start();
+ }));
+
+ btnDelete.addMouseListener(new MyMouseListener().onClick(e -> {
+ int row = table.getSelectedRow();
+ if (row < 0 || profileList == null || row >= profileList.size()) return;
+ LaunchProfile profile = profileList.get(row);
+ int confirm = JOptionPane.showConfirmDialog(this, "Delete profile '" + profile.name + "'?", "Confirm", JOptionPane.YES_NO_OPTION);
+ if (confirm == JOptionPane.YES_OPTION) {
+ ProfileManager.delete(profile.name);
+ updateData();
+ }
+ }));
+ }
+
+ public void updateData() {
+ try {
+ profileList = ProfileManager.loadAll();
+ String[] columnNames = {"Name", "MC Version", "Loader", "Game Dir", "RAM (MB)", "Server"};
+ Object[][] data = new Object[profileList.size()][6];
+ for (int i = 0; i < profileList.size(); i++) {
+ LaunchProfile p = profileList.get(i);
+ data[i][0] = p.name;
+ data[i][1] = p.minecraftVersion;
+ data[i][2] = p.loaderType;
+ data[i][3] = p.gameDir != null ? p.gameDir.getAbsolutePath() : "Default";
+ data[i][4] = p.maxMemoryMb > 0 ? p.maxMemoryMb : "Default (2048)";
+ data[i][5] = p.serverAddress != null ? p.serverAddress : "-";
+ }
+ table.setModel(new DefaultTableModel(data, columnNames));
+ } catch (Exception e) {
+ AL.warn("Failed to load profiles", e);
+ }
+ }
+}
diff --git a/src/main/java/com/osiris/autoplug/client/ui/MinecraftServersPanel.java b/src/main/java/com/osiris/autoplug/client/ui/MinecraftServersPanel.java
new file mode 100644
index 00000000..d44fe5ec
--- /dev/null
+++ b/src/main/java/com/osiris/autoplug/client/ui/MinecraftServersPanel.java
@@ -0,0 +1,137 @@
+/*
+ * Copyright (c) 2024 Osiris-Team.
+ * All rights reserved.
+ *
+ * This software is copyrighted work, licensed under the terms
+ * of the MIT-License. Consult the "LICENSE" file for details.
+ * Implements GitHub issue #296.
+ */
+
+package com.osiris.autoplug.client.ui;
+
+import com.osiris.autoplug.client.minecraft.server.ServerBrowser;
+import com.osiris.autoplug.client.minecraft.server.ServerEntry;
+import com.osiris.autoplug.client.ui.utils.MyMouseListener;
+import com.osiris.autoplug.client.utils.GD;
+import com.osiris.betterlayout.BLayout;
+import com.osiris.jlib.logger.AL;
+
+import javax.swing.*;
+import javax.swing.table.DefaultTableModel;
+import java.awt.*;
+import java.io.File;
+import java.util.List;
+
+public class MinecraftServersPanel extends BLayout {
+ public JButton btnRefresh = new JButton("Ping / Refresh");
+ public JButton btnAdd = new JButton("Add Server");
+ public JButton btnJoin = new JButton("Join Selected");
+ public JButton btnDelete = new JButton("Delete");
+ public JTable table = new JTable();
+
+ private final ServerBrowser browser;
+ private List serverList;
+
+ public MinecraftServersPanel(Container parent) throws Exception {
+ super(parent);
+ File serversDat = new File(GD.WORKING_DIR, "autoplug/profiles/default/game/servers.dat");
+ this.browser = new ServerBrowser(serversDat);
+
+ JPanel btnPanel = new JPanel(new FlowLayout(FlowLayout.LEFT));
+ btnPanel.add(btnRefresh);
+ btnPanel.add(btnAdd);
+ btnPanel.add(btnJoin);
+ btnPanel.add(btnDelete);
+ this.addV(btnPanel);
+
+ JScrollPane scrollPane = new JScrollPane(table);
+ this.addV(scrollPane);
+
+ updateData();
+
+ btnRefresh.addMouseListener(new MyMouseListener().onClick(e -> {
+ new Thread(() -> {
+ try {
+ browser.pingAllAsync().thenAccept(res -> SwingUtilities.invokeLater(this::populateTable));
+ } catch (Exception ex) {
+ AL.warn(ex);
+ }
+ }).start();
+ }));
+
+ btnAdd.addMouseListener(new MyMouseListener().onClick(e -> {
+ String name = JOptionPane.showInputDialog(this, "Enter Server Name:", "Add Server", JOptionPane.PLAIN_MESSAGE);
+ if (name == null || name.trim().isEmpty()) return;
+ String ip = JOptionPane.showInputDialog(this, "Enter Server Address (host:port):", "Add Server", JOptionPane.PLAIN_MESSAGE);
+ if (ip == null || ip.trim().isEmpty()) return;
+ try {
+ browser.addServer(new ServerEntry(name.trim(), ip.trim()));
+ updateData();
+ } catch (Exception ex) {
+ AL.warn(ex);
+ JOptionPane.showMessageDialog(this, "Failed to add server: " + ex.getMessage(), "Error", JOptionPane.ERROR_MESSAGE);
+ }
+ }));
+
+ btnJoin.addMouseListener(new MyMouseListener().onClick(e -> {
+ int row = table.getSelectedRow();
+ if (row < 0 || serverList == null || row >= serverList.size()) {
+ JOptionPane.showMessageDialog(this, "Please select a server to join.", "Info", JOptionPane.INFORMATION_MESSAGE);
+ return;
+ }
+ ServerEntry entry = serverList.get(row);
+ String player = JOptionPane.showInputDialog(this, "Enter Player Name:", "Player");
+ if (player == null || player.trim().isEmpty()) player = "Player";
+ final String finalPlayer = player.trim();
+ new Thread(() -> {
+ try {
+ AL.info("Joining " + entry.ip + " as " + finalPlayer + "...");
+ Process proc = browser.join(entry, finalPlayer, null, null);
+ proc.waitFor();
+ } catch (Exception ex) {
+ AL.warn(ex);
+ SwingUtilities.invokeLater(() ->
+ JOptionPane.showMessageDialog(this, "Failed to join: " + ex.getMessage(), "Error", JOptionPane.ERROR_MESSAGE));
+ }
+ }, "join-" + entry.ip).start();
+ }));
+
+ btnDelete.addMouseListener(new MyMouseListener().onClick(e -> {
+ int row = table.getSelectedRow();
+ if (row < 0 || serverList == null || row >= serverList.size()) return;
+ ServerEntry entry = serverList.get(row);
+ try {
+ browser.removeServer(entry.ip);
+ updateData();
+ } catch (Exception ex) {
+ AL.warn(ex);
+ }
+ }));
+ }
+
+ public void updateData() {
+ try {
+ serverList = browser.readServers();
+ populateTable();
+ } catch (Exception e) {
+ AL.warn("Failed to load servers.dat", e);
+ }
+ }
+
+ private void populateTable() {
+ String[] columnNames = {"Name", "IP", "Version", "Players", "Latency", "MOTD"};
+ Object[][] data = new Object[serverList != null ? serverList.size() : 0][6];
+ if (serverList != null) {
+ for (int i = 0; i < serverList.size(); i++) {
+ ServerEntry s = serverList.get(i);
+ data[i][0] = s.name;
+ data[i][1] = s.ip;
+ data[i][2] = s.detectedVersion != null ? s.detectedVersion : "-";
+ data[i][3] = s.onlinePlayers >= 0 ? s.onlinePlayers + "/" + s.maxPlayers : "-";
+ data[i][4] = s.latencyMs >= 0 ? s.latencyMs + "ms" : "-";
+ data[i][5] = s.motd != null ? s.motd : "-";
+ }
+ }
+ table.setModel(new DefaultTableModel(data, columnNames));
+ }
+}
diff --git a/src/test/java/com/osiris/autoplug/client/minecraft/LaunchArgBuilderTest.java b/src/test/java/com/osiris/autoplug/client/minecraft/LaunchArgBuilderTest.java
new file mode 100644
index 00000000..f5a7c4b7
--- /dev/null
+++ b/src/test/java/com/osiris/autoplug/client/minecraft/LaunchArgBuilderTest.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2024 Osiris-Team.
+ * All rights reserved.
+ *
+ * This software is copyrighted work, licensed under the terms
+ * of the MIT-License. Consult the "LICENSE" file for details.
+ * Implements GitHub issue #296 tests.
+ */
+
+package com.osiris.autoplug.client.minecraft;
+
+import com.osiris.autoplug.client.minecraft.launcher.LaunchArgBuilder;
+import com.osiris.autoplug.client.minecraft.launcher.MinecraftLauncher;
+import com.osiris.autoplug.client.minecraft.launcher.MojangManifest;
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class LaunchArgBuilderTest {
+
+ private static final String JSON = "{"
+ + "\"id\": \"1.21.1\","
+ + "\"mainClass\": \"net.minecraft.client.main.Main\","
+ + "\"assetIndex\": {\"id\": \"17\", \"url\": \"https://example.com/17.json\"},"
+ + "\"downloads\": {\"client\": {\"url\": \"https://example.com/c.jar\", \"sha1\": \"00\", \"size\": 10}},"
+ + "\"arguments\": {"
+ + " \"jvm\": ["
+ + " \"-cp\", \"${classpath}\""
+ + " ],"
+ + " \"game\": ["
+ + " \"--username\", \"${auth_player_name}\","
+ + " \"--version\", \"${version_name}\","
+ + " \"--gameDir\", \"${game_directory}\","
+ + " \"--assetsDir\", \"${assets_root}\","
+ + " \"--assetIndex\", \"${assets_index_name}\","
+ + " \"--uuid\", \"${auth_uuid}\","
+ + " \"--accessToken\", \"${auth_access_token}\""
+ + " ]"
+ + "}"
+ + "}";
+
+ @Test
+ public void testBuildOfflineArguments() {
+ MojangManifest.VersionDetail detail = MojangManifest.parseVersionJson(JSON);
+ File nativesDir = new File("/tmp/mc/natives");
+ File gameDir = new File("/tmp/mc/game");
+ File assetsDir = new File("/tmp/mc/assets");
+ List cp = List.of(new File("/tmp/mc/lib.jar"), new File("/tmp/mc/client.jar"));
+
+ String playerName = "Steve";
+ String uuid = MinecraftLauncher.offlineUUID(playerName);
+ assertNotNull(uuid);
+
+ LaunchArgBuilder builder = new LaunchArgBuilder(
+ detail, nativesDir, cp, gameDir, assetsDir,
+ playerName, uuid, "null", "release",
+ List.of("-Dextra=true"), 3072);
+
+ List args = builder.build();
+ assertNotNull(args);
+
+ // Memory
+ assertTrue(args.contains("-Xmx3072m"));
+ assertTrue(args.contains("-Xms256m"));
+
+ // Natives
+ assertTrue(args.contains("-Djava.library.path=" + nativesDir.getAbsolutePath()));
+
+ // Extra JVM args
+ assertTrue(args.contains("-Dextra=true"));
+
+ // Main class
+ assertTrue(args.contains("net.minecraft.client.main.Main"));
+
+ // Game args
+ int userIdx = args.indexOf("--username");
+ assertTrue(userIdx >= 0 && userIdx + 1 < args.size());
+ assertEquals("Steve", args.get(userIdx + 1));
+
+ int uuidIdx = args.indexOf("--uuid");
+ assertTrue(uuidIdx >= 0 && uuidIdx + 1 < args.size());
+ assertEquals(uuid, args.get(uuidIdx + 1));
+
+ int tokenIdx = args.indexOf("--accessToken");
+ assertTrue(tokenIdx >= 0 && tokenIdx + 1 < args.size());
+ assertEquals("null", args.get(tokenIdx + 1));
+ }
+}
diff --git a/src/test/java/com/osiris/autoplug/client/minecraft/ModVersionAdapterTest.java b/src/test/java/com/osiris/autoplug/client/minecraft/ModVersionAdapterTest.java
new file mode 100644
index 00000000..3c2e14b4
--- /dev/null
+++ b/src/test/java/com/osiris/autoplug/client/minecraft/ModVersionAdapterTest.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright (c) 2024 Osiris-Team.
+ * All rights reserved.
+ *
+ * This software is copyrighted work, licensed under the terms
+ * of the MIT-License. Consult the "LICENSE" file for details.
+ * Implements GitHub issue #296 tests.
+ */
+
+package com.osiris.autoplug.client.minecraft;
+
+import com.osiris.autoplug.client.minecraft.profile.ModVersionAdapter;
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.nio.file.Files;
+import java.util.List;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class ModVersionAdapterTest {
+
+ @Test
+ public void testModDisabledRenamingWhenNotFound() throws Exception {
+ File tempModsDir = Files.createTempDirectory("mods-test-").toFile();
+ tempModsDir.deleteOnExit();
+
+ // Create a dummy fake mod jar that has no release on Modrinth
+ File fakeMod = new File(tempModsDir, "nonexistent-test-mod-xyz123.jar");
+ try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(fakeMod))) {
+ zos.putNextEntry(new ZipEntry("fabric.mod.json"));
+ String json = "{\"id\": \"nonexistent-test-mod-xyz123\", \"version\": \"1.0.0\"}";
+ zos.write(json.getBytes());
+ zos.closeEntry();
+ }
+ assertTrue(fakeMod.exists());
+
+ // Adapt for a version
+ List results = ModVersionAdapter.adapt(tempModsDir, "1.21.1", "fabric");
+ assertNotNull(results);
+ assertEquals(1, results.size());
+
+ // File should be renamed to .jar.disabled (never crash)
+ File disabledFile = new File(tempModsDir, "nonexistent-test-mod-xyz123.jar.disabled");
+ assertTrue(disabledFile.exists() || results.get(0).contains("[DISABLED]") || results.get(0).contains("[SKIP]"));
+ }
+}
diff --git a/src/test/java/com/osiris/autoplug/client/minecraft/MojangManifestTest.java b/src/test/java/com/osiris/autoplug/client/minecraft/MojangManifestTest.java
new file mode 100644
index 00000000..113f5a75
--- /dev/null
+++ b/src/test/java/com/osiris/autoplug/client/minecraft/MojangManifestTest.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) 2024 Osiris-Team.
+ * All rights reserved.
+ *
+ * This software is copyrighted work, licensed under the terms
+ * of the MIT-License. Consult the "LICENSE" file for details.
+ * Implements GitHub issue #296 tests.
+ */
+
+package com.osiris.autoplug.client.minecraft;
+
+import com.osiris.autoplug.client.minecraft.launcher.MojangManifest;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class MojangManifestTest {
+
+ private static final String SAMPLE_VERSION_JSON = "{"
+ + "\"id\": \"1.21.1\","
+ + "\"mainClass\": \"net.minecraft.client.main.Main\","
+ + "\"javaVersion\": {\"majorVersion\": 21},"
+ + "\"assetIndex\": {\"id\": \"17\", \"url\": \"https://example.com/assets.json\", \"sha1\": \"abc123\"},"
+ + "\"downloads\": {\"client\": {\"url\": \"https://example.com/client.jar\", \"sha1\": \"def456\", \"size\": 12345678}},"
+ + "\"arguments\": {"
+ + " \"jvm\": ["
+ + " \"-Djava.library.path=${natives_directory}\","
+ + " {\"rules\": [{\"action\": \"allow\", \"os\": {\"name\": \"osx\"}}], \"value\": \"-XstartOnFirstThread\"}"
+ + " ],"
+ + " \"game\": ["
+ + " \"--username\", \"${auth_player_name}\","
+ + " \"--version\", \"${version_name}\""
+ + " ]"
+ + "},"
+ + "\"libraries\": ["
+ + " {\"name\": \"com.mojang:authlib:6.0.55\", \"downloads\": {\"artifact\": {\"url\": \"https://example.com/authlib.jar\", \"sha1\": \"111\", \"size\": 1000}}}"
+ + "]"
+ + "}";
+
+ @Test
+ public void testParseVersionJson() {
+ MojangManifest.VersionDetail detail = MojangManifest.parseVersionJson(SAMPLE_VERSION_JSON);
+ assertNotNull(detail);
+ assertEquals("1.21.1", detail.id);
+ assertEquals("net.minecraft.client.main.Main", detail.mainClass);
+ assertEquals(21, detail.javaVersion);
+ assertEquals("17", detail.assetIndex);
+ assertEquals("https://example.com/client.jar", detail.clientJarUrl);
+ assertEquals("def456", detail.clientJarSha1);
+ assertEquals(12345678, detail.clientJarSize);
+
+ // Check libraries
+ assertEquals(1, detail.libraries.size());
+ MojangManifest.Library lib = detail.libraries.get(0);
+ assertEquals("com.mojang:authlib:6.0.55", lib.name);
+ assertEquals("com/mojang/authlib/6.0.55/authlib-6.0.55.jar", lib.toRelativePath());
+ assertEquals("https://example.com/authlib.jar", lib.url);
+
+ // Check arguments
+ assertEquals(2, detail.jvmArguments.size());
+ assertEquals(4, detail.gameArguments.size());
+ }
+
+ @Test
+ public void testArgumentRules() {
+ MojangManifest.VersionDetail detail = MojangManifest.parseVersionJson(SAMPLE_VERSION_JSON);
+ MojangManifest.Argument osxArg = detail.jvmArguments.get(1);
+ assertEquals("-XstartOnFirstThread", osxArg.value);
+ boolean isOsx = System.getProperty("os.name", "").toLowerCase().contains("mac");
+ assertEquals(isOsx, osxArg.appliesOnCurrentOs());
+ }
+}
diff --git a/src/test/java/com/osiris/autoplug/client/minecraft/ProfileTest.java b/src/test/java/com/osiris/autoplug/client/minecraft/ProfileTest.java
new file mode 100644
index 00000000..ce398922
--- /dev/null
+++ b/src/test/java/com/osiris/autoplug/client/minecraft/ProfileTest.java
@@ -0,0 +1,132 @@
+/*
+ * Copyright (c) 2024 Osiris-Team.
+ * All rights reserved.
+ *
+ * This software is copyrighted work, licensed under the terms
+ * of the MIT-License. Consult the "LICENSE" file for details.
+ * Implements GitHub issue #296 tests.
+ */
+
+package com.osiris.autoplug.client.minecraft;
+
+import com.osiris.autoplug.client.minecraft.profile.LaunchProfile;
+import com.osiris.autoplug.client.minecraft.profile.ProfileManager;
+import com.osiris.autoplug.client.utils.GD;
+import org.junit.jupiter.api.*;
+
+import java.io.File;
+import java.nio.file.Files;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+public class ProfileTest {
+
+ private static File testWorkingDir;
+
+ @BeforeAll
+ public static void setUp() throws Exception {
+ testWorkingDir = Files.createTempDirectory("ap-profile-test-").toFile();
+ GD.WORKING_DIR = testWorkingDir;
+ File logFile = new File(testWorkingDir, "logs/test.log");
+ logFile.getParentFile().mkdirs();
+ try {
+ new com.osiris.jlib.logger.AL().start("AL", true, logFile, false, false);
+ } catch (Exception ignored) {}
+ }
+
+ @AfterAll
+ public static void tearDown() {
+ if (testWorkingDir != null && testWorkingDir.exists()) {
+ deleteDir(testWorkingDir);
+ }
+ }
+
+ private static void deleteDir(File dir) {
+ File[] files = dir.listFiles();
+ if (files != null) {
+ for (File f : files) {
+ if (f.isDirectory()) deleteDir(f);
+ else f.delete();
+ }
+ }
+ dir.delete();
+ }
+
+ @Test
+ @Order(1)
+ public void testCreateAndLoadProfile() throws Exception {
+ String name = "survival-test";
+ LaunchProfile p = ProfileManager.create(name, "1.21.1");
+ assertNotNull(p);
+ assertEquals(name, p.name);
+ assertEquals("1.21.1", p.minecraftVersion);
+ assertEquals("vanilla", p.loaderType);
+
+ // Load back
+ LaunchProfile loaded = ProfileManager.load(name);
+ assertNotNull(loaded);
+ assertEquals(name, loaded.name);
+ assertEquals("1.21.1", loaded.minecraftVersion);
+ assertEquals("vanilla", loaded.loaderType);
+ }
+
+ @Test
+ @Order(2)
+ public void testProfileSaveModifications() throws Exception {
+ String name = "modded-test";
+ LaunchProfile p = ProfileManager.create(name, "1.20.4");
+ p.loaderType = "fabric";
+ p.maxMemoryMb = 4096;
+ p.jvmArgs.add("-XX:+UseG1GC");
+ p.serverAddress = "mc.hypixel.net";
+ ProfileManager.save(p);
+
+ LaunchProfile loaded = ProfileManager.load(name);
+ assertNotNull(loaded);
+ assertEquals("fabric", loaded.loaderType);
+ assertEquals(4096, loaded.maxMemoryMb);
+ assertTrue(loaded.jvmArgs.contains("-XX:+UseG1GC"));
+ assertEquals("mc.hypixel.net", loaded.serverAddress);
+ }
+
+ @Test
+ @Order(3)
+ public void testProfileIsolation() {
+ LaunchProfile p1 = new LaunchProfile("p1", "1.20.1");
+ LaunchProfile p2 = new LaunchProfile("p2", "1.21.1");
+
+ File p1Dir = p1.profileDir(testWorkingDir);
+ File p2Dir = p2.profileDir(testWorkingDir);
+ assertNotEquals(p1Dir.getAbsolutePath(), p2Dir.getAbsolutePath());
+
+ File p1Mods = p1.modsDir(testWorkingDir);
+ File p2Mods = p2.modsDir(testWorkingDir);
+ assertNotEquals(p1Mods.getAbsolutePath(), p2Mods.getAbsolutePath());
+ }
+
+ @Test
+ @Order(4)
+ public void testListProfiles() {
+ List names = ProfileManager.listNames();
+ assertTrue(names.contains("survival-test"));
+ assertTrue(names.contains("modded-test"));
+ }
+
+ @Test
+ @Order(5)
+ public void testDeleteProfile() {
+ assertTrue(ProfileManager.delete("survival-test"));
+ assertNull(tryLoad("survival-test"));
+ assertFalse(ProfileManager.listNames().contains("survival-test"));
+ }
+
+ private LaunchProfile tryLoad(String name) {
+ try {
+ return ProfileManager.load(name);
+ } catch (Exception e) {
+ return null;
+ }
+ }
+}
diff --git a/src/test/java/com/osiris/autoplug/client/minecraft/ServersDatTest.java b/src/test/java/com/osiris/autoplug/client/minecraft/ServersDatTest.java
new file mode 100644
index 00000000..b3cce09c
--- /dev/null
+++ b/src/test/java/com/osiris/autoplug/client/minecraft/ServersDatTest.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright (c) 2024 Osiris-Team.
+ * All rights reserved.
+ *
+ * This software is copyrighted work, licensed under the terms
+ * of the MIT-License. Consult the "LICENSE" file for details.
+ * Implements GitHub issue #296 tests.
+ */
+
+package com.osiris.autoplug.client.minecraft;
+
+import com.osiris.autoplug.client.minecraft.server.ServerEntry;
+import com.osiris.autoplug.client.minecraft.server.ServersDat;
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class ServersDatTest {
+
+ @Test
+ public void testRoundTripPreservingUnknownTags() throws Exception {
+ File tempDat = Files.createTempFile("servers-", ".dat").toFile();
+ tempDat.deleteOnExit();
+
+ List original = new ArrayList<>();
+ ServerEntry s1 = new ServerEntry("Hypixel Network", "mc.hypixel.net");
+ s1.acceptTextures = 1;
+ s1.icon = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==";
+ s1.extraTags.put("customUnknownTag", "extraValue123");
+ original.add(s1);
+
+ ServerEntry s2 = new ServerEntry("Local Test Server", "127.0.0.1:25565");
+ s2.extraTags.put("anotherTag", "valueXYZ");
+ original.add(s2);
+
+ // Write
+ ServersDat.write(tempDat, original);
+ assertTrue(tempDat.exists());
+ assertTrue(tempDat.length() > 0);
+
+ // Read back
+ List readBack = ServersDat.read(tempDat);
+ assertNotNull(readBack);
+ assertEquals(2, readBack.size());
+
+ ServerEntry r1 = readBack.get(0);
+ assertEquals("Hypixel Network", r1.name);
+ assertEquals("mc.hypixel.net", r1.ip);
+ assertEquals(1, r1.acceptTextures);
+ assertEquals("data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==", r1.icon);
+ assertEquals("extraValue123", r1.extraTags.get("customUnknownTag"));
+
+ ServerEntry r2 = readBack.get(1);
+ assertEquals("Local Test Server", r2.name);
+ assertEquals("127.0.0.1:25565", r2.ip);
+ assertEquals("valueXYZ", r2.extraTags.get("anotherTag"));
+ assertEquals("127.0.0.1", r2.host());
+ assertEquals(25565, r2.port());
+ }
+
+ @Test
+ public void testEmptyServersDat() throws Exception {
+ File tempDat = Files.createTempFile("servers-empty-", ".dat").toFile();
+ tempDat.deleteOnExit();
+
+ ServersDat.write(tempDat, List.of());
+ List list = ServersDat.read(tempDat);
+ assertNotNull(list);
+ assertEquals(0, list.size());
+ }
+}