diff --git a/src/main/java/com/osiris/autoplug/client/console/Commands.java b/src/main/java/com/osiris/autoplug/client/console/Commands.java index 5e37ec10..35090e78 100644 --- a/src/main/java/com/osiris/autoplug/client/console/Commands.java +++ b/src/main/java/com/osiris/autoplug/client/console/Commands.java @@ -58,6 +58,7 @@ import com.osiris.autoplug.client.utils.tasks.UtilsTasks; import com.osiris.jlib.logger.AL; import com.osiris.autoplug.client.tasks.updater.plugins.InstalledPluginLoader; +import com.osiris.autoplug.client.minecraft.LauncherCommands; /** * Listens for input started with . @@ -98,6 +99,7 @@ public static boolean execute(@NotNull String command) { AL.info(""); AL.info(".help | Prints out this (Shortcut: .h)"); AL.info(".ping | Responds with 'Pong!' as a way to test if AutoPlug is running."); + AL.info(".mc help | Minecraft client launcher, profile and server browser commands."); AL.info(".run tasks | Runs the 'before server startup tasks' without starting the server (.rt)"); AL.info(".con info | Shows details about AutoPlugs network connections (.ci)"); AL.info(".con reload | Closes and reconnects all connections (.cr)"); @@ -325,6 +327,10 @@ public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IO SSHManager.stop(true); SSHManager.start(true); return true; + } else if (command.startsWith(".mc")) { + String sub = command.length() > 3 ? command.substring(3).trim() : ""; + String[] tokens = sub.isEmpty() ? new String[0] : sub.split("\\s+"); + return LauncherCommands.execute(tokens); } else if (command.equals(".ping")) { // This is used for the SSH test, as well as for users to test if AutoPlug is running. AL.info("Pong!"); return true; diff --git a/src/main/java/com/osiris/autoplug/client/minecraft/LauncherCommands.java b/src/main/java/com/osiris/autoplug/client/minecraft/LauncherCommands.java new file mode 100644 index 00000000..a3e7c06d --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/minecraft/LauncherCommands.java @@ -0,0 +1,193 @@ +/* + * 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; + +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.minecraft.server.ServerBrowser; +import com.osiris.autoplug.client.minecraft.server.ServerEntry; +import com.osiris.autoplug.client.utils.GD; +import com.osiris.jlib.logger.AL; +import org.jetbrains.annotations.NotNull; + +import java.io.File; +import java.util.List; + +/** + * Handles AutoPlug dot-commands for the Minecraft launcher, profiles, and server browser. + *

+ * Commands are routed here from {@code Commands.execute()} in the main console handler. + *

+ * Supported commands: + *

+ *   .mc launch  [playerName] [uuid] [token]
+ *   .mc profile create  
+ *   .mc profile list
+ *   .mc profile delete 
+ *   .mc server add  
+ *   .mc server list
+ *   .mc server remove 
+ *   .mc server ping 
+ *   .mc server join  [playerName]
+ * 
+ */ +public class LauncherCommands { + + /** + * Entry point. Returns true if the command was recognised and handled. + * + * @param args tokens after ".mc" (already trimmed and split on spaces) + */ + public static boolean execute(@NotNull String[] args) { + if (args.length == 0) { printHelp(); return true; } + switch (args[0]) { + case "launch": return doLaunch(args); + case "profile": return doProfile(args); + case "server": return doServer(args); + case "help": printHelp(); return true; + default: return false; + } + } + + // ── .mc launch ──────────────────────────────────────────────────────────── + + private static boolean doLaunch(String[] args) { + if (args.length < 2) { AL.info("Usage: .mc launch [playerName] [uuid] [token]"); return true; } + String profileName = args[1]; + String playerName = args.length > 2 ? args[2] : "Player"; + String uuid = args.length > 3 ? args[3] : null; + String token = args.length > 4 ? args[4] : null; + new Thread(() -> { + try { + LaunchProfile p = ProfileManager.load(profileName); + if (p == null) { AL.warn("Profile '" + profileName + "' not found. Use .mc profile create first."); return; } + Process proc = MinecraftLauncher.launch(p, playerName, uuid, token, + (done, total) -> AL.info("[Download] " + done + "/" + total)); + AL.info("[Launcher] Minecraft launched (PID: " + proc.pid() + "). Waiting..."); + int exit = proc.waitFor(); + AL.info("[Launcher] Minecraft exited with code " + exit); + } catch (Exception e) { + AL.warn("[Launcher] Launch failed: " + e.getMessage(), e); + } + }, "mc-launch-" + profileName).start(); + return true; + } + + // ── .mc profile ─────────────────────────────────────────────────────────── + + private static boolean doProfile(String[] args) { + if (args.length < 2) { AL.info("Usage: .mc profile [args...]"); return true; } + switch (args[1]) { + case "create": { + if (args.length < 4) { AL.info("Usage: .mc profile create "); return true; } + try { + LaunchProfile p = ProfileManager.create(args[2], args[3]); + AL.info("Created profile: " + p); + } catch (Exception e) { AL.warn("Profile create failed: " + e.getMessage(), e); } + return true; + } + case "list": { + List names = ProfileManager.listNames(); + if (names.isEmpty()) { AL.info("No profiles found. Use .mc profile create ."); return true; } + AL.info("Profiles:"); + for (String n : names) { + try { + LaunchProfile p = ProfileManager.load(n); + AL.info(" " + (p != null ? p : n)); + } catch (Exception e) { AL.info(" " + n + " (error loading)"); } + } + return true; + } + case "delete": { + if (args.length < 3) { AL.info("Usage: .mc profile delete "); return true; } + boolean ok = ProfileManager.delete(args[2]); + AL.info(ok ? "Deleted profile '" + args[2] + "'." : "Profile '" + args[2] + "' not found."); + return true; + } + default: AL.info("Unknown profile sub-command: " + args[1]); return true; + } + } + + // ── .mc server ──────────────────────────────────────────────────────────── + + private static boolean doServer(String[] args) { + if (args.length < 2) { AL.info("Usage: .mc server [args...]"); return true; } + File datFile = new File(GD.WORKING_DIR, "autoplug/profiles/default/game/servers.dat"); + ServerBrowser browser = new ServerBrowser(datFile); + switch (args[1]) { + case "add": { + if (args.length < 4) { AL.info("Usage: .mc server add "); return true; } + try { + browser.addServer(new ServerEntry(args[2], args[3])); + AL.info("Added server: " + args[2] + " (" + args[3] + ")"); + } catch (Exception e) { AL.warn("Failed to add server: " + e.getMessage(), e); } + return true; + } + case "list": { + try { + List servers = browser.readServers(); + if (servers.isEmpty()) { AL.info("No servers in servers.dat."); return true; } + AL.info("Servers:"); + for (ServerEntry s : servers) AL.info(" " + s); + } catch (Exception e) { AL.warn("Failed to read servers: " + e.getMessage(), e); } + return true; + } + case "remove": { + if (args.length < 3) { AL.info("Usage: .mc server remove "); return true; } + try { + boolean ok = browser.removeServer(args[2]); + AL.info(ok ? "Removed " + args[2] : "Server not found: " + args[2]); + } catch (Exception e) { AL.warn("Failed to remove server: " + e.getMessage(), e); } + return true; + } + case "ping": { + if (args.length < 3) { AL.info("Usage: .mc server ping "); return true; } + ServerEntry e = new ServerEntry(args[2], args[2]); + boolean ok = browser.ping(e); + if (ok) AL.info("Ping " + args[2] + ": " + e.detectedVersion + + " | " + e.onlinePlayers + "/" + e.maxPlayers + + " | " + e.latencyMs + "ms | " + e.motd); + else AL.info("Server " + args[2] + " is offline or unreachable."); + return true; + } + case "join": { + if (args.length < 3) { AL.info("Usage: .mc server join [playerName]"); return true; } + String ip = args[2]; + String player = args.length > 3 ? args[3] : "Player"; + ServerEntry e = new ServerEntry(ip, ip); + new Thread(() -> { + try { + Process proc = browser.join(e, player, null, null); + int exit = proc.waitFor(); + AL.info("[Launcher] Game exited: " + exit); + } catch (Exception ex) { + AL.warn("[Launcher] Join failed: " + ex.getMessage(), ex); + } + }, "mc-join-" + ip).start(); + return true; + } + default: AL.info("Unknown server sub-command: " + args[1]); return true; + } + } + + private static void printHelp() { + AL.info("Minecraft Launcher commands:"); + AL.info(" .mc launch [player] [uuid] [token] – Launch a profile"); + AL.info(" .mc profile create – Create a profile"); + AL.info(" .mc profile list – List profiles"); + AL.info(" .mc profile delete – Delete a profile"); + AL.info(" .mc server add – Add a server"); + AL.info(" .mc server list – List servers"); + AL.info(" .mc server remove – Remove a server"); + AL.info(" .mc server ping – Ping a server"); + AL.info(" .mc server join [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 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: + *
    + *
  1. Pings the server to detect the required Minecraft version.
  2. + *
  3. Finds an existing profile matching that version, or clones the base template.
  4. + *
  5. Runs {@link ModVersionAdapter} to upgrade/downgrade mods for that MC version.
  6. + *
  7. Launches the game process pointing at the server.
  8. + *
+ * + * @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()); + } +}