From aff88293122f5efa2c9248ada174269eb7e68ab2 Mon Sep 17 00:00:00 2001 From: elcuervo171234 <43775780+elcuervo171234@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:18:40 -0400 Subject: [PATCH 01/23] docs(minecraft): document native launcher and validation limits --- docs/NATIVE_MINECRAFT.md | 75 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 docs/NATIVE_MINECRAFT.md diff --git a/docs/NATIVE_MINECRAFT.md b/docs/NATIVE_MINECRAFT.md new file mode 100644 index 00000000..2f71118b --- /dev/null +++ b/docs/NATIVE_MINECRAFT.md @@ -0,0 +1,75 @@ +# Native Minecraft dashboard + +The dashboard implements the native launcher/profile workflow from issue #296 on master 10.2.0. It uses the existing FlatLaf Swing theme, MineStat status query and AutoPlug resource search/update providers. It does not include HMCL or third-party launcher implementations. Existing Server, console, tasks and backup controls remain available; closing the dashboard hides it rather than stopping AutoPlug. + +## First use + +Open the tray icon, then Client → Minecraft. The Servers, Profiles, Virtual worlds, and Account & settings views share one runtime and profile-use locks with console commands. Minecraft's standard `servers.dat` is imported automatically only when AutoPlug's own list is empty. Importing never rewrites the original file. Add/Edit/Remove changes only AutoPlug's saved list. A ping does not identify the mod loader: choose that explicitly for each server. + +Create client MODS profiles, server PLUGINS profiles (Paper/Spigot/Purpur/etc.) or MODS_SERVER profiles. Each has separate `mods/` or `plugins/`, configuration and game directory. Select a template in settings for automatic version migration. Cloning a template creates a normal launchable profile. Check/update shows compatible replacements, confirmed incompatibilities and unresolved provider errors separately. Errors block applying; disabled files require explicit approval. Failed downloads leave the old assets intact. Immutable cached JARs are shared where supported; mutable configurations are independent copies. Stop a profile before changing it. + +On Join, the existing ping utility supplies the version. Ambiguous/absent versions need an explicit override. An exact local version/loader match is used automatically; otherwise a template clone and migration are proposed. Assets without a compatible release are renamed `.jar.disabled` only after approval. A provider outage is an error, never proof that a mod is incompatible. Unknown provider mappings need configuration; the legacy Bukkit/Spigot latest-only search cannot prove a target version and is not installed blindly. Profile provider mappings use that profile's `config/mods.yml` or `config/plugins.yml`, not global server configuration. + +## Java, accounts and loaders + +Configure Java home directories, one per line. Each must contain `release` metadata and `bin/java` (or `java.exe`). The launcher chooses the exact Minecraft-required Java major version and compatible architecture; having only a newer Java is not enough. AutoPlug's already-installed managed JRE is also considered. A missing runtime produces a visible error rather than installing or guessing an incompatible Java. Legacy Intel-only native releases cannot run on an ARM JVM; use a compatible installation/environment instead. + +Use a legitimately registered Microsoft application client ID with the necessary Minecraft/Xbox access. No borrowed application ID or embedded secret is supplied. Device authorization shows Microsoft's verification URL and user code. Access/refresh tokens stay in memory; never paste them into settings, logs or issue comments. Sign-out/exit clears the session. Offline fixture mode is explicit and does not provide ownership or access to authenticated servers. Microsoft authentication and ownership still have to succeed for online play. + +The launcher resolves official Mojang metadata, hashed client/libraries/assets, OS rules and natives. Fabric and Quilt use their official metadata. Forge/NeoForge client installers are obtained from official repositories with their SHA1 sidecar, run with the selected Java and checked for a resulting profile; this runs vendor installer code when the user requests installation. Installing a client loader is not installing a dedicated server. Do not print `PreparedLaunch.getCommand()` because it contains authentication arguments. Cancellation and Stop release only AutoPlug-owned processes and temporary natives. + +## Virtual worlds and sharing + +1. Create a non-template server profile, then a world attached to it. +2. Configure that world's matching client profile and an official installed **executable server JAR**. Review and explicitly accept Minecraft's EULA in the world UI (or edit its own `eula.txt` manually). Creating a world leaves `eula=false`. +3. Play starts the dedicated server, waits for MineStat readiness, then connects the client. Further plays reuse saved configuration. Server mods/plugins are snapshotted per world; saves remain independent. Client exit requests graceful server stop, followed by bounded termination if necessary. Server logs are kept in that world's `server.log`. +4. Play & share explicitly binds the server to the LAN and requests a temporary UPnP mapping. Share address copies the actual reported WAN endpoint only after a mapping succeeds. The finite mapping expires; Share can renew it. Stop removes the mapping only if it still belongs to this session. + +The runtime command currently uses `java -jar nogui`: it is intended for executable Paper/Spigot/Fabric-style server JARs already installed by the user. Modern Forge/NeoForge dedicated-server distributions requiring `@unix_args.txt`/`@win_args.txt`, their installed libraries or platform scripts are not provisioned by this path; client loader support must not be described as universal server-runtime support. Configure a supported runtime and matching mods. This implementation does not download or accept a server EULA automatically. + +Ordinary Play binds to loopback. To share a world initially launched without sharing, stop it and use Play & share. UPnP is optional and may fail due to router support, upstream NAT or CGNAT; a private/invalid WAN address is not presented as public connectivity. Failure is visible and suggests manual forwarding/tunnel alternatives. A router accepting a mapping is not proof that an external friend successfully joined. No real mapping is opened by tests. + +## CLI + +Commands use the same services as the UI. Quote paths containing spaces. See `.profiles help` and `.mc help` for current syntax. Existing commands without `--profile` retain legacy behavior. + +```text +.profiles create base 1.20.4 FABRIC --template +.profiles create plugins 1.20.4 PAPER --type PLUGINS +.profiles list --type MODS +.profiles clone base survival --version 1.21 +.check mods --profile survival --version 1.21 +.update mods --profile survival --version 1.21 --confirm-disable +.mc settings set defaultTemplate base +.mc settings set javaHomes "/path/to/java-home" +.mc settings set clientId "your-registered-application-id" +.mc signin +.mc signin cancel +.mc loader survival +.mc servers add "My server" "example.invalid:25565" FABRIC +.mc servers import "/path/to/servers.dat" VANILLA +.mc servers ping +.mc servers join --clone new-client --version 1.21 --confirm-disable +.mc launch survival --server example.invalid:25565 +.mc stop survival +.mc worlds create local plugins +.mc worlds configure local --client client-1.20.4 --server-jar "/path/to/paper.jar" +.mc worlds launch local +.mc worlds stop local +``` + +The profile version in an example must be a real supported release and the matching Java/loader installed; these commands are documentation, not commands automatically executed on the user's system. The explicit update/clone command authorizes its normal replacements; `--confirm-disable` additionally approves the displayed incompatible files. To review first, use `.check`. + +## Validation and desktop review + +Build uses the existing Maven POM/JPM dependency declarations and Java 9 language target. Tests use JUnit Jupiter 5.9.2. New tests exercise real service composition using synthetic transports, process doubles and temporary directories; recorded Mojang 1.20.4 metadata is public primary data verified against its official SHA1. No Minecraft game code, real credentials or private account responses are test fixtures. + +```sh +mvn -Djava.awt.headless=true '-Dtest=**/mc/**/*Test,NativeMinecraftPanelTest,UtilsStringTest,UtilsEnvironmentTest,UtilsFileTest,UtilsMapTest' package +``` + +This runs all added native tests plus the selected existing offline utility regressions. It deliberately excludes older integration tests that contact Google/Modrinth/Bukkit/Spiget/Jenkins or install/run Steam/Minecraft servers. Compiling all production and test sources is not a claim that the full network-dependent suite passed. Actual measured counts and historical failures are recorded in the contribution's validation report. + +`NativeMinecraftPreview` in test sources opens a disposable desktop populated with clearly labeled synthetic data. It uses no real Minecraft account or downloads, and does not start a game/server or router mapping. Its data path is printed. It is for reviewing add/edit, profiles/templates and world configuration, not proof of live gameplay. After building, run its main class with the test/runtime dependency classpath and a parent temporary directory. Do not accept EULAs or enter credentials merely to review the fixture. + +Before submitting, the contributor should inspect the changes and verify the desktop behavior personally. Maintainers said they will perform final live Microsoft-authenticated game/join acceptance. Automated fixtures, desktop renders, CI success and a submitted PR are not a bounty award or a payment. From d6c4df23be10d645529d24b7fc305426b75141f1 Mon Sep 17 00:00:00 2001 From: elcuervo171234 <43775780+elcuervo171234@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:19:24 -0400 Subject: [PATCH 02/23] feat(minecraft): integrate console (2/23) --- .../com/osiris/autoplug/client/console/Commands.java | 10 ++++++++++ 1 file changed, 10 insertions(+) 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..899ec7e8 100644 --- a/src/main/java/com/osiris/autoplug/client/console/Commands.java +++ b/src/main/java/com/osiris/autoplug/client/console/Commands.java @@ -30,6 +30,8 @@ import org.jetbrains.annotations.NotNull; import com.osiris.autoplug.client.Main; +import com.osiris.autoplug.client.mc.client.NativeClientAccess; +import com.osiris.autoplug.client.mc.client.NativeClientCommands; import com.osiris.autoplug.client.Server; import com.osiris.autoplug.client.configs.SSHConfig; import com.osiris.autoplug.client.configs.UpdaterConfig; @@ -93,10 +95,18 @@ public static boolean execute(@NotNull String command) { if (first.equals(".")) { try { + if (command.startsWith(".mc ") || command.equals(".mc") || command.startsWith(".profiles ") || command.equals(".profiles") + || ((command.startsWith(".check ") || command.startsWith(".update ")) && command.contains("--profile"))) { + return NativeClientAccess.commands().dispatch(NativeClientCommands.tokenize(command), System.out); + } SSHConfig sshConfig = new SSHConfig(); if (command.equals(".help") || command.equals(".h")) { AL.info(""); AL.info(".help | Prints out this (Shortcut: .h)"); + AL.info(".profiles help | Isolated Minecraft client/server profiles and templates"); + AL.info(".mc help | Native launcher, saved servers, loaders and virtual worlds"); + AL.info(".check mods|plugins --profile [--version ] | Profile-scoped check"); + AL.info(".update mods|plugins --profile [--version ] [--confirm-disable] | Profile-scoped update"); AL.info(".ping | Responds with 'Pong!' as a way to test if AutoPlug is running."); 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)"); From 069cbe173023d1c33eedcf96a4dc564f67d3100c Mon Sep 17 00:00:00 2001 From: elcuervo171234 <43775780+elcuervo171234@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:19:53 -0400 Subject: [PATCH 03/23] feat(minecraft): integrate mc (3/23) --- .../client/mc/VanillaServerListReader.java | 284 ++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 src/main/java/com/osiris/autoplug/client/mc/VanillaServerListReader.java diff --git a/src/main/java/com/osiris/autoplug/client/mc/VanillaServerListReader.java b/src/main/java/com/osiris/autoplug/client/mc/VanillaServerListReader.java new file mode 100644 index 00000000..37d1412f --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/mc/VanillaServerListReader.java @@ -0,0 +1,284 @@ +package com.osiris.autoplug.client.mc; + +import java.io.ByteArrayInputStream; +import java.io.DataInputStream; +import java.io.EOFException; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.PushbackInputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.zip.GZIPInputStream; + +/** + * Read-only importer for a Java Edition servers.dat NBT compound. Only server + * names and addresses are returned; unknown NBT fields are validated and skipped. + * Supports uncompressed and gzip data, not Bedrock/little-endian NBT or zlib. + */ +public final class VanillaServerListReader { + public static final Limits DEFAULT_LIMITS = new Limits( + 8 * 1024 * 1024, 8 * 1024 * 1024, 64, 65535, 100000, 4096, 100000); + + private final Limits limits; + + public VanillaServerListReader() { + this(DEFAULT_LIMITS); + } + + public VanillaServerListReader(Limits limits) { + this.limits = Objects.requireNonNull(limits, "limits"); + } + + /** Opens and closes the file, without creating, changing or replacing it. */ + public List read(Path file) throws IOException { + try (InputStream input = Files.newInputStream(file)) { + return read(input); + } + } + + /** Reads one complete NBT document; the caller's stream is left open. */ + public List read(InputStream input) throws IOException { + Objects.requireNonNull(input, "input"); + PushbackInputStream encoded = new PushbackInputStream(new LimitedInputStream( + new NonClosingInputStream(input), limits.maxInputBytes), 2); + int first = encoded.read(); + int second = encoded.read(); + if (second != -1) encoded.unread(second); + if (first != -1) encoded.unread(first); + try (InputStream decoded = first == 0x1f && second == 0x8b + ? new GZIPInputStream(encoded) : encoded) { + DataInputStream data = new DataInputStream(new LimitedInputStream( + decoded, limits.maxDecodedBytes)); + Parser parser = new Parser(data, limits); + List result = parser.readRoot(); + // Reading through EOF also verifies a gzip trailer/checksum. + if (data.read() != -1) throw new IOException("Trailing data after NBT root"); + return Collections.unmodifiableList(result); + } catch (EOFException e) { + throw new IOException("Truncated servers.dat NBT", e); + } + } + + public static final class ServerEntry { + private final String name; + private final String address; + + private ServerEntry(String name, String address) { + this.name = name; + this.address = address; + } + + public String getName() { + return name; + } + + /** Original ip string, including an optional port; no connection is made. */ + public String getAddress() { + return address; + } + } + + /** Independent encoded/decoded byte limits also bound gzip expansion. */ + public static final class Limits { + private final long maxInputBytes; + private final long maxDecodedBytes; + private final int maxDepth; + private final int maxStringBytes; + private final int maxCollectionLength; + private final int maxServers; + private final int maxTags; + + public Limits(long maxInputBytes, long maxDecodedBytes, int maxDepth, + int maxStringBytes, int maxCollectionLength, int maxServers, int maxTags) { + if (maxInputBytes < 1 || maxDecodedBytes < 1 || maxDepth < 1 + || maxStringBytes < 1 || maxStringBytes > 65535 + || maxCollectionLength < 1 || maxServers < 1 || maxTags < 1) { + throw new IllegalArgumentException("NBT limits must be positive; strings are at most 65535 bytes"); + } + this.maxInputBytes = maxInputBytes; + this.maxDecodedBytes = maxDecodedBytes; + this.maxDepth = maxDepth; + this.maxStringBytes = maxStringBytes; + this.maxCollectionLength = maxCollectionLength; + this.maxServers = maxServers; + this.maxTags = maxTags; + } + } + + private static final class Parser { + private final DataInputStream in; + private final Limits limits; + private final byte[] scratch = new byte[8192]; + private long tags; + + private Parser(DataInputStream in, Limits limits) { + this.in = in; + this.limits = limits; + } + + private List readRoot() throws IOException { + if (readType() != 10) throw new IOException("NBT root must be a compound"); + readString(); // Root name is not part of the server data. + enter(1); + List servers = null; + int type; + while ((type = readType()) != 0) { + String key = readString(); + if ("servers".equals(key)) { + if (servers != null || type != 9) { + throw new IOException("Expected one servers list"); + } + servers = readServers(2); + } else { + skip(type, 2); + } + } + if (servers == null) throw new IOException("NBT compound has no servers list"); + return servers; + } + + private List readServers(int depth) throws IOException { + enter(depth); + int childType = readType(); + int count = readLength(Math.min(limits.maxServers, limits.maxCollectionLength)); + if (childType != 10 && !(count == 0 && childType == 0)) { + throw new IOException("servers must contain compounds"); + } + List result = new ArrayList<>(Math.min(count, 64)); + for (int i = 0; i < count; i++) result.add(readServer(depth + 1)); + return result; + } + + private ServerEntry readServer(int depth) throws IOException { + enter(depth); + String name = null; + String address = null; + int type; + while ((type = readType()) != 0) { + String key = readString(); + if ("name".equals(key) || "ip".equals(key)) { + if (type != 8) throw new IOException("Server name/ip must be strings"); + enter(depth + 1); + if ("name".equals(key)) { + if (name != null) throw new IOException("Duplicate server name"); + name = readString(); + } else { + if (address != null) throw new IOException("Duplicate server ip"); + address = readString(); + } + } else { + skip(type, depth + 1); + } + } + if (name == null || address == null) throw new IOException("Server is missing name or ip"); + return new ServerEntry(name, address); + } + + private void skip(int type, int depth) throws IOException { + enter(depth); + switch (type) { + case 1: skipBytes(1); break; + case 2: skipBytes(2); break; + case 3: case 5: skipBytes(4); break; + case 4: case 6: skipBytes(8); break; + case 7: skipBytes(readLength(limits.maxCollectionLength)); break; + case 8: readString(); break; + case 9: + int childType = readType(); + int length = readLength(limits.maxCollectionLength); + if (childType == 0 && length != 0) throw new IOException("Nonempty TAG_End list"); + for (int i = 0; i < length; i++) skip(childType, depth + 1); + break; + case 10: + int child; + while ((child = readType()) != 0) { + readString(); + skip(child, depth + 1); + } + break; + case 11: skipBytes(4L * readLength(limits.maxCollectionLength)); break; + case 12: skipBytes(8L * readLength(limits.maxCollectionLength)); break; + default: throw new IOException("Invalid NBT payload type"); + } + } + + private void enter(int depth) throws IOException { + if (depth > limits.maxDepth) throw new IOException("NBT depth limit exceeded"); + if (++tags > limits.maxTags) throw new IOException("NBT tag limit exceeded"); + } + + private int readType() throws IOException { + int type = in.readUnsignedByte(); + if (type > 12) throw new IOException("Illegal NBT tag type"); + return type; + } + + private int readLength(int maximum) throws IOException { + int length = in.readInt(); + if (length < 0 || length > maximum) throw new IOException("NBT collection length limit exceeded"); + return length; + } + + private String readString() throws IOException { + int length = in.readUnsignedShort(); + if (length > limits.maxStringBytes) throw new IOException("NBT string length limit exceeded"); + byte[] data = new byte[length + 2]; + data[0] = (byte) (length >>> 8); + data[1] = (byte) length; + in.readFully(data, 2, length); + // Java Edition NBT strings use DataInput's modified UTF-8 encoding. + return new DataInputStream(new ByteArrayInputStream(data)).readUTF(); + } + + private void skipBytes(long length) throws IOException { + while (length > 0) { + int count = (int) Math.min(scratch.length, length); + in.readFully(scratch, 0, count); + length -= count; + } + } + } + + private static final class LimitedInputStream extends FilterInputStream { + private final long maximum; + private long count; + + private LimitedInputStream(InputStream input, long maximum) { + super(input); + this.maximum = maximum; + } + + @Override + public int read() throws IOException { + int value = in.read(); + if (value != -1 && ++count > maximum) throw new IOException("NBT byte limit exceeded"); + return value; + } + + @Override + public int read(byte[] data, int offset, int length) throws IOException { + if (length == 0) return 0; + long remaining = maximum - count; + int request = remaining < length ? (int) (remaining + 1) : length; + int read = in.read(data, offset, request); + if (read > 0 && (count += read) > maximum) throw new IOException("NBT byte limit exceeded"); + return read; + } + } + + private static final class NonClosingInputStream extends FilterInputStream { + private NonClosingInputStream(InputStream input) { + super(input); + } + + @Override + public void close() { + // The read(InputStream) overload does not own the caller's stream. + } + } +} From c2f804f6d3a08c148105ff6b0fcbc51fd076a5e6 Mon Sep 17 00:00:00 2001 From: elcuervo171234 <43775780+elcuervo171234@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:20:37 -0400 Subject: [PATCH 04/23] feat(minecraft): integrate client (4/23) --- .../client/mc/client/ClientSettings.java | 49 ++ .../client/mc/client/ClientStorage.java | 66 +++ .../client/mc/client/NativeClientAccess.java | 27 + .../mc/client/NativeClientCommands.java | 409 ++++++++++++++ .../client/mc/client/NativeClientContext.java | 509 ++++++++++++++++++ .../mc/client/SavedServerRepository.java | 98 ++++ .../client/mc/client/ServerAddress.java | 91 ++++ 7 files changed, 1249 insertions(+) create mode 100644 src/main/java/com/osiris/autoplug/client/mc/client/ClientSettings.java create mode 100644 src/main/java/com/osiris/autoplug/client/mc/client/ClientStorage.java create mode 100644 src/main/java/com/osiris/autoplug/client/mc/client/NativeClientAccess.java create mode 100644 src/main/java/com/osiris/autoplug/client/mc/client/NativeClientCommands.java create mode 100644 src/main/java/com/osiris/autoplug/client/mc/client/NativeClientContext.java create mode 100644 src/main/java/com/osiris/autoplug/client/mc/client/SavedServerRepository.java create mode 100644 src/main/java/com/osiris/autoplug/client/mc/client/ServerAddress.java diff --git a/src/main/java/com/osiris/autoplug/client/mc/client/ClientSettings.java b/src/main/java/com/osiris/autoplug/client/mc/client/ClientSettings.java new file mode 100644 index 00000000..1aebe5f6 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/mc/client/ClientSettings.java @@ -0,0 +1,49 @@ +package com.osiris.autoplug.client.mc.client; + +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; + +/** Non-secret configuration only. Access/refresh tokens never enter this file. */ +public final class ClientSettings { + private static final java.util.Set KEYS = java.util.Set.of("clientId", "javaHomes", "defaultTemplate", "username", "offlineFixtureMode", "cacheDirectory"); + private final Path file; + private final ClientStorage storage = new ClientStorage(); + private final Properties values; + + public ClientSettings(Path dataRoot) throws IOException { + file = dataRoot.resolve("client-settings.properties"); values = storage.read(file); + try { for (String key : values.stringPropertyNames()) validate(key, values.getProperty(key)); } + catch (IllegalArgumentException error) { throw new IOException("Invalid non-secret client settings", error); } + } + public synchronized String get(String key, String fallback) { return values.getProperty(key, fallback); } + public synchronized void set(String key, String value) { + validate(key, value); + if (value == null) values.remove(key); else values.setProperty(key, value); + } + public synchronized void save() throws IOException { storage.write(file, values); } + public synchronized List javaHomes(Path autoplugWorkingDirectory) { + List paths = new ArrayList<>(); + paths.add(Paths.get(System.getProperty("java.home"))); + paths.add(autoplugWorkingDirectory.resolve("autoplug/system/jre")); + for (String path : get("javaHomes", "").split("\\r?\\n")) if (!path.trim().isEmpty()) paths.add(Paths.get(path.trim())); + return paths; + } + + private void validate(String key, String value) { + if (!KEYS.contains(key)) throw new IllegalArgumentException("Unknown non-secret setting"); + if (value == null) return; + if (value.length() > 16384 || value.indexOf('\0') >= 0) throw new IllegalArgumentException("Setting value exceeds limit or contains a NUL"); + if (key.equals("offlineFixtureMode") && !value.equals("true") && !value.equals("false")) throw new IllegalArgumentException("Offline fixture mode must be true or false"); + if (key.equals("username") && !value.matches("[A-Za-z0-9_]{1,16}")) throw new IllegalArgumentException("Invalid Minecraft username"); + if (key.equals("defaultTemplate") && !value.isEmpty() && !value.matches("[a-z0-9][a-z0-9._-]{0,79}")) throw new IllegalArgumentException("Invalid default template ID"); + if (key.equals("javaHomes")) { + if (value.split("\\r?\\n", -1).length > 32) throw new IllegalArgumentException("Too many Java home paths"); + for (String path : value.split("\\r?\\n")) if (!path.trim().isEmpty()) Paths.get(path.trim()); + } + if (key.equals("cacheDirectory") && !value.trim().isEmpty()) Paths.get(value.trim()); + } +} diff --git a/src/main/java/com/osiris/autoplug/client/mc/client/ClientStorage.java b/src/main/java/com/osiris/autoplug/client/mc/client/ClientStorage.java new file mode 100644 index 00000000..e14a0b6a --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/mc/client/ClientStorage.java @@ -0,0 +1,66 @@ +package com.osiris.autoplug.client.mc.client; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Properties; + +final class ClientStorage { + private static final long MAX_BYTES = 8 * 1024 * 1024; + + Properties read(Path path) throws IOException { + path = directPath(path, false); + Properties values = new Properties(); + if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) return values; + if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Expected a regular settings file: " + path); + if (Files.size(path) > MAX_BYTES) throw new IOException("Settings file exceeds size limit"); + try (InputStream input = new java.io.FilterInputStream(Files.newInputStream(path)) { + private long count; + @Override public int read() throws IOException { + int value = in.read(); if (value != -1 && ++count > MAX_BYTES) throw new IOException("Settings file exceeds size limit"); return value; + } + @Override public int read(byte[] bytes, int offset, int length) throws IOException { + int read = in.read(bytes, offset, (int) Math.min(length, MAX_BYTES - count + 1)); + if (read > 0 && (count += read) > MAX_BYTES) throw new IOException("Settings file exceeds size limit"); return read; + } + }) { values.load(input); } + catch (IllegalArgumentException error) { throw new IOException("Invalid settings file: " + path, error); } + return values; + } + + void write(Path path, Properties values) throws IOException { + path = directPath(path, true); + if (Files.exists(path, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Cannot replace a linked or special settings file"); + } + java.io.ByteArrayOutputStream bytes = new java.io.ByteArrayOutputStream(); + values.store(bytes, "AutoPlug native Minecraft client"); + if (bytes.size() > MAX_BYTES) throw new IOException("Settings exceed size limit"); + Path temporary = Files.createTempFile(path.getParent(), ".settings-", ".tmp"); + try { + try (OutputStream output = Files.newOutputStream(temporary)) { bytes.writeTo(output); } + Files.move(temporary, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } finally { Files.deleteIfExists(temporary); } + } + + private Path directPath(Path path, boolean createParent) throws IOException { + Path absolute = path.toAbsolutePath().normalize(); + Path parent = absolute.getParent(); + if (parent == null) throw new IOException("Settings file needs a parent directory"); + Path existing = parent; + while (existing != null && !Files.exists(existing, LinkOption.NOFOLLOW_LINKS)) existing = existing.getParent(); + if (existing != null && (!Files.isDirectory(existing, LinkOption.NOFOLLOW_LINKS) || !existing.equals(existing.toRealPath()))) { + throw new IOException("Settings ancestor must be a direct canonical directory"); + } + if (createParent) Files.createDirectories(parent); + if (Files.exists(parent, LinkOption.NOFOLLOW_LINKS) + && (!Files.isDirectory(parent, LinkOption.NOFOLLOW_LINKS) || !parent.equals(parent.toRealPath()))) { + throw new IOException("Settings parent must be a direct canonical directory"); + } + return absolute; + } +} diff --git a/src/main/java/com/osiris/autoplug/client/mc/client/NativeClientAccess.java b/src/main/java/com/osiris/autoplug/client/mc/client/NativeClientAccess.java new file mode 100644 index 00000000..62dda4c5 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/mc/client/NativeClientAccess.java @@ -0,0 +1,27 @@ +package com.osiris.autoplug.client.mc.client; + +import com.osiris.autoplug.client.utils.GD; +import java.io.IOException; +import java.nio.file.Path; + +/** UI and console must share profile-use locks and running processes. */ +public final class NativeClientAccess { + private static NativeClientContext instance; + private static NativeClientCommands commands; + private NativeClientAccess() { } + public static synchronized NativeClientContext get() throws IOException { + if (instance == null) { + Path working = GD.WORKING_DIR.toPath(); + instance = new NativeClientContext(working.resolve("autoplug/minecraft"), working); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + if (commands != null) commands.close(); + try { instance.close(); } catch (IOException error) { System.err.println("Minecraft shutdown: " + error.getMessage()); } + }, "AutoPlug-native-shutdown")); + } + return instance; + } + public static synchronized NativeClientCommands commands() throws IOException { + if (commands == null) commands = new NativeClientCommands(get()); + return commands; + } +} diff --git a/src/main/java/com/osiris/autoplug/client/mc/client/NativeClientCommands.java b/src/main/java/com/osiris/autoplug/client/mc/client/NativeClientCommands.java new file mode 100644 index 00000000..fd6258e6 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/mc/client/NativeClientCommands.java @@ -0,0 +1,409 @@ +package com.osiris.autoplug.client.mc.client; + +import com.osiris.autoplug.client.mc.launch.Cancellation; +import com.osiris.autoplug.client.mc.launch.MicrosoftDeviceCodeAuth; +import com.osiris.autoplug.client.mc.profiles.Profile; +import com.osiris.autoplug.client.mc.profiles.ProfileResolver; +import com.osiris.autoplug.client.mc.profiles.ProfileType; +import com.osiris.autoplug.client.mc.update.ProfileUpdateService; +import com.osiris.autoplug.client.mc.worlds.World; +import com.osiris.autoplug.client.mc.worlds.WorldSessionManager; +import java.io.IOException; +import java.io.PrintStream; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** CLI and desktop use the same context, repositories, update engine and process registry. */ +public final class NativeClientCommands implements AutoCloseable { + private final NativeClientContext context; + private volatile Cancellation signInCancellation; + private volatile Thread signInThread; + private volatile boolean closed; + + public NativeClientCommands(NativeClientContext context) { + this.context = java.util.Objects.requireNonNull(context); + } + + /** Tokenizes the existing console's command text, preserving Windows paths and quoted spaces. */ + public static String[] tokenize(String command) { + if (command == null) return new String[0]; + List tokens = new ArrayList<>(); + StringBuilder token = new StringBuilder(); + char quote = 0; + boolean active = false; + for (int i = 0; i < command.length(); i++) { + char character = command.charAt(i); + if (character == '\\' && i + 1 < command.length()) { + char next = command.charAt(i + 1); + if (next == quote || (quote == 0 && (Character.isWhitespace(next) || next == '\'' || next == '"'))) { + token.append(next); active = true; i++; continue; + } + } + if ((character == '\'' || character == '"') && (quote == 0 || quote == character)) { + quote = quote == 0 ? character : 0; active = true; + } else if (Character.isWhitespace(character) && quote == 0) { + if (active) { tokens.add(token.toString()); token.setLength(0); active = false; } + } else { token.append(character); active = true; } + } + if (quote != 0) throw new IllegalArgumentException("Unclosed command quote"); + if (active) tokens.add(token.toString()); + return tokens.toArray(new String[0]); + } + + /** Returns false for legacy/non-native commands, which the existing dispatcher must handle. */ + public boolean dispatch(String[] arguments, PrintStream out) throws Exception { + if (closed) throw new IOException("Native client commands are closed"); + if (arguments == null || arguments.length == 0) return false; + String[] args = Arrays.stream(arguments).map(this::unquote).toArray(String[]::new); + String command = args[0].startsWith(".") ? args[0].substring(1) : args[0]; + if (command.equals("profiles")) { profiles(args, out); return true; } + if (command.equals("mc")) { minecraft(args, out); return true; } + if ((command.equals("check") || command.equals("update")) && Arrays.stream(args).anyMatch(value -> value.equals("--profile") || value.startsWith("--profile="))) { + update(args, out, command.equals("update")); return true; + } + return false; + } + + private void profiles(String[] args, PrintStream out) throws Exception { + if (args.length < 2 || args[1].equals("help") || args[1].equals("--help")) { + out.println(".profiles list [--type MODS|PLUGINS|MODS-SERVER]"); + out.println(".profiles create [--type TYPE] [--template]"); + out.println(".profiles clone [--version target] [--confirm-disable]"); + out.println(".profiles rename | delete | template "); return; + } + Options options = new Options(args, 2, "template", "confirm-disable"); + switch (args[1]) { + case "list": + options.allow("type"); options.count(0); + ProfileType filter = options.has("type") ? type(options.value("type")) : null; + for (Profile profile : context.profiles().list()) if (filter == null || filter == profile.getType()) print(profile, out); + return; + case "create": + options.allow("type", "template"); options.count(3); + print(context.profiles().create(options.position(0), options.has("type") ? type(options.value("type")) : ProfileType.MODS, + options.position(1), options.position(2), options.has("template")), out); return; + case "clone": + options.allow("version", "confirm-disable"); options.count(2); + if (options.has("version")) { + ProfileUpdateService.Plan plan = context.updater().planClone(options.position(0), options.position(1), options.value("version")); + apply(plan, options.has("confirm-disable"), out); + } else print(context.profiles().cloneProfile(options.position(0), options.position(1)), out); + return; + case "rename": + options.allow(); options.count(2); requireUnreferenced(options.position(0)); + Profile renamed = context.profiles().rename(options.position(0), options.position(1)); + if (options.position(0).equals(context.settings().get("defaultTemplate", ""))) { + context.settings().set("defaultTemplate", renamed.getId()); context.settings().save(); + } + print(renamed, out); return; + case "delete": + options.allow(); options.count(1); requireUnreferenced(options.position(0)); + context.profiles().delete(options.position(0)); + if (options.position(0).equals(context.settings().get("defaultTemplate", ""))) { + context.settings().set("defaultTemplate", ""); context.settings().save(); + } + out.println("Deleted profile " + options.position(0)); return; + case "template": + options.allow(); options.count(2); + if (parseBoolean(options.position(1))) requireUnreferenced(options.position(0)); + print(context.profiles().updateTemplate(options.position(0), parseBoolean(options.position(1))), out); return; + default: throw usage("Unknown .profiles operation; use .profiles help"); + } + } + + private void update(String[] args, PrintStream out, boolean apply) throws Exception { + Options options = new Options(args, 1, "confirm-disable"); + options.allow("profile", "version", "confirm-disable"); options.count(1); + if (!options.has("profile")) throw usage("Specify --profile "); + Profile profile = context.profiles().require(options.value("profile")); + boolean mods = options.position(0).equals("mods"); + boolean plugins = options.position(0).equals("plugins"); + if ((!mods && !plugins) || plugins != (profile.getType() == ProfileType.PLUGINS)) { + throw usage("Use mods for MODS/MODS_SERVER and plugins for PLUGINS"); + } + ProfileUpdateService.Plan plan = context.updater().check(profile.getId(), options.has("version") ? options.value("version") : profile.getGameVersion()); + if (apply) apply(plan, options.has("confirm-disable"), out); + else out.println(plan.getSummary()); + } + + private void apply(ProfileUpdateService.Plan plan, boolean confirmed, PrintStream out) throws IOException { + out.println(plan.getSummary()); + if (!plan.canApply()) throw new IOException("Resolve the reported check errors before applying changes"); + if (!plan.getDisabledAssets().isEmpty() && !confirmed) { + out.println("Nothing changed. Review the summary, then repeat with --confirm-disable to approve disabling these files."); return; + } + print(context.updater().apply(plan, confirmed), out); + } + + private void minecraft(String[] args, PrintStream out) throws Exception { + if (args.length < 2 || args[1].equals("help") || args[1].equals("--help")) { + out.println(".mc servers list|add|import|ping|join; .mc worlds list|create|launch|stop|share"); + out.println(".mc launch [--server host:port] | stop "); + out.println(".mc signin [--client-id registeredId] | device login | signin cancel | signout"); + out.println(".mc loader [loaderVersion] | settings show|set "); + out.println(".mc offline enables local fixture mode only; no Microsoft authentication."); return; + } + if (args[1].equals("servers")) { servers(args, out); return; } + if (args[1].equals("worlds")) { worlds(args, out); return; } + Options options = new Options(args, 2); + switch (args[1]) { + case "launch": + options.allow("server"); options.count(1); + context.launch(options.position(0), options.has("server") ? options.value("server") : null, new Cancellation()); + out.println("Minecraft client started for " + options.position(0)); return; + case "stop": + options.allow(); options.count(1); context.stopClient(options.position(0)); out.println("Client stop requested for " + options.position(0)); return; + case "signin": case "login": case "device": + options.allow("client-id"); + if (args[1].equals("device")) { + if (options.positions.isEmpty() || (!options.position(0).equals("login") && !options.position(0).equals("cancel"))) throw usage(".mc device login [--client-id registeredId] | .mc device cancel"); + if (options.position(0).equals("login")) options.positions.remove(0); + } + if (options.positions.size() == 1 && options.position(0).equals("cancel")) { cancelSignIn(); out.println("Sign-in cancellation requested"); return; } + options.count(0); + if (options.has("client-id")) { context.settings().set("clientId", options.value("client-id")); context.settings().save(); } + beginSignIn(out); return; + case "signout": case "logout": + options.allow(); options.count(0); cancelSignIn(); context.signOut(); out.println("Signed out; no token file was created"); return; + case "offline": + options.allow(); options.count(1); context.launcher().offlineSession(options.position(0)); + context.settings().set("username", options.position(0)); context.settings().set("offlineFixtureMode", "true"); context.settings().save(); + out.println("Offline fixture mode enabled for local test servers; this is not Microsoft authentication"); return; + case "loader": + options.allow(); + if (options.positions.size() < 1 || options.positions.size() > 2) throw usage(".mc loader [loaderVersion]"); + String installed = context.installLoader(options.position(0), options.positions.size() == 2 ? options.position(1) : null, new Cancellation()); + out.println("Installed loader version " + installed + " for " + options.position(0)); return; + case "settings": + options.allow(); + if (options.positions.size() == 1 && options.position(0).equals("show")) { + for (String key : new String[]{"clientId", "javaHomes", "defaultTemplate", "username", "offlineFixtureMode", "cacheDirectory"}) { + out.println(key + "=" + context.settings().get(key, "")); + } + } else { + options.count(3); + if (!options.position(0).equals("set")) throw usage(".mc settings show | set "); + context.settings().set(options.position(1), options.position(2)); context.settings().save(); + out.println("Saved non-secret setting " + options.position(1) + "; changing cacheDirectory takes effect after restart"); + } + return; + default: throw usage("Unknown .mc operation; use .mc help"); + } + } + + private void servers(String[] args, PrintStream out) throws Exception { + if (args.length < 3) throw usage(".mc servers list | add
| import | ping | join [--profile id]"); + Options options = new Options(args, 3, "confirm-disable"); + switch (args[2]) { + case "list": + options.allow(); options.count(0); + for (SavedServerRepository.SavedServer server : context.servers().list()) out.println(server.getId() + " | " + server + " | " + server.getLoader()); return; + case "add": + options.allow(); options.count(3); out.println(context.servers().add(options.position(0), options.position(1), options.position(2))); return; + case "import": + options.allow(); options.count(2); out.println("Imported " + context.servers().importVanilla(Paths.get(options.position(0)), options.position(1)) + " servers; servers.dat unchanged"); return; + case "ping": + options.allow(); options.count(1); out.println(context.ping(serverAddress(options.position(0)))); return; + case "join": + options.allow("profile", "version", "loader", "clone", "confirm-disable"); options.count(1); join(options, out); return; + default: throw usage("Unknown .mc servers operation"); + } + } + + private void join(Options options, PrintStream out) throws Exception { + String requested = options.position(0); + String address = serverAddress(requested); + Cancellation cancellation = new Cancellation(); + if (options.has("profile")) { + if (options.has("clone")) throw usage("Choose --profile or --clone, not both"); + Profile profile = requireClientProfile(options.value("profile")); + if (options.has("loader") && !profile.getLoader().equalsIgnoreCase(options.value("loader"))) throw usage("Selected profile does not match --loader"); + if (options.has("version") && !profile.getGameVersion().equals(options.value("version"))) throw usage("Selected profile does not match --version"); + context.ensureLoader(profile.getId(), cancellation); + context.launch(profile.getId(), address, cancellation); out.println("Minecraft client started"); return; + } + String loader = options.has("loader") ? options.value("loader") : null; + for (SavedServerRepository.SavedServer entry : context.servers().list()) if (entry.getId().equals(requested)) loader = options.has("loader") ? loader : entry.getLoader(); + if (loader == null) throw usage("Supply --loader explicitly, or use a saved server with an explicit loader"); + String version = options.has("version") ? options.value("version") : pingVersion(address); + ProfileResolver.Resolution resolution = new ProfileResolver(context.profiles()).resolve(version, loader, context.settings().get("defaultTemplate", "").isEmpty() ? null : context.settings().get("defaultTemplate", "")); + switch (resolution.getState()) { + case READY: + Profile selected = requireClientProfile(resolution.getProfiles().get(0).getId()); + context.ensureLoader(selected.getId(), cancellation); + context.launch(selected.getId(), address, cancellation); out.println("Minecraft client started"); return; + case CHOOSE_PROFILE: + out.println("Several profiles match; repeat join with --profile :"); + for (Profile profile : resolution.getProfiles()) print(profile, out); return; + case CONFIRM_TEMPLATE_CLONE: + if (!options.has("clone")) { out.println("Template clone and migration require approval; repeat with --clone after reviewing the template."); return; } + ProfileUpdateService.Plan plan = context.updater().planClone(resolution.getProfiles().get(0).getId(), options.value("clone"), version); + out.println(plan.getSummary()); + if (!plan.canApply()) throw new IOException("Resolve migration errors before joining"); + if (!plan.getDisabledAssets().isEmpty() && !options.has("confirm-disable")) { out.println("Nothing changed; review the disabled-file summary and repeat with --confirm-disable to approve."); return; } + Profile clone = context.updater().apply(plan, options.has("confirm-disable")); + print(clone, out); + requireClientProfile(clone.getId()); + context.ensureLoader(clone.getId(), cancellation); + context.launch(clone.getId(), address, cancellation); + out.println("Minecraft client started with cloned profile " + clone.getId()); return; + default: out.println("No compatible profile; create one for Minecraft " + version + " / " + loader + " before joining."); + } + } + + private Profile requireClientProfile(String id) throws IOException { + Profile profile = context.profiles().require(id); + if (profile.getType() != ProfileType.MODS || profile.isTemplate()) throw new IOException("Select a non-template client profile"); + if (!Arrays.asList("VANILLA", "FABRIC", "QUILT", "FORGE", "NEOFORGE").contains(profile.getLoader().toUpperCase(Locale.ROOT))) { + throw new IOException("Unsupported client loader: " + profile.getLoader()); + } + return profile; + } + + private String pingVersion(String address) throws IOException { + NativeClientContext.ServerStatus status = context.ping(address); + if (!status.isOnline()) throw new IOException("Server status unavailable; specify --version and --loader explicitly"); + if (status.getVersion() == null) throw new IOException("Server version unavailable; specify --version explicitly"); + Matcher match = Pattern.compile("(? | configure --client --server-jar | launch [--share] | stop | share "); + Options options = new Options(args, 3, "share"); + switch (args[2]) { + case "list": + options.allow(); options.count(0); + for (World world : context.worlds().list()) out.println(world.getId() + " | " + world.getProfileId() + " | " + world.getDirectory()); return; + case "create": + options.allow(); options.count(2); World world = context.worlds().create(options.position(0), options.position(1)); + out.println("Created world " + world.getId() + "; review Minecraft's EULA and set eula=true yourself before starting its server"); return; + case "launch": + options.allow("client", "server-jar", "share"); options.count(1); + if (options.has("client") != options.has("server-jar")) throw usage("Supply both --client and --server-jar, or use the saved world launch settings"); + if (options.has("client")) context.configureWorld(options.position(0), options.value("client"), Paths.get(options.value("server-jar"))); + WorldSessionManager.Session session = context.playWorld(options.position(0), options.has("share"), new Cancellation()); + out.println("World started; " + session.getShareMessage()); return; + case "configure": + options.allow("client", "server-jar"); options.count(1); + if (!options.has("client") || !options.has("server-jar")) throw usage("World configuration requires --client and --server-jar "); + context.configureWorld(options.position(0), options.value("client"), Paths.get(options.value("server-jar"))); + out.println("Saved world launch settings; no server started and no EULA was accepted"); return; + case "stop": + options.allow(); options.count(1); context.stopWorld(options.position(0)); out.println("World stop requested"); return; + case "share": + options.allow(); options.count(1); WorldSessionManager.Session active = context.activeWorld(options.position(0)); + if (active == null) throw new IOException("Start the world before explicitly sharing it"); + out.println(active.share()); return; + default: throw usage("Unknown .mc worlds operation"); + } + } + + private synchronized void beginSignIn(PrintStream out) { + if (closed) throw new IllegalStateException("Native client commands are closed"); + if (signInThread != null && signInThread.isAlive()) throw usage("Sign-in already pending; use .mc signin cancel first"); + MicrosoftDeviceCodeAuth authenticator = context.authenticator(); // Validate configuration before starting a worker. + Cancellation cancellation = new Cancellation(); signInCancellation = cancellation; + signInThread = new Thread(() -> { + try { + MicrosoftDeviceCodeAuth.DeviceAuthorization device = authenticator.begin(cancellation); + out.println("Open " + device.getVerificationUri() + " and enter " + device.getUserCode() + "; expires " + device.getExpiresAt()); + MicrosoftDeviceCodeAuth.AuthResult result = authenticator.poll(device, cancellation); + cancellation.check(); context.signedIn(result); + context.settings().set("offlineFixtureMode", "false"); context.settings().save(); + out.println("Signed in as " + result.getSession().getUsername() + "; tokens remain in memory only"); + } catch (Exception error) { + // Provider bodies/exception chains can contain tokens: never print them. + out.println(cancellation.isCancelled() ? "Sign-in cancelled" : "Sign-in failed; verify client ID, application access and account eligibility, then retry"); + } + }, "AutoPlug-Microsoft-signin"); + signInThread.setDaemon(true); signInThread.start(); + } + + private void cancelSignIn() { Cancellation cancellation = signInCancellation; if (cancellation != null) cancellation.cancel(); } + + @Override public synchronized void close() { + closed = true; cancelSignIn(); + Thread worker = signInThread; + if (worker != null && worker != Thread.currentThread()) { + worker.interrupt(); + try { worker.join(2000); } catch (InterruptedException error) { Thread.currentThread().interrupt(); } + } + } + + private void requireUnreferenced(String id) throws IOException { + for (World world : context.worlds().list()) { + if (world.getProfileId().equals(id)) throw new IOException("Profile is referenced by world " + world.getId() + "; preserve that reference before renaming/deleting it"); + java.util.Properties config = new ClientStorage().read(world.getDirectory().resolve("world-launch.properties")); + if (!config.isEmpty() && !"1".equals(config.getProperty("formatVersion"))) throw new IOException("Invalid launch settings for world " + world.getId()); + if (id.equals(config.getProperty("clientProfileId"))) throw new IOException("Profile is the configured client for world " + world.getId() + "; change its launch settings before renaming/deleting it"); + } + } + + private ProfileType type(String value) { + try { return ProfileType.valueOf(value.toUpperCase(Locale.ROOT).replace('-', '_')); } + catch (IllegalArgumentException error) { throw usage("Type must be MODS, PLUGINS or MODS-SERVER"); } + } + private boolean parseBoolean(String value) { + if (!value.equals("true") && !value.equals("false")) throw usage("Use true or false explicitly"); return Boolean.parseBoolean(value); + } + private void print(Profile profile, PrintStream out) { + out.println(profile.getId() + " | " + profile.getType() + " | " + profile.getGameVersion() + " | " + profile.getLoader() + (profile.isTemplate() ? " | template" : "")); + } + private String unquote(String value) { + if (value != null && value.length() >= 2 && ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'")))) return value.substring(1, value.length() - 1); + return value; + } + private IllegalArgumentException usage(String message) { return new IllegalArgumentException(message); } + + private final class Options { + private final List positions = new ArrayList<>(); + private final Map values = new HashMap<>(); + Options(String[] args, int start, String... booleanFlags) { + Set flags = new HashSet<>(Arrays.asList(booleanFlags)); + for (int i = start; i < args.length; i++) { + String argument = args[i]; + if (!argument.startsWith("--")) { positions.add(argument); continue; } + String key = argument.substring(2); + int equals = key.indexOf('='); + if (equals >= 0) { + String value = key.substring(equals + 1); key = key.substring(0, equals); + if (value.isEmpty() || flags.contains(key)) throw usage("Invalid value for --" + key); + if (values.putIfAbsent(key, value) != null) throw usage("Duplicate option --" + key); + continue; + } + if (values.containsKey(key)) throw usage("Duplicate option --" + key); + if (flags.contains(key)) values.put(key, "true"); + else { + if (i + 1 == args.length || args[i + 1].startsWith("--")) throw usage("Missing value for --" + key); + values.put(key, args[++i]); + } + } + } + void allow(String... keys) { + Set permitted = new HashSet<>(Arrays.asList(keys)); + for (String key : values.keySet()) if (!permitted.contains(key)) throw usage("Unknown option --" + key); + } + void count(int expected) { if (positions.size() != expected) throw usage("Expected " + expected + " positional arguments; use .mc help or .profiles help"); } + boolean has(String key) { return values.containsKey(key); } + String value(String key) { return values.get(key); } + String position(int index) { return positions.get(index); } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/mc/client/NativeClientContext.java b/src/main/java/com/osiris/autoplug/client/mc/client/NativeClientContext.java new file mode 100644 index 00000000..4d3bd901 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/mc/client/NativeClientContext.java @@ -0,0 +1,509 @@ +package com.osiris.autoplug.client.mc.client; + +import com.osiris.autoplug.client.mc.launch.*; +import com.osiris.autoplug.client.mc.profiles.*; +import com.osiris.autoplug.client.mc.sharing.UpnpPortMapper; +import com.osiris.autoplug.client.mc.update.*; +import com.osiris.autoplug.client.mc.worlds.*; +import com.osiris.autoplug.client.utils.MineStat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; + +/** One shared context for desktop and CLI. Construction performs no network requests. */ +public final class NativeClientContext implements AutoCloseable { + /** Injectable process boundary; tests need not start Java or Minecraft. */ + public interface ClientLaunch extends AutoCloseable { + Process start() throws IOException; + @Override void close() throws IOException; + } + public interface LaunchPreparation { + ClientLaunch prepare(MinecraftLauncher launcher, String version, Path directory, MinecraftSession session, + String host, int port, Cancellation cancellation) throws IOException; + } + public interface WorldSessionsFactory { + WorldSessionManager create(WorldRepository repository); + } + private static final LaunchPreparation DEFAULT_PREPARATION = (launcher, version, directory, session, host, port, cancellation) -> { + PreparedLaunch prepared = launcher.prepare(version, directory, session, host, port, cancellation); + return new ClientLaunch() { + public Process start() throws IOException { return prepared.start(); } + public void close() throws IOException { prepared.close(); } + }; + }; + public static final class ServerStatus { + private final boolean online; + private final String version, motd; + private final int players, maximum; + private final long latency; + ServerStatus(MineStat status) { + online = status.isServerUp(); version = status.getVersion(); motd = status.getStrippedMotd(); + players = status.getCurrentPlayers(); maximum = status.getMaximumPlayers(); latency = status.getLatency(); + } + public boolean isOnline() { return online; } + public String getVersion() { return version; } + public String getMotd() { return motd; } + @Override public String toString() { return online ? version + " | " + players + "/" + maximum + " | " + latency + "ms | " + motd : "Offline / status unavailable"; } + } + + private final Path dataRoot, workingDirectory; + private final ClientSettings settings; + private final ProfileRepository profiles; + private final SavedServerRepository servers; + private final WorldRepository worlds; + private final ProfileUpdateService updater; + private final LaunchTransport transport; + private final ArtifactCache artifacts; + private final MojangMetadata metadata; + private final WorldSessionManager worldSessions; + private final ConcurrentHashMap running = new ConcurrentHashMap<>(); + private final ConcurrentHashMap operations = new ConcurrentHashMap<>(); + private final ConcurrentHashMap worldOperations = new ConcurrentHashMap<>(); + private final ConcurrentHashMap clientFailures = new ConcurrentHashMap<>(); + private final LaunchPreparation preparation; + private final Object lifecycle = new Object(); + private final Object refreshLock = new Object(); + private volatile boolean closed; + private volatile MicrosoftDeviceCodeAuth.AuthResult authentication; + + public NativeClientContext(Path dataRoot, Path workingDirectory) throws IOException { + this(dataRoot, workingDirectory, new HttpLaunchTransport(), new LegacyProfileUpdateEngine()); + } + + /** Injectable transport/update boundary supports offline previews and deterministic tests. */ + public NativeClientContext(Path dataRoot, Path workingDirectory, LaunchTransport transport, + ProfileUpdateService.Engine engine) throws IOException { + this(dataRoot, workingDirectory, transport, engine, DEFAULT_PREPARATION); + } + + public NativeClientContext(Path dataRoot, Path workingDirectory, LaunchTransport transport, + ProfileUpdateService.Engine engine, LaunchPreparation preparation) throws IOException { + this(dataRoot, workingDirectory, transport, engine, preparation, repository -> new WorldSessionManager(repository, new UpnpPortMapper())); + } + + public NativeClientContext(Path dataRoot, Path workingDirectory, LaunchTransport transport, + ProfileUpdateService.Engine engine, LaunchPreparation preparation, + WorldSessionsFactory worldSessionsFactory) throws IOException { + Files.createDirectories(dataRoot); + this.dataRoot = dataRoot.toRealPath(); this.workingDirectory = workingDirectory.toAbsolutePath().normalize(); + this.preparation = java.util.Objects.requireNonNull(preparation, "preparation"); + settings = new ClientSettings(this.dataRoot); + profiles = new ProfileRepository(this.dataRoot); + servers = new SavedServerRepository(this.dataRoot); + worlds = new WorldRepository(this.dataRoot, profiles); + this.transport = transport; + String configured = settings.get("cacheDirectory", "").trim(); + Path cacheRoot = configured.isEmpty() ? Paths.get(System.getProperty("user.home"), ".autoplug", "cache") : Paths.get(configured); + artifacts = new ArtifactCache(cacheRoot.resolve("launcher"), transport); + metadata = new MojangMetadata(transport, artifacts); + updater = new ProfileUpdateService(profiles, new SharedArtifactCache(cacheRoot.resolve("profiles")), engine); + worldSessions = java.util.Objects.requireNonNull(worldSessionsFactory.create(worlds), "worldSessions"); + } + + public Path getDataRoot() { return dataRoot; } + public ClientSettings settings() { return settings; } + public ProfileRepository profiles() { return profiles; } + public SavedServerRepository servers() { return servers; } + public WorldRepository worlds() { return worlds; } + public ProfileUpdateService updater() { return updater; } + public MojangMetadata metadata() { return metadata; } + + public ServerStatus ping(String address) { + ServerAddress parsed = new ServerAddress(address); + return new ServerStatus(new MineStat(parsed.getHost(), parsed.getPort(), 3, MineStat.Request.JSON)); + } + + public MicrosoftDeviceCodeAuth authenticator() { + return new MicrosoftDeviceCodeAuth(settings.get("clientId", ""), transport); + } + public void signedIn(MicrosoftDeviceCodeAuth.AuthResult result) { + synchronized (lifecycle) { + if (closed) throw new IllegalStateException("Client context is closed"); + authentication = java.util.Objects.requireNonNull(result, "result"); + } + } + public void signOut() { synchronized (lifecycle) { authentication = null; } } + public String getAccountLabel() { + MicrosoftDeviceCodeAuth.AuthResult current = authentication; + return current == null ? "Not signed in" : current.getSession().getUsername(); + } + + public MinecraftLauncher launcher() { + String os = System.getProperty("os.name").toLowerCase(Locale.ROOT); + String platform = os.contains("win") ? "windows" : os.contains("mac") ? "osx" : "linux"; + return new MinecraftLauncher(artifacts, metadata, + new LaunchPlatform(platform, System.getProperty("os.arch"), System.getProperty("os.version"), Collections.emptyMap()), + new JavaRuntimeSelector(settings.javaHomes(workingDirectory), platform.equals("windows"))); + } + + public MinecraftSession session(Cancellation cancellation) throws IOException { + ensureOpen(); cancellation.check(); + if (Boolean.parseBoolean(settings.get("offlineFixtureMode", "false"))) { + return launcher().offlineSession(settings.get("username", "Player")); + } + synchronized (refreshLock) { + MicrosoftDeviceCodeAuth.AuthResult result = authentication; + if (result == null) throw new IOException("Sign in with Microsoft first, or explicitly select offline fixture mode for local tests"); + if (!java.time.Instant.now().isBefore(result.getExpiresAt().minusSeconds(30))) { + MicrosoftDeviceCodeAuth.AuthResult refreshed = authenticator().refresh(result.getRefreshToken(), cancellation); + synchronized (lifecycle) { + ensureOpen(); cancellation.check(); + if (authentication != result) throw new IOException("Account changed during refresh; sign in again"); + authentication = refreshed; result = refreshed; + } + } + return result.getSession(); + } + } + + public Process launch(String profileId, String serverAddress, Cancellation cancellation) throws IOException { + return launchWithLease(profileId, serverAddress, cancellation, null); + } + + private Process launchWithLease(String profileId, String serverAddress, Cancellation cancellation, + ProfileRepository.UseLease suppliedLease) throws IOException { + try { beginOperation(operations, profileId, cancellation); } + catch (IOException | RuntimeException error) { + if (suppliedLease != null) suppliedLease.close(); + throw error; + } + ProfileRepository.UseLease lease = suppliedLease; + ClientLaunch prepared = null; + Process process = null; + try { + if (lease == null) lease = profiles.acquireUse(profileId); + Profile profile = profiles.require(profileId); // Read metadata only after acquiring its lease. + if (profile.getType() != ProfileType.MODS || profile.isTemplate()) throw new IOException("Select a non-template client-mod profile"); + ServerAddress server = serverAddress == null || serverAddress.isEmpty() ? null : new ServerAddress(serverAddress); + prepared = preparation.prepare(launcher(), launchVersion(profile), profile.getDirectory(), session(cancellation), + server == null ? null : server.getHost(), server == null ? 25565 : server.getPort(), cancellation); + if (prepared == null) throw new IOException("Launch preparation returned no handle"); + synchronized (lifecycle) { + ensureOpen(); cancellation.check(); + if (running.putIfAbsent(profileId, prepared) != null) throw new IOException("This client profile is already starting or running"); + } + process = prepared.start(); + if (process == null) throw new IOException("Launcher returned no process"); + ensureOpen(); cancellation.check(); + monitorClient(profileId, cancellation, prepared, lease, process); + return process; + } catch (IOException | RuntimeException error) { + if (prepared != null) { + try { prepared.close(); } catch (IOException | RuntimeException cleanup) { + error.addSuppressed(cleanup); + clientFailures.put(profileId, cleanup instanceof IOException ? (IOException) cleanup : new IOException("Client cleanup failed", cleanup)); + } + } + if (process != null && process.isAlive() && prepared != null && lease != null) { + // Cancellation after start can fail to kill an owned process. Preserve its + // handle and profile lease so Stop can retry while the exit monitor waits. + monitorClient(profileId, cancellation, prepared, lease, process); + throw error; + } + if (prepared != null) running.remove(profileId, prepared); + operations.remove(profileId, cancellation); + if (lease != null) lease.close(); throw error; + } + } + + private void monitorClient(String profileId, Cancellation cancellation, ClientLaunch active, + ProfileRepository.UseLease activeLease, Process process) { + Thread monitor = new Thread(() -> { + boolean interrupted = false; + while (process.isAlive()) { + try { process.waitFor(); } + catch (InterruptedException error) { + interrupted = true; + try { active.close(); } + catch (IOException cleanup) { clientFailures.put(profileId, cleanup); } + catch (RuntimeException cleanup) { clientFailures.put(profileId, new IOException("Client cleanup failed", cleanup)); } + } + } + try { active.close(); } + catch (IOException cleanup) { clientFailures.put(profileId, cleanup); } + catch (RuntimeException cleanup) { clientFailures.put(profileId, new IOException("Client cleanup failed", cleanup)); } + finally { + running.remove(profileId, active); operations.remove(profileId, cancellation); activeLease.close(); + if (interrupted) Thread.currentThread().interrupt(); + } + }, "AutoPlug-client-" + profileId); + monitor.setDaemon(true); monitor.start(); + } + + private String launchVersion(Profile profile) throws IOException { + String loader = profile.getLoader().toUpperCase(Locale.ROOT); + if (loader.equals("VANILLA")) return profile.getGameVersion(); + PropertiesLoaderVersion installed = new PropertiesLoaderVersion(profile.getDirectory()); + String version = installed.read(profile.getGameVersion(), loader); + if (version == null) throw new IOException("Install/import the official " + loader + " loader for this profile first"); + return version; + } + + public void recordLoader(String profileId, String versionId) throws IOException { + synchronized (lifecycle) { + ensureOpen(); + synchronized (profiles) { + profiles.requireNotInUse(profileId); + Profile profile = profiles.require(profileId); + new PropertiesLoaderVersion(profile.getDirectory()).write(profile.getGameVersion(), profile.getLoader(), versionId); + } + } + } + + /** One lease covers vendor metadata/installer work and publishing its loader marker. */ + public String installLoader(String profileId, String loaderVersion, Cancellation cancellation) throws IOException { + return loaderOperation(profileId, loaderVersion, cancellation, false); + } + + /** Reuses only a matching marker and complete locally imported loader metadata. */ + public String ensureLoader(String profileId, Cancellation cancellation) throws IOException { + return loaderOperation(profileId, null, cancellation, true); + } + + private String loaderOperation(String profileId, String loaderVersion, Cancellation cancellation, boolean reuse) throws IOException { + beginOperation(operations, profileId, cancellation); + try (ProfileRepository.UseLease lease = profiles.acquireUse(profileId)) { + Profile profile = profiles.require(profileId); + if (profile.getType() != ProfileType.MODS || profile.isTemplate()) throw new IOException("Select a non-template client profile"); + String loader = profile.getLoader().toUpperCase(Locale.ROOT); + cancellation.check(); + if (reuse && !loader.equals("VANILLA")) { + String installed = new PropertiesLoaderVersion(profile.getDirectory()).read(profile.getGameVersion(), loader); + if (installed != null && cachedLoaderMatches(installed, profile.getGameVersion())) return installed; + } + String installed; + if (loader.equals("VANILLA")) installed = profile.getGameVersion(); + else if (loader.equals("FABRIC") || loader.equals("QUILT")) { + String selected = loaderVersion == null || loaderVersion.isEmpty() + ? latestLightweightLoader(loader, profile.getGameVersion(), cancellation) : loaderVersion; + installed = loader.equals("FABRIC") ? metadata.installFabric(profile.getGameVersion(), selected, cancellation) + : metadata.installQuilt(profile.getGameVersion(), selected, cancellation); + } else if (loader.equals("FORGE") || loader.equals("NEOFORGE")) { + installed = loaderInstaller().install(loader, profile.getGameVersion(), loaderVersion, profile.getDirectory(), cancellation); + } else throw new IOException("Unsupported client loader: " + loader); + synchronized (lifecycle) { + ensureOpen(); cancellation.check(); + new PropertiesLoaderVersion(profile.getDirectory()).write(profile.getGameVersion(), loader, installed); + } + return installed; + } finally { operations.remove(profileId, cancellation); } + } + + private boolean cachedLoaderMatches(String installed, String game) throws IOException { + if (!installed.matches("[A-Za-z0-9][A-Za-z0-9._+-]{0,159}")) return false; + Path file = artifacts.resolve("versions/" + installed + "/" + installed + ".json"); + Path marker = file.resolveSibling("autoplug-loader-profile"); + if (!Files.isRegularFile(file, java.nio.file.LinkOption.NOFOLLOW_LINKS) + || !Files.isRegularFile(marker, java.nio.file.LinkOption.NOFOLLOW_LINKS) + || Files.size(file) > 8 * 1024 * 1024) return false; + if (!file.toRealPath().equals(file.toAbsolutePath().normalize()) + || !marker.toRealPath().equals(marker.toAbsolutePath().normalize())) throw new IOException("Linked loader metadata is not supported"); + try { + com.google.gson.JsonObject json = com.google.gson.JsonParser.parseString(new String(Files.readAllBytes(file), java.nio.charset.StandardCharsets.UTF_8)).getAsJsonObject(); + return installed.equals(json.get("id").getAsString()) && game.equals(json.get("inheritsFrom").getAsString()) + && !json.get("mainClass").getAsString().trim().isEmpty() && json.get("libraries").isJsonArray(); + } catch (RuntimeException invalid) { return false; } + } + + public OfficialLoaderInstaller loaderInstaller() { + return new OfficialLoaderInstaller(artifacts, metadata, transport, + new JavaRuntimeSelector(settings.javaHomes(workingDirectory), System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("win"))); + } + + private String latestLightweightLoader(String loader, String game, Cancellation cancellation) throws IOException { + String prefix = loader.equals("FABRIC") ? "https://meta.fabricmc.net/v2/versions/loader/" : "https://meta.quiltmc.org/v3/versions/loader/"; + Path temporary = Files.createTempFile(dataRoot, ".loader-list-", ".json"); + try { + transport.download(java.net.URI.create(prefix + game), temporary, cancellation); + cancellation.check(); + if (Files.size(temporary) > 2 * 1024 * 1024) throw new IOException("Loader version list exceeds limit"); + com.google.gson.JsonElement json = com.google.gson.JsonParser.parseString(new String(Files.readAllBytes(temporary), java.nio.charset.StandardCharsets.UTF_8)); + if (!json.isJsonArray()) throw new IOException("Invalid official loader version list"); + for (com.google.gson.JsonElement item : json.getAsJsonArray()) { + com.google.gson.JsonObject details = item.getAsJsonObject().getAsJsonObject("loader"); + String version = details.get("version").getAsString(); + boolean stable = details.has("stable") ? details.get("stable").getAsBoolean() + : !version.toLowerCase(Locale.ROOT).matches(".*(?:alpha|beta|snapshot|rc).*?"); + if (stable && version.matches("[A-Za-z0-9][A-Za-z0-9._+-]{0,199}")) return version; + } + throw new IOException("No stable official loader version found for this Minecraft version"); + } catch (RuntimeException error) { throw new IOException("Invalid official loader version list", error); } + finally { Files.deleteIfExists(temporary); } + } + + public void stopClient(String profileId) throws IOException { + IOException failure = cancel(operations.get(profileId), null); + ClientLaunch prepared = running.get(profileId); + try { if (prepared != null) prepared.close(); } + catch (IOException error) { failure = combine(failure, error); } + if (failure != null) throw failure; + } + + public WorldSessionManager.Session playWorld(String worldId, String clientProfileId, Path serverJar, + boolean share, Cancellation cancellation) throws IOException, InterruptedException { + beginOperation(worldOperations, worldId, cancellation); + ProfileRepository.UseLease serverLease = null, clientLease = null; + boolean worldStartInvoked = false; + java.util.concurrent.atomic.AtomicBoolean clientHandedOff = new java.util.concurrent.atomic.AtomicBoolean(); + try { + World world = worlds.find(worldId).orElseThrow(() -> new IOException("Unknown world: " + worldId)); + WorldRepository.requireEulaAccepted(world); + serverLease = profiles.acquireUse(world.getProfileId()); + clientLease = profiles.acquireUse(clientProfileId); + Profile serverProfile = profiles.require(world.getProfileId()); + Profile clientProfile = profiles.require(clientProfileId); + validateWorldProfiles(serverProfile, clientProfile); + Path jar = validatedServerJar(serverJar); + com.google.gson.JsonObject version = metadata.version(serverProfile.getGameVersion(), cancellation); + int javaMajor = version.has("javaVersion") ? version.getAsJsonObject("javaVersion").get("majorVersion").getAsInt() : 8; + Path java = new JavaRuntimeSelector(settings.javaHomes(workingDirectory), System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("win")).select(javaMajor); + List command = new ArrayList<>(); command.add(java.toString()); command.add("-jar"); command.add(jar.toString()); + final ProfileRepository.UseLease clientUse = clientLease, serverUse = serverLease; + ensureOpen(); cancellation.check(); + worldStartInvoked = true; + WorldSessionManager.Session started = worldSessions.start(world, command, + (host, port) -> { + // From this call onward launchWithLease owns release, including its + // retained exit monitor when cancellation cannot terminate a client. + clientHandedOff.set(true); + Process process = launchWithLease(clientProfileId, host + ":" + port, cancellation, clientUse); + return process; + }, share, cancellation::isCancelled); + started.onClose(() -> { serverUse.close(); worldOperations.remove(worldId, cancellation); }); + return started; + } catch (IOException | InterruptedException | RuntimeException error) { + WorldSessionManager.Session pending = worldStartInvoked ? worldSessions.getActive(worldId) : null; + if (pending != null && serverLease != null) { + final ProfileRepository.UseLease retained = serverLease; + pending.onClose(() -> { retained.close(); worldOperations.remove(worldId, cancellation); }); + } else { + if (serverLease != null) serverLease.close(); + worldOperations.remove(worldId, cancellation); + } + if (clientLease != null && !clientHandedOff.get()) clientLease.close(); + throw error; + } + } + + public static final class WorldLaunchConfig { + private final String clientProfileId; + private final Path serverJar; + WorldLaunchConfig(String clientProfileId, Path serverJar) { this.clientProfileId = clientProfileId; this.serverJar = serverJar; } + public String getClientProfileId() { return clientProfileId; } + public Path getServerJar() { return serverJar; } + } + + public void configureWorld(String worldId, String clientProfileId, Path serverJar) throws IOException { + synchronized (lifecycle) { + ensureOpen(); + if (worldOperations.containsKey(worldId) || worldSessions.getActive(worldId) != null) throw new IOException("Stop this world before changing launch settings"); + // WorldRepository itself takes its monitor before consulting profiles. + // Do not invert that lock order by holding profiles while finding a world. + World world = worlds.find(worldId).orElseThrow(() -> new IOException("Unknown world: " + worldId)); + synchronized (profiles) { + validateWorldProfiles(profiles.require(world.getProfileId()), profiles.require(clientProfileId)); + java.util.Properties values = new java.util.Properties(); + values.setProperty("formatVersion", "1"); values.setProperty("clientProfileId", clientProfileId); + values.setProperty("serverJar", validatedServerJar(serverJar).toString()); + new ClientStorage().write(world.getDirectory().resolve("world-launch.properties"), values); + } + } + } + + public WorldLaunchConfig worldConfig(String worldId) throws IOException { + ensureOpen(); + World world = worlds.find(worldId).orElseThrow(() -> new IOException("Unknown world: " + worldId)); + java.util.Properties values = new ClientStorage().read(world.getDirectory().resolve("world-launch.properties")); + if (!"1".equals(values.getProperty("formatVersion"))) throw new IOException("Configure this world's client profile and installed server jar first"); + String client = values.getProperty("clientProfileId"); + try { + validateWorldProfiles(profiles.require(world.getProfileId()), profiles.require(client)); + return new WorldLaunchConfig(client, validatedServerJar(Paths.get(values.getProperty("serverJar")))); + } catch (IllegalArgumentException | NullPointerException error) { throw new IOException("Invalid world launch settings", error); } + } + + public WorldSessionManager.Session playWorld(String worldId, boolean share, Cancellation cancellation) throws IOException, InterruptedException { + WorldLaunchConfig config = worldConfig(worldId); + return playWorld(worldId, config.getClientProfileId(), config.getServerJar(), share, cancellation); + } + + private void validateWorldProfiles(Profile server, Profile client) throws IOException { + if (client.getType() != ProfileType.MODS || client.isTemplate()) throw new IOException("Select a non-template client profile for this world"); + if (!server.getGameVersion().equals(client.getGameVersion())) throw new IOException("Client and server profiles must use the same Minecraft version"); + if (server.getType() == ProfileType.MODS_SERVER && !server.getLoader().equalsIgnoreCase(client.getLoader())) { + throw new IOException("Client and modded-server profiles must use the same loader"); + } + } + + private Path validatedServerJar(Path jar) throws IOException { + if (jar == null || !Files.isRegularFile(jar, java.nio.file.LinkOption.NOFOLLOW_LINKS)) throw new IOException("Select the official installed server jar for this world"); + return jar.toRealPath(); + } + + public WorldSessionManager.Session activeWorld(String id) { return worldSessions.getActive(id); } + public void stopWorld(String id) throws IOException { + IOException failure = cancel(worldOperations.get(id), null); + try { worldSessions.cancel(id); } catch (IOException error) { failure = combine(failure, error); } + if (failure != null) throw failure; + } + + public IOException lastClientFailure(String profileId) { return clientFailures.get(profileId); } + + @Override public void close() throws IOException { + synchronized (lifecycle) { closed = true; authentication = null; } + IOException failure = null; + for (Cancellation operation : operations.values()) failure = cancel(operation, failure); + for (Cancellation operation : worldOperations.values()) failure = cancel(operation, failure); + try { worldSessions.close(); } catch (IOException error) { failure = combine(failure, error); } + for (ClientLaunch launch : running.values()) { + try { launch.close(); } catch (IOException error) { if (failure == null) failure = error; else failure.addSuppressed(error); } + catch (RuntimeException error) { failure = combine(failure, new IOException("Client cleanup failed", error)); } + } + if (failure != null) throw failure; + } + + private void ensureOpen() throws IOException { if (closed) throw new IOException("Client context is closed"); } + + private void beginOperation(ConcurrentHashMap registry, String id, Cancellation cancellation) throws IOException { + java.util.Objects.requireNonNull(cancellation, "cancellation"); + synchronized (lifecycle) { + ensureOpen(); cancellation.check(); + if (id == null || registry.putIfAbsent(id, cancellation) != null) throw new IOException("This profile/world already has an active operation"); + clientFailures.remove(id); + } + } + + private IOException cancel(Cancellation cancellation, IOException failure) { + try { if (cancellation != null) cancellation.cancel(); } + catch (RuntimeException error) { failure = combine(failure, new IOException("Cancellation callback failed", error)); } + return failure; + } + + private IOException combine(IOException first, IOException next) { + if (first == null) return next; + if (first != next) first.addSuppressed(next); + return first; + } + + private final class PropertiesLoaderVersion { + private final Path file; + PropertiesLoaderVersion(Path profile) { file = profile.resolve("loader.properties"); } + String read(String game, String loader) throws IOException { + java.util.Properties properties = new ClientStorage().read(file); + return game.equals(properties.getProperty("game")) && loader.equalsIgnoreCase(properties.getProperty("loader")) ? properties.getProperty("versionId") : null; + } + void write(String game, String loader, String versionId) throws IOException { + if (versionId == null || !versionId.matches("[A-Za-z0-9][A-Za-z0-9._+-]{0,199}")) throw new IOException("Invalid installed loader version ID"); + java.util.Properties properties = new java.util.Properties(); properties.setProperty("game", game); properties.setProperty("loader", loader); properties.setProperty("versionId", versionId); + new ClientStorage().write(file, properties); + } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/mc/client/SavedServerRepository.java b/src/main/java/com/osiris/autoplug/client/mc/client/SavedServerRepository.java new file mode 100644 index 00000000..672e7f07 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/mc/client/SavedServerRepository.java @@ -0,0 +1,98 @@ +package com.osiris.autoplug.client.mc.client; + +import com.osiris.autoplug.client.mc.VanillaServerListReader; +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Properties; +import java.util.UUID; + +/** Own saved-server list. Import never rewrites Minecraft's servers.dat. */ +public final class SavedServerRepository { + public static final class SavedServer { + private final String id, name, address, loader; + SavedServer(String id, String name, String address, String loader) { + if (id == null || !id.matches("[a-z0-9-]{1,80}")) throw new IllegalArgumentException("Invalid server ID"); + if (name == null || name.trim().isEmpty() || name.length() > 200 || name.chars().anyMatch(Character::isISOControl)) throw new IllegalArgumentException("A server name is required without control characters"); + if (loader == null || !loader.matches("[A-Za-z0-9_-]{1,40}")) throw new IllegalArgumentException("Select an explicit loader"); + this.id = id; this.name = name; this.address = new ServerAddress(address).toString(); this.loader = loader; + } + public String getId() { return id; } + public String getName() { return name; } + public String getAddress() { return address; } + public String getLoader() { return loader; } + @Override public String toString() { return name + " (" + address + ")"; } + } + + private final Path file; + private final ClientStorage storage = new ClientStorage(); + public SavedServerRepository(Path dataRoot) { file = dataRoot.resolve("saved-servers.properties"); } + + public synchronized List list() throws IOException { + Properties values = storage.read(file); + List result = new ArrayList<>(); + try { + if (values.containsKey("formatVersion") && !"1".equals(values.getProperty("formatVersion"))) throw new IllegalArgumentException("Unsupported saved-server format"); + if (!values.isEmpty() && !values.containsKey("count")) throw new IllegalArgumentException("Missing saved-server count"); + int count = Integer.parseInt(values.getProperty("count", "0")); + if (count < 0 || count > 10000) throw new IllegalArgumentException("Invalid saved-server count"); + java.util.Set ids = new java.util.HashSet<>(); + for (int i = 0; i < count; i++) { + String prefix = "server." + i + "."; + result.add(new SavedServer(values.getProperty(prefix + "id"), values.getProperty(prefix + "name"), + values.getProperty(prefix + "address"), values.getProperty(prefix + "loader"))); + if (!ids.add(result.get(result.size() - 1).id)) throw new IllegalArgumentException("Duplicate saved-server ID"); + } + } catch (IllegalArgumentException error) { throw new IOException("Invalid AutoPlug saved-server list", error); } + return Collections.unmodifiableList(result); + } + + public synchronized SavedServer add(String name, String address, String loader) throws IOException { + List entries = new ArrayList<>(list()); + SavedServer entry = new SavedServer(UUID.randomUUID().toString(), name, address, loader); + entries.add(entry); save(entries); return entry; + } + + public synchronized void update(String id, String name, String address, String loader) throws IOException { + List entries = new ArrayList<>(list()); + for (int i = 0; i < entries.size(); i++) if (entries.get(i).id.equals(id)) { + entries.set(i, new SavedServer(id, name, address, loader)); save(entries); return; + } + throw new IOException("Unknown saved server: " + id); + } + + public synchronized void delete(String id) throws IOException { + List entries = new ArrayList<>(list()); + if (!entries.removeIf(entry -> entry.id.equals(id))) throw new IOException("Unknown saved server: " + id); + save(entries); + } + + public synchronized int importVanilla(Path path, String loader) throws IOException { + List entries = new ArrayList<>(list()); + int added = 0; + for (VanillaServerListReader.ServerEntry entry : new VanillaServerListReader().read(path)) { + String address; + try { address = new ServerAddress(entry.getAddress()).toString(); } + catch (IllegalArgumentException error) { throw new IOException("Invalid imported Minecraft server address", error); } + if (entries.stream().anyMatch(existing -> existing.address.equals(address))) continue; + try { entries.add(new SavedServer(UUID.randomUUID().toString(), entry.getName(), address, loader)); } + catch (IllegalArgumentException error) { throw new IOException("Invalid imported Minecraft server metadata", error); } + added++; + } + if (added > 0) save(entries); + return added; + } + + private void save(List entries) throws IOException { + if (entries.size() > 10000) throw new IOException("Saved-server limit reached"); + Properties values = new Properties(); values.setProperty("formatVersion", "1"); values.setProperty("count", Integer.toString(entries.size())); + for (int i = 0; i < entries.size(); i++) { + SavedServer entry = entries.get(i); String prefix = "server." + i + "."; + values.setProperty(prefix + "id", entry.id); values.setProperty(prefix + "name", entry.name); + values.setProperty(prefix + "address", entry.address); values.setProperty(prefix + "loader", entry.loader); + } + storage.write(file, values); + } +} diff --git a/src/main/java/com/osiris/autoplug/client/mc/client/ServerAddress.java b/src/main/java/com/osiris/autoplug/client/mc/client/ServerAddress.java new file mode 100644 index 00000000..d86f15a7 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/mc/client/ServerAddress.java @@ -0,0 +1,91 @@ +package com.osiris.autoplug.client.mc.client; + +import java.net.IDN; + +/** Parses addresses without DNS resolution or a network connection. */ +public final class ServerAddress { + private final String host; + private final int port; + + public ServerAddress(String address) { + if (address == null || address.isEmpty() || address.length() > 512 || !address.equals(address.trim()) + || address.chars().anyMatch(c -> Character.isWhitespace(c) || Character.isISOControl(c)) + || address.contains("/") || address.contains("\\") || address.contains("@")) { + throw new IllegalArgumentException("Use a Minecraft host, host:port or [IPv6]:port"); + } + String name = address; + String portText = null; + if (address.startsWith("[")) { + int end = address.indexOf(']'); + if (end < 2) throw new IllegalArgumentException("Invalid bracketed IPv6 address"); + name = address.substring(1, end); + String suffix = address.substring(end + 1); + if (!suffix.isEmpty()) { + if (!suffix.startsWith(":")) throw new IllegalArgumentException("Invalid IPv6 port"); + portText = suffix.substring(1); + } + if (!name.contains(":")) throw new IllegalArgumentException("Brackets require an IPv6 address"); + } else { + long colons = address.chars().filter(c -> c == ':').count(); + if (colons == 1) { + int separator = address.indexOf(':'); + name = address.substring(0, separator); + portText = address.substring(separator + 1); + } + } + if (name.contains(":")) { + if (!validIpv6(name)) throw new IllegalArgumentException("Invalid IPv6 literal"); + name = name.toLowerCase(java.util.Locale.ROOT); + } else { + name = IDN.toASCII(name); + if (name.endsWith(".")) name = name.substring(0, name.length() - 1); + if (name.isEmpty() || name.length() > 253) throw new IllegalArgumentException("Invalid Minecraft host"); + for (String label : name.split("\\.", -1)) { + if (!label.matches("[A-Za-z0-9](?:[A-Za-z0-9_-]{0,61}[A-Za-z0-9])?")) throw new IllegalArgumentException("Invalid Minecraft host"); + } + if (name.matches("[0-9.]+") && !validIpv4(name)) throw new IllegalArgumentException("Invalid IPv4 literal"); + name = name.toLowerCase(java.util.Locale.ROOT); + } + int parsed = 25565; + if (portText != null) { + if (!portText.matches("[0-9]{1,5}")) throw new IllegalArgumentException("Invalid Minecraft port"); + parsed = Integer.parseInt(portText); + if (parsed < 1 || parsed > 65535) throw new IllegalArgumentException("Minecraft port must be between 1 and 65535"); + } + this.host = name; + this.port = parsed; + } + + public String getHost() { return host; } + public int getPort() { return port; } + @Override public String toString() { return (host.contains(":") ? "[" + host + "]" : host) + ":" + port; } + + private static boolean validIpv4(String literal) { + String[] parts = literal.split("\\.", -1); + if (parts.length != 4) return false; + for (String part : parts) if (!part.matches("[0-9]{1,3}") || Integer.parseInt(part) > 255) return false; + return true; + } + + private static boolean validIpv6(String literal) { + if (!literal.matches("[0-9a-fA-F:.]+") || literal.contains(":::")) return false; + if (literal.startsWith(":") && !literal.startsWith("::") || literal.endsWith(":") && !literal.endsWith("::")) return false; + int compression = literal.indexOf("::"); + if (compression >= 0 && compression != literal.lastIndexOf("::")) return false; + if (compression < 0 && (literal.startsWith(":") || literal.endsWith(":"))) return false; + String[] groups = literal.split(":", -1); + int count = 0; + for (int i = 0; i < groups.length; i++) { + String group = groups[i]; + if (group.isEmpty()) continue; + if (group.contains(".")) { + if (i != groups.length - 1 || !validIpv4(group)) return false; + count += 2; + } else { + if (!group.matches("[0-9a-fA-F]{1,4}")) return false; + count++; + } + } + return compression >= 0 ? count < 8 : count == 8; + } +} From 0ed40e3e902be27ba6c1eadbed37b256008250c5 Mon Sep 17 00:00:00 2001 From: elcuervo171234 <43775780+elcuervo171234@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:20:47 -0400 Subject: [PATCH 05/23] feat(minecraft): integrate launch (5/23) --- .../client/mc/launch/ArtifactCache.java | 112 +++++++ .../client/mc/launch/Cancellation.java | 45 +++ .../client/mc/launch/HttpLaunchTransport.java | 95 ++++++ .../client/mc/launch/JavaRuntimeSelector.java | 74 ++++ .../client/mc/launch/LaunchFiles.java | 25 ++ .../client/mc/launch/LaunchPlatform.java | 52 +++ .../client/mc/launch/LaunchTransport.java | 14 + .../mc/launch/MicrosoftDeviceCodeAuth.java | 209 ++++++++++++ .../client/mc/launch/MinecraftLauncher.java | 316 ++++++++++++++++++ .../client/mc/launch/MinecraftSession.java | 34 ++ .../client/mc/launch/MojangMetadata.java | 155 +++++++++ .../mc/launch/OfficialLoaderInstaller.java | 175 ++++++++++ .../client/mc/launch/PreparedLaunch.java | 81 +++++ 13 files changed, 1387 insertions(+) create mode 100644 src/main/java/com/osiris/autoplug/client/mc/launch/ArtifactCache.java create mode 100644 src/main/java/com/osiris/autoplug/client/mc/launch/Cancellation.java create mode 100644 src/main/java/com/osiris/autoplug/client/mc/launch/HttpLaunchTransport.java create mode 100644 src/main/java/com/osiris/autoplug/client/mc/launch/JavaRuntimeSelector.java create mode 100644 src/main/java/com/osiris/autoplug/client/mc/launch/LaunchFiles.java create mode 100644 src/main/java/com/osiris/autoplug/client/mc/launch/LaunchPlatform.java create mode 100644 src/main/java/com/osiris/autoplug/client/mc/launch/LaunchTransport.java create mode 100644 src/main/java/com/osiris/autoplug/client/mc/launch/MicrosoftDeviceCodeAuth.java create mode 100644 src/main/java/com/osiris/autoplug/client/mc/launch/MinecraftLauncher.java create mode 100644 src/main/java/com/osiris/autoplug/client/mc/launch/MinecraftSession.java create mode 100644 src/main/java/com/osiris/autoplug/client/mc/launch/MojangMetadata.java create mode 100644 src/main/java/com/osiris/autoplug/client/mc/launch/OfficialLoaderInstaller.java create mode 100644 src/main/java/com/osiris/autoplug/client/mc/launch/PreparedLaunch.java diff --git a/src/main/java/com/osiris/autoplug/client/mc/launch/ArtifactCache.java b/src/main/java/com/osiris/autoplug/client/mc/launch/ArtifactCache.java new file mode 100644 index 00000000..b7a3cd8a --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/mc/launch/ArtifactCache.java @@ -0,0 +1,112 @@ +package com.osiris.autoplug.client.mc.launch; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** Files become visible only after a complete download and expected SHA-1/size verification. */ +public final class ArtifactCache { + private final Path root; + private final LaunchTransport transport; + + public ArtifactCache(Path root, LaunchTransport transport) throws IOException { + Files.createDirectories(root); + this.root = root.toRealPath(); + this.transport = transport; + } + + public Path getRoot() { return root; } + + /** Maven-style loader metadata may publish a checksum sidecar instead of embedding it. */ + public Path obtainWithSidecar(String relative, URI url, Cancellation cancellation) throws IOException { + Path installed = installedLibrary(relative); + if (installed != null) return installed; + Path checksum = Files.createTempFile(root, ".checksum-", ".txt"); + try { + transport.download(URI.create(url.toString() + ".sha1"), checksum, cancellation); + if (Files.size(checksum) > 4096) throw new IOException("Invalid library checksum sidecar"); + String text = new String(Files.readAllBytes(checksum), java.nio.charset.StandardCharsets.US_ASCII).trim(); + String expected = text.split("\\s+")[0]; + return obtain(relative, url, expected, -1, cancellation); + } finally { + Files.deleteIfExists(checksum); + } + } + + /** A verified official installer may generate libraries that have no public Maven URL. */ + public synchronized void importInstallerLibrary(String relative, Path generated) throws IOException { + Path target = resolve(relative); + Files.createDirectories(target.getParent()); + Files.copy(generated, target, StandardCopyOption.REPLACE_EXISTING); + Files.write(target.resolveSibling(target.getFileName() + ".autoplug-sha1"), + sha1(target).getBytes(java.nio.charset.StandardCharsets.US_ASCII)); + } + + public Path installedLibrary(String relative) throws IOException { + Path target = resolve(relative); + Path checksum = target.resolveSibling(target.getFileName() + ".autoplug-sha1"); + if (!Files.isRegularFile(target) || !Files.isRegularFile(checksum)) return null; + if (Files.size(checksum) != 40) throw new IOException("Invalid installer library checksum"); + String hash = new String(Files.readAllBytes(checksum), java.nio.charset.StandardCharsets.US_ASCII); + if (!valid(target, hash, -1)) throw new IOException("Installed loader library changed after installation"); + return target; + } + + public Path resolve(String relative) throws IOException { + Path target = root.resolve(relative).normalize(); + if (!target.startsWith(root) || target.equals(root)) throw new IOException("Invalid cache path"); + return target; + } + + public synchronized Path obtain(String relative, URI url, String sha1, long size, + Cancellation cancellation) throws IOException { + cancellation.check(); + Path target = resolve(relative); + if (Files.isRegularFile(target) && valid(target, sha1, size)) return target; + Files.createDirectories(target.getParent()); + Path temporary = Files.createTempFile(target.getParent(), ".download-", ".part"); + try { + transport.download(url, temporary, cancellation); + cancellation.check(); + if (!valid(temporary, sha1, size)) throw new IOException("Artifact checksum or size mismatch: " + relative); + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException error) { + Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING); + } + return target; + } finally { + Files.deleteIfExists(temporary); + } + } + + public boolean valid(Path file, String expectedSha1, long size) throws IOException { + if (size >= 0 && Files.size(file) != size) return false; + if (expectedSha1 == null || !expectedSha1.matches("[a-fA-F0-9]{40}")) { + throw new IOException("An expected SHA-1 is required for official Minecraft artifacts"); + } + return expectedSha1.equalsIgnoreCase(sha1(file)); + } + + public String sha1(Path file) throws IOException { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-1"); + try (InputStream input = Files.newInputStream(file)) { + byte[] buffer = new byte[65536]; + int length; + while ((length = input.read(buffer)) != -1) digest.update(buffer, 0, length); + } + StringBuilder hex = new StringBuilder(); + for (byte value : digest.digest()) hex.append(String.format("%02x", value & 255)); + return hex.toString(); + } catch (NoSuchAlgorithmException error) { + throw new IOException("SHA-1 unavailable", error); + } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/mc/launch/Cancellation.java b/src/main/java/com/osiris/autoplug/client/mc/launch/Cancellation.java new file mode 100644 index 00000000..9d411070 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/mc/launch/Cancellation.java @@ -0,0 +1,45 @@ +package com.osiris.autoplug.client.mc.launch; + +import java.io.InterruptedIOException; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; + +/** One cancellation scope for downloads, authentication and the child game process. */ +public final class Cancellation { + private final AtomicBoolean cancelled = new AtomicBoolean(); + private final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList<>(); + + public void check() throws InterruptedIOException { + if (cancelled.get() || Thread.currentThread().isInterrupted()) { + throw new InterruptedIOException("Minecraft operation cancelled"); + } + } + + public boolean isCancelled() { return cancelled.get(); } + + public AutoCloseable onCancel(Runnable listener) { + Registration registration = new Registration(java.util.Objects.requireNonNull(listener)); + listeners.add(registration); + if (cancelled.get()) { registration.fire(); listeners.remove(registration); } + return () -> { registration.done.set(true); listeners.remove(registration); }; + } + + public void cancel() { + if (cancelled.compareAndSet(false, true)) { + for (Registration listener : listeners) listener.fire(); + listeners.clear(); + } + } + + private final class Registration { + private final AtomicBoolean done = new AtomicBoolean(); + private final Runnable listener; + private Registration(Runnable listener) { this.listener = listener; } + private void fire() { + if (done.compareAndSet(false, true)) { + try { listener.run(); } + catch (RuntimeException ignored) { /* One failed cleanup must not prevent the others. */ } + } + } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/mc/launch/HttpLaunchTransport.java b/src/main/java/com/osiris/autoplug/client/mc/launch/HttpLaunchTransport.java new file mode 100644 index 00000000..2cbf22ff --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/mc/launch/HttpLaunchTransport.java @@ -0,0 +1,95 @@ +package com.osiris.autoplug.client.mc.launch; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +/** HTTPS transport with bounded JSON responses, timeouts, streaming files and cancellation. */ +public final class HttpLaunchTransport implements LaunchTransport { + @Override + public JsonObject json(String method, URI uri, Map headers, byte[] body, + Cancellation cancellation) throws IOException { + HttpURLConnection connection = connect(uri, cancellation); + try (AutoCloseable ignored = cancellation.onCancel(connection::disconnect)) { + connection.setRequestMethod(method); + for (Map.Entry entry : headers.entrySet()) { + connection.setRequestProperty(entry.getKey(), entry.getValue()); + } + if (body != null) { + connection.setDoOutput(true); + try (OutputStream output = connection.getOutputStream()) { output.write(body); } + } + int status = connection.getResponseCode(); + InputStream response = status >= 200 && status < 300 + ? connection.getInputStream() : connection.getErrorStream(); + if (response == null) throw new IOException("HTTP request failed (" + status + ")"); + try (InputStream input = response; ByteArrayOutputStream output = new ByteArrayOutputStream()) { + byte[] buffer = new byte[8192]; + int length; + while ((length = input.read(buffer)) != -1) { + cancellation.check(); + if (output.size() + length > 8 * 1024 * 1024) throw new IOException("JSON response too large"); + output.write(buffer, 0, length); + } + JsonObject result = JsonParser.parseString(new String(output.toByteArray(), StandardCharsets.UTF_8)).getAsJsonObject(); + if (status < 200 || status >= 300) { + // OAuth polling errors must be interpreted by the caller; never log response bodies/tokens. + if (result.has("error")) return result; + throw new IOException("HTTP request failed (" + status + ")"); + } + return result; + } + } catch (IOException error) { + throw error; + } catch (Exception error) { + throw new IOException("Unable to read JSON response", error); + } finally { + connection.disconnect(); + } + } + + @Override + public void download(URI uri, Path destination, Cancellation cancellation) throws IOException { + HttpURLConnection connection = connect(uri, cancellation); + try (AutoCloseable ignored = cancellation.onCancel(connection::disconnect)) { + int status = connection.getResponseCode(); + if (status < 200 || status >= 300) throw new IOException("Artifact download failed (" + status + ")"); + try (InputStream input = connection.getInputStream(); OutputStream output = Files.newOutputStream(destination)) { + byte[] buffer = new byte[65536]; + int length; + while ((length = input.read(buffer)) != -1) { + cancellation.check(); + output.write(buffer, 0, length); + } + } + } catch (IOException error) { + throw error; + } catch (Exception error) { + throw new IOException("Artifact download interrupted", error); + } finally { + connection.disconnect(); + } + } + + private HttpURLConnection connect(URI uri, Cancellation cancellation) throws IOException { + cancellation.check(); + if (!"https".equalsIgnoreCase(uri.getScheme()) || uri.getHost() == null || uri.getUserInfo() != null) { + throw new IOException("Minecraft downloads and authentication require HTTPS"); + } + HttpURLConnection connection = (HttpURLConnection) uri.toURL().openConnection(); + connection.setConnectTimeout(15000); + connection.setReadTimeout(30000); + connection.setInstanceFollowRedirects(false); + connection.setRequestProperty("User-Agent", "AutoPlug-Native-Launcher/10.2.0"); + return connection; + } +} diff --git a/src/main/java/com/osiris/autoplug/client/mc/launch/JavaRuntimeSelector.java b/src/main/java/com/osiris/autoplug/client/mc/launch/JavaRuntimeSelector.java new file mode 100644 index 00000000..e600e7ea --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/mc/launch/JavaRuntimeSelector.java @@ -0,0 +1,74 @@ +package com.osiris.autoplug.client.mc.launch; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; + +/** Reads existing JRE release metadata, including homes supplied by AutoPlug's JRE manager. */ +public final class JavaRuntimeSelector { + private final List homes; + private final boolean windows; + + public JavaRuntimeSelector(List homes, boolean windows) { + this.homes = new ArrayList<>(homes); + this.windows = windows; + } + + public Path select(int major) throws IOException { + return select(major, System.getProperty("os.arch")); + } + + public Path select(int major, String requiredArchitecture) throws IOException { + String required = architecture(requiredArchitecture); + for (Path home : candidates()) { + Path release = home.resolve("release"); + Path executable = home.resolve("bin").resolve(windows ? "java.exe" : "java"); + if (!Files.isRegularFile(release) || !Files.isRegularFile(executable)) continue; + Properties properties = new Properties(); + try (InputStream input = Files.newInputStream(release)) { properties.load(input); } + String version = properties.getProperty("JAVA_VERSION", "").replace("\"", ""); + String actualArchitecture = architecture(properties.getProperty("OS_ARCH", "").replace("\"", "")); + if (actualArchitecture.isEmpty() || !actualArchitecture.equals(required)) continue; + String[] parts = version.split("[._+-]"); + try { + int actual = Integer.parseInt(parts[0].equals("1") && parts.length > 1 ? parts[1] : parts[0]); + if (actual == major) return executable; + } catch (NumberFormatException ignored) { /* Not an identifiable runtime. */ } + } + throw new IOException("Minecraft requires Java " + major + " for " + required + "; configure/install a matching JRE with AutoPlug before launching"); + } + + /** AutoPlug extracts vendor archives under its jre directory; macOS archives add Contents/Home. */ + private List candidates() throws IOException { + java.util.LinkedHashSet result = new java.util.LinkedHashSet<>(); + for (Path configured : homes) { + Path home = configured.toAbsolutePath().normalize(); + result.add(home); result.add(home.resolve("Contents/Home")); + if (!Files.isDirectory(home, java.nio.file.LinkOption.NOFOLLOW_LINKS)) continue; + List children = new ArrayList<>(); + try (java.nio.file.DirectoryStream entries = Files.newDirectoryStream(home)) { + int count = 0; + for (Path entry : entries) { + if (++count > 128) throw new IOException("Too many entries in a configured JRE directory; configure the exact Java home"); + if (Files.isDirectory(entry, java.nio.file.LinkOption.NOFOLLOW_LINKS)) children.add(entry); + } + } + children.sort(java.util.Comparator.comparing(path -> path.getFileName().toString())); + for (Path child : children) { result.add(child); result.add(child.resolve("Contents/Home")); } + } + return new ArrayList<>(result); + } + + private String architecture(String value) { + String normalized = value == null ? "" : value.toLowerCase(java.util.Locale.ROOT); + if (normalized.matches("amd64|x86_64|x64")) return "x86_64"; + if (normalized.matches("aarch64|arm64")) return "aarch64"; + if (normalized.matches("x86|i[3-6]86")) return "x86"; + if (normalized.matches("arm|arm32|aarch32")) return "arm"; + return normalized; + } +} diff --git a/src/main/java/com/osiris/autoplug/client/mc/launch/LaunchFiles.java b/src/main/java/com/osiris/autoplug/client/mc/launch/LaunchFiles.java new file mode 100644 index 00000000..4504a3e8 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/mc/launch/LaunchFiles.java @@ -0,0 +1,25 @@ +package com.osiris.autoplug.client.mc.launch; + +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; + +/** Cleanup is limited to the unique native directory created for one prepared launch. */ +final class LaunchFiles { + void removeTree(Path root) throws IOException { + if (!Files.exists(root, LinkOption.NOFOLLOW_LINKS)) return; + Files.walkFileTree(root, new SimpleFileVisitor() { + @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException { + Files.delete(file); return FileVisitResult.CONTINUE; + } + @Override public FileVisitResult postVisitDirectory(Path directory, IOException failure) throws IOException { + if (failure != null) throw failure; + Files.delete(directory); return FileVisitResult.CONTINUE; + } + }); + } +} diff --git a/src/main/java/com/osiris/autoplug/client/mc/launch/LaunchPlatform.java b/src/main/java/com/osiris/autoplug/client/mc/launch/LaunchPlatform.java new file mode 100644 index 00000000..fd88d3d0 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/mc/launch/LaunchPlatform.java @@ -0,0 +1,52 @@ +package com.osiris.autoplug.client.mc.launch; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.io.IOException; +import java.util.Collections; +import java.util.Map; + +/** Mojang library/argument rules, evaluated using an explicit platform for fixture coverage. */ +public final class LaunchPlatform { + private final String name; + private final String architecture; + private final String version; + private final Map features; + + public LaunchPlatform(String name, String architecture, String version, Map features) { + this.name = name; + this.architecture = architecture; + this.version = version; + this.features = Collections.unmodifiableMap(new java.util.HashMap<>(features)); + } + + public String getName() { return name; } + public String getArchitecture() { return architecture; } + public String getNativeBits() { return architecture.contains("64") ? "64" : "32"; } + + public boolean allows(JsonObject object) throws IOException { + if (!object.has("rules")) return true; + boolean allowed = false; + try { + for (JsonElement entry : object.getAsJsonArray("rules")) { + JsonObject rule = entry.getAsJsonObject(); + boolean matches = true; + if (rule.has("os")) { + JsonObject os = rule.getAsJsonObject("os"); + if (os.has("name")) matches &= name.equals(os.get("name").getAsString()); + if (os.has("arch")) matches &= architecture.matches(os.get("arch").getAsString()); + if (os.has("version")) matches &= version.matches(os.get("version").getAsString()); + } + if (rule.has("features")) { + for (Map.Entry feature : rule.getAsJsonObject("features").entrySet()) { + matches &= features.getOrDefault(feature.getKey(), false) == feature.getValue().getAsBoolean(); + } + } + if (matches) allowed = "allow".equals(rule.get("action").getAsString()); + } + return allowed; + } catch (RuntimeException error) { + throw new IOException("Invalid Minecraft platform rule", error); + } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/mc/launch/LaunchTransport.java b/src/main/java/com/osiris/autoplug/client/mc/launch/LaunchTransport.java new file mode 100644 index 00000000..140487a3 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/mc/launch/LaunchTransport.java @@ -0,0 +1,14 @@ +package com.osiris.autoplug.client.mc.launch; + +import com.google.gson.JsonObject; +import java.io.IOException; +import java.net.URI; +import java.nio.file.Path; +import java.util.Map; + +/** Injectable network boundary. Fixture tests implement this without network access. */ +public interface LaunchTransport { + JsonObject json(String method, URI uri, Map headers, byte[] body, + Cancellation cancellation) throws IOException; + void download(URI uri, Path destination, Cancellation cancellation) throws IOException; +} diff --git a/src/main/java/com/osiris/autoplug/client/mc/launch/MicrosoftDeviceCodeAuth.java b/src/main/java/com/osiris/autoplug/client/mc/launch/MicrosoftDeviceCodeAuth.java new file mode 100644 index 00000000..331b9a8c --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/mc/launch/MicrosoftDeviceCodeAuth.java @@ -0,0 +1,209 @@ +package com.osiris.autoplug.client.mc.launch; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Microsoft consumer device flow -> Xbox Live -> XSTS -> Minecraft Services. + * Requires the application's own registered public client ID and Minecraft API access. + * No borrowed client ID, password collection, embedded secret or token file is used. + */ +public final class MicrosoftDeviceCodeAuth { + private final String clientId; + private final LaunchTransport transport; + private final Clock clock; + private final Sleeper sleeper; + + public MicrosoftDeviceCodeAuth(String clientId, LaunchTransport transport) { + this(clientId, transport, Clock.systemUTC(), Thread::sleep); + } + + public MicrosoftDeviceCodeAuth(String clientId, LaunchTransport transport, Clock clock, Sleeper sleeper) { + if (clientId == null || clientId.trim().isEmpty()) { + throw new IllegalArgumentException("Configure a legitimately registered Microsoft public application client ID first"); + } + this.clientId = clientId.trim(); + this.transport = transport; + this.clock = clock; + this.sleeper = sleeper; + } + + public DeviceAuthorization begin(Cancellation cancellation) throws IOException { + Map form = new LinkedHashMap<>(); + form.put("client_id", clientId); + form.put("scope", "XboxLive.signin offline_access"); + JsonObject response = form("https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode", form, cancellation); + rejectError(response); + try { + long expires = response.get("expires_in").getAsLong(); + long interval = response.has("interval") ? response.get("interval").getAsLong() : 5; + if (expires <= 0 || interval <= 0) throw new IOException("Invalid Microsoft device-code expiration"); + URI verification = URI.create(response.get("verification_uri").getAsString()); + if (!"https".equalsIgnoreCase(verification.getScheme())) throw new IOException("Device-code verification requires HTTPS"); + return new DeviceAuthorization(response.get("device_code").getAsString(), response.get("user_code").getAsString(), + verification, clock.instant().plusSeconds(expires), interval); + } catch (RuntimeException error) { + throw new IOException("Invalid Microsoft device authorization response", error); + } + } + + public AuthResult poll(DeviceAuthorization authorization, Cancellation cancellation) throws IOException { + long interval = authorization.intervalSeconds; + while (clock.instant().isBefore(authorization.expiresAt)) { + pause(interval, cancellation); + if (!clock.instant().isBefore(authorization.expiresAt)) break; + Map form = new LinkedHashMap<>(); + form.put("client_id", clientId); + form.put("grant_type", "urn:ietf:params:oauth:grant-type:device_code"); + form.put("device_code", authorization.deviceCode); + JsonObject response = form("https://login.microsoftonline.com/consumers/oauth2/v2.0/token", form, cancellation); + if (response.has("error")) { + String error = response.get("error").getAsString(); + if ("authorization_pending".equals(error)) continue; + if ("slow_down".equals(error)) { interval += 5; continue; } + throw new IOException("Microsoft sign-in was declined or the device code expired; start sign-in again"); + } + return exchange(response, null, cancellation); + } + throw new IOException("Microsoft device code expired; start sign-in again"); + } + + public AuthResult refresh(String refreshToken, Cancellation cancellation) throws IOException { + if (refreshToken == null || refreshToken.isEmpty()) throw new IOException("No refresh token supplied"); + Map form = new LinkedHashMap<>(); + form.put("client_id", clientId); + form.put("grant_type", "refresh_token"); + form.put("refresh_token", refreshToken); + form.put("scope", "XboxLive.signin offline_access"); + JsonObject response = form("https://login.microsoftonline.com/consumers/oauth2/v2.0/token", form, cancellation); + rejectError(response); + return exchange(response, refreshToken, cancellation); + } + + private AuthResult exchange(JsonObject oauth, String previousRefreshToken, Cancellation cancellation) throws IOException { + try { + String microsoftToken = oauth.get("access_token").getAsString(); + String refresh = oauth.has("refresh_token") ? oauth.get("refresh_token").getAsString() : previousRefreshToken; + JsonObject properties = new JsonObject(); + properties.addProperty("AuthMethod", "RPS"); + properties.addProperty("SiteName", "user.auth.xboxlive.com"); + properties.addProperty("RpsTicket", "d=" + microsoftToken); + JsonObject xboxRequest = new JsonObject(); + xboxRequest.add("Properties", properties); + xboxRequest.addProperty("RelyingParty", "http://auth.xboxlive.com"); + xboxRequest.addProperty("TokenType", "JWT"); + JsonObject xbox = post("https://user.auth.xboxlive.com/user/authenticate", xboxRequest, cancellation); + rejectError(xbox); + String userHash = xbox.getAsJsonObject("DisplayClaims").getAsJsonArray("xui").get(0).getAsJsonObject().get("uhs").getAsString(); + JsonObject xstsProperties = new JsonObject(); + xstsProperties.addProperty("SandboxId", "RETAIL"); + JsonArray tokens = new JsonArray(); tokens.add(xbox.get("Token").getAsString()); + xstsProperties.add("UserTokens", tokens); + JsonObject xstsRequest = new JsonObject(); + xstsRequest.add("Properties", xstsProperties); + xstsRequest.addProperty("RelyingParty", "rp://api.minecraftservices.com/"); + xstsRequest.addProperty("TokenType", "JWT"); + JsonObject xsts = post("https://xsts.auth.xboxlive.com/xsts/authorize", xstsRequest, cancellation); + rejectError(xsts); + JsonObject claims = xsts.getAsJsonObject("DisplayClaims").getAsJsonArray("xui").get(0).getAsJsonObject(); + if (!userHash.equals(claims.get("uhs").getAsString())) throw new IOException("Xbox account changed during sign-in"); + JsonObject minecraftRequest = new JsonObject(); + minecraftRequest.addProperty("identityToken", "XBL3.0 x=" + userHash + ";" + xsts.get("Token").getAsString()); + JsonObject minecraft = post("https://api.minecraftservices.com/authentication/login_with_xbox", minecraftRequest, cancellation); + rejectError(minecraft); + String accessToken = minecraft.get("access_token").getAsString(); + Map headers = Collections.singletonMap("Authorization", "Bearer " + accessToken); + JsonObject entitlements = transport.json("GET", URI.create("https://api.minecraftservices.com/entitlements/mcstore"), headers, null, cancellation); + rejectError(entitlements); + boolean ownsJava = false; + for (com.google.gson.JsonElement item : entitlements.getAsJsonArray("items")) { + String name = item.getAsJsonObject().get("name").getAsString(); + if (name.equals("game_minecraft") || name.equals("product_minecraft")) ownsJava = true; + } + if (!ownsJava) throw new IOException("The signed-in account has no Minecraft Java entitlement"); + JsonObject profile = transport.json("GET", URI.create("https://api.minecraftservices.com/minecraft/profile"), headers, null, cancellation); + rejectError(profile); + MinecraftSession session = new MinecraftSession(profile.get("name").getAsString(), profile.get("id").getAsString(), + accessToken, "msa", clientId, claims.has("xid") ? claims.get("xid").getAsString() : ""); + long expires = minecraft.has("expires_in") ? minecraft.get("expires_in").getAsLong() : 3600; + return new AuthResult(session, refresh, clock.instant().plusSeconds(expires)); + } catch (RuntimeException error) { + throw new IOException("Microsoft/Xbox/Minecraft sign-in returned incomplete account data", error); + } + } + + private JsonObject post(String url, JsonObject body, Cancellation cancellation) throws IOException { + return transport.json("POST", URI.create(url), Collections.singletonMap("Content-Type", "application/json"), + body.toString().getBytes(StandardCharsets.UTF_8), cancellation); + } + + private JsonObject form(String url, Map form, Cancellation cancellation) throws IOException { + StringBuilder body = new StringBuilder(); + for (Map.Entry entry : form.entrySet()) { + if (body.length() > 0) body.append('&'); + body.append(URLEncoder.encode(entry.getKey(), "UTF-8")).append('=').append(URLEncoder.encode(entry.getValue(), "UTF-8")); + } + return transport.json("POST", URI.create(url), Collections.singletonMap("Content-Type", "application/x-www-form-urlencoded"), + body.toString().getBytes(StandardCharsets.UTF_8), cancellation); + } + + private void rejectError(JsonObject response) throws IOException { + if (response.has("error") || response.has("XErr")) throw new IOException("Microsoft/Xbox/Minecraft sign-in failed; verify application access and account eligibility"); + } + + private void pause(long seconds, Cancellation cancellation) throws IOException { + long remaining = seconds * 1000; + try { + while (remaining > 0) { + cancellation.check(); + long step = Math.min(100, remaining); + sleeper.sleep(step); + remaining -= step; + } + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + cancellation.check(); + } + } + + public interface Sleeper { void sleep(long milliseconds) throws InterruptedException; } + + public final class DeviceAuthorization { + private final String deviceCode; + private final String userCode; + private final URI verificationUri; + private final Instant expiresAt; + private final long intervalSeconds; + + private DeviceAuthorization(String deviceCode, String userCode, URI verificationUri, Instant expiresAt, long intervalSeconds) { + this.deviceCode = deviceCode; this.userCode = userCode; this.verificationUri = verificationUri; + this.expiresAt = expiresAt; this.intervalSeconds = intervalSeconds; + } + public String getUserCode() { return userCode; } + public URI getVerificationUri() { return verificationUri; } + public Instant getExpiresAt() { return expiresAt; } + @Override public String toString() { return "DeviceAuthorization[code redacted]"; } + } + + public final class AuthResult { + private final MinecraftSession session; + private final String refreshToken; + private final Instant expiresAt; + private AuthResult(MinecraftSession session, String refreshToken, Instant expiresAt) { + this.session = session; this.refreshToken = refreshToken; this.expiresAt = expiresAt; + } + public MinecraftSession getSession() { return session; } + public String getRefreshToken() { return refreshToken; } + public Instant getExpiresAt() { return expiresAt; } + @Override public String toString() { return "AuthResult[tokens redacted]"; } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/mc/launch/MinecraftLauncher.java b/src/main/java/com/osiris/autoplug/client/mc/launch/MinecraftLauncher.java new file mode 100644 index 00000000..e2f56d6d --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/mc/launch/MinecraftLauncher.java @@ -0,0 +1,316 @@ +package com.osiris.autoplug.client.mc.launch; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** Native Mojang launch planning; only PreparedLaunch.start() can start a process. */ +public final class MinecraftLauncher { + private final ArtifactCache cache; + private final MojangMetadata metadata; + private final LaunchPlatform platform; + private final JavaRuntimeSelector runtimes; + + public MinecraftLauncher(ArtifactCache cache, MojangMetadata metadata, LaunchPlatform platform, JavaRuntimeSelector runtimes) { + this.cache = cache; + this.metadata = metadata; + this.platform = platform; + this.runtimes = runtimes; + } + + public MinecraftSession offlineSession(String username) { + if (username == null || !username.matches("[A-Za-z0-9_]{1,16}")) throw new IllegalArgumentException("Invalid offline player name"); + String id = UUID.nameUUIDFromBytes(("OfflinePlayer:" + username).getBytes(StandardCharsets.UTF_8)).toString(); + return new MinecraftSession(username, id, "0", "legacy"); + } + + public PreparedLaunch prepare(String versionId, Path gameDirectory, MinecraftSession session, + String serverHost, int port, Cancellation cancellation) throws IOException { + cancellation.check(); + if (session == null) throw new IOException("A Minecraft session is required"); + if (serverHost != null && (serverHost.trim().isEmpty() || serverHost.matches(".*[\\s\\p{Cntrl}].*") || port < 1 || port > 65535)) { + throw new IOException("Invalid direct-connect server or port"); + } + Files.createDirectories(gameDirectory); + Path game = gameDirectory.toRealPath(); + JsonObject version = metadata.version(versionId, cancellation); + int javaMajor = version.has("javaVersion") ? version.getAsJsonObject("javaVersion").get("majorVersion").getAsInt() : 8; + Path java = runtimes.select(javaMajor, platform.getArchitecture()); + Path internal = game.resolve(".autoplug"); + Files.createDirectories(internal); + Path natives = Files.createTempDirectory(internal, "natives-"); + boolean prepared = false; + try { + Set classpath = libraries(version, natives, cancellation); + String clientVersion = version.has("autoplugClientVersion") ? version.get("autoplugClientVersion").getAsString() : version.get("id").getAsString(); + JsonObject client = version.getAsJsonObject("downloads").getAsJsonObject("client"); + classpath.add(artifact(client, "versions/" + clientVersion + "/" + clientVersion + ".jar", cancellation)); + String assetId = assets(version, game, cancellation); + String separator = platform.getName().equals("windows") ? ";" : ":"; + List classpathStrings = new ArrayList<>(); + for (Path path : classpath) classpathStrings.add(path.toString()); + Map values = new HashMap<>(); + values.put("auth_player_name", session.getUsername()); + values.put("auth_uuid", session.getUuid()); + values.put("auth_access_token", session.getAccessToken()); + values.put("auth_session", "token:" + session.getAccessToken() + ":" + session.getUuid()); + values.put("auth_xuid", session.getXuid()); + values.put("clientid", session.getClientId()); + values.put("user_type", session.getUserType()); + values.put("user_properties", "{}"); + values.put("version_name", versionId); + values.put("version_type", version.has("type") ? version.get("type").getAsString() : "custom"); + values.put("game_directory", game.toString()); + values.put("assets_root", cache.resolve("assets").toString()); + values.put("assets_index_name", assetId); + values.put("game_assets", cache.resolve("assets/virtual/" + assetId).toString()); + values.put("natives_directory", natives.toString()); + values.put("library_directory", cache.resolve("libraries").toString()); + values.put("classpath_separator", separator); + values.put("classpath", String.join(separator, classpathStrings)); + values.put("launcher_name", "AutoPlug"); + values.put("launcher_version", "10.2.0"); + List command = new ArrayList<>(); + command.add(java.toString()); + if (version.has("arguments")) { + addArguments(version.getAsJsonObject("arguments").getAsJsonArray("jvm"), values, command); + } else { + command.add("-Djava.library.path=" + natives); + if (platform.getName().equals("osx")) command.add("-XstartOnFirstThread"); + } + if (command.stream().noneMatch(value -> value.startsWith("-Xmx"))) command.add("-Xmx2G"); + if (!command.contains("-cp") && !command.contains("-classpath") && !command.contains("--class-path")) { + command.add("-cp"); command.add(values.get("classpath")); + } + addLogging(version, command, cancellation); + command.add(version.get("mainClass").getAsString()); + if (version.has("arguments") && version.getAsJsonObject("arguments").has("game")) { + addArguments(version.getAsJsonObject("arguments").getAsJsonArray("game"), values, command); + } else if (version.has("minecraftArguments")) { + for (String token : splitLegacy(version.get("minecraftArguments").getAsString())) command.add(expand(token, values)); + } + if (serverHost != null) { + command.add("--server"); command.add(serverHost); + command.add("--port"); command.add(Integer.toString(port)); + } + cancellation.check(); + PreparedLaunch result = new PreparedLaunch(command, game, cancellation, natives); + prepared = true; + return result; + } catch (RuntimeException error) { + throw new IOException("Invalid Minecraft launch metadata", error); + } finally { + if (!prepared) new LaunchFiles().removeTree(natives); + } + } + + private Set libraries(JsonObject version, Path natives, Cancellation cancellation) throws IOException { + Set classpath = new LinkedHashSet<>(); + Set nativeGroups = new java.util.HashSet<>(); + Set matchingNativeGroups = new java.util.HashSet<>(); + for (JsonElement entry : version.getAsJsonArray("libraries")) { + cancellation.check(); + JsonObject library = entry.getAsJsonObject(); + if (!platform.allows(library)) continue; + String coordinate = library.get("name").getAsString(); + String[] coordinateParts = coordinate.split(":"); + if (coordinateParts.length == 4 && coordinateParts[3].startsWith("natives-")) { + String group = coordinateParts[0] + ":" + coordinateParts[1] + ":" + coordinateParts[2]; + nativeGroups.add(group); + if (!matchesNativeArchitecture(coordinateParts[3])) continue; + matchingNativeGroups.add(group); + } + JsonObject downloads = library.has("downloads") ? library.getAsJsonObject("downloads") : new JsonObject(); + if (downloads.has("artifact")) { + JsonObject artifact = downloads.getAsJsonObject("artifact"); + Path jar = artifact(artifact, "libraries/" + artifact.get("path").getAsString(), cancellation); + classpath.add(jar); + if (coordinateParts.length == 4 && coordinateParts[3].startsWith("natives-")) { + extractNatives(jar, natives, java.util.Collections.singletonList("META-INF/"), cancellation); + } + } else if (!library.has("natives")) { + String relative = coordinatePath(library.get("name").getAsString()); + String base = library.has("url") ? library.get("url").getAsString() : "https://libraries.minecraft.net/"; + Path jar = cache.obtainWithSidecar("libraries/" + relative, + URI.create(base.endsWith("/") ? base + relative : base + "/" + relative), cancellation); + classpath.add(jar); + } + if (library.has("natives") && library.getAsJsonObject("natives").has(platform.getName())) { + String classifier = library.getAsJsonObject("natives").get(platform.getName()).getAsString() + .replace("${arch}", platform.getNativeBits()); + if ((platform.getArchitecture().contains("arm") || platform.getArchitecture().contains("aarch")) + && !classifier.contains("arm") && !classifier.contains("aarch")) { + throw new IOException("This Minecraft version does not provide " + platform.getArchitecture() + " native libraries for " + platform.getName()); + } + if (!downloads.has("classifiers") || !downloads.getAsJsonObject("classifiers").has(classifier)) { + throw new IOException("No Minecraft native library for " + platform.getName() + "/" + platform.getArchitecture()); + } + JsonObject descriptor = downloads.getAsJsonObject("classifiers").getAsJsonObject(classifier); + Path jar = artifact(descriptor, "libraries/" + descriptor.get("path").getAsString(), cancellation); + List exclusions = new ArrayList<>(); + exclusions.add("META-INF/"); + if (library.has("extract") && library.getAsJsonObject("extract").has("exclude")) { + for (JsonElement excluded : library.getAsJsonObject("extract").getAsJsonArray("exclude")) exclusions.add(excluded.getAsString()); + } + extractNatives(jar, natives, exclusions, cancellation); + } + } + if (!matchingNativeGroups.containsAll(nativeGroups)) throw new IOException("Minecraft native libraries do not support " + platform.getArchitecture()); + return classpath; + } + + private boolean matchesNativeArchitecture(String classifier) { + boolean arm = platform.getArchitecture().contains("arm") || platform.getArchitecture().contains("aarch"); + boolean bits64 = platform.getNativeBits().equals("64"); + if (classifier.endsWith("-arm64") || classifier.endsWith("-aarch64")) return arm && bits64; + if (classifier.endsWith("-arm32")) return arm && !bits64; + if (classifier.endsWith("-x86")) return !arm && !bits64; + return !arm && bits64; + } + + private Path artifact(JsonObject descriptor, String relative, Cancellation cancellation) throws IOException { + if (!descriptor.has("sha1") || !descriptor.has("url") || descriptor.get("url").getAsString().isEmpty()) { + Path installed = cache.installedLibrary(relative); + if (installed != null) return installed; + throw new IOException("Loader library must be produced by its official installer first: " + relative); + } + return cache.obtain(relative, URI.create(descriptor.get("url").getAsString()), descriptor.get("sha1").getAsString(), + descriptor.has("size") ? descriptor.get("size").getAsLong() : -1, cancellation); + } + + private String assets(JsonObject version, Path game, Cancellation cancellation) throws IOException { + if (!version.has("assetIndex")) return version.has("assets") ? version.get("assets").getAsString() : "legacy"; + JsonObject descriptor = version.getAsJsonObject("assetIndex"); + String id = descriptor.get("id").getAsString(); + if (!id.matches("[A-Za-z0-9._-]+")) throw new IOException("Invalid asset index ID"); + Path file = artifact(descriptor, "assets/indexes/" + id + ".json", cancellation); + JsonObject index = JsonParser.parseString(new String(Files.readAllBytes(file), StandardCharsets.UTF_8)).getAsJsonObject(); + for (Map.Entry entry : index.getAsJsonObject("objects").entrySet()) { + JsonObject object = entry.getValue().getAsJsonObject(); + String hash = object.get("hash").getAsString(); + if (!hash.matches("[a-f0-9]{40}")) throw new IOException("Invalid asset hash"); + String relative = hash.substring(0, 2) + "/" + hash; + Path cached = cache.obtain("assets/objects/" + relative, URI.create("https://resources.download.minecraft.net/" + relative), + hash, object.get("size").getAsLong(), cancellation); + if (index.has("virtual") && index.get("virtual").getAsBoolean()) copyAsset(cached, cache.resolve("assets/virtual/" + id), entry.getKey()); + if (index.has("map_to_resources") && index.get("map_to_resources").getAsBoolean()) copyAsset(cached, game.resolve("resources"), entry.getKey()); + } + return id; + } + + private void copyAsset(Path source, Path root, String name) throws IOException { + Path target = root.resolve(name).normalize(); + if (!target.startsWith(root) || target.equals(root)) throw new IOException("Invalid asset path"); + Files.createDirectories(target.getParent()); + Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); + } + + private void addLogging(JsonObject version, List command, Cancellation cancellation) throws IOException { + if (!version.has("logging") || !version.getAsJsonObject("logging").has("client")) return; + JsonObject logging = version.getAsJsonObject("logging").getAsJsonObject("client"); + JsonObject file = logging.getAsJsonObject("file"); + Path config = artifact(file, "assets/log_configs/" + file.get("id").getAsString(), cancellation); + command.add(logging.get("argument").getAsString().replace("${path}", config.toString())); + } + + private void addArguments(JsonArray arguments, Map values, List output) throws IOException { + if (arguments == null) return; + for (JsonElement entry : arguments) { + if (entry.isJsonPrimitive()) output.add(expand(entry.getAsString(), values)); + else { + JsonObject conditional = entry.getAsJsonObject(); + if (!platform.allows(conditional)) continue; + JsonElement value = conditional.get("value"); + if (value.isJsonArray()) for (JsonElement token : value.getAsJsonArray()) output.add(expand(token.getAsString(), values)); + else output.add(expand(value.getAsString(), values)); + } + } + } + + private String expand(String text, Map values) throws IOException { + Matcher matcher = Pattern.compile("\\$\\{([^}]+)}").matcher(text); + StringBuffer result = new StringBuffer(); + while (matcher.find()) { + String value = values.get(matcher.group(1)); + if (value == null) throw new IOException("Unsupported launch placeholder: " + matcher.group(1)); + matcher.appendReplacement(result, Matcher.quoteReplacement(value)); + } + matcher.appendTail(result); + return result.toString(); + } + + private List splitLegacy(String text) throws IOException { + List result = new ArrayList<>(); + StringBuilder token = new StringBuilder(); + char quote = 0; + for (char character : text.toCharArray()) { + if ((character == '\'' || character == '"') && (quote == 0 || quote == character)) { + quote = quote == 0 ? character : 0; + } else if (Character.isWhitespace(character) && quote == 0) { + if (token.length() > 0) { result.add(token.toString()); token.setLength(0); } + } else token.append(character); + } + if (quote != 0) throw new IOException("Unclosed legacy launch argument quote"); + if (token.length() > 0) result.add(token.toString()); + return result; + } + + private String coordinatePath(String coordinate) throws IOException { + String[] extension = coordinate.split("@", -1); + String[] parts = extension[0].split(":", -1); + if (parts.length < 3 || parts.length > 4 || extension.length > 2) throw new IOException("Invalid library coordinate"); + String suffix = extension.length == 2 ? extension[1] : "jar"; + for (String part : parts) if (!part.matches("[A-Za-z0-9._+-]+")) throw new IOException("Invalid library coordinate component"); + if (!suffix.matches("[A-Za-z0-9]+")) throw new IOException("Invalid library extension"); + return parts[0].replace('.', '/') + "/" + parts[1] + "/" + parts[2] + "/" + parts[1] + "-" + parts[2] + + (parts.length == 4 ? "-" + parts[3] : "") + "." + suffix; + } + + private void extractNatives(Path archive, Path root, List excluded, Cancellation cancellation) throws IOException { + long total = 0; + int files = 0; + try (InputStream input = Files.newInputStream(archive); ZipInputStream zip = new ZipInputStream(input)) { + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + cancellation.check(); + String name = entry.getName(); + boolean skip = false; + for (String prefix : excluded) if (name.startsWith(prefix)) skip = true; + if (skip || entry.isDirectory()) continue; + if (++files > 8192) throw new IOException("Too many native files"); + Path target = root.resolve(name).normalize(); + if (!target.startsWith(root) || target.equals(root)) throw new IOException("Invalid native extraction path"); + Files.createDirectories(target.getParent()); + try (java.io.OutputStream output = Files.newOutputStream(target)) { + byte[] buffer = new byte[65536]; + int length; + while ((length = zip.read(buffer)) != -1) { + cancellation.check(); + total += length; + if (total > 256L * 1024 * 1024) throw new IOException("Native expansion limit exceeded"); + output.write(buffer, 0, length); + } + } + } + } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/mc/launch/MinecraftSession.java b/src/main/java/com/osiris/autoplug/client/mc/launch/MinecraftSession.java new file mode 100644 index 00000000..9478e0b8 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/mc/launch/MinecraftSession.java @@ -0,0 +1,34 @@ +package com.osiris.autoplug.client.mc.launch; + +/** In-memory session. Tokens are never persisted or included in toString(). */ +public final class MinecraftSession { + private final String username; + private final String uuid; + private final String accessToken; + private final String userType; + private final String clientId; + private final String xuid; + + public MinecraftSession(String username, String uuid, String accessToken, String userType) { + this(username, uuid, accessToken, userType, "", ""); + } + + public MinecraftSession(String username, String uuid, String accessToken, String userType, String clientId, String xuid) { + if (username == null || username.isEmpty() || uuid == null || !uuid.replace("-", "").matches("[a-fA-F0-9]{32}") + || accessToken == null || userType == null) throw new IllegalArgumentException("Invalid Minecraft session"); + this.username = username; + this.uuid = uuid.replace("-", ""); + this.accessToken = accessToken; + this.userType = userType; + this.clientId = clientId; + this.xuid = xuid; + } + + public String getUsername() { return username; } + public String getUuid() { return uuid; } + public String getAccessToken() { return accessToken; } + public String getUserType() { return userType; } + public String getClientId() { return clientId; } + public String getXuid() { return xuid; } + @Override public String toString() { return "MinecraftSession[" + username + "]"; } +} diff --git a/src/main/java/com/osiris/autoplug/client/mc/launch/MojangMetadata.java b/src/main/java/com/osiris/autoplug/client/mc/launch/MojangMetadata.java new file mode 100644 index 00000000..44e47934 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/mc/launch/MojangMetadata.java @@ -0,0 +1,155 @@ +package com.osiris.autoplug.client.mc.launch; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +/** Official Mojang version manifests plus native Fabric/Quilt and installed Forge/NeoForge metadata. */ +public final class MojangMetadata { + private final LaunchTransport transport; + private final ArtifactCache cache; + + public MojangMetadata(LaunchTransport transport, ArtifactCache cache) { + this.transport = transport; + this.cache = cache; + } + + public JsonObject version(String id, Cancellation cancellation) throws IOException { + return version(id, cancellation, new HashSet<>()); + } + + private JsonObject version(String id, Cancellation cancellation, Set visiting) throws IOException { + validateId(id); + cancellation.check(); + if (visiting.size() >= 8 || !visiting.add(id)) throw new IOException("Cyclic or excessive Minecraft version inheritance"); + try { + Path file = cache.resolve("versions/" + id + "/" + id + ".json"); + JsonObject child; + // Local loader profiles have no entry in Mojang's version manifest. + if (Files.isRegularFile(file) && Files.isRegularFile(file.resolveSibling("autoplug-loader-profile"))) { + child = read(file); + } else { + JsonObject manifest = transport.json("GET", URI.create("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"), + Collections.emptyMap(), null, cancellation); + JsonObject selected = null; + for (JsonElement entry : manifest.getAsJsonArray("versions")) { + JsonObject candidate = entry.getAsJsonObject(); + if (id.equals(candidate.get("id").getAsString())) { selected = candidate; break; } + } + if (selected == null) throw new IOException("Unknown official Minecraft version: " + id); + file = cache.obtain("versions/" + id + "/" + id + ".json", URI.create(selected.get("url").getAsString()), + selected.get("sha1").getAsString(), -1, cancellation); + child = read(file); + } + if (!id.equals(child.get("id").getAsString())) throw new IOException("Minecraft version ID mismatch"); + if (!child.has("inheritsFrom")) return child; + JsonObject base = version(child.get("inheritsFrom").getAsString(), cancellation, visiting); + return merge(base, child); + } catch (RuntimeException error) { + throw new IOException("Invalid Minecraft version metadata", error); + } finally { + visiting.remove(id); + } + } + + /** Fetches an official loader profile. This does not authenticate or launch Minecraft. */ + public String installFabric(String gameVersion, String loaderVersion, Cancellation cancellation) throws IOException { + return installRemoteProfile("https://meta.fabricmc.net/v2/versions/loader/", gameVersion, loaderVersion, cancellation); + } + + public String installQuilt(String gameVersion, String loaderVersion, Cancellation cancellation) throws IOException { + return installRemoteProfile("https://meta.quiltmc.org/v3/versions/loader/", gameVersion, loaderVersion, cancellation); + } + + private String installRemoteProfile(String prefix, String gameVersion, String loaderVersion, Cancellation cancellation) throws IOException { + validateId(gameVersion); + validateId(loaderVersion); + JsonObject profile = transport.json("GET", URI.create(prefix + gameVersion + "/" + loaderVersion + "/profile/json"), + Collections.emptyMap(), null, cancellation); + if (!profile.has("inheritsFrom") || !gameVersion.equals(profile.get("inheritsFrom").getAsString())) { + throw new IOException("Loader profile does not inherit the requested Minecraft version"); + } + return saveLoaderProfile(profile); + } + + /** Imports the JSON emitted by the official Forge/NeoForge installer, never GPL launcher code. */ + public String importInstalledLoaderProfile(Path json) throws IOException { + return saveLoaderProfile(read(json)); + } + + private String saveLoaderProfile(JsonObject profile) throws IOException { + try { + String id = profile.get("id").getAsString(); + validateId(id); + if (!profile.has("inheritsFrom") || !profile.has("mainClass") || !profile.has("libraries")) { + throw new IOException("An installed loader profile must declare inheritance, main class and libraries"); + } + validateId(profile.get("inheritsFrom").getAsString()); + Path target = cache.resolve("versions/" + id + "/" + id + ".json"); + Files.createDirectories(target.getParent()); + Files.write(target, profile.toString().getBytes(StandardCharsets.UTF_8)); + Files.write(target.resolveSibling("autoplug-loader-profile"), new byte[0]); + return id; + } catch (RuntimeException error) { + throw new IOException("Invalid loader profile", error); + } + } + + /** Loader libraries override the same group/artifact/classifier; base JVM/game arguments remain. */ + public JsonObject merge(JsonObject parent, JsonObject child) throws IOException { + JsonObject merged = parent.deepCopy(); + for (Map.Entry entry : child.entrySet()) { + if (!entry.getKey().equals("libraries") && !entry.getKey().equals("arguments")) { + merged.add(entry.getKey(), entry.getValue().deepCopy()); + } + } + LinkedHashMap libraries = new LinkedHashMap<>(); + if (parent.has("libraries")) for (JsonElement entry : parent.getAsJsonArray("libraries")) libraries.put(libraryKey(entry), entry.deepCopy()); + if (child.has("libraries")) for (JsonElement entry : child.getAsJsonArray("libraries")) libraries.put(libraryKey(entry), entry.deepCopy()); + JsonArray list = new JsonArray(); + for (JsonElement entry : libraries.values()) list.add(entry); + merged.add("libraries", list); + JsonObject arguments = new JsonObject(); + for (String group : new String[]{"jvm", "game"}) { + JsonArray combined = new JsonArray(); + for (JsonObject source : new JsonObject[]{parent, child}) { + if (source.has("arguments") && source.getAsJsonObject("arguments").has(group)) { + for (JsonElement entry : source.getAsJsonObject("arguments").getAsJsonArray(group)) combined.add(entry.deepCopy()); + } + } + if (combined.size() > 0) arguments.add(group, combined); + } + if (arguments.size() > 0) merged.add("arguments", arguments); + // The inherited client JAR belongs to the vanilla base, not the loader profile ID. + merged.addProperty("autoplugClientVersion", parent.has("autoplugClientVersion") + ? parent.get("autoplugClientVersion").getAsString() : parent.get("id").getAsString()); + return merged; + } + + private String libraryKey(JsonElement entry) throws IOException { + String[] parts = entry.getAsJsonObject().get("name").getAsString().split(":"); + if (parts.length < 3 || parts.length > 4) throw new IOException("Invalid Maven library coordinate"); + return parts[0] + ":" + parts[1] + (parts.length == 4 ? ":" + parts[3] : ""); + } + + private JsonObject read(Path file) throws IOException { + if (Files.size(file) > 8 * 1024 * 1024) throw new IOException("Version metadata too large"); + try { return JsonParser.parseString(new String(Files.readAllBytes(file), StandardCharsets.UTF_8)).getAsJsonObject(); } + catch (RuntimeException error) { throw new IOException("Invalid version JSON", error); } + } + + private void validateId(String id) throws IOException { + if (id == null || !id.matches("[A-Za-z0-9][A-Za-z0-9._+-]{0,159}")) throw new IOException("Invalid version identifier"); + } +} diff --git a/src/main/java/com/osiris/autoplug/client/mc/launch/OfficialLoaderInstaller.java b/src/main/java/com/osiris/autoplug/client/mc/launch/OfficialLoaderInstaller.java new file mode 100644 index 00000000..99373f3e --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/mc/launch/OfficialLoaderInstaller.java @@ -0,0 +1,175 @@ +package com.osiris.autoplug.client.mc.launch; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; +import org.w3c.dom.NodeList; + +/** Runs only checksum-verified Forge/NeoForge vendor installers, in an explicit game directory. */ +public final class OfficialLoaderInstaller { + private final ArtifactCache cache; + private final MojangMetadata metadata; + private final LaunchTransport transport; + private final JavaRuntimeSelector runtimes; + private final ProcessRunner runner; + + public OfficialLoaderInstaller(ArtifactCache cache, MojangMetadata metadata, LaunchTransport transport, JavaRuntimeSelector runtimes) { + this(cache, metadata, transport, runtimes, new InstallerProcessRunner()); + } + + public OfficialLoaderInstaller(ArtifactCache cache, MojangMetadata metadata, LaunchTransport transport, + JavaRuntimeSelector runtimes, ProcessRunner runner) { + this.cache = cache; this.metadata = metadata; this.transport = transport; this.runtimes = runtimes; this.runner = runner; + } + + /** Null loaderVersion selects the vendor's recommended/latest stable release for that game version. */ + public String install(String loader, String gameVersion, String loaderVersion, Path gameDirectory, + Cancellation cancellation) throws IOException { + cancellation.check(); + String brand = loader.toUpperCase(java.util.Locale.ROOT); + if (!brand.equals("FORGE") && !brand.equals("NEOFORGE")) throw new IOException("Expected Forge or NeoForge"); + validateVersion(gameVersion); + String selected = loaderVersion == null || loaderVersion.isEmpty() ? latest(brand, gameVersion, cancellation) : loaderVersion; + validateVersion(selected); + String coordinate; + String base; + if (brand.equals("FORGE")) { + coordinate = "net/minecraftforge/forge/" + gameVersion + "-" + selected + "/forge-" + gameVersion + "-" + selected + "-installer.jar"; + base = "https://maven.minecraftforge.net/"; + } else { + coordinate = "net/neoforged/neoforge/" + selected + "/neoforge-" + selected + "-installer.jar"; + base = "https://maven.neoforged.net/releases/"; + } + Path installer = cache.obtainWithSidecar("installers/" + coordinate, URI.create(base + coordinate), cancellation); + JsonObject vanilla = metadata.version(gameVersion, cancellation); + int major = vanilla.has("javaVersion") ? vanilla.getAsJsonObject("javaVersion").get("majorVersion").getAsInt() : 8; + Path javaExecutable = runtimes.select(major); + Files.createDirectories(gameDirectory); + Path game = gameDirectory.toRealPath(); + Path versions = game.resolve("versions"); + Path baseVersion = versions.resolve(gameVersion); + Files.createDirectories(baseVersion); + Files.write(baseVersion.resolve(gameVersion + ".json"), vanilla.toString().getBytes(StandardCharsets.UTF_8)); + JsonObject client = vanilla.getAsJsonObject("downloads").getAsJsonObject("client"); + Path clientJar = cache.obtain("versions/" + gameVersion + "/" + gameVersion + ".jar", URI.create(client.get("url").getAsString()), + client.get("sha1").getAsString(), client.get("size").getAsLong(), cancellation); + Files.copy(clientJar, baseVersion.resolve(gameVersion + ".jar"), StandardCopyOption.REPLACE_EXISTING); + Path launcherProfiles = game.resolve("launcher_profiles.json"); + if (!Files.exists(launcherProfiles)) Files.write(launcherProfiles, "{\"profiles\":{}}".getBytes(StandardCharsets.UTF_8)); + int exit = runner.run(Arrays.asList(javaExecutable.toString(), "-jar", installer.toString(), "--installClient", game.toString()), game, cancellation); + cancellation.check(); + if (exit != 0) throw new IOException("Official " + brand + " installer failed with exit code " + exit + "; inspect loader-install.log"); + List candidates = new ArrayList<>(); + try (Stream paths = Files.walk(versions, 2)) { + for (Path path : paths.filter(file -> file.getFileName().toString().endsWith(".json")).collect(Collectors.toList())) { + JsonObject profile = readJson(path); + if (profile.has("inheritsFrom") && gameVersion.equals(profile.get("inheritsFrom").getAsString()) + && profile.has("id") && profile.get("id").getAsString().toLowerCase(java.util.Locale.ROOT).contains("forge") + && profile.get("id").getAsString().contains(selected)) candidates.add(path); + } + } + if (candidates.size() != 1) throw new IOException("Installer did not produce one identifiable loader version profile"); + Path libraries = game.resolve("libraries"); + if (Files.isDirectory(libraries)) { + try (Stream paths = Files.walk(libraries)) { + for (Path file : paths.filter(Files::isRegularFile).collect(Collectors.toList())) { + cancellation.check(); + if (file.getFileName().toString().endsWith(".jar")) { + cache.importInstallerLibrary("libraries/" + libraries.relativize(file).toString().replace('\\', '/'), file); + } + } + } + } + return metadata.importInstalledLoaderProfile(candidates.get(0)); + } + + private String latest(String brand, String gameVersion, Cancellation cancellation) throws IOException { + if (brand.equals("FORGE")) { + JsonObject json = transport.json("GET", URI.create("https://files.minecraftforge.net/net/minecraftforge/forge/promotions_slim.json"), + Collections.emptyMap(), null, cancellation); + JsonObject promotions = json.getAsJsonObject("promos"); + JsonElement selected = promotions.has(gameVersion + "-recommended") ? promotions.get(gameVersion + "-recommended") : promotions.get(gameVersion + "-latest"); + if (selected == null) throw new IOException("Forge has no published release for " + gameVersion); + return selected.getAsString(); + } + String[] game = gameVersion.split("\\."); + if (game.length < 2 || !game[0].equals("1")) throw new IOException("Select an explicit NeoForge version for this Minecraft release"); + String prefix = game[1] + "." + (game.length > 2 ? game[2] : "0") + "."; + Path xml = Files.createTempFile(cache.getRoot(), ".neoforge-versions-", ".xml"); + try { + transport.download(URI.create("https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml"), xml, cancellation); + if (Files.size(xml) > 2 * 1024 * 1024) throw new IOException("NeoForge version metadata too large"); + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + NodeList versions = factory.newDocumentBuilder().parse(xml.toFile()).getElementsByTagName("version"); + List matches = new ArrayList<>(); + for (int i = 0; i < versions.getLength(); i++) { + String version = versions.item(i).getTextContent().trim(); + if (version.startsWith(prefix) && version.matches("[0-9]+\\.[0-9]+\\.[0-9]+")) matches.add(version); + } + matches.sort((left, right) -> { + String[] a = left.split("\\."); String[] b = right.split("\\."); + for (int i = 0; i < 3; i++) { + int order = new java.math.BigInteger(a[i]).compareTo(new java.math.BigInteger(b[i])); + if (order != 0) return order; + } + return 0; + }); + if (matches.isEmpty()) throw new IOException("NeoForge has no stable release for " + gameVersion); + return matches.get(matches.size() - 1); + } catch (IOException error) { throw error; } + catch (Exception error) { throw new IOException("Invalid NeoForge version metadata", error); } + finally { Files.deleteIfExists(xml); } + } + + private JsonObject readJson(Path file) throws IOException { + if (Files.size(file) > 8 * 1024 * 1024) throw new IOException("Loader metadata too large"); + try { return JsonParser.parseString(new String(Files.readAllBytes(file), StandardCharsets.UTF_8)).getAsJsonObject(); } + catch (RuntimeException error) { throw new IOException("Invalid installed loader JSON", error); } + } + + private void validateVersion(String value) throws IOException { + if (value == null || !value.matches("[A-Za-z0-9][A-Za-z0-9._+-]{0,159}")) throw new IOException("Invalid loader/game version"); + } + + public interface ProcessRunner { + int run(List command, Path directory, Cancellation cancellation) throws IOException; + } + + private static final class InstallerProcessRunner implements ProcessRunner { + @Override public int run(List command, Path directory, Cancellation cancellation) throws IOException { + cancellation.check(); + Process process = new ProcessBuilder(command).directory(directory.toFile()).redirectErrorStream(true) + .redirectOutput(directory.resolve("loader-install.log").toFile()).start(); + long deadline = System.nanoTime() + TimeUnit.MINUTES.toNanos(20); + try (AutoCloseable ignored = cancellation.onCancel(process::destroy)) { + while (!process.waitFor(100, TimeUnit.MILLISECONDS)) { + cancellation.check(); + if (System.nanoTime() > deadline) throw new IOException("Loader installer timed out"); + } + return process.exitValue(); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); throw new IOException("Loader installation interrupted", error); + } catch (IOException error) { throw error; } + catch (Exception error) { throw new IOException("Loader installation failed", error); } + finally { if (process.isAlive()) process.destroyForcibly(); } + } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/mc/launch/PreparedLaunch.java b/src/main/java/com/osiris/autoplug/client/mc/launch/PreparedLaunch.java new file mode 100644 index 00000000..5cafee11 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/mc/launch/PreparedLaunch.java @@ -0,0 +1,81 @@ +package com.osiris.autoplug.client.mc.launch; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** A prepared command contains an access token: callers must never print/log it. */ +public final class PreparedLaunch implements AutoCloseable { + private final List command; + private final Path directory; + private final Cancellation cancellation; + private final Path nativesDirectory; + private final ProcessStarter starter; + private Process process; + private boolean attempted; + private boolean cleaned; + + PreparedLaunch(List command, Path directory, Cancellation cancellation, Path nativesDirectory) { + this(command, directory, cancellation, nativesDirectory, ProcessBuilder::start); + } + + PreparedLaunch(List command, Path directory, Cancellation cancellation, Path nativesDirectory, ProcessStarter starter) { + this.command = Collections.unmodifiableList(new ArrayList<>(command)); + this.directory = directory; + this.cancellation = cancellation; + this.nativesDirectory = nativesDirectory; + this.starter = starter; + } + + /** For execution/testing only, not diagnostic logs. */ + public List getCommand() { return command; } + public Path getDirectory() { return directory; } + + public synchronized Process start() throws IOException { + cancellation.check(); + if (attempted) throw new IOException("This prepared launch has already been started"); + attempted = true; + try { process = starter.start(new ProcessBuilder(command).directory(directory.toFile()).inheritIO()); } + catch (IOException error) { + try { cleanup(); } catch (IOException failure) { error.addSuppressed(failure); } + throw error; + } + AutoCloseable registration = cancellation.onCancel(() -> { + process.destroy(); + java.util.concurrent.CompletableFuture.runAsync(() -> { + try { + if (!process.waitFor(5, java.util.concurrent.TimeUnit.SECONDS)) process.destroyForcibly(); + } catch (InterruptedException error) { Thread.currentThread().interrupt(); process.destroyForcibly(); } + }); + }); + process.onExit().thenRun(() -> { + try { registration.close(); } catch (Exception ignored) { } + try { cleanup(); } catch (IOException ignored) { } + }); + return process; + } + + public void cancel() { cancellation.cancel(); } + @Override public synchronized void close() throws IOException { + cancellation.cancel(); + if (process != null && process.isAlive()) { + try { + if (!process.waitFor(5, java.util.concurrent.TimeUnit.SECONDS)) { + process.destroyForcibly(); + if (!process.waitFor(5, java.util.concurrent.TimeUnit.SECONDS)) throw new IOException("Minecraft process did not terminate"); + } + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); process.destroyForcibly(); + throw new IOException("Interrupted while stopping Minecraft", error); + } + } + cleanup(); + } + private synchronized void cleanup() throws IOException { + if (!cleaned) { new LaunchFiles().removeTree(nativesDirectory); cleaned = true; } + } + interface ProcessStarter { Process start(ProcessBuilder builder) throws IOException; } + @Override public String toString() { return "PreparedLaunch[command redacted]"; } +} From 10e5ab067f1d87f567ffc0c3529c1cce0cffd646 Mon Sep 17 00:00:00 2001 From: elcuervo171234 <43775780+elcuervo171234@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:21:31 -0400 Subject: [PATCH 06/23] feat(minecraft): integrate profiles (6/23) --- .../autoplug/client/mc/profiles/Profile.java | 64 ++++ .../client/mc/profiles/ProfileRepository.java | 289 ++++++++++++++++++ .../client/mc/profiles/ProfileResolver.java | 72 +++++ .../client/mc/profiles/ProfileType.java | 18 ++ 4 files changed, 443 insertions(+) create mode 100644 src/main/java/com/osiris/autoplug/client/mc/profiles/Profile.java create mode 100644 src/main/java/com/osiris/autoplug/client/mc/profiles/ProfileRepository.java create mode 100644 src/main/java/com/osiris/autoplug/client/mc/profiles/ProfileResolver.java create mode 100644 src/main/java/com/osiris/autoplug/client/mc/profiles/ProfileType.java diff --git a/src/main/java/com/osiris/autoplug/client/mc/profiles/Profile.java b/src/main/java/com/osiris/autoplug/client/mc/profiles/Profile.java new file mode 100644 index 00000000..4c449abe --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/mc/profiles/Profile.java @@ -0,0 +1,64 @@ +package com.osiris.autoplug.client.mc.profiles; + +import java.nio.file.Path; +import java.util.Objects; + +/** Immutable metadata; its directory is assigned by the repository, not by a metadata file. */ +public final class Profile { + private final String id; + private final ProfileType type; + private final String gameVersion; + private final String loader; + private final Path directory; + private final boolean template; + + Profile(String id, ProfileType type, String gameVersion, String loader, Path directory, boolean template) { + if (id == null || !id.matches("[a-z0-9][a-z0-9._-]{0,79}")) { + throw new IllegalArgumentException("Profile ID must contain 1-80 lowercase letters, digits, dots, underscores or hyphens and start with a letter or digit"); + } + if (gameVersion == null || !gameVersion.matches("[A-Za-z0-9][A-Za-z0-9._+-]{0,79}")) { + throw new IllegalArgumentException("A non-empty Minecraft version is required"); + } + if (loader == null || !loader.matches("[A-Za-z0-9][A-Za-z0-9._+-]{0,63}")) { + throw new IllegalArgumentException("A non-empty loader identifier is required"); + } + this.id = id; + this.type = Objects.requireNonNull(type, "type"); + this.gameVersion = gameVersion; + this.loader = loader; + this.directory = Objects.requireNonNull(directory, "directory"); + this.template = template; + } + + public String getId() { + return id; + } + + public ProfileType getType() { + return type; + } + + public String getGameVersion() { + return gameVersion; + } + + public String getLoader() { + return loader; + } + + public Path getDirectory() { + return directory; + } + + public Path getAssetsDirectory() { + return directory.resolve(type.getAssetDirectoryName()); + } + + public Path getConfigDirectory() { + return directory.resolve("config"); + } + + public boolean isTemplate() { + return template; + } +} diff --git a/src/main/java/com/osiris/autoplug/client/mc/profiles/ProfileRepository.java b/src/main/java/com/osiris/autoplug/client/mc/profiles/ProfileRepository.java new file mode 100644 index 00000000..298263df --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/mc/profiles/ProfileRepository.java @@ -0,0 +1,289 @@ +package com.osiris.autoplug.client.mc.profiles; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.DirectoryStream; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.FileVisitResult; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.Properties; + +/** + * Local profile storage shared by future UI and CLI callers. It does not launch a game, + * download files, modify the server-wide updater configuration or migrate asset versions. + * Copies deliberately retain the source version; migration needs a separate updater step. + */ +public class ProfileRepository { + private final Path profilesDirectory; + private final java.util.Set inUse = new java.util.HashSet<>(); + + /** Held by a launcher/world server from preparation until process exit or cancellation. */ + public final class UseLease implements AutoCloseable { + private final String id; + private boolean closed; + private UseLease(String id) { this.id = id; } + @Override public void close() { + synchronized (ProfileRepository.this) { + if (!closed) { inUse.remove(id); closed = true; } + } + } + } + + public synchronized UseLease acquireUse(String id) throws IOException { + require(id); + requireNotInUse(id); + inUse.add(id); + return new UseLease(id); + } + + public synchronized boolean isInUse(String id) { return inUse.contains(id); } + + public synchronized void requireNotInUse(String id) throws IOException { + if (inUse.contains(id)) throw new IOException("Profile is currently in use: " + id); + } + + + public ProfileRepository(Path dataDirectory) throws IOException { + Files.createDirectories(dataDirectory); + Path root = dataDirectory.toRealPath(); + profilesDirectory = root.resolve("profiles"); + if (Files.exists(profilesDirectory, LinkOption.NOFOLLOW_LINKS)) { + requireDirectory(profilesDirectory); + } else { + Files.createDirectory(profilesDirectory); + } + } + + public synchronized Profile create(String id, ProfileType type, String gameVersion, + String loader, boolean template) throws IOException { + Profile profile = new Profile(id, type, gameVersion, loader, directoryFor(id), template); + requireAvailable(profile.getDirectory()); + Path staging = Files.createTempDirectory(profilesDirectory, ".create-"); + try { + Files.createDirectory(staging.resolve(type.getAssetDirectoryName())); + Files.createDirectory(staging.resolve("config")); + writeMetadata(staging, profile); + // No REPLACE_EXISTING: a competing creator must never replace an existing profile. + Files.move(staging, profile.getDirectory()); + return profile; + } finally { + removeStaging(staging); + } + } + + public synchronized Optional find(String id) throws IOException { + Path directory = directoryFor(id); + if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) return Optional.empty(); + return Optional.of(readMetadata(directory)); + } + + public synchronized List list() throws IOException { + List profiles = new ArrayList<>(); + try (DirectoryStream entries = Files.newDirectoryStream(profilesDirectory)) { + for (Path entry : entries) { + if (!entry.getFileName().toString().startsWith(".")) profiles.add(readMetadata(entry)); + } + } + profiles.sort(Comparator.comparing(Profile::getId)); + return profiles; + } + + /** Independent same-version copy. A copy of a template is a normal profile. */ + public synchronized Profile cloneProfile(String sourceId, String targetId) throws IOException { + requireNotInUse(sourceId); + Profile source = find(sourceId).orElseThrow(() -> new IOException("Unknown profile: " + sourceId)); + Profile target = new Profile(targetId, source.getType(), source.getGameVersion(), source.getLoader(), + directoryFor(targetId), false); + requireAvailable(target.getDirectory()); + Path staging = Files.createTempDirectory(profilesDirectory, ".clone-"); + try { + copyContents(source.getDirectory(), staging); + writeMetadata(staging, target); + Files.move(staging, target.getDirectory()); + return target; + } finally { + removeStaging(staging); + } + } + + /** Changes the template marker without changing the assets or the game version. */ + public synchronized Profile updateTemplate(String id, boolean template) throws IOException { + requireNotInUse(id); + Profile old = require(id); + Profile updated = new Profile(id, old.getType(), old.getGameVersion(), old.getLoader(), + old.getDirectory(), template); + replaceMetadata(updated); + return updated; + } + + /** Renames a profile atomically; the contents and configuration stay independent. */ + public synchronized Profile rename(String id, String newId) throws IOException { + requireNotInUse(id); + Profile old = require(id); + if (id.equals(newId)) return old; + Profile renamed = new Profile(newId, old.getType(), old.getGameVersion(), old.getLoader(), + directoryFor(newId), old.isTemplate()); + requireAvailable(renamed.getDirectory()); + Files.move(old.getDirectory(), renamed.getDirectory()); + try { + replaceMetadata(renamed); + } catch (IOException failure) { + Files.move(renamed.getDirectory(), old.getDirectory()); + throw failure; + } + return renamed; + } + + /** Deletes only this repository's direct profile tree, never a symlink target. */ + public synchronized void delete(String id) throws IOException { + requireNotInUse(id); + Profile profile = require(id); + Path removed = profilesDirectory.resolve(".delete-" + java.util.UUID.randomUUID()); + Files.move(profile.getDirectory(), removed); + removeStaging(removed); + } + + public synchronized Profile require(String id) throws IOException { + return find(id).orElseThrow(() -> new IOException("Unknown profile: " + id)); + } + + /** Called after a successfully applied, explicitly approved asset migration. */ + public synchronized Profile recordMigratedVersion(String id, String version) throws IOException { + requireNotInUse(id); + Profile old = require(id); + Profile updated = new Profile(id, old.getType(), version, old.getLoader(), old.getDirectory(), old.isTemplate()); + replaceMetadata(updated); + return updated; + } + + public Path getProfilesDirectory() { return profilesDirectory; } + + private void replaceMetadata(Profile profile) throws IOException { + Path temporary = Files.createTempDirectory(profilesDirectory, ".metadata-"); + try { + writeMetadata(temporary, profile); + try { + Files.move(temporary.resolve("profile.properties"), profile.getDirectory().resolve("profile.properties"), + java.nio.file.StandardCopyOption.ATOMIC_MOVE, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } catch (java.nio.file.AtomicMoveNotSupportedException unsupported) { + Files.move(temporary.resolve("profile.properties"), profile.getDirectory().resolve("profile.properties"), + java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + } finally { + removeStaging(temporary); + } + } + + private Path directoryFor(String id) { + if (id == null || !id.matches("[a-z0-9][a-z0-9._-]{0,79}")) { + throw new IllegalArgumentException("Invalid profile ID: " + id); + } + return profilesDirectory.resolve(id); + } + + private void requireAvailable(Path directory) throws IOException { + requireDirectory(profilesDirectory); + if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) { + throw new FileAlreadyExistsException(directory.toString()); + } + } + + private void requireDirectory(Path path) throws IOException { + if (!Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Expected a direct profile directory: " + path); + } + } + + private Profile readMetadata(Path directory) throws IOException { + requireDirectory(profilesDirectory); + requireDirectory(directory); + Path metadata = directory.resolve("profile.properties"); + if (!Files.isRegularFile(metadata, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Missing profile metadata: " + metadata); + } + Properties values = new Properties(); + try (InputStream input = Files.newInputStream(metadata)) { + values.load(input); + } catch (IllegalArgumentException error) { + throw new IOException("Invalid profile metadata: " + metadata, error); + } + try { + if (!"1".equals(values.getProperty("formatVersion"))) { + throw new IllegalArgumentException("Unsupported profile format"); + } + if (!directory.getFileName().toString().equals(values.getProperty("id"))) { + throw new IllegalArgumentException("Profile ID does not match its directory"); + } + String template = values.getProperty("isTemplate"); + if (!"true".equals(template) && !"false".equals(template)) { + throw new IllegalArgumentException("Invalid template flag"); + } + Profile profile = new Profile(values.getProperty("id"), ProfileType.valueOf(values.getProperty("type")), + values.getProperty("gameVersion"), values.getProperty("loader"), directory, + Boolean.parseBoolean(template)); + requireDirectory(profile.getAssetsDirectory()); + requireDirectory(profile.getConfigDirectory()); + return profile; + } catch (IllegalArgumentException | NullPointerException error) { + throw new IOException("Invalid profile metadata: " + metadata, error); + } + } + + private void writeMetadata(Path directory, Profile profile) throws IOException { + Properties values = new Properties(); + values.setProperty("formatVersion", "1"); + values.setProperty("id", profile.getId()); + values.setProperty("type", profile.getType().name()); + values.setProperty("gameVersion", profile.getGameVersion()); + values.setProperty("loader", profile.getLoader()); + values.setProperty("isTemplate", Boolean.toString(profile.isTemplate())); + try (OutputStream output = Files.newOutputStream(directory.resolve("profile.properties"))) { + values.store(output, "AutoPlug native profile"); + } + } + + private void copyContents(Path source, Path target) throws IOException { + Files.walkFileTree(source, new SimpleFileVisitor() { + @Override + public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) throws IOException { + if (!directory.equals(source)) Files.createDirectory(target.resolve(source.relativize(directory))); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException { + if (!attributes.isRegularFile()) throw new IOException("Cannot clone a linked or special file: " + file); + Path relative = source.relativize(file); + if (!relative.toString().equals("profile.properties")) Files.copy(file, target.resolve(relative)); + return FileVisitResult.CONTINUE; + } + }); + } + + private void removeStaging(Path staging) throws IOException { + if (!Files.exists(staging, LinkOption.NOFOLLOW_LINKS)) return; + Files.walkFileTree(staging, new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException { + Files.delete(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path directory, IOException error) throws IOException { + if (error != null) throw error; + Files.delete(directory); + return FileVisitResult.CONTINUE; + } + }); + } +} diff --git a/src/main/java/com/osiris/autoplug/client/mc/profiles/ProfileResolver.java b/src/main/java/com/osiris/autoplug/client/mc/profiles/ProfileResolver.java new file mode 100644 index 00000000..2b834627 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/mc/profiles/ProfileResolver.java @@ -0,0 +1,72 @@ +package com.osiris.autoplug.client.mc.profiles; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** Selects client profiles without downloading, cloning or launching anything. */ +public final class ProfileResolver { + private final ProfileRepository repository; + + public ProfileResolver(ProfileRepository repository) { + if (repository == null) throw new IllegalArgumentException("A profile repository is required"); + this.repository = repository; + } + + /** + * READY means an exact metadata match, not verified launch or mod compatibility. + * Callers must still complete asset validation and the launch preparation pipeline. + * A server ping supplies a game version, but cannot identify the required mod loader. + * The caller must therefore provide its explicitly selected loader. A template is + * only a proposal: cloning, migration and user confirmation remain separate steps. + */ + public Resolution resolve(String gameVersion, String loader, String defaultTemplateId) throws IOException { + if (gameVersion == null || gameVersion.trim().isEmpty() + || loader == null || loader.trim().isEmpty()) { + throw new IllegalArgumentException("An explicit Minecraft version and loader are required"); + } + List matches = new ArrayList<>(); + for (Profile profile : repository.list()) { + if (profile.getType() == ProfileType.MODS && !profile.isTemplate() + && profile.getGameVersion().equals(gameVersion) + && profile.getLoader().equalsIgnoreCase(loader)) { + matches.add(profile); + } + } + if (!matches.isEmpty()) { + return new Resolution(matches.size() == 1 ? State.READY : State.CHOOSE_PROFILE, + matches, gameVersion); + } + if (defaultTemplateId == null) { + return new Resolution(State.CREATE_PROFILE, Collections.emptyList(), gameVersion); + } + Profile template = repository.find(defaultTemplateId) + .orElseThrow(() -> new IOException("Unknown template: " + defaultTemplateId)); + if (!template.isTemplate() || template.getType() != ProfileType.MODS + || !template.getLoader().equalsIgnoreCase(loader)) { + throw new IOException("The default template must be a client-mod template for the selected loader"); + } + return new Resolution(State.CONFIRM_TEMPLATE_CLONE, Collections.singletonList(template), gameVersion); + } + + public enum State { + READY, CHOOSE_PROFILE, CONFIRM_TEMPLATE_CLONE, CREATE_PROFILE + } + + public final class Resolution { + private final State state; + private final List profiles; + private final String targetGameVersion; + + private Resolution(State state, List profiles, String targetGameVersion) { + this.state = state; + this.profiles = Collections.unmodifiableList(new ArrayList<>(profiles)); + this.targetGameVersion = targetGameVersion; + } + + public State getState() { return state; } + public List getProfiles() { return profiles; } + public String getTargetGameVersion() { return targetGameVersion; } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/mc/profiles/ProfileType.java b/src/main/java/com/osiris/autoplug/client/mc/profiles/ProfileType.java new file mode 100644 index 00000000..d4e9581b --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/mc/profiles/ProfileType.java @@ -0,0 +1,18 @@ +package com.osiris.autoplug.client.mc.profiles; + +/** Versioned collections may belong to a client or a server. */ +public enum ProfileType { + MODS("mods"), + PLUGINS("plugins"), + MODS_SERVER("mods"); + + private final String assetDirectoryName; + + ProfileType(String assetDirectoryName) { + this.assetDirectoryName = assetDirectoryName; + } + + public String getAssetDirectoryName() { + return assetDirectoryName; + } +} From 07442cca6a25606147be47b79936f1b644410d56 Mon Sep 17 00:00:00 2001 From: elcuervo171234 <43775780+elcuervo171234@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:22:02 -0400 Subject: [PATCH 07/23] feat(minecraft): integrate sharing (7/23) --- .../client/mc/sharing/PortSharingService.java | 14 + .../client/mc/sharing/UpnpPortMapper.java | 404 ++++++++++++++++++ 2 files changed, 418 insertions(+) create mode 100644 src/main/java/com/osiris/autoplug/client/mc/sharing/PortSharingService.java create mode 100644 src/main/java/com/osiris/autoplug/client/mc/sharing/UpnpPortMapper.java diff --git a/src/main/java/com/osiris/autoplug/client/mc/sharing/PortSharingService.java b/src/main/java/com/osiris/autoplug/client/mc/sharing/PortSharingService.java new file mode 100644 index 00000000..b5e97e54 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/mc/sharing/PortSharingService.java @@ -0,0 +1,14 @@ +package com.osiris.autoplug.client.mc.sharing; + +import java.io.IOException; + +/** Mapping is requested only after the user explicitly enables sharing. */ +public interface PortSharingService { + Lease share(int localPort) throws IOException; + + interface Lease extends AutoCloseable { + String getJoinAddress(); + default boolean isExpired() { return false; } + @Override void close() throws IOException; + } +} diff --git a/src/main/java/com/osiris/autoplug/client/mc/sharing/UpnpPortMapper.java b/src/main/java/com/osiris/autoplug/client/mc/sharing/UpnpPortMapper.java new file mode 100644 index 00000000..076127f3 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/mc/sharing/UpnpPortMapper.java @@ -0,0 +1,404 @@ +package com.osiris.autoplug.client.mc.sharing; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.HttpURLConnection; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.SocketTimeoutException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Clean-room SSDP/IGD SOAP adapter. No network activity happens until share() is called. */ +public final class UpnpPortMapper implements PortSharingService { + private static final int MAX_XML_BYTES = 256 * 1024; + public interface Discovery { Gateway discover() throws IOException; } + public interface SoapTransport { + String call(Gateway gateway, String action, Map arguments) throws IOException; + } + + public static final class Gateway { + private final URL controlUrl; + private final String serviceType; + private final String localAddress; + + public Gateway(URL controlUrl, String serviceType, String localAddress) throws IOException { + requireLocalUrl(controlUrl); + if (!isServiceType(serviceType)) throw new IOException("Unsupported IGD service"); + if (!isPrivateIpv4(localAddress) || localAddress.startsWith("127.")) throw new IOException("A LAN IPv4 address is required"); + this.controlUrl = controlUrl; + this.serviceType = serviceType; + this.localAddress = localAddress; + } + public URL getControlUrl() { return controlUrl; } + public String getServiceType() { return serviceType; } + public String getLocalAddress() { return localAddress; } + } + + public static final class SoapFault extends IOException { + private final int code; + public SoapFault(int code, String message) { super("UPnP error " + code + ": " + message); this.code = code; } + public int getCode() { return code; } + } + + private final Discovery discovery; + private final SoapTransport transport; + private final java.util.function.LongSupplier clock; + + public UpnpPortMapper() { + HttpTransport http = new HttpTransport(); + this.discovery = new SsdpDiscovery(http); + this.transport = http; + this.clock = System::currentTimeMillis; + } + + public UpnpPortMapper(Discovery discovery, SoapTransport transport) { + this(discovery, transport, System::currentTimeMillis); + } + + public UpnpPortMapper(Discovery discovery, SoapTransport transport, java.util.function.LongSupplier clock) { + this.discovery = discovery; + this.transport = transport; + this.clock = clock; + } + + @Override public Lease share(int localPort) throws IOException { + if (localPort < 1 || localPort > 65535) throw new IOException("Invalid port"); + Gateway gateway = discovery.discover(); + String external = value(call(gateway, "GetExternalIPAddress", new LinkedHashMap<>()), "NewExternalIPAddress"); + if (!isPublicIpv4(external)) { + throw new IOException("Router has no public IPv4 address (possibly double NAT or CGNAT)"); + } + Map identity = mappingIdentity(localPort); + // Never overwrite/delete a mapping another application already owns. + try { + call(gateway, "GetSpecificPortMappingEntry", identity); + throw new IOException("The requested external TCP port already has a mapping"); + } catch (SoapFault fault) { + if (fault.getCode() != 714) throw fault; // NoSuchEntryInArray is the only free-port result. + } + Map arguments = new LinkedHashMap<>(identity); + arguments.put("NewInternalPort", Integer.toString(localPort)); + arguments.put("NewInternalClient", gateway.localAddress); + arguments.put("NewEnabled", "1"); + arguments.put("NewPortMappingDescription", "AutoPlug-" + UUID.randomUUID()); + // A finite lease bounds exposure after a crash. Permanent-only routers use visible fallback. + arguments.put("NewLeaseDuration", "3600"); + try { call(gateway, "AddPortMapping", arguments); } + catch (IOException error) { + // A timed-out HTTP reply does not prove that the router did not apply the request. + try { deleteOwnedMapping(gateway, identity, arguments); } + catch (IOException cleanup) { error.addSuppressed(cleanup); } + throw error; + } + long expiresAt = clock.getAsLong() + 3600000; + return new Lease() { + private final AtomicBoolean removed = new AtomicBoolean(); + @Override public String getJoinAddress() { return external + ":" + localPort; } + @Override public boolean isExpired() { return clock.getAsLong() >= expiresAt; } + @Override public void close() throws IOException { + if (removed.compareAndSet(false, true)) { + try { deleteOwnedMapping(gateway, identity, arguments); } + catch (IOException error) { removed.set(false); throw error; } + } + } + }; + } + + private void deleteOwnedMapping(Gateway gateway, Map identity, + Map created) throws IOException { + Map current; + try { current = call(gateway, "GetSpecificPortMappingEntry", identity); } + catch (SoapFault absent) { if (absent.getCode() == 714) return; else throw absent; } + for (String field : new String[]{"NewPortMappingDescription", "NewInternalClient", "NewInternalPort"}) { + if (!created.get(field).equals(current.get(field))) { + throw new IOException("Router mapping ownership changed; refusing to delete another mapping"); + } + } + call(gateway, "DeletePortMapping", identity); + } + + private Map call(Gateway gateway, String action, Map arguments) throws IOException { + return parseResponse(transport.call(gateway, action, arguments), action); + } + + private static Map mappingIdentity(int port) { + Map arguments = new LinkedHashMap<>(); + arguments.put("NewRemoteHost", ""); + arguments.put("NewExternalPort", Integer.toString(port)); + arguments.put("NewProtocol", "TCP"); + return arguments; + } + + static String envelope(String service, String action, Map arguments) throws IOException { + if (!isServiceType(service) || !action.matches("[A-Za-z][A-Za-z0-9]*")) throw new IOException("Invalid SOAP action"); + StringBuilder xml = new StringBuilder("") + .append(""); + for (Map.Entry entry : arguments.entrySet()) { + if (!entry.getKey().matches("[A-Za-z][A-Za-z0-9]*")) throw new IOException("Invalid SOAP argument"); + xml.append('<').append(entry.getKey()).append('>').append(escape(entry.getValue())) + .append("'); + } + return xml.append("").toString(); + } + + static Map parseResponse(String xml) throws IOException { + return parseResponse(xml, null); + } + + private static Map parseResponse(String xml, String expectedAction) throws IOException { + Document document = parseXml(xml); + NodeList faults = document.getElementsByTagNameNS("*", "Fault"); + if (faults.getLength() > 0) { + String code = text(document.getDocumentElement(), "errorCode"); + String description = text(document.getDocumentElement(), "errorDescription"); + int number; + try { number = Integer.parseInt(code); } catch (NumberFormatException error) { number = -1; } + throw new SoapFault(number, description == null ? "Gateway rejected the request" : description); + } + NodeList bodies = document.getElementsByTagNameNS("*", "Body"); + if (bodies.getLength() != 1) throw new IOException("Malformed SOAP response"); + Element response = null; + for (Node child = bodies.item(0).getFirstChild(); child != null; child = child.getNextSibling()) { + if (child instanceof Element) { + if (response != null) throw new IOException("Ambiguous SOAP response"); + response = (Element) child; + } + } + if (response == null) throw new IOException("Empty SOAP response"); + if (expectedAction != null && !(expectedAction + "Response").equals(response.getLocalName())) { + throw new IOException("Unexpected UPnP response action"); + } + Map result = new LinkedHashMap<>(); + for (Node child = response.getFirstChild(); child != null; child = child.getNextSibling()) { + if (child instanceof Element) result.put(child.getLocalName() == null ? child.getNodeName() : child.getLocalName(), child.getTextContent().trim()); + } + return result; + } + + static Gateway gatewayFromDescription(String xml, URL location, String localAddress) throws IOException { + requireLocalUrl(location); + Document document = parseXml(xml); + URL base = location; + String baseValue = text(document.getDocumentElement(), "URLBase"); + if (baseValue != null && !baseValue.isEmpty()) { + base = new URL(location, baseValue); + requireSameGateway(location, base); + } + NodeList services = document.getElementsByTagNameNS("*", "service"); + for (int i = 0; i < services.getLength(); i++) { + Element service = (Element) services.item(i); + String type = text(service, "serviceType"); + if (!isServiceType(type)) continue; + String control = text(service, "controlURL"); + if (control == null || control.isEmpty()) continue; + URL url = new URL(base, control); + requireSameGateway(location, url); + return new Gateway(url, type, localAddress); + } + throw new IOException("No WANIPConnection/WANPPPConnection service found"); + } + + private static String value(Map values, String name) throws IOException { + String value = values.get(name); + if (value == null || value.isEmpty()) throw new IOException("Missing UPnP response field: " + name); + return value; + } + + private static String text(Element parent, String localName) { + NodeList nodes = parent.getElementsByTagNameNS("*", localName); + return nodes.getLength() == 0 ? null : nodes.item(0).getTextContent().trim(); + } + + private static Document parseXml(String xml) throws IOException { + if (xml == null || xml.getBytes(StandardCharsets.UTF_8).length > MAX_XML_BYTES) throw new IOException("UPnP XML exceeds limit"); + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + javax.xml.parsers.DocumentBuilder builder = factory.newDocumentBuilder(); + builder.setErrorHandler(new org.xml.sax.helpers.DefaultHandler() { + @Override public void error(org.xml.sax.SAXParseException error) throws org.xml.sax.SAXException { throw error; } + @Override public void fatalError(org.xml.sax.SAXParseException error) throws org.xml.sax.SAXException { throw error; } + }); + return builder.parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + } catch (Exception error) { throw new IOException("Invalid UPnP XML", error); } + } + + private static String escape(String value) { + return value.replace("&", "&").replace("<", "<").replace(">", ">") + .replace("\"", """).replace("'", "'"); + } + + private static boolean isServiceType(String value) { + return "urn:schemas-upnp-org:service:WANIPConnection:1".equals(value) + || "urn:schemas-upnp-org:service:WANIPConnection:2".equals(value) + || "urn:schemas-upnp-org:service:WANPPPConnection:1".equals(value); + } + + private static int[] ipv4(String value) { + if (value == null || !value.matches("[0-9]{1,3}(\\.[0-9]{1,3}){3}")) return null; + String[] parts = value.split("\\."); + int[] bytes = new int[4]; + for (int i = 0; i < 4; i++) { + if (parts[i].length() > 1 && parts[i].startsWith("0")) return null; + bytes[i] = Integer.parseInt(parts[i]); + if (bytes[i] > 255) return null; + } + return bytes; + } + + private static boolean isPrivateIpv4(String value) { + int[] ip = ipv4(value); + return ip != null && (ip[0] == 10 || ip[0] == 192 && ip[1] == 168 + || ip[0] == 172 && ip[1] >= 16 && ip[1] <= 31 || ip[0] == 169 && ip[1] == 254); + } + + static boolean isPublicIpv4(String value) { + int[] ip = ipv4(value); + return ip != null && !isPrivateIpv4(value) && ip[0] != 0 && ip[0] != 127 && ip[0] < 224 + && !(ip[0] == 100 && ip[1] >= 64 && ip[1] <= 127) + && !(ip[0] == 192 && ip[1] == 0 && (ip[2] == 0 || ip[2] == 2)) + && !(ip[0] == 198 && (ip[1] == 18 || ip[1] == 19 || ip[1] == 51 && ip[2] == 100)) + && !(ip[0] == 203 && ip[1] == 0 && ip[2] == 113); + } + + private static void requireLocalUrl(URL url) throws IOException { + if (!"http".equalsIgnoreCase(url.getProtocol()) || !isPrivateIpv4(url.getHost()) + || url.getUserInfo() != null || url.getRef() != null) throw new IOException("UPnP control must use a local IPv4 HTTP gateway"); + } + + private static void requireSameGateway(URL original, URL next) throws IOException { + requireLocalUrl(next); + if (!original.getHost().equals(next.getHost())) throw new IOException("UPnP control URL leaves discovered gateway"); + } + + /** Finite local multicast discovery, no internet IP-lookup service or DNS lookup. */ + public static final class SsdpDiscovery implements Discovery { + private final HttpTransport http; + public SsdpDiscovery(HttpTransport http) { this.http = http; } + @Override public Gateway discover() throws IOException { + InetAddress multicast = InetAddress.getByName("239.255.255.250"); + String request = "M-SEARCH * HTTP/1.1\r\nHOST: 239.255.255.250:1900\r\nMAN: \"ssdp:discover\"\r\nMX: 1\r\nST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n\r\n"; + byte[] bytes = request.getBytes(StandardCharsets.US_ASCII); + long deadline = System.nanoTime() + 3_000_000_000L; + IOException last = null; + try (DatagramSocket socket = new DatagramSocket()) { + socket.send(new DatagramPacket(bytes, bytes.length, multicast, 1900)); + while (System.nanoTime() < deadline) { + socket.setSoTimeout((int) Math.max(1, Math.min(1000, (deadline - System.nanoTime()) / 1_000_000))); + DatagramPacket response = new DatagramPacket(new byte[8192], 8192); + try { socket.receive(response); } + catch (SocketTimeoutException timeout) { continue; } + try { + if (!isPrivateIpv4(response.getAddress().getHostAddress())) continue; + String locationValue = location(new String(response.getData(), 0, response.getLength(), StandardCharsets.US_ASCII)); + URL location = new URL(locationValue); + requireLocalUrl(location); + if (!response.getAddress().getHostAddress().equals(location.getHost())) continue; + String local; + try (DatagramSocket route = new DatagramSocket()) { + route.connect(new InetSocketAddress(response.getAddress(), 1900)); + local = route.getLocalAddress().getHostAddress(); + } + return gatewayFromDescription(http.get(location), location, local); + } catch (IOException error) { last = error; } + } + } + throw new IOException("No usable UPnP gateway found within discovery timeout", last); + } + + static String location(String response) throws IOException { + String[] lines = response.split("\\r?\\n"); + if (lines.length == 0 || !lines[0].matches("HTTP/1\\.[01] 200(?: .*)?")) throw new IOException("Invalid SSDP response"); + String location = null; + for (String line : lines) { + int colon = line.indexOf(':'); + if (colon > 0 && line.substring(0, colon).equalsIgnoreCase("location")) { + if (location != null) throw new IOException("Duplicate SSDP location"); + location = line.substring(colon + 1).trim(); + } + } + if (location == null || location.isEmpty()) throw new IOException("SSDP response has no location"); + return location; + } + } + + public static final class HttpTransport implements SoapTransport { + @Override public String call(Gateway gateway, String action, Map arguments) throws IOException { + byte[] body = envelope(gateway.serviceType, action, arguments).getBytes(StandardCharsets.UTF_8); + HttpURLConnection connection = connection(gateway.controlUrl); + try { + connection.setRequestMethod("POST"); + connection.setDoOutput(true); + connection.setRequestProperty("Content-Type", "text/xml; charset=\"utf-8\""); + connection.setRequestProperty("SOAPAction", "\"" + gateway.serviceType + "#" + action + "\""); + connection.setFixedLengthStreamingMode(body.length); + try (java.io.OutputStream output = connection.getOutputStream()) { output.write(body); } + int status = connection.getResponseCode(); + InputStream stream = status >= 200 && status < 300 ? connection.getInputStream() : connection.getErrorStream(); + if (stream == null) throw new IOException("UPnP HTTP error " + status); + String xml; + try (InputStream input = stream) { xml = readXml(input); } + if (status < 200 || status >= 300) { + parseResponse(xml); // Preserve structured UPnP fault codes, including free-port 714. + throw new IOException("UPnP HTTP error " + status); + } + return xml; + } finally { connection.disconnect(); } + } + + String get(URL url) throws IOException { + HttpURLConnection connection = connection(url); + try { + if (connection.getResponseCode() != 200) throw new IOException("UPnP descriptor HTTP error"); + try (InputStream input = connection.getInputStream()) { return readXml(input); } + } finally { connection.disconnect(); } + } + + private HttpURLConnection connection(URL url) throws IOException { + requireLocalUrl(url); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(java.net.Proxy.NO_PROXY); + connection.setConnectTimeout(2000); + connection.setReadTimeout(2000); + connection.setInstanceFollowRedirects(false); + return connection; + } + + private String readXml(InputStream input) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int read; + long deadline = System.nanoTime() + 2_000_000_000L; + while ((read = input.read(buffer)) != -1) { + if (System.nanoTime() > deadline) throw new IOException("UPnP response timed out"); + if (bytes.size() + read > MAX_XML_BYTES) throw new IOException("UPnP response exceeds limit"); + bytes.write(buffer, 0, read); + } + return new String(bytes.toByteArray(), StandardCharsets.UTF_8); + } + } +} From a3624a99226e2f60cc9222d7a3b9f862d695f500 Mon Sep 17 00:00:00 2001 From: elcuervo171234 <43775780+elcuervo171234@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:22:33 -0400 Subject: [PATCH 08/23] feat(minecraft): integrate update (8/23) --- .../mc/update/LegacyProfileUpdateEngine.java | 151 +++++++++ .../mc/update/ProfileUpdateService.java | 298 ++++++++++++++++++ .../client/mc/update/SharedArtifactCache.java | 141 +++++++++ 3 files changed, 590 insertions(+) create mode 100644 src/main/java/com/osiris/autoplug/client/mc/update/LegacyProfileUpdateEngine.java create mode 100644 src/main/java/com/osiris/autoplug/client/mc/update/ProfileUpdateService.java create mode 100644 src/main/java/com/osiris/autoplug/client/mc/update/SharedArtifactCache.java diff --git a/src/main/java/com/osiris/autoplug/client/mc/update/LegacyProfileUpdateEngine.java b/src/main/java/com/osiris/autoplug/client/mc/update/LegacyProfileUpdateEngine.java new file mode 100644 index 00000000..83fb9973 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/mc/update/LegacyProfileUpdateEngine.java @@ -0,0 +1,151 @@ +package com.osiris.autoplug.client.mc.update; + +import com.osiris.autoplug.client.mc.profiles.Profile; +import com.osiris.autoplug.client.mc.profiles.ProfileType; +import com.osiris.autoplug.client.tasks.updater.mods.InstalledModLoader; +import com.osiris.autoplug.client.tasks.updater.mods.MinecraftMod; +import com.osiris.autoplug.client.tasks.updater.plugins.InstalledPluginLoader; +import com.osiris.autoplug.client.tasks.updater.plugins.MinecraftPlugin; +import com.osiris.autoplug.client.tasks.updater.plugins.ResourceFinder; +import com.osiris.autoplug.client.tasks.updater.search.SearchResult; +import com.osiris.autoplug.client.utils.UtilsMinecraft; +import com.osiris.dyml.Yaml; +import com.osiris.dyml.YamlSection; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** Profile adapter for the existing metadata reader and resource providers, without global YAML writes. */ +public final class LegacyProfileUpdateEngine implements ProfileUpdateService.Engine { + private final ResourceFinder finder; + private final OkHttpClient http; + + public LegacyProfileUpdateEngine() { this(new ResourceFinder()); } + public LegacyProfileUpdateEngine(ResourceFinder finder) { + this.finder = java.util.Objects.requireNonNull(finder); + http = new OkHttpClient.Builder().followSslRedirects(false).connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS).callTimeout(120, TimeUnit.SECONDS).build(); + } + + @Override public ProfileUpdateService.Decision resolve(Profile profile, Path installedJar, String targetVersion) throws IOException { + // UtilsMinecraft deliberately scans *.jar only. A private read-only copy lets the same + // real parser inspect an already-disabled jar without renaming a live profile file. + Path temporary = Files.createTempDirectory("autoplug-profile-inspect-"); + Path jar = temporary.resolve("inspect.jar"); + try { + Files.copy(installedJar, jar); + SearchResult result; + if (profile.getType() == ProfileType.PLUGINS) { + String software = profile.getLoader().toLowerCase(java.util.Locale.ROOT); + if (!java.util.Set.of("bukkit", "spigot", "paper", "purpur", "velocity", "bungeecord", "waterfall", + "folia", "luminolmc", "canvas", "shreddedpaper").contains(software)) { + return ProfileUpdateService.Decision.error("Unsupported plugin loader: " + profile.getLoader()); + } + List plugins = new UtilsMinecraft().getPlugins(temporary.toFile()); + if (plugins.size() != 1) return ProfileUpdateService.Decision.error("Cannot read a unique plugin descriptor; configure or remove this jar"); + MinecraftPlugin plugin = plugins.get(0); + Yaml settings = settings(profile, "plugins"); + if (excluded(settings, "plugins", plugin.getName())) return excluded(profile, targetVersion); + plugin.modrinthId = value(settings, "plugins", plugin.getName(), "modrinth-id", plugin.modrinthId); + if (plugin.modrinthId == null) { + return ProfileUpdateService.Decision.error("Set plugins..modrinth-id in this profile's config/plugins.yml; legacy latest-only Spigot/Bukkit checks cannot prove target-version compatibility"); + } + result = finder.findCompatibleProfilePlugin(new InstalledPluginLoader(profile.getLoader()).getLoaderList(), plugin, targetVersion); + } else { + List mods = new UtilsMinecraft().getMods(temporary.toFile()); + if (mods.size() != 1) return ProfileUpdateService.Decision.error("Cannot read a unique mod descriptor; configure or remove this jar"); + MinecraftMod mod = mods.get(0); + Yaml settings = settings(profile, "mods"); + if (excluded(settings, "mods", mod.getName())) return excluded(profile, targetVersion); + mod.modrinthId = value(settings, "mods", mod.getName(), "modrinth-id", mod.modrinthId); + mod.curseforgeId = value(settings, "mods", mod.getName(), "curseforge-id", mod.curseforgeId); + // force-latest is intentionally not copied: it would violate this profile's target. + result = finder.findCompatibleProfileMod(new InstalledModLoader(profile.getLoader()), mod, targetVersion); + } + if (result.type == SearchResult.Type.NO_COMPATIBLE_RELEASE) { + return ProfileUpdateService.Decision.incompatible("No release for Minecraft " + targetVersion + " / " + profile.getLoader()); + } + if (!SearchResult.isMatchFound(result) || result.downloadUrl == null) { + return ProfileUpdateService.Decision.error(result.exception == null + ? "No compatible provider mapping; add this profile's modrinth-id or curseforge-id" + : "Provider check failed: " + result.exception.getClass().getSimpleName()); + } + requireHttps(result.downloadUrl); + return ProfileUpdateService.Decision.replace(result.downloadUrl, result.sha256, + "Install compatible release " + result.latestVersion + " for " + targetVersion); + } catch (IOException failure) { + throw failure; + } catch (Exception failure) { + throw new IOException("Cannot inspect profile asset: " + installedJar.getFileName(), failure); + } finally { + Files.deleteIfExists(jar); + Files.deleteIfExists(temporary); + } + } + + @Override public void download(ProfileUpdateService.Decision decision, Path destination) throws IOException { + requireHttps(decision.getDownloadUrl()); + Request request = new Request.Builder().url(decision.getDownloadUrl()).header("User-Agent", "AutoPlug-Client").build(); + try (Response response = http.newCall(request).execute()) { + if (!response.isSuccessful() || response.body() == null) throw new IOException("Artifact HTTP " + response.code()); + if (!response.request().url().isHttps()) throw new IOException("Artifact redirected to insecure HTTP"); + long maximum = 512L * 1024 * 1024; + if (response.body().contentLength() > maximum) throw new IOException("Artifact is too large"); + try (InputStream input = response.body().byteStream(); OutputStream output = Files.newOutputStream(destination)) { + byte[] buffer = new byte[16384]; long total = 0; int count; + while ((count = input.read(buffer)) != -1) { + total += count; + if (total > maximum) throw new IOException("Artifact is too large"); + output.write(buffer, 0, count); + } + } + } + } + + private static ProfileUpdateService.Decision excluded(Profile profile, String target) { + return profile.getGameVersion().equals(target) + ? ProfileUpdateService.Decision.keep("Explicitly excluded by this profile's configuration; not checked") + : ProfileUpdateService.Decision.error("Excluded asset cannot be certified for a different Minecraft version"); + } + + private static Yaml settings(Profile profile, String kind) throws Exception { + Path file = profile.getConfigDirectory().resolve(kind + ".yml"); + if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return null; + if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Not a direct profile configuration file"); + // A stream-backed reader has no output path or watcher and never saves on check. + try (InputStream input = Files.newInputStream(file)) { + Yaml yaml = new Yaml(input, null); + yaml.load(); + return yaml; + } + } + + private static String value(Yaml yaml, String root, String name, String key, String fallback) throws Exception { + if (yaml == null || name == null) return fallback; + YamlSection section = yaml.get(root, name, key); + return section == null || section.asString() == null || section.asString().trim().isEmpty() ? fallback : section.asString().trim(); + } + + private static boolean excluded(Yaml yaml, String root, String name) throws Exception { + return Boolean.parseBoolean(value(yaml, root, name, "exclude", "false")); + } + + private static void requireHttps(String url) throws IOException { + try { + URI uri = URI.create(url); + if (!"https".equalsIgnoreCase(uri.getScheme()) || uri.getHost() == null || uri.getUserInfo() != null) { + throw new IOException("Expected an HTTPS artifact URL without credentials"); + } + } catch (IllegalArgumentException invalid) { throw new IOException("Invalid artifact URL", invalid); } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/mc/update/ProfileUpdateService.java b/src/main/java/com/osiris/autoplug/client/mc/update/ProfileUpdateService.java new file mode 100644 index 00000000..66bc90a3 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/mc/update/ProfileUpdateService.java @@ -0,0 +1,298 @@ +package com.osiris.autoplug.client.mc.update; + +import com.osiris.autoplug.client.mc.profiles.Profile; +import com.osiris.autoplug.client.mc.profiles.ProfileRepository; + +import java.io.IOException; +import java.nio.file.DirectoryStream; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +/** Plans target-version changes first, then applies the approved plan without global configuration. */ +public final class ProfileUpdateService { + public interface Engine { + Decision resolve(Profile profile, Path installedJar, String targetVersion) throws IOException; + void download(Decision decision, Path destination) throws IOException; + } + + public enum Action { KEEP, REPLACE, DISABLE, ERROR } + + public static final class Decision { + private final Action action; + private final String downloadUrl, sha256, explanation; + private Decision(Action action, String url, String hash, String explanation) { + this.action = action; this.downloadUrl = url; this.sha256 = hash; + this.explanation = explanation == null ? "" : explanation; + } + public static Decision keep(String explanation) { return new Decision(Action.KEEP, null, null, explanation); } + public static Decision replace(String url, String hash, String explanation) { + if (url == null || url.trim().isEmpty()) throw new IllegalArgumentException("Missing download URL"); + return new Decision(Action.REPLACE, url, hash, explanation); + } + /** Only use after the provider positively reports no release for the requested version/loader. */ + public static Decision incompatible(String explanation) { return new Decision(Action.DISABLE, null, null, explanation); } + public static Decision error(String explanation) { return new Decision(Action.ERROR, null, null, explanation); } + public Action getAction() { return action; } + public String getDownloadUrl() { return downloadUrl; } + public String getSha256() { return sha256; } + public String getExplanation() { return explanation; } + } + + public static final class Change { + private final String fileName; + private final Decision decision; + private Change(String name, Decision decision) { this.fileName = name; this.decision = decision; } + public String getFileName() { return fileName; } + public Decision getDecision() { return decision; } + public Action getAction() { return decision.getAction(); } + public String getExplanation() { return decision.getExplanation(); } + } + + public static final class Plan { + private final Profile source; + private final String targetId, targetVersion; + private final List changes; + private final Map snapshot; + private final ProfileUpdateService owner; + private Plan(ProfileUpdateService owner, Profile source, String targetId, String version, + List changes, Map snapshot) { + this.owner = owner; this.source = source; this.targetId = targetId; this.targetVersion = version; + this.changes = Collections.unmodifiableList(changes); this.snapshot = snapshot; + } + public Profile getProfile() { return source; } + public String getTargetProfileId() { return targetId; } + public String getTargetVersion() { return targetVersion; } + public boolean isClone() { return !source.getId().equals(targetId); } + public List getChanges() { return changes; } + public boolean canApply() { return changes.stream().noneMatch(c -> c.getAction() == Action.ERROR); } + public List getDisabledAssets() { + List disabled = new ArrayList<>(); + for (Change change : changes) if (change.getAction() == Action.DISABLE) disabled.add(change); + return Collections.unmodifiableList(disabled); + } + public String getSummary() { + StringBuilder result = new StringBuilder(source.getId()).append(" -> ").append(targetId) + .append(" / Minecraft ").append(targetVersion).append(" / ").append(source.getLoader()); + for (Change change : changes) result.append('\n').append(change.getAction()).append(' ') + .append(change.getFileName()).append(": ").append(change.getExplanation()); + if (!getDisabledAssets().isEmpty()) result.append("\nIncompatible jars will be renamed .jar.disabled; explicit approval is required."); + return result.toString(); + } + } + + private final ProfileRepository repository; + private final SharedArtifactCache cache; + private final Engine engine; + + public ProfileUpdateService(ProfileRepository repository, SharedArtifactCache cache, Engine engine) { + this.repository = java.util.Objects.requireNonNull(repository); + this.cache = java.util.Objects.requireNonNull(cache); + this.engine = java.util.Objects.requireNonNull(engine); + } + + /** Read-only check. Neither profile metadata nor configuration is changed. */ + public synchronized Plan check(String profileId, String targetGameVersion) throws IOException { + return check(profileId, targetGameVersion, () -> false); + } + + public synchronized Plan check(String profileId, String targetGameVersion, + java.util.function.BooleanSupplier cancelled) throws IOException { + checkCancelled(cancelled); + return plan(repository.require(profileId), profileId, targetGameVersion, cancelled); + } + + /** Read-only proposal for an independent clone and explicit version migration. */ + public synchronized Plan planClone(String sourceId, String targetId, String targetGameVersion) throws IOException { + return planClone(sourceId, targetId, targetGameVersion, () -> false); + } + + public synchronized Plan planClone(String sourceId, String targetId, String targetGameVersion, + java.util.function.BooleanSupplier cancelled) throws IOException { + checkCancelled(cancelled); + if (sourceId.equals(targetId)) throw new IllegalArgumentException("Clone needs a new profile ID"); + if (repository.find(targetId).isPresent()) throw new IOException("Profile already exists: " + targetId); + return plan(repository.require(sourceId), targetId, targetGameVersion, cancelled); + } + + private Plan plan(Profile profile, String targetId, String version, java.util.function.BooleanSupplier cancelled) throws IOException { + checkCancelled(cancelled); + if (version == null || !version.matches("[A-Za-z0-9][A-Za-z0-9._+-]{0,79}")) { + throw new IllegalArgumentException("Invalid target Minecraft version"); + } + Map before = snapshot(profile.getDirectory()); + List jars = new ArrayList<>(); + try (DirectoryStream entries = Files.newDirectoryStream(profile.getAssetsDirectory())) { + for (Path entry : entries) { + String name = entry.getFileName().toString(); + if (name.endsWith(".jar") || name.endsWith(".jar.disabled")) { + if (!Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Not a direct jar: " + entry); + jars.add(entry); + } + } + } + jars.sort(Comparator.comparing(p -> p.getFileName().toString())); + List changes = new ArrayList<>(); + for (Path jar : jars) { + checkCancelled(cancelled); + Decision decision; + try { + decision = java.util.Objects.requireNonNull(engine.resolve(profile, jar, version)); + if (decision.getAction() == Action.REPLACE && decision.getSha256() != null + && !jar.getFileName().toString().endsWith(".disabled") + && decision.getSha256().equalsIgnoreCase(SharedArtifactCache.sha256(jar))) { + decision = Decision.keep("Already matches the compatible target artifact"); + } + } catch (IOException failure) { + decision = Decision.error(failure.getMessage()); + } + checkCancelled(cancelled); + changes.add(new Change(jar.getFileName().toString(), decision)); + } + checkCancelled(cancelled); + if (!before.equals(snapshot(profile.getDirectory()))) throw new IOException("Profile changed during the check; check again"); + return new Plan(this, profile, targetId, version, changes, before); + } + + /** Download failures leave the old profile intact; unresolved checks are never applied. */ + public synchronized Profile apply(Plan plan, boolean confirmDisabling) throws IOException { + return apply(plan, confirmDisabling, () -> false); + } + + public synchronized Profile apply(Plan plan, boolean confirmDisabling, + java.util.function.BooleanSupplier cancelled) throws IOException { + synchronized (repository) { + return applyLocked(plan, confirmDisabling, cancelled); + } + } + + private Profile applyLocked(Plan plan, boolean confirmDisabling, + java.util.function.BooleanSupplier cancelled) throws IOException { + checkCancelled(cancelled); + if (plan == null || plan.owner != this) throw new IllegalArgumentException("Plan belongs to another updater"); + repository.requireNotInUse(plan.source.getId()); + repository.requireNotInUse(plan.targetId); + if (!plan.canApply()) throw new IOException("Resolve the check errors before applying this plan"); + if (!plan.getDisabledAssets().isEmpty() && !confirmDisabling) throw new IOException("The disabled-asset summary needs explicit approval"); + Profile current = repository.require(plan.source.getId()); + if (!plan.snapshot.equals(snapshot(current.getDirectory()))) throw new IOException("Profile changed since approval; check again"); + if (plan.isClone() && repository.find(plan.targetId).isPresent()) throw new IOException("Clone target already exists"); + + Path staging = Files.createTempDirectory(repository.getProfilesDirectory(), ".update-"); + Profile target = current; + boolean cloned = false, replaced = false; + Path backup = staging.resolve("old-assets"); + try { + Path prepared = staging.resolve("prepared"); + copyTree(current.getAssetsDirectory(), prepared, cancelled); + for (Change change : plan.changes) { + checkCancelled(cancelled); + Path staged = prepared.resolve(change.fileName); + Files.delete(staged); + String enabledName = change.fileName.endsWith(".disabled") + ? change.fileName.substring(0, change.fileName.length() - ".disabled".length()) : change.fileName; + String destinationName = change.getAction() == Action.KEEP ? change.fileName + : change.getAction() == Action.DISABLE ? enabledName + ".disabled" : enabledName; + Path destination = prepared.resolve(destinationName); + if (Files.exists(destination, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Conflicting enabled/disabled jars: " + enabledName); + Path object = change.getAction() == Action.REPLACE + ? cache.fetch(change.decision.sha256, path -> { + checkCancelled(cancelled); + engine.download(change.decision, path); + checkCancelled(cancelled); + }) + : cache.store(current.getAssetsDirectory().resolve(change.fileName)); + checkCancelled(cancelled); + cache.link(object, destination); + } + // Re-check after potentially slow downloads and before the first profile mutation. + if (!plan.snapshot.equals(snapshot(current.getDirectory()))) throw new IOException("Profile changed during preparation; check again"); + checkCancelled(cancelled); + if (plan.isClone()) { + target = repository.cloneProfile(current.getId(), plan.targetId); + cloned = true; + } + checkCancelled(cancelled); + // From this first move through metadata commit, finish or roll back as one unit. + // A cancellation arriving after commit must not misreport a successful update as cancelled. + Files.move(target.getAssetsDirectory(), backup); + try { + Files.move(prepared, target.getAssetsDirectory()); + replaced = true; + target = repository.recordMigratedVersion(target.getId(), plan.targetVersion); + } catch (IOException failure) { + if (replaced) removeTree(target.getAssetsDirectory()); + Files.move(backup, target.getAssetsDirectory()); + throw failure; + } + return target; + } catch (IOException | RuntimeException failure) { + if (cloned) { + try { repository.delete(plan.targetId); } catch (IOException cleanup) { failure.addSuppressed(cleanup); } + } + throw failure; + } finally { + removeTree(staging); + } + } + + private static Map snapshot(Path root) throws IOException { + Map values = new TreeMap<>(); + Files.walkFileTree(root, new SimpleFileVisitor() { + @Override public FileVisitResult preVisitDirectory(Path path, BasicFileAttributes attributes) { + values.put(root.relativize(path).toString() + "/", "directory"); + return FileVisitResult.CONTINUE; + } + @Override public FileVisitResult visitFile(Path path, BasicFileAttributes attributes) throws IOException { + if (!attributes.isRegularFile()) throw new IOException("Linked/special profile files are not supported: " + path); + values.put(root.relativize(path).toString(), SharedArtifactCache.sha256(path)); + return FileVisitResult.CONTINUE; + } + }); + return values; + } + + private static void copyTree(Path source, Path target, java.util.function.BooleanSupplier cancelled) throws IOException { + Files.walkFileTree(source, new SimpleFileVisitor() { + @Override public FileVisitResult preVisitDirectory(Path path, BasicFileAttributes attributes) throws IOException { + checkCancelled(cancelled); + Files.createDirectory(target.resolve(source.relativize(path))); + return FileVisitResult.CONTINUE; + } + @Override public FileVisitResult visitFile(Path path, BasicFileAttributes attributes) throws IOException { + checkCancelled(cancelled); + if (!attributes.isRegularFile()) throw new IOException("Cannot copy linked/special profile file: " + path); + Files.copy(path, target.resolve(source.relativize(path))); + return FileVisitResult.CONTINUE; + } + }); + } + + private static void checkCancelled(java.util.function.BooleanSupplier cancelled) throws IOException { + if (Thread.currentThread().isInterrupted() || (cancelled != null && cancelled.getAsBoolean())) { + throw new java.io.InterruptedIOException("Profile update cancelled before commit; profile unchanged"); + } + } + + private static void removeTree(Path root) throws IOException { + if (!Files.exists(root, LinkOption.NOFOLLOW_LINKS)) return; + Files.walkFileTree(root, new SimpleFileVisitor() { + @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException { + Files.delete(file); return FileVisitResult.CONTINUE; + } + @Override public FileVisitResult postVisitDirectory(Path directory, IOException error) throws IOException { + if (error != null) throw error; + Files.delete(directory); return FileVisitResult.CONTINUE; + } + }); + } +} diff --git a/src/main/java/com/osiris/autoplug/client/mc/update/SharedArtifactCache.java b/src/main/java/com/osiris/autoplug/client/mc/update/SharedArtifactCache.java new file mode 100644 index 00000000..63755a3e --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/mc/update/SharedArtifactCache.java @@ -0,0 +1,141 @@ +package com.osiris.autoplug.client.mc.update; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.EnumSet; +import java.util.Locale; +import java.util.zip.ZipFile; + +/** Content-addressed, read-only jars. Mutable profile configuration never enters this cache. */ +public final class SharedArtifactCache { + public interface Downloader { void download(Path destination) throws IOException; } + private static final long MAX_JAR_BYTES = 512L * 1024 * 1024; + private final Path directory; + + public SharedArtifactCache(Path directory) throws IOException { + Files.createDirectories(directory); + if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Cache must be a direct directory: " + directory); + } + this.directory = directory.toRealPath(); + } + + public Path getDirectory() { return directory; } + + /** The optional publisher SHA-256 is verified before publishing a new cache object. */ + public synchronized Path fetch(String expectedSha256, Downloader downloader) throws IOException { + String expected = normalizeHash(expectedSha256); + if (expected != null) { + Path existing = directory.resolve(expected + ".jar"); + if (Files.exists(existing, LinkOption.NOFOLLOW_LINKS)) { + verifyObject(existing, expected); + return existing; + } + } + Path temporary = Files.createTempFile(directory, ".download-", ".jar"); + try { + downloader.download(temporary); + verifyJar(temporary); + String hash = sha256(temporary); + if (expected != null && !expected.equals(hash)) throw new IOException("Downloaded jar checksum mismatch"); + Path object = directory.resolve(hash + ".jar"); + makeReadOnly(temporary); + try { + Files.move(temporary, object); + } catch (FileAlreadyExistsException race) { + verifyObject(object, hash); + } + verifyObject(object, hash); + return object; + } finally { + // Windows cannot unlink a DOS read-only file after a publication race. + if (Files.exists(temporary, LinkOption.NOFOLLOW_LINKS) + && !Files.getFileStore(temporary).supportsFileAttributeView("posix")) temporary.toFile().setWritable(true); + Files.deleteIfExists(temporary); + } + } + + public Path store(Path localJar) throws IOException { + if (!Files.isRegularFile(localJar, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Not a direct jar: " + localJar); + return fetch(sha256(localJar), destination -> Files.copy(localJar, destination, + java.nio.file.StandardCopyOption.REPLACE_EXISTING)); + } + + /** Creates a hardlink where supported, otherwise an independent read-only copy. */ + public void link(Path object, Path destination) throws IOException { + if (!object.getParent().equals(directory) || !object.getFileName().toString().matches("[0-9a-f]{64}\\.jar")) { + throw new IOException("Not a cache object: " + object); + } + verifyObject(object, object.getFileName().toString().substring(0, 64)); + if (!Files.isDirectory(destination.getParent(), LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Not a direct destination directory"); + } + if (!Files.getFileStore(object).supportsFileAttributeView("posix")) { + // DOS read-only is shared by hardlinks and prevents normal profile deletion. + // Keep the cached object read-only and use an independent profile copy here. + Files.copy(object, destination); + if (!destination.toFile().setWritable(true)) throw new IOException("Cannot make independent profile copy writable"); + return; + } + try { + Files.createLink(destination, object); + } catch (UnsupportedOperationException | java.nio.file.FileSystemException unsupported) { + if (Files.exists(destination, LinkOption.NOFOLLOW_LINKS)) throw unsupported; + Files.copy(object, destination); + makeReadOnly(destination); + } + } + + public static String sha256(Path file) throws IOException { + if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Not a direct regular file: " + file); + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try (InputStream input = Files.newInputStream(file)) { + byte[] buffer = new byte[16384]; + int length; + while ((length = input.read(buffer)) != -1) digest.update(buffer, 0, length); + } + StringBuilder hash = new StringBuilder(64); + for (byte value : digest.digest()) hash.append(String.format(Locale.ROOT, "%02x", value & 255)); + return hash.toString(); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException(impossible); + } + } + + private static String normalizeHash(String hash) { + if (hash == null || hash.isEmpty()) return null; + if (!hash.matches("(?i)[0-9a-f]{64}")) throw new IllegalArgumentException("Invalid SHA-256"); + return hash.toLowerCase(Locale.ROOT); + } + + private static void verifyObject(Path object, String hash) throws IOException { + verifyJar(object); + if (!hash.equals(sha256(object))) throw new IOException("Cached jar checksum mismatch: " + object.getFileName()); + } + + private static void verifyJar(Path jar) throws IOException { + if (!Files.isRegularFile(jar, LinkOption.NOFOLLOW_LINKS) || Files.size(jar) == 0 || Files.size(jar) > MAX_JAR_BYTES) { + throw new IOException("Invalid or oversized jar: " + jar); + } + try (ZipFile zip = new ZipFile(jar.toFile())) { + if (!zip.entries().hasMoreElements()) throw new IOException("Empty jar archive"); + } + } + + private static void makeReadOnly(Path file) throws IOException { + try { + Files.setPosixFilePermissions(file, EnumSet.of(PosixFilePermission.OWNER_READ, + PosixFilePermission.GROUP_READ, PosixFilePermission.OTHERS_READ)); + } catch (UnsupportedOperationException unsupported) { + if (!file.toFile().setReadOnly()) throw new IOException("Cannot make cache object read-only"); + } + } +} From 4cf89cea88d7adee3216d014a398f221777857c1 Mon Sep 17 00:00:00 2001 From: elcuervo171234 <43775780+elcuervo171234@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:22:39 -0400 Subject: [PATCH 09/23] feat(minecraft): integrate worlds (9/23) --- .../autoplug/client/mc/worlds/World.java | 20 + .../client/mc/worlds/WorldRepository.java | 219 +++++++++++ .../client/mc/worlds/WorldSessionManager.java | 351 ++++++++++++++++++ 3 files changed, 590 insertions(+) create mode 100644 src/main/java/com/osiris/autoplug/client/mc/worlds/World.java create mode 100644 src/main/java/com/osiris/autoplug/client/mc/worlds/WorldRepository.java create mode 100644 src/main/java/com/osiris/autoplug/client/mc/worlds/WorldSessionManager.java diff --git a/src/main/java/com/osiris/autoplug/client/mc/worlds/World.java b/src/main/java/com/osiris/autoplug/client/mc/worlds/World.java new file mode 100644 index 00000000..0e8c3738 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/mc/worlds/World.java @@ -0,0 +1,20 @@ +package com.osiris.autoplug.client.mc.worlds; + +import java.nio.file.Path; + +/** An AutoPlug-owned world directory, separate from the global server and vanilla saves. */ +public final class World { + private final String id; + private final String profileId; + private final Path directory; + + World(String id, String profileId, Path directory) { + this.id = id; + this.profileId = profileId; + this.directory = directory; + } + + public String getId() { return id; } + public String getProfileId() { return profileId; } + public Path getDirectory() { return directory; } +} diff --git a/src/main/java/com/osiris/autoplug/client/mc/worlds/WorldRepository.java b/src/main/java/com/osiris/autoplug/client/mc/worlds/WorldRepository.java new file mode 100644 index 00000000..e9ce4fa5 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/mc/worlds/WorldRepository.java @@ -0,0 +1,219 @@ +package com.osiris.autoplug.client.mc.worlds; + +import com.osiris.autoplug.client.mc.profiles.Profile; +import com.osiris.autoplug.client.mc.profiles.ProfileRepository; +import com.osiris.autoplug.client.mc.profiles.ProfileType; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.DirectoryStream; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.Properties; + +/** Metadata and isolated per-world profile snapshots. Never reads or changes vanilla saves. */ +public final class WorldRepository { + private final Path directory; + private final ProfileRepository profiles; + + public WorldRepository(Path dataRoot, ProfileRepository profiles) throws IOException { + Files.createDirectories(dataRoot); + this.directory = dataRoot.toRealPath().resolve("worlds"); + this.profiles = profiles; + if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) Files.createDirectory(directory); + requireDirectory(directory); + } + + public synchronized World create(String id, String profileId) throws IOException { + Path target = path(id); + Profile profile = requireServerProfile(profileId); + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) throw new FileAlreadyExistsException(id); + Path staging = Files.createTempDirectory(directory, ".create-"); + try { + Properties metadata = new Properties(); + metadata.setProperty("formatVersion", "1"); + metadata.setProperty("id", id); + metadata.setProperty("profileId", profile.getId()); + store(staging.resolve("world.properties"), metadata); + Properties eula = new Properties(); + eula.setProperty("eula", "false"); + store(staging.resolve("eula.txt"), eula); + Files.move(staging, target); + return new World(id, profileId, target); + } finally { + deleteTree(staging); + } + } + + public synchronized Optional find(String id) throws IOException { + Path target = path(id); + if (!Files.exists(target, LinkOption.NOFOLLOW_LINKS)) return Optional.empty(); + requireDirectory(target); + Properties values = load(target.resolve("world.properties")); + if (!"1".equals(values.getProperty("formatVersion")) || !id.equals(values.getProperty("id"))) { + throw new IOException("Invalid world metadata: " + id); + } + String profileId = values.getProperty("profileId"); + requireServerProfile(profileId); + return Optional.of(new World(id, profileId, target)); + } + + public synchronized List list() throws IOException { + requireDirectory(directory); + List worlds = new ArrayList<>(); + try (DirectoryStream entries = Files.newDirectoryStream(directory)) { + for (Path entry : entries) { + String id = entry.getFileName().toString(); + if (!id.startsWith(".")) worlds.add(find(id).orElseThrow(() -> new IOException("World disappeared"))); + } + } + worlds.sort(Comparator.comparing(World::getId)); + return worlds; + } + + /** + * Refreshes this world's managed mods/plugins snapshot; world data is untouched. + * Existing per-world config files are preserved. The owner must accept Minecraft's + * EULA themselves before this operation; AutoPlug never writes eula=true. + */ + public synchronized void prepare(World world) throws IOException { + requireOwned(world); + requireEulaAccepted(world); + Profile profile = requireServerProfile(world.getProfileId()); + String assets = profile.getAssetsDirectory().getFileName().toString(); + Path staging = Files.createTempDirectory(world.getDirectory(), ".profile-"); + Path target = world.getDirectory().resolve(assets), config = world.getDirectory().resolve("config"); + Path preparedAssets = staging.resolve("assets"), preparedConfig = staging.resolve("config"); + Path oldAssets = staging.resolve("old-assets"), oldConfig = staging.resolve("old-config"); + boolean assetsBackedUp = false, configBackedUp = false, assetsInstalled = false, configInstalled = false; + boolean preserveRecovery = false; + try { + // Validate and stage both trees before changing either live directory. + copyTree(profile.getAssetsDirectory(), preparedAssets, false); + if (Files.exists(config, LinkOption.NOFOLLOW_LINKS)) copyTree(config, preparedConfig, false); + copyTree(profile.getConfigDirectory(), preparedConfig, true); + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + requireDirectory(target); + Files.move(target, oldAssets); assetsBackedUp = true; + } + try { + if (Files.exists(config, LinkOption.NOFOLLOW_LINKS)) { + Files.move(config, oldConfig); configBackedUp = true; + } + Files.move(preparedAssets, target); assetsInstalled = true; + Files.move(preparedConfig, config); configInstalled = true; + } catch (IOException error) { + try { + if (configInstalled) deleteTree(config); + if (assetsInstalled) deleteTree(target); + if (configBackedUp) Files.move(oldConfig, config); + if (assetsBackedUp) Files.move(oldAssets, target); + } catch (IOException rollback) { + preserveRecovery = true; + error.addSuppressed(new IOException("World preparation rollback failed; recovery data preserved at " + staging, rollback)); + } + throw error; + } + } finally { + if (!preserveRecovery) deleteTree(staging); + } + } + + public static void requireEulaAccepted(World world) throws IOException { + Properties values; + try { + values = load(world.getDirectory().resolve("eula.txt")); + } catch (IOException error) { + throw new IOException("Read the Minecraft EULA and explicitly accept it in this world's eula.txt before playing. AutoPlug does not accept it for you.", error); + } + if (!"true".equals(values.getProperty("eula"))) { + throw new IOException("Read the Minecraft EULA and explicitly accept it in this world's eula.txt before playing. AutoPlug does not accept it for you."); + } + } + + public void requireOwned(World world) throws IOException { + Path expected = path(world.getId()); + if (!expected.equals(world.getDirectory())) throw new IOException("World is outside this repository"); + requireDirectory(expected); + Properties metadata = load(expected.resolve("world.properties")); + if (!"1".equals(metadata.getProperty("formatVersion")) || !world.getId().equals(metadata.getProperty("id")) + || !world.getProfileId().equals(metadata.getProperty("profileId"))) { + throw new IOException("World metadata changed"); + } + } + + private Profile requireServerProfile(String id) throws IOException { + Profile profile = profiles.find(id).orElseThrow(() -> new IOException("Unknown world profile: " + id)); + if (profile.getType() == ProfileType.MODS) throw new IOException("Worlds require a PLUGINS or MODS_SERVER profile"); + return profile; + } + + private Path path(String id) throws IOException { + if (id == null || !id.matches("[a-z0-9][a-z0-9._-]{0,79}")) throw new IOException("Invalid world ID"); + requireDirectory(directory); + return directory.resolve(id); + } + + private static void requireDirectory(Path path) throws IOException { + if (!Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Expected a direct world/profile directory: " + path); + } + + static Properties load(Path path) throws IOException { + if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Expected a regular configuration file: " + path); + Properties values = new Properties(); + try (InputStream input = Files.newInputStream(path)) { values.load(input); } + catch (IllegalArgumentException error) { throw new IOException("Malformed configuration", error); } + return values; + } + + static void store(Path path, Properties values) throws IOException { + if (Files.exists(path, LinkOption.NOFOLLOW_LINKS) + && !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Refusing linked configuration file"); + try (OutputStream output = Files.newOutputStream(path)) { values.store(output, "AutoPlug isolated world"); } + } + + private static void copyTree(Path source, Path target, boolean preserveExisting) throws IOException { + requireDirectory(source); + if (!Files.exists(target, LinkOption.NOFOLLOW_LINKS)) Files.createDirectory(target); + requireDirectory(target); + Files.walkFileTree(source, new SimpleFileVisitor() { + @Override public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { + Path destination = target.resolve(source.relativize(dir)); + if (!Files.exists(destination, LinkOption.NOFOLLOW_LINKS)) Files.createDirectory(destination); + requireDirectory(destination); + return FileVisitResult.CONTINUE; + } + @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + if (!attrs.isRegularFile()) throw new IOException("Refusing linked or special profile content"); + Path destination = target.resolve(source.relativize(file)); + if (preserveExisting && Files.exists(destination, LinkOption.NOFOLLOW_LINKS)) { + if (!Files.isRegularFile(destination, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Refusing linked world config"); + } else Files.copy(file, destination); + return FileVisitResult.CONTINUE; + } + }); + } + + private static void deleteTree(Path root) throws IOException { + if (!Files.exists(root, LinkOption.NOFOLLOW_LINKS)) return; + Files.walkFileTree(root, new SimpleFileVisitor() { + @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Files.delete(file); return FileVisitResult.CONTINUE; + } + @Override public FileVisitResult postVisitDirectory(Path dir, IOException error) throws IOException { + if (error != null) throw error; + Files.delete(dir); return FileVisitResult.CONTINUE; + } + }); + } +} diff --git a/src/main/java/com/osiris/autoplug/client/mc/worlds/WorldSessionManager.java b/src/main/java/com/osiris/autoplug/client/mc/worlds/WorldSessionManager.java new file mode 100644 index 00000000..b1c6e9f4 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/mc/worlds/WorldSessionManager.java @@ -0,0 +1,351 @@ +package com.osiris.autoplug.client.mc.worlds; + +import com.osiris.autoplug.client.mc.sharing.PortSharingService; +import com.osiris.autoplug.client.utils.MineStat; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Properties; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BooleanSupplier; + +/** Per-world processes and lifecycle; deliberately independent of GD and the global server. */ +public final class WorldSessionManager implements AutoCloseable { + public interface ClientLauncher { + Process launch(String host, int port) throws IOException, InterruptedException; + } + public interface ProcessFactory { + Process start(List command, Path directory) throws IOException; + } + public interface ReadinessProbe { + boolean ready(String host, int port) throws IOException; + } + public interface PortAllocator { int allocate() throws IOException; } + + public static final class Options { + final long readinessMillis; + final long stopMillis; + final long pollMillis; + public Options(long readinessMillis, long stopMillis, long pollMillis) { + if (readinessMillis < 1 || stopMillis < 1 || pollMillis < 1) throw new IllegalArgumentException("Timeouts must be positive"); + this.readinessMillis = readinessMillis; + this.stopMillis = stopMillis; + this.pollMillis = pollMillis; + } + } + + private final WorldRepository repository; + private final ProcessFactory processes; + private final ReadinessProbe readiness; + private final PortAllocator ports; + private final PortSharingService sharing; + private final Options options; + private final ConcurrentHashMap active = new ConcurrentHashMap<>(); + private final AtomicBoolean closed = new AtomicBoolean(); + + public WorldSessionManager(WorldRepository repository, PortSharingService sharing) { + this(repository, sharing, (command, directory) -> new ProcessBuilder(command) + .directory(directory.toFile()).redirectErrorStream(true) + .redirectOutput(ProcessBuilder.Redirect.appendTo(directory.resolve("server.log").toFile())).start(), + (host, port) -> new MineStat(host, port, 1, MineStat.Request.JSON).isServerUp(), + () -> { + try (ServerSocket socket = new ServerSocket()) { + socket.bind(new InetSocketAddress(InetAddress.getByName("127.0.0.1"), 0)); + return socket.getLocalPort(); + } + }, new Options(120000, 10000, 250)); + } + + /** Injectable boundaries let fixtures exercise lifecycle without servers, routers or sockets. */ + public WorldSessionManager(WorldRepository repository, PortSharingService sharing, ProcessFactory processes, + ReadinessProbe readiness, PortAllocator ports, Options options) { + this.repository = repository; + this.sharing = sharing; + this.processes = processes; + this.readiness = readiness; + this.ports = ports; + this.options = options; + } + + /** Run off the UI thread. serverCommand must identify a prepared, user-approved server runtime. */ + public Session start(World world, List serverCommand, ClientLauncher launcher, + boolean share, BooleanSupplier cancelled) throws IOException, InterruptedException { + if (closed.get()) throw new IOException("World session manager is closed"); + if (serverCommand == null || serverCommand.isEmpty()) throw new IOException("A prepared server command is required"); + Session session = new Session(world, share); + if (active.putIfAbsent(world.getId(), session) != null) throw new IOException("This world is already starting or running"); + try { + session.checkCancelled(cancelled); + repository.prepare(world); + session.port = ports.allocate(); + if (session.port < 1 || session.port > 65535) throw new IOException("Invalid local port"); + configure(world, session.port, share); + List command = new ArrayList<>(serverCommand); + if (!command.contains("nogui") && !command.contains("--nogui")) command.add("nogui"); + session.attachServer(processes.start(Collections.unmodifiableList(command), world.getDirectory())); + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(options.readinessMillis); + while (true) { + session.checkCancelled(cancelled); + if (!session.server.isAlive()) throw new IOException("World server exited before becoming ready; inspect its server.log"); + if (readiness.ready("127.0.0.1", session.port)) break; + if (System.nanoTime() >= deadline) throw new IOException("World server did not become ready before the timeout"); + Thread.sleep(options.pollMillis); + } + session.checkCancelled(cancelled); + if (share) session.openSharing(); + session.checkCancelled(cancelled); + session.attachClient(launcher.launch("127.0.0.1", session.port)); + synchronized (session) { + session.checkCancelled(cancelled); + session.startFinished = true; + session.monitor = new Thread(() -> { + try { session.client.waitFor(); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + finally { + try { session.close(); } + catch (IOException ignored) { /* close retains the failure and permits a later Stop retry. */ } + } + }, "AutoPlug-world-" + world.getId()); + session.monitor.setDaemon(true); + session.monitor.start(); + session.serverMonitor = new Thread(() -> { + try { session.server.waitFor(); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + finally { + try { session.close(); } + catch (IOException ignored) { /* close retains the failure and permits a later Stop retry. */ } + } + }, "AutoPlug-world-server-" + world.getId()); + session.serverMonitor.setDaemon(true); + session.serverMonitor.start(); + } + return session; + } catch (IOException | InterruptedException | RuntimeException error) { + synchronized (session) { session.startFinished = true; } + try { session.close(); } catch (IOException cleanup) { error.addSuppressed(cleanup); } + throw error; + } + } + + public Session getActive(String worldId) { return active.get(worldId); } + + /** Also cancels an in-flight start, before the client callback has returned. */ + public void cancel(String worldId) throws IOException { + Session session = active.get(worldId); + if (session != null) session.close(); + } + + @Override public void close() throws IOException { + closed.set(true); + IOException failure = null; + for (Session session : new ArrayList<>(active.values())) { + try { session.close(); } + catch (IOException error) { if (failure == null) failure = error; else failure.addSuppressed(error); } + } + if (failure != null) throw failure; + } + + private void configure(World world, int port, boolean share) throws IOException { + Path file = world.getDirectory().resolve("server.properties"); + Properties values = Files.exists(file, LinkOption.NOFOLLOW_LINKS) ? WorldRepository.load(file) : new Properties(); + values.setProperty("server-port", Integer.toString(port)); + // LAN binding is enabled only by the explicit --share option. A router cannot forward to 127.0.0.1. + values.setProperty("server-ip", share ? "0.0.0.0" : "127.0.0.1"); + values.setProperty("enable-status", "true"); + values.setProperty("level-name", "world"); + WorldRepository.store(file, values); + } + + public final class Session implements AutoCloseable { + private final World world; + private final boolean sharingEnabled; + private final AtomicBoolean closing = new AtomicBoolean(); + private volatile Process server; + private volatile Process client; + private volatile Thread monitor; + private volatile Thread serverMonitor; + private volatile int port; + private volatile PortSharingService.Lease mapping; + private volatile String shareMessage = "Not shared; local loopback play only."; + private volatile IOException failure; + private volatile IOException mappingFailure; + private final List closeListeners = new ArrayList<>(); + private final List serverChildren = new ArrayList<>(); + private final List clientChildren = new ArrayList<>(); + private boolean startFinished; + private volatile boolean closeCompleted; + + private Session(World world, boolean sharingEnabled) { + this.world = world; + this.sharingEnabled = sharingEnabled; + } + + public World getWorld() { return world; } + public int getPort() { return port; } + public boolean isClosed() { return closeCompleted; } + public boolean isStopping() { return closing.get() && !closeCompleted; } + public IOException getFailure() { return failure; } + public String getShareMessage() { + PortSharingService.Lease lease = mapping; + return lease != null && lease.isExpired() && !closing.get() + ? "Router sharing lease expired. Choose Share again to request a new finite lease." : shareMessage; + } + public String getJoinAddress() { + PortSharingService.Lease lease = mapping; + return closing.get() || lease == null || lease.isExpired() ? null : lease.getJoinAddress(); + } + + /** Each registration runs once after cleanup, or immediately when cleanup already finished. */ + public void onClose(Runnable listener) { + java.util.Objects.requireNonNull(listener, "listener"); + synchronized (this) { + if (!closeCompleted) { closeListeners.add(listener); return; } + } + listener.run(); + } + + /** A loopback-only server must be restarted with explicit sharing consent. */ + public synchronized String share() throws IOException { + if (closing.get()) throw new IOException("World session is closed"); + if (!sharingEnabled) return "Restart this world with Share enabled to allow LAN/router access. No port was opened."; + if (mapping != null && mapping.isExpired()) cleanupMapping(); + if (mapping == null) openSharing(); + return shareMessage; + } + + private void checkCancelled(BooleanSupplier cancelled) throws IOException, InterruptedException { + if (Thread.currentThread().isInterrupted()) throw new InterruptedException("World launch interrupted"); + if (closed.get() || closing.get() || (cancelled != null && cancelled.getAsBoolean())) throw new IOException("World launch cancelled"); + } + + private synchronized void attachServer(Process process) throws IOException { + if (process == null) throw new IOException("Server factory returned no process"); + server = process; + if (closing.get()) { terminate(process, true, serverChildren); throw new IOException("World launch cancelled"); } + } + + private synchronized void attachClient(Process process) throws IOException { + if (process == null) throw new IOException("Client launcher returned no process"); + client = process; + if (closing.get()) { terminate(process, false, clientChildren); throw new IOException("World launch cancelled"); } + } + + private synchronized void openSharing() { + if (closing.get() || mapping != null) return; + try { + if (sharing == null) throw new IOException("UPnP is not configured"); + mapping = sharing.share(port); + if (mapping == null) throw new IOException("UPnP returned no mapping"); + shareMessage = "Router mapping requested: " + mapping.getJoinAddress() + + ". The router lease lasts up to one hour. The server is also reachable on your LAN. Internet reachability is not verified."; + } catch (IOException error) { + shareMessage = "Sharing unavailable: " + error.getMessage() + + ". The server remains reachable on your LAN. Check router forwarding/CGNAT or use a trusted tunnel; no tunnel is started automatically."; + } + } + + @Override public synchronized void close() throws IOException { + closing.set(true); + IOException error = null; + List listeners; + synchronized (this) { + if (closeCompleted) { cleanupMapping(); return; } + boolean processCleanupFailed = false; + try { if (client != null) terminate(client, false, clientChildren); } + catch (IOException e) { processCleanupFailed = true; error = e; } + try { if (server != null) terminate(server, true, serverChildren); } + catch (IOException e) { processCleanupFailed = true; if (error == null) error = e; else error.addSuppressed(e); } + try { cleanupMapping(); } + catch (IOException e) { if (error == null) error = e; else error.addSuppressed(e); } + failure = error; + // A pending callback may still return an owned process. A failed shutdown may + // also leave a process (or descendant) alive. Retain the session and leases + // until a later close can verify that all owned processes actually exited. + if (!startFinished || processCleanupFailed || mapping != null) { + if (error != null) throw error; + return; + } + closeCompleted = true; + listeners = new ArrayList<>(closeListeners); + closeListeners.clear(); + } + active.remove(world.getId(), this); + Thread thread = monitor; + if (thread != null && thread != Thread.currentThread()) thread.interrupt(); + thread = serverMonitor; + if (thread != null && thread != Thread.currentThread()) thread.interrupt(); + for (Runnable listener : listeners) { + try { listener.run(); } + catch (RuntimeException callbackError) { + IOException wrapped = new IOException("World close callback failed", callbackError); + if (error == null) error = wrapped; else error.addSuppressed(wrapped); + synchronized (this) { + if (failure == null) failure = wrapped; + else if (failure != error) failure.addSuppressed(wrapped); + } + } + } + if (error != null) throw error; + } + + private void cleanupMapping() throws IOException { + if (mapping == null) return; + try { + mapping.close(); + mapping = null; + if (failure == mappingFailure) failure = null; + mappingFailure = null; + } catch (IOException error) { mappingFailure = error; throw error; } + } + } + + /** Attempts graceful server stop, then destroy/force; interruption never skips cleanup. */ + private void terminate(Process process, boolean stopCommand, List descendants) throws IOException { + boolean interrupted = Thread.interrupted(); + try { + try { process.descendants().forEach(child -> { + if (descendants.stream().noneMatch(known -> known.pid() == child.pid())) descendants.add(child); + }); } + catch (UnsupportedOperationException ignored) { /* Process test doubles need not expose OS handles. */ } + if (process.isAlive() && stopCommand) { + try { + process.getOutputStream().write("stop\n".getBytes(StandardCharsets.UTF_8)); + process.getOutputStream().flush(); + } catch (IOException ignored) { /* A closed stdin still needs destroy fallback. */ } + } else if (process.isAlive()) process.destroy(); + try { if (process.isAlive()) process.waitFor(options.stopMillis, TimeUnit.MILLISECONDS); } + catch (InterruptedException e) { interrupted = true; } + if (process.isAlive()) process.destroy(); + try { if (process.isAlive()) process.waitFor(options.stopMillis, TimeUnit.MILLISECONDS); } + catch (InterruptedException e) { interrupted = true; } + if (process.isAlive()) process.destroyForcibly(); + try { if (process.isAlive()) process.waitFor(options.stopMillis, TimeUnit.MILLISECONDS); } + catch (InterruptedException e) { interrupted = true; } + for (ProcessHandle child : descendants) if (child.isAlive()) child.destroy(); + for (ProcessHandle child : descendants) if (child.isAlive()) child.destroyForcibly(); + long childrenDeadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(options.stopMillis); + while (descendants.stream().anyMatch(ProcessHandle::isAlive) && System.nanoTime() < childrenDeadline) { + try { Thread.sleep(Math.min(options.stopMillis, 10)); } + catch (InterruptedException e) { interrupted = true; } + } + if (process.isAlive() || descendants.stream().anyMatch(ProcessHandle::isAlive)) { + throw new IOException("Owned world process did not exit after forced shutdown"); + } + } catch (RuntimeException error) { + throw new IOException("Unable to terminate owned world process", error); + } finally { + if (interrupted) Thread.currentThread().interrupt(); + } + } +} From fcc37fc5225de007059b3b227ea38336fa105f45 Mon Sep 17 00:00:00 2001 From: elcuervo171234 <43775780+elcuervo171234@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:22:48 -0400 Subject: [PATCH 10/23] feat(minecraft): integrate mods (10/23) --- .../tasks/updater/mods/CurseForgeAPI.java | 66 ++++++++++++++++++ .../updater/mods/InstalledModLoader.java | 19 ++++- .../tasks/updater/mods/ModrinthAPI.java | 69 +++++++++++++++++++ .../tasks/updater/mods/TaskModsUpdater.java | 39 +++++++++++ 4 files changed, 192 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/CurseForgeAPI.java b/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/CurseForgeAPI.java index 6506b96c..00c63670 100644 --- a/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/CurseForgeAPI.java +++ b/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/CurseForgeAPI.java @@ -33,6 +33,72 @@ public class CurseForgeAPI { private final String baseUrl = "https://api.curseforge.com/v1"; + public interface ReleaseLoader { JsonArray load(String url) throws Exception; } + private final ReleaseLoader profileReleases; + public CurseForgeAPI() { this(CurseForgeAPI::loadProfileReleases); } + public CurseForgeAPI(ReleaseLoader loader) { profileReleases = java.util.Objects.requireNonNull(loader); } + + private static JsonArray loadProfileReleases(String url) throws Exception { + okhttp3.OkHttpClient client = new okhttp3.OkHttpClient.Builder().followSslRedirects(false) + .connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .callTimeout(45, java.util.concurrent.TimeUnit.SECONDS).build(); + okhttp3.Request request = new okhttp3.Request.Builder().url(url) + .header("User-Agent", "AutoPlug-Client").header("x-api-key", new CurseForgeJson().key).build(); + try (okhttp3.Response response = client.newCall(request).execute()) { + if (!response.isSuccessful() || response.body() == null) throw new IOException("CurseForge HTTP " + response.code()); + return JsonParser.parseString(response.body().string()).getAsJsonObject().getAsJsonArray("data"); + } + } + + /** Profile-only compatibility check. forceLatest and local timestamps are intentionally ignored. */ + public SearchResult searchCompatible(InstalledModLoader loader, MinecraftMod mod, String mcVersion) { + SearchResult result = new SearchResult(null, SearchResult.Type.API_ERROR, null, null, ".jar", null, null, false); + result.mod = mod; + if (mod.curseforgeId == null || !mod.curseforgeId.matches("[0-9]+")) { + result.type = SearchResult.Type.RESOURCE_NOT_FOUND; + return result; + } + try { + JsonArray releases = profileReleases.load(baseUrl + "/mods/" + mod.curseforgeId + + "/files?gameVersion=" + java.net.URLEncoder.encode(mcVersion, "UTF-8") + "&pageSize=50"); + JsonObject selected = null; + boolean unknownLoader = false; + for (JsonElement element : releases) { + JsonObject release = element.getAsJsonObject(); + boolean version = false, matchingLoader = false, knownLoader = false; + for (JsonElement tagElement : release.getAsJsonArray("gameVersions")) { + String tag = tagElement.getAsString(); + if (tag.equals(mcVersion)) version = true; + if (tag.equalsIgnoreCase(loader.getProfileLoader())) matchingLoader = true; + if (tag.equalsIgnoreCase("forge") || tag.equalsIgnoreCase("neoforge") + || tag.equalsIgnoreCase("fabric") || tag.equalsIgnoreCase("quilt")) knownLoader = true; + } + if (version && !knownLoader) unknownLoader = true; + if (version && matchingLoader && (selected == null + || release.get("id").getAsLong() > selected.get("id").getAsLong())) selected = release; + } + if (selected == null) { + if (releases.size() >= 50) throw new IOException("CurseForge results may be paginated; compatibility remains unresolved"); + if (unknownLoader) throw new IOException("CurseForge release does not declare its mod loader"); + result.type = SearchResult.Type.NO_COMPATIBLE_RELEASE; + return result; + } + if (!selected.has("downloadUrl") || selected.get("downloadUrl").isJsonNull()) { + throw new IOException("The publisher does not permit automatic downloads of this CurseForge file"); + } + result.type = SearchResult.Type.UPDATE_AVAILABLE; + result.downloadUrl = selected.get("downloadUrl").getAsString(); + result.fileName = selected.get("fileName").getAsString(); + if (!result.fileName.endsWith(".jar")) throw new IOException("Selected CurseForge artifact is not a jar"); + result.latestVersion = result.fileName; + } catch (Exception error) { + result.type = SearchResult.Type.API_ERROR; + result.exception = error; + } + return result; + } + /** * Requires curseforgeId not null. */ 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..a02f995f 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 @@ -13,7 +13,24 @@ import java.io.File; public class InstalledModLoader { - public boolean isForge, isFabric, isQuilt; + public boolean isForge, isFabric, isQuilt, isNeoForge; + + /** Explicit profile loader; never inspects the server working directory. */ + public InstalledModLoader(String loader) { + String normalized = java.util.Objects.requireNonNull(loader).toLowerCase(java.util.Locale.ROOT); + isFabric = normalized.equals("fabric"); + isQuilt = normalized.equals("quilt"); + isForge = normalized.equals("forge"); + isNeoForge = normalized.equals("neoforge"); + if (!isFabric && !isQuilt && !isForge && !isNeoForge) throw new IllegalArgumentException("Unsupported mod loader: " + loader); + } + + public String getProfileLoader() { + if (isNeoForge) return "neoforge"; + if (isQuilt) return "quilt"; + if (isFabric) return "fabric"; + return "forge"; + } public InstalledModLoader() { try { diff --git a/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/ModrinthAPI.java b/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/ModrinthAPI.java index d45039d8..b3448662 100644 --- a/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/ModrinthAPI.java +++ b/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/ModrinthAPI.java @@ -9,6 +9,8 @@ package com.osiris.autoplug.client.tasks.updater.mods; import com.google.gson.JsonObject; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; import com.osiris.autoplug.client.tasks.updater.plugins.MinecraftPlugin; import com.osiris.autoplug.client.tasks.updater.search.SearchResult; import com.osiris.autoplug.client.utils.UtilsURL; @@ -23,6 +25,73 @@ public class ModrinthAPI { private final String baseUrl = "https://api.modrinth.com/v2"; + /** Injectable transport keeps profile compatibility selection testable without a network. */ + public interface ReleaseLoader { JsonArray load(String url) throws Exception; } + private final ReleaseLoader profileReleases; + + public ModrinthAPI() { this(ModrinthAPI::loadProfileReleases); } + public ModrinthAPI(ReleaseLoader loader) { profileReleases = java.util.Objects.requireNonNull(loader); } + + /** Strict target-version selection, independent of jar timestamps and force-latest. */ + public SearchResult searchCompatible(List loaders, String id, String mcVersion) { + SearchResult result = new SearchResult(null, SearchResult.Type.API_ERROR, null, null, ".jar", null, null, false); + if (id == null || !id.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,127}")) { + result.type = SearchResult.Type.RESOURCE_NOT_FOUND; + return result; + } + try { + String query = "[\"" + String.join("\",\"", loaders) + "\"]"; + String url = baseUrl + "/project/" + id + "/version?loaders=" + + java.net.URLEncoder.encode(query, "UTF-8") + "&game_versions=" + + java.net.URLEncoder.encode("[\"" + mcVersion + "\"]", "UTF-8"); + JsonArray releases = profileReleases.load(url); + for (JsonElement element : releases) { + JsonObject release = element.getAsJsonObject(); + if (!contains(release.getAsJsonArray("game_versions"), List.of(mcVersion)) + || !contains(release.getAsJsonArray("loaders"), loaders)) continue; + JsonArray files = release.getAsJsonArray("files"); + JsonObject selected = null; + for (JsonElement fileElement : files) { + JsonObject file = fileElement.getAsJsonObject(); + if (!file.get("filename").getAsString().endsWith(".jar")) continue; + if (selected == null) selected = file; + if (file.has("primary") && file.get("primary").getAsBoolean()) { selected = file; break; } + } + if (selected == null) throw new java.io.IOException("Compatible release has no jar artifact"); + result.type = SearchResult.Type.UPDATE_AVAILABLE; + result.latestVersion = release.get("version_number").getAsString(); + result.downloadUrl = selected.get("url").getAsString(); + result.fileName = selected.get("filename").getAsString(); + JsonObject hashes = selected.getAsJsonObject("hashes"); + if (hashes != null && hashes.has("sha256")) result.sha256 = hashes.get("sha256").getAsString(); + return result; + } + result.type = SearchResult.Type.NO_COMPATIBLE_RELEASE; + } catch (Exception error) { + result.exception = error; + result.type = SearchResult.Type.API_ERROR; + } + return result; + } + + private static boolean contains(JsonArray values, List expected) { + if (values == null) return false; + for (JsonElement value : values) if (expected.contains(value.getAsString())) return true; + return false; + } + + private static JsonArray loadProfileReleases(String url) throws Exception { + okhttp3.OkHttpClient client = new okhttp3.OkHttpClient.Builder().followSslRedirects(false) + .connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .callTimeout(45, java.util.concurrent.TimeUnit.SECONDS).build(); + okhttp3.Request request = new okhttp3.Request.Builder().url(url).header("User-Agent", "AutoPlug-Client").build(); + try (okhttp3.Response response = client.newCall(request).execute()) { + if (!response.isSuccessful() || response.body() == null) throw new java.io.IOException("Modrinth HTTP " + response.code()); + return com.google.gson.JsonParser.parseString(response.body().string()).getAsJsonArray(); + } + } + private boolean isInt(String s) { try { Integer.parseInt(s); diff --git a/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/TaskModsUpdater.java b/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/TaskModsUpdater.java index e991ca4d..0638cac6 100644 --- a/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/TaskModsUpdater.java +++ b/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/TaskModsUpdater.java @@ -57,13 +57,52 @@ public class TaskModsUpdater extends BThread { private DataOutputStream online_dos; private int updatesAvailable = 0; + private com.osiris.autoplug.client.mc.update.ProfileUpdateService nativeProfileUpdater; + private com.osiris.autoplug.client.mc.update.ProfileUpdateService.Plan nativeProfilePlan; + private String nativeProfileId, nativeTargetVersion; + private boolean nativeApply, nativeConfirmDisabling; + public TaskModsUpdater(String name, BThreadManager manager) { super(name, manager); } + /** Profile path is isolated from the legacy server-wide updater configuration. */ + public TaskModsUpdater(String name, BThreadManager manager, + com.osiris.autoplug.client.mc.update.ProfileUpdateService updater, + String profileId, String targetVersion, boolean apply, boolean confirmDisabling) { + super(name, manager); + nativeProfileUpdater = java.util.Objects.requireNonNull(updater); + nativeProfileId = profileId; + nativeTargetVersion = targetVersion; + if (apply) throw new IllegalArgumentException("Apply requires a previously reviewed Plan constructor"); + nativeApply = false; + nativeConfirmDisabling = confirmDisabling; + } + + /** Applies exactly the plan reviewed by the caller, without silently checking a different plan. */ + public TaskModsUpdater(String name, BThreadManager manager, + com.osiris.autoplug.client.mc.update.ProfileUpdateService updater, + com.osiris.autoplug.client.mc.update.ProfileUpdateService.Plan approvedPlan, boolean confirmDisabling) { + super(name, manager); + nativeProfileUpdater = java.util.Objects.requireNonNull(updater); + nativeProfilePlan = java.util.Objects.requireNonNull(approvedPlan); + nativeApply = true; + nativeConfirmDisabling = confirmDisabling; + } + + public com.osiris.autoplug.client.mc.update.ProfileUpdateService.Plan getNativeProfilePlan() { + return nativeProfilePlan; + } + @Override public void runAtStart() throws Exception { + if (nativeProfileUpdater != null) { + if (nativeProfilePlan == null) nativeProfilePlan = nativeProfileUpdater.check(nativeProfileId, nativeTargetVersion); + addInfo(nativeProfilePlan.getSummary()); + if (nativeApply) nativeProfileUpdater.apply(nativeProfilePlan, nativeConfirmDisabling); + return; + } updaterConfig = new UpdaterConfig(); if (!updaterConfig.mods_updater.asBoolean()) { skip(); From c7d541d273303004ae79a4a46bfae169d77d194b Mon Sep 17 00:00:00 2001 From: elcuervo171234 <43775780+elcuervo171234@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:22:54 -0400 Subject: [PATCH 11/23] feat(minecraft): integrate plugins (11/23) --- .../tasks/updater/plugins/ResourceFinder.java | 21 ++++++++++ .../updater/plugins/TaskPluginsUpdater.java | 39 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/src/main/java/com/osiris/autoplug/client/tasks/updater/plugins/ResourceFinder.java b/src/main/java/com/osiris/autoplug/client/tasks/updater/plugins/ResourceFinder.java index 7d0bf079..30299e72 100644 --- a/src/main/java/com/osiris/autoplug/client/tasks/updater/plugins/ResourceFinder.java +++ b/src/main/java/com/osiris/autoplug/client/tasks/updater/plugins/ResourceFinder.java @@ -26,6 +26,27 @@ public class ResourceFinder { + /** Uses the same providers as the legacy updater, with a strict profile target and explicit loader. */ + public SearchResult findCompatibleProfileMod(InstalledModLoader loader, MinecraftMod mod, String mcVersion) { + String modrinthId = mod.modrinthId; + if (modrinthId == null && mod.curseforgeId != null && !mod.curseforgeId.matches("[0-9]+")) modrinthId = mod.curseforgeId; + SearchResult primary = new ModrinthAPI().searchCompatible(List.of(loader.getProfileLoader()), modrinthId, mcVersion); + primary.mod = mod; + if (SearchResult.isMatchFound(primary)) return primary; + if (mod.curseforgeId == null || !mod.curseforgeId.matches("[0-9]+")) return primary; + SearchResult alternate = new CurseForgeAPI().searchCompatible(loader, mod, mcVersion); + // A network error is unresolved, never proof that the asset is incompatible. + if (SearchResult.isMatchFound(alternate)) return alternate; + if (primary.type == SearchResult.Type.API_ERROR) return primary; + return alternate; + } + + public SearchResult findCompatibleProfilePlugin(List loaders, MinecraftPlugin plugin, String mcVersion) { + SearchResult result = new ModrinthAPI().searchCompatible(loaders, plugin.modrinthId, mcVersion); + result.plugin = plugin; + return result; + } + /** * If the spigot/bukkit id is not given this type of search * based on the plugins' name and author will be executed.
diff --git a/src/main/java/com/osiris/autoplug/client/tasks/updater/plugins/TaskPluginsUpdater.java b/src/main/java/com/osiris/autoplug/client/tasks/updater/plugins/TaskPluginsUpdater.java index cb3fafd1..946b844f 100644 --- a/src/main/java/com/osiris/autoplug/client/tasks/updater/plugins/TaskPluginsUpdater.java +++ b/src/main/java/com/osiris/autoplug/client/tasks/updater/plugins/TaskPluginsUpdater.java @@ -71,12 +71,51 @@ public class TaskPluginsUpdater extends BThread { private int updatesAvailable = 0; private GeneralConfig generalConfig; + private com.osiris.autoplug.client.mc.update.ProfileUpdateService nativeProfileUpdater; + private com.osiris.autoplug.client.mc.update.ProfileUpdateService.Plan nativeProfilePlan; + private String nativeProfileId, nativeTargetVersion; + private boolean nativeApply, nativeConfirmDisabling; + public TaskPluginsUpdater(String name, BThreadManager manager) { super(name, manager); } + /** Profile path is isolated from the legacy server-wide updater configuration. */ + public TaskPluginsUpdater(String name, BThreadManager manager, + com.osiris.autoplug.client.mc.update.ProfileUpdateService updater, + String profileId, String targetVersion, boolean apply, boolean confirmDisabling) { + super(name, manager); + nativeProfileUpdater = java.util.Objects.requireNonNull(updater); + nativeProfileId = profileId; + nativeTargetVersion = targetVersion; + if (apply) throw new IllegalArgumentException("Apply requires a previously reviewed Plan constructor"); + nativeApply = false; + nativeConfirmDisabling = confirmDisabling; + } + + /** Applies exactly the plan reviewed by the caller, without silently checking a different plan. */ + public TaskPluginsUpdater(String name, BThreadManager manager, + com.osiris.autoplug.client.mc.update.ProfileUpdateService updater, + com.osiris.autoplug.client.mc.update.ProfileUpdateService.Plan approvedPlan, boolean confirmDisabling) { + super(name, manager); + nativeProfileUpdater = java.util.Objects.requireNonNull(updater); + nativeProfilePlan = java.util.Objects.requireNonNull(approvedPlan); + nativeApply = true; + nativeConfirmDisabling = confirmDisabling; + } + + public com.osiris.autoplug.client.mc.update.ProfileUpdateService.Plan getNativeProfilePlan() { + return nativeProfilePlan; + } + @Override public void runAtStart() throws Exception { + if (nativeProfileUpdater != null) { + if (nativeProfilePlan == null) nativeProfilePlan = nativeProfileUpdater.check(nativeProfileId, nativeTargetVersion); + addInfo(nativeProfilePlan.getSummary()); + if (nativeApply) nativeProfileUpdater.apply(nativeProfilePlan, nativeConfirmDisabling); + return; + } pluginsConfig = new PluginsConfig(); pluginsConfig.load(); // No lock needed, since there are no other threads that access this file String name = pluginsConfig.getFileNameWithoutExt(); From 2752fbbaca100ffbea47916b0ea9db1fe8158f7f Mon Sep 17 00:00:00 2001 From: elcuervo171234 <43775780+elcuervo171234@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:22:59 -0400 Subject: [PATCH 12/23] feat(minecraft): integrate search (12/23) --- .../autoplug/client/tasks/updater/search/SearchResult.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/osiris/autoplug/client/tasks/updater/search/SearchResult.java b/src/main/java/com/osiris/autoplug/client/tasks/updater/search/SearchResult.java index 9c6c9d26..579f3468 100644 --- a/src/main/java/com/osiris/autoplug/client/tasks/updater/search/SearchResult.java +++ b/src/main/java/com/osiris/autoplug/client/tasks/updater/search/SearchResult.java @@ -21,7 +21,7 @@ public class SearchResult { public static boolean isMatchFound(SearchResult sr) { - return sr != null && sr.type != Type.API_ERROR && sr.type != Type.RESOURCE_NOT_FOUND; + return sr != null && sr.type != Type.API_ERROR && sr.type != Type.RESOURCE_NOT_FOUND && sr.type != Type.NO_COMPATIBLE_RELEASE; } // TODO remove getters and setters and make everything public for easier future coding @@ -38,6 +38,8 @@ public static boolean isMatchFound(SearchResult sr) { public Type type; public Exception exception; public String fileName; + /** Optional publisher checksum for the exact selected artifact. */ + public String sha256; /** * @param type All codes:
@@ -127,7 +129,8 @@ public enum Type { RESOURCE_NOT_FOUND((byte) 3), RESOURCE_EXCLUDED((byte) 4), UPDATE_DOWNLOADED((byte) 5), - UPDATE_INSTALLED((byte) 6); + UPDATE_INSTALLED((byte) 6), + NO_COMPATIBLE_RELEASE((byte) 7); public byte id = 0; From c03e170f03b2ae8bbf21c68df1f8c3c4e809ab09 Mon Sep 17 00:00:00 2001 From: elcuervo171234 <43775780+elcuervo171234@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:23:07 -0400 Subject: [PATCH 13/23] feat(minecraft): integrate ui (13/23) --- .../autoplug/client/ui/ClientPanel.java | 27 +- .../osiris/autoplug/client/ui/MainWindow.java | 14 +- .../client/ui/NativeMinecraftPanel.java | 261 ++++++++++++++++++ 3 files changed, 270 insertions(+), 32 deletions(-) create mode 100644 src/main/java/com/osiris/autoplug/client/ui/NativeMinecraftPanel.java 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..016f7a37 100644 --- a/src/main/java/com/osiris/autoplug/client/ui/ClientPanel.java +++ b/src/main/java/com/osiris/autoplug/client/ui/ClientPanel.java @@ -9,6 +9,7 @@ package com.osiris.autoplug.client.ui; import com.osiris.autoplug.client.Target; +import com.osiris.autoplug.client.mc.client.NativeClientAccess; import com.osiris.autoplug.client.console.Commands; import com.osiris.autoplug.client.ui.utils.HintTextField; import com.osiris.autoplug.client.ui.utils.MyMouseListener; @@ -33,14 +34,7 @@ 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 - - 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 + tabs.addTab("Minecraft", new NativeMinecraftPanel(NativeClientAccess.get())); MinecraftModsPanel minecraftMods = new MinecraftModsPanel(tabs); tabs.addTab("Mods", minecraftMods); @@ -55,21 +49,4 @@ public ClientPanel(Container parent) throws Exception { tabs.addTab("Settings", settingsPanel); } - private JButton getBtnMinecraftLaunch() { - JButton btn = new JButton("Launch Minecraft"); - btn.addMouseListener(new MyMouseListener().onClick(event -> { - - })); - return btn; - } - - private java.util.List toLabel(String ansi) { - // TODO convert ansi colors to awt - String[] lines = ansi.split("\n"); - java.util.List list = new ArrayList<>(); - for (String line : lines) { - list.add(new JLabel(line)); - } - return list; - } } diff --git a/src/main/java/com/osiris/autoplug/client/ui/MainWindow.java b/src/main/java/com/osiris/autoplug/client/ui/MainWindow.java index 60c29f6b..25139dbf 100644 --- a/src/main/java/com/osiris/autoplug/client/ui/MainWindow.java +++ b/src/main/java/com/osiris/autoplug/client/ui/MainWindow.java @@ -103,7 +103,7 @@ public void start() throws Exception { Image image = Toolkit.getDefaultToolkit().getImage(icon.getAbsolutePath()); trayIcon = new TrayIcon(image, "AutoPlug", null); trayIcon.addMouseListener(new MyMouseListener().onClick(event -> { - this.setVisible(true); + SwingUtilities.invokeLater(() -> { this.setVisible(true); this.toFront(); }); })); trayIcon.setImageAutoSize(true); try { @@ -135,12 +135,12 @@ public void start() throws Exception { private void initUI() throws Exception { // TODO dont stop full autoplug when this window is closed this.setDefaultCloseOperation(JFrame.HIDE_ON_CLOSE); - this.setName("AutoPlug-Tray"); - this.setTitle("AutoPlug-Tray"); - this.setUndecorated(true); + this.setName("AutoPlug-Dashboard"); + this.setTitle("AutoPlug — Minecraft & Server Dashboard"); + this.setResizable(true); int screenWidth = Toolkit.getDefaultToolkit().getScreenSize().width, screenHeight = Toolkit.getDefaultToolkit().getScreenSize().height; - int width = (int) (screenWidth / 1.5), height = screenHeight / 2; - this.setShape(new RoundRectangle2D.Double(0, 0, width, height, 20, 20)); + int width = Math.min(screenWidth, Math.max(1024, (int) (screenWidth / 1.5))), height = Math.min(screenHeight, Math.max(680, (int)(screenHeight * 0.75))); + this.setMinimumSize(new Dimension(Math.min(900, screenWidth), Math.min(600, screenHeight))); this.setLocation((screenWidth / 2) - (width / 2), (screenHeight / 2) - (height / 2)); // Position frame in mid of screen this.setSize(width, height); this.setVisible(false); @@ -154,7 +154,7 @@ private void initUI() throws Exception { titleAutoPlug.putClientProperty("FlatLaf.style", "font: 200% $semibold.font"); thisLy.addH(titleAutoPlug); - titleTray.setText(" | Tray"); + titleTray.setText(" | Dashboard"); titleTray.putClientProperty("FlatLaf.style", "font: 200% $light.font"); thisLy.addH(titleTray).delPadding().paddingTop(); diff --git a/src/main/java/com/osiris/autoplug/client/ui/NativeMinecraftPanel.java b/src/main/java/com/osiris/autoplug/client/ui/NativeMinecraftPanel.java new file mode 100644 index 00000000..58102899 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/ui/NativeMinecraftPanel.java @@ -0,0 +1,261 @@ +package com.osiris.autoplug.client.ui; + +import com.osiris.autoplug.client.mc.client.*; +import com.osiris.autoplug.client.mc.launch.Cancellation; +import com.osiris.autoplug.client.mc.launch.MicrosoftDeviceCodeAuth; +import com.osiris.autoplug.client.mc.profiles.*; +import com.osiris.autoplug.client.mc.update.ProfileUpdateService; +import com.osiris.autoplug.client.mc.worlds.*; +import javax.swing.*; +import javax.swing.table.DefaultTableModel; +import java.awt.*; +import java.awt.datatransfer.StringSelection; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.function.Consumer; + +/** Native MIT launcher UI. Blocking work runs on one cancellable worker, never the EDT. */ +public final class NativeMinecraftPanel extends JPanel { + private static final String[] LOADERS = {"VANILLA", "FABRIC", "QUILT", "FORGE", "NEOFORGE"}; + private final NativeClientContext context; + private final DefaultTableModel serverRows = rows("Name", "Address", "Loader", "Status"); + private final DefaultTableModel profileRows = rows("ID", "Type", "Minecraft", "Loader", "Template"); + private final DefaultTableModel worldRows = rows("Preview", "World", "Server profile", "State"); + private final JTable servers = new JTable(serverRows), profiles = new JTable(profileRows), worlds = new JTable(worldRows); + private final JTextArea log = new JTextArea(5, 70); + private final JLabel activity = new JLabel("Ready"), account = new JLabel(); + private final JProgressBar progress = new JProgressBar(); + private final JButton cancel = new JButton("Cancel operation"); + private final List actions = new ArrayList<>(); + private List serverValues = new ArrayList<>(); + private List profileValues = new ArrayList<>(); + private List worldValues = new ArrayList<>(); + private volatile Cancellation cancellation; + private volatile boolean busy; + + public NativeMinecraftPanel(NativeClientContext context) { + super(new BorderLayout(8, 8)); this.context = context; + setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); + JTabbedPane tabs = new JTabbedPane(); + tabs.addTab("Servers", serverPanel()); tabs.addTab("Profiles", profilePanel()); + tabs.addTab("Virtual worlds", worldPanel()); tabs.addTab("Account & settings", settingsPanel()); + add(tabs, BorderLayout.CENTER); + log.setEditable(false); log.setLineWrap(true); log.setWrapStyleWord(true); + JPanel footer = new JPanel(new BorderLayout()); + JPanel state = new JPanel(new BorderLayout(8, 0)); state.add(activity); state.add(progress, BorderLayout.EAST); + footer.add(state, BorderLayout.NORTH); footer.add(new JScrollPane(log)); footer.add(cancel, BorderLayout.EAST); + cancel.setEnabled(false); cancel.addActionListener(e -> { Cancellation token = cancellation; if (token != null) token.cancel(); }); + add(footer, BorderLayout.SOUTH); + run("Load profiles and saved servers", () -> { + Path vanilla = defaultMinecraftDirectory().resolve("servers.dat"); + if (context.servers().list().isEmpty() && Files.isRegularFile(vanilla)) context.servers().importVanilla(vanilla, "VANILLA"); + return load(); + }, this::render); + } + + private static DefaultTableModel rows(String... columns) { + return new DefaultTableModel(columns, 0) { @Override public boolean isCellEditable(int row, int column) { return false; } @Override public Class getColumnClass(int column){return columns[column].equals("Preview")?Icon.class:Object.class;} }; + } + private JPanel tablePanel(JTable table, JPanel buttons) { + table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); table.setAutoCreateRowSorter(true); + JPanel panel = new JPanel(new BorderLayout(4,4)); panel.add(new JScrollPane(table)); + JScrollPane toolbar = new JScrollPane(buttons,ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER,ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED); + toolbar.setBorder(BorderFactory.createEmptyBorder()); + toolbar.setPreferredSize(new Dimension(0,buttons.getPreferredSize().height+18)); + panel.add(toolbar,BorderLayout.SOUTH); return panel; + } + private JButton button(JPanel panel, String label, Runnable operation) { + JButton button = new JButton(label); button.addActionListener(e -> { try { operation.run(); } catch (Exception error) { showError(error); } }); + actions.add(button); panel.add(button); return button; + } + private JPanel serverPanel() { + JPanel buttons = new JPanel(new FlowLayout(FlowLayout.LEFT)); + button(buttons,"Add", () -> editServer(null)); button(buttons,"Edit", () -> editServer(server())); + button(buttons,"Remove", () -> { SavedServerRepository.SavedServer s = server(); if (confirm("Remove saved server " + s.getName() + "? Minecraft's servers.dat will be preserved.")) mutate("Remove server", () -> {context.servers().delete(s.getId()); return null;}); }); + button(buttons,"Import servers.dat", () -> { Path path = chooseFile("Choose Minecraft servers.dat"); if(path != null) mutate("Import servers", () -> context.servers().importVanilla(path,"VANILLA")); }); + button(buttons,"Refresh status", () -> { SavedServerRepository.SavedServer s=server(); run("Contact server",()->context.ping(s.getAddress()),status->{ int index=serverValues.indexOf(s); if(index>=0)serverRows.setValueAt(status.toString(),index,3); }); }); + button(buttons,"Join", () -> joinServer(server())); + return tablePanel(servers,buttons); + } + private void editServer(SavedServerRepository.SavedServer existing) { + JTextField name=new JTextField(existing==null?"":existing.getName()), address=new JTextField(existing==null?"":existing.getAddress()); + JComboBox loader=new JComboBox<>(LOADERS); if(existing!=null)loader.setSelectedItem(existing.getLoader()); + if(!form("Saved server", new String[]{"Name","Address (host:port)","Required loader (not discoverable by ping)"},new JComponent[]{name,address,loader}))return; + String savedName=name.getText(),savedAddress=address.getText(),savedLoader=loader.getSelectedItem().toString(); + mutate("Save server",()-> { if(existing==null)context.servers().add(savedName,savedAddress,savedLoader); else context.servers().update(existing.getId(),savedName,savedAddress,savedLoader); return null; }); + } + private void joinServer(SavedServerRepository.SavedServer server) { + run("Read server version",()->context.ping(server.getAddress()),status->{ + String detected=detectVersion(status.getVersion());String version=detected.isEmpty()?input("Minecraft version (server did not report one; check its requirements)",""):detected; if(version==null)return; + run("Find compatible local profiles",()->new ProfileResolver(context.profiles()).resolve(version,server.getLoader(),emptyToNull(context.settings().get("defaultTemplate",""))),resolution->{ + if(resolution.getState()==ProfileResolver.State.READY || resolution.getState()==ProfileResolver.State.CHOOSE_PROFILE) { + Profile selected=resolution.getProfiles().size()==1?resolution.getProfiles().get(0):chooseProfile(resolution.getProfiles(),"Client profile"); if(selected!=null)prepareJoin(selected,server); + } else if(resolution.getState()==ProfileResolver.State.CONFIRM_TEMPLATE_CLONE) { + Profile template=resolution.getProfiles().get(0); String id=input("New profile cloned from " + template.getId(),"client-"+version); if(id==null)return; + run("Check template for target version",()->context.updater().planClone(template.getId(),id,version,cancellation::isCancelled),plan->approvePlan(plan,p->launch(p,server.getAddress()))); + } else { + String id=input("No matching profile. Create a client profile ID", "client-"+version); if(id==null)return; + run("Create client profile",()->context.profiles().create(id,ProfileType.MODS,version,server.getLoader(),false),p->prepareJoin(p,server)); + } + }); + }); + } + private void prepareJoin(Profile profile,SavedServerRepository.SavedServer server) { + run("Check compatible mods",()->context.updater().check(profile.getId(),profile.getGameVersion(),cancellation::isCancelled), plan->{if(plan.canApply()&&plan.getChanges().stream().allMatch(change->change.getAction()==ProfileUpdateService.Action.KEEP))launch(profile,server.getAddress());else approvePlan(plan,p->launch(p,server.getAddress()));}); + } + private void launch(Profile profile,String address) { + if(profile.getType()!=ProfileType.MODS||profile.isTemplate())throw new IllegalArgumentException("Select a non-template client profile"); + run("Prepare and launch Minecraft",()-> { context.ensureLoader(profile.getId(),cancellation); return context.launch(profile.getId(),address,cancellation); }, process->{append("Started profile " + profile.getId() + ". Use Stop client to close it.");run("Reload running profiles",this::load,this::render);}); + } + + private JPanel profilePanel() { + JPanel buttons=new JPanel(new FlowLayout(FlowLayout.LEFT)); + button(buttons,"Create",this::createProfile); button(buttons,"Clone / change version",()->planProfile(true)); + button(buttons,"Check / update",()->planProfile(false)); + button(buttons,"Rename",()-> { Profile p=profile(); String id=input("New profile ID",p.getId()); if(id!=null)mutate("Rename profile",()->{ensureUnreferenced(p); context.profiles().rename(p.getId(),id); if(p.getId().equals(context.settings().get("defaultTemplate",""))){context.settings().set("defaultTemplate",id);context.settings().save();} return null;}); }); + button(buttons,"Delete",()-> { Profile p=profile(); if(confirm("Permanently delete profile " + p.getId() + " and its local files?"))mutate("Delete profile",()->{ensureUnreferenced(p);context.profiles().delete(p.getId());if(p.getId().equals(context.settings().get("defaultTemplate",""))){context.settings().set("defaultTemplate","");context.settings().save();}return null;}); }); + button(buttons,"Toggle template",()-> { Profile p=profile();mutate("Change template",()->{if(!p.isTemplate())ensureUnreferenced(p);return context.profiles().updateTemplate(p.getId(),!p.isTemplate());}); }); + button(buttons,"Install loader",()-> {Profile p=profile();String version=input("Loader version (blank = official stable/recommended)","");if(version!=null)run("Install official loader",()->context.installLoader(p.getId(),emptyToNull(version),cancellation),id->append("Installed "+id));}); + button(buttons,"Launch",()->launch(profile(),null)); button(buttons,"Stop client",()->{ Profile p=profile();run("Stop client",()->{context.stopClient(p.getId());return load();},this::render); }); + button(buttons,"Refresh",()->run("Reload",this::load,this::render)); + return tablePanel(profiles,buttons); + } + private void ensureUnreferenced(Profile profile)throws IOException { + for(World world:context.worlds().list()){ + if(world.getProfileId().equals(profile.getId()))throw new IOException("Profile is referenced by world " + world.getId()); + Path config=world.getDirectory().resolve("world-launch.properties");if(Files.exists(config,LinkOption.NOFOLLOW_LINKS)){ + if(!Files.isRegularFile(config,LinkOption.NOFOLLOW_LINKS))throw new IOException("Invalid world launch configuration"); + java.util.Properties saved=new java.util.Properties();try(java.io.InputStream input=Files.newInputStream(config,LinkOption.NOFOLLOW_LINKS)){saved.load(input);} + if(profile.getId().equals(saved.getProperty("clientProfileId")))throw new IOException("Client profile is configured for world "+world.getId()+"; reconfigure the world first"); + } + } + } + private void createProfile() { + JTextField id=new JTextField(),version=new JTextField("1.20.4");JComboBox type=new JComboBox<>(ProfileType.values());JComboBox loader=new JComboBox<>(LOADERS);type.addActionListener(e->loader.setModel(new DefaultComboBoxModel<>(type.getSelectedItem()==ProfileType.PLUGINS?new String[]{"PAPER","SPIGOT","PURPUR","BUKKIT","FOLIA"}:LOADERS)));JCheckBox template=new JCheckBox("Template (clone before launch)"); + if(form("New isolated profile",new String[]{"ID","Type","Minecraft version","Loader",""},new JComponent[]{id,type,version,loader,template})){ + String savedId=id.getText(),savedVersion=version.getText(),savedLoader=loader.getSelectedItem().toString();ProfileType savedType=(ProfileType)type.getSelectedItem();boolean savedTemplate=template.isSelected(); + mutate("Create profile",()->context.profiles().create(savedId,savedType,savedVersion,savedLoader,savedTemplate)); + } + } + private void planProfile(boolean clone) { + Profile p=profile();String target=input("Target Minecraft version",p.getGameVersion());if(target==null)return; + String id=clone?input("New profile ID",p.getId()+"-copy"):p.getId();if(id==null)return; + run("Resolve compatible assets",()->clone?context.updater().planClone(p.getId(),id,target,cancellation::isCancelled):context.updater().check(p.getId(),target,cancellation::isCancelled),plan->approvePlan(plan, ignored->run("Reload profiles",this::load,this::render))); + } + private void approvePlan(ProfileUpdateService.Plan plan,Consumer next) { + JTextArea text=new JTextArea(plan.getSummary(),14,65);text.setEditable(false);text.setCaretPosition(0); + if(!plan.canApply()){JOptionPane.showMessageDialog(this,new JScrollPane(text),"Check failed; profile unchanged",JOptionPane.ERROR_MESSAGE);return;} + if(JOptionPane.showConfirmDialog(this,new JScrollPane(text),"Apply this plan? Disabled jars require this approval",JOptionPane.OK_CANCEL_OPTION)!=JOptionPane.OK_OPTION)return; + run("Apply approved plan",()->context.updater().apply(plan,true,cancellation::isCancelled),p->{append("Updated " + p.getId()); next.accept(p);}); + } + + private JPanel worldPanel() { + JPanel buttons=new JPanel(new FlowLayout(FlowLayout.LEFT)); + button(buttons,"Create",()-> { List candidates=new ArrayList<>();for(Profile p:profileValues)if(p.getType()!=ProfileType.MODS&&!p.isTemplate())candidates.add(p);Profile p=chooseProfile(candidates,"Server profile");if(p==null)return;String id=input("World ID","");if(id!=null)mutate("Create world",()->context.worlds().create(id,p.getId())); }); + button(buttons,"Configure",this::configureWorld);button(buttons,"Review EULA",this::worldEula); + button(buttons,"Play",()->playWorld(false));button(buttons,"Play & share",()->playWorld(true)); + button(buttons,"Share address",()-> { World w=world();run("Get public join address",()->{WorldSessionManager.Session s=context.activeWorld(w.getId());if(s==null)throw new IOException("Start this world with Play & share first");if(s.getJoinAddress()==null)s.share();return s;},s->{String address=s.getJoinAddress();if(address==null)append(s.getShareMessage());else{Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new StringSelection(address),null);append("Copied join address: "+address);} }); }); + button(buttons,"Stop",()-> {World w=world();mutate("Stop world",()->{context.stopWorld(w.getId());return null;});}); + button(buttons,"Refresh",()->run("Reload worlds",this::load,this::render)); + JPanel panel=tablePanel(worlds,buttons);worlds.setRowHeight(52);panel.add(new JLabel("Worlds are local servers with independent saves. Multiplayer requires matching server mods/plugins."),BorderLayout.NORTH);return panel; + } + private void worldEula() { + World w=world();JCheckBox accepted=new JCheckBox("I have read and accept the Minecraft EULA for this server"); + JPanel panel=new JPanel(new BorderLayout());JTextArea terms=new JTextArea("Review https://www.minecraft.net/eula before accepting.\nThis changes only this world's eula.txt.\nWorld: "+w.getDirectory());terms.setEditable(false);panel.add(terms);panel.add(accepted,BorderLayout.SOUTH); + if(JOptionPane.showConfirmDialog(this,panel,"Minecraft EULA",JOptionPane.OK_CANCEL_OPTION)==JOptionPane.OK_OPTION&&accepted.isSelected())mutate("Record user's EULA acceptance",()->{ context.worlds().requireOwned(w);Files.write(w.getDirectory().resolve("eula.txt"),"eula=true\n".getBytes(StandardCharsets.UTF_8),StandardOpenOption.WRITE,StandardOpenOption.CREATE,StandardOpenOption.TRUNCATE_EXISTING,LinkOption.NOFOLLOW_LINKS);return null;}); + } + private void configureWorld() { + World w=world(); + Profile serverProfile=profileValues.stream().filter(p->p.getId().equals(w.getProfileId())).findFirst().orElseThrow(()->new IllegalArgumentException("Missing server profile")); + List candidates=new ArrayList<>(); + for(Profile p:profileValues)if(p.getType()==ProfileType.MODS&&!p.isTemplate()&&p.getGameVersion().equals(serverProfile.getGameVersion())&&(serverProfile.getType()!=ProfileType.MODS_SERVER||p.getLoader().equalsIgnoreCase(serverProfile.getLoader())))candidates.add(p); + Profile client=chooseProfile(candidates,"Matching client profile");if(client==null)return; + Path jar=chooseFile("Choose the installed server jar matching this world's server profile");if(jar==null)return; + mutate("Save world launch settings",()->{context.configureWorld(w.getId(),client.getId(),jar);return null;}); + } + private void playWorld(boolean share) { + World w=world(); + if(share&&!confirm("Share this local server? It will bind to your LAN and request a temporary UPnP router mapping. UPnP/CGNAT may prevent public access. Stop closes the mapping."))return; + run("Check world launch configuration",()->context.worldConfig(w.getId()),config->{ + run("Prepare local server and client",()->{ + context.worlds().requireEulaAccepted(w); // validates consent without changing a running world + context.ensureLoader(config.getClientProfileId(),cancellation); + return context.playWorld(w.getId(),share,cancellation); + },session->{append("World ready on port "+session.getPort()+". "+session.getShareMessage());run("Reload worlds",this::load,this::render);}); + }); + } + + private JPanel settingsPanel() { + JPanel panel=new JPanel(new BorderLayout());JPanel fields=new JPanel(new GridLayout(0,2,8,8)); + JTextField clientId=new JTextField(context.settings().get("clientId","")),template=new JTextField(context.settings().get("defaultTemplate","")), username=new JTextField(context.settings().get("username","Player")),cache=new JTextField(context.settings().get("cacheDirectory","")); + JTextArea javaHomes=new JTextArea(context.settings().get("javaHomes",""),4,35);JCheckBox offline=new JCheckBox("Offline fixture mode (no licensed online play)",Boolean.parseBoolean(context.settings().get("offlineFixtureMode","false"))); + String[] labels={"Registered Microsoft application client ID","Default template ID (optional)","Java homes (one per line)","Cache directory (restart required)","Offline username","Local testing"}; + JComponent[] components={clientId,template,new JScrollPane(javaHomes),cache,username,offline};for(int i=0;i{String id=clientId.getText().trim(),base=template.getText().trim(),homes=javaHomes.getText(),cachePath=cache.getText().trim(),user=username.getText().trim(),fixture=Boolean.toString(offline.isSelected());mutate("Save settings",()-> {context.settings().set("clientId",id);context.settings().set("defaultTemplate",base);context.settings().set("javaHomes",homes);context.settings().set("cacheDirectory",cachePath);context.settings().set("username",user);context.settings().set("offlineFixtureMode",fixture);context.settings().save();return null;});}); + button(buttons,"Sign in with Microsoft",()->signIn(offline));button(buttons,"Sign out",()->{context.signOut();account.setText(context.getAccountLabel());});account.setText(context.getAccountLabel());buttons.add(account);panel.add(buttons,BorderLayout.SOUTH);return panel; + } + private void signIn(JCheckBox offlineMode) { + run("Request Microsoft device code",()->context.authenticator().begin(cancellation),authorization->{ + JTextArea instruction=new JTextArea("Open in your browser: "+authorization.getVerificationUri()+"\nEnter this code: "+authorization.getUserCode()+"\nContinue only after reviewing the account and permissions on Microsoft's site.");instruction.setEditable(false); + if(JOptionPane.showConfirmDialog(this,instruction,"Microsoft sign-in",JOptionPane.OK_CANCEL_OPTION)!=JOptionPane.OK_OPTION)return; + run("Waiting for Microsoft sign-in (Cancel to stop)",()->{MicrosoftDeviceCodeAuth.AuthResult result=context.authenticator().poll(authorization,cancellation);cancellation.check();context.signedIn(result);context.settings().set("offlineFixtureMode","false");context.settings().save();return result;},result->{offlineMode.setSelected(false);account.setText(context.getAccountLabel());append("Signed in as "+context.getAccountLabel());}); + }); + } + + private final class Snapshot { + List servers;List profiles;List worlds;java.util.Map previews=new java.util.HashMap<>(); + } + private Snapshot load()throws IOException {Snapshot state=new Snapshot();state.servers=context.servers().list();state.profiles=context.profiles().list();state.worlds=context.worlds().list();for(World world:state.worlds){context.worlds().requireOwned(world);state.previews.put(world.getId(),worldPreview(world.getDirectory()));}return state;} + private void render(Snapshot state) { + serverValues=state.servers;profileValues=state.profiles;worldValues=state.worlds;serverRows.setRowCount(0);profileRows.setRowCount(0);worldRows.setRowCount(0); + for(SavedServerRepository.SavedServer s:serverValues)serverRows.addRow(new Object[]{s.getName(),s.getAddress(),s.getLoader(),"Not checked"}); + for(Profile p:profileValues)profileRows.addRow(new Object[]{p.getId(),p.getType(),p.getGameVersion(),p.getLoader(),p.isTemplate()}); + for(World w:worldValues){WorldSessionManager.Session session=context.activeWorld(w.getId());worldRows.addRow(new Object[]{state.previews.get(w.getId()),w.getId(),w.getProfileId(),session==null?"Stopped":session.getFailure()!=null?"Failed: "+session.getFailure().getMessage():"Running :"+session.getPort()});} + account.setText(context.getAccountLabel()); + } + /** Decode only small, direct PNG previews, off the UI thread. */ + static Icon worldPreview(Path world) { + for(String relative:new String[]{"server-icon.png","world/icon.png"}) { + Path path=world.resolve(relative); + try { + if(!Files.isRegularFile(path,LinkOption.NOFOLLOW_LINKS)||Files.size(path)>1024*1024||!path.toRealPath().startsWith(world.toRealPath()))continue; + try(javax.imageio.stream.ImageInputStream input=javax.imageio.ImageIO.createImageInputStream(path.toFile())) { + java.util.Iterator readers=javax.imageio.ImageIO.getImageReaders(input); + if(!readers.hasNext())continue;javax.imageio.ImageReader reader=readers.next(); + try {reader.setInput(input);if(!reader.getFormatName().equalsIgnoreCase("png")||reader.getWidth(0)>512||reader.getHeight(0)>512)continue; + return new ImageIcon(reader.read(0).getScaledInstance(48,48,Image.SCALE_SMOOTH)); + } finally {reader.dispose();} + } + }catch(IOException|RuntimeException ignored){ /* A missing/corrupt preview never blocks playing a world. */ } + } + return null; + } + private void mutate(String label,Callable operation){run(label,()->{T value=operation.call();return load();},this::render);} + private void run(String label,Callable operation,Consumer complete) { + if(busy){append("Wait for the current operation or cancel it.");return;}busy=true;cancellation=new Cancellation();Cancellation token=cancellation;setBusy(true,label); + new SwingWorker(){ + @Override protected T doInBackground()throws Exception{token.check();return operation.call();} + @Override protected void done(){busy=false;setBusy(false,"Ready");try{T result=get();if(token.isCancelled()){append("Operation completed before cancellation could stop it. Completed changes are retained; no next launch will start.");NativeMinecraftPanel.this.run("Reload completed state",NativeMinecraftPanel.this::load,NativeMinecraftPanel.this::render);return;}complete.accept(result);}catch(Exception error){Throwable cause=error.getCause()==null?error:error.getCause();if(token.isCancelled())append("Cancelled: "+cause.getMessage());else showError(cause);}} + }.execute(); + } + private void setBusy(boolean value,String label){for(JButton button:actions)button.setEnabled(!value);cancel.setEnabled(value);activity.setText(label);progress.setIndeterminate(value);} + private void append(String message){log.append(message+"\n");log.setCaretPosition(log.getDocument().getLength());} + private void showError(Throwable error){String message=error.getMessage()==null?error.getClass().getSimpleName():error.getMessage();append("Error: "+message);JOptionPane.showMessageDialog(this,message,"Minecraft operation failed",JOptionPane.ERROR_MESSAGE);} + private SavedServerRepository.SavedServer server(){return serverValues.get(selected(servers));} + private Profile profile(){return profileValues.get(selected(profiles));} + private World world(){return worldValues.get(selected(worlds));} + private int selected(JTable table){int row=table.getSelectedRow();if(row<0)throw new IllegalArgumentException("Select a row first");return table.convertRowIndexToModel(row);} + private boolean confirm(String text){return JOptionPane.showConfirmDialog(this,text,"Confirm",JOptionPane.OK_CANCEL_OPTION)==JOptionPane.OK_OPTION;} + private String input(String text,String value){return (String)JOptionPane.showInputDialog(this,text,"Minecraft",JOptionPane.QUESTION_MESSAGE,null,null,value);} + private boolean form(String title,String[] labels,JComponent[] values){JPanel panel=new JPanel(new GridLayout(0,2,8,8));for(int i=0;i candidates,String label){if(candidates.isEmpty())throw new IllegalArgumentException("Create a suitable profile first");String[] ids=candidates.stream().map(Profile::getId).toArray(String[]::new);String id=(String)JOptionPane.showInputDialog(this,label,"Choose profile",JOptionPane.QUESTION_MESSAGE,null,ids,ids[0]);if(id==null)return null;return candidates.stream().filter(p->p.getId().equals(id)).findFirst().orElse(null);} + private Path chooseFile(String label){JFileChooser chooser=new JFileChooser();chooser.setDialogTitle(label);return chooser.showOpenDialog(this)==JFileChooser.APPROVE_OPTION?chooser.getSelectedFile().toPath():null;} + private static String emptyToNull(String value){return value==null||value.trim().isEmpty()?null:value.trim();} + static String detectVersion(String status){if(status!=null){java.util.regex.Matcher match=java.util.regex.Pattern.compile("(? Date: Sat, 17 Oct 2026 08:23:14 -0400 Subject: [PATCH 14/23] feat(minecraft): integrate utils (14/23) --- .../autoplug/client/utils/UtilsMinecraft.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/main/java/com/osiris/autoplug/client/utils/UtilsMinecraft.java b/src/main/java/com/osiris/autoplug/client/utils/UtilsMinecraft.java index f02f3d5d..14a2fd1d 100644 --- a/src/main/java/com/osiris/autoplug/client/utils/UtilsMinecraft.java +++ b/src/main/java/com/osiris/autoplug/client/utils/UtilsMinecraft.java @@ -214,6 +214,22 @@ public List getMods(File dir) throws FileNotFoundException { } catch (Exception e) { } mods.add(new MinecraftMod(jar.getPath(), name, version, author, null, bukkitId, null)); + } else if (filePath.endsWith("quilt.mod.json")) { // Quilt mod + found = true; + JsonObject root = JsonParser.parseReader(new InputStreamReader(zipFile.getInputStream(entry))).getAsJsonObject(); + JsonObject loader = root.getAsJsonObject("quilt_loader"); + String id = loader.get("id").getAsString(); + String version = loader.get("version").getAsString(); + JsonObject metadata = loader.getAsJsonObject("metadata"); + String name = metadata != null && metadata.has("name") ? metadata.get("name").getAsString() : id; + String author = null; + if (metadata != null && metadata.has("contributors")) { + for (Map.Entry contributor : metadata.getAsJsonObject("contributors").entrySet()) { + author = contributor.getKey(); + break; + } + } + mods.add(new MinecraftMod(jar.getPath(), name, version, author, id, null, null)); } else if (filePath.endsWith("fabric.mod.json")) { // Fabric mod found = true; JsonObject obj = JsonParser.parseReader(new InputStreamReader(zipFile.getInputStream(entry))).getAsJsonObject(); From d6340602b19c31776ff115c5ba0e1cb0a3527720 Mon Sep 17 00:00:00 2001 From: elcuervo171234 <43775780+elcuervo171234@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:23:18 -0400 Subject: [PATCH 15/23] test(minecraft): integrate mc (15/23) --- .../mc/VanillaServerListReaderTest.java | 294 ++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 src/test/java/com/osiris/autoplug/client/mc/VanillaServerListReaderTest.java diff --git a/src/test/java/com/osiris/autoplug/client/mc/VanillaServerListReaderTest.java b/src/test/java/com/osiris/autoplug/client/mc/VanillaServerListReaderTest.java new file mode 100644 index 00000000..a16db891 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/mc/VanillaServerListReaderTest.java @@ -0,0 +1,294 @@ +package com.osiris.autoplug.client.mc; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.zip.GZIPOutputStream; + +import static org.junit.jupiter.api.Assertions.*; + +class VanillaServerListReaderTest { + private final VanillaServerListReader reader = new VanillaServerListReader(); + + @Test + void readsPlainSyntheticServerNamesAndAddresses() throws Exception { + List entries = reader.read(input(servers("Home", "localhost:25565"))); + assertEquals(1, entries.size()); + assertEquals("Home", entries.get(0).getName()); + assertEquals("localhost:25565", entries.get(0).getAddress()); + assertThrows(UnsupportedOperationException.class, entries::clear); + } + + @Test + void readsGzipAndPreservesJavaModifiedUtfIncludingSupplementaryCharacters() throws Exception { + String name = "Español 中文 \u0000 \ud83d\ude80"; + List entries = reader.read(input(gzip(servers(name, "[::1]:25565")))); + assertEquals(name, entries.get(0).getName()); + assertEquals("[::1]:25565", entries.get(0).getAddress()); + } + + @Test + void readsPathWithoutChangingItsBytes() throws Exception { + byte[] fixture = gzip(servers("Synthetic", "example.invalid")); + Path file = Files.createTempFile("autoplug-nbt-fixture-", ".dat"); + try { + Files.write(file, fixture); + assertEquals("Synthetic", reader.read(file).get(0).getName()); + assertArrayEquals(fixture, Files.readAllBytes(file)); + } finally { + Files.deleteIfExists(file); + } + } + + @Test + void leavesCallerOwnedStreamOpenForPlainAndGzip() throws Exception { + for (byte[] fixture : new byte[][]{servers("A", "a.invalid"), gzip(servers("B", "b.invalid"))}) { + TrackingInput input = new TrackingInput(fixture); + reader.read(input); + assertFalse(input.closed); + } + } + + @Test + void skipsUnknownScalarArrayListAndNestedCompoundTags() throws Exception { + byte[] fixture = document(out -> { + named(out, 1, "byte"); out.writeByte(7); + named(out, 2, "short"); out.writeShort(123); + named(out, 3, "int"); out.writeInt(123); + named(out, 4, "long"); out.writeLong(Long.MAX_VALUE); + named(out, 5, "float"); out.writeFloat(1.5f); + named(out, 6, "double"); out.writeDouble(2.5); + named(out, 7, "bytes"); out.writeInt(3); out.write(new byte[]{1, 2, 3}); + named(out, 8, "text"); out.writeUTF("ignored"); + named(out, 9, "list"); out.writeByte(3); out.writeInt(2); out.writeInt(1); out.writeInt(2); + named(out, 10, "compound"); named(out, 8, "child"); out.writeUTF("nested"); out.writeByte(0); + named(out, 11, "ints"); out.writeInt(1); out.writeInt(123); + named(out, 12, "longs"); out.writeInt(1); out.writeLong(456); + writeServers(out, "Keep", "keep.invalid", true); + }); + assertEquals("Keep", reader.read(input(fixture)).get(0).getName()); + } + + @Test + void acceptsEmptyCompoundListAndEmptyEndList() throws Exception { + for (int elementType : new int[]{0, 10}) { + byte[] fixture = document(out -> { + named(out, 9, "servers"); out.writeByte(elementType); out.writeInt(0); + }); + assertTrue(reader.read(input(fixture)).isEmpty()); + } + } + + @Test + void preservesOrderAndRequiresBothListLengthLimits() throws Exception { + byte[] fixture = document(out -> { + named(out, 9, "servers"); out.writeByte(10); out.writeInt(2); + named(out, 8, "name"); out.writeUTF("First"); + named(out, 8, "ip"); out.writeUTF("first.invalid"); out.writeByte(0); + named(out, 8, "name"); out.writeUTF("Second"); + named(out, 8, "ip"); out.writeUTF("second.invalid"); out.writeByte(0); + }); + List entries = reader.read(input(fixture)); + assertEquals("First", entries.get(0).getName()); + assertEquals("second.invalid", entries.get(1).getAddress()); + assertThrows(IOException.class, () -> limited(10000, 10000, 64, 65535, 1, 10, 100) + .read(input(fixture))); + assertThrows(IOException.class, () -> limited(10000, 10000, 64, 65535, 10, 1, 100) + .read(input(fixture))); + } + + @Test + void rejectsEveryTruncatedPrefixWithoutReturningPartialResults() throws Exception { + byte[] fixture = servers("Do not partially import", "host.invalid"); + for (int length = 0; length < fixture.length; length++) { + final byte[] prefix = Arrays.copyOf(fixture, length); + assertThrows(IOException.class, () -> reader.read(input(prefix)), "prefix " + length); + } + } + + @Test + void rejectsTruncatedAndCorruptGzip() throws Exception { + byte[] fixture = gzip(servers("A", "a.invalid")); + assertThrows(IOException.class, () -> reader.read(input(Arrays.copyOf(fixture, fixture.length - 1)))); + fixture[fixture.length - 8] ^= 1; + assertThrows(IOException.class, () -> reader.read(input(fixture))); + } + + @Test + void encodedByteLimitIsInclusive() throws Exception { + byte[] fixture = servers("A", "a.invalid"); + assertEquals(1, limited(fixture.length, 10000, 64, 65535, 1000, 1000, 1000) + .read(input(fixture)).size()); + assertThrows(IOException.class, () -> limited(fixture.length - 1, 10000, 64, 65535, 1000, 1000, 1000) + .read(input(fixture))); + } + + @Test + void decompressedByteLimitBoundsHighlyCompressibleUnknownData() throws Exception { + byte[] fixture = gzip(document(out -> { + named(out, 7, "padding"); out.writeInt(3000); out.write(new byte[3000]); + writeServers(out, "A", "a.invalid", false); + })); + assertTrue(fixture.length < 1000); + assertThrows(IOException.class, () -> limited(1000, 1000, 64, 65535, 10000, 1000, 1000) + .read(input(fixture))); + } + + @Test + void rejectsExcessiveNestingAndTagCount() throws Exception { + byte[] fixture = document(out -> { + named(out, 10, "one"); named(out, 10, "two"); + named(out, 8, "three"); out.writeUTF("leaf"); out.writeByte(0); out.writeByte(0); + writeServers(out, "A", "a.invalid", false); + }); + assertThrows(IOException.class, () -> limited(10000, 10000, 3, 65535, 1000, 1000, 1000) + .read(input(fixture))); + assertThrows(IOException.class, () -> limited(10000, 10000, 64, 65535, 1000, 1000, 3) + .read(input(servers("A", "a.invalid")))); + } + + @Test + void rejectsExcessiveStringServerAndCollectionLengthsBeforeAllocation() throws Exception { + assertThrows(IOException.class, () -> limited(10000, 10000, 64, 12, 1000, 1000, 1000) + .read(input(servers("Long server name", "a.invalid")))); + byte[] serversTooMany = document(out -> { + named(out, 9, "servers"); out.writeByte(10); out.writeInt(Integer.MAX_VALUE); + }); + assertThrows(IOException.class, () -> reader.read(input(serversTooMany))); + byte[] arrayTooLarge = document(out -> { + named(out, 12, "array"); out.writeInt(Integer.MAX_VALUE); + }); + assertThrows(IOException.class, () -> reader.read(input(arrayTooLarge))); + byte[] listTooLarge = document(out -> { + named(out, 9, "other"); out.writeByte(10); out.writeInt(Integer.MAX_VALUE); + }); + assertThrows(IOException.class, () -> reader.read(input(listTooLarge))); + } + + @Test + void rejectsNegativeCollectionLengths() throws Exception { + for (int type : new int[]{7, 9, 11, 12}) { + byte[] fixture = document(out -> { + named(out, type, "unknown"); + if (type == 9) out.writeByte(10); + out.writeInt(-1); + }); + assertThrows(IOException.class, () -> reader.read(input(fixture))); + } + } + + @Test + void rejectsIllegalTagIdsAndNonemptyEndLists() throws Exception { + for (int type : new int[]{13, 255}) { + assertThrows(IOException.class, () -> reader.read(input(document(out -> out.writeByte(type))))); + assertThrows(IOException.class, () -> reader.read(input(document(out -> { + named(out, 9, "servers"); out.writeByte(type); out.writeInt(0); + })))); + } + assertThrows(IOException.class, () -> reader.read(input(document(out -> { + named(out, 9, "unknown"); out.writeByte(0); out.writeInt(1); + })))); + } + + @Test + void rejectsWrongRootTypeAndTrailingBytes() throws Exception { + assertThrows(IOException.class, () -> reader.read(input(new byte[]{0}))); + byte[] valid = servers("A", "a.invalid"); + byte[] trailing = Arrays.copyOf(valid, valid.length + 1); + assertThrows(IOException.class, () -> reader.read(input(trailing))); + } + + @Test + void rejectsMissingDuplicateAndWrongTypedSchemaFields() throws Exception { + assertThrows(IOException.class, () -> reader.read(input(document(out -> {})))); + assertThrows(IOException.class, () -> reader.read(input(document(out -> { + writeServers(out, "A", "a.invalid", false); writeServers(out, "B", "b.invalid", false); + })))); + assertThrows(IOException.class, () -> reader.read(input(document(out -> { + named(out, 9, "servers"); out.writeByte(10); out.writeInt(1); + named(out, 8, "name"); out.writeUTF("Missing ip"); out.writeByte(0); + })))); + assertThrows(IOException.class, () -> reader.read(input(document(out -> { + named(out, 9, "servers"); out.writeByte(10); out.writeInt(1); + named(out, 1, "name"); out.writeByte(0); out.writeByte(0); + })))); + assertThrows(IOException.class, () -> reader.read(input(document(out -> { + named(out, 9, "servers"); out.writeByte(10); out.writeInt(1); + named(out, 8, "name"); out.writeUTF("A"); named(out, 8, "name"); out.writeUTF("B"); out.writeByte(0); + })))); + } + + @Test + void rejectsMalformedModifiedUtfInsteadOfReplacingBytes() throws Exception { + byte[] fixture = document(out -> { + named(out, 9, "servers"); out.writeByte(10); out.writeInt(1); + named(out, 8, "name"); out.writeShort(1); out.writeByte(0xff); + }); + assertThrows(IOException.class, () -> reader.read(input(fixture))); + } + + @Test + void rejectsInvalidLimitConfiguration() { + assertThrows(IllegalArgumentException.class, () -> limited(0, 100, 1, 10, 10, 10, 10)); + assertThrows(IllegalArgumentException.class, () -> limited(100, 100, 1, 65536, 10, 10, 10)); + } + + private static VanillaServerListReader limited(long encoded, long decoded, int depth, int string, + int collection, int servers, int tags) { + return new VanillaServerListReader(new VanillaServerListReader.Limits( + encoded, decoded, depth, string, collection, servers, tags)); + } + + private static ByteArrayInputStream input(byte[] fixture) { + return new ByteArrayInputStream(fixture); + } + + private static byte[] servers(String name, String address) throws IOException { + return document(out -> writeServers(out, name, address, false)); + } + + private static byte[] document(Writer body) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (DataOutputStream out = new DataOutputStream(bytes)) { + named(out, 10, ""); + body.write(out); + out.writeByte(0); + } + return bytes.toByteArray(); + } + + private static void named(DataOutputStream out, int type, String name) throws IOException { + out.writeByte(type); + out.writeUTF(name); + } + + private static void writeServers(DataOutputStream out, String name, String address, boolean unknown) throws IOException { + named(out, 9, "servers"); out.writeByte(10); out.writeInt(1); + named(out, 8, "name"); out.writeUTF(name); + if (unknown) { named(out, 1, "acceptTextures"); out.writeByte(1); } + named(out, 8, "ip"); out.writeUTF(address); + if (unknown) { named(out, 8, "icon"); out.writeUTF("synthetic-icon"); } + out.writeByte(0); + } + + private static byte[] gzip(byte[] plain) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (GZIPOutputStream out = new GZIPOutputStream(bytes)) { out.write(plain); } + return bytes.toByteArray(); + } + + private interface Writer { void write(DataOutputStream out) throws IOException; } + + private static final class TrackingInput extends ByteArrayInputStream { + private boolean closed; + private TrackingInput(byte[] bytes) { super(bytes); } + @Override public void close() { closed = true; } + } +} From 1fc9b220bbdbb43df87157aeb5cc2bdafb9fe26a Mon Sep 17 00:00:00 2001 From: elcuervo171234 <43775780+elcuervo171234@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:23:25 -0400 Subject: [PATCH 16/23] test(minecraft): integrate client (16/23) --- .../client/ClientStorageAndServersTest.java | 104 +++++++ .../mc/client/NativeClientCommandsTest.java | 261 ++++++++++++++++++ .../mc/client/NativeClientContextTest.java | 240 ++++++++++++++++ .../client/NativeWorkflowIntegrationTest.java | 239 ++++++++++++++++ 4 files changed, 844 insertions(+) create mode 100644 src/test/java/com/osiris/autoplug/client/mc/client/ClientStorageAndServersTest.java create mode 100644 src/test/java/com/osiris/autoplug/client/mc/client/NativeClientCommandsTest.java create mode 100644 src/test/java/com/osiris/autoplug/client/mc/client/NativeClientContextTest.java create mode 100644 src/test/java/com/osiris/autoplug/client/mc/client/NativeWorkflowIntegrationTest.java diff --git a/src/test/java/com/osiris/autoplug/client/mc/client/ClientStorageAndServersTest.java b/src/test/java/com/osiris/autoplug/client/mc/client/ClientStorageAndServersTest.java new file mode 100644 index 00000000..70d474ce --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/mc/client/ClientStorageAndServersTest.java @@ -0,0 +1,104 @@ +package com.osiris.autoplug.client.mc.client; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; + +import static org.junit.jupiter.api.Assertions.*; + +class ClientStorageAndServersTest { + @TempDir Path temporary; + + @Test void validatesIpv6StructureWithoutResolvingAnyHost() { + for (String invalid : new String[]{"[::::]", "[1::2::3]", "[:1::]", "[::1:]", "[1:2:3]", "[1:2:3:4:5:6:7:8:9]", "[::ffff:300.1.2.3]", "[localhost]", "a..invalid", "-bad.invalid", "bad-.invalid", "999.1.2.3"}) { + assertThrows(IllegalArgumentException.class, () -> new ServerAddress(invalid), invalid); + } + assertEquals("[::1]:25565", new ServerAddress("::1").toString()); + assertEquals("[2001:db8::1]:25577", new ServerAddress("[2001:DB8::1]:25577").toString()); + assertEquals("[::ffff:192.0.2.1]:25565", new ServerAddress("::ffff:192.0.2.1").toString()); + assertEquals("example.invalid:25565", new ServerAddress("EXAMPLE.invalid.").toString()); + assertEquals("xn--espaol-zwa.invalid:25565", new ServerAddress("español.invalid").toString()); + } + + @Test void settingsRoundTripRejectsUnknownSecretKeysAndInvalidOfflineFlags() throws Exception { + Path root = temporary.toRealPath(); ClientSettings settings = new ClientSettings(root); + settings.set("username", "Fixture"); settings.set("offlineFixtureMode", "false"); settings.save(); + assertEquals("Fixture", new ClientSettings(root).get("username", "")); + assertThrows(IllegalArgumentException.class, () -> settings.set("accessToken", "fixture-only")); + assertThrows(IllegalArgumentException.class, () -> settings.set("offlineFixtureMode", "yes")); + assertThrows(IllegalArgumentException.class, () -> settings.set("username", "bad name")); + Files.write(root.resolve("client-settings.properties"), "accessToken=fixture-not-a-secret\n".getBytes(StandardCharsets.UTF_8)); + assertThrows(IOException.class, () -> new ClientSettings(root)); + } + + @Test void storageRejectsLinkedFileAndParentWithoutWritingThroughThem() throws Exception { + Path root = temporary.toRealPath(), outside = Files.createDirectory(root.resolve("outside")); + Path protectedFile = outside.resolve("original.properties"); Files.write(protectedFile, "original=yes\n".getBytes(StandardCharsets.US_ASCII)); + Path linked = root.resolve("linked.properties"); Files.createSymbolicLink(linked, protectedFile); + ClientStorage storage = new ClientStorage(); Properties values = new Properties(); values.setProperty("new", "value"); + assertThrows(IOException.class, () -> storage.read(linked)); + assertThrows(IOException.class, () -> storage.write(linked, values)); + Path linkedParent = root.resolve("parent-link"); Files.createSymbolicLink(linkedParent, outside); + assertThrows(IOException.class, () -> storage.write(linkedParent.resolve("new-directory/file.properties"), values)); + assertFalse(Files.exists(outside.resolve("new-directory"))); + assertEquals("original=yes\n", new String(Files.readAllBytes(protectedFile), StandardCharsets.US_ASCII)); + } + + @Test void oversizedOrMalformedSettingsFailAndPreservePreviousFile() throws Exception { + Path root = temporary.toRealPath(), file = root.resolve("settings.properties"); + ClientStorage storage = new ClientStorage(); Properties first = new Properties(); first.setProperty("first", "kept"); storage.write(file, first); + byte[] before = Files.readAllBytes(file); + Properties huge = new Properties(); huge.setProperty("huge", new String(new char[8 * 1024 * 1024 + 1]).replace('\0', 'x')); + assertThrows(IOException.class, () -> storage.write(file, huge)); assertArrayEquals(before, Files.readAllBytes(file)); + Files.write(file, new byte[8 * 1024 * 1024 + 1]); assertThrows(IOException.class, () -> storage.read(file)); + Files.write(file, "bad=\\uXYZ1\n".getBytes(StandardCharsets.US_ASCII)); assertThrows(IOException.class, () -> storage.read(file)); + try (java.util.stream.Stream files = Files.list(root)) { assertFalse(files.anyMatch(p -> p.getFileName().toString().startsWith(".settings-"))); } + } + + @Test void savedServerCrudPersistsCanonicalAddressesAndRejectsDuplicateIds() throws Exception { + Path root = temporary.toRealPath(); SavedServerRepository servers = new SavedServerRepository(root); + SavedServerRepository.SavedServer first = servers.add("First", "EXAMPLE.invalid", "VANILLA"); + assertEquals("example.invalid:25565", servers.list().get(0).getAddress()); + servers.update(first.getId(), "Renamed", "localhost:25566", "FABRIC"); + assertEquals("FABRIC", new SavedServerRepository(root).list().get(0).getLoader()); + Properties corrupt = new ClientStorage().read(root.resolve("saved-servers.properties")); + corrupt.setProperty("count", "2"); + for (String field : new String[]{"id", "name", "address", "loader"}) corrupt.setProperty("server.1." + field, corrupt.getProperty("server.0." + field)); + new ClientStorage().write(root.resolve("saved-servers.properties"), corrupt); + assertThrows(IOException.class, servers::list); + } + + @Test void vanillaImportIsAtomicDeduplicatedAndNeverChangesTheOriginal() throws Exception { + Path root = temporary.toRealPath(), vanilla = root.resolve("synthetic-servers.dat"); + SavedServerRepository servers = new SavedServerRepository(root); + servers.add("Existing", "example.invalid", "VANILLA"); + byte[] original = nbt(new String[][]{{"Duplicate", "EXAMPLE.invalid"}, {"New", "new.invalid:25570"}}); + Files.write(vanilla, original); + assertEquals(1, servers.importVanilla(vanilla, "VANILLA")); assertEquals(0, servers.importVanilla(vanilla, "VANILLA")); + assertArrayEquals(original, Files.readAllBytes(vanilla)); + byte[] savedBefore = Files.readAllBytes(root.resolve("saved-servers.properties")); + Files.write(vanilla, nbt(new String[][]{{"Would be added", "third.invalid"}, {"Invalid", "[::::]"}})); + assertThrows(IOException.class, () -> servers.importVanilla(vanilla, "VANILLA")); + assertArrayEquals(savedBefore, Files.readAllBytes(root.resolve("saved-servers.properties"))); + } + + private static byte[] nbt(String[][] entries) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (DataOutputStream out = new DataOutputStream(bytes)) { + out.writeByte(10); out.writeUTF(""); out.writeByte(9); out.writeUTF("servers"); out.writeByte(10); out.writeInt(entries.length); + for (String[] entry : entries) { + out.writeByte(8); out.writeUTF("name"); out.writeUTF(entry[0]); + out.writeByte(8); out.writeUTF("ip"); out.writeUTF(entry[1]); out.writeByte(0); + } + out.writeByte(0); + } + return bytes.toByteArray(); + } +} diff --git a/src/test/java/com/osiris/autoplug/client/mc/client/NativeClientCommandsTest.java b/src/test/java/com/osiris/autoplug/client/mc/client/NativeClientCommandsTest.java new file mode 100644 index 00000000..ee49c765 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/mc/client/NativeClientCommandsTest.java @@ -0,0 +1,261 @@ +package com.osiris.autoplug.client.mc.client; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.osiris.autoplug.client.mc.launch.Cancellation; +import com.osiris.autoplug.client.mc.launch.LaunchTransport; +import com.osiris.autoplug.client.mc.profiles.Profile; +import com.osiris.autoplug.client.mc.profiles.ProfileType; +import com.osiris.autoplug.client.mc.update.ProfileUpdateService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.PrintStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.*; + +class NativeClientCommandsTest { + @TempDir Path temporaryDirectory; + + @Test void tokenizerPreservesQuotedPathsWindowsBackslashesAndEmptyValues() { + assertArrayEquals(new String[]{".mc", "settings", "set", "javaHomes", "C:\\Program Files\\Java"}, + NativeClientCommands.tokenize(".mc settings set javaHomes \"C:\\Program Files\\Java\"")); + assertArrayEquals(new String[]{".mc", "settings", "set", "defaultTemplate", ""}, NativeClientCommands.tokenize(".mc settings set defaultTemplate ''")); + assertArrayEquals(new String[]{"one path", "two words", "a\"b"}, NativeClientCommands.tokenize("one\\ path 'two words' \"a\\\"b\"")); + assertThrows(IllegalArgumentException.class, () -> NativeClientCommands.tokenize(".mc launch \"unterminated")); + assertArrayEquals(new String[0], NativeClientCommands.tokenize(" ")); + } + + @Test void leavesLegacyCommandsForTheExistingDispatcher() throws Exception { + try (NativeClientContext context = context()) { + NativeClientCommands commands = new NativeClientCommands(context); + PrintStream out = new PrintStream(new ByteArrayOutputStream()); + assertFalse(commands.dispatch(new String[0], out)); + assertFalse(commands.dispatch(NativeClientCommands.tokenize(".check mods"), out)); + assertFalse(commands.dispatch(NativeClientCommands.tokenize(".update server"), out)); + assertFalse(commands.dispatch(NativeClientCommands.tokenize(".restart"), out)); + } + } + + @Test void profileCrudAndTemplateCommandsUseTheSharedRepository() throws Exception { + try (NativeClientContext context = context()) { + NativeClientCommands commands = new NativeClientCommands(context); + dispatch(commands, ".profiles create base 1.20.4 FABRIC --template"); + assertTrue(context.profiles().require("base").isTemplate()); + dispatch(commands, ".profiles clone base copied"); + assertFalse(context.profiles().require("copied").isTemplate()); + dispatch(commands, ".profiles rename copied renamed"); + assertFalse(context.profiles().find("copied").isPresent()); + dispatch(commands, ".profiles template renamed true"); + assertTrue(context.profiles().require("renamed").isTemplate()); + dispatch(commands, ".profiles create server 1.20.4 NEOFORGE --type MODS-SERVER"); + assertEquals(ProfileType.MODS_SERVER, context.profiles().require("server").getType()); + assertTrue(dispatch(commands, ".profiles list --type MODS-SERVER").contains("server")); + dispatch(commands, ".profiles delete renamed"); + assertFalse(context.profiles().find("renamed").isPresent()); + } + } + + @Test void checkIsReadOnlyAndUpdateNeedsExplicitDisabledAssetConfirmation() throws Exception { + try (NativeClientContext context = context()) { + Profile profile = context.profiles().create("client", ProfileType.MODS, "1.20.4", "FABRIC", false); + try (java.util.jar.JarOutputStream jar = new java.util.jar.JarOutputStream(Files.newOutputStream(profile.getAssetsDirectory().resolve("fixture.jar")))) { + jar.putNextEntry(new java.util.jar.JarEntry("synthetic.txt")); + jar.write("Synthetic CLI fixture".getBytes(StandardCharsets.UTF_8)); jar.closeEntry(); + } + NativeClientCommands commands = new NativeClientCommands(context); + assertTrue(dispatch(commands, ".check mods --profile=client --version 1.21").contains("DISABLE")); + assertTrue(Files.exists(profile.getAssetsDirectory().resolve("fixture.jar"))); + assertEquals("1.20.4", context.profiles().require("client").getGameVersion()); + assertTrue(dispatch(commands, ".update mods --profile client --version 1.21").contains("Nothing changed")); + assertTrue(Files.exists(profile.getAssetsDirectory().resolve("fixture.jar"))); + dispatch(commands, ".update mods --profile client --version 1.21 --confirm-disable"); + assertFalse(Files.exists(profile.getAssetsDirectory().resolve("fixture.jar"))); + assertTrue(Files.exists(profile.getAssetsDirectory().resolve("fixture.jar.disabled"))); + assertEquals("1.21", context.profiles().require("client").getGameVersion()); + } + } + + @Test void invalidOptionsAndMismatchedAssetTypesDoNotMutateProfiles() throws Exception { + try (NativeClientContext context = context()) { + NativeClientCommands commands = new NativeClientCommands(context); + assertThrows(IllegalArgumentException.class, () -> dispatch(commands, ".profiles create bad 1.20.4 FABRIC --typo ignored")); + assertFalse(context.profiles().find("bad").isPresent()); + context.profiles().create("client", ProfileType.MODS, "1.20.4", "FABRIC", false); + assertThrows(IllegalArgumentException.class, () -> dispatch(commands, ".update plugins --profile client")); + assertThrows(IllegalArgumentException.class, () -> dispatch(commands, ".profiles template client perhaps")); + } + } + + @Test void referencedWorldProfilesCannotBeRenamedDeletedOrTurnedIntoTemplates() throws Exception { + try (NativeClientContext context = context()) { + context.profiles().create("server", ProfileType.PLUGINS, "1.20.4", "PAPER", false); + NativeClientCommands commands = new NativeClientCommands(context); + dispatch(commands, ".mc worlds create world server"); + assertThrows(IOException.class, () -> dispatch(commands, ".profiles rename server renamed")); + assertThrows(IOException.class, () -> dispatch(commands, ".profiles delete server")); + assertThrows(IOException.class, () -> dispatch(commands, ".profiles template server true")); + assertTrue(dispatch(commands, ".mc worlds list").contains("world | server")); + assertThrows(IOException.class, () -> dispatch(commands, ".mc worlds share world")); + assertEquals("false", readEula(context.worlds().find("world").orElseThrow(AssertionError::new).getDirectory().resolve("eula.txt"))); + } + } + + @Test void addsAndImportsServersReadOnlyWithoutPingingOrConnecting() throws Exception { + try (NativeClientContext context = context()) { + NativeClientCommands commands = new NativeClientCommands(context); + dispatch(commands, ".mc servers add 'Synthetic Server' localhost:25566 VANILLA"); + assertEquals(1, context.servers().list().size()); + assertTrue(dispatch(commands, ".mc servers list").contains("Synthetic Server")); + Path input = temporaryDirectory.resolve("servers.dat"); byte[] original = serversFixture(); Files.write(input, original); + assertTrue(dispatch(commands, ".mc servers import '" + input + "' VANILLA").contains("Imported 1")); + assertEquals(2, context.servers().list().size()); + assertArrayEquals(original, Files.readAllBytes(input)); + } + } + + @Test void joinWithoutAnExplicitLoaderOrCompatibleProfileDoesNotStartProcesses() throws Exception { + try (NativeClientContext context = context()) { + NativeClientCommands commands = new NativeClientCommands(context); + assertThrows(IllegalArgumentException.class, () -> dispatch(commands, ".mc servers join example.invalid --version 1.20.4")); + assertTrue(dispatch(commands, ".mc servers join example.invalid --version 1.20.4 --loader VANILLA").contains("No compatible profile")); + context.profiles().create("base", ProfileType.MODS, "1.20.4", "VANILLA", true); + context.settings().set("defaultTemplate", "base"); + assertTrue(dispatch(commands, ".mc servers join example.invalid --version 1.20.4 --loader VANILLA").contains("require approval")); + assertEquals(1, context.profiles().list().size()); + } + } + + @Test void fixtureModeAndNonSecretSettingsAreExplicitAndNeverAcceptTokenSettings() throws Exception { + try (NativeClientContext context = context()) { + NativeClientCommands commands = new NativeClientCommands(context); + assertTrue(dispatch(commands, ".mc offline Fixture").contains("not Microsoft authentication")); + assertEquals("true", context.settings().get("offlineFixtureMode", "")); + dispatch(commands, ".mc settings set clientId fixture-public-id"); + assertTrue(dispatch(commands, ".mc settings show").contains("clientId=fixture-public-id")); + assertThrows(IllegalArgumentException.class, () -> dispatch(commands, ".mc settings set accessToken should-not-save")); + assertFalse(new String(Files.readAllBytes(context.getDataRoot().resolve("client-settings.properties")), StandardCharsets.UTF_8).contains("should-not-save")); + } + } + + @Test void missingClientIdFailsBeforeStartingAnyAuthenticationWorker() throws Exception { + try (NativeClientContext context = context()) { + NativeClientCommands commands = new NativeClientCommands(context); + assertThrows(IllegalArgumentException.class, () -> dispatch(commands, ".mc signin")); + assertThrows(IllegalArgumentException.class, () -> dispatch(commands, ".mc device login")); + assertTrue(dispatch(commands, ".mc device cancel").contains("cancellation requested")); + assertEquals("Not signed in", context.getAccountLabel()); + } + } + + @Test void approvedVanillaTemplateCloneContinuesToLaunchAndStillRequiresAnExplicitSession() throws Exception { + try (NativeClientContext context = context()) { + context.profiles().create("base", ProfileType.MODS, "1.20.4", "VANILLA", true); + context.settings().set("defaultTemplate", "base"); + IOException error = assertThrows(IOException.class, () -> dispatch(new NativeClientCommands(context), + ".mc servers join example.invalid --version 1.20.4 --loader VANILLA --clone approved")); + assertTrue(error.getMessage().contains("Sign in")); + assertFalse(context.profiles().require("approved").isTemplate()); + assertTrue(Files.exists(context.profiles().require("approved").getDirectory().resolve("loader.properties"))); + assertEquals("Not signed in", context.getAccountLabel()); + } + } + + @Test void closingTheSharedCommandInstanceCancelsPendingDeviceLoginWithoutXboxRequests() throws Exception { + java.util.concurrent.CountDownLatch requested = new java.util.concurrent.CountDownLatch(1); + java.util.concurrent.atomic.AtomicInteger calls = new java.util.concurrent.atomic.AtomicInteger(); + LaunchTransport transport = new LaunchTransport() { + @Override public JsonObject json(String method, URI uri, Map headers, byte[] body, Cancellation cancellation) throws IOException { + cancellation.check(); calls.incrementAndGet(); requested.countDown(); + if (!uri.toString().endsWith("/devicecode")) throw new IOException("No Xbox/authentication service calls in this fixture"); + return JsonParser.parseString("{\"device_code\":\"synthetic-only\",\"user_code\":\"FAKE-CODE\",\"verification_uri\":\"https://microsoft.com/devicelogin\",\"expires_in\":300,\"interval\":5}").getAsJsonObject(); + } + @Override public void download(URI uri, Path path, Cancellation cancellation) throws IOException { throw new IOException("No downloads"); } + }; + try (NativeClientContext context = context(transport)) { + context.settings().set("clientId", "fixture-public-id"); + NativeClientCommands commands = new NativeClientCommands(context); + dispatch(commands, ".mc signin"); + assertTrue(requested.await(2, java.util.concurrent.TimeUnit.SECONDS)); + commands.close(); commands.close(); + assertEquals(1, calls.get()); assertEquals("Not signed in", context.getAccountLabel()); + assertThrows(IOException.class, () -> dispatch(commands, ".mc help")); + } + } + + @Test void renamingAndDeletingTheDefaultTemplateKeepsPersistedSettingsConsistent() throws Exception { + try (NativeClientContext context = context()) { + context.profiles().create("base", ProfileType.MODS, "1.20.4", "VANILLA", true); + context.settings().set("defaultTemplate", "base"); context.settings().save(); + NativeClientCommands commands = new NativeClientCommands(context); + dispatch(commands, ".profiles rename base renamed"); + assertEquals("renamed", new ClientSettings(context.getDataRoot()).get("defaultTemplate", "")); + dispatch(commands, ".profiles delete renamed"); + assertEquals("", new ClientSettings(context.getDataRoot()).get("defaultTemplate", "")); + } + } + + @Test void worldLaunchConfigurationIsPersistedAndProtectsItsClientProfileReference() throws Exception { + try (NativeClientContext context = context()) { + context.profiles().create("server", ProfileType.PLUGINS, "1.20.4", "PAPER", false); + context.profiles().create("client", ProfileType.MODS, "1.20.4", "VANILLA", false); + NativeClientCommands commands = new NativeClientCommands(context); dispatch(commands, ".mc worlds create world server"); + Path serverJar = temporaryDirectory.resolve("synthetic server.jar"); + try (java.util.jar.JarOutputStream jar = new java.util.jar.JarOutputStream(Files.newOutputStream(serverJar))) { + jar.putNextEntry(new java.util.jar.JarEntry("synthetic.txt")); jar.write(new byte[]{1}); jar.closeEntry(); + } + assertTrue(dispatch(commands, ".mc worlds configure world --client client --server-jar '" + serverJar + "'").contains("no EULA was accepted")); + assertEquals("client", context.worldConfig("world").getClientProfileId()); + assertEquals(serverJar.toRealPath(), context.worldConfig("world").getServerJar()); + assertThrows(IOException.class, () -> dispatch(commands, ".profiles rename client renamed")); + assertThrows(IOException.class, () -> dispatch(commands, ".profiles delete client")); + assertEquals("false", readEula(context.worlds().find("world").orElseThrow(AssertionError::new).getDirectory().resolve("eula.txt"))); + } + } + + private NativeClientContext context() throws Exception { + LaunchTransport transport = new LaunchTransport() { + @Override public JsonObject json(String method, URI uri, Map headers, byte[] body, Cancellation cancellation) throws IOException { throw new IOException("No external requests in CLI fixtures"); } + @Override public void download(URI uri, Path target, Cancellation cancellation) throws IOException { throw new IOException("No downloads in CLI fixtures"); } + }; + return context(transport); + } + + private NativeClientContext context(LaunchTransport transport) throws Exception { + Path root = temporaryDirectory.toRealPath().resolve("data"); + ClientSettings settings = new ClientSettings(root); settings.set("cacheDirectory", temporaryDirectory.toRealPath().resolve("isolated-cache").toString()); settings.save(); + ProfileUpdateService.Engine engine = new ProfileUpdateService.Engine() { + @Override public ProfileUpdateService.Decision resolve(Profile profile, Path jar, String target) { + return target.equals("1.21") ? ProfileUpdateService.Decision.incompatible("Synthetic provider reports no target release") : ProfileUpdateService.Decision.keep("Synthetic compatible fixture"); + } + @Override public void download(ProfileUpdateService.Decision decision, Path target) throws IOException { throw new IOException("No downloads in CLI fixtures"); } + }; + return new NativeClientContext(root, temporaryDirectory, transport, engine); + } + + private String dispatch(NativeClientCommands commands, String text) throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + assertTrue(commands.dispatch(NativeClientCommands.tokenize(text), new PrintStream(output, true, "UTF-8"))); + return new String(output.toByteArray(), StandardCharsets.UTF_8); + } + private String readEula(Path file) throws IOException { + java.util.Properties properties = new java.util.Properties(); + try (java.io.InputStream input = Files.newInputStream(file)) { properties.load(input); } + return properties.getProperty("eula"); + } + private byte[] serversFixture() throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (DataOutputStream output = new DataOutputStream(bytes)) { + output.writeByte(10); output.writeUTF(""); output.writeByte(9); output.writeUTF("servers"); output.writeByte(10); output.writeInt(1); + output.writeByte(8); output.writeUTF("name"); output.writeUTF("Imported synthetic"); + output.writeByte(8); output.writeUTF("ip"); output.writeUTF("127.0.0.1:25567"); output.writeByte(0); output.writeByte(0); + } + return bytes.toByteArray(); + } +} diff --git a/src/test/java/com/osiris/autoplug/client/mc/client/NativeClientContextTest.java b/src/test/java/com/osiris/autoplug/client/mc/client/NativeClientContextTest.java new file mode 100644 index 00000000..d3b18821 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/mc/client/NativeClientContextTest.java @@ -0,0 +1,240 @@ +package com.osiris.autoplug.client.mc.client; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.osiris.autoplug.client.mc.launch.Cancellation; +import com.osiris.autoplug.client.mc.launch.LaunchTransport; +import com.osiris.autoplug.client.mc.profiles.Profile; +import com.osiris.autoplug.client.mc.profiles.ProfileRepository; +import com.osiris.autoplug.client.mc.profiles.ProfileType; +import com.osiris.autoplug.client.mc.update.ProfileUpdateService; +import com.osiris.autoplug.client.mc.worlds.World; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +class NativeClientContextTest { + @TempDir Path temporary; + private static final ProfileUpdateService.Engine ENGINE = new ProfileUpdateService.Engine() { + public ProfileUpdateService.Decision resolve(Profile profile, Path jar, String version) { return ProfileUpdateService.Decision.keep("fixture"); } + public void download(ProfileUpdateService.Decision decision, Path target) { throw new AssertionError("No updater network expected"); } + }; + private static final LaunchTransport NO_NETWORK = new LaunchTransport() { + public JsonObject json(String method, URI uri, Map headers, byte[] body, Cancellation cancellation) { throw new AssertionError("No JSON request expected"); } + public void download(URI uri, Path target, Cancellation cancellation) { throw new AssertionError("No download expected"); } + }; + + @Test void startFailureClosesPreparedNativesAndReleasesTheProfileLease() throws Exception { + AtomicReference handle = new AtomicReference<>(); + try (NativeClientContext context = context(NO_NETWORK, (launcher, version, directory, session, host, port, cancellation) -> { + FakeLaunch fake = new FakeLaunch(directory, cancellation); fake.failStart = true; handle.set(fake); return fake; + })) { + context.profiles().create("client", ProfileType.MODS, "1.20.4", "VANILLA", false); + assertThrows(IOException.class, () -> context.launch("client", "example.invalid", new Cancellation())); + assertTrue(handle.get().closed); assertFalse(Files.exists(handle.get().natives)); + assertFalse(context.profiles().isInUse("client")); + } + } + + @Test void processExitClosesNativesAndRetainsItsLeaseUntilExit() throws Exception { + AtomicReference handle = new AtomicReference<>(); + try (NativeClientContext context = context(NO_NETWORK, (launcher, version, directory, session, host, port, cancellation) -> { + FakeLaunch fake = new FakeLaunch(directory, cancellation); handle.set(fake); return fake; + })) { + context.profiles().create("client", ProfileType.MODS, "1.20.4", "VANILLA", false); + context.launch("client", null, new Cancellation()); assertTrue(context.profiles().isInUse("client")); + assertThrows(IOException.class, () -> context.profiles().delete("client")); + handle.get().process.finish(); await(() -> !context.profiles().isInUse("client")); + assertTrue(handle.get().closed); assertFalse(Files.exists(handle.get().natives)); + } + } + + @Test void closingContextDuringPreparationCancelsAndClosesALatePreparedHandle() throws Exception { + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + AtomicReference handle = new AtomicReference<>(); + NativeClientContext context = context(NO_NETWORK, (launcher, version, directory, session, host, port, cancellation) -> { + entered.countDown(); waitFixture(release); + FakeLaunch fake = new FakeLaunch(directory, cancellation); handle.set(fake); return fake; + }); + context.profiles().create("client", ProfileType.MODS, "1.20.4", "VANILLA", false); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future task = executor.submit(() -> assertThrows(IOException.class, () -> context.launch("client", null, new Cancellation()))); + assertTrue(entered.await(1, TimeUnit.SECONDS)); context.close(); release.countDown(); task.get(1, TimeUnit.SECONDS); + assertTrue(handle.get().closed); assertEquals(0, handle.get().starts); assertFalse(context.profiles().isInUse("client")); + assertThrows(IOException.class, () -> context.launch("client", null, new Cancellation())); + } finally { release.countDown(); executor.shutdownNow(); context.close(); } + } + + @Test void stopClientCancelsPreparationAndDuplicateStartDoesNotDisturbTheFirstScope() throws Exception { + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); Cancellation first = new Cancellation(); + try (NativeClientContext context = context(NO_NETWORK, (launcher, version, directory, session, host, port, cancellation) -> { + entered.countDown(); waitFixture(release); return new FakeLaunch(directory, cancellation); + })) { + context.profiles().create("client", ProfileType.MODS, "1.20.4", "VANILLA", false); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future task = executor.submit(() -> assertThrows(IOException.class, () -> context.launch("client", null, first))); + assertTrue(entered.await(1, TimeUnit.SECONDS)); + assertThrows(IOException.class, () -> context.launch("client", null, new Cancellation())); assertFalse(first.isCancelled()); + context.stopClient("client"); assertTrue(first.isCancelled()); release.countDown(); task.get(1, TimeUnit.SECONDS); + assertFalse(context.profiles().isInUse("client")); + } finally { release.countDown(); executor.shutdownNow(); } + } + } + + @Test void recordLoaderCannotChangeAnActiveProfileAndPersistsWhenIdle() throws Exception { + try (NativeClientContext context = context(NO_NETWORK, (launcher, version, directory, session, host, port, cancellation) -> new FakeLaunch(directory, cancellation))) { + Profile profile = context.profiles().create("client", ProfileType.MODS, "1.20.4", "FABRIC", false); + try (ProfileRepository.UseLease ignored = context.profiles().acquireUse("client")) { + assertThrows(IOException.class, () -> context.recordLoader("client", "fabric-fixture")); + assertFalse(Files.exists(profile.getDirectory().resolve("loader.properties"))); + } + context.recordLoader("client", "fabric-fixture"); + assertEquals("fabric-fixture", new ClientStorage().read(profile.getDirectory().resolve("loader.properties")).getProperty("versionId")); + } + } + + @Test void cancellationAfterStartKeepsLeaseWhenProcessCleanupFailsUntilStopRetry() throws Exception { + FakeProcess process = new FakeProcess(); java.util.concurrent.atomic.AtomicBoolean refuse = new java.util.concurrent.atomic.AtomicBoolean(true); + try (NativeClientContext context = context(NO_NETWORK, (launcher, version, directory, session, host, port, cancellation) -> new NativeClientContext.ClientLaunch() { + public Process start() { cancellation.cancel(); return process; } + public void close() throws IOException { if (refuse.get()) throw new IOException("Synthetic process refuses cleanup"); process.finish(); } + })) { + context.profiles().create("client", ProfileType.MODS, "1.20.4", "VANILLA", false); + assertThrows(IOException.class, () -> context.launch("client", null, new Cancellation())); + assertTrue(process.isAlive()); assertTrue(context.profiles().isInUse("client")); + assertThrows(IOException.class, () -> context.profiles().delete("client")); + assertThrows(IOException.class, () -> context.stopClient("client")); + refuse.set(false); context.stopClient("client"); await(() -> !context.profiles().isInUse("client")); + assertFalse(process.isAlive()); + } finally { refuse.set(false); process.finish(); } + } + + @Test void loaderInstallKeepsLeaseUntilMarkerAndSelectsStableOfficialMetadata() throws Exception { + AtomicReference owner = new AtomicReference<>(); AtomicInteger requests = new AtomicInteger(); + LaunchTransport transport = new LaunchTransport() { + public void download(URI uri, Path target, Cancellation cancellation) throws IOException { + requests.incrementAndGet(); assertTrue(owner.get().profiles().isInUse("fabric")); + assertTrue(uri.toString().endsWith("/1.20.4")); + Files.write(target, "[{\"loader\":{\"version\":\"beta-fixture\",\"stable\":false}},{\"loader\":{\"version\":\"0.15.0\",\"stable\":true}}]".getBytes(StandardCharsets.UTF_8)); + } + public JsonObject json(String method, URI uri, Map headers, byte[] body, Cancellation cancellation) { + requests.incrementAndGet(); assertTrue(owner.get().profiles().isInUse("fabric")); + assertTrue(uri.toString().contains("/0.15.0/profile/json")); + return JsonParser.parseString("{\"id\":\"fabric-fixture\",\"inheritsFrom\":\"1.20.4\",\"mainClass\":\"fixture.Main\",\"libraries\":[]}").getAsJsonObject(); + } + }; + try (NativeClientContext context = context(transport, (launcher, version, directory, session, host, port, cancellation) -> new FakeLaunch(directory, cancellation))) { + owner.set(context); Profile profile = context.profiles().create("fabric", ProfileType.MODS, "1.20.4", "FABRIC", false); + assertEquals("fabric-fixture", context.installLoader("fabric", null, new Cancellation())); + assertFalse(context.profiles().isInUse("fabric")); assertEquals(2, requests.get()); + assertEquals("fabric-fixture", new ClientStorage().read(profile.getDirectory().resolve("loader.properties")).getProperty("versionId")); + assertEquals("fabric-fixture", context.ensureLoader("fabric", new Cancellation())); + assertEquals(2, requests.get(), "A coherent local import must not rerun installation"); + Path imported = context.getDataRoot().resolve("cache/launcher/versions/fabric-fixture/fabric-fixture.json"); + Files.write(imported, "{\"id\":\"wrong-id\"}".getBytes(StandardCharsets.UTF_8)); + assertEquals("fabric-fixture", context.ensureLoader("fabric", new Cancellation())); assertEquals(4, requests.get()); + Files.delete(imported.resolveSibling("autoplug-loader-profile")); + assertEquals("fabric-fixture", context.ensureLoader("fabric", new Cancellation())); assertEquals(6, requests.get()); + java.util.Properties stale = new ClientStorage().read(profile.getDirectory().resolve("loader.properties")); + stale.setProperty("game", "1.20.2"); new ClientStorage().write(profile.getDirectory().resolve("loader.properties"), stale); + assertEquals("fabric-fixture", context.ensureLoader("fabric", new Cancellation())); assertEquals(8, requests.get()); + assertEquals("fabric-fixture", context.installLoader("fabric", "0.15.0", new Cancellation())); + assertEquals(9, requests.get(), "Explicit installation remains an intentional reinstall"); + try (java.util.stream.Stream files = Files.list(context.getDataRoot())) { assertFalse(files.anyMatch(p -> p.getFileName().toString().startsWith(".loader-list-"))); } + } + } + + @Test void worldLaunchConfigPersistsAndRejectsVersionLoaderAndJarMismatches() throws Exception { + try (NativeClientContext context = context(NO_NETWORK, (launcher, version, directory, session, host, port, cancellation) -> new FakeLaunch(directory, cancellation))) { + context.profiles().create("server", ProfileType.MODS_SERVER, "1.20.4", "FABRIC", false); + context.profiles().create("matching", ProfileType.MODS, "1.20.4", "FABRIC", false); + context.profiles().create("wrong-loader", ProfileType.MODS, "1.20.4", "VANILLA", false); + context.profiles().create("wrong-version", ProfileType.MODS, "1.20.2", "FABRIC", false); + World world = context.worlds().create("island", "server"); Path jar = context.getDataRoot().resolve("synthetic-server.jar"); Files.write(jar, new byte[]{1}); + assertThrows(IOException.class, () -> context.configureWorld("island", "wrong-loader", jar)); + assertThrows(IOException.class, () -> context.configureWorld("island", "wrong-version", jar)); + context.configureWorld("island", "matching", jar); + assertEquals("matching", context.worldConfig("island").getClientProfileId()); assertEquals(jar.toRealPath(), context.worldConfig("island").getServerJar()); + assertFalse(new ClientStorage().read(world.getDirectory().resolve("eula.txt")).getProperty("eula").equals("true")); + assertThrows(IOException.class, () -> context.playWorld("island", false, new Cancellation())); + assertFalse(context.profiles().isInUse("server")); assertFalse(context.profiles().isInUse("matching")); + Files.delete(jar); assertThrows(IOException.class, () -> context.worldConfig("island")); + } + } + + @Test void cancelledLoaderInstallPublishesNoMarkerAndReleasesItsLease() throws Exception { + Cancellation cancellation = new Cancellation(); + LaunchTransport cancelled = new LaunchTransport() { + public void download(URI uri, Path target, Cancellation scope) throws IOException { + Files.write(target, "[]".getBytes(StandardCharsets.UTF_8)); scope.cancel(); + } + public JsonObject json(String method, URI uri, Map headers, byte[] body, Cancellation scope) { + throw new AssertionError("Cancelled list must not be used to install a loader"); + } + }; + try (NativeClientContext context = context(cancelled, (launcher, version, directory, session, host, port, scope) -> new FakeLaunch(directory, scope))) { + Profile profile = context.profiles().create("fabric", ProfileType.MODS, "1.20.4", "FABRIC", false); + assertThrows(IOException.class, () -> context.installLoader("fabric", null, cancellation)); + assertFalse(context.profiles().isInUse("fabric")); assertFalse(Files.exists(profile.getDirectory().resolve("loader.properties"))); + try (java.util.stream.Stream files = Files.list(context.getDataRoot())) { assertFalse(files.anyMatch(p -> p.getFileName().toString().startsWith(".loader-list-"))); } + } + } + + private NativeClientContext context(LaunchTransport transport, NativeClientContext.LaunchPreparation preparation) throws Exception { + Path data = Files.createTempDirectory(temporary.toRealPath(), "context-").toRealPath(); + ClientSettings settings = new ClientSettings(data); settings.set("cacheDirectory", data.resolve("cache").toString()); + settings.set("offlineFixtureMode", "true"); settings.set("username", "Fixture"); settings.save(); + return new NativeClientContext(data, data, transport, ENGINE, preparation); + } + + private static void waitFixture(CountDownLatch latch) throws IOException { + try { if (!latch.await(2, TimeUnit.SECONDS)) throw new IOException("Fixture release timed out"); } + catch (InterruptedException error) { Thread.currentThread().interrupt(); throw new IOException("Fixture interrupted", error); } + } + private static void await(java.util.function.BooleanSupplier condition) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(1); + while (!condition.getAsBoolean() && System.nanoTime() < deadline) Thread.sleep(1); + assertTrue(condition.getAsBoolean()); + } + + private static final class FakeLaunch implements NativeClientContext.ClientLaunch { + final FakeProcess process = new FakeProcess(); final Path natives; final Cancellation cancellation; + volatile boolean closed; boolean failStart; int starts; + FakeLaunch(Path directory, Cancellation cancellation) throws IOException { + this.cancellation = cancellation; natives = Files.createTempDirectory(directory, ".fixture-natives-"); + } + public Process start() throws IOException { cancellation.check(); starts++; if (failStart) throw new IOException("fixture start failed"); return process; } + public synchronized void close() throws IOException { cancellation.cancel(); process.finish(); Files.deleteIfExists(natives); closed = true; } + } + private static final class FakeProcess extends Process { + final CountDownLatch exited = new CountDownLatch(1); + public OutputStream getOutputStream() { return new java.io.ByteArrayOutputStream(); } + public InputStream getInputStream() { return new ByteArrayInputStream(new byte[0]); } + public InputStream getErrorStream() { return new ByteArrayInputStream(new byte[0]); } + public int waitFor() throws InterruptedException { exited.await(); return 0; } + public int exitValue() { if (isAlive()) throw new IllegalThreadStateException(); return 0; } + public void destroy() { finish(); } + public boolean isAlive() { return exited.getCount() != 0; } + void finish() { exited.countDown(); } + } +} diff --git a/src/test/java/com/osiris/autoplug/client/mc/client/NativeWorkflowIntegrationTest.java b/src/test/java/com/osiris/autoplug/client/mc/client/NativeWorkflowIntegrationTest.java new file mode 100644 index 00000000..b0d5e765 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/mc/client/NativeWorkflowIntegrationTest.java @@ -0,0 +1,239 @@ +package com.osiris.autoplug.client.mc.client; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.osiris.autoplug.client.mc.launch.Cancellation; +import com.osiris.autoplug.client.mc.launch.LaunchTransport; +import com.osiris.autoplug.client.mc.profiles.Profile; +import com.osiris.autoplug.client.mc.profiles.ProfileRepository; +import com.osiris.autoplug.client.mc.profiles.ProfileType; +import com.osiris.autoplug.client.mc.update.ProfileUpdateService; +import com.osiris.autoplug.client.mc.worlds.World; +import com.osiris.autoplug.client.mc.worlds.WorldSessionManager; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; +import java.security.MessageDigest; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +/** Synthetic integration only: no Minecraft, authentication, router or network access. */ +class NativeWorkflowIntegrationTest { + @TempDir Path temporary; + private static final byte[] ORIGINAL_MOD = syntheticJar("synthetic original mod"); + private static final byte[] UPGRADED_MOD = syntheticJar("synthetic upgraded mod"); + private static final ProfileUpdateService.Engine ENGINE = new ProfileUpdateService.Engine() { + public ProfileUpdateService.Decision resolve(Profile profile, Path jar, String version) throws IOException { + assertEquals("1.20.4", version); + return jar.getFileName().toString().equals("unsupported.jar") + ? ProfileUpdateService.Decision.incompatible("Fixture has no compatible release") + : ProfileUpdateService.Decision.replace("https://fixture.invalid/upgraded.jar", hash("SHA-256", UPGRADED_MOD), "Synthetic compatible replacement"); + } + public void download(ProfileUpdateService.Decision decision, Path target) throws IOException { + assertEquals("https://fixture.invalid/upgraded.jar", decision.getDownloadUrl()); Files.write(target, UPGRADED_MOD); + } + }; + + @Test @EnabledOnOs({OS.LINUX, OS.MAC}) + void templateMigrationActualSyntheticJvmAndCleanupUseTheSameContext() throws Exception { + Path data = data(); SyntheticTransport transport = new SyntheticTransport(17); + Path fakeHome = Files.createDirectories(data.resolve("synthetic-jre/bin")).getParent(); + Path arguments = data.resolve("synthetic-arguments.txt"); + // This executable only records synthetic arguments and becomes a short-lived sleep. + // exec avoids a detached child process. It never interprets or runs a game/JAR. + String script = "#!/bin/sh\nprintf '%s\\n' \"$@\" > " + quote(arguments.toString()) + "\nexec /bin/sleep 2\n"; + Path executable = fakeHome.resolve("bin/java"); Files.write(executable, script.getBytes(StandardCharsets.UTF_8)); + Files.setPosixFilePermissions(executable, PosixFilePermissions.fromString("rwx------")); + Files.write(fakeHome.resolve("release"), ("JAVA_VERSION=\"17.0.1\"\nOS_ARCH=\"" + System.getProperty("os.arch") + "\"\n").getBytes(StandardCharsets.UTF_8)); + ClientSettings settings = new ClientSettings(data); settings.set("javaHomes", fakeHome.toString()); settings.save(); + try (NativeClientContext context = new NativeClientContext(data, data, transport, ENGINE)) { + Profile template = context.profiles().create("template", ProfileType.MODS, "1.20.2", "VANILLA", true); + Files.write(template.getAssetsDirectory().resolve("upgrade.jar"), ORIGINAL_MOD); + Files.write(template.getAssetsDirectory().resolve("unsupported.jar"), syntheticJar("synthetic unsupported mod")); + Files.write(template.getConfigDirectory().resolve("settings.txt"), new byte[]{3}); + ProfileUpdateService.Plan plan = context.updater().planClone("template", "playable", "1.20.4"); + assertEquals(1, plan.getDisabledAssets().size()); + assertThrows(IOException.class, () -> context.updater().apply(plan, false)); + Profile playable = context.updater().apply(plan, true); + assertFalse(playable.isTemplate()); assertEquals("1.20.4", playable.getGameVersion()); + assertEquals("1.20.2", context.profiles().require("template").getGameVersion()); + assertArrayEquals(ORIGINAL_MOD, Files.readAllBytes(template.getAssetsDirectory().resolve("upgrade.jar"))); + assertArrayEquals(UPGRADED_MOD, Files.readAllBytes(playable.getAssetsDirectory().resolve("upgrade.jar"))); + assertTrue(Files.isRegularFile(playable.getAssetsDirectory().resolve("unsupported.jar.disabled"))); + assertArrayEquals(new byte[]{3}, Files.readAllBytes(playable.getConfigDirectory().resolve("settings.txt"))); + + Process process = context.launch("playable", "example.invalid:25577", new Cancellation()); + assertTrue(context.profiles().isInUse("playable")); + assertThrows(IOException.class, () -> context.profiles().delete("playable")); + await(() -> Files.isRegularFile(arguments)); + List args = Files.readAllLines(arguments, StandardCharsets.UTF_8); + assertEquals(playable.getDirectory().toString(), following(args, "--gameDir")); + assertEquals("1.20.4", following(args, "--version")); + assertEquals("example.invalid", following(args, "--server")); assertEquals("25577", following(args, "--port")); + assertTrue(process.waitFor(5, TimeUnit.SECONDS)); + await(() -> !context.profiles().isInUse("playable")); assertNoNatives(playable); + int downloads = transport.downloads; + Files.delete(arguments); + Process second = context.launch("playable", null, new Cancellation()); await(() -> Files.isRegularFile(arguments)); + context.stopClient("playable"); assertTrue(second.waitFor(5, TimeUnit.SECONDS)); + await(() -> !context.profiles().isInUse("playable")); assertNoNatives(playable); + assertEquals(downloads, transport.downloads, "Verified synthetic artifacts are reused from the shared cache"); + } + } + + @Test void failedWorldStartRetainsServerLeaseUntilContextStopCanActuallyFinish() throws Exception { + Path data = data(); ResistantProcess server = new ResistantProcess(); + AtomicReference manager = new AtomicReference<>(); + try (NativeClientContext context = new NativeClientContext(data, data, new SyntheticTransport(runtimeMajor()), ENGINE, + (launcher, version, directory, session, host, port, cancel) -> { throw new AssertionError("No client before readiness"); }, + repository -> { WorldSessionManager sessions = new WorldSessionManager(repository, null, (command, directory) -> server, + (host, port) -> false, () -> 25577, new WorldSessionManager.Options(2, 1, 1)); manager.set(sessions); return sessions; })) { + World world = createWorld(context); Path jar = Files.write(data.resolve("synthetic-server.jar"), new byte[]{1}); + assertThrows(IOException.class, () -> context.playWorld("island", "client", jar, false, new Cancellation())); + assertTrue(context.profiles().isInUse("server")); assertFalse(context.profiles().isInUse("client")); + assertNotNull(context.activeWorld("island")); assertTrue(context.activeWorld("island").isStopping()); + assertThrows(IOException.class, () -> context.profiles().delete("server")); + server.allowExit.set(true); context.stopWorld("island"); + assertFalse(context.profiles().isInUse("server")); assertNull(context.activeWorld("island")); assertFalse(server.isAlive()); + } finally { server.finish(); if (manager.get() != null) manager.get().close(); } + } + + @Test void contextStopRetriesFailedRouterCleanupWithoutReleasingServerLeaseEarly() throws Exception { + Path data = data(); ResistantProcess server = new ResistantProcess(), client = new ResistantProcess(); + server.allowExit.set(true); client.allowExit.set(true); AtomicBoolean refuseRemoval = new AtomicBoolean(true); + try (NativeClientContext context = new NativeClientContext(data, data, new SyntheticTransport(runtimeMajor()), ENGINE, + (launcher, version, directory, session, host, port, cancel) -> new NativeClientContext.ClientLaunch() { + public Process start() { return client; } + public void close() { client.finish(); } + }, repository -> new WorldSessionManager(repository, port -> new com.osiris.autoplug.client.mc.sharing.PortSharingService.Lease() { + public String getJoinAddress() { return "fixture.invalid:25577"; } + public void close() throws IOException { if (refuseRemoval.get()) throw new IOException("Synthetic router refused cleanup"); } + }, (command, directory) -> server, (host, port) -> true, () -> 25577, new WorldSessionManager.Options(2, 1, 1)))) { + createWorld(context); Path jar = Files.write(data.resolve("synthetic-server.jar"), new byte[]{1}); + context.playWorld("island", "client", jar, true, new Cancellation()); + assertThrows(IOException.class, () -> context.stopWorld("island")); + assertNotNull(context.activeWorld("island")); assertTrue(context.profiles().isInUse("server")); + assertFalse(context.activeWorld("island").isClosed()); + refuseRemoval.set(false); context.stopWorld("island"); + assertNull(context.activeWorld("island")); assertFalse(context.profiles().isInUse("server")); + await(() -> !context.profiles().isInUse("client")); + } finally { refuseRemoval.set(false); server.finish(); client.finish(); } + } + + @Test void cancelledWorldClientThatResistsCleanupKeepsItsTransferredLease() throws Exception { + Path data = data(); ResistantProcess server = new ResistantProcess(), client = new ResistantProcess(); + server.allowExit.set(true); AtomicBoolean refuse = new AtomicBoolean(true); + try (NativeClientContext context = new NativeClientContext(data, data, new SyntheticTransport(runtimeMajor()), ENGINE, + (launcher, version, directory, session, host, port, cancel) -> new NativeClientContext.ClientLaunch() { + public Process start() { cancel.cancel(); return client; } + public void close() throws IOException { if (refuse.get()) throw new IOException("Synthetic client refuses cleanup"); client.finish(); } + }, repository -> new WorldSessionManager(repository, null, (command, directory) -> server, + (host, port) -> true, () -> 25577, new WorldSessionManager.Options(2, 1, 1)))) { + createWorld(context); Path jar = Files.write(data.resolve("synthetic-server.jar"), new byte[]{1}); + assertThrows(IOException.class, () -> context.playWorld("island", "client", jar, false, new Cancellation())); + assertFalse(context.profiles().isInUse("server")); assertTrue(context.profiles().isInUse("client")); + assertTrue(client.isAlive()); assertThrows(IOException.class, () -> context.profiles().delete("client")); + refuse.set(false); context.stopClient("client"); await(() -> !context.profiles().isInUse("client")); + assertFalse(client.isAlive()); + } finally { refuse.set(false); server.finish(); client.finish(); } + } + + private Path data() throws IOException { + Path data = Files.createTempDirectory(temporary.toRealPath(), "workflow-").toRealPath(); + ClientSettings settings = new ClientSettings(data); settings.set("cacheDirectory", data.resolve("cache").toString()); + settings.set("offlineFixtureMode", "true"); settings.set("username", "Fixture"); settings.save(); return data; + } + private World createWorld(NativeClientContext context) throws IOException { + context.profiles().create("server", ProfileType.PLUGINS, "1.20.4", "PAPER", false); + context.profiles().create("client", ProfileType.MODS, "1.20.4", "VANILLA", false); + World world = context.worlds().create("island", "server"); + // Explicit acceptance exists only in this synthetic fixture, never a production side effect. + Files.write(world.getDirectory().resolve("eula.txt"), "eula=true\n".getBytes(StandardCharsets.UTF_8)); return world; + } + private static int runtimeMajor() { return Integer.parseInt(System.getProperty("java.specification.version")); } + private static String quote(String value) { return "'" + value.replace("'", "'\\''") + "'"; } + private static String following(List values, String flag) { int index = values.indexOf(flag); assertTrue(index >= 0); return values.get(index + 1); } + private static void assertNoNatives(Profile profile) throws IOException { + try (java.util.stream.Stream paths = Files.list(profile.getDirectory().resolve(".autoplug"))) { + assertFalse(paths.anyMatch(path -> path.getFileName().toString().startsWith("natives-"))); + } + } + private static void await(java.util.function.BooleanSupplier condition) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (!condition.getAsBoolean() && System.nanoTime() < deadline) Thread.sleep(2); + assertTrue(condition.getAsBoolean()); + } + private static String hash(String algorithm, byte[] bytes) throws IOException { + try { + StringBuilder text = new StringBuilder(); + for (byte value : MessageDigest.getInstance(algorithm).digest(bytes)) text.append(String.format("%02x", value & 255)); + return text.toString(); + } catch (java.security.NoSuchAlgorithmException error) { throw new IOException(error); } + } + private static byte[] syntheticJar(String content) { + try { + java.io.ByteArrayOutputStream bytes = new java.io.ByteArrayOutputStream(); + try (java.util.zip.ZipOutputStream zip = new java.util.zip.ZipOutputStream(bytes)) { + java.util.zip.ZipEntry entry = new java.util.zip.ZipEntry("synthetic-fixture.txt"); entry.setTime(0); + zip.putNextEntry(entry); zip.write(content.getBytes(StandardCharsets.UTF_8)); zip.closeEntry(); + } + return bytes.toByteArray(); + } catch (IOException error) { throw new ExceptionInInitializerError(error); } + } + + private static final class SyntheticTransport implements LaunchTransport { + final Map bytes = new HashMap<>(); final JsonObject manifest = new JsonObject(); int downloads; + SyntheticTransport(int javaMajor) throws IOException { + JsonObject version = JsonParser.parseString("{\"id\":\"1.20.4\",\"type\":\"release\",\"mainClass\":\"fixture.Main\",\"libraries\":[],\"arguments\":{\"jvm\":[\"-Djava.library.path=${natives_directory}\",\"-cp\",\"${classpath}\"],\"game\":[\"--username\",\"${auth_player_name}\",\"--gameDir\",\"${game_directory}\",\"--version\",\"${version_name}\"]}}").getAsJsonObject(); + JsonObject runtime = new JsonObject(); runtime.addProperty("majorVersion", javaMajor); version.add("javaVersion", runtime); + JsonObject artifacts = new JsonObject(); artifacts.add("client", artifact("https://fixture.invalid/client.jar", new byte[]{1, 2, 3})); version.add("downloads", artifacts); + JsonObject assets = artifact("https://fixture.invalid/assets.json", "{\"objects\":{}}".getBytes(StandardCharsets.UTF_8)); assets.addProperty("id", "fixture-assets"); version.add("assetIndex", assets); + JsonObject descriptor = artifact("https://fixture.invalid/version.json", version.toString().getBytes(StandardCharsets.UTF_8)); descriptor.addProperty("id", "1.20.4"); + JsonArray versions = new JsonArray(); versions.add(descriptor); manifest.add("versions", versions); + } + private JsonObject artifact(String url, byte[] data) throws IOException { + bytes.put(url, data); JsonObject descriptor = new JsonObject(); descriptor.addProperty("url", url); + descriptor.addProperty("sha1", hash("SHA-1", data)); descriptor.addProperty("size", data.length); return descriptor; + } + public JsonObject json(String method, URI uri, Map headers, byte[] body, Cancellation cancel) throws IOException { + cancel.check(); if (!uri.toString().equals("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json")) throw new AssertionError("No fixture JSON for " + uri); + return manifest.deepCopy(); + } + public void download(URI uri, Path target, Cancellation cancel) throws IOException { + cancel.check(); byte[] body = bytes.get(uri.toString()); if (body == null) throw new AssertionError("No fixture bytes for " + uri); + downloads++; Files.write(target, body); + } + } + private static final class ResistantProcess extends Process { + final CountDownLatch exited = new CountDownLatch(1); final AtomicBoolean allowExit = new AtomicBoolean(); + public OutputStream getOutputStream() { return new OutputStream() { public void write(int value) { } public void flush() { if (allowExit.get()) finish(); } }; } + public InputStream getInputStream() { return new ByteArrayInputStream(new byte[0]); } + public InputStream getErrorStream() { return new ByteArrayInputStream(new byte[0]); } + public int waitFor() throws InterruptedException { exited.await(); return 0; } + public boolean waitFor(long time, TimeUnit unit) { return !isAlive(); } + public int exitValue() { if (isAlive()) throw new IllegalThreadStateException(); return 0; } + public void destroy() { if (allowExit.get()) finish(); } + public Process destroyForcibly() { destroy(); return this; } + public boolean isAlive() { return exited.getCount() != 0; } + void finish() { exited.countDown(); } + } +} From c65ade449df2d9c6d00cca21f5934d41eaf4ee86 Mon Sep 17 00:00:00 2001 From: elcuervo171234 <43775780+elcuervo171234@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:23:33 -0400 Subject: [PATCH 17/23] test(minecraft): integrate launch (17/23) --- .../client/mc/launch/LaunchFixtures.java | 123 +++++++++++ .../client/mc/launch/LaunchLifecycleTest.java | 85 ++++++++ .../launch/MicrosoftDeviceCodeAuthTest.java | 115 ++++++++++ .../mc/launch/MinecraftLauncherTest.java | 196 ++++++++++++++++++ .../client/mc/launch/MojangMetadataTest.java | 73 +++++++ .../launch/OfficialLoaderInstallerTest.java | 78 +++++++ .../mc/launch/RecordedMojangMetadataTest.java | 45 ++++ 7 files changed, 715 insertions(+) create mode 100644 src/test/java/com/osiris/autoplug/client/mc/launch/LaunchFixtures.java create mode 100644 src/test/java/com/osiris/autoplug/client/mc/launch/LaunchLifecycleTest.java create mode 100644 src/test/java/com/osiris/autoplug/client/mc/launch/MicrosoftDeviceCodeAuthTest.java create mode 100644 src/test/java/com/osiris/autoplug/client/mc/launch/MinecraftLauncherTest.java create mode 100644 src/test/java/com/osiris/autoplug/client/mc/launch/MojangMetadataTest.java create mode 100644 src/test/java/com/osiris/autoplug/client/mc/launch/OfficialLoaderInstallerTest.java create mode 100644 src/test/java/com/osiris/autoplug/client/mc/launch/RecordedMojangMetadataTest.java diff --git a/src/test/java/com/osiris/autoplug/client/mc/launch/LaunchFixtures.java b/src/test/java/com/osiris/autoplug/client/mc/launch/LaunchFixtures.java new file mode 100644 index 00000000..6700f249 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/mc/launch/LaunchFixtures.java @@ -0,0 +1,123 @@ +package com.osiris.autoplug.client.mc.launch; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** Synthetic byte fixtures only; an unknown URL fails instead of reaching the network. */ +final class LaunchFixtures { + final FakeTransport transport = new FakeTransport(); + final ArtifactCache cache; + final MojangMetadata metadata; + final JavaRuntimeSelector runtimes; + final JsonObject version = new JsonObject(); + final Path root; + + LaunchFixtures(Path root) throws Exception { + this.root = root; + cache = new ArtifactCache(root.resolve("cache"), transport); + metadata = new MojangMetadata(transport, cache); + Path javaExecutable = Files.createDirectories(root.resolve("jre17/bin")).resolve("java"); + Files.write(javaExecutable, new byte[0]); + Files.write(root.resolve("jre17/release"), "JAVA_VERSION=\"17.0.12\"\nOS_ARCH=\"x86_64\"\n".getBytes(StandardCharsets.UTF_8)); + Files.createDirectories(root.resolve("jre17-arm64/bin")); Files.write(root.resolve("jre17-arm64/bin/java"), new byte[0]); + Files.write(root.resolve("jre17-arm64/release"), "JAVA_VERSION=\"17.0.12\"\nOS_ARCH=\"aarch64\"\n".getBytes(StandardCharsets.UTF_8)); + runtimes = new JavaRuntimeSelector(java.util.Arrays.asList(root.resolve("jre17"), root.resolve("jre17-arm64")), false); + version.addProperty("id", "1.20.4"); + version.addProperty("type", "release"); + version.addProperty("mainClass", "example.fixture.Main"); + version.add("javaVersion", json("{\"majorVersion\":17}")); + JsonObject downloads = new JsonObject(); + downloads.add("client", artifact("https://fixture.invalid/client.jar", new byte[]{1, 2, 3})); + version.add("downloads", downloads); + JsonArray libraries = new JsonArray(); + JsonObject library = new JsonObject(); library.addProperty("name", "example:library:1.0"); + JsonObject libraryDownloads = new JsonObject(); + JsonObject descriptor = artifact("https://fixture.invalid/library.jar", new byte[]{4, 5}); + descriptor.addProperty("path", "example/library/1.0/library-1.0.jar"); + libraryDownloads.add("artifact", descriptor); library.add("downloads", libraryDownloads); libraries.add(library); + version.add("libraries", libraries); + version.add("arguments", json("{\"jvm\":[\"-Djava.library.path=${natives_directory}\",\"-cp\",\"${classpath}\"],\"game\":[\"--username\",\"${auth_player_name}\",\"--gameDir\",\"${game_directory}\",\"--accessToken\",\"${auth_access_token}\"]}")); + byte[] asset = "synthetic sound".getBytes(StandardCharsets.UTF_8); + String hash = hash(asset); + JsonObject index = json("{\"virtual\":true,\"map_to_resources\":true,\"objects\":{}}"); + JsonObject item = new JsonObject(); item.addProperty("hash", hash); item.addProperty("size", asset.length); + index.getAsJsonObject("objects").add("sounds/example.txt", item); + transport.bytes.put("https://resources.download.minecraft.net/" + hash.substring(0, 2) + "/" + hash, asset); + JsonObject indexDescriptor = artifact("https://fixture.invalid/assets.json", index.toString().getBytes(StandardCharsets.UTF_8)); + indexDescriptor.addProperty("id", "fixture-assets"); version.add("assetIndex", indexDescriptor); + publishVersion(); + } + + MinecraftLauncher launcher(String os, String architecture) { + return new MinecraftLauncher(cache, metadata, new LaunchPlatform(os, architecture, "fixture", Collections.emptyMap()), runtimes); + } + + void publishVersion() throws Exception { + JsonObject descriptor = artifact("https://fixture.invalid/version.json", version.toString().getBytes(StandardCharsets.UTF_8)); + descriptor.addProperty("id", version.get("id").getAsString()); + JsonObject manifest = new JsonObject(); JsonArray versions = new JsonArray(); versions.add(descriptor); + manifest.add("versions", versions); + transport.responses.put("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json", manifest); + } + + JsonObject artifact(String url, byte[] bytes) throws Exception { + transport.bytes.put(url, bytes); + JsonObject result = new JsonObject(); result.addProperty("url", url); result.addProperty("size", bytes.length); result.addProperty("sha1", hash(bytes)); + return result; + } + + String hash(byte[] bytes) throws Exception { + StringBuilder result = new StringBuilder(); + for (byte value : MessageDigest.getInstance("SHA-1").digest(bytes)) result.append(String.format("%02x", value & 255)); + return result.toString(); + } + + JsonObject json(String json) { return JsonParser.parseString(json).getAsJsonObject(); } + + byte[] zip(String name, String content) throws IOException { + return zip(Collections.singletonMap(name, content)); + } + + byte[] zip(Map files) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(output)) { + for (Map.Entry file : files.entrySet()) { + zip.putNextEntry(new ZipEntry(file.getKey())); zip.write(file.getValue().getBytes(StandardCharsets.UTF_8)); zip.closeEntry(); + } + } + return output.toByteArray(); + } + + final class FakeTransport implements LaunchTransport { + final Map bytes = new HashMap<>(); + final Map responses = new HashMap<>(); + final List downloads = new ArrayList<>(); + @Override public JsonObject json(String method, URI uri, Map headers, byte[] body, Cancellation cancellation) throws IOException { + cancellation.check(); + JsonObject response = responses.get(uri.toString()); + if (response == null) throw new IOException("No synthetic JSON fixture for " + uri); + return response.deepCopy(); + } + @Override public void download(URI uri, Path destination, Cancellation cancellation) throws IOException { + cancellation.check(); + byte[] data = bytes.get(uri.toString()); + if (data == null) throw new IOException("No synthetic download fixture for " + uri); + downloads.add(uri.toString()); Files.write(destination, data); + } + } +} diff --git a/src/test/java/com/osiris/autoplug/client/mc/launch/LaunchLifecycleTest.java b/src/test/java/com/osiris/autoplug/client/mc/launch/LaunchLifecycleTest.java new file mode 100644 index 00000000..07bb4026 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/mc/launch/LaunchLifecycleTest.java @@ -0,0 +1,85 @@ +package com.osiris.autoplug.client.mc.launch; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import static org.junit.jupiter.api.Assertions.*; + +class LaunchLifecycleTest { + @TempDir Path temporaryDirectory; + + @Test void oneThrowingCancellationListenerCannotSkipOtherCleanup() { + Cancellation cancellation = new Cancellation(); AtomicInteger calls = new AtomicInteger(); + cancellation.onCancel(() -> { throw new IllegalStateException("Synthetic cleanup failure"); }); + cancellation.onCancel(calls::incrementAndGet); cancellation.cancel(); cancellation.cancel(); + assertEquals(1, calls.get()); + } + + @Test void listenerRegistrationRacingWithCancellationFiresAtMostOnce() throws Exception { + for (int attempt = 0; attempt < 100; attempt++) { + Cancellation cancellation = new Cancellation(); AtomicInteger calls = new AtomicInteger(); CyclicBarrier start = new CyclicBarrier(2); + Thread register = new Thread(() -> { await(start); cancellation.onCancel(calls::incrementAndGet); }); + Thread cancel = new Thread(() -> { await(start); cancellation.cancel(); }); + register.start(); cancel.start(); register.join(); cancel.join(); + assertEquals(1, calls.get()); + } + } + + @Test void unregisteredListenerDoesNotRunAndLateRegistrationRunsExactlyOnce() throws Exception { + Cancellation cancellation = new Cancellation(); AtomicInteger calls = new AtomicInteger(); + AutoCloseable registration = cancellation.onCancel(calls::incrementAndGet); registration.close(); cancellation.cancel(); + assertEquals(0, calls.get()); + cancellation.onCancel(calls::incrementAndGet); cancellation.cancel(); assertEquals(1, calls.get()); + } + + @Test void closeWaitsForTerminationAndRemovesOnlyOwnedNativesWithoutStartingARealProcess() throws Exception { + Path natives = Files.createDirectory(temporaryDirectory.resolve("owned-natives")); Files.write(natives.resolve("fixture.so"), new byte[]{1}); + Path keep = temporaryDirectory.resolve("keep.txt"); Files.write(keep, new byte[]{7}); + FakeProcess process = new FakeProcess(true); + PreparedLaunch prepared = new PreparedLaunch(Collections.singletonList("synthetic-java"), temporaryDirectory, new Cancellation(), natives, builder -> process); + assertSame(process, prepared.start()); + prepared.close(); prepared.close(); + assertFalse(process.isAlive()); assertTrue(process.forced.get() > 0); assertFalse(Files.exists(natives)); assertTrue(Files.exists(keep)); + } + + @Test void failedProcessStartCleansNativesAndDoesNotAllowReusingTheCommand() throws Exception { + Path natives = Files.createDirectory(temporaryDirectory.resolve("owned-natives")); + PreparedLaunch prepared = new PreparedLaunch(Collections.singletonList("synthetic-java"), temporaryDirectory, new Cancellation(), natives, + builder -> { throw new IOException("Synthetic process-start failure"); }); + assertThrows(IOException.class, prepared::start); assertFalse(Files.exists(natives)); + assertThrows(IOException.class, prepared::start); prepared.close(); + } + + private void await(CyclicBarrier barrier) { + try { barrier.await(); } catch (Exception error) { throw new AssertionError(error); } + } + private final class FakeProcess extends Process { + private volatile boolean alive = true; + private final boolean ignoreDestroy; + private final AtomicInteger forced = new AtomicInteger(); + private final CompletableFuture exit = new CompletableFuture<>(); + FakeProcess(boolean ignoreDestroy) { this.ignoreDestroy = ignoreDestroy; } + @Override public OutputStream getOutputStream() { return new ByteArrayOutputStream(); } + @Override public InputStream getInputStream() { return new ByteArrayInputStream(new byte[0]); } + @Override public InputStream getErrorStream() { return new ByteArrayInputStream(new byte[0]); } + @Override public int waitFor() { return 0; } + @Override public boolean waitFor(long timeout, TimeUnit unit) { return !alive; } + @Override public int exitValue() { if (alive) throw new IllegalThreadStateException(); return 0; } + @Override public boolean isAlive() { return alive; } + @Override public void destroy() { if (!ignoreDestroy) finish(); } + @Override public Process destroyForcibly() { forced.incrementAndGet(); finish(); return this; } + private void finish() { alive = false; exit.complete(this); } + @Override public CompletableFuture onExit() { return exit; } + } +} diff --git a/src/test/java/com/osiris/autoplug/client/mc/launch/MicrosoftDeviceCodeAuthTest.java b/src/test/java/com/osiris/autoplug/client/mc/launch/MicrosoftDeviceCodeAuthTest.java new file mode 100644 index 00000000..363bce67 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/mc/launch/MicrosoftDeviceCodeAuthTest.java @@ -0,0 +1,115 @@ +package com.osiris.autoplug.client.mc.launch; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.jupiter.api.Test; +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.*; + +class MicrosoftDeviceCodeAuthTest { + @Test void requiresAnApplicationOwnedClientIdInsteadOfEmbeddingSomeoneElses() { + assertThrows(IllegalArgumentException.class, () -> new MicrosoftDeviceCodeAuth("", new QueueTransport())); + } + + @Test void deviceFlowPollsPendingAndSlowDownThenExchangesAllTokensInMemory() throws Exception { + QueueTransport transport = new QueueTransport(); MutableClock clock = new MutableClock(); + transport.add("{\"device_code\":\"synthetic-device\",\"user_code\":\"TEST-CODE\",\"verification_uri\":\"https://microsoft.com/devicelogin\",\"expires_in\":300,\"interval\":1}"); + transport.add("{\"error\":\"authorization_pending\"}"); transport.add("{\"error\":\"slow_down\"}"); + successfulResponses(transport, true); + MicrosoftDeviceCodeAuth auth = new MicrosoftDeviceCodeAuth("fixture-public-client", transport, clock, clock::advance); + MicrosoftDeviceCodeAuth.DeviceAuthorization authorization = auth.begin(new Cancellation()); + assertEquals("TEST-CODE", authorization.getUserCode()); + assertFalse(authorization.toString().contains("TEST-CODE")); + MicrosoftDeviceCodeAuth.AuthResult result = auth.poll(authorization, new Cancellation()); + assertEquals("FixturePlayer", result.getSession().getUsername()); + assertEquals("msa", result.getSession().getUserType()); + assertEquals("mc-fixture-token", result.getSession().getAccessToken()); + assertEquals("refresh-fixture", result.getRefreshToken()); + assertEquals(8, clock.elapsedSeconds()); // 1 pending + 1 slowdown + 6 final poll. + assertTrue(transport.bodies.get(0).contains("client_id=fixture-public-client")); + assertTrue(transport.bodies.stream().anyMatch(body -> body.contains("d=oauth-fixture-token"))); + assertTrue(transport.bodies.stream().anyMatch(body -> body.contains("XBL3.0 x=fixture-hash;xsts-fixture-token"))); + assertFalse(result.toString().contains("refresh-fixture")); + assertEquals("Bearer mc-fixture-token", transport.authorizationHeaders.get(transport.authorizationHeaders.size() - 1)); + } + + @Test void rejectsAccountsWithoutMinecraftJavaOwnershipBeforeReturningASession() throws Exception { + QueueTransport transport = new QueueTransport(); successfulResponses(transport, false); + MicrosoftDeviceCodeAuth auth = new MicrosoftDeviceCodeAuth("fixture-public-client", transport); + assertThrows(IOException.class, () -> auth.refresh("old-refresh", new Cancellation())); + assertFalse(transport.urls.contains("https://api.minecraftservices.com/minecraft/profile")); + } + + @Test void refreshKeepsTheExistingRefreshTokenWhenTheProviderDoesNotRotateIt() throws Exception { + QueueTransport transport = new QueueTransport(); successfulResponses(transport, true); + transport.responses.removeFirst(); + transport.responses.addFirst(json("{\"access_token\":\"oauth-fixture-token\"}")); + MicrosoftDeviceCodeAuth.AuthResult result = new MicrosoftDeviceCodeAuth("fixture-public-client", transport) + .refresh("previous-refresh", new Cancellation()); + assertEquals("previous-refresh", result.getRefreshToken()); + assertTrue(transport.bodies.get(0).contains("grant_type=refresh_token")); + } + + @Test void declinedAuthorizationAndExpiryDoNotContinueToXbox() throws Exception { + QueueTransport transport = new QueueTransport(); MutableClock clock = new MutableClock(); + transport.add("{\"device_code\":\"fixture\",\"user_code\":\"CODE\",\"verification_uri\":\"https://microsoft.com/devicelogin\",\"expires_in\":2,\"interval\":1}"); + transport.add("{\"error\":\"authorization_declined\"}"); + MicrosoftDeviceCodeAuth auth = new MicrosoftDeviceCodeAuth("fixture-client", transport, clock, clock::advance); + MicrosoftDeviceCodeAuth.DeviceAuthorization authorization = auth.begin(new Cancellation()); + assertThrows(IOException.class, () -> auth.poll(authorization, new Cancellation())); + assertEquals(2, transport.urls.size()); + } + + @Test void cancellationPreventsSendingTokensOrWaitingForThePollingInterval() throws Exception { + QueueTransport transport = new QueueTransport(); Cancellation cancellation = new Cancellation(); cancellation.cancel(); + assertThrows(IOException.class, () -> new MicrosoftDeviceCodeAuth("fixture-client", transport).refresh("synthetic", cancellation)); + assertTrue(transport.urls.isEmpty()); + } + + private void successfulResponses(QueueTransport transport, boolean ownership) { + transport.add("{\"access_token\":\"oauth-fixture-token\",\"refresh_token\":\"refresh-fixture\"}"); + transport.add("{\"Token\":\"xbox-fixture-token\",\"DisplayClaims\":{\"xui\":[{\"uhs\":\"fixture-hash\"}]}}"); + transport.add("{\"Token\":\"xsts-fixture-token\",\"DisplayClaims\":{\"xui\":[{\"uhs\":\"fixture-hash\",\"xid\":\"12345\"}]}}"); + transport.add("{\"access_token\":\"mc-fixture-token\",\"expires_in\":3600}"); + transport.add(ownership ? "{\"items\":[{\"name\":\"game_minecraft\"}]}" : "{\"items\":[]}"); + if (ownership) transport.add("{\"name\":\"FixturePlayer\",\"id\":\"0123456789abcdef0123456789abcdef\"}"); + } + + private JsonObject json(String text) { return JsonParser.parseString(text).getAsJsonObject(); } + + private final class QueueTransport implements LaunchTransport { + final ArrayDeque responses = new ArrayDeque<>(); + final List bodies = new ArrayList<>(); final List urls = new ArrayList<>(); + final List authorizationHeaders = new ArrayList<>(); + void add(String text) { responses.add(MicrosoftDeviceCodeAuthTest.this.json(text)); } + @Override public JsonObject json(String method, URI uri, Map headers, byte[] body, Cancellation cancellation) throws IOException { + cancellation.check(); urls.add(uri.toString()); + bodies.add(body == null ? "" : new String(body, StandardCharsets.UTF_8)); + authorizationHeaders.add(headers.get("Authorization")); + if (responses.isEmpty()) throw new IOException("No synthetic authentication response"); + return responses.removeFirst(); + } + @Override public void download(URI uri, Path path, Cancellation cancellation) throws IOException { throw new IOException("Authentication fixture never downloads"); } + } + + private final class MutableClock extends Clock { + private Instant now = Instant.parse("2026-10-17T00:00:00Z"); + void advance(long milliseconds) { now = now.plusMillis(milliseconds); } + long elapsedSeconds() { return java.time.Duration.between(Instant.parse("2026-10-17T00:00:00Z"), now).getSeconds(); } + @Override public ZoneId getZone() { return ZoneOffset.UTC; } + @Override public Clock withZone(ZoneId zone) { return this; } + @Override public Instant instant() { return now; } + } +} diff --git a/src/test/java/com/osiris/autoplug/client/mc/launch/MinecraftLauncherTest.java b/src/test/java/com/osiris/autoplug/client/mc/launch/MinecraftLauncherTest.java new file mode 100644 index 00000000..9e93df4e --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/mc/launch/MinecraftLauncherTest.java @@ -0,0 +1,196 @@ +package com.osiris.autoplug.client.mc.launch; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.stream.Stream; +import static org.junit.jupiter.api.Assertions.*; + +class MinecraftLauncherTest { + @TempDir Path temporaryDirectory; + + @Test void resolvesModernLibrariesAssetsArgumentsAndDirectConnectWithoutStartingAnything() throws Exception { + LaunchFixtures fixture = new LaunchFixtures(temporaryDirectory); + MinecraftLauncher launcher = fixture.launcher("linux", "amd64"); + Path game = temporaryDirectory.resolve("game with spaces"); + try (PreparedLaunch launch = launcher.prepare("1.20.4", game, launcher.offlineSession("FixturePlayer"), "example.invalid", 25570, new Cancellation())) { + List command = launch.getCommand(); + assertEquals(temporaryDirectory.resolve("jre17/bin/java").toString(), command.get(0)); + assertTrue(command.contains("example.fixture.Main")); + assertEquals(game.toRealPath().toString(), command.get(command.indexOf("--gameDir") + 1)); + assertEquals("example.invalid", command.get(command.indexOf("--server") + 1)); + assertEquals("25570", command.get(command.indexOf("--port") + 1)); + assertTrue(command.get(command.indexOf("-cp") + 1).contains("library-1.0.jar")); + assertTrue(Files.isRegularFile(game.resolve("resources/sounds/example.txt"))); + assertTrue(Files.isRegularFile(fixture.cache.resolve("assets/virtual/fixture-assets/sounds/example.txt"))); + assertEquals("PreparedLaunch[command redacted]", launch.toString()); + } + try (Stream natives = Files.list(game.resolve(".autoplug"))) { assertEquals(0, natives.count()); } + } + + @Test void cacheVerifiesExistingBytesAndRepairsCorruption() throws Exception { + LaunchFixtures fixture = new LaunchFixtures(temporaryDirectory); + byte[] expected = new byte[]{1, 2, 3}; + Cancellation cancellation = new Cancellation(); + Path file = fixture.cache.obtain("fixture.bin", URI.create("https://fixture.invalid/client.jar"), fixture.hash(expected), 3, cancellation); + int downloads = fixture.transport.downloads.size(); + fixture.cache.obtain("fixture.bin", URI.create("https://fixture.invalid/client.jar"), fixture.hash(expected), 3, cancellation); + assertEquals(downloads, fixture.transport.downloads.size()); + Files.write(file, new byte[]{9, 9, 9}); + fixture.cache.obtain("fixture.bin", URI.create("https://fixture.invalid/client.jar"), fixture.hash(expected), 3, cancellation); + assertArrayEquals(expected, Files.readAllBytes(file)); + assertEquals(downloads + 1, fixture.transport.downloads.size()); + } + + @Test void checksumFailurePublishesNeitherArtifactNorPartialFile() throws Exception { + LaunchFixtures fixture = new LaunchFixtures(temporaryDirectory); + assertThrows(IOException.class, () -> fixture.cache.obtain("bad/artifact.jar", URI.create("https://fixture.invalid/client.jar"), + "0000000000000000000000000000000000000000", 3, new Cancellation())); + assertFalse(Files.exists(fixture.cache.resolve("bad/artifact.jar"))); + try (Stream files = Files.list(fixture.cache.resolve("bad"))) { assertEquals(0, files.count()); } + } + + @Test void cancelledPreparationDoesNotDownloadOrBuildACommand() throws Exception { + LaunchFixtures fixture = new LaunchFixtures(temporaryDirectory); + Cancellation cancellation = new Cancellation(); cancellation.cancel(); + assertThrows(IOException.class, () -> fixture.launcher("linux", "amd64").prepare("1.20.4", temporaryDirectory.resolve("game"), + fixture.launcher("linux", "amd64").offlineSession("Fixture"), null, 0, cancellation)); + assertTrue(fixture.transport.downloads.isEmpty()); + } + + @Test void extractsOnlyTheMatchingNativeArchitectureAndOmitsMetadata() throws Exception { + LaunchFixtures fixture = new LaunchFixtures(temporaryDirectory); + for (String classifier : new String[]{"natives-macos", "natives-macos-arm64"}) { + JsonObject library = new JsonObject(); library.addProperty("name", "example:native:1:" + classifier); + JsonObject downloads = new JsonObject(); + JsonObject artifact = fixture.artifact("https://fixture.invalid/" + classifier + ".jar", + fixture.zip(java.util.Map.of("libfixture.dylib", classifier, "META-INF/fixture.txt", "excluded"))); + artifact.addProperty("path", classifier + ".jar"); downloads.add("artifact", artifact); library.add("downloads", downloads); + library.add("rules", fixture.json("{\"rules\":[{\"action\":\"allow\",\"os\":{\"name\":\"osx\"}}]}").get("rules")); + fixture.version.getAsJsonArray("libraries").add(library); + } + fixture.publishVersion(); + MinecraftLauncher launcher = fixture.launcher("osx", "aarch64"); + try (PreparedLaunch launch = launcher.prepare("1.20.4", temporaryDirectory.resolve("game"), launcher.offlineSession("Fixture"), null, 0, new Cancellation())) { + String nativeArgument = launch.getCommand().stream().filter(argument -> argument.startsWith("-Djava.library.path=")).findFirst().orElseThrow(AssertionError::new); + Path natives = java.nio.file.Paths.get(nativeArgument.substring(nativeArgument.indexOf('=') + 1)); + assertEquals("natives-macos-arm64", new String(Files.readAllBytes(natives.resolve("libfixture.dylib")), StandardCharsets.UTF_8)); + assertFalse(Files.exists(natives.resolve("META-INF/fixture.txt"))); + assertFalse(launch.getCommand().get(launch.getCommand().indexOf("-cp") + 1).contains("natives-macos.jar")); + } + } + + @Test void legacyArgumentsExpandAfterTokenizationAndKeepSpacesWithinOneArgument() throws Exception { + LaunchFixtures fixture = new LaunchFixtures(temporaryDirectory); + fixture.version.remove("arguments"); + fixture.version.addProperty("minecraftArguments", "--username ${auth_player_name} --gameDir ${game_directory} --quoted \"hello world\""); + fixture.publishVersion(); + MinecraftLauncher launcher = fixture.launcher("linux", "amd64"); + try (PreparedLaunch launch = launcher.prepare("1.20.4", temporaryDirectory.resolve("space in name"), launcher.offlineSession("Fixture"), null, 0, new Cancellation())) { + assertTrue(launch.getCommand().contains("hello world")); + assertEquals(temporaryDirectory.resolve("space in name").toRealPath().toString(), launch.getCommand().get(launch.getCommand().indexOf("--gameDir") + 1)); + assertTrue(launch.getCommand().contains("-cp")); + } + } + + @Test void orderedOsAndFeatureRulesUseLastMatchingAction() throws Exception { + LaunchFixtures fixture = new LaunchFixtures(temporaryDirectory); + LaunchPlatform linux = new LaunchPlatform("linux", "amd64", "6.1", Collections.emptyMap()); + assertTrue(linux.allows(fixture.json("{\"rules\":[{\"action\":\"allow\"},{\"action\":\"disallow\",\"os\":{\"name\":\"windows\"}}]}"))); + assertFalse(linux.allows(fixture.json("{\"rules\":[{\"action\":\"allow\"},{\"action\":\"disallow\",\"os\":{\"name\":\"linux\"}}]}"))); + assertFalse(linux.allows(fixture.json("{\"rules\":[{\"action\":\"allow\",\"features\":{\"is_demo_user\":true}}]}"))); + } + + @Test void exactRuntimeMajorIsRequiredRatherThanAssumingANewerJreWorks() throws Exception { + LaunchFixtures fixture = new LaunchFixtures(temporaryDirectory); + assertEquals(temporaryDirectory.resolve("jre17/bin/java"), fixture.runtimes.select(17, "amd64")); + assertThrows(IOException.class, () -> fixture.runtimes.select(8)); + assertThrows(IOException.class, () -> fixture.runtimes.select(21)); + } + + @Test void nativeExtractionFailureCleansTheUniqueNativeDirectory() throws Exception { + LaunchFixtures fixture = new LaunchFixtures(temporaryDirectory); + JsonObject library = new JsonObject(); library.addProperty("name", "example:native:1:natives-linux"); + JsonObject artifact = fixture.artifact("https://fixture.invalid/native.jar", fixture.zip("../bad.so", "bad")); + artifact.addProperty("path", "native.jar"); JsonObject downloads = new JsonObject(); downloads.add("artifact", artifact); library.add("downloads", downloads); + fixture.version.getAsJsonArray("libraries").add(library); fixture.publishVersion(); + MinecraftLauncher launcher = fixture.launcher("linux", "amd64"); + Path game = temporaryDirectory.resolve("game"); + assertThrows(IOException.class, () -> launcher.prepare("1.20.4", game, launcher.offlineSession("Fixture"), null, 0, new Cancellation())); + try (Stream natives = Files.list(game.resolve(".autoplug"))) { assertEquals(0, natives.count()); } + } + + @Test void offlineSessionIsDeterministicAndNeverClaimsMicrosoftAuthentication() throws Exception { + MinecraftLauncher launcher = new LaunchFixtures(temporaryDirectory).launcher("linux", "amd64"); + MinecraftSession first = launcher.offlineSession("Fixture"); + assertEquals(first.getUuid(), launcher.offlineSession("Fixture").getUuid()); + assertEquals("legacy", first.getUserType()); assertEquals("0", first.getAccessToken()); + assertThrows(IllegalArgumentException.class, () -> launcher.offlineSession("bad name")); + assertFalse(new MinecraftSession("Fixture", first.getUuid(), "secret-token", "msa").toString().contains("secret-token")); + } + + @Test void cancellationDuringAStreamingDownloadNeverPublishesItsPartialBytes() throws Exception { + Cancellation cancellation = new Cancellation(); + LaunchTransport transport = new LaunchTransport() { + @Override public JsonObject json(String method, URI uri, java.util.Map headers, byte[] body, Cancellation scope) throws IOException { throw new IOException("Not needed"); } + @Override public void download(URI uri, Path target, Cancellation scope) throws IOException { + Files.write(target, new byte[]{1}); scope.cancel(); + } + }; + ArtifactCache cache = new ArtifactCache(temporaryDirectory.resolve("cancel-cache"), transport); + assertThrows(IOException.class, () -> cache.obtain("client.jar", URI.create("https://fixture.invalid/client"), + "0000000000000000000000000000000000000000", 1, cancellation)); + try (Stream files = Files.list(cache.getRoot())) { assertEquals(0, files.count()); } + } + + @Test void javaEightReleaseMetadataAndWindowsExecutableNamesAreRecognized() throws Exception { + Path home = temporaryDirectory.resolve("windows-jre8"); Files.createDirectories(home.resolve("bin")); + Files.write(home.resolve("bin/java.exe"), new byte[0]); + Files.write(home.resolve("release"), "JAVA_VERSION=\"1.8.0_402\"\nOS_ARCH=\"amd64\"".getBytes(StandardCharsets.UTF_8)); + assertEquals(home.resolve("bin/java.exe"), new JavaRuntimeSelector(Collections.singletonList(home), true).select(8, "x86_64")); + } + + @Test void rejectsAnIntelJreForAnArmLaunchAndSelectsAMatchingRuntimeWhenAvailable() throws Exception { + LaunchFixtures fixture = new LaunchFixtures(temporaryDirectory); + JavaRuntimeSelector intelOnly = new JavaRuntimeSelector(Collections.singletonList(temporaryDirectory.resolve("jre17")), false); + assertThrows(IOException.class, () -> intelOnly.select(17, "aarch64")); + assertEquals(temporaryDirectory.resolve("jre17-arm64/bin/java"), fixture.runtimes.select(17, "arm64")); + } + + @Test void legacyIntelOnlyMacNativesFailClearlyForAnArmJvmBeforeNativeDownload() throws Exception { + LaunchFixtures fixture = new LaunchFixtures(temporaryDirectory); + JsonObject library = new JsonObject(); library.addProperty("name", "example:legacy-native:1"); + library.add("natives", fixture.json("{\"osx\":\"natives-osx\"}")); + JsonObject nativeArtifact = fixture.artifact("https://fixture.invalid/legacy-native.jar", fixture.zip("legacy.dylib", "Intel only")); + nativeArtifact.addProperty("path", "legacy-native.jar"); + JsonObject classifiers = new JsonObject(); classifiers.add("natives-osx", nativeArtifact); + JsonObject downloads = new JsonObject(); downloads.add("classifiers", classifiers); library.add("downloads", downloads); + fixture.version.getAsJsonArray("libraries").add(library); fixture.publishVersion(); + MinecraftLauncher launcher = fixture.launcher("osx", "aarch64"); + IOException error = assertThrows(IOException.class, () -> launcher.prepare("1.20.4", temporaryDirectory.resolve("game"), launcher.offlineSession("Fixture"), null, 0, new Cancellation())); + assertTrue(error.getMessage().contains("native libraries")); + assertFalse(fixture.transport.downloads.contains("https://fixture.invalid/legacy-native.jar")); + } + + @Test void discoversAutoPlugManagedArchiveFoldersAndMacContentsHomeWithoutUnboundedRecursion() throws Exception { + Path manager = temporaryDirectory.resolve("autoplug/system/jre"); + Path java8 = manager.resolve("jdk8/bin/java"); Files.createDirectories(java8.getParent()); Files.write(java8, new byte[0]); + Files.write(manager.resolve("jdk8/release"), "JAVA_VERSION=\"1.8.0_402\"\nOS_ARCH=\"x86_64\"".getBytes(StandardCharsets.UTF_8)); + Path macHome = manager.resolve("jdk17.jdk/Contents/Home"); Files.createDirectories(macHome.resolve("bin")); Files.write(macHome.resolve("bin/java"), new byte[0]); + Files.write(macHome.resolve("release"), "JAVA_VERSION=\"17.0.12\"\nOS_ARCH=\"aarch64\"".getBytes(StandardCharsets.UTF_8)); + Path tooDeep = manager.resolve("unrelated/nested/jdk21"); Files.createDirectories(tooDeep.resolve("bin")); Files.write(tooDeep.resolve("bin/java"), new byte[0]); + Files.write(tooDeep.resolve("release"), "JAVA_VERSION=\"21.0.1\"\nOS_ARCH=\"aarch64\"".getBytes(StandardCharsets.UTF_8)); + JavaRuntimeSelector selector = new JavaRuntimeSelector(Collections.singletonList(manager), false); + assertEquals(java8, selector.select(8, "amd64")); + assertEquals(macHome.resolve("bin/java"), selector.select(17, "arm64")); + assertThrows(IOException.class, () -> selector.select(21, "arm64")); + } +} diff --git a/src/test/java/com/osiris/autoplug/client/mc/launch/MojangMetadataTest.java b/src/test/java/com/osiris/autoplug/client/mc/launch/MojangMetadataTest.java new file mode 100644 index 00000000..d3e45d16 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/mc/launch/MojangMetadataTest.java @@ -0,0 +1,73 @@ +package com.osiris.autoplug.client.mc.launch; + +import com.google.gson.JsonObject; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import static org.junit.jupiter.api.Assertions.*; + +class MojangMetadataTest { + @TempDir Path temporaryDirectory; + + @Test void importsLoaderInheritanceOverridesLibrariesAndRetainsTheVanillaClientJar() throws Exception { + LaunchFixtures fixture = new LaunchFixtures(temporaryDirectory); + JsonObject loader = fixture.json("{\"id\":\"fixture-loader\",\"inheritsFrom\":\"1.20.4\",\"mainClass\":\"fixture.loader.Main\",\"libraries\":[{\"name\":\"example:library:2.0\"}],\"arguments\":{\"game\":[\"--loaderFlag\",\"fixture\"]}}"); + Path input = temporaryDirectory.resolve("loader.json"); Files.write(input, loader.toString().getBytes(StandardCharsets.UTF_8)); + fixture.metadata.importInstalledLoaderProfile(input); + JsonObject merged = fixture.metadata.version("fixture-loader", new Cancellation()); + assertEquals("fixture.loader.Main", merged.get("mainClass").getAsString()); + assertEquals("1.20.4", merged.get("autoplugClientVersion").getAsString()); + assertEquals(1, merged.getAsJsonArray("libraries").size()); + assertEquals("example:library:2.0", merged.getAsJsonArray("libraries").get(0).getAsJsonObject().get("name").getAsString()); + assertEquals("--username", merged.getAsJsonObject("arguments").getAsJsonArray("game").get(0).getAsString()); + assertEquals("fixture", merged.getAsJsonObject("arguments").getAsJsonArray("game").get(7).getAsString()); + } + + @Test void fabricAndQuiltUseTheirOwnMetadataApisAndValidateTheRequestedBase() throws Exception { + LaunchFixtures fixture = new LaunchFixtures(temporaryDirectory); + fixture.transport.responses.put("https://meta.fabricmc.net/v2/versions/loader/1.20.4/0.15.11/profile/json", + fixture.json("{\"id\":\"fabric-fixture\",\"inheritsFrom\":\"1.20.4\",\"mainClass\":\"fixture.Fabric\",\"libraries\":[]}")); + fixture.transport.responses.put("https://meta.quiltmc.org/v3/versions/loader/1.20.4/0.26.0/profile/json", + fixture.json("{\"id\":\"quilt-fixture\",\"inheritsFrom\":\"1.20.4\",\"mainClass\":\"fixture.Quilt\",\"libraries\":[]}")); + assertEquals("fabric-fixture", fixture.metadata.installFabric("1.20.4", "0.15.11", new Cancellation())); + assertEquals("quilt-fixture", fixture.metadata.installQuilt("1.20.4", "0.26.0", new Cancellation())); + assertEquals("fixture.Quilt", fixture.metadata.version("quilt-fixture", new Cancellation()).get("mainClass").getAsString()); + } + + @Test void inheritedProfileCyclesAndMismatchedIdsFailClearly() throws Exception { + LaunchFixtures fixture = new LaunchFixtures(temporaryDirectory); + JsonObject cyclic = fixture.json("{\"id\":\"cycle\",\"inheritsFrom\":\"cycle\",\"mainClass\":\"fixture.Main\",\"libraries\":[]}"); + Path input = temporaryDirectory.resolve("cycle.json"); Files.write(input, cyclic.toString().getBytes(StandardCharsets.UTF_8)); + fixture.metadata.importInstalledLoaderProfile(input); + assertThrows(IOException.class, () -> fixture.metadata.version("cycle", new Cancellation())); + fixture.version.addProperty("id", "wrong-id"); + fixture.publishVersion(); + assertThrows(IOException.class, () -> fixture.metadata.version("1.20.4", new Cancellation())); + } + + @Test void cachedMetadataMustStillMatchItsOfficialHash() throws Exception { + LaunchFixtures fixture = new LaunchFixtures(temporaryDirectory); + fixture.metadata.version("1.20.4", new Cancellation()); + Path cached = fixture.cache.resolve("versions/1.20.4/1.20.4.json"); + Files.write(cached, "{}".getBytes(StandardCharsets.UTF_8)); + assertEquals("1.20.4", fixture.metadata.version("1.20.4", new Cancellation()).get("id").getAsString()); + assertTrue(Files.size(cached) > 2); + } + + @Test void libraryChecksumSidecarsAreVerifiedAndInstallerOutputsDetectLaterChanges() throws Exception { + LaunchFixtures fixture = new LaunchFixtures(temporaryDirectory); + byte[] library = new byte[]{9, 8}; + fixture.transport.bytes.put("https://fixture.invalid/loader.jar", library); + fixture.transport.bytes.put("https://fixture.invalid/loader.jar.sha1", (fixture.hash(library) + " loader.jar").getBytes(StandardCharsets.US_ASCII)); + Path artifact = fixture.cache.obtainWithSidecar("libraries/loader.jar", java.net.URI.create("https://fixture.invalid/loader.jar"), new Cancellation()); + assertArrayEquals(library, Files.readAllBytes(artifact)); + Path generated = temporaryDirectory.resolve("generated.jar"); Files.write(generated, new byte[]{3}); + fixture.cache.importInstallerLibrary("libraries/generated.jar", generated); + Path installed = fixture.cache.installedLibrary("libraries/generated.jar"); assertNotNull(installed); + Files.write(installed, new byte[]{4}); + assertThrows(IOException.class, () -> fixture.cache.installedLibrary("libraries/generated.jar")); + } +} diff --git a/src/test/java/com/osiris/autoplug/client/mc/launch/OfficialLoaderInstallerTest.java b/src/test/java/com/osiris/autoplug/client/mc/launch/OfficialLoaderInstallerTest.java new file mode 100644 index 00000000..7faa530b --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/mc/launch/OfficialLoaderInstallerTest.java @@ -0,0 +1,78 @@ +package com.osiris.autoplug.client.mc.launch; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; + +class OfficialLoaderInstallerTest { + @TempDir Path temporaryDirectory; + + @Test void resolvesForgeRecommendationVerifiesInstallerAndImportsItsGeneratedProfileAndLibraries() throws Exception { + LaunchFixtures fixture = new LaunchFixtures(temporaryDirectory); + fixture.transport.responses.put("https://files.minecraftforge.net/net/minecraftforge/forge/promotions_slim.json", + fixture.json("{\"promos\":{\"1.20.4-recommended\":\"49.0.1\",\"1.20.4-latest\":\"49.0.2\"}}")); + installerFixture(fixture, "https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.4-49.0.1/forge-1.20.4-49.0.1-installer.jar"); + List actualCommand = new ArrayList<>(); + OfficialLoaderInstaller installer = new OfficialLoaderInstaller(fixture.cache, fixture.metadata, fixture.transport, fixture.runtimes, + (command, directory, cancellation) -> { + actualCommand.addAll(command); + installedProfile(fixture, directory, "1.20.4-forge-49.0.1"); + Path generated = directory.resolve("libraries/fixture/generated/1/generated-1.jar"); + Files.createDirectories(generated.getParent()); Files.write(generated, new byte[]{7, 8}); + return 0; + }); + String id = installer.install("FORGE", "1.20.4", null, temporaryDirectory.resolve("game"), new Cancellation()); + assertEquals("1.20.4-forge-49.0.1", id); + assertEquals("--installClient", actualCommand.get(3)); + assertEquals(temporaryDirectory.resolve("game").toRealPath().toString(), actualCommand.get(4)); + assertEquals("fixture.ForgeMain", fixture.metadata.version(id, new Cancellation()).get("mainClass").getAsString()); + assertArrayEquals(new byte[]{7, 8}, Files.readAllBytes(fixture.cache.installedLibrary("libraries/fixture/generated/1/generated-1.jar"))); + } + + @Test void selectsNeoForgeStableVersionsNumericallyAndUsesTheOfficialVendorPath() throws Exception { + LaunchFixtures fixture = new LaunchFixtures(temporaryDirectory); + fixture.transport.bytes.put("https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml", + "20.4.920.4.1021.1.120.4.11-beta".getBytes(StandardCharsets.UTF_8)); + installerFixture(fixture, "https://maven.neoforged.net/releases/net/neoforged/neoforge/20.4.10/neoforge-20.4.10-installer.jar"); + OfficialLoaderInstaller installer = new OfficialLoaderInstaller(fixture.cache, fixture.metadata, fixture.transport, fixture.runtimes, + (command, directory, cancellation) -> { installedProfile(fixture, directory, "neoforge-20.4.10"); return 0; }); + assertEquals("neoforge-20.4.10", installer.install("NEOFORGE", "1.20.4", null, temporaryDirectory.resolve("game"), new Cancellation())); + assertTrue(fixture.transport.downloads.stream().anyMatch(url -> url.contains("neoforge-20.4.10-installer.jar"))); + } + + @Test void nonzeroInstallerExitIsNotReportedAsAnInstalledLoader() throws Exception { + LaunchFixtures fixture = new LaunchFixtures(temporaryDirectory); + installerFixture(fixture, "https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.4-49.0.1/forge-1.20.4-49.0.1-installer.jar"); + OfficialLoaderInstaller installer = new OfficialLoaderInstaller(fixture.cache, fixture.metadata, fixture.transport, fixture.runtimes, + (command, directory, cancellation) -> 23); + IOException error = assertThrows(IOException.class, + () -> installer.install("FORGE", "1.20.4", "49.0.1", temporaryDirectory.resolve("game"), new Cancellation())); + assertTrue(error.getMessage().contains("23")); + assertFalse(Files.exists(fixture.cache.resolve("versions/1.20.4-forge-49.0.1/autoplug-loader-profile"))); + } + + @Test void installerCannotReportSuccessWithoutProducingItsVersionJson() throws Exception { + LaunchFixtures fixture = new LaunchFixtures(temporaryDirectory); + installerFixture(fixture, "https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.4-49.0.1/forge-1.20.4-49.0.1-installer.jar"); + OfficialLoaderInstaller installer = new OfficialLoaderInstaller(fixture.cache, fixture.metadata, fixture.transport, fixture.runtimes, + (command, directory, cancellation) -> 0); + assertThrows(IOException.class, () -> installer.install("FORGE", "1.20.4", "49.0.1", temporaryDirectory.resolve("game"), new Cancellation())); + } + + private void installerFixture(LaunchFixtures fixture, String url) throws Exception { + byte[] jar = new byte[]{1, 3, 5}; + fixture.transport.bytes.put(url, jar); + fixture.transport.bytes.put(url + ".sha1", fixture.hash(jar).getBytes(StandardCharsets.US_ASCII)); + } + + private void installedProfile(LaunchFixtures fixture, Path game, String id) throws IOException { + Path file = game.resolve("versions").resolve(id).resolve(id + ".json"); Files.createDirectories(file.getParent()); + Files.write(file, ("{\"id\":\"" + id + "\",\"inheritsFrom\":\"1.20.4\",\"mainClass\":\"fixture.ForgeMain\",\"libraries\":[]}").getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/src/test/java/com/osiris/autoplug/client/mc/launch/RecordedMojangMetadataTest.java b/src/test/java/com/osiris/autoplug/client/mc/launch/RecordedMojangMetadataTest.java new file mode 100644 index 00000000..6f8550a4 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/mc/launch/RecordedMojangMetadataTest.java @@ -0,0 +1,45 @@ +package com.osiris.autoplug.client.mc.launch; + +import com.google.gson.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.*; +import java.net.URI; +import java.nio.file.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +/** Exact public Mojang 1.20.4 metadata recorded 2026-10-17 through the browser. + * Source: https://piston-meta.mojang.com/v1/packages/2c592ccd71ca2d5dadebeb6543ad6b18e6f43305/1.20.4.json + * No account, client jar or third-party implementation is contained in this fixture. */ +class RecordedMojangMetadataTest { + @TempDir Path temporary; + @Test void readsTheOfficialHashedVersionAndNativeRulesWithoutNetwork() throws Exception { + byte[] recorded; + try(InputStream input=getClass().getResourceAsStream("/minecraft/mojang-1.20.4.json")) {assertNotNull(input);recorded=input.readAllBytes();} + String hash=hex(java.security.MessageDigest.getInstance("SHA-1").digest(recorded)); + assertEquals("2c592ccd71ca2d5dadebeb6543ad6b18e6f43305",hash); + String url="https://piston-meta.mojang.com/v1/packages/"+hash+"/1.20.4.json"; + LaunchTransport transport=new LaunchTransport(){ + public JsonObject json(String method,URI uri,Map headers,byte[] body,Cancellation cancellation)throws IOException{ + if(!uri.toString().equals("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"))throw new IOException("Unexpected network request forbidden"); + return JsonParser.parseString("{\"versions\":[{\"id\":\"1.20.4\",\"url\":\""+url+"\",\"sha1\":\""+hash+"\"}]}").getAsJsonObject(); + } + public void download(URI uri,Path destination,Cancellation cancellation)throws IOException{ + if(!url.equals(uri.toString()))throw new IOException("Only the recorded metadata is available");Files.write(destination,recorded); + } + }; + JsonObject version=new MojangMetadata(transport,new ArtifactCache(temporary,transport)).version("1.20.4",new Cancellation()); + assertEquals("net.minecraft.client.main.Main",version.get("mainClass").getAsString()); + assertEquals(17,version.getAsJsonObject("javaVersion").get("majorVersion").getAsInt()); + assertEquals(88,version.getAsJsonArray("libraries").size()); + LaunchPlatform platform=new LaunchPlatform("osx","aarch64","fixture",Collections.emptyMap()); + int armNatives=0; + for(JsonElement value:version.getAsJsonArray("libraries")){ + JsonObject library=value.getAsJsonObject(); + if(library.get("name").getAsString().endsWith(":natives-macos-arm64")&&platform.allows(library))armNatives++; + } + assertTrue(armNatives>0,"The public release includes Apple Silicon natives"); + } + private static String hex(byte[] bytes){StringBuilder text=new StringBuilder();for(byte value:bytes)text.append(String.format("%02x",value&255));return text.toString();} +} From e9eafb9cdab43b33c1a5293be1770f32d65e391b Mon Sep 17 00:00:00 2001 From: elcuervo171234 <43775780+elcuervo171234@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:23:40 -0400 Subject: [PATCH 18/23] test(minecraft): integrate profiles (18/23) --- .../mc/profiles/ProfileManagementTest.java | 60 +++++ .../mc/profiles/ProfileRepositoryTest.java | 216 ++++++++++++++++++ .../mc/profiles/ProfileResolverTest.java | 88 +++++++ 3 files changed, 364 insertions(+) create mode 100644 src/test/java/com/osiris/autoplug/client/mc/profiles/ProfileManagementTest.java create mode 100644 src/test/java/com/osiris/autoplug/client/mc/profiles/ProfileRepositoryTest.java create mode 100644 src/test/java/com/osiris/autoplug/client/mc/profiles/ProfileResolverTest.java diff --git a/src/test/java/com/osiris/autoplug/client/mc/profiles/ProfileManagementTest.java b/src/test/java/com/osiris/autoplug/client/mc/profiles/ProfileManagementTest.java new file mode 100644 index 00000000..91bbc4c0 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/mc/profiles/ProfileManagementTest.java @@ -0,0 +1,60 @@ +package com.osiris.autoplug.client.mc.profiles; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +class ProfileManagementTest { + @TempDir Path temporary; + + @Test void templateRenameAndDeleteOperateOnlyOnTheSelectedProfile() throws Exception { + ProfileRepository repository = new ProfileRepository(temporary); + Profile profile = repository.create("one", ProfileType.MODS, "1.21", "fabric", false); + Profile other = repository.create("two", ProfileType.PLUGINS, "1.20", "paper", false); + Path config = profile.getConfigDirectory().resolve("options.txt"); + Files.write(config, "independent".getBytes(StandardCharsets.UTF_8)); + assertTrue(repository.updateTemplate("one", true).isTemplate()); + Profile renamed = repository.rename("one", "template"); + assertFalse(repository.find("one").isPresent()); + assertTrue(renamed.isTemplate()); + assertEquals("independent", new String(Files.readAllBytes(renamed.getConfigDirectory().resolve("options.txt")), StandardCharsets.UTF_8)); + assertThrows(IOException.class, () -> repository.rename("template", "two")); + repository.delete("template"); + assertFalse(repository.find("template").isPresent()); + assertTrue(Files.isDirectory(other.getDirectory())); + } + + @Test void useLeaseBlocksEveryMutatingOperationAndClosesIdempotently() throws Exception { + ProfileRepository repository = new ProfileRepository(temporary); + repository.create("one", ProfileType.MODS, "1.21", "fabric", false); + ProfileRepository.UseLease lease = repository.acquireUse("one"); + assertTrue(repository.isInUse("one")); + assertThrows(IOException.class, () -> repository.acquireUse("one")); + assertThrows(IOException.class, () -> repository.updateTemplate("one", true)); + assertThrows(IOException.class, () -> repository.recordMigratedVersion("one", "1.20.1")); + assertThrows(IOException.class, () -> repository.cloneProfile("one", "copy")); + assertThrows(IOException.class, () -> repository.rename("one", "renamed")); + assertThrows(IOException.class, () -> repository.delete("one")); + lease.close(); lease.close(); + assertFalse(repository.isInUse("one")); + try (ProfileRepository.UseLease next = repository.acquireUse("one")) { assertTrue(repository.isInUse("one")); } + repository.delete("one"); + assertTrue(repository.list().isEmpty()); + } + + @Test void deleteDoesNotFollowLinksInsideAProfile() throws Exception { + ProfileRepository repository = new ProfileRepository(temporary.resolve("data")); + Profile p = repository.create("one", ProfileType.MODS, "1.21", "fabric", false); + Path outside = temporary.resolve("keep.txt"); + Files.write(outside, new byte[]{7}); + Files.createSymbolicLink(p.getConfigDirectory().resolve("external.txt"), outside); + repository.delete("one"); + assertArrayEquals(new byte[]{7}, Files.readAllBytes(outside)); + } +} diff --git a/src/test/java/com/osiris/autoplug/client/mc/profiles/ProfileRepositoryTest.java b/src/test/java/com/osiris/autoplug/client/mc/profiles/ProfileRepositoryTest.java new file mode 100644 index 00000000..dbc4f145 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/mc/profiles/ProfileRepositoryTest.java @@ -0,0 +1,216 @@ +package com.osiris.autoplug.client.mc.profiles; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Properties; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +class ProfileRepositoryTest { + @TempDir + Path temporaryDirectory; + + @Test + void createsAllThreeTypesWithTheirOwnAssetsAndConfig() throws Exception { + ProfileRepository repository = new ProfileRepository(temporaryDirectory); + Profile client = repository.create("client", ProfileType.MODS, "1.20.4", "FABRIC", false); + Profile plugins = repository.create("plugins", ProfileType.PLUGINS, "1.20.4", "PAPER", false); + Profile server = repository.create("server", ProfileType.MODS_SERVER, "1.20.4", "NEOFORGE", false); + assertEquals("mods", client.getAssetsDirectory().getFileName().toString()); + assertEquals("plugins", plugins.getAssetsDirectory().getFileName().toString()); + assertEquals("mods", server.getAssetsDirectory().getFileName().toString()); + for (Profile profile : repository.list()) { + assertTrue(Files.isDirectory(profile.getAssetsDirectory())); + assertTrue(Files.isDirectory(profile.getConfigDirectory())); + assertEquals(temporaryDirectory.toRealPath().resolve("profiles").resolve(profile.getId()), profile.getDirectory()); + } + } + + @Test + void metadataRoundTripsInANewRepositoryInstance() throws Exception { + ProfileRepository repository = new ProfileRepository(temporaryDirectory); + repository.create("server-template", ProfileType.MODS_SERVER, "26w10a", "FORGE", true); + Profile restored = new ProfileRepository(temporaryDirectory).find("server-template").orElseThrow(() -> new AssertionError("Missing profile")); + assertEquals("server-template", restored.getId()); + assertEquals(ProfileType.MODS_SERVER, restored.getType()); + assertEquals("26w10a", restored.getGameVersion()); + assertEquals("FORGE", restored.getLoader()); + assertTrue(restored.isTemplate()); + } + + @Test + void configurationsOfDifferentProfilesAreIsolated() throws Exception { + ProfileRepository repository = new ProfileRepository(temporaryDirectory); + Profile first = repository.create("first", ProfileType.MODS, "1.20.4", "FABRIC", false); + Profile second = repository.create("second", ProfileType.MODS, "1.20.4", "FABRIC", false); + Files.write(first.getConfigDirectory().resolve("settings.txt"), "first".getBytes(StandardCharsets.UTF_8)); + assertFalse(Files.exists(second.getConfigDirectory().resolve("settings.txt"))); + assertFalse(Files.exists(temporaryDirectory.resolve("config"))); + assertFalse(Files.exists(temporaryDirectory.resolve("mods"))); + } + + @Test + void clonePreservesTypeVersionAndLoaderAndIsNoLongerATemplate() throws Exception { + ProfileRepository repository = new ProfileRepository(temporaryDirectory); + repository.create("base", ProfileType.PLUGINS, "1.21", "PURPUR", true); + Profile clone = repository.cloneProfile("base", "copy"); + assertEquals("copy", clone.getId()); + assertEquals(ProfileType.PLUGINS, clone.getType()); + assertEquals("1.21", clone.getGameVersion()); + assertEquals("PURPUR", clone.getLoader()); + assertFalse(clone.isTemplate()); + assertFalse(new ProfileRepository(temporaryDirectory).find("copy").orElseThrow(() -> new AssertionError("Missing profile")).isTemplate()); + assertTrue(repository.find("base").orElseThrow(() -> new AssertionError("Missing profile")).isTemplate()); + } + + @Test + void cloneCopiesNestedConfigAssetsAndDisabledFilesWithoutSharingMutableFiles() throws Exception { + ProfileRepository repository = new ProfileRepository(temporaryDirectory); + Profile source = repository.create("base", ProfileType.MODS, "1.20.4", "FABRIC", true); + Path sourceConfig = Files.createDirectories(source.getConfigDirectory().resolve("nested")).resolve("settings.txt"); + Files.write(sourceConfig, "original".getBytes(StandardCharsets.UTF_8)); + Path sourceJar = source.getAssetsDirectory().resolve("example.jar"); + Files.write(sourceJar, new byte[]{1, 2, 3}); + Files.write(source.getAssetsDirectory().resolve("incompatible.jar.disabled"), new byte[]{4, 5}); + Profile clone = repository.cloneProfile("base", "copy"); + Path clonedConfig = clone.getConfigDirectory().resolve("nested/settings.txt"); + Path clonedJar = clone.getAssetsDirectory().resolve("example.jar"); + assertArrayEquals(new byte[]{1, 2, 3}, Files.readAllBytes(clonedJar)); + assertArrayEquals(new byte[]{4, 5}, Files.readAllBytes(clone.getAssetsDirectory().resolve("incompatible.jar.disabled"))); + assertFalse(Files.isSameFile(sourceJar, clonedJar)); + Files.write(clonedConfig, "changed".getBytes(StandardCharsets.UTF_8)); + Files.write(clonedJar, new byte[]{9}); + assertEquals("original", new String(Files.readAllBytes(sourceConfig), StandardCharsets.UTF_8)); + assertArrayEquals(new byte[]{1, 2, 3}, Files.readAllBytes(sourceJar)); + } + + @Test + void pluginCloneRetainsPluginDirectories() throws Exception { + ProfileRepository repository = new ProfileRepository(temporaryDirectory); + Profile source = repository.create("hub", ProfileType.PLUGINS, "1.21", "SPIGOT", false); + Path pluginConfig = Files.createDirectories(source.getAssetsDirectory().resolve("Example")).resolve("config.yml"); + Files.write(pluginConfig, "enabled: true".getBytes(StandardCharsets.UTF_8)); + Profile clone = repository.cloneProfile("hub", "hub-copy"); + assertTrue(Files.isRegularFile(clone.getAssetsDirectory().resolve("Example/config.yml"))); + assertFalse(Files.exists(clone.getDirectory().resolve("mods"))); + } + + @Test + void duplicateCreationDoesNotOverwriteExistingMetadataOrFiles() throws Exception { + ProfileRepository repository = new ProfileRepository(temporaryDirectory); + Profile first = repository.create("existing", ProfileType.MODS, "1.20.4", "FABRIC", false); + Files.write(first.getConfigDirectory().resolve("keep.txt"), new byte[]{7}); + assertThrows(FileAlreadyExistsException.class, + () -> repository.create("existing", ProfileType.PLUGINS, "1.21", "PAPER", true)); + assertEquals(ProfileType.MODS, repository.find("existing").orElseThrow(() -> new AssertionError("Missing profile")).getType()); + assertArrayEquals(new byte[]{7}, Files.readAllBytes(first.getConfigDirectory().resolve("keep.txt"))); + } + + @Test + void duplicateCloneAndMissingSourceLeaveRepositoryUnchanged() throws Exception { + ProfileRepository repository = new ProfileRepository(temporaryDirectory); + repository.create("base", ProfileType.MODS, "1.20.4", "FABRIC", false); + repository.create("existing", ProfileType.PLUGINS, "1.21", "PAPER", false); + assertThrows(FileAlreadyExistsException.class, () -> repository.cloneProfile("base", "existing")); + assertThrows(IOException.class, () -> repository.cloneProfile("missing", "new")); + assertEquals(2, repository.list().size()); + } + + @Test + void rejectsInvalidIdsBeforeTouchingOtherPaths() throws Exception { + ProfileRepository repository = new ProfileRepository(temporaryDirectory); + for (String id : new String[]{"../outside", "/absolute", "", ".hidden", "two/parts", "two\\parts", null}) { + assertThrows(IllegalArgumentException.class, + () -> repository.create(id, ProfileType.MODS, "1.20.4", "FABRIC", false)); + assertThrows(IllegalArgumentException.class, () -> repository.find(id)); + } + assertTrue(repository.list().isEmpty()); + } + + @Test + void rejectsMissingVersionsAndLoadersWithoutLeavingPartialProfiles() throws Exception { + ProfileRepository repository = new ProfileRepository(temporaryDirectory); + assertThrows(IllegalArgumentException.class, () -> repository.create("a", ProfileType.MODS, "", "FABRIC", false)); + assertThrows(IllegalArgumentException.class, () -> repository.create("b", ProfileType.MODS, "1.20.4", "", false)); + assertTrue(repository.list().isEmpty()); + } + + @Test + void listingIsDeterministicAndDoesNotExposeUnpublishedStagingDirectories() throws Exception { + ProfileRepository repository = new ProfileRepository(temporaryDirectory); + repository.create("z-last", ProfileType.MODS, "1.20.4", "FABRIC", false); + repository.create("a-first", ProfileType.MODS, "1.20.4", "FABRIC", false); + Files.createDirectory(temporaryDirectory.resolve("profiles/.create-interrupted")); + assertEquals(List.of("a-first", "z-last"), + repository.list().stream().map(Profile::getId).collect(Collectors.toList())); + } + + @Test + void doesNotTrustADirectoryEntryInMetadata() throws Exception { + ProfileRepository repository = new ProfileRepository(temporaryDirectory); + Profile profile = repository.create("local", ProfileType.MODS, "1.20.4", "FABRIC", false); + changeMetadata(profile, "directory", temporaryDirectory.resolve("elsewhere").toString()); + assertEquals(profile.getDirectory(), repository.find("local").orElseThrow(() -> new AssertionError("Missing profile")).getDirectory()); + assertFalse(Files.exists(temporaryDirectory.resolve("elsewhere"))); + } + + @Test + void corruptOrMismatchedMetadataIsReportedRatherThanInventingDefaults() throws Exception { + ProfileRepository repository = new ProfileRepository(temporaryDirectory); + Profile profile = repository.create("local", ProfileType.MODS, "1.20.4", "FABRIC", false); + changeMetadata(profile, "id", "some-other-profile"); + assertThrows(IOException.class, () -> repository.find("local")); + changeMetadata(profile, "id", "local"); + changeMetadata(profile, "isTemplate", "not-a-boolean"); + assertThrows(IOException.class, repository::list); + changeMetadata(profile, "isTemplate", "false"); + changeMetadata(profile, "formatVersion", "999"); + assertThrows(IOException.class, () -> repository.find("local")); + } + + @Test + void linkedFilesCannotEscapeProfileIsolationAndFailedCloneIsNotPublished() throws Exception { + ProfileRepository repository = new ProfileRepository(temporaryDirectory); + Profile source = repository.create("base", ProfileType.MODS, "1.20.4", "FABRIC", true); + Path external = temporaryDirectory.resolve("outside.txt"); + Files.write(external, new byte[]{4}); + Files.createSymbolicLink(source.getConfigDirectory().resolve("outside.txt"), external); + assertThrows(IOException.class, () -> repository.cloneProfile("base", "copy")); + assertFalse(repository.find("copy").isPresent()); + assertArrayEquals(new byte[]{4}, Files.readAllBytes(external)); + try (Stream entries = Files.list(temporaryDirectory.resolve("profiles"))) { + assertEquals(List.of("base"), entries.map(path -> path.getFileName().toString()).collect(Collectors.toList())); + } + } + + @Test + void rejectsLinkedProfileDirectory() throws Exception { + ProfileRepository repository = new ProfileRepository(temporaryDirectory); + Path outside = Files.createDirectory(temporaryDirectory.resolve("outside")); + Files.createSymbolicLink(temporaryDirectory.resolve("profiles/linked"), outside); + assertThrows(IOException.class, () -> repository.find("linked")); + } + + private void changeMetadata(Profile profile, String key, String value) throws IOException { + Path file = profile.getDirectory().resolve("profile.properties"); + Properties metadata = new Properties(); + try (InputStream input = Files.newInputStream(file)) { + metadata.load(input); + } + metadata.setProperty(key, value); + try (OutputStream output = Files.newOutputStream(file)) { + metadata.store(output, "fixture"); + } + } +} diff --git a/src/test/java/com/osiris/autoplug/client/mc/profiles/ProfileResolverTest.java b/src/test/java/com/osiris/autoplug/client/mc/profiles/ProfileResolverTest.java new file mode 100644 index 00000000..93774bcd --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/mc/profiles/ProfileResolverTest.java @@ -0,0 +1,88 @@ +package com.osiris.autoplug.client.mc.profiles; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +class ProfileResolverTest { + @TempDir Path temporaryDirectory; + + @Test void selectsOneExactClientProfileWithoutConsideringServerProfiles() throws Exception { + ProfileRepository repository = new ProfileRepository(temporaryDirectory); + repository.create("server", ProfileType.MODS_SERVER, "1.20.4", "fabric", false); + repository.create("plugins", ProfileType.PLUGINS, "1.20.4", "fabric", false); + repository.create("client", ProfileType.MODS, "1.20.4", "fabric", false); + ProfileResolver.Resolution result = new ProfileResolver(repository).resolve("1.20.4", "FABRIC", null); + assertEquals(ProfileResolver.State.READY, result.getState()); + assertEquals("client", result.getProfiles().get(0).getId()); + } + + @Test void doesNotGuessALoaderFromOnlyTheVersion() throws Exception { + ProfileResolver resolver = new ProfileResolver(new ProfileRepository(temporaryDirectory)); + assertThrows(IllegalArgumentException.class, () -> resolver.resolve("1.20.4", null, null)); + assertThrows(IllegalArgumentException.class, () -> resolver.resolve("1.20.4", " ", null)); + assertThrows(IllegalArgumentException.class, () -> resolver.resolve("", "fabric", null)); + } + + @Test void doesNotMatchAnotherLoaderOrVersion() throws Exception { + ProfileRepository repository = new ProfileRepository(temporaryDirectory); + repository.create("forge", ProfileType.MODS, "1.20.4", "forge", false); + repository.create("older", ProfileType.MODS, "1.20.2", "fabric", false); + assertEquals(ProfileResolver.State.CREATE_PROFILE, + new ProfileResolver(repository).resolve("1.20.4", "fabric", null).getState()); + } + + @Test void asksForAChoiceInsteadOfPickingArbitraryModCollections() throws Exception { + ProfileRepository repository = new ProfileRepository(temporaryDirectory); + repository.create("zeta", ProfileType.MODS, "1.20.4", "fabric", false); + repository.create("alpha", ProfileType.MODS, "1.20.4", "fabric", false); + ProfileResolver.Resolution result = new ProfileResolver(repository).resolve("1.20.4", "fabric", null); + assertEquals(ProfileResolver.State.CHOOSE_PROFILE, result.getState()); + assertEquals(2, result.getProfiles().size()); + assertEquals("alpha", result.getProfiles().get(0).getId()); + assertThrows(UnsupportedOperationException.class, () -> result.getProfiles().clear()); + } + + @Test void neverLaunchesATemplateEvenWhenItsVersionMatches() throws Exception { + ProfileRepository repository = new ProfileRepository(temporaryDirectory); + repository.create("base", ProfileType.MODS, "1.20.4", "fabric", true); + assertEquals(ProfileResolver.State.CONFIRM_TEMPLATE_CLONE, + new ProfileResolver(repository).resolve("1.20.4", "fabric", "base").getState()); + } + + @Test void proposingAMigrationDoesNotCloneOrChangeSourceMetadata() throws Exception { + ProfileRepository repository = new ProfileRepository(temporaryDirectory); + Profile template = repository.create("base", ProfileType.MODS, "1.20.2", "fabric", true); + byte[] before = Files.readAllBytes(template.getDirectory().resolve("profile.properties")); + ProfileResolver.Resolution result = new ProfileResolver(repository).resolve("1.20.4", "fabric", "base"); + assertEquals(ProfileResolver.State.CONFIRM_TEMPLATE_CLONE, result.getState()); + assertEquals("1.20.4", result.getTargetGameVersion()); + assertEquals("1.20.2", result.getProfiles().get(0).getGameVersion()); + assertArrayEquals(before, Files.readAllBytes(template.getDirectory().resolve("profile.properties"))); + assertEquals(1, repository.list().size()); + } + + @Test void rejectsAMissingOrIncompatibleDefaultTemplate() throws Exception { + ProfileRepository repository = new ProfileRepository(temporaryDirectory); + repository.create("ordinary", ProfileType.MODS, "1.20.2", "fabric", false); + repository.create("wrongloader", ProfileType.MODS, "1.20.2", "forge", true); + repository.create("server", ProfileType.MODS_SERVER, "1.20.2", "fabric", true); + ProfileResolver resolver = new ProfileResolver(repository); + assertThrows(IOException.class, () -> resolver.resolve("1.20.4", "fabric", "missing")); + assertThrows(IOException.class, () -> resolver.resolve("1.20.4", "fabric", "ordinary")); + assertThrows(IOException.class, () -> resolver.resolve("1.20.4", "fabric", "wrongloader")); + assertThrows(IOException.class, () -> resolver.resolve("1.20.4", "fabric", "server")); + } + + @Test void anExactProfileDoesNotNeedTheFallbackTemplate() throws Exception { + ProfileRepository repository = new ProfileRepository(temporaryDirectory); + repository.create("client", ProfileType.MODS, "1.20.4", "fabric", false); + assertEquals(ProfileResolver.State.READY, + new ProfileResolver(repository).resolve("1.20.4", "fabric", "unused-missing").getState()); + } +} From a9af4ecb580fd459c11295bdebd103b9689e8e52 Mon Sep 17 00:00:00 2001 From: elcuervo171234 <43775780+elcuervo171234@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:23:47 -0400 Subject: [PATCH 19/23] test(minecraft): integrate sharing (19/23) --- .../client/mc/sharing/UpnpPortMapperTest.java | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 src/test/java/com/osiris/autoplug/client/mc/sharing/UpnpPortMapperTest.java diff --git a/src/test/java/com/osiris/autoplug/client/mc/sharing/UpnpPortMapperTest.java b/src/test/java/com/osiris/autoplug/client/mc/sharing/UpnpPortMapperTest.java new file mode 100644 index 00000000..1c3b7de3 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/mc/sharing/UpnpPortMapperTest.java @@ -0,0 +1,191 @@ +package com.osiris.autoplug.client.mc.sharing; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.URL; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +class UpnpPortMapperTest { + private static final String SERVICE = "urn:schemas-upnp-org:service:WANIPConnection:1"; + + @Test void explicitShareCreatesFiniteTcpMappingAndDeletesOnlyThatMapping() throws Exception { + List actions = new ArrayList<>(); List> arguments = new ArrayList<>(); + Map created = new LinkedHashMap<>(); + UpnpPortMapper mapper = new UpnpPortMapper(() -> gateway(), (gateway, action, values) -> { + actions.add(action); arguments.add(new LinkedHashMap<>(values)); + if (action.equals("GetExternalIPAddress")) return response(action, "8.8.8.8"); + if (action.equals("GetSpecificPortMappingEntry")) return created.isEmpty() ? fault(714) : response(action, ownedFields(created)); + if (action.equals("AddPortMapping")) created.putAll(values); + return response(action, ""); + }); + assertTrue(actions.isEmpty()); + PortSharingService.Lease lease = mapper.share(25577); + assertEquals("8.8.8.8:25577", lease.getJoinAddress()); + assertEquals("TCP", arguments.get(2).get("NewProtocol")); + assertEquals("192.168.1.5", arguments.get(2).get("NewInternalClient")); + assertEquals("3600", arguments.get(2).get("NewLeaseDuration")); + assertEquals("25577", arguments.get(2).get("NewInternalPort")); + lease.close(); lease.close(); + assertEquals(java.util.Arrays.asList("GetExternalIPAddress", "GetSpecificPortMappingEntry", "AddPortMapping", "GetSpecificPortMappingEntry", "DeletePortMapping"), actions); + assertEquals(arguments.get(1), arguments.get(4)); + } + + @Test void rejectsPrivateCgnatAndNonliteralExternalAddressesBeforeOpeningAnything() throws Exception { + for (String address : new String[]{"10.0.0.1", "100.64.0.1", "192.168.1.2", "172.16.0.1", "127.0.0.1", "0.0.0.0", "224.1.2.3", "2001:db8::1", "example.invalid", "192.0.2.1"}) { + AtomicInteger calls = new AtomicInteger(); + UpnpPortMapper mapper = new UpnpPortMapper(() -> gateway(), (gateway, action, values) -> { + calls.incrementAndGet(); return response(action, "" + address + ""); + }); + assertThrows(IOException.class, () -> mapper.share(25577), address); + assertEquals(1, calls.get()); + } + } + + @Test void neverOverwritesOrDeletesAnExistingMapping() throws Exception { + List actions = new ArrayList<>(); + UpnpPortMapper mapper = new UpnpPortMapper(() -> gateway(), (gateway, action, values) -> { + actions.add(action); + return response(action, action.equals("GetExternalIPAddress") + ? "8.8.8.8" : "192.168.1.9"); + }); + assertThrows(IOException.class, () -> mapper.share(25577)); + assertEquals(java.util.Arrays.asList("GetExternalIPAddress", "GetSpecificPortMappingEntry"), actions); + } + + @Test void rejectedOrPermanentOnlyMappingNeverReturnsAJoinAddress() throws Exception { + List actions = new ArrayList<>(); + UpnpPortMapper mapper = new UpnpPortMapper(() -> gateway(), (gateway, action, values) -> { + actions.add(action); + if (action.equals("GetExternalIPAddress")) return response(action, "8.8.8.8"); + return fault(action.equals("GetSpecificPortMappingEntry") ? 714 : 725); + }); + UpnpPortMapper.SoapFault error = assertThrows(UpnpPortMapper.SoapFault.class, () -> mapper.share(25577)); + assertEquals(725, error.getCode()); assertFalse(actions.contains("DeletePortMapping")); + } + + @Test void cleanupFailureIsReportedAndCanBeRetried() throws Exception { + AtomicInteger deletions = new AtomicInteger(); + Map created = new LinkedHashMap<>(); + UpnpPortMapper mapper = new UpnpPortMapper(() -> gateway(), (gateway, action, values) -> { + if (action.equals("GetExternalIPAddress")) return response(action, "8.8.8.8"); + if (action.equals("GetSpecificPortMappingEntry")) return created.isEmpty() ? fault(714) : response(action, ownedFields(created)); + if (action.equals("AddPortMapping")) created.putAll(values); + if (action.equals("DeletePortMapping") && deletions.incrementAndGet() == 1) throw new IOException("fixture router timeout"); + return response(action, ""); + }); + PortSharingService.Lease lease = mapper.share(25577); + assertThrows(IOException.class, lease::close); lease.close(); assertEquals(2, deletions.get()); + } + + @Test void parsesRelativeGatewayControlUrlsAndKnownServiceVersions() throws Exception { + for (String service : new String[]{SERVICE, "urn:schemas-upnp-org:service:WANIPConnection:2", "urn:schemas-upnp-org:service:WANPPPConnection:1"}) { + String xml = "" + service + + "/control"; + UpnpPortMapper.Gateway parsed = UpnpPortMapper.gatewayFromDescription(xml, new URL("http://192.168.1.1:5000/root.xml"), "192.168.1.5"); + assertEquals("http://192.168.1.1:5000/control", parsed.getControlUrl().toString()); + assertEquals(service, parsed.getServiceType()); + } + } + + @Test void rejectsDescriptorsRedirectingControlOutsideTheDiscoveredRouter() throws Exception { + String prefix = "" + SERVICE + ""; + for (String control : new String[]{"http://8.8.8.8/control", "http://192.168.1.9/control", "file:///tmp/control"}) { + assertThrows(IOException.class, () -> UpnpPortMapper.gatewayFromDescription(prefix + control + "", + new URL("http://192.168.1.1/root.xml"), "192.168.1.5")); + } + assertThrows(IOException.class, () -> new UpnpPortMapper.Gateway(new URL("http://example.invalid/control"), SERVICE, "192.168.1.5")); + assertThrows(IOException.class, () -> new UpnpPortMapper.Gateway(new URL("http://192.168.1.1/control"), SERVICE, "127.0.0.1")); + } + + @Test void rejectsXmlEntitiesOversizedAndAmbiguousResponses() throws Exception { + assertThrows(IOException.class, () -> UpnpPortMapper.parseResponse("]>&x;")); + assertThrows(IOException.class, () -> UpnpPortMapper.parseResponse(new String(new char[262145]).replace('\0', 'x'))); + assertThrows(IOException.class, () -> UpnpPortMapper.parseResponse("")); + assertThrows(IOException.class, () -> UpnpPortMapper.parseResponse("")); + } + + @Test void soapEncodesValuesAndRejectsInjectedActionNames() throws Exception { + Map values = new LinkedHashMap<>(); values.put("NewPortMappingDescription", "a&"); + String xml = UpnpPortMapper.envelope(SERVICE, "AddPortMapping", values); + assertTrue(xml.contains("a&<b>")); + assertTrue(xml.contains(" UpnpPortMapper.envelope(SERVICE, "Add\"Bad", values)); + } + + @Test void parsesSsdpHeadersCaseInsensitivelyAndRejectsAmbiguousLocations() throws Exception { + assertEquals("http://192.168.1.1/root.xml", UpnpPortMapper.SsdpDiscovery.location("HTTP/1.1 200 OK\r\nLoCaTiOn: http://192.168.1.1/root.xml\r\n\r\n")); + assertThrows(IOException.class, () -> UpnpPortMapper.SsdpDiscovery.location("HTTP/1.1 200 OK\r\nLOCATION: one\r\nLOCATION: two\r\n")); + assertThrows(IOException.class, () -> UpnpPortMapper.SsdpDiscovery.location("HTTP/1.1 404 Missing\r\nLOCATION: one\r\n")); + assertThrows(IOException.class, () -> UpnpPortMapper.SsdpDiscovery.location("HTTP/1.1 200 OK\r\n")); + } + + @Test void unexpectedSoapActionCannotCountAsSuccessfulPortOpening() throws Exception { + UpnpPortMapper mapper = new UpnpPortMapper(() -> gateway(), (gateway, action, values) -> response("Unrelated", "")); + assertThrows(IOException.class, () -> mapper.share(25577)); + } + + @Test void cleanupRefusesAMappingReplacedByAnotherApplication() throws Exception { + Map created = new LinkedHashMap<>(); AtomicInteger deleted = new AtomicInteger(); + UpnpPortMapper mapper = new UpnpPortMapper(() -> gateway(), (gateway, action, values) -> { + if (action.equals("GetExternalIPAddress")) return response(action, "8.8.8.8"); + if (action.equals("GetSpecificPortMappingEntry")) return created.isEmpty() ? fault(714) : response(action, ownedFields(created)); + if (action.equals("AddPortMapping")) created.putAll(values); + if (action.equals("DeletePortMapping")) deleted.incrementAndGet(); + return response(action, ""); + }); + PortSharingService.Lease lease = mapper.share(25577); + created.put("NewPortMappingDescription", "another application"); + assertThrows(IOException.class, lease::close); assertEquals(0, deleted.get()); + } + + @Test void uncertainAddResponseChecksAndCleansItsOwnAppliedMapping() throws Exception { + Map created = new LinkedHashMap<>(); AtomicInteger deleted = new AtomicInteger(); + UpnpPortMapper mapper = new UpnpPortMapper(() -> gateway(), (gateway, action, values) -> { + if (action.equals("GetExternalIPAddress")) return response(action, "8.8.8.8"); + if (action.equals("GetSpecificPortMappingEntry")) return created.isEmpty() ? fault(714) : response(action, ownedFields(created)); + if (action.equals("AddPortMapping")) { created.putAll(values); throw new IOException("fixture lost response"); } + if (action.equals("DeletePortMapping")) deleted.incrementAndGet(); + return response(action, ""); + }); + assertThrows(IOException.class, () -> mapper.share(25577)); assertEquals(1, deleted.get()); + } + + @Test void leaseReportsExpiryWithoutClaimingIndefinitePortExposure() throws Exception { + Map created = new LinkedHashMap<>(); java.util.concurrent.atomic.AtomicLong clock = new java.util.concurrent.atomic.AtomicLong(1000); + UpnpPortMapper mapper = new UpnpPortMapper(() -> gateway(), (gateway, action, values) -> { + if (action.equals("GetExternalIPAddress")) return response(action, "8.8.8.8"); + if (action.equals("GetSpecificPortMappingEntry")) return created.isEmpty() ? fault(714) : response(action, ownedFields(created)); + if (action.equals("AddPortMapping")) created.putAll(values); + return response(action, ""); + }, clock::get); + PortSharingService.Lease lease = mapper.share(25577); assertFalse(lease.isExpired()); + clock.addAndGet(3600000); assertTrue(lease.isExpired()); lease.close(); + } + + private static String ownedFields(Map values) { + return "" + values.get("NewPortMappingDescription") + "" + + "" + values.get("NewInternalClient") + "" + + "" + values.get("NewInternalPort") + ""; + } + + private static UpnpPortMapper.Gateway gateway() throws IOException { + return new UpnpPortMapper.Gateway(new URL("http://192.168.1.1:5000/control"), SERVICE, "192.168.1.5"); + } + + private static String response(String action, String fields) { + return "" + + fields + ""; + } + + private static String fault(int code) { + return "" + + code + "fixture rejection"; + } +} From fadbe51775c0eec26a0374f7263b2b349e2d0ec0 Mon Sep 17 00:00:00 2001 From: elcuervo171234 <43775780+elcuervo171234@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:23:52 -0400 Subject: [PATCH 20/23] test(minecraft): integrate update (20/23) --- .../update/LegacyProfileUpdateEngineTest.java | 108 ++++++ .../ProfileCompatibilitySearchTest.java | 86 +++++ .../mc/update/ProfileUpdateServiceTest.java | 323 ++++++++++++++++++ .../mc/update/SharedArtifactCacheTest.java | 63 ++++ 4 files changed, 580 insertions(+) create mode 100644 src/test/java/com/osiris/autoplug/client/mc/update/LegacyProfileUpdateEngineTest.java create mode 100644 src/test/java/com/osiris/autoplug/client/mc/update/ProfileCompatibilitySearchTest.java create mode 100644 src/test/java/com/osiris/autoplug/client/mc/update/ProfileUpdateServiceTest.java create mode 100644 src/test/java/com/osiris/autoplug/client/mc/update/SharedArtifactCacheTest.java diff --git a/src/test/java/com/osiris/autoplug/client/mc/update/LegacyProfileUpdateEngineTest.java b/src/test/java/com/osiris/autoplug/client/mc/update/LegacyProfileUpdateEngineTest.java new file mode 100644 index 00000000..b9fb97c3 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/mc/update/LegacyProfileUpdateEngineTest.java @@ -0,0 +1,108 @@ +package com.osiris.autoplug.client.mc.update; + +import com.osiris.autoplug.client.mc.profiles.Profile; +import com.osiris.autoplug.client.mc.profiles.ProfileRepository; +import com.osiris.autoplug.client.mc.profiles.ProfileType; +import com.osiris.autoplug.client.tasks.updater.mods.InstalledModLoader; +import com.osiris.autoplug.client.tasks.updater.mods.MinecraftMod; +import com.osiris.autoplug.client.tasks.updater.plugins.MinecraftPlugin; +import com.osiris.autoplug.client.tasks.updater.plugins.ResourceFinder; +import com.osiris.autoplug.client.tasks.updater.search.SearchResult; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.*; + +class LegacyProfileUpdateEngineTest { + @TempDir Path temporary; + + @Test void readsActualFabricMetadataAndOnlyTheSelectedProfilesYaml() throws Exception { + ProfileRepository repository = new ProfileRepository(temporary.resolve("data")); + Profile p = repository.create("fabric", ProfileType.MODS, "1.21", "fabric", false); + Path jar = descriptor(p, "fabric.mod.json", "{\"id\":\"embedded\",\"name\":\"Example\",\"version\":\"3\",\"authors\":[\"Fixture\"]}"); + Path config = p.getConfigDirectory().resolve("mods.yml"); + String settings = "mods:\n Example:\n modrinth-id: explicit-project\n curseforge-id: 123\n force-latest: true\n"; + Files.write(config, settings.getBytes(StandardCharsets.UTF_8)); + String workingDirectory = System.getProperty("user.dir"); + final Path[] inspected = new Path[1]; + LegacyProfileUpdateEngine engine = new LegacyProfileUpdateEngine(new ResourceFinder() { + @Override public SearchResult findCompatibleProfileMod(InstalledModLoader loader, MinecraftMod mod, String version) { + assertEquals("fabric", loader.getProfileLoader()); + assertEquals("1.20.1", version); + assertEquals("explicit-project", mod.modrinthId); + assertEquals("123", mod.curseforgeId); + assertFalse(mod.forceLatest, "Global force-latest must not override the profile target"); + inspected[0] = java.nio.file.Paths.get(mod.installationPath); + assertTrue(Files.exists(inspected[0])); + return release(SearchResult.Type.UPDATE_AVAILABLE); + } + }); + assertEquals(ProfileUpdateService.Action.REPLACE, engine.resolve(p, jar, "1.20.1").getAction()); + assertEquals(settings, new String(Files.readAllBytes(config), StandardCharsets.UTF_8)); + assertEquals(workingDirectory, System.getProperty("user.dir")); + assertFalse(Files.exists(inspected[0]), "Temporary parser input is removed"); + } + + @Test void quiltDescriptorUsesTheActualQuiltLoaderAndCanInspectDisabledJars() throws Exception { + Profile p = new ProfileRepository(temporary.resolve("data")).create("quilt", ProfileType.MODS, "1.21", "quilt", false); + Path jar = descriptor(p, "quilt.mod.json", "{\"quilt_loader\":{\"id\":\"example-quilt\",\"version\":\"3\",\"metadata\":{\"name\":\"Quilt Example\",\"contributors\":{\"Fixture\":\"Author\"}}}}"); + Path disabled = Files.move(jar, jar.resolveSibling("example.jar.disabled")); + LegacyProfileUpdateEngine engine = new LegacyProfileUpdateEngine(new ResourceFinder() { + @Override public SearchResult findCompatibleProfileMod(InstalledModLoader loader, MinecraftMod mod, String version) { + assertTrue(loader.isQuilt); assertFalse(loader.isFabric); + assertEquals("example-quilt", mod.modrinthId); + assertEquals("Quilt Example", mod.getName()); + return release(SearchResult.Type.NO_COMPATIBLE_RELEASE); + } + }); + assertEquals(ProfileUpdateService.Action.DISABLE, engine.resolve(p, disabled, "1.20.1").getAction()); + assertTrue(Files.exists(disabled)); + } + + @Test void pluginCompatibilityUsesProfileLoaderAndMappingWithoutGlobalPluginConfig() throws Exception { + Profile p = new ProfileRepository(temporary.resolve("data")).create("paper", ProfileType.PLUGINS, "1.21", "paper", false); + Path jar = descriptor(p, "plugin.yml", "name: Example\nversion: 3\nauthor: Fixture\n"); + Files.write(p.getConfigDirectory().resolve("plugins.yml"), "plugins:\n Example:\n modrinth-id: plugin-project\n".getBytes(StandardCharsets.UTF_8)); + LegacyProfileUpdateEngine engine = new LegacyProfileUpdateEngine(new ResourceFinder() { + @Override public SearchResult findCompatibleProfilePlugin(List loaders, MinecraftPlugin plugin, String version) { + assertTrue(loaders.contains("paper")); + assertEquals("plugin-project", plugin.modrinthId); + assertEquals("1.20.1", version); + return release(SearchResult.Type.UPDATE_AVAILABLE); + } + }); + assertEquals(ProfileUpdateService.Action.REPLACE, engine.resolve(p, jar, "1.20.1").getAction()); + } + + @Test void missingPluginMappingAndProviderOutageAreErrorsNotDisabledAssets() throws Exception { + Profile p = new ProfileRepository(temporary.resolve("data")).create("paper", ProfileType.PLUGINS, "1.21", "paper", false); + Path jar = descriptor(p, "plugin.yml", "name: Example\nversion: 3\nauthor: Fixture\n"); + ResourceFinder finder = new ResourceFinder() { + @Override public SearchResult findCompatibleProfilePlugin(List loaders, MinecraftPlugin plugin, String version) { + return release(SearchResult.Type.API_ERROR); + } + }; + LegacyProfileUpdateEngine engine = new LegacyProfileUpdateEngine(finder); + assertEquals(ProfileUpdateService.Action.ERROR, engine.resolve(p, jar, "1.20.1").getAction()); + Files.write(p.getConfigDirectory().resolve("plugins.yml"), "plugins:\n Example:\n modrinth-id: example\n".getBytes(StandardCharsets.UTF_8)); + assertEquals(ProfileUpdateService.Action.ERROR, engine.resolve(p, jar, "1.20.1").getAction()); + } + + private static SearchResult release(SearchResult.Type type) { + return new SearchResult(null, type, "compatible-2", "https://example.invalid/fixture.jar", ".jar", null, null, false); + } + private Path descriptor(Profile profile, String name, String contents) throws Exception { + Path jar = profile.getAssetsDirectory().resolve("example.jar"); + try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(jar))) { + output.putNextEntry(new ZipEntry(name)); output.write(contents.getBytes(StandardCharsets.UTF_8)); output.closeEntry(); + } + return jar; + } +} diff --git a/src/test/java/com/osiris/autoplug/client/mc/update/ProfileCompatibilitySearchTest.java b/src/test/java/com/osiris/autoplug/client/mc/update/ProfileCompatibilitySearchTest.java new file mode 100644 index 00000000..7d034224 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/mc/update/ProfileCompatibilitySearchTest.java @@ -0,0 +1,86 @@ +package com.osiris.autoplug.client.mc.update; + +import com.google.gson.JsonArray; +import com.google.gson.JsonParser; +import com.osiris.autoplug.client.tasks.updater.mods.CurseForgeAPI; +import com.osiris.autoplug.client.tasks.updater.mods.InstalledModLoader; +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.search.SearchResult; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class ProfileCompatibilitySearchTest { + @Test void modrinthSelectsExactVersionLoaderAndPrimaryJarWithoutTimestampOrdering() { + JsonArray releases = json("[" + + "{\"game_versions\":[\"1.21\"],\"loaders\":[\"fabric\"],\"files\":[]}," + + "{\"game_versions\":[\"1.20.1\"],\"loaders\":[\"forge\"],\"files\":[]}," + + "{\"game_versions\":[\"1.20.1\"],\"loaders\":[\"fabric\"],\"version_number\":\"old-2\",\"files\":[" + + "{\"filename\":\"sources.jar\",\"url\":\"https://example.invalid/sources.jar\",\"primary\":false}," + + "{\"filename\":\"target.jar\",\"url\":\"https://example.invalid/target.jar\",\"primary\":true}]}]"); + ModrinthAPI api = new ModrinthAPI(url -> { + assertTrue(url.contains("game_versions=")); assertTrue(url.contains("1.20.1")); return releases; + }); + SearchResult result = api.searchCompatible(List.of("fabric"), "example", "1.20.1"); + assertEquals(SearchResult.Type.UPDATE_AVAILABLE, result.type); + assertEquals("https://example.invalid/target.jar", result.downloadUrl); + assertEquals("old-2", result.latestVersion); + } + + @Test void emptyCompatibleSetAndTransportFailureHaveDifferentOutcomes() { + SearchResult empty = new ModrinthAPI(url -> json("[]")).searchCompatible(List.of("neoforge"), "example", "1.20.1"); + assertEquals(SearchResult.Type.NO_COMPATIBLE_RELEASE, empty.type); + assertFalse(SearchResult.isMatchFound(empty)); + SearchResult outage = new ModrinthAPI(url -> { throw new IOException("Synthetic outage"); }) + .searchCompatible(List.of("neoforge"), "example", "1.20.1"); + assertEquals(SearchResult.Type.API_ERROR, outage.type); + assertNotNull(outage.exception); + } + + @Test void missingOrMalformedProviderMappingDoesNotMakeAnApiRequest() { + ModrinthAPI api = new ModrinthAPI(url -> { fail("Invalid mapping must not reach transport"); return null; }); + assertEquals(SearchResult.Type.RESOURCE_NOT_FOUND, api.searchCompatible(List.of("fabric"), null, "1.20.1").type); + assertEquals(SearchResult.Type.RESOURCE_NOT_FOUND, api.searchCompatible(List.of("fabric"), "../../other", "1.20.1").type); + } + + @Test void curseforgeRequiresExactVersionAndDistinguishesNeoForgeFromForge() { + MinecraftMod mod = new MinecraftMod("unused", "Example", "9", "Fixture", null, "123", null); + mod.forceLatest = true; + CurseForgeAPI api = new CurseForgeAPI(url -> { + assertTrue(url.contains("gameVersion=1.20.1")); + return json("[{\"id\":9,\"gameVersions\":[\"1.21\",\"NeoForge\"]}," + + "{\"id\":8,\"gameVersions\":[\"1.20.1\",\"Forge\"]}," + + "{\"id\":7,\"gameVersions\":[\"1.20.1\",\"NeoForge\"],\"fileName\":\"compatible.jar\",\"downloadUrl\":\"https://example.invalid/compatible.jar\"}]"); + }); + SearchResult result = api.searchCompatible(new InstalledModLoader("neoforge"), mod, "1.20.1"); + assertEquals(SearchResult.Type.UPDATE_AVAILABLE, result.type); + assertEquals("compatible.jar", result.fileName); + assertTrue(mod.forceLatest, "Profile selection does not mutate legacy configuration values"); + } + + @Test void curseforgeMissingLoaderInformationCannotBeCertifiedIncompatible() { + MinecraftMod mod = new MinecraftMod("unused", "Example", "9", "Fixture", null, "123", null); + SearchResult result = new CurseForgeAPI(url -> json("[{\"id\":7,\"gameVersions\":[\"1.20.1\"]}]") ) + .searchCompatible(new InstalledModLoader("forge"), mod, "1.20.1"); + assertEquals(SearchResult.Type.API_ERROR, result.type); + assertNotNull(result.exception); + } + + @Test void curseforgeDoesNotTreatPaginationOrRestrictedDownloadsAsIncompatibility() { + MinecraftMod mod = new MinecraftMod("unused", "Example", "9", "Fixture", null, "123", null); + JsonArray fullPage = new JsonArray(); + for (int i = 0; i < 50; i++) fullPage.add(JsonParser.parseString("{\"id\":" + i + ",\"gameVersions\":[\"1.20.1\",\"Fabric\"]}")); + assertEquals(SearchResult.Type.API_ERROR, new CurseForgeAPI(url -> fullPage) + .searchCompatible(new InstalledModLoader("forge"), mod, "1.20.1").type); + assertEquals(SearchResult.Type.API_ERROR, new CurseForgeAPI(url -> json("[{\"id\":7,\"gameVersions\":[\"1.20.1\",\"Forge\"],\"downloadUrl\":null}]") ) + .searchCompatible(new InstalledModLoader("forge"), mod, "1.20.1").type); + assertEquals(SearchResult.Type.NO_COMPATIBLE_RELEASE, new CurseForgeAPI(url -> json("[]")) + .searchCompatible(new InstalledModLoader("forge"), mod, "1.20.1").type); + } + + private static JsonArray json(String source) { return JsonParser.parseString(source).getAsJsonArray(); } +} diff --git a/src/test/java/com/osiris/autoplug/client/mc/update/ProfileUpdateServiceTest.java b/src/test/java/com/osiris/autoplug/client/mc/update/ProfileUpdateServiceTest.java new file mode 100644 index 00000000..4322cdc6 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/mc/update/ProfileUpdateServiceTest.java @@ -0,0 +1,323 @@ +package com.osiris.autoplug.client.mc.update; + +import com.osiris.autoplug.client.mc.profiles.Profile; +import com.osiris.autoplug.client.mc.profiles.ProfileRepository; +import com.osiris.autoplug.client.mc.profiles.ProfileType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.*; + +class ProfileUpdateServiceTest { + @TempDir Path temporary; + ProfileRepository repository; + SharedArtifactCache cache; + Profile profile; + Path oldJar, newJar; + + @BeforeEach void setup() throws Exception { + repository = new ProfileRepository(temporary.resolve("data")); + cache = new SharedArtifactCache(temporary.resolve("cache")); + profile = repository.create("client", ProfileType.MODS, "1.21", "fabric", false); + oldJar = jar(profile.getAssetsDirectory().resolve("example.jar"), "old"); + newJar = jar(temporary.resolve("compatible.jar"), "older-compatible-release"); + } + + @Test void checkIsReadOnlyAndPassesTheExactDowngradeTarget() throws Exception { + String metadata = read(profile.getDirectory().resolve("profile.properties")); + String hash = SharedArtifactCache.sha256(oldJar); + ProfileUpdateService service = service(new AtomicInteger()); + ProfileUpdateService.Plan plan = service.check("client", "1.20.1"); + assertEquals("1.20.1", plan.getTargetVersion()); + assertEquals(ProfileUpdateService.Action.REPLACE, plan.getChanges().get(0).getAction()); + assertEquals(metadata, read(profile.getDirectory().resolve("profile.properties"))); + assertEquals(hash, SharedArtifactCache.sha256(oldJar)); + try (java.util.stream.Stream entries = Files.list(cache.getDirectory())) { assertEquals(0, entries.count()); } + } + + @Test void updateCanDowngradeAndCachesImmutableArtifactsAcrossProfiles() throws Exception { + AtomicInteger downloads = new AtomicInteger(); + Profile second = repository.cloneProfile("client", "second"); + ProfileUpdateService service = service(downloads); + Profile firstUpdated = service.apply(service.check("client", "1.20.1"), false); + Profile secondUpdated = service.apply(service.check("second", "1.20.1"), false); + assertEquals("1.20.1", firstUpdated.getGameVersion()); + assertEquals("1.20.1", secondUpdated.getGameVersion()); + assertEquals(1, downloads.get(), "Identical verified artifacts should not be downloaded twice"); + assertEquals(SharedArtifactCache.sha256(newJar), SharedArtifactCache.sha256(firstUpdated.getAssetsDirectory().resolve("example.jar"))); + assertEquals(SharedArtifactCache.sha256(newJar), SharedArtifactCache.sha256(second.getAssetsDirectory().resolve("example.jar"))); + assertTrue(Files.isSameFile(firstUpdated.getAssetsDirectory().resolve("example.jar"), + cache.getDirectory().resolve(SharedArtifactCache.sha256(newJar) + ".jar")), "This local filesystem supports hardlinks"); + } + + @Test void incompatibilityNeedsApprovalAndRenamesWithoutDestroyingTheJar() throws Exception { + String hash = SharedArtifactCache.sha256(oldJar); + ProfileUpdateService service = serviceWith(ProfileUpdateService.Decision.incompatible("No target release")); + ProfileUpdateService.Plan plan = service.check("client", "1.20.1"); + assertEquals(1, plan.getDisabledAssets().size()); + assertTrue(plan.getSummary().contains("example.jar")); + assertThrows(IOException.class, () -> service.apply(plan, false)); + assertTrue(Files.exists(oldJar)); + assertEquals("1.21", repository.require("client").getGameVersion()); + service.apply(plan, true); + assertFalse(Files.exists(oldJar)); + assertEquals(hash, SharedArtifactCache.sha256(oldJar.resolveSibling("example.jar.disabled"))); + } + + @Test void providerErrorsCannotDisableOrChangeVersion() throws Exception { + ProfileUpdateService service = serviceWith(ProfileUpdateService.Decision.error("Temporary provider outage")); + ProfileUpdateService.Plan plan = service.check("client", "1.20.1"); + assertFalse(plan.canApply()); + assertTrue(plan.getDisabledAssets().isEmpty()); + assertThrows(IOException.class, () -> service.apply(plan, true)); + assertTrue(Files.exists(oldJar)); + assertEquals("1.21", repository.require("client").getGameVersion()); + } + + @Test void staleApprovalRejectsChangedConfigurationBeforeDownloading() throws Exception { + AtomicInteger downloads = new AtomicInteger(); + ProfileUpdateService service = service(downloads); + ProfileUpdateService.Plan plan = service.check("client", "1.20.1"); + Files.write(profile.getConfigDirectory().resolve("settings.txt"), "changed".getBytes(StandardCharsets.UTF_8)); + assertThrows(IOException.class, () -> service.apply(plan, false)); + assertEquals(0, downloads.get()); + assertEquals("1.21", repository.require("client").getGameVersion()); + } + + @Test void failedDownloadLeavesAllOriginalAssetsAndMetadataUntouched() throws Exception { + ProfileUpdateService service = new ProfileUpdateService(repository, cache, new ProfileUpdateService.Engine() { + public ProfileUpdateService.Decision resolve(Profile p, Path path, String version) { + return ProfileUpdateService.Decision.replace("https://example.invalid/fixture.jar", null, "fixture"); + } + public void download(ProfileUpdateService.Decision d, Path target) throws IOException { + Files.write(target, new byte[]{1, 2}); throw new IOException("Synthetic connection failure"); + } + }); + String hash = SharedArtifactCache.sha256(oldJar); + assertThrows(IOException.class, () -> service.apply(service.check("client", "1.20.1"), false)); + assertEquals(hash, SharedArtifactCache.sha256(oldJar)); + assertEquals("1.21", repository.require("client").getGameVersion()); + } + + @Test void templateCloneIsOnlyCreatedAtApplyAndCopiesMutableConfiguration() throws Exception { + repository.updateTemplate("client", true); + Path config = profile.getConfigDirectory().resolve("options.txt"); + Files.write(config, "source-config".getBytes(StandardCharsets.UTF_8)); + ProfileUpdateService service = service(new AtomicInteger()); + ProfileUpdateService.Plan plan = service.planClone("client", "migrated", "1.20.1"); + assertFalse(repository.find("migrated").isPresent()); + Profile clone = service.apply(plan, false); + assertFalse(clone.isTemplate()); + assertEquals("1.20.1", clone.getGameVersion()); + assertEquals("1.21", repository.require("client").getGameVersion()); + assertFalse(Files.isSameFile(config, clone.getConfigDirectory().resolve("options.txt"))); + Files.write(clone.getConfigDirectory().resolve("options.txt"), "clone-config".getBytes(StandardCharsets.UTF_8)); + assertEquals("source-config", read(config)); + assertEquals(SharedArtifactCache.sha256(jar(temporary.resolve("expected-old.jar"), "old")), SharedArtifactCache.sha256(oldJar)); + } + + @Test void compatibleDisabledJarCanBeEnabledAgain() throws Exception { + Files.move(oldJar, oldJar.resolveSibling("example.jar.disabled")); + ProfileUpdateService service = service(new AtomicInteger()); + service.apply(service.check("client", "1.20.1"), false); + assertTrue(Files.exists(oldJar)); + assertFalse(Files.exists(oldJar.resolveSibling("example.jar.disabled"))); + } + + @Test void explicitlyKeptDisabledJarStaysDisabled() throws Exception { + Files.move(oldJar, oldJar.resolveSibling("example.jar.disabled")); + ProfileUpdateService service = serviceWith(ProfileUpdateService.Decision.keep("excluded")); + service.apply(service.check("client", "1.21"), false); + assertFalse(Files.exists(oldJar)); + assertTrue(Files.exists(oldJar.resolveSibling("example.jar.disabled"))); + } + + @Test void errorDuringMetadataCommitRollsBackAssetsAndRemovesFailedClones() throws Exception { + ProfileRepository failing = new ProfileRepository(temporary.resolve("data")) { + @Override public synchronized Profile recordMigratedVersion(String id, String version) throws IOException { + throw new IOException("Synthetic metadata failure"); + } + }; + String oldHash = SharedArtifactCache.sha256(oldJar); + ProfileUpdateService service = new ProfileUpdateService(failing, cache, fixtureEngine(new AtomicInteger())); + assertThrows(IOException.class, () -> service.apply(service.check("client", "1.20.1"), false)); + assertEquals(oldHash, SharedArtifactCache.sha256(oldJar)); + assertEquals("1.21", repository.require("client").getGameVersion()); + assertThrows(IOException.class, () -> service.apply(service.planClone("client", "failed-clone", "1.20.1"), false)); + assertFalse(repository.find("failed-clone").isPresent()); + } + + @Test void pluginAndModsServerAssetsUseTheirOwnProfileDirectories() throws Exception { + for (ProfileType type : new ProfileType[]{ProfileType.PLUGINS, ProfileType.MODS_SERVER}) { + Profile p = repository.create(type.name().toLowerCase(), type, "1.21", type == ProfileType.PLUGINS ? "paper" : "forge", false); + jar(p.getAssetsDirectory().resolve("sample.jar"), "old"); + ProfileUpdateService service = service(new AtomicInteger()); + Profile updated = service.apply(service.check(p.getId(), "1.20.1"), false); + assertEquals("1.20.1", updated.getGameVersion()); + assertEquals(SharedArtifactCache.sha256(newJar), SharedArtifactCache.sha256(updated.getAssetsDirectory().resolve("sample.jar"))); + } + assertEquals("1.21", repository.require("client").getGameVersion()); + } + + @Test void enabledDisabledCollisionRejectsTheWholePlan() throws Exception { + jar(oldJar.resolveSibling("example.jar.disabled"), "disabled"); + ProfileUpdateService service = service(new AtomicInteger()); + assertThrows(IOException.class, () -> service.apply(service.check("client", "1.20.1"), false)); + assertTrue(Files.exists(oldJar)); + assertTrue(Files.exists(oldJar.resolveSibling("example.jar.disabled"))); + assertEquals("1.21", repository.require("client").getGameVersion()); + } + + @Test void profileSymlinkCannotReadOrOverwriteAnExternalFile() throws Exception { + Path outside = jar(temporary.resolve("outside.jar"), "outside"); + Files.delete(oldJar); + Files.createSymbolicLink(oldJar, outside); + ProfileUpdateService service = service(new AtomicInteger()); + assertThrows(IOException.class, () -> service.check("client", "1.20.1")); + assertEquals(SharedArtifactCache.sha256(jar(temporary.resolve("outside-copy.jar"), "outside")), SharedArtifactCache.sha256(outside)); + } + + @Test void activeProfileCannotBeUpdatedAndTheSamePlanCanBeAppliedAfterExit() throws Exception { + ProfileUpdateService service = service(new AtomicInteger()); + ProfileUpdateService.Plan plan = service.check("client", "1.20.1"); + try (ProfileRepository.UseLease lease = repository.acquireUse("client")) { + assertThrows(IOException.class, () -> service.apply(plan, false)); + } + assertEquals("1.20.1", service.apply(plan, false).getGameVersion()); + } + + @Test void launchCannotAcquireAUseLeaseHalfwayThroughAnUpdate() throws Exception { + java.util.concurrent.CountDownLatch downloading = new java.util.concurrent.CountDownLatch(1); + java.util.concurrent.CountDownLatch finishDownload = new java.util.concurrent.CountDownLatch(1); + ProfileUpdateService service = new ProfileUpdateService(repository, cache, new ProfileUpdateService.Engine() { + public ProfileUpdateService.Decision resolve(Profile p, Path path, String version) throws IOException { + return ProfileUpdateService.Decision.replace("https://example.invalid/fixture.jar", SharedArtifactCache.sha256(newJar), "fixture"); + } + public void download(ProfileUpdateService.Decision d, Path target) throws IOException { + downloading.countDown(); + try { + if (!finishDownload.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new IOException("Fixture timed out"); + } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new IOException(interrupted); } + Files.copy(newJar, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + }); + ProfileUpdateService.Plan plan = service.check("client", "1.20.1"); + java.util.concurrent.ExecutorService threads = java.util.concurrent.Executors.newFixedThreadPool(2); + try { + java.util.concurrent.Future updating = threads.submit(() -> service.apply(plan, false)); + assertTrue(downloading.await(5, java.util.concurrent.TimeUnit.SECONDS)); + java.util.concurrent.Future launching = threads.submit(() -> repository.acquireUse("client")); + assertThrows(java.util.concurrent.TimeoutException.class, () -> launching.get(100, java.util.concurrent.TimeUnit.MILLISECONDS)); + finishDownload.countDown(); + assertEquals("1.20.1", updating.get(5, java.util.concurrent.TimeUnit.SECONDS).getGameVersion()); + launching.get(5, java.util.concurrent.TimeUnit.SECONDS).close(); + } finally { finishDownload.countDown(); threads.shutdownNow(); } + } + + @Test void cancellationBeforeCheckOrApplyNeverStartsDownloadsOrMutatesProfiles() throws Exception { + AtomicInteger downloads = new AtomicInteger(); + ProfileUpdateService service = service(downloads); + assertThrows(java.io.InterruptedIOException.class, () -> service.check("client", "1.20.1", () -> true)); + assertThrows(java.io.InterruptedIOException.class, () -> service.planClone("client", "copy", "1.20.1", () -> true)); + ProfileUpdateService.Plan plan = service.check("client", "1.20.1"); + assertThrows(java.io.InterruptedIOException.class, () -> service.apply(plan, false, () -> true)); + assertEquals(0, downloads.get()); + assertEquals("1.21", repository.require("client").getGameVersion()); + assertFalse(repository.find("copy").isPresent()); + } + + @Test void cancellationDuringAProviderCheckIsNotReturnedAsACompatiblePlan() throws Exception { + java.util.concurrent.atomic.AtomicBoolean cancelled = new java.util.concurrent.atomic.AtomicBoolean(); + ProfileUpdateService service = new ProfileUpdateService(repository, cache, new ProfileUpdateService.Engine() { + public ProfileUpdateService.Decision resolve(Profile p, Path jar, String version) { + cancelled.set(true); return ProfileUpdateService.Decision.keep("fixture"); + } + public void download(ProfileUpdateService.Decision d, Path path) { fail("No download"); } + }); + assertThrows(java.io.InterruptedIOException.class, () -> service.check("client", "1.20.1", cancelled::get)); + assertEquals("1.21", repository.require("client").getGameVersion()); + } + + @Test void cancellationDuringDownloadRollsBackBeforeTheFirstProfileMutation() throws Exception { + java.util.concurrent.atomic.AtomicBoolean cancelled = new java.util.concurrent.atomic.AtomicBoolean(); + String oldHash = SharedArtifactCache.sha256(oldJar); + ProfileUpdateService service = new ProfileUpdateService(repository, cache, new ProfileUpdateService.Engine() { + public ProfileUpdateService.Decision resolve(Profile p, Path path, String version) throws IOException { + return ProfileUpdateService.Decision.replace("https://example.invalid/fixture.jar", SharedArtifactCache.sha256(newJar), "fixture"); + } + public void download(ProfileUpdateService.Decision d, Path destination) throws IOException { + Files.copy(newJar, destination, java.nio.file.StandardCopyOption.REPLACE_EXISTING); cancelled.set(true); + } + }); + ProfileUpdateService.Plan plan = service.planClone("client", "copy", "1.20.1"); + assertThrows(java.io.InterruptedIOException.class, () -> service.apply(plan, false, cancelled::get)); + assertFalse(repository.find("copy").isPresent()); + assertEquals(oldHash, SharedArtifactCache.sha256(oldJar)); + assertEquals("1.21", repository.require("client").getGameVersion()); + } + + @Test void cancellationAfterCreatingTheCloneRemovesOnlyTheUncommittedClone() throws Exception { + java.util.concurrent.atomic.AtomicBoolean cancelled = new java.util.concurrent.atomic.AtomicBoolean(); + ProfileRepository cancelling = new ProfileRepository(temporary.resolve("data")) { + @Override public synchronized Profile cloneProfile(String from, String to) throws IOException { + Profile copied = super.cloneProfile(from, to); cancelled.set(true); return copied; + } + }; + ProfileUpdateService service = new ProfileUpdateService(cancelling, cache, fixtureEngine(new AtomicInteger())); + ProfileUpdateService.Plan plan = service.planClone("client", "copy", "1.20.1"); + assertThrows(java.io.InterruptedIOException.class, () -> service.apply(plan, false, cancelled::get)); + assertFalse(repository.find("copy").isPresent()); + assertEquals("1.21", repository.require("client").getGameVersion()); + } + + @Test void cancellationAfterSuccessfulMetadataCommitReturnsTheActualSuccess() throws Exception { + java.util.concurrent.atomic.AtomicBoolean cancelled = new java.util.concurrent.atomic.AtomicBoolean(); + ProfileRepository cancelling = new ProfileRepository(temporary.resolve("data")) { + @Override public synchronized Profile recordMigratedVersion(String id, String version) throws IOException { + Profile committed = super.recordMigratedVersion(id, version); cancelled.set(true); return committed; + } + }; + ProfileUpdateService service = new ProfileUpdateService(cancelling, cache, fixtureEngine(new AtomicInteger())); + Profile committed = service.apply(service.check("client", "1.20.1"), false, cancelled::get); + assertTrue(cancelled.get()); + assertEquals("1.20.1", committed.getGameVersion()); + assertEquals("1.20.1", repository.require("client").getGameVersion()); + assertEquals(SharedArtifactCache.sha256(newJar), SharedArtifactCache.sha256(oldJar)); + } + + private ProfileUpdateService service(AtomicInteger count) { return new ProfileUpdateService(repository, cache, fixtureEngine(count)); } + private ProfileUpdateService.Engine fixtureEngine(AtomicInteger count) { + return new ProfileUpdateService.Engine() { + public ProfileUpdateService.Decision resolve(Profile p, Path path, String version) throws IOException { + assertEquals("1.20.1", version); + return ProfileUpdateService.Decision.replace("https://example.invalid/fixture.jar", SharedArtifactCache.sha256(newJar), "compatible fixture"); + } + public void download(ProfileUpdateService.Decision d, Path destination) throws IOException { + count.incrementAndGet(); Files.copy(newJar, destination, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + }; + } + private ProfileUpdateService serviceWith(ProfileUpdateService.Decision decision) { + return new ProfileUpdateService(repository, cache, new ProfileUpdateService.Engine() { + public ProfileUpdateService.Decision resolve(Profile p, Path path, String target) { return decision; } + public void download(ProfileUpdateService.Decision d, Path path) { fail("No download expected"); } + }); + } + static String read(Path path) throws IOException { return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); } + static Path jar(Path path, String content) throws IOException { + try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(path))) { + ZipEntry entry = new ZipEntry("fixture.txt"); entry.setTime(0); + output.putNextEntry(entry); output.write(content.getBytes(StandardCharsets.UTF_8)); output.closeEntry(); + } + return path; + } +} diff --git a/src/test/java/com/osiris/autoplug/client/mc/update/SharedArtifactCacheTest.java b/src/test/java/com/osiris/autoplug/client/mc/update/SharedArtifactCacheTest.java new file mode 100644 index 00000000..2a67999b --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/mc/update/SharedArtifactCacheTest.java @@ -0,0 +1,63 @@ +package com.osiris.autoplug.client.mc.update; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; + +import static org.junit.jupiter.api.Assertions.*; + +class SharedArtifactCacheTest { + @TempDir Path temporary; + + @Test void storesByContentAndLinksOnlyVerifiedReadOnlyJars() throws Exception { + SharedArtifactCache cache = new SharedArtifactCache(temporary.resolve("cache")); + Path jar = ProfileUpdateServiceTest.jar(temporary.resolve("one.jar"), "fixture"); + Path object = cache.store(jar); + assertEquals(object, cache.store(jar)); + Path directory = Files.createDirectory(temporary.resolve("mods")); + Path link = directory.resolve("one.jar"); + cache.link(object, link); + assertTrue(Files.isSameFile(object, link)); + assertFalse(Files.getPosixFilePermissions(object).contains(PosixFilePermission.OWNER_WRITE)); + assertFalse(Files.isSameFile(object, jar), "Original inputs are not made into shared mutable cache objects"); + } + + @Test void checksumMismatchAndNonJarDownloadAreNeverPublished() throws Exception { + SharedArtifactCache cache = new SharedArtifactCache(temporary.resolve("cache")); + Path jar = ProfileUpdateServiceTest.jar(temporary.resolve("one.jar"), "fixture"); + assertThrows(IOException.class, () -> cache.fetch(new String(new char[64]).replace("\0", "0"), path -> Files.copy(jar, path, java.nio.file.StandardCopyOption.REPLACE_EXISTING))); + assertThrows(IOException.class, () -> cache.fetch(null, path -> Files.write(path, new byte[]{1, 2, 3}))); + try (java.util.stream.Stream objects = Files.list(cache.getDirectory())) { assertEquals(0, objects.count()); } + } + + @Test void cachedCorruptionIsDetectedBeforeAnyLinkOrReuse() throws Exception { + SharedArtifactCache cache = new SharedArtifactCache(temporary.resolve("cache")); + Path jar = ProfileUpdateServiceTest.jar(temporary.resolve("one.jar"), "fixture"); + Path object = cache.store(jar); + String hash = SharedArtifactCache.sha256(object); + object.toFile().setWritable(true); + ProfileUpdateServiceTest.jar(object, "tampered"); + assertThrows(IOException.class, () -> cache.fetch(hash, path -> fail("Must not hide corruption by downloading"))); + Path target = Files.createDirectory(temporary.resolve("mods")).resolve("one.jar"); + assertThrows(IOException.class, () -> cache.link(object, target)); + assertFalse(Files.exists(target)); + } + + @Test void symlinkRootsAndExternalObjectsAreRejected() throws Exception { + Path outside = Files.createDirectory(temporary.resolve("outside")); + Path linkedRoot = temporary.resolve("linked-cache"); + Files.createSymbolicLink(linkedRoot, outside); + assertThrows(IOException.class, () -> new SharedArtifactCache(linkedRoot)); + SharedArtifactCache cache = new SharedArtifactCache(temporary.resolve("cache")); + Path jar = ProfileUpdateServiceTest.jar(outside.resolve("one.jar"), "fixture"); + Path target = Files.createDirectory(temporary.resolve("mods")).resolve("one.jar"); + assertThrows(IOException.class, () -> cache.link(jar, target)); + Path hashLink = cache.getDirectory().resolve(SharedArtifactCache.sha256(jar) + ".jar"); + Files.createSymbolicLink(hashLink, jar); + assertThrows(IOException.class, () -> cache.fetch(SharedArtifactCache.sha256(jar), p -> fail("No download"))); + } +} From 92fa7b7f93cf660600a94cbdd4bb6a0b8ff92ac3 Mon Sep 17 00:00:00 2001 From: elcuervo171234 <43775780+elcuervo171234@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:23:59 -0400 Subject: [PATCH 21/23] test(minecraft): integrate worlds (21/23) --- .../client/mc/worlds/WorldRepositoryTest.java | 131 +++++++++ .../mc/worlds/WorldSessionManagerTest.java | 268 ++++++++++++++++++ 2 files changed, 399 insertions(+) create mode 100644 src/test/java/com/osiris/autoplug/client/mc/worlds/WorldRepositoryTest.java create mode 100644 src/test/java/com/osiris/autoplug/client/mc/worlds/WorldSessionManagerTest.java diff --git a/src/test/java/com/osiris/autoplug/client/mc/worlds/WorldRepositoryTest.java b/src/test/java/com/osiris/autoplug/client/mc/worlds/WorldRepositoryTest.java new file mode 100644 index 00000000..d14b8e93 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/mc/worlds/WorldRepositoryTest.java @@ -0,0 +1,131 @@ +package com.osiris.autoplug.client.mc.worlds; + +import com.osiris.autoplug.client.mc.profiles.Profile; +import com.osiris.autoplug.client.mc.profiles.ProfileRepository; +import com.osiris.autoplug.client.mc.profiles.ProfileType; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +class WorldRepositoryTest { + @TempDir Path root; + + @Test void persistsWorldProfileLinkAndRejectsClientOnlyProfiles() throws Exception { + ProfileRepository profiles = new ProfileRepository(root); + profiles.create("server", ProfileType.PLUGINS, "1.20.4", "PAPER", false); + profiles.create("client", ProfileType.MODS, "1.20.4", "VANILLA", false); + WorldRepository worlds = new WorldRepository(root, profiles); + World world = worlds.create("island", "server"); + assertEquals(root.resolve("worlds/island").toRealPath(), world.getDirectory()); + assertEquals("server", new WorldRepository(root, profiles).find("island").get().getProfileId()); + assertEquals(1, worlds.list().size()); + assertThrows(IOException.class, () -> worlds.create("invalid", "client")); + assertThrows(IOException.class, () -> worlds.create("island", "server")); + assertThrows(IOException.class, () -> worlds.create("../outside", "server")); + } + + @Test void neverAcceptsTheEulaOrStartsPreparationBeforeExplicitAcceptance() throws Exception { + ProfileRepository profiles = new ProfileRepository(root); + profiles.create("server", ProfileType.PLUGINS, "1.20.4", "PAPER", false); + WorldRepository worlds = new WorldRepository(root, profiles); + World world = worlds.create("island", "server"); + byte[] before = Files.readAllBytes(world.getDirectory().resolve("eula.txt")); + IOException failure = assertThrows(IOException.class, () -> worlds.prepare(world)); + assertTrue(failure.getMessage().contains("explicitly accept")); + assertArrayEquals(before, Files.readAllBytes(world.getDirectory().resolve("eula.txt"))); + assertFalse(Files.exists(world.getDirectory().resolve("plugins"))); + } + + @Test void refreshesOnlyManagedAssetsAndPreservesWorldDataAndExistingConfigs() throws Exception { + ProfileRepository profiles = new ProfileRepository(root); + Profile profile = profiles.create("server", ProfileType.PLUGINS, "1.20.4", "PAPER", false); + Files.write(profile.getAssetsDirectory().resolve("plugin.jar"), new byte[]{1}); + Files.write(profile.getConfigDirectory().resolve("settings.txt"), new byte[]{2}); + WorldRepository worlds = new WorldRepository(root, profiles); + World world = worlds.create("island", "server"); + acceptFixtureEula(world); + worlds.prepare(world); + Files.createDirectory(world.getDirectory().resolve("world")); + Files.write(world.getDirectory().resolve("world/level.dat"), new byte[]{9}); + Files.write(world.getDirectory().resolve("config/settings.txt"), new byte[]{3}); + Files.delete(profile.getAssetsDirectory().resolve("plugin.jar")); + Files.write(profile.getAssetsDirectory().resolve("updated.jar"), new byte[]{4}); + worlds.prepare(world); + assertFalse(Files.exists(world.getDirectory().resolve("plugins/plugin.jar"))); + assertArrayEquals(new byte[]{4}, Files.readAllBytes(world.getDirectory().resolve("plugins/updated.jar"))); + assertArrayEquals(new byte[]{3}, Files.readAllBytes(world.getDirectory().resolve("config/settings.txt"))); + assertArrayEquals(new byte[]{2}, Files.readAllBytes(profile.getConfigDirectory().resolve("settings.txt"))); + assertArrayEquals(new byte[]{9}, Files.readAllBytes(world.getDirectory().resolve("world/level.dat"))); + } + + @Test void worldsDoNotShareMutableFilesOrUseTheGlobalServerDirectory() throws Exception { + Path global = root.resolve("server.properties"); Files.write(global, new byte[]{8}); + ProfileRepository profiles = new ProfileRepository(root); + Profile profile = profiles.create("mod-server", ProfileType.MODS_SERVER, "1.20.4", "FABRIC", false); + Files.write(profile.getAssetsDirectory().resolve("mod.jar"), new byte[]{1}); + WorldRepository worlds = new WorldRepository(root, profiles); + World first = worlds.create("first", "mod-server"), second = worlds.create("second", "mod-server"); + acceptFixtureEula(first); acceptFixtureEula(second); worlds.prepare(first); worlds.prepare(second); + Files.write(first.getDirectory().resolve("mods/mod.jar"), new byte[]{7}); + assertArrayEquals(new byte[]{1}, Files.readAllBytes(second.getDirectory().resolve("mods/mod.jar"))); + assertArrayEquals(new byte[]{1}, Files.readAllBytes(profile.getAssetsDirectory().resolve("mod.jar"))); + assertArrayEquals(new byte[]{8}, Files.readAllBytes(global)); + } + + @Test void configConflictDoesNotPartiallyReplaceLiveAssetsOrConfig() throws Exception { + ProfileRepository profiles = new ProfileRepository(root); + Profile profile = profiles.create("server", ProfileType.PLUGINS, "1.20.4", "PAPER", false); + Files.write(profile.getAssetsDirectory().resolve("original.jar"), new byte[]{1}); + WorldRepository worlds = new WorldRepository(root, profiles); + World world = worlds.create("island", "server"); acceptFixtureEula(world); worlds.prepare(world); + Files.write(world.getDirectory().resolve("config/conflict"), new byte[]{7}); + Files.createDirectory(profile.getConfigDirectory().resolve("conflict")); + Files.write(profile.getConfigDirectory().resolve("conflict/new.txt"), new byte[]{8}); + Files.delete(profile.getAssetsDirectory().resolve("original.jar")); + Files.write(profile.getAssetsDirectory().resolve("replacement.jar"), new byte[]{2}); + + assertThrows(IOException.class, () -> worlds.prepare(world)); + assertArrayEquals(new byte[]{1}, Files.readAllBytes(world.getDirectory().resolve("plugins/original.jar"))); + assertFalse(Files.exists(world.getDirectory().resolve("plugins/replacement.jar"))); + assertArrayEquals(new byte[]{7}, Files.readAllBytes(world.getDirectory().resolve("config/conflict"))); + try (java.util.stream.Stream paths = Files.list(world.getDirectory())) { + assertFalse(paths.anyMatch(path -> path.getFileName().toString().startsWith(".profile-"))); + } + } + + @Test void rejectsLinkedDirectoriesAndProfileFilesWithoutFollowingThem() throws Exception { + ProfileRepository profiles = new ProfileRepository(root); + Profile profile = profiles.create("server", ProfileType.PLUGINS, "1.20.4", "PAPER", false); + WorldRepository worlds = new WorldRepository(root, profiles); + Path outside = Files.createDirectory(root.resolve("outside")); + Files.createSymbolicLink(root.resolve("worlds/linked"), outside); + assertThrows(IOException.class, () -> worlds.find("linked")); + World world = worlds.create("island", "server"); acceptFixtureEula(world); + Files.write(outside.resolve("secret.jar"), new byte[]{8}); + Files.createSymbolicLink(profile.getAssetsDirectory().resolve("linked.jar"), outside.resolve("secret.jar")); + assertThrows(IOException.class, () -> worlds.prepare(world)); + assertArrayEquals(new byte[]{8}, Files.readAllBytes(outside.resolve("secret.jar"))); + assertFalse(Files.exists(world.getDirectory().resolve("plugins"))); + } + + @Test void rejectsChangedWorldOwnershipMetadata() throws Exception { + ProfileRepository profiles = new ProfileRepository(root); + profiles.create("server", ProfileType.PLUGINS, "1.20.4", "PAPER", false); + WorldRepository worlds = new WorldRepository(root, profiles); + World world = worlds.create("island", "server"); acceptFixtureEula(world); + Files.write(world.getDirectory().resolve("world.properties"), "id=other\nprofileId=server\nformatVersion=1\n".getBytes(StandardCharsets.UTF_8)); + assertThrows(IOException.class, () -> worlds.find("island")); + assertThrows(IOException.class, () -> worlds.prepare(world)); + } + + static void acceptFixtureEula(World world) throws IOException { + // Synthetic fixture only: production never performs this action. + Files.write(world.getDirectory().resolve("eula.txt"), "eula=true\n".getBytes(StandardCharsets.US_ASCII)); + } +} diff --git a/src/test/java/com/osiris/autoplug/client/mc/worlds/WorldSessionManagerTest.java b/src/test/java/com/osiris/autoplug/client/mc/worlds/WorldSessionManagerTest.java new file mode 100644 index 00000000..98469737 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/mc/worlds/WorldSessionManagerTest.java @@ -0,0 +1,268 @@ +package com.osiris.autoplug.client.mc.worlds; + +import com.osiris.autoplug.client.mc.profiles.ProfileRepository; +import com.osiris.autoplug.client.mc.profiles.ProfileType; +import com.osiris.autoplug.client.mc.sharing.PortSharingService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +class WorldSessionManagerTest { + @TempDir Path root; + + @Test void waitsForPingThenConnectsClientAndStopsServerAfterClientExit() throws Exception { + Fixture f = fixture(); AtomicInteger probes = new AtomicInteger(); + try (WorldSessionManager manager = manager(f, (host, port) -> probes.incrementAndGet() >= 2, null)) { + WorldSessionManager.Session session = manager.start(f.world, Arrays.asList("java", "-jar", "server.jar"), (host, port) -> { + assertEquals(2, probes.get()); assertEquals("127.0.0.1", host); assertEquals(25577, port); return f.client; + }, false, () -> false); + assertEquals("127.0.0.1", WorldRepository.load(f.world.getDirectory().resolve("server.properties")).getProperty("server-ip")); + assertEquals("world", WorldRepository.load(f.world.getDirectory().resolve("server.properties")).getProperty("level-name")); + assertTrue(f.command.contains("nogui")); + assertEquals(f.world.getDirectory(), f.startedDirectory); + f.client.finish(); + await(() -> manager.getActive("island") == null); + assertEquals("stop\n", f.server.commands.toString("UTF-8")); + assertFalse(f.server.isAlive()); assertTrue(session.isClosed()); assertNull(session.getFailure()); + } + } + + @Test void doesNotSpawnAnyProcessWhenEulaHasNotBeenAccepted() throws Exception { + Fixture f = fixture(); java.nio.file.Files.delete(f.world.getDirectory().resolve("eula.txt")); + try (WorldSessionManager manager = manager(f, (host, port) -> true, null)) { + assertThrows(IOException.class, () -> manager.start(f.world, Arrays.asList("java"), (host, port) -> f.client, false, () -> false)); + assertNull(f.startedDirectory); assertNull(manager.getActive("island")); + } + } + + @Test void cancelledReadinessClosesServerAndNeverLaunchesClient() throws Exception { + Fixture f = fixture(); AtomicBoolean cancelled = new AtomicBoolean(); AtomicInteger clients = new AtomicInteger(); + try (WorldSessionManager manager = manager(f, (host, port) -> { cancelled.set(true); return false; }, null)) { + assertThrows(IOException.class, () -> manager.start(f.world, Arrays.asList("java"), (host, port) -> { + clients.incrementAndGet(); return f.client; + }, false, cancelled::get)); + assertEquals(0, clients.get()); assertFalse(f.server.isAlive()); assertNull(manager.getActive("island")); + } + } + + @Test void readinessTimeoutAndClientFailureBothStopTheServer() throws Exception { + Fixture f = fixture(); + try (WorldSessionManager manager = manager(f, (host, port) -> false, null)) { + assertThrows(IOException.class, () -> manager.start(f.world, Arrays.asList("java"), (host, port) -> f.client, false, () -> false)); + assertFalse(f.server.isAlive()); + } + Fixture second = fixture(); + try (WorldSessionManager manager = manager(second, (host, port) -> true, null)) { + assertThrows(IOException.class, () -> manager.start(second.world, Arrays.asList("java"), (host, port) -> { + throw new IOException("fixture launch failure"); + }, false, () -> false)); + assertFalse(second.server.isAlive()); + } + } + + @Test void killsAClientReturnedAfterConcurrentStartCancellation() throws Exception { + Fixture f = fixture(); CountDownLatch inCallback = new CountDownLatch(1), release = new CountDownLatch(1); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try (WorldSessionManager manager = manager(f, (host, port) -> true, null)) { + Future start = executor.submit(() -> { + assertThrows(IOException.class, () -> manager.start(f.world, Arrays.asList("java"), (host, port) -> { + inCallback.countDown(); release.await(); return f.client; + }, false, () -> false)); + }); + assertTrue(inCallback.await(1, TimeUnit.SECONDS)); + WorldSessionManager.Session starting = manager.getActive("island"); + AtomicInteger callbacks = new AtomicInteger(); starting.onClose(callbacks::incrementAndGet); + manager.cancel("island"); assertFalse(f.server.isAlive()); + assertSame(starting, manager.getActive("island")); assertFalse(starting.isClosed()); + assertTrue(starting.isStopping()); assertEquals(0, callbacks.get()); release.countDown(); + start.get(1, TimeUnit.SECONDS); assertFalse(f.client.isAlive()); + assertNull(manager.getActive("island")); assertTrue(starting.isClosed()); assertEquals(1, callbacks.get()); + } finally { release.countDown(); executor.shutdownNow(); } + } + + @Test void forcedShutdownIsUsedForAnUnresponsiveServerAndIsIdempotent() throws Exception { + Fixture f = fixture(); f.server.stopCompletes = false; f.server.destroyCompletes = false; + try (WorldSessionManager manager = manager(f, (host, port) -> true, null)) { + WorldSessionManager.Session session = manager.start(f.world, Arrays.asList("java"), (host, port) -> f.client, false, () -> false); + session.close(); session.close(); + assertFalse(f.server.isAlive()); assertEquals(1, f.server.forced.get()); assertFalse(f.client.isAlive()); + } + } + + @Test void sharingIsOptInAndItsMappingIsDeletedOnClientExit() throws Exception { + Fixture f = fixture(); AtomicInteger opened = new AtomicInteger(), removed = new AtomicInteger(); + PortSharingService sharing = port -> { + opened.incrementAndGet(); assertEquals(25577, port); + return new PortSharingService.Lease() { + public String getJoinAddress() { return "fixture-public:25577"; } + public void close() { removed.incrementAndGet(); } + }; + }; + try (WorldSessionManager manager = manager(f, (host, port) -> true, sharing)) { + WorldSessionManager.Session session = manager.start(f.world, Arrays.asList("java"), (host, port) -> f.client, false, () -> false); + assertEquals(0, opened.get()); assertTrue(session.share().contains("Restart")); assertEquals(0, opened.get()); session.close(); + } + Fixture second = fixture(); + try (WorldSessionManager manager = manager(second, (host, port) -> true, sharing)) { + WorldSessionManager.Session session = manager.start(second.world, Arrays.asList("java"), (host, port) -> second.client, true, () -> false); + assertEquals(1, opened.get()); assertNotNull(session.getJoinAddress()); + assertEquals("0.0.0.0", WorldRepository.load(second.world.getDirectory().resolve("server.properties")).getProperty("server-ip")); + second.client.finish(); await(() -> removed.get() == 1); assertNull(session.getJoinAddress()); + } + } + + @Test void failedForcedShutdownRetainsSessionAndLeasesUntilSuccessfulRetry() throws Exception { + Fixture f = fixture(); f.server.stopCompletes = false; f.server.destroyCompletes = false; + f.server.forceCompletes = false; + AtomicInteger callbacks = new AtomicInteger(); + WorldSessionManager manager = manager(f, (host, port) -> true, null); + try { + WorldSessionManager.Session session = manager.start(f.world, Arrays.asList("java"), (host, port) -> f.client, false, () -> false); + session.onClose(callbacks::incrementAndGet); + assertThrows(IOException.class, session::close); + assertTrue(f.server.isAlive()); assertFalse(session.isClosed()); assertTrue(session.isStopping()); + assertSame(session, manager.getActive("island")); assertNotNull(session.getFailure()); + assertEquals(0, callbacks.get()); + assertThrows(IOException.class, () -> manager.start(f.world, Arrays.asList("java"), (host, port) -> f.client, false, () -> false)); + f.server.forceCompletes = true; + session.close(); + assertFalse(f.server.isAlive()); assertTrue(session.isClosed()); assertFalse(session.isStopping()); + assertNull(manager.getActive("island")); assertEquals(1, callbacks.get()); + session.close(); assertEquals(1, callbacks.get()); + } finally { f.server.finish(); manager.close(); } + } + + @Test void mappingFailureIsVisibleAndLocalClientStillRuns() throws Exception { + Fixture f = fixture(); + try (WorldSessionManager manager = manager(f, (host, port) -> true, port -> { throw new IOException("CGNAT fixture"); })) { + WorldSessionManager.Session session = manager.start(f.world, Arrays.asList("java"), (host, port) -> f.client, true, () -> false); + assertTrue(session.getShareMessage().contains("CGNAT")); assertTrue(session.getShareMessage().contains("LAN")); + assertNull(session.getJoinAddress()); assertTrue(f.client.isAlive()); + } + } + + @Test void sameWorldCannotStartTwiceAndClosedManagerCannotStart() throws Exception { + Fixture f = fixture(); WorldSessionManager manager = manager(f, (host, port) -> true, null); + manager.start(f.world, Arrays.asList("java"), (host, port) -> f.client, false, () -> false); + assertThrows(IOException.class, () -> manager.start(f.world, Arrays.asList("java"), (host, port) -> f.client, false, () -> false)); + manager.close(); assertFalse(f.server.isAlive()); assertFalse(f.client.isAlive()); + assertThrows(IOException.class, () -> manager.start(f.world, Arrays.asList("java"), (host, port) -> f.client, false, () -> false)); + } + + @Test void serverCrashStopsTheClientAndRemovesTheMapping() throws Exception { + Fixture f = fixture(); AtomicInteger removed = new AtomicInteger(); + PortSharingService sharing = port -> new PortSharingService.Lease() { + public String getJoinAddress() { return "fixture:25577"; } + public void close() { removed.incrementAndGet(); } + }; + try (WorldSessionManager manager = manager(f, (host, port) -> true, sharing)) { + manager.start(f.world, Arrays.asList("java"), (host, port) -> f.client, true, () -> false); + f.server.finish(); await(() -> manager.getActive("island") == null); + assertFalse(f.client.isAlive()); assertEquals(1, removed.get()); + } + } + + @Test void mappingCleanupFailureIsVisibleAndDoesNotSkipProcessCleanup() throws Exception { + Fixture f = fixture(); AtomicBoolean failCleanup = new AtomicBoolean(true); AtomicInteger callbacks = new AtomicInteger(); + PortSharingService sharing = port -> new PortSharingService.Lease() { + public String getJoinAddress() { return "fixture:25577"; } + public void close() throws IOException { if (failCleanup.get()) throw new IOException("fixture cleanup failed"); } + }; + try (WorldSessionManager manager = manager(f, (host, port) -> true, sharing)) { + WorldSessionManager.Session session = manager.start(f.world, Arrays.asList("java"), (host, port) -> f.client, true, () -> false); + session.onClose(callbacks::incrementAndGet); + assertThrows(IOException.class, session::close); + assertNotNull(session.getFailure()); assertFalse(f.server.isAlive()); assertFalse(f.client.isAlive()); + assertSame(session, manager.getActive("island")); assertFalse(session.isClosed()); assertTrue(session.isStopping()); + assertEquals(0, callbacks.get()); + failCleanup.set(false); manager.cancel("island"); + assertNull(manager.getActive("island")); assertTrue(session.isClosed()); assertNull(session.getFailure()); + assertEquals(1, callbacks.get()); + } finally { + failCleanup.set(false); + } + } + + @Test void onCloseRunsOnceAndLateRegistrationDoesNotLoseTheCleanupCallback() throws Exception { + Fixture f = fixture(); AtomicInteger callbacks = new AtomicInteger(); + try (WorldSessionManager manager = manager(f, (host, port) -> true, null)) { + WorldSessionManager.Session session = manager.start(f.world, Arrays.asList("java"), (host, port) -> f.client, false, () -> false); + session.onClose(callbacks::incrementAndGet); + session.close(); session.close(); assertEquals(1, callbacks.get()); + session.onClose(callbacks::incrementAndGet); assertEquals(2, callbacks.get()); + } + } + + @Test void aFailingCloseCallbackDoesNotPreventOtherLeasesFromBeingReleased() throws Exception { + Fixture f = fixture(); AtomicInteger callbacks = new AtomicInteger(); + try (WorldSessionManager manager = manager(f, (host, port) -> true, null)) { + WorldSessionManager.Session session = manager.start(f.world, Arrays.asList("java"), (host, port) -> f.client, false, () -> false); + session.onClose(() -> { throw new IllegalStateException("fixture callback"); }); + session.onClose(callbacks::incrementAndGet); + assertThrows(IOException.class, session::close); assertEquals(1, callbacks.get()); + assertFalse(f.server.isAlive()); assertFalse(f.client.isAlive()); + } + } + + private Fixture fixture() throws Exception { + Path data = java.nio.file.Files.createTempDirectory(root, "fixture-"); + ProfileRepository profiles = new ProfileRepository(data); + profiles.create("server", ProfileType.PLUGINS, "1.20.4", "PAPER", false); + Fixture f = new Fixture(); f.repository = new WorldRepository(data, profiles); f.world = f.repository.create("island", "server"); + WorldRepositoryTest.acceptFixtureEula(f.world); return f; + } + + private WorldSessionManager manager(Fixture f, WorldSessionManager.ReadinessProbe readiness, PortSharingService sharing) { + return new WorldSessionManager(f.repository, sharing, (command, directory) -> { + f.command = command; f.startedDirectory = directory; return f.server; + }, readiness, () -> 25577, new WorldSessionManager.Options(15, 2, 1)); + } + + private static void await(java.util.function.BooleanSupplier done) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(1); + while (!done.getAsBoolean() && System.nanoTime() < deadline) Thread.sleep(1); + assertTrue(done.getAsBoolean()); + } + + private static final class Fixture { + WorldRepository repository; World world; Path startedDirectory; List command; + FakeProcess server = new FakeProcess(), client = new FakeProcess(); + } + + private static final class FakeProcess extends Process { + final CountDownLatch exited = new CountDownLatch(1); + final AtomicInteger forced = new AtomicInteger(); + volatile boolean stopCompletes = true, destroyCompletes = true, forceCompletes = true; + final ByteArrayOutputStream commands = new ByteArrayOutputStream(); + public OutputStream getOutputStream() { return new OutputStream() { + public void write(int value) { commands.write(value); } + public void flush() { if (stopCompletes && commands.toString().contains("stop\n")) finish(); } + }; } + public InputStream getInputStream() { return new ByteArrayInputStream(new byte[0]); } + public InputStream getErrorStream() { return new ByteArrayInputStream(new byte[0]); } + public int waitFor() throws InterruptedException { exited.await(); return 0; } + public boolean waitFor(long timeout, TimeUnit unit) { return !isAlive(); } + public int exitValue() { if (isAlive()) throw new IllegalThreadStateException(); return 0; } + public void destroy() { if (destroyCompletes) finish(); } + public Process destroyForcibly() { forced.incrementAndGet(); if (forceCompletes) finish(); return this; } + public boolean isAlive() { return exited.getCount() != 0; } + void finish() { exited.countDown(); } + } +} From 7720826a08b4c9b49e3156afb5ef75e9816d964f Mon Sep 17 00:00:00 2001 From: elcuervo171234 <43775780+elcuervo171234@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:24:05 -0400 Subject: [PATCH 22/23] test(minecraft): integrate ui (22/23) --- .../client/ui/NativeMinecraftPanelTest.java | 127 ++++++++++++++++++ .../client/ui/NativeMinecraftPreview.java | 56 ++++++++ 2 files changed, 183 insertions(+) create mode 100644 src/test/java/com/osiris/autoplug/client/ui/NativeMinecraftPanelTest.java create mode 100644 src/test/java/com/osiris/autoplug/client/ui/NativeMinecraftPreview.java diff --git a/src/test/java/com/osiris/autoplug/client/ui/NativeMinecraftPanelTest.java b/src/test/java/com/osiris/autoplug/client/ui/NativeMinecraftPanelTest.java new file mode 100644 index 00000000..c3a2c319 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/ui/NativeMinecraftPanelTest.java @@ -0,0 +1,127 @@ +package com.osiris.autoplug.client.ui; + +import com.google.gson.JsonObject; +import com.osiris.autoplug.client.mc.client.ClientSettings; +import com.osiris.autoplug.client.mc.client.NativeClientContext; +import com.osiris.autoplug.client.mc.launch.Cancellation; +import com.osiris.autoplug.client.mc.launch.LaunchTransport; +import com.osiris.autoplug.client.mc.profiles.Profile; +import com.osiris.autoplug.client.mc.profiles.ProfileType; +import com.osiris.autoplug.client.mc.update.ProfileUpdateService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import javax.swing.JButton; +import javax.swing.JTabbedPane; +import javax.swing.JTable; +import javax.swing.SwingUtilities; +import java.awt.Component; +import java.awt.Container; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +class NativeMinecraftPanelTest { + @TempDir Path temporary; + + @Test void detectsReleaseVersionsInsideServerSoftwareLabels() { + assertEquals("1.20.4", NativeMinecraftPanel.detectVersion("Paper 1.20.4")); + assertEquals("1.20.1", NativeMinecraftPanel.detectVersion("Forge 47.2.20 / Minecraft 1.20.1")); + assertEquals("1.21", NativeMinecraftPanel.detectVersion("1.21")); + } + + @Test void doesNotInventAReleaseVersionFromSnapshotOrMalformedStatus() { + assertEquals("", NativeMinecraftPanel.detectVersion(null)); + assertEquals("", NativeMinecraftPanel.detectVersion("")); + assertEquals("", NativeMinecraftPanel.detectVersion("Snapshot 24w14a")); + assertEquals("", NativeMinecraftPanel.detectVersion("Proxy supports 1.8 through 1.20.4")); + assertEquals("", NativeMinecraftPanel.detectVersion("unsupported 1.20.4.8")); + } + + @Test void bootstrapRendersOwnSavedDataAndTemplatesWithoutNetworkOrGameLaunch() throws Exception { + temporary = temporary.toRealPath(); // macOS /var is an alias; storage deliberately requires canonical ancestors. + Path data = Files.createDirectory(temporary.resolve("data")); + ClientSettings settings = new ClientSettings(data); + settings.set("cacheDirectory", temporary.resolve("cache").toString()); settings.save(); + AtomicInteger externalCalls = new AtomicInteger(); + LaunchTransport noNetwork = new LaunchTransport() { + public JsonObject json(String method, URI uri, Map headers, byte[] body, Cancellation cancellation) { + externalCalls.incrementAndGet(); throw new AssertionError("UI bootstrap must not contact a service"); + } + public void download(URI uri, Path destination, Cancellation cancellation) { + externalCalls.incrementAndGet(); throw new AssertionError("UI bootstrap must not download"); + } + }; + ProfileUpdateService.Engine noUpdate = new ProfileUpdateService.Engine() { + public ProfileUpdateService.Decision resolve(Profile profile, Path jar, String target) { + externalCalls.incrementAndGet(); throw new AssertionError("UI bootstrap must not check assets"); + } + public void download(ProfileUpdateService.Decision decision, Path destination) { + externalCalls.incrementAndGet(); throw new AssertionError("UI bootstrap must not download assets"); + } + }; + try (NativeClientContext context = new NativeClientContext(data, temporary, noNetwork, noUpdate)) { + // A populated private fixture list prevents automatic inspection of real servers.dat. + context.servers().add("Synthetic server", "example.invalid:25565", "VANILLA"); + context.profiles().create("client", ProfileType.MODS, "1.20.4", "VANILLA", false); + context.profiles().create("template", ProfileType.MODS, "1.21", "FABRIC", true); + context.profiles().create("server", ProfileType.PLUGINS, "1.20.4", "PAPER", false); + context.worlds().create("synthetic", "server"); + AtomicReference panel = new AtomicReference<>(); + SwingUtilities.invokeAndWait(() -> panel.set(new NativeMinecraftPanel(context))); + AtomicBoolean loading = new AtomicBoolean(true); + long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(5); + while (loading.get() && System.nanoTime() < deadline) { + SwingUtilities.invokeAndWait(() -> loading.set(components(panel.get(), JButton.class).stream() + .filter(button -> button.getText().equals("Cancel operation")).findFirst().orElseThrow(AssertionError::new).isEnabled())); + if (loading.get()) Thread.sleep(10); + } + assertFalse(loading.get(), "Read-only fixture bootstrap should finish"); + SwingUtilities.invokeAndWait(() -> { + JTabbedPane tabs = components(panel.get(), JTabbedPane.class).get(0); + assertEquals(4, tabs.getTabCount()); + List tables = components(panel.get(), JTable.class); + assertEquals(3, tables.size()); + assertEquals(1, tables.get(0).getRowCount()); + assertEquals("Synthetic server", tables.get(0).getValueAt(0, 0)); + assertEquals(3, tables.get(1).getRowCount()); + assertEquals("template", tables.get(1).getValueAt(2, 0)); + assertEquals(Boolean.TRUE, tables.get(1).getValueAt(2, 4)); + assertEquals(1, tables.get(2).getRowCount()); + assertEquals("Stopped", tables.get(2).getValueAt(0, 3)); + }); + assertEquals(0, externalCalls.get()); + } + } + + @Test void loadsOnlyBoundedPngWorldPreviewAndIgnoresCorruption() throws Exception { + Path world=Files.createDirectory(temporary.resolve("preview")); + assertNull(NativeMinecraftPanel.worldPreview(world)); + Files.write(world.resolve("server-icon.png"),new byte[]{1,2,3}); + assertNull(NativeMinecraftPanel.worldPreview(world)); + java.awt.image.BufferedImage image=new java.awt.image.BufferedImage(64,64,java.awt.image.BufferedImage.TYPE_INT_RGB); + javax.imageio.ImageIO.write(image,"png",world.resolve("server-icon.png").toFile()); + javax.swing.Icon icon=NativeMinecraftPanel.worldPreview(world); + assertNotNull(icon);assertEquals(48,icon.getIconWidth());assertEquals(48,icon.getIconHeight()); + image=new java.awt.image.BufferedImage(1024,1024,java.awt.image.BufferedImage.TYPE_INT_RGB); + javax.imageio.ImageIO.write(image,"png",world.resolve("server-icon.png").toFile()); + assertNull(NativeMinecraftPanel.worldPreview(world)); + } + + private static List components(Container parent, Class type) { + List result = new ArrayList<>(); + for (Component child : parent.getComponents()) { + if (type.isInstance(child)) result.add(type.cast(child)); + if (child instanceof Container) result.addAll(components((Container) child, type)); + } + return result; + } +} diff --git a/src/test/java/com/osiris/autoplug/client/ui/NativeMinecraftPreview.java b/src/test/java/com/osiris/autoplug/client/ui/NativeMinecraftPreview.java new file mode 100644 index 00000000..9722d9be --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/ui/NativeMinecraftPreview.java @@ -0,0 +1,56 @@ +package com.osiris.autoplug.client.ui; + +import com.formdev.flatlaf.FlatDarkLaf; +import com.google.gson.JsonObject; +import com.osiris.autoplug.client.mc.client.*; +import com.osiris.autoplug.client.mc.launch.*; +import com.osiris.autoplug.client.mc.profiles.*; +import com.osiris.autoplug.client.mc.update.*; +import javax.swing.*; +import java.awt.*; +import java.io.IOException; +import java.net.URI; +import java.nio.file.*; +import java.util.Map; + +/** Disposable desktop review fixture, not a production entry point or a live-game test. */ +public final class NativeMinecraftPreview { + public static void main(String[] arguments) throws Exception { + Path root=Files.createTempDirectory(Paths.get(arguments[0]),"autoplug-desktop-review-").toRealPath(); + ClientSettings settings=new ClientSettings(root); + settings.set("cacheDirectory",root.resolve("cache").toString());settings.save(); + LaunchTransport transport=new LaunchTransport(){ + public JsonObject json(String method,URI uri,Map headers,byte[] body,Cancellation cancellation)throws IOException{throw new IOException("Offline desktop fixture: no online metadata/account requests");} + public void download(URI uri,Path target,Cancellation cancellation)throws IOException{throw new IOException("Offline desktop fixture: no downloads");} + }; + ProfileUpdateService.Engine engine=new ProfileUpdateService.Engine(){ + public ProfileUpdateService.Decision resolve(Profile profile,Path jar,String version){return ProfileUpdateService.Decision.error("Offline review fixture has no provider response");} + public void download(ProfileUpdateService.Decision decision,Path target)throws IOException{throw new IOException("No fixture download");} + }; + NativeClientContext context=new NativeClientContext(root,root,transport,engine); + context.profiles().create("review-vanilla",ProfileType.MODS,"1.20.4","VANILLA",false); + context.profiles().create("review-paper",ProfileType.PLUGINS,"1.20.4","PAPER",false); + context.profiles().create("review-template",ProfileType.MODS,"1.19.4","FABRIC",true); + context.servers().add("Synthetic example (not online)","example.invalid:25565","VANILLA"); + context.worlds().create("review-world","review-paper"); + System.out.println("Disposable review data: "+root); + SwingUtilities.invokeAndWait(()->{ + FlatDarkLaf.setup();JFrame frame=new JFrame("AutoPlug — Offline review (synthetic data)"); + frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);frame.setLayout(new BorderLayout()); + JLabel notice=new JLabel(" OFFLINE REVIEW — synthetic data; no Minecraft account, game, server or router test "); + notice.setBorder(BorderFactory.createEmptyBorder(10,8,10,8));frame.add(notice,BorderLayout.NORTH); + frame.add(new NativeMinecraftPanel(context));frame.setSize(1180,760);frame.setLocationRelativeTo(null); + frame.addWindowListener(new java.awt.event.WindowAdapter(){@Override public void windowClosed(java.awt.event.WindowEvent event){try{context.close();}catch(IOException error){System.err.println(error.getMessage());}}}); + frame.setVisible(true); + if(arguments.length>1){ + javax.swing.Timer render=new javax.swing.Timer(750,event->{ + try { + java.awt.image.BufferedImage image=new java.awt.image.BufferedImage(frame.getContentPane().getWidth(),frame.getContentPane().getHeight(),java.awt.image.BufferedImage.TYPE_INT_RGB); + Graphics2D graphics=image.createGraphics();frame.getContentPane().printAll(graphics);graphics.dispose(); + javax.imageio.ImageIO.write(image,"png",Paths.get(arguments[1]).toFile()); + }catch(IOException error){System.err.println("Cannot render review fixture: "+error.getMessage());} + });render.setRepeats(false);render.start(); + } + }); + } +} From 1e852ef917a57dbfce9554c5a6a9166d89a41c1e Mon Sep 17 00:00:00 2001 From: elcuervo171234 <43775780+elcuervo171234@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:24:10 -0400 Subject: [PATCH 23/23] test(minecraft): integrate minecraft (23/23) --- src/test/resources/minecraft/mojang-1.20.4.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/test/resources/minecraft/mojang-1.20.4.json diff --git a/src/test/resources/minecraft/mojang-1.20.4.json b/src/test/resources/minecraft/mojang-1.20.4.json new file mode 100644 index 00000000..a898593c --- /dev/null +++ b/src/test/resources/minecraft/mojang-1.20.4.json @@ -0,0 +1 @@ +{"arguments": {"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}", "--clientId", "${clientid}", "--xuid", "${auth_xuid}", "--userType", "${user_type}", "--versionType", "${version_type}", {"rules": [{"action": "allow", "features": {"is_demo_user": true}}], "value": "--demo"}, {"rules": [{"action": "allow", "features": {"has_custom_resolution": true}}], "value": ["--width", "${resolution_width}", "--height", "${resolution_height}"]}, {"rules": [{"action": "allow", "features": {"has_quick_plays_support": true}}], "value": ["--quickPlayPath", "${quickPlayPath}"]}, {"rules": [{"action": "allow", "features": {"is_quick_play_singleplayer": true}}], "value": ["--quickPlaySingleplayer", "${quickPlaySingleplayer}"]}, {"rules": [{"action": "allow", "features": {"is_quick_play_multiplayer": true}}], "value": ["--quickPlayMultiplayer", "${quickPlayMultiplayer}"]}, {"rules": [{"action": "allow", "features": {"is_quick_play_realms": true}}], "value": ["--quickPlayRealms", "${quickPlayRealms}"]}], "jvm": [{"rules": [{"action": "allow", "os": {"name": "osx"}}], "value": ["-XstartOnFirstThread"]}, {"rules": [{"action": "allow", "os": {"name": "windows"}}], "value": "-XX:HeapDumpPath=MojangTricksIntelDriversForPerformance_javaw.exe_minecraft.exe.heapdump"}, {"rules": [{"action": "allow", "os": {"arch": "x86"}}], "value": "-Xss1M"}, "-Djava.library.path=${natives_directory}", "-Djna.tmpdir=${natives_directory}", "-Dorg.lwjgl.system.SharedLibraryExtractPath=${natives_directory}", "-Dio.netty.native.workdir=${natives_directory}", "-Dminecraft.launcher.brand=${launcher_name}", "-Dminecraft.launcher.version=${launcher_version}", "-cp", "${classpath}"]}, "assetIndex": {"id": "12", "sha1": "129fc8163fdedb50c886731a839751c22aa0dbc6", "size": 437886, "totalSize": 656562487, "url": "https://piston-meta.mojang.com/v1/packages/129fc8163fdedb50c886731a839751c22aa0dbc6/12.json"}, "assets": "12", "complianceLevel": 1, "downloads": {"client": {"sha1": "fd19469fed4a4b4c15b2d5133985f0e3e7816a8a", "size": 24445539, "url": "https://piston-data.mojang.com/v1/objects/fd19469fed4a4b4c15b2d5133985f0e3e7816a8a/client.jar"}, "client_mappings": {"sha1": "be76ecc174ea25580bdc9bf335481a5192d9f3b7", "size": 8897012, "url": "https://piston-data.mojang.com/v1/objects/be76ecc174ea25580bdc9bf335481a5192d9f3b7/client.txt"}, "server": {"sha1": "8dd1a28015f51b1803213892b50b7b4fc76e594d", "size": 49150256, "url": "https://piston-data.mojang.com/v1/objects/8dd1a28015f51b1803213892b50b7b4fc76e594d/server.jar"}, "server_mappings": {"sha1": "c1cafe916dd8b58ed1fe0564fc8f786885224e62", "size": 6797462, "url": "https://piston-data.mojang.com/v1/objects/c1cafe916dd8b58ed1fe0564fc8f786885224e62/server.txt"}}, "id": "1.20.4", "javaVersion": {"component": "java-runtime-gamma", "majorVersion": 17}, "libraries": [{"downloads": {"artifact": {"path": "ca/weblite/java-objc-bridge/1.1/java-objc-bridge-1.1.jar", "sha1": "1227f9e0666314f9de41477e3ec277e542ed7f7b", "size": 1330045, "url": "https://libraries.minecraft.net/ca/weblite/java-objc-bridge/1.1/java-objc-bridge-1.1.jar"}}, "name": "ca.weblite:java-objc-bridge:1.1", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "com/github/oshi/oshi-core/6.4.5/oshi-core-6.4.5.jar", "sha1": "943ba26de047eb6b28fff47f5ee939a34eb5fc8e", "size": 970546, "url": "https://libraries.minecraft.net/com/github/oshi/oshi-core/6.4.5/oshi-core-6.4.5.jar"}}, "name": "com.github.oshi:oshi-core:6.4.5"}, {"downloads": {"artifact": {"path": "com/google/code/gson/gson/2.10.1/gson-2.10.1.jar", "sha1": "b3add478d4382b78ea20b1671390a858002feb6c", "size": 283367, "url": "https://libraries.minecraft.net/com/google/code/gson/gson/2.10.1/gson-2.10.1.jar"}}, "name": "com.google.code.gson:gson:2.10.1"}, {"downloads": {"artifact": {"path": "com/google/guava/failureaccess/1.0.1/failureaccess-1.0.1.jar", "sha1": "1dcf1de382a0bf95a3d8b0849546c88bac1292c9", "size": 4617, "url": "https://libraries.minecraft.net/com/google/guava/failureaccess/1.0.1/failureaccess-1.0.1.jar"}}, "name": "com.google.guava:failureaccess:1.0.1"}, {"downloads": {"artifact": {"path": "com/google/guava/guava/32.1.2-jre/guava-32.1.2-jre.jar", "sha1": "5e64ec7e056456bef3a4bc4c6fdaef71e8ab6318", "size": 3041591, "url": "https://libraries.minecraft.net/com/google/guava/guava/32.1.2-jre/guava-32.1.2-jre.jar"}}, "name": "com.google.guava:guava:32.1.2-jre"}, {"downloads": {"artifact": {"path": "com/ibm/icu/icu4j/73.2/icu4j-73.2.jar", "sha1": "61ad4ef7f9131fcf6d25c34b817f90d6da06c9e9", "size": 14567819, "url": "https://libraries.minecraft.net/com/ibm/icu/icu4j/73.2/icu4j-73.2.jar"}}, "name": "com.ibm.icu:icu4j:73.2"}, {"downloads": {"artifact": {"path": "com/mojang/authlib/6.0.52/authlib-6.0.52.jar", "sha1": "65085e2eb921c4a3ebdde3d248637f1776e6d80d", "size": 115471, "url": "https://libraries.minecraft.net/com/mojang/authlib/6.0.52/authlib-6.0.52.jar"}}, "name": "com.mojang:authlib:6.0.52"}, {"downloads": {"artifact": {"path": "com/mojang/blocklist/1.0.10/blocklist-1.0.10.jar", "sha1": "5c685c5ffa94c4cd39496c7184c1d122e515ecef", "size": 964, "url": "https://libraries.minecraft.net/com/mojang/blocklist/1.0.10/blocklist-1.0.10.jar"}}, "name": "com.mojang:blocklist:1.0.10"}, {"downloads": {"artifact": {"path": "com/mojang/brigadier/1.2.9/brigadier-1.2.9.jar", "sha1": "73e324f2ee541493a5179abf367237faa782ed21", "size": 79955, "url": "https://libraries.minecraft.net/com/mojang/brigadier/1.2.9/brigadier-1.2.9.jar"}}, "name": "com.mojang:brigadier:1.2.9"}, {"downloads": {"artifact": {"path": "com/mojang/datafixerupper/6.0.8/datafixerupper-6.0.8.jar", "sha1": "3ba4a30557a9b057760af4011f909ba619fc5125", "size": 689960, "url": "https://libraries.minecraft.net/com/mojang/datafixerupper/6.0.8/datafixerupper-6.0.8.jar"}}, "name": "com.mojang:datafixerupper:6.0.8"}, {"downloads": {"artifact": {"path": "com/mojang/logging/1.1.1/logging-1.1.1.jar", "sha1": "832b8e6674a9b325a5175a3a6267dfaf34c85139", "size": 15343, "url": "https://libraries.minecraft.net/com/mojang/logging/1.1.1/logging-1.1.1.jar"}}, "name": "com.mojang:logging:1.1.1"}, {"downloads": {"artifact": {"path": "com/mojang/patchy/2.2.10/patchy-2.2.10.jar", "sha1": "da05971b07cbb379d002cf7eaec6a2048211fefc", "size": 4439, "url": "https://libraries.minecraft.net/com/mojang/patchy/2.2.10/patchy-2.2.10.jar"}}, "name": "com.mojang:patchy:2.2.10"}, {"downloads": {"artifact": {"path": "com/mojang/text2speech/1.17.9/text2speech-1.17.9.jar", "sha1": "3cad216e3a7f0c19b4b394388bc9ffc446f13b14", "size": 12243, "url": "https://libraries.minecraft.net/com/mojang/text2speech/1.17.9/text2speech-1.17.9.jar"}}, "name": "com.mojang:text2speech:1.17.9"}, {"downloads": {"artifact": {"path": "commons-codec/commons-codec/1.16.0/commons-codec-1.16.0.jar", "sha1": "4e3eb3d79888d76b54e28b350915b5dc3919c9de", "size": 360738, "url": "https://libraries.minecraft.net/commons-codec/commons-codec/1.16.0/commons-codec-1.16.0.jar"}}, "name": "commons-codec:commons-codec:1.16.0"}, {"downloads": {"artifact": {"path": "commons-io/commons-io/2.13.0/commons-io-2.13.0.jar", "sha1": "8bb2bc9b4df17e2411533a0708a69f983bf5e83b", "size": 483954, "url": "https://libraries.minecraft.net/commons-io/commons-io/2.13.0/commons-io-2.13.0.jar"}}, "name": "commons-io:commons-io:2.13.0"}, {"downloads": {"artifact": {"path": "commons-logging/commons-logging/1.2/commons-logging-1.2.jar", "sha1": "4bfc12adfe4842bf07b657f0369c4cb522955686", "size": 61829, "url": "https://libraries.minecraft.net/commons-logging/commons-logging/1.2/commons-logging-1.2.jar"}}, "name": "commons-logging:commons-logging:1.2"}, {"downloads": {"artifact": {"path": "io/netty/netty-buffer/4.1.97.Final/netty-buffer-4.1.97.Final.jar", "sha1": "f8f3d8644afa5e6e1a40a3a6aeb9d9aa970ecb4f", "size": 306590, "url": "https://libraries.minecraft.net/io/netty/netty-buffer/4.1.97.Final/netty-buffer-4.1.97.Final.jar"}}, "name": "io.netty:netty-buffer:4.1.97.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-codec/4.1.97.Final/netty-codec-4.1.97.Final.jar", "sha1": "384ba4d75670befbedb45c4d3b497a93639c206d", "size": 345274, "url": "https://libraries.minecraft.net/io/netty/netty-codec/4.1.97.Final/netty-codec-4.1.97.Final.jar"}}, "name": "io.netty:netty-codec:4.1.97.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-common/4.1.97.Final/netty-common-4.1.97.Final.jar", "sha1": "7cceacaf11df8dc63f23d0fb58e9d4640fc88404", "size": 659930, "url": "https://libraries.minecraft.net/io/netty/netty-common/4.1.97.Final/netty-common-4.1.97.Final.jar"}}, "name": "io.netty:netty-common:4.1.97.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-handler/4.1.97.Final/netty-handler-4.1.97.Final.jar", "sha1": "abb86c6906bf512bf2b797a41cd7d2e8d3cd7c36", "size": 560040, "url": "https://libraries.minecraft.net/io/netty/netty-handler/4.1.97.Final/netty-handler-4.1.97.Final.jar"}}, "name": "io.netty:netty-handler:4.1.97.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-resolver/4.1.97.Final/netty-resolver-4.1.97.Final.jar", "sha1": "cec8348108dc76c47cf87c669d514be52c922144", "size": 37792, "url": "https://libraries.minecraft.net/io/netty/netty-resolver/4.1.97.Final/netty-resolver-4.1.97.Final.jar"}}, "name": "io.netty:netty-resolver:4.1.97.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-transport-classes-epoll/4.1.97.Final/netty-transport-classes-epoll-4.1.97.Final.jar", "sha1": "795da37ded759e862457a82d9d92c4d39ce8ecee", "size": 147139, "url": "https://libraries.minecraft.net/io/netty/netty-transport-classes-epoll/4.1.97.Final/netty-transport-classes-epoll-4.1.97.Final.jar"}}, "name": "io.netty:netty-transport-classes-epoll:4.1.97.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-transport-native-epoll/4.1.97.Final/netty-transport-native-epoll-4.1.97.Final-linux-aarch_64.jar", "sha1": "5514744c588190ffda076b35a9b8c9f24946a960", "size": 40427, "url": "https://libraries.minecraft.net/io/netty/netty-transport-native-epoll/4.1.97.Final/netty-transport-native-epoll-4.1.97.Final-linux-aarch_64.jar"}}, "name": "io.netty:netty-transport-native-epoll:4.1.97.Final:linux-aarch_64", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "io/netty/netty-transport-native-epoll/4.1.97.Final/netty-transport-native-epoll-4.1.97.Final-linux-x86_64.jar", "sha1": "54188f271e388e7f313aea995e82f58ce2cdb809", "size": 38954, "url": "https://libraries.minecraft.net/io/netty/netty-transport-native-epoll/4.1.97.Final/netty-transport-native-epoll-4.1.97.Final-linux-x86_64.jar"}}, "name": "io.netty:netty-transport-native-epoll:4.1.97.Final:linux-x86_64", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "io/netty/netty-transport-native-unix-common/4.1.97.Final/netty-transport-native-unix-common-4.1.97.Final.jar", "sha1": "d469d84265ab70095b01b40886cabdd433b6e664", "size": 43897, "url": "https://libraries.minecraft.net/io/netty/netty-transport-native-unix-common/4.1.97.Final/netty-transport-native-unix-common-4.1.97.Final.jar"}}, "name": "io.netty:netty-transport-native-unix-common:4.1.97.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-transport/4.1.97.Final/netty-transport-4.1.97.Final.jar", "sha1": "f37380d23c9bb079bc702910833b2fd532c9abd0", "size": 489624, "url": "https://libraries.minecraft.net/io/netty/netty-transport/4.1.97.Final/netty-transport-4.1.97.Final.jar"}}, "name": "io.netty:netty-transport:4.1.97.Final"}, {"downloads": {"artifact": {"path": "it/unimi/dsi/fastutil/8.5.12/fastutil-8.5.12.jar", "sha1": "c24946d46824bd528054bface3231d2ecb7e95e8", "size": 23326598, "url": "https://libraries.minecraft.net/it/unimi/dsi/fastutil/8.5.12/fastutil-8.5.12.jar"}}, "name": "it.unimi.dsi:fastutil:8.5.12"}, {"downloads": {"artifact": {"path": "net/java/dev/jna/jna-platform/5.13.0/jna-platform-5.13.0.jar", "sha1": "88e9a306715e9379f3122415ef4ae759a352640d", "size": 1363209, "url": "https://libraries.minecraft.net/net/java/dev/jna/jna-platform/5.13.0/jna-platform-5.13.0.jar"}}, "name": "net.java.dev.jna:jna-platform:5.13.0"}, {"downloads": {"artifact": {"path": "net/java/dev/jna/jna/5.13.0/jna-5.13.0.jar", "sha1": "1200e7ebeedbe0d10062093f32925a912020e747", "size": 1879325, "url": "https://libraries.minecraft.net/net/java/dev/jna/jna/5.13.0/jna-5.13.0.jar"}}, "name": "net.java.dev.jna:jna:5.13.0"}, {"downloads": {"artifact": {"path": "net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar", "sha1": "4fdac2fbe92dfad86aa6e9301736f6b4342a3f5c", "size": 78146, "url": "https://libraries.minecraft.net/net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar"}}, "name": "net.sf.jopt-simple:jopt-simple:5.0.4"}, {"downloads": {"artifact": {"path": "org/apache/commons/commons-compress/1.22/commons-compress-1.22.jar", "sha1": "691a8b4e6cf4248c3bc72c8b719337d5cb7359fa", "size": 1039712, "url": "https://libraries.minecraft.net/org/apache/commons/commons-compress/1.22/commons-compress-1.22.jar"}}, "name": "org.apache.commons:commons-compress:1.22"}, {"downloads": {"artifact": {"path": "org/apache/commons/commons-lang3/3.13.0/commons-lang3-3.13.0.jar", "sha1": "b7263237aa89c1f99b327197c41d0669707a462e", "size": 632267, "url": "https://libraries.minecraft.net/org/apache/commons/commons-lang3/3.13.0/commons-lang3-3.13.0.jar"}}, "name": "org.apache.commons:commons-lang3:3.13.0"}, {"downloads": {"artifact": {"path": "org/apache/httpcomponents/httpclient/4.5.13/httpclient-4.5.13.jar", "sha1": "e5f6cae5ca7ecaac1ec2827a9e2d65ae2869cada", "size": 780321, "url": "https://libraries.minecraft.net/org/apache/httpcomponents/httpclient/4.5.13/httpclient-4.5.13.jar"}}, "name": "org.apache.httpcomponents:httpclient:4.5.13"}, {"downloads": {"artifact": {"path": "org/apache/httpcomponents/httpcore/4.4.16/httpcore-4.4.16.jar", "sha1": "51cf043c87253c9f58b539c9f7e44c8894223850", "size": 327891, "url": "https://libraries.minecraft.net/org/apache/httpcomponents/httpcore/4.4.16/httpcore-4.4.16.jar"}}, "name": "org.apache.httpcomponents:httpcore:4.4.16"}, {"downloads": {"artifact": {"path": "org/apache/logging/log4j/log4j-api/2.19.0/log4j-api-2.19.0.jar", "sha1": "ea1b37f38c327596b216542bc636cfdc0b8036fa", "size": 317566, "url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-api/2.19.0/log4j-api-2.19.0.jar"}}, "name": "org.apache.logging.log4j:log4j-api:2.19.0"}, {"downloads": {"artifact": {"path": "org/apache/logging/log4j/log4j-core/2.19.0/log4j-core-2.19.0.jar", "sha1": "3b6eeb4de4c49c0fe38a4ee27188ff5fee44d0bb", "size": 1864386, "url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-core/2.19.0/log4j-core-2.19.0.jar"}}, "name": "org.apache.logging.log4j:log4j-core:2.19.0"}, {"downloads": {"artifact": {"path": "org/apache/logging/log4j/log4j-slf4j2-impl/2.19.0/log4j-slf4j2-impl-2.19.0.jar", "sha1": "5c04bfdd63ce9dceb2e284b81e96b6a70010ee10", "size": 27721, "url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-slf4j2-impl/2.19.0/log4j-slf4j2-impl-2.19.0.jar"}}, "name": "org.apache.logging.log4j:log4j-slf4j2-impl:2.19.0"}, {"downloads": {"artifact": {"path": "org/joml/joml/1.10.5/joml-1.10.5.jar", "sha1": "22566d58af70ad3d72308bab63b8339906deb649", "size": 712082, "url": "https://libraries.minecraft.net/org/joml/joml/1.10.5/joml-1.10.5.jar"}}, "name": "org.joml:joml:1.10.5"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.2/lwjgl-glfw-3.3.2.jar", "sha1": "757920418805fb90bfebb3d46b1d9e7669fca2eb", "size": 135828, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.2/lwjgl-glfw-3.3.2.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.2"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.2/lwjgl-glfw-3.3.2-natives-linux.jar", "sha1": "0766bb0e8e829598b1c8052fd8173c62af741c52", "size": 115553, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.2/lwjgl-glfw-3.3.2-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.2:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.2/lwjgl-glfw-3.3.2-natives-macos.jar", "sha1": "8223ba1b757c43624f03b09ab9d9228c7f6db001", "size": 140627, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.2/lwjgl-glfw-3.3.2-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.2:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.2/lwjgl-glfw-3.3.2-natives-macos-arm64.jar", "sha1": "7e35644de1bf324ca9b7d52acd6e0b8d9a6da4ad", "size": 137535, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.2/lwjgl-glfw-3.3.2-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.2:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.2/lwjgl-glfw-3.3.2-natives-windows.jar", "sha1": "01251e3cb7e5d6159334cfb9244f789ce992f03b", "size": 165947, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.2/lwjgl-glfw-3.3.2-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.2:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.2/lwjgl-glfw-3.3.2-natives-windows-arm64.jar", "sha1": "e79c4857a887bd79ba78bdf2d422a7d333028a2d", "size": 141892, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.2/lwjgl-glfw-3.3.2-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.2:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.2/lwjgl-glfw-3.3.2-natives-windows-x86.jar", "sha1": "17e1f9ec031ef72c2f7825c38eeb3a79c4d8bb17", "size": 156550, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.2/lwjgl-glfw-3.3.2-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.2:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.2/lwjgl-jemalloc-3.3.2.jar", "sha1": "877e17e39ebcd58a9c956dc3b5b777813de0873a", "size": 43233, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.2/lwjgl-jemalloc-3.3.2.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.2"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.2/lwjgl-jemalloc-3.3.2-natives-linux.jar", "sha1": "2bc33176cdfabf34a63df154b80914e8f433316b", "size": 204677, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.2/lwjgl-jemalloc-3.3.2-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.2:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.2/lwjgl-jemalloc-3.3.2-natives-macos.jar", "sha1": "959ea96ea27cd1ee45943123140fdb55c49e961f", "size": 153695, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.2/lwjgl-jemalloc-3.3.2-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.2:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.2/lwjgl-jemalloc-3.3.2-natives-macos-arm64.jar", "sha1": "fe75e1cad7ac1f7af4a071c4b83e39d11b65a1cc", "size": 142101, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.2/lwjgl-jemalloc-3.3.2-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.2:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.2/lwjgl-jemalloc-3.3.2-natives-windows.jar", "sha1": "db886c1f9e313c3fa2a25543b99ccd250d3f9fb5", "size": 180026, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.2/lwjgl-jemalloc-3.3.2-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.2:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.2/lwjgl-jemalloc-3.3.2-natives-windows-arm64.jar", "sha1": "598790de603c286dbc4068b27829eacc37592786", "size": 152780, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.2/lwjgl-jemalloc-3.3.2-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.2:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.2/lwjgl-jemalloc-3.3.2-natives-windows-x86.jar", "sha1": "9b07558f81a5d54dfaeb861bab3ccc86bb4477c9", "size": 148041, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.2/lwjgl-jemalloc-3.3.2-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.2:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.2/lwjgl-openal-3.3.2.jar", "sha1": "ae5357ed6d934546d3533993ea84c0cfb75eed95", "size": 108230, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.2/lwjgl-openal-3.3.2.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.2"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.2/lwjgl-openal-3.3.2-natives-linux.jar", "sha1": "650981780b8bbfb3ce43657b22ec8e77bfb1f37a", "size": 579919, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.2/lwjgl-openal-3.3.2-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.2:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.2/lwjgl-openal-3.3.2-natives-macos.jar", "sha1": "56fa16d15039142634e3a6d97d638b56679be821", "size": 515159, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.2/lwjgl-openal-3.3.2-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.2:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.2/lwjgl-openal-3.3.2-natives-macos-arm64.jar", "sha1": "7b43d16069bdabc9ca0923ec8756a51c5d61cb75", "size": 467151, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.2/lwjgl-openal-3.3.2-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.2:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.2/lwjgl-openal-3.3.2-natives-windows.jar", "sha1": "e74f299a602192faaf14b917632e4cbbb493c940", "size": 694908, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.2/lwjgl-openal-3.3.2-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.2:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.2/lwjgl-openal-3.3.2-natives-windows-arm64.jar", "sha1": "545ddec7959007a78b6662d616e00dacf00e1c29", "size": 627059, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.2/lwjgl-openal-3.3.2-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.2:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.2/lwjgl-openal-3.3.2-natives-windows-x86.jar", "sha1": "21fcb44d32ccf101017ec939fc740197677557d5", "size": 636273, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.2/lwjgl-openal-3.3.2-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.2:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.2/lwjgl-opengl-3.3.2.jar", "sha1": "ee8e95be0b438602038bc1f02dc5e3d011b1b216", "size": 928871, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.2/lwjgl-opengl-3.3.2.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.2"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.2/lwjgl-opengl-3.3.2-natives-linux.jar", "sha1": "b8368430ef0d91a5acbc6fbfa47b20c3ec083aec", "size": 80464, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.2/lwjgl-opengl-3.3.2-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.2:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.2/lwjgl-opengl-3.3.2-natives-macos.jar", "sha1": "05141389ca737369f317b1288a570534c40db9cf", "size": 41485, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.2/lwjgl-opengl-3.3.2-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.2:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.2/lwjgl-opengl-3.3.2-natives-macos-arm64.jar", "sha1": "baadfd67936dcd7e0334dd3a9cf8dd13a0bcb009", "size": 42490, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.2/lwjgl-opengl-3.3.2-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.2:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.2/lwjgl-opengl-3.3.2-natives-windows.jar", "sha1": "83cd34469d4e0bc335bf74c7f62206530a9480bf", "size": 101530, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.2/lwjgl-opengl-3.3.2-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.2:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.2/lwjgl-opengl-3.3.2-natives-windows-arm64.jar", "sha1": "21df035bf03dbf5001f92291b24dc951da513481", "size": 83132, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.2/lwjgl-opengl-3.3.2-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.2:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.2/lwjgl-opengl-3.3.2-natives-windows-x86.jar", "sha1": "22fa4149159154b24f6c1bd46a342d4958a9fba1", "size": 88610, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.2/lwjgl-opengl-3.3.2-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.2:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.2/lwjgl-stb-3.3.2.jar", "sha1": "a2550795014d622b686e9caac50b14baa87d2c70", "size": 118874, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.2/lwjgl-stb-3.3.2.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.2"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.2/lwjgl-stb-3.3.2-natives-linux.jar", "sha1": "5c987f43b342d722b54970159040af76f1c87403", "size": 231821, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.2/lwjgl-stb-3.3.2-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.2:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.2/lwjgl-stb-3.3.2-natives-macos.jar", "sha1": "61614cb49cee0b95587893a36bd72f63ab815c82", "size": 216457, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.2/lwjgl-stb-3.3.2-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.2:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.2/lwjgl-stb-3.3.2-natives-macos-arm64.jar", "sha1": "db49f0f76e8377520b625c688cd45aacb3dcdc9b", "size": 183630, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.2/lwjgl-stb-3.3.2-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.2:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.2/lwjgl-stb-3.3.2-natives-windows.jar", "sha1": "1c4f4b8353bdb78c5264ab921436f03fc9aa1ba5", "size": 261137, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.2/lwjgl-stb-3.3.2-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.2:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.2/lwjgl-stb-3.3.2-natives-windows-arm64.jar", "sha1": "c29df97c3cca97dc00d34e171936153764c9f78b", "size": 218460, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.2/lwjgl-stb-3.3.2-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.2:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.2/lwjgl-stb-3.3.2-natives-windows-x86.jar", "sha1": "a0de7bde6722fa68d25ba6afbd7395508c53c730", "size": 227583, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.2/lwjgl-stb-3.3.2-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.2:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.2/lwjgl-tinyfd-3.3.2.jar", "sha1": "9f65c248dd77934105274fcf8351abb75b34327c", "size": 13404, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.2/lwjgl-tinyfd-3.3.2.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.2"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.2/lwjgl-tinyfd-3.3.2-natives-linux.jar", "sha1": "05d27fc67172b3fcd8400d61a7cfb75da881f609", "size": 43961, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.2/lwjgl-tinyfd-3.3.2-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.2:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.2/lwjgl-tinyfd-3.3.2-natives-macos.jar", "sha1": "ce88dcda2fffe5d912de8ea1dabd68490a8f8471", "size": 45096, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.2/lwjgl-tinyfd-3.3.2-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.2:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.2/lwjgl-tinyfd-3.3.2-natives-macos-arm64.jar", "sha1": "0b4aa34d244c75bcbb78d6cdb0b41200348da330", "size": 41812, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.2/lwjgl-tinyfd-3.3.2-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.2:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.2/lwjgl-tinyfd-3.3.2-natives-windows.jar", "sha1": "54a93ed247d20007a6f579355263fdc2c030753a", "size": 130126, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.2/lwjgl-tinyfd-3.3.2-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.2:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.2/lwjgl-tinyfd-3.3.2-natives-windows-arm64.jar", "sha1": "500f5daa3b731ca282d4b90aeafda94c528d3e27", "size": 110758, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.2/lwjgl-tinyfd-3.3.2-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.2:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.2/lwjgl-tinyfd-3.3.2-natives-windows-x86.jar", "sha1": "0c1dfa1c438e0262453e7bf625289540e5cbffb2", "size": 111596, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.2/lwjgl-tinyfd-3.3.2-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.2:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2.jar", "sha1": "4421d94af68e35dcaa31737a6fc59136a1e61b94", "size": 786196, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2.jar"}}, "name": "org.lwjgl:lwjgl:3.3.2"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-linux.jar", "sha1": "767684973f259d97e7dc66a125eb153986f177e7", "size": 114144, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl:3.3.2:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-macos.jar", "sha1": "3256dce7fa36d6b572afa5e5730f532cf987f3bf", "size": 60240, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl:3.3.2:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-macos-arm64.jar", "sha1": "319eea74a8829ce92fd54ce7c684b6b6557c05bb", "size": 48270, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl:3.3.2:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-windows.jar", "sha1": "a55169ced70ffcd15f2162daf4a9c968578f6cd5", "size": 164993, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl:3.3.2:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-windows-arm64.jar", "sha1": "d900e4678449ba97ff46fa64b22e0376bf8cd00e", "size": 133200, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl:3.3.2:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-windows-x86.jar", "sha1": "ed495259b2c8f068794da0ffedfa7ae7c130b3c5", "size": 139365, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.2/lwjgl-3.3.2-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl:3.3.2:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/slf4j/slf4j-api/2.0.7/slf4j-api-2.0.7.jar", "sha1": "41eb7184ea9d556f23e18b5cb99cad1f8581fc00", "size": 63635, "url": "https://libraries.minecraft.net/org/slf4j/slf4j-api/2.0.7/slf4j-api-2.0.7.jar"}}, "name": "org.slf4j:slf4j-api:2.0.7"}], "logging": {"client": {"argument": "-Dlog4j.configurationFile=${path}", "file": {"id": "client-1.12.xml", "sha1": "bd65e7d2e3c237be76cfbef4c2405033d7f91521", "size": 888, "url": "https://piston-data.mojang.com/v1/objects/bd65e7d2e3c237be76cfbef4c2405033d7f91521/client-1.12.xml"}, "type": "log4j2-xml"}}, "mainClass": "net.minecraft.client.main.Main", "minimumLauncherVersion": 21, "releaseTime": "2023-12-07T12:56:20+00:00", "time": "2023-12-07T12:56:20+00:00", "type": "release"} \ No newline at end of file