diff --git a/README.md b/README.md index 3f8bad59..ba75593f 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ support. - Automated, modularized backup creation - Fast, asynchronous, multithreaded task execution - Have synchronized (shared) folders +- Native Minecraft client profiles, server browser and local virtual worlds — see the [launcher guide](./docs/native-launcher.md). - Web-Panel for starting, stopping restarting the server and viewing the last updating results summaries Premium [AutoPlug-Web](https://autoplug.one) features: diff --git a/docs/authentication-lifecycle.md b/docs/authentication-lifecycle.md new file mode 100644 index 00000000..2f2c07a2 --- /dev/null +++ b/docs/authentication-lifecycle.md @@ -0,0 +1,69 @@ +# Microsoft sign-in lifecycle regression + +Based on PR #297 revision `fdc3c95d3ed6454874e0a9bfdb5e474366869ca6` by AntonSelin. +The native launcher/profile/dashboard implementation remains that existing MIT contribution. +This small supplemental patch and regression fixtures were written and reviewed by an AI agent; +no separate human review or human acceptance test is claimed. PR #298 was read for comparison; +none of its source was copied into this patch. No HMCL code or new dependencies are introduced. + +## Defect and behavior + +The modeless sign-in dialog allows another account choice while authentication is pending. +The original service installs the result unconditionally, so sign-in A can overwrite an offline +choice, or replace newer sign-in B if A finishes last. Closing the device dialog does not cancel +the service. Obsolete results can also change the persisted selected account and save credentials. + +Each account intent now reserves a generation before the dashboard queues its worker. New sign-in, +offline selection, dialog Cancel/close and dashboard/service shutdown invalidate the earlier intent +and interrupt its polling worker. Device instructions and account persistence check the generation; +UI callbacks check a separate display generation. A late network result that ignores interruption +cannot commit. A queued older offline worker cannot cancel a newer sign-in. Changed Microsoft app +ID or credential persistence preferences cancel active sign-in. Cancellation does not mark a newer +operation's progress as failed. The original single-argument sign-in/offline service APIs remain usable. + +Interruption stops sleeping polling. It does not guarantee immediate abort of an already executing +OkHttp HTTP call; existing connect/read timeouts still bound that call. The generation check prevents +its late response from changing state. This change does not refactor token-refresh locking or establish +transactional disk rollback if persisting a valid account fails. + +## Reproduction and verification + +`LauncherAuthenticationRaceTest` runs real `LauncherServices` state/persistence operations with a +latched fake `MicrosoftAccountService`. One fixture deliberately ignores interruption, so merely +interrupting a worker cannot satisfy the regression. Tests cover A→offline→A completion, A/B reverse +completion, cancellation, shutdown, delayed device instructions, stale queued sign-in/offline requests, +interruptible polling, changed auth preferences, and a valid current login. Test credentials and paths +are synthetic. All discovery is pointed at an explicit temporary Minecraft directory. + +The baseline uses the original account state logic with only injectable-provider constructor plumbing +and backwards-compatible interface defaults, so the same tests compile. Its authentication methods, +offline method, close method and UI remain unchanged. It fails nine of ten cases, while the current-login +control passes. The patched service passes all ten. + +`DashboardAuthenticationLifecycleTest` creates native Swing windows with a fake `LauncherActions`, +programmatically clicks actual buttons, answers the synthetic device-start confirmation, sends a native +window-closing event, and supplies delayed callbacks. It verifies repeated login selection, window close, +offline selection, and completion progress. It uses no real authentication or browser. Its image artifacts +are Swing renders of live native-peer windows, not photographs or OS screenshots. Service persistence +is tested separately above; the UI fixture is not an end-to-end Microsoft protocol test. + +Typical commands after resolving existing Maven dependencies: + +```sh +mvn -o -Djava.awt.headless=true \ + -Dtest=LauncherAuthenticationRaceTest,MicrosoftAccountServiceTest,DashboardPanelTest test +mvn -o -Djava.awt.headless=false \ + -Dtest=DashboardAuthenticationLifecycleTest,DashboardDisplayableTest test +``` + +The prepared workspace uses a checksum-verified portable JDK 21 and Maven 3.9.11, explicit blank Maven +settings, its own local dependency repository and `user.home`, and no `Main` invocation. Headless tests +run under the restricted environment with Maven offline. Native tests need desktop access; the first +restricted native attempt terminated the JVM (exit 134), then the explicitly scoped approved native run +succeeded. No automatic approval rejection occurred. + +Validation covers Java 9 release compilation, ten service regressions, five existing auth fixtures, +sixteen existing dashboard cases and four native cases. It does not validate real Microsoft login, +Minecraft gameplay/mod compatibility, authenticated server admission, real-router sharing or all #296 +acceptance requirements. No game/server artifacts, EULA acceptance, port mapping or payment action +are involved. This supplemental patch alone is not a complete bounty implementation. diff --git a/docs/derived-verification.md b/docs/derived-verification.md new file mode 100644 index 00000000..55f9711e --- /dev/null +++ b/docs/derived-verification.md @@ -0,0 +1,152 @@ +# Derived authentication, world startup and profile validation fixes + +This derived contribution builds on AntonSelin's PR #297, commit +`fdc3c95d3ed6454874e0a9bfdb5e474366869ca6`, and the repository's MIT license. +ithyme7's PR #298, commit `cb16ea15d4aeb2e9f35ec3f019bc449c204390dd`, was +reviewed for comparable authentication and world cancellation designs. Its +implementation was not copied into this delta. The inherited launcher, +profiles, dashboard, world orchestration and previews remain the original +contributors' work. + +AI coding agents wrote and directly source-reviewed this delta and ran its +automated and native Swing fixture checks. No separate human technical review, +human gameplay test, real Microsoft sign-in or real router validation is claimed. +Issue #296 delegates the final live authenticated join to maintainers. Passing +these checks does not establish maintainer acceptance, merge or bounty payment. + +## Behavior + +- A cancelled or superseded Microsoft sign-in cannot replace the current + account or persisted choice. See [authentication-lifecycle.md](authentication-lifecycle.md). +- `WorldService` registers a pending owner before installation. Stop and service + close invalidate and interrupt it even before a running session exists. + Late installer, readiness, process-factory and client results are checked; + owned processes and locks are cleaned up rather than publishing a late session. + A cancelled but unfinished collaborator retains its ownership until it returns. +- Profiles validate type/loader compatibility at create, save and read. A + PLUGINS collection requires a plugin loader; vanilla server collections use + MODS_SERVER. Client/server mod collections reject plugin loaders. Create and + clone dialogs filter choices using the same rule. Previously saved invalid + metadata produces an explicit error; it is not rewritten or silently discarded. +- The offline client fixture compares filesystem identity rather than literal + working-directory spelling, accommodating macOS `/var` versus `/private/var` + while still rejecting a different launch directory. + +## Relevant new regressions + +`LauncherAuthenticationRaceTest`, `DashboardAuthenticationLifecycleTest`, +`WorldStartupCancellationTest`, `ProfileLoaderCompatibilityTest`, +`DashboardProfileCompatibilityTest`, and `ManagedWorldStartupNativeTest` use +fake auth/installers or original harmless process fixtures. Delayed collaborators +intentionally ignore interruption, so tests require invalidation as well as an +interrupt signal. The native profile dialog persists through actual ProfileStore; +the native Stop buttons call actual WorldService. + +Synthetic EULA flags belong only to temporary test metadata. Real Minecraft +servers are never launched by these tests. Production EULA guards remain in +place. The packaged CLI smoke refuses unaccepted-world launch before installation; +the packaged-wrapper smoke starts original tiny JVM servers without an eula.txt. + +Native tests must run separately with `-Djava.awt.headless=false`; a headless +skip is not a native test pass. Native peer paint images and offscreen previews +show component layout; process/account assertions establish fixture behavior. + +Existing selected suites cover profile isolation and migration, provider errors, +disable summaries, actual MineStat/NBT responses, manifests and loader metadata, +offline client JVM arguments/cache, UPnP local SOAP/forwarder ownership, managed +child cleanup, default utility staging, chooser/history/fullscreen arguments, +progress and UI layout. External-network/SteamCMD/download suites are not included +in the deterministic selection. Runtime compile targets remain Java 9, with the +inherited legacy world helper targeting Java 8. + +## Remaining limits + +In-flight non-cooperative HTTP/installer operations may finish before the caller +can release their resources, but cancellation rejects their late launch results. +Long service monitors in inherited launcher/settings code and server readiness +version verification remain review areas outside this delta. This is not a general +launch parallelism rewrite or a promise of immediate cancellation of every IO call. + +Game/utility-pack play, true save load/preservation in Minecraft, real loader +processors against game artifacts, real Microsoft entitlement/join and router/WAN +reachability still require appropriate acceptance testing. Real publisher JSON +and metadata-only production parser checks do not execute game or loader code. + +## Executed validation + +The AI agent executed these checks on macOS with checksum-verified JDK 21 and +Maven 3.9.11, using isolated data/home directories and Maven dependencies: + +- 210 selected deterministic test cases passed, zero failures/errors/skips. +- 6 native Swing cases passed separately, zero failures/errors/skips. +- Offline Maven packaging passed with Java 9 and legacy-helper Java 8 targets. +- The 12 new world/profile regressions against unmodified #297 behavior produced + 10 assertion failures, one blocked-close timeout, and one passing positive control; + all 12 pass with this delta. The earlier auth baseline fails nine of ten, with + the current-login control passing. Original auth/UI coverage remains included. +- The actual packaged CLI passed invalid-profile guards, version periods, + empty-profile clone/migration, offline selection, world creation and refusal + to launch an unaccepted world before installation. +- The inherited `ManagedWorldPackagedSmoke` started two actual packaged AutoPlug + wrappers around original tiny JVM servers, verifying command isolation, restart, + graceful stop and wrapper exit after the raw server stopped. No game server + binary or real EULA was involved. Native/wrapper checks blocked non-loopback + traffic using a supplemental OS policy and IPv4 loopback configuration. +- Read-only public Mojang/Fabric/Modrinth JSON probes returned HTTP 200; + published Mojang metadata SHA1 values matched. A production metadata-only + parser read 1.21.1 and 26.3 as Java 21 and 25. No game/mod/loader/runtime + artifact was installed or executed by these metadata probes. + +[Per-suite results](verification/derived-results.json) omit raw Maven environment +properties, local paths and credentials. The exact deterministic selector is in +[derived-test-selector.txt](verification/derived-test-selector.txt). +No inherited author test counts are added to these 216 executed cases. + +After preparing the existing Maven dependencies in a reviewed environment: + +```sh +selector=$(cat docs/verification/derived-test-selector.txt) +mvn -o -Djava.awt.headless=true "-Dtest=$selector" package +mvn -o -Djava.awt.headless=false \ + -Dtest=DashboardAuthenticationLifecycleTest,DashboardDisplayableTest,DashboardProfileCompatibilityTest,ManagedWorldStartupNativeTest test +``` + +The native suite requires a desktop; a headless skip must not be reported as a +pass. For packaged offline-world reproduction, run the inherited smoke helper +with the packaged JAR and test classes after reviewing its source: + +```sh +fixture_root=$(mktemp -d) +JAVA_TOOL_OPTIONS="-Duser.home=$fixture_root/home -Djava.net.preferIPv4Stack=true" \ + java -cp target/test-classes:target/AutoPlug-Client.jar \ + com.osiris.autoplug.client.worlds.ManagedWorldPackagedSmoke "$PWD" "$fixture_root/worlds" +``` + +Do not use raw local Maven logs as public evidence: they contain execution paths +and environment properties. No payout address, credentials, game JAR or local +user files are part of this contribution. Full local logs remain available for +private verification; the public summary only reports checks actually executed. + +## Native fixture images + +These are component paints of live native-peer windows with synthetic data, +not human screenshots or gameplay evidence. The auth baseline intentionally +shows the stale-login defect after closing the dialog. + +![Auth baseline after close](images/derived-verification/baseline-after-close.png) +![Auth fixed after close](images/derived-verification/fixed-after-close.png) +![Pending world before Stop](images/derived-verification/world-preparation-before-stop.png) +![Cancelled world after Stop](images/derived-verification/world-preparation-after-stop.png) +![Compatible plugin profile dialog](images/derived-verification/profile-plugin-dialog.png) + +## Submission and acceptance + +This derived draft requests maintainer feedback and final acceptance validation +for [#296](https://github.com/Osiris-Team/AutoPlug-Client/issues/296), including +[the maintainer's final authenticated join arrangement](https://github.com/Osiris-Team/AutoPlug-Client/issues/296#issuecomment-5972762400). +It does not claim all gameplay/UX acceptance is complete or a bounty is earned. +The original author remains active; this is not an abandonment claim. Current +bounty terms and the original contributor's seven-calendar-day grace period, +when initiated by a maintainer for a merge-ready derived PR, remain applicable. +Draft submission alone is not claimed to start that period. No payout information +or profile edit is requested or included. diff --git a/docs/images/derived-verification/baseline-after-close.png b/docs/images/derived-verification/baseline-after-close.png new file mode 100644 index 00000000..1a6878a1 Binary files /dev/null and b/docs/images/derived-verification/baseline-after-close.png differ diff --git a/docs/images/derived-verification/fixed-after-close.png b/docs/images/derived-verification/fixed-after-close.png new file mode 100644 index 00000000..c8b07b04 Binary files /dev/null and b/docs/images/derived-verification/fixed-after-close.png differ diff --git a/docs/images/derived-verification/profile-plugin-dialog.png b/docs/images/derived-verification/profile-plugin-dialog.png new file mode 100644 index 00000000..6045cb07 Binary files /dev/null and b/docs/images/derived-verification/profile-plugin-dialog.png differ diff --git a/docs/images/derived-verification/world-preparation-after-stop.png b/docs/images/derived-verification/world-preparation-after-stop.png new file mode 100644 index 00000000..4c2d5cdd Binary files /dev/null and b/docs/images/derived-verification/world-preparation-after-stop.png differ diff --git a/docs/images/derived-verification/world-preparation-before-stop.png b/docs/images/derived-verification/world-preparation-before-stop.png new file mode 100644 index 00000000..decba23f Binary files /dev/null and b/docs/images/derived-verification/world-preparation-before-stop.png differ diff --git a/docs/images/native-launcher/launch-chooser-compact.png b/docs/images/native-launcher/launch-chooser-compact.png new file mode 100644 index 00000000..37c2acf5 Binary files /dev/null and b/docs/images/native-launcher/launch-chooser-compact.png differ diff --git a/docs/images/native-launcher/launch-chooser-dark.png b/docs/images/native-launcher/launch-chooser-dark.png new file mode 100644 index 00000000..dae9b362 Binary files /dev/null and b/docs/images/native-launcher/launch-chooser-dark.png differ diff --git a/docs/images/native-launcher/launch-chooser.png b/docs/images/native-launcher/launch-chooser.png new file mode 100644 index 00000000..b06b34ff Binary files /dev/null and b/docs/images/native-launcher/launch-chooser.png differ diff --git a/docs/images/native-launcher/managed-console-compact.png b/docs/images/native-launcher/managed-console-compact.png new file mode 100644 index 00000000..d4f2344a Binary files /dev/null and b/docs/images/native-launcher/managed-console-compact.png differ diff --git a/docs/images/native-launcher/managed-console-dark-compact.png b/docs/images/native-launcher/managed-console-dark-compact.png new file mode 100644 index 00000000..0231e450 Binary files /dev/null and b/docs/images/native-launcher/managed-console-dark-compact.png differ diff --git a/docs/images/native-launcher/managed-console.png b/docs/images/native-launcher/managed-console.png new file mode 100644 index 00000000..fbc7ec5c Binary files /dev/null and b/docs/images/native-launcher/managed-console.png differ diff --git a/docs/images/native-launcher/overall-progress-compact.png b/docs/images/native-launcher/overall-progress-compact.png new file mode 100644 index 00000000..6b73e764 Binary files /dev/null and b/docs/images/native-launcher/overall-progress-compact.png differ diff --git a/docs/images/native-launcher/overall-progress-dark.png b/docs/images/native-launcher/overall-progress-dark.png new file mode 100644 index 00000000..e6607bcd Binary files /dev/null and b/docs/images/native-launcher/overall-progress-dark.png differ diff --git a/docs/images/native-launcher/overall-progress.png b/docs/images/native-launcher/overall-progress.png new file mode 100644 index 00000000..46f75d4a Binary files /dev/null and b/docs/images/native-launcher/overall-progress.png differ diff --git a/docs/images/native-launcher/profiles-compact.png b/docs/images/native-launcher/profiles-compact.png new file mode 100644 index 00000000..4824a404 Binary files /dev/null and b/docs/images/native-launcher/profiles-compact.png differ diff --git a/docs/images/native-launcher/profiles-dark.png b/docs/images/native-launcher/profiles-dark.png new file mode 100644 index 00000000..dfe5551f Binary files /dev/null and b/docs/images/native-launcher/profiles-dark.png differ diff --git a/docs/images/native-launcher/profiles.png b/docs/images/native-launcher/profiles.png new file mode 100644 index 00000000..e9176b65 Binary files /dev/null and b/docs/images/native-launcher/profiles.png differ diff --git a/docs/images/native-launcher/server-browser-compact.png b/docs/images/native-launcher/server-browser-compact.png new file mode 100644 index 00000000..c02f43d7 Binary files /dev/null and b/docs/images/native-launcher/server-browser-compact.png differ diff --git a/docs/images/native-launcher/server-browser-dark.png b/docs/images/native-launcher/server-browser-dark.png new file mode 100644 index 00000000..b81a5565 Binary files /dev/null and b/docs/images/native-launcher/server-browser-dark.png differ diff --git a/docs/images/native-launcher/server-browser.png b/docs/images/native-launcher/server-browser.png new file mode 100644 index 00000000..bce582d0 Binary files /dev/null and b/docs/images/native-launcher/server-browser.png differ diff --git a/docs/images/native-launcher/settings-compact.png b/docs/images/native-launcher/settings-compact.png new file mode 100644 index 00000000..3d9af264 Binary files /dev/null and b/docs/images/native-launcher/settings-compact.png differ diff --git a/docs/images/native-launcher/settings-dark.png b/docs/images/native-launcher/settings-dark.png new file mode 100644 index 00000000..394d9959 Binary files /dev/null and b/docs/images/native-launcher/settings-dark.png differ diff --git a/docs/images/native-launcher/settings-fullscreen-compact.png b/docs/images/native-launcher/settings-fullscreen-compact.png new file mode 100644 index 00000000..50bcde13 Binary files /dev/null and b/docs/images/native-launcher/settings-fullscreen-compact.png differ diff --git a/docs/images/native-launcher/settings.png b/docs/images/native-launcher/settings.png new file mode 100644 index 00000000..1574e409 Binary files /dev/null and b/docs/images/native-launcher/settings.png differ diff --git a/docs/images/native-launcher/virtual-worlds.png b/docs/images/native-launcher/virtual-worlds.png new file mode 100644 index 00000000..9c057791 Binary files /dev/null and b/docs/images/native-launcher/virtual-worlds.png differ diff --git a/docs/images/native-launcher/worlds-compact.png b/docs/images/native-launcher/worlds-compact.png new file mode 100644 index 00000000..9b2046e4 Binary files /dev/null and b/docs/images/native-launcher/worlds-compact.png differ diff --git a/docs/images/native-launcher/worlds.png b/docs/images/native-launcher/worlds.png new file mode 100644 index 00000000..139d9cae Binary files /dev/null and b/docs/images/native-launcher/worlds.png differ diff --git a/docs/native-launcher.md b/docs/native-launcher.md new file mode 100644 index 00000000..612b52d7 --- /dev/null +++ b/docs/native-launcher.md @@ -0,0 +1,258 @@ +# Native Minecraft launcher, profiles and worlds + +AutoPlug's desktop dashboard includes **Server Browser**, **Worlds**, **Profiles**, **Server Manager** and **Settings**. The launcher downloads Minecraft metadata, libraries, assets and loader components directly; it does not embed another launcher. The existing server wrapper and updater commands remain available. Light is the default theme: rounded frosted panels sit on a clean off-white canvas, with blue accents for primary actions, selected navigation and focus. Existing dark or Darcula preferences remain supported. + +AutoPlug targets Java 9 or later. Minecraft runs in a separate JVM selected from its version metadata. Settings supports Java 8, 17 and 21 executable overrides and additional `major=path` entries. If no matching runtime is configured or already installed, AutoPlug uses its Adoptium provider to download one. Runtime availability depends on the operating system and architecture. + +## Desktop workflow + +Enable the existing system-tray option in AutoPlug's general configuration and click the tray icon to open the dashboard. Closing the window hides it; it does not stop AutoPlug or its running worlds. When the operating system has no system tray, the dashboard opens as a normal window. + +The header names the current tab, and common actions have icons with text or accessible labels. Long operations run in background workers. An overall progress bar advances while work is active and reaches completion when the operation succeeds. **Overall progress is an estimate**, not a measurement of all remaining work; the tooltip separately reports current-file bytes and percentage when known. The current source URL can be selected and copied, and **Activity details** keeps a copyable history of steps and download sources. Download URLs also appear in logs with sensitive credentials redacted. Settings keeps account and sharing controls visible in two compact columns; **Advanced** expands optional runtime overrides. + +1. **Profiles** prepares three defaults for the configured Minecraft version, initially `1.21.1`: `Default (VANILLA)` client, `Default` Vanilla server, and `Default (FABRIC)` client utilities. Creating these entries is offline; game files and the Fabric utility collection download only when selected for launch. An existing usable default preference is preserved; a template or pending migration is replaced as the launch default without changing that profile. Version-specific defaults are reused rather than retargeting an existing profile. Create additional client profiles with type `MODS`, a Minecraft version and a loader. Vanilla also uses `MODS`, with loader `VANILLA` and an empty collection. +2. Add mod or plugin JARs to the profile's collection directory, available through **Open folder**, or use `.profiles add` below. Provider identities in `collection.json` allow version-aware update checks; `.profiles add --modrinth` records a Modrinth project ID or slug. +3. In **Settings**, select an offline player name or sign in with Microsoft. Minecraft starts fullscreen by default; the **Fullscreen** preference controls subsequent client launches. Configure runtime overrides only if automatic selection is unsuitable. +4. Use **Launch client**, or select a server in **Server Browser** and choose a compatible client profile. If no client profiles exist, the dashboard prepares defaults before continuing. The browser shows Minecraft version, MOTD, player count and latency through AutoPlug's existing status ping. A server's reported version is not proof that every installed mod will be compatible; review the selected loader and collection. + +Server Browser presents compact favorites with a server icon, address, distinct online/offline state, MOTD, player count, reported version and latency. The status ping supplies the server's icon when available: AutoPlug accepts bounded 64 × 64 PNG data and uses its local fallback icon for absent or invalid images. It does not follow arbitrary image URLs. The generic imported name `Minecraft Server` is displayed as the server address. Each entry keeps Join, Ping, Copy address, Edit and Remove nearby. The initial sort is **Last joined**, newest first; search, alternative sorts, reverse order and keyboard navigation remain available. History is recorded after the client process starts for that target, which does not prove that the multiplayer connection succeeded. + +The browser imports favorites from the standard Minecraft `servers.dat` when the dashboard starts. Importing reads the vanilla file and keeps AutoPlug favorites separately; it does not rewrite the vanilla file. Favorites can also be added through the dashboard or CLI. + +Joining a server or launching a world opens a chooser with large profile buttons. AutoPlug remembers a ready profile per target and falls back to an eligible configured default or the world's selected client. When a preferred choice is available, a visible two-second countdown starts it. **Wait, let me select something else…** cancels that countdown and leaves the choices open; selecting another profile uses it immediately. Closing the chooser cancels it. Deleted, template, pending-migration or incompatible choices are not silently reused. Existing singleplayer saves still require a Vanilla profile with their exact recorded version. + +Native client preparation downloads independent libraries, native archives, the client JAR and assets with at most eight workers and a bounded queue. Verified cached files are reused. Retryable connection and HTTP failures get up to three attempts; invalid metadata, file-system failures and cancellation are not retried indefinitely. A failed or interrupted batch cancels outstanding HTTP requests and workers. Loader installers can run their own download processes; their source URLs and output appear in the same activity history. This describes background preparation cancellation, not a separate general-purpose Cancel button in the dashboard. + +### Dashboard previews + +These previews render the actual Swing components with synthetic fixture data at 1200 × 820 and 950 × 620 pixels in light and dark themes. They show layout and controls; they do not demonstrate live Microsoft authentication, server connectivity, gameplay or router sharing. The [reproducible fixture helper and instructions](testing/README.md) include the sample data. Icons, logs and progress are synthetic; the progress preview uses a fixed estimated position, not a download measurement. + +**Profiles:** isolated client and server collections, reusable templates, and migration controls. + +![Profiles dashboard with sample vanilla, Fabric and Paper profiles](images/native-launcher/profiles.png) + +**Worlds:** existing singleplayer saves and separate managed server worlds in one place. Only managed server worlds expose sharing and server EULA controls. + +![Worlds dashboard with fixture singleplayer saves and managed worlds](images/native-launcher/worlds.png) + +**Settings:** default profile, sharing preference and account controls, with runtime overrides under **Advanced**. + +![Settings dashboard with an offline fixture account and empty Microsoft application ID](images/native-launcher/settings.png) + +**Server Browser:** responsive cards show fixture connection states and server details; action icons retain labels or accessible tooltips. + +![Server Browser with fixture favorites and action icons](images/native-launcher/server-browser.png) + +Additional Server Browser previews show the [dark theme](images/native-launcher/server-browser-dark.png) and [compact 950 × 620 layout](images/native-launcher/server-browser-compact.png). Profiles shows the full first-launch utility summary in its [compact layout](images/native-launcher/profiles-compact.png). Settings previews show the [dark theme](images/native-launcher/settings-dark.png), [compact account view](images/native-launcher/settings-compact.png) and [fullscreen/sharing controls after scrolling](images/native-launcher/settings-fullscreen-compact.png). + +**Managed console:** each selected managed world has its own console, command entry and lifecycle controls, shown with sample logs in [light](images/native-launcher/managed-console.png) and [compact dark](images/native-launcher/managed-console-dark-compact.png) layouts. + +**Profile chooser:** large profile buttons and the red countdown-cancel control are shown in [light](images/native-launcher/launch-chooser.png), [dark](images/native-launcher/launch-chooser-dark.png) and [compact](images/native-launcher/launch-chooser-compact.png) component previews. + +**Activity:** the current step stays readable above the overall progress fill, with a copyable source URL below it. See the synthetic [light](images/native-launcher/overall-progress.png), [dark](images/native-launcher/overall-progress-dark.png) and [compact](images/native-launcher/overall-progress-compact.png) previews. + +## Profiles and migration + +| Type | Purpose | Collection folder | +| --- | --- | --- | +| `MODS` | Native Minecraft client, vanilla or modded | `mods/` | +| `PLUGINS` | Dedicated server with plugins | `plugins/` | +| `MODS_SERVER` | Dedicated server with mods, or vanilla server | `mods/` | + +Client loaders are `VANILLA`, `FABRIC`, `QUILT`, `FORGE` and `NEOFORGE`. Virtual-world server installation supports vanilla, Fabric, Quilt, Forge and NeoForge, and plugin servers such as Paper, Spigot and Purpur through AutoPlug's existing server updater. Bukkit selects the Spigot build path. Proxy software such as Velocity or BungeeCord cannot host a virtual world. Versions must exist in the selected provider's metadata; Spigot installation additionally runs BuildTools and requires its build prerequisites, including Git. + +Mark reusable packs as **templates**, then clone them before playing. A clone has its own configuration and collection metadata. Changing its game version or loader marks migration as pending. AutoPlug checks the target version, including downgrades, and shows a summary before applying changes. A pending migration cannot be launched. + +**Check** finds compatible releases without downloading or installing them. **Update** applies the reviewed plan. Downloads are staged and verified before installed JARs are replaced. During a confirmed migration, artifacts without a compatible release or sufficiently precise provider identity become `name.jar.disabled`; they are retained for recovery. A provider/network error is reported separately and blocks application rather than being interpreted as incompatibility. Configuration files are preserved. + +Modrinth and CurseForge identities support target-version checks. A generic GitHub, Spigot or custom update URL may not describe game/loader compatibility, so it cannot by itself establish compatibility during migration. The existing provider integrations still handle ordinary updates where applicable. + +The Profiles tab links to [Modrinth](https://modrinth.com/mods) and [CurseForge](https://www.curseforge.com/minecraft) for browsing additional mods. These open the provider in the browser; downloaded user-selected JARs still belong in the chosen profile's collection. + +### Default Fabric utilities + +The additional Fabric client uses a deliberately small recipe from the requested utility list: **Fabric API, Sodium, Entity Culling, ImmediatelyFast and Mod Menu**. Required provider-declared libraries are included recursively; the current Minecraft 1.21.1 selection also needs **Text Placeholder API**. The preset adds no map, shader pack or gameplay-changing mod and does not require these utilities on the server. A server can still enforce its own mod rules. + +On its first launch, AutoPlug resolves listed stable Fabric releases for the exact Minecraft version. It rejects server-required dependencies, conflicting required versions, declared incompatibilities and server-only artifacts. Optional dependencies are not added. The complete dependency plan is resolved before downloading, and every downloaded JAR is staged, size/checksum checked and inspected for Fabric metadata before any part of the collection is activated. Duplicate dependencies and cycles do not cause repeated installs. + +If a compatible release is absent, the provider fails, a download is invalid, or activation fails, the launch reports the error and leaves no active partial preset. Existing user mods in an uninstalled default are preserved and cause an explicit conflict instead of being overwritten. After a successful install, normal launches do not query the provider or re-seed removed mods; deliberate customization remains intact. Provider identities are retained in `collection.json` so subsequent changes use the ordinary **Check**/**Update** workflow. The Fabric marker and version make default creation idempotent, including after renaming the profile. This recipe does not promise availability for every historical Minecraft release. + +Profiles in use are locked against concurrent update, clone or delete operations. Deleting a profile moves it into recoverable trash; profiles referenced by saved worlds must be detached or their worlds removed first. + +## Commands + +Commands can be entered in AutoPlug's interactive console. They can also be passed directly to the executable JAR: + +```text +java -jar AutoPlug-Client.jar .profiles list +``` + +The examples below show the command portion. Replace `PROFILE_ID`, `CLONE_ID`, `SERVER_PROFILE_ID`, `CLIENT_PROFILE_ID`, `WORLD_ID` and `REGISTERED_CLIENT_ID` with actual values. Creation commands print generated IDs. Quote names and file paths containing spaces. + +```text +.profiles list +.profiles list --type mods-server +.profiles create "Fabric base" 1.20.1 FABRIC --type mods --template +.profiles create "Local server" 1.20.1 PAPER --type plugins +.profiles add PROFILE_ID "path/to/example.jar" --modrinth PROJECT_ID_OR_SLUG +.profiles template PROFILE_ID +.profiles template PROFILE_ID --off +.profiles clone PROFILE_ID 1.20.4 --name "Updated pack" --loader FABRIC +.check mods --profile CLONE_ID +.update mods --profile CLONE_ID --yes +.check plugins --profile SERVER_PROFILE_ID +.update plugins --profile SERVER_PROFILE_ID +.profiles delete PROFILE_ID --yes +``` + +Use `mods` for both `MODS` and `MODS_SERVER`, and `plugins` for `PLUGINS`. A CLI update prints a fresh plan. Migration requires `--yes`, which explicitly authorizes the reported replacements and disabling of unresolved artifacts. Clone also accepts `--yes` to apply its migration immediately; omit it when you want to review first. The examples use `--profile` to select the isolated collection explicitly. + +```text +.mc account offline LocalPlayer +.mc launch CLIENT_PROFILE_ID +.mc launch CLIENT_PROFILE_ID --server example.org:25565 +.mc launch CLIENT_PROFILE_ID --server example.org --port 25566 +.mc servers add "My server" example.org:25565 +.mc servers import +.mc servers list +``` + +Server listing imports vanilla favorites and pings each saved server. Direct `.mc launch` selects the supplied profile; the dashboard provides the profile selection and clone/migration workflow. + +## Microsoft accounts + +Microsoft sign-in requires a **registered public application client ID** configured in Settings. The application must support the device authorization flow for personal Microsoft accounts and be permitted to access the Minecraft services it calls. AutoPlug does not ship a borrowed launcher ID or require a client secret. A missing or unapproved application registration cannot be repaired by entering an account password into AutoPlug. + +Choose **Sign in with Microsoft**, then complete the displayed device-code instructions on Microsoft's verification page. AutoPlug exchanges the resulting authorization through Xbox services, checks Minecraft Java Edition entitlement and retrieves the Minecraft profile. Account policy, entitlement and application-registration failures are reported to the user. + +**Remember Microsoft account on this computer** is optional and off by default. When enabled, account and refresh tokens are stored in the launcher's local `accounts.json` with owner-restricted permissions where the filesystem supports them. This is local credential storage, not an encrypted credential vault. Leave it unchecked for an in-memory session. + +```text +.mc account microsoft REGISTERED_CLIENT_ID +.mc account microsoft REGISTERED_CLIENT_ID --remember +``` + +Use the first command in a running AutoPlug console to keep sign-in within that session. A standalone command exits after sign-in, so a later independent JAR invocation needs either a new sign-in or the explicit `--remember` option. Offline names provide a local test identity; they do not authenticate to Microsoft-backed multiplayer servers. + +## Existing singleplayer saves + +**Worlds** scans `saves/` in the standard Minecraft directory: `%APPDATA%/.minecraft` on Windows, `~/Library/Application Support/minecraft` on macOS, and `~/.minecraft` on Linux. Its bounded `level.dat` reader exposes name, recorded version, modded status, last-played time, enabled datapacks, cheats and hardcore settings. Cards also show a validated `icon.png`, save size and Quick Play eligibility where known. Local saves and managed worlds are ordered by newest known activity, considering recorded last play, creation and file modification times. Size scanning does not follow links and stops at its entry, depth or time budget; an incomplete size is labeled **At least**. Datapack display is capped. Refresh rescans the folder. Discovery does not copy, convert, rename or rewrite saves; malformed or linked entries are skipped with a diagnostic. A problem reading the local saves root does not hide valid managed worlds. + +Choose **Launch** on a singleplayer card to open the original save using Vanilla Minecraft and the account selected in Settings. The recorded game version is preserved, including the exact pre-release or release-candidate build. A save with no recorded version requires you to choose its original version explicitly; AutoPlug never chooses an upgrade automatically. Saves marked as modded must be opened using their original modded setup to preserve their contents. + +When the publisher's version metadata advertises singleplayer Quick Play, AutoPlug passes the save folder name directly to Minecraft. For supported pre-Quick-Play versions with Mojang client mappings (1.14 onward), a bundled Java 8 startup helper resolves the exact version's native world-entry methods. The older releases **1.7.10, 1.8.9, 1.12.2 and 1.13.2** use bundled adapters pinned to the original client JAR's SHA-1; those adapters were checked against original JAR bytecode and MCP mappings. Other pre-mapping builds, or unsupported mapping layouts, fail with an explicit compatibility error. This does not claim support for every historical snapshot. + +The helper schedules Minecraft's normal integrated-world loading flow on the game thread, preserving the original game folder and the save's display name. It does not invent unsupported command-line flags, convert the save to a dedicated server or bypass account authentication. A launch status confirms that the game was started with automatic entry configured; it does not prove that world loading completed. Helper validation failures and timeouts are logged. Launching allows Minecraft to write its normal save, options and log files in the original game directory. Back up important saves before playing. AutoPlug serializes its launches in that shared directory and leaves Minecraft's own world locking in place. + +Preparing a local launch registers a stable `world-` ownership entry under AutoPlug's data root. This entry contains only a reference to the validated original save; it does not move or copy its contents. Repeated launches reuse the same entry and do not add duplicate cards. Existing `local:` command aliases continue to work after the displayed ID becomes `world-`. + +The CLI uses the same discovered IDs and version checks: + +```text +.mc worlds list +.mc worlds launch "local:My World" +.mc worlds launch "local:Older World" --version 1.12.2 +``` + +Use `--version` only to supply a missing original version; an override that differs from a save's recorded version is rejected. Singleplayer saves have no AutoPlug dedicated server, server EULA flag or UPnP sharing action. Leave the world or use **Open to LAN** from Minecraft's own menu. Quitting AutoPlug leaves these local games running so Minecraft can save and exit normally; close them from Minecraft itself. + +## Managed worlds and sharing + +In **Worlds**, create a managed world with a server profile and a playable client profile. Matching defaults are prepared when needed; they do not accept the server EULA. The profiles must use the same game version. Non-Vanilla modded servers also require matching client loaders; a Vanilla server can use a modded client, including the default Fabric utilities. Each world owns a separate save directory, even when multiple worlds use the same server profile. Mod/plugin JARs are materialized from the profile; world-specific configuration and saves remain separate. An existing world icon or server icon is shown as its thumbnail when available, and the bounded save metadata appears after the world has a readable `level.dat`. + +Read and accept the [Minecraft EULA](https://aka.ms/MinecraftEULA) for the world before launching it. AutoPlug does not silently accept it during profile creation or server installation. The CLI flag below records that explicit choice: + +```text +.mc worlds create "Local world" SERVER_PROFILE_ID CLIENT_PROFILE_ID +.mc worlds launch WORLD_ID --accept-eula +.mc worlds list +.mc worlds share WORLD_ID +.mc worlds stop WORLD_ID +``` + +`--accept-eula` is also supported by `worlds create`. After acceptance is recorded, subsequent launches need no flag. `worlds launch WORLD_ID --share` additionally requests sharing, subject to the settings and account requirements below. + +Managed worlds require the packaged `AutoPlug-Client.jar`; running the parent from an IDE classes directory produces an explicit packaging error for this feature. Each world gets a copy of the current AutoPlug JAR under its own `server/autoplug/` directory. That child runs the full legacy server wrapper in the world's isolated working directory, with independent configuration, console input, logs and server process. The wrapper uses the parent's Java 9-or-later runtime; Minecraft uses its separately selected runtime, including Java 8 where needed. The installer's exact argument vector is preserved, including paths with spaces and modern Forge/NeoForge argument files. + +Launching starts the child AutoPlug wrapper and a dedicated server bound to `127.0.0.1`, waits for Minecraft status readiness, then connects the native client to it. The preferred port from Settings is used when available; an occupied port causes selection of another free loopback port. Closing the client sends that world's wrapper `.stop both`, allowing its server-saving shutdown hook to run. Startup failures and application shutdown also clean up owned processes, with a bounded graceful wait before termination if they do not exit. Restart tracks the process generation so a previous server exit cannot stop its live replacement. + +**Server Manager** selects a managed world and exposes **Play / start**, **Stop**, **Restart**, **Back up**, **AutoPlug help**, **Server status**, **Configuration** and **World folder**. Its console accepts one Minecraft or AutoPlug command at a time and displays the latest 64 KiB of that world's output. Refreshing a console or switching worlds does not route commands to the parent's legacy singleton server. Configuration opens that world's `autoplug` folder; backup and other optional legacy features use its own configuration. Backup is initially disabled and can be enabled there. + +The parent owns managed-session lifetime and pinned versions. Child self/Java/server/mod/plugin updaters, recurring update checks and scheduled restarts stay disabled, with comments explaining the policy; use the parent's profile update workflow and the selected world's Restart control. Child startup also disables its own tray, start-on-boot registration, automatic EULA acceptance and crash restart. World-specific keys, backup preferences and unrelated configuration are preserved. Existing authentication checks still apply to optional AutoPlug online services; an unconfigured key does not authorize a remote connection. The local AutoPlug-plugin listener binds loopback and selects a free port independently for each child. These child settings do not change the parent's normal legacy configuration. + +Sharing requires all of the following: + +- A Microsoft-authenticated world session. Offline worlds use local authentication and remain loopback-only; sign in and relaunch before sharing. +- **Use UPnP when I explicitly share a world** enabled in Settings. This option is on by default but does not itself start sharing; a separate explicit **Share** action is required. Turning it off releases existing owned mappings. +- An explicit **Share** action or `--share` launch flag, plus a compatible gateway with a public IPv4 address. + +For sharing, AutoPlug opens a LAN TCP forwarding endpoint to the loopback server and requests a leased UPnP mapping. It reports the external address for friends to use. The Minecraft server itself remains bound to loopback. Closing the world removes the owned forwarding endpoint and mapping; AutoPlug does not overwrite or delete another application's mapping. + +If automatic sharing is unavailable, the local world continues and the result explains the failure. A TCP tunnel can target the displayed `127.0.0.1:port`. Manual router forwarding alone cannot reach a loopback-bound server; it also needs an appropriate LAN forwarding endpoint. AutoPlug does not silently alter firewall rules, and a successful mapping is not proof that every upstream firewall permits the connection. + +Running sessions belong to the AutoPlug process that launched them. Use its dashboard or interactive console for `share` and `stop`; a second standalone JAR invocation cannot control the first process's in-memory sessions. A standalone world launch keeps AutoPlug running until the session ends. Closing only the dashboard window leaves the session running in the tray. + +## Storage and isolation + +The default data root is `~/.autoplug`, where `~` means the operating-system user's home directory. To isolate a test installation, set `-Dautoplug.home=PATH` **before** `-jar` and use the same value for every command. + +| Location under the data root | Contents | +| --- | --- | +| `profiles//` | Profile identity, collection metadata, configuration and mod/plugin collection | +| `worlds//world.json` | World identity, profile references and recorded EULA choice | +| `worlds//server/` | Dedicated server files, world saves and `autoplug-server.log` | +| `worlds//server/autoplug/` | Copied AutoPlug executable, exact server command metadata, and this world's legacy wrapper configuration/logs | +| `worlds/world-/local-world.json` | Metadata-only ownership reference to an existing singleplayer save; its files remain in Minecraft's original `saves/` directory | +| `cache/` | Shared game downloads, libraries, assets, installer artifacts, content-addressed JARs and runtimes | +| `servers.json` | AutoPlug server favorites and last-join history | +| `settings.json` | Launcher preferences, fullscreen/default-profile/account selection, and remembered target/profile choices | +| `accounts.json` | Credentials only when account persistence is explicitly requested | +| `trash/`, `worlds/.trash/` | Recoverable removed profiles/worlds | + +Artifacts use hard links when possible, with symbolic-link or copy fallback. AutoPlug replaces an artifact link when updating instead of overwriting shared bytes. Treat cached JARs and their linked copies as immutable; do not edit their contents in place. Mutable settings and world saves are copied into independent directories. The legacy server's working directory and global updater configuration are not repointed to implement profile operations. + +## Verification and optional live smoke checks + +The focused automated tests use provider fixtures, real local child JVMs, loopback Minecraft status responses and a simulated UPnP SOAP gateway. They exercise profile isolation, migration, three defaults, transactional Fabric dependency installation, bounded world metadata, newest-activity sorting, ownership references and CLI aliases, launch arguments, account exchanges, process shutdown and mapping ownership without using a real account or changing a router. Additional tests cover bounded server icons, persisted join/profile preferences, the chooser countdown and abort, parallel download retries/cancellation, estimated progress, and per-world console routing. Wrapper tests verify configuration/JAR preparation and real isolated fixture-process control; these tests alone do not prove that the full packaged legacy wrapper or Minecraft game has run. + +The final-review automated run reported **189 tests: 188 passed, no failures or errors, and one Windows symbolic-link capability skip**. Java 9 release compilation and Maven packaging passed. The separate packaged-wrapper smoke started two real AutoPlug child instances around fixture JVM servers, verified isolated console commands, restarted one server, stopped it gracefully, and verified that a raw stop of the second server automatically exited its wrapper within 15 seconds. That last check exercises the shutdown hook without holding the server lifecycle lock. No Microsoft sign-in, live multiplayer join or gameplay result is implied by these checks. + +Dashboard tests also cover synchronous layout invalidation while measuring Settings. Settings and world-card stacks use GridBagLayout so wrapped text can invalidate its ancestors without clearing BoxLayout's in-progress measurement arrays. Fixture previews check light and dark layouts at 1200 × 820 and 950 × 620. + +`DashboardDisplayableTest` additionally creates hidden native Swing peers, validates the actual window/scroll-pane hierarchy, and resizes both themes repeatedly across the Settings column breakpoint. It checks Advanced controls, preserved field edits, scroll reachability, world-card bounds and uncaught event-thread exceptions. It requires a desktop environment, skips in headless mode, and never shows a window or contacts game services. This is automated window-lifecycle coverage, not human acceptance testing or interactive gameplay. + +Legacy automatic entry is checked with exact-version mapping fixtures, forked contract clients and original client-bytecode inspection. These checks verify method contracts and scheduling but do not establish successful interactive Minecraft gameplay. Full game loading and play were not verified in the implementation environment; unsupported historical builds are not presented as tested. + +From a checkout with Maven and a suitable JDK, run: + +```text +mvn "-Dtest=ProfileStoreTest,ProfileUpdatesTest,ProviderCompatibilityTest,LauncherDefaultsTest,FabricDefaultProfileTest,LauncherPreferencesTest,LauncherCommandsTest,LocalClientLifecycleTest,WorldActivityOrderingTest,MinecraftLauncherTest,LegacyWorldLaunchTest,DownloadProgressTest,ParallelDownloadsTest,LegacyDownloadProgressTest,ModrinthArtifactIdentityTest,MicrosoftAccountServiceTest,JavaRuntimeManagerTest,WorldStoreTest,LocalWorldStoreTest,WorldServiceTest,AutoPlugWorldBootstrapTest,ServerProcessObservationTest,MinecraftServerInstallerTest,UpnpSharingServiceTest,ServerBrowserTest,ServerFavoriteHistoryTest,ServerIconTest,ServerStatusIconIntegrationTest,MineStatJsonTest,DashboardPanelTest,DashboardServerRowsTest,LaunchChoicePanelTest,OverallProgressTest,ManagedWorldPanelTest" test +mvn -DskipTests package +``` + +On a desktop host, run the additional window-lifecycle check explicitly: + +```text +mvn -Djava.awt.headless=false -Dtest=DashboardDisplayableTest test +``` + +The dependency-inclusive executable is `target/AutoPlug-Client.jar`. After packaging, the standalone `ManagedWorldPackagedSmoke` test helper can exercise actual packaged AutoPlug child instances around offline fixture servers. From the checkout on Windows, replace both paths below, using a fresh scratch directory: + +```text +java -cp "target/AutoPlug-Client.jar;target/test-classes" com.osiris.autoplug.client.worlds.ManagedWorldPackagedSmoke "C:\path\to\AutoPlug-Client" "C:\path\to\fresh-smoke-directory" +``` + +Use `:` instead of `;` between the classpath entries on Linux or macOS and supply absolute paths for those systems. The helper creates two isolated wrapper copies, checks readiness through loopback status responses, compares fixture PIDs with owned descendants, routes commands to the correct console, restarts one server and stops both gracefully. It also checks retained managed-policy comments. The fixture writes its own PID/command evidence into the scratch directory. It does not load Minecraft saves, accept the Minecraft EULA, authenticate an account or contact an external server. A passing result establishes the packaged wrapper lifecycle, not gameplay. + +A live download/client smoke check is optional and contacts the official game/runtime providers; it may download substantial assets and opens a Minecraft window: + +```text +java -Dautoplug.home=./launcher-smoke -jar target/AutoPlug-Client.jar .profiles create "Vanilla smoke" 1.20.1 VANILLA --type mods +java -Dautoplug.home=./launcher-smoke -jar target/AutoPlug-Client.jar .mc launch CLIENT_PROFILE_ID +``` + +Replace `CLIENT_PROFILE_ID` with the ID from the first command. Check that the selected runtime starts and the client reaches its title screen, then close it. To test Fabric, Quilt, Forge or NeoForge, create a separate profile with that loader and a version the provider supports. A successful metadata download or fixture test alone does not establish that the actual game started. + +For a world smoke check, create a matching server profile and world in the same isolated data root. Review the EULA, explicitly accept it if appropriate, launch without `--share`, confirm the client joins its local world and verify that closing the client stops the server. Router sharing should be tested separately only by an operator who explicitly enables it. + +Maintainer live validation still includes a registered Microsoft application, real device-code sign-in, entitlement verification, token refresh and a join to an authenticated server. Those outcomes depend on external account/application authorization and are not claimed by fixture-based tests. A real network sharing check also needs a suitable gateway and another client outside the LAN. diff --git a/docs/testing/FinalUiPreview.java b/docs/testing/FinalUiPreview.java new file mode 100644 index 00000000..92704096 --- /dev/null +++ b/docs/testing/FinalUiPreview.java @@ -0,0 +1,164 @@ +package com.osiris.autoplug.client.ui; + +import com.formdev.flatlaf.FlatDarkLaf; +import com.formdev.flatlaf.FlatLightLaf; +import com.osiris.autoplug.client.browser.*; +import com.osiris.autoplug.client.ui.LauncherActions.*; +import javax.imageio.ImageIO; +import javax.swing.*; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.nio.file.*; +import java.util.*; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BooleanSupplier; +import java.util.function.Consumer; + +/** External offscreen renderer. All accounts, logs, icons, states and progress are fixtures. + * No Minecraft launch, downloaded artifact, public server connection or account operation occurs. + */ +public final class FinalUiPreview { + public static void main(String[] args) throws Exception { + if (args.length != 4) throw new IllegalArgumentException("Usage: FinalUiPreview "); + Path output = Paths.get(args[0]).toAbsolutePath(); Files.createDirectories(output); + int width = Integer.parseInt(args[1]), height = Integer.parseInt(args[2]); + Path data = Files.createTempDirectory("autoplug-final-ui-fixture-"); + FixtureActions actions = new FixtureActions(); AtomicReference dashboard = new AtomicReference<>(); + AtomicReference chooser = new AtomicReference<>(); + SwingUtilities.invokeAndWait(() -> { + if ("dark".equalsIgnoreCase(args[3])) FlatDarkLaf.setup(); else FlatLightLaf.setup(); + dashboard.set(new DashboardPanel(actions, new ServerBrowserService(data.resolve("servers.json"), data.resolve("servers.dat")), false)); + }); + DashboardPanel panel = dashboard.get(); + try { + await(() -> ((JProgressBar)find(panel, "activity-progress")).getValue() == 100); + SwingUtilities.invokeAndWait(() -> { + SavedServer newest = new SavedServer("Quiet Cove", "cove.example.invalid", 3000); + SavedServer generic = new SavedServer("Minecraft Server", "skyline.example.invalid:25566", 2000); + SavedServer pending = new SavedServer("Weekend Adventure", "weekend.example.invalid", 1000); + Map states = new LinkedHashMap<>(); + states.put(newest.address, new ServerStatus(true, "Build together, explore new places, and relax.\nWelcome to our community survival world.", "Paper 1.21.1", 12, 40, 767, 38, "SUCCESS", fixtureIcon())); + states.put(generic.address, new ServerStatus(false, "", "", 0, 0, 0, 0, "Could not reach the server. Try again when it is online.")); + panel.displayServers(Arrays.asList(pending, generic, newest), states); + }); + for (String view : new String[]{"Server Browser", "Worlds", "Profiles", "Settings"}) { + SwingUtilities.invokeAndWait(() -> button(panel, view).doClick(0)); + if ("Profiles".equals(view)) { + SwingUtilities.invokeAndWait(() -> table(panel).setRowSelectionInterval(1, 1)); + await(() -> ((JProgressBar)find(panel, "activity-progress")).getValue() == 100); + } + render(panel, output.resolve(view.toLowerCase(Locale.ROOT).replace(' ', '-') + ".png"), width, height); + if ("Settings".equals(view) && width < 1000) { + SwingUtilities.invokeAndWait(() -> { + Container defaults = find(panel, "settings-defaults"); + JViewport viewport = (JViewport)SwingUtilities.getAncestorOfClass(JViewport.class, defaults); + if (viewport != null) viewport.setViewPosition(new Point(0, Math.max(0, defaults.getParent().getY() - 6))); + }); + render(panel, output.resolve("settings-defaults.png"), width, height); + } + } + SwingUtilities.invokeAndWait(() -> { + button(panel, "Server Browser").doClick(0); + ((JTabbedPane)find(panel, "server-browser-tabs")).setSelectedIndex(1); + ((ManagedWorldPanel)find(panel, "managed-world-console")).select("cove"); + }); + await(() -> ((JTextArea)find(panel, "managed-world-output")).getText().contains("Preview fixture")); + render(panel, output.resolve("managed-console.png"), width, height); + SwingUtilities.invokeAndWait(() -> chooser.set(new LaunchChoicePanel(actions.clients(), actions.profiles().get(1), true, + ignored -> { throw new AssertionError("Fixture must never launch a profile"); }, () -> 0L))); + render(chooser.get(), output.resolve("launch-chooser.png"), width < 1000 ? 460 : 610, 350); + SwingUtilities.invokeAndWait(() -> { + button(panel, "Profiles").doClick(0); + panel.importArtifact(actions.profiles().get(1), "fixture-utility.jar", "fixture-project"); + }); + if (!actions.started.await(4, TimeUnit.SECONDS)) throw new AssertionError("Fixture work did not start"); + actions.progress.accept("Preparing utility mods — fixture download 37% · https://example.invalid/fixture-utility.jar"); + await(() -> ((JTextField)find(panel, "download-source")).isVisible()); + // Presentation-only position for this synthetic activity; the real progress estimator is tested separately. + SwingUtilities.invokeAndWait(() -> ((JProgressBar)find(panel, "activity-progress")).setValue(48)); + render(panel, output.resolve("overall-progress.png"), width, height); + System.out.println("Rendered fixture previews: " + output); + } finally { + actions.release.countDown(); + SwingUtilities.invokeAndWait(() -> { if (chooser.get() != null) chooser.get().close(); panel.close(); }); + Files.deleteIfExists(data); // This fixture directory remains empty; never recursively remove data. + } + } + + private static final class FixtureActions implements LauncherActions { + volatile Consumer progress = ignored -> {}; + final CountDownLatch started = new CountDownLatch(1), release = new CountDownLatch(1); + public void onProgress(Consumer listener) { progress = listener == null ? ignored -> {} : listener; } + public List profiles() { return Arrays.asList( + new ProfileInfo("vanilla", "Default (VANILLA)", "1.21.1", "VANILLA", "MODS", "sample-profiles/default-vanilla", false), + new ProfileInfo("fabric", "Default (FABRIC)", "1.21.1", "FABRIC", "MODS", "sample-profiles/default-fabric", false, + "Client utility mods install on first launch: Fabric API, Sodium, Entity Culling, ImmediatelyFast and Mod Menu, with required libraries. Exact-version availability is checked before launch.", true), + new ProfileInfo("server", "Default", "1.21.1", "VANILLA", "MODS_SERVER", "sample-profiles/default-server", false), + new ProfileInfo("friends", "Friends server", "1.21.1", "PAPER", "PLUGINS", "sample-profiles/friends", false), + new ProfileInfo("template", "Exploration base", "1.21.1", "FABRIC", "MODS", "sample-profiles/exploration", true)); } + List clients() { return profiles().subList(0, 2); } + public List worlds() { return Arrays.asList( + new WorldInfo("fresh", "Fresh start", "server", "vanilla", "sample-worlds/fresh-start", "", false, false, "1.21.1", + false, -1, 0, true, Collections.emptyList(), false, false, false), + new WorldInfo("cove", "Quiet Cove", "friends", "fabric", "sample-worlds/quiet-cove", "", true, false, "1.21.1", + true, 1791140400000L, 597688320, true, Arrays.asList("vanilla", "file/terrain"), true, false, false), + new WorldInfo("local:cottage", "Lakeside cottage", "", "", "sample-minecraft/saves/Lakeside cottage", "", false, true, "1.21.1", + false, 1791072000000L, 184549376, true, Collections.singletonList("vanilla"), false, false, true)); } + public SettingsInfo settings() { SettingsInfo settings = new SettingsInfo(); settings.account = "Offline · Alex"; + settings.defaultProfile = "fabric"; settings.fullscreen = true; settings.upnp = true; return settings; } + public String serverLog(String id) { return "[Preview fixture — no live server]\n" + + "[AutoPlug] Isolated instance: Quiet Cove\n[AutoPlug] Configuration: sample-worlds/quiet-cove/autoplug\n" + + "[AutoPlug] Started server process for Minecraft 1.21.1\n[Server] Preparing spawn area\n" + + "[Server] Done (3.842s)! For help, type \"help\"\n[Server] Local bind: 127.0.0.1:25565\n" + + "[AutoPlug] Ready. Sharing is disabled until explicitly requested.\n"; } + public void addArtifact(String profile, String jar, String project) throws Exception { started.countDown(); release.await(); } + } + + private static BufferedImage fixtureIcon() { + BufferedImage image = new BufferedImage(64, 64, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = image.createGraphics(); try { + g.setColor(new Color(0x24577B)); g.fillRect(0, 0, 64, 64); + g.setColor(new Color(0x61B5CD)); g.fillRect(0, 42, 64, 22); + g.setColor(new Color(0xF5D47A)); g.fillRect(43, 8, 12, 12); + g.setColor(new Color(0x39795F)); g.fillPolygon(new int[]{4, 28, 51}, new int[]{45, 10, 45}, 3); + g.setColor(new Color(0xE9EEE9)); g.fillPolygon(new int[]{20, 28, 36}, new int[]{22, 10, 22}, 3); + } finally { g.dispose(); } return image; + } + private static void render(JComponent component, Path destination, int width, int height) throws Exception { + SwingUtilities.invokeAndWait(() -> { + try { + component.setSize(width, height); for (int i = 0; i < 8; i++) layout(component); + BufferedImage bitmap = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + Graphics2D g = bitmap.createGraphics(); try { g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); component.printAll(g); } + finally { g.dispose(); } + if (!ImageIO.write(bitmap, "png", destination.toFile())) throw new IllegalStateException("PNG writer missing"); + } catch (Exception e) { throw new RuntimeException(e); } + }); + } + private static void await(BooleanSupplier condition) throws Exception { + AtomicBoolean ready = new AtomicBoolean(); long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (!ready.get() && System.nanoTime() < deadline) { SwingUtilities.invokeAndWait(() -> ready.set(condition.getAsBoolean())); if (!ready.get()) Thread.sleep(20); } + if (!ready.get()) throw new AssertionError("Preview fixture did not settle"); + } + private static Container find(Container parent, String name) { + if (name.equals(parent.getName())) return parent; + for (Component child : parent.getComponents()) if (child instanceof Container) { Container match = find((Container)child, name); if (match != null) return match; } + return null; + } + private static AbstractButton button(Container parent, String text) { + for (Component child : parent.getComponents()) { + if (child instanceof AbstractButton && text.equals(((AbstractButton)child).getText())) return (AbstractButton)child; + if (child instanceof Container) { AbstractButton match = button((Container)child, text); if (match != null) return match; } + } return null; + } + private static JTable table(Container parent) { + for (Component child : parent.getComponents()) { if (child instanceof JTable) return (JTable)child; + if (child instanceof Container) { JTable match = table((Container)child); if (match != null) return match; } } return null; + } + private static void layout(Container parent) { parent.doLayout(); for (Component child : parent.getComponents()) if (child instanceof Container) layout((Container)child); } +} diff --git a/docs/testing/README.md b/docs/testing/README.md new file mode 100644 index 00000000..148765e9 --- /dev/null +++ b/docs/testing/README.md @@ -0,0 +1,19 @@ +# Dashboard fixture previews + +`FinalUiPreview.java` renders the actual Swing dashboard offscreen, using synthetic profiles, worlds, status responses, icons, console logs and progress. It does not connect to public servers, download Minecraft, launch a game, authenticate an account or open a router port. Screenshots demonstrate layout rather than live gameplay. The progress screenshot uses a fixed estimated bar position and a fictional current-file message; it is not a download benchmark. The chooser uses a fixed fixture clock to keep its two-second countdown visible. + +Build the project package first, then run these commands from the repository root using a JDK with `javac` (Java 9 or later): + +```powershell +javac --release 9 -encoding UTF-8 -cp "target/AutoPlug-Client.jar" -d target/ui-preview docs/testing/FinalUiPreview.java +java -Djava.awt.headless=true -cp "target/ui-preview;target/AutoPlug-Client.jar" com.osiris.autoplug.client.ui.FinalUiPreview target/ui-previews/light-1200 1200 820 light +java -Djava.awt.headless=true -cp "target/ui-preview;target/AutoPlug-Client.jar" com.osiris.autoplug.client.ui.FinalUiPreview target/ui-previews/dark-1200 1200 820 dark +java -Djava.awt.headless=true -cp "target/ui-preview;target/AutoPlug-Client.jar" com.osiris.autoplug.client.ui.FinalUiPreview target/ui-previews/light-950 950 620 light +java -Djava.awt.headless=true -cp "target/ui-preview;target/AutoPlug-Client.jar" com.osiris.autoplug.client.ui.FinalUiPreview target/ui-previews/dark-950 950 620 dark +``` + +On Linux or macOS, replace the semicolon in each runtime classpath with a colon. No test classes or additional dependencies are needed with the packaged JAR. Theme colors and fonts can vary slightly by platform. + +Each output directory contains `server-browser.png`, `worlds.png`, `profiles.png`, `settings.png`, `managed-console.png`, `launch-chooser.png` and `overall-progress.png`. Compact runs also capture the scrolled fullscreen/sharing settings as `settings-defaults.png`. The chooser is a standalone component preview at 610 × 350 or 460 × 350; other screenshots use the requested dashboard size. + +The helper uses an empty temporary directory for favorites and removes that exact directory afterward. Profile/world paths, the favicon and all `.invalid` addresses are fixture data. Review generated images before copying them into `docs/images/native-launcher`. diff --git a/docs/verification/derived-results.json b/docs/verification/derived-results.json new file mode 100644 index 00000000..2f8298de --- /dev/null +++ b/docs/verification/derived-results.json @@ -0,0 +1,350 @@ +{ + "fixture": { + "totals": { + "tests": 210, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + "suites": [ + { + "name": "com.osiris.autoplug.client.browser.MineStatJsonTest", + "tests": "3", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "1.422" + }, + { + "name": "com.osiris.autoplug.client.browser.ServerBrowserTest", + "tests": "4", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "0.01" + }, + { + "name": "com.osiris.autoplug.client.browser.ServerFavoriteHistoryTest", + "tests": "2", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "0.008" + }, + { + "name": "com.osiris.autoplug.client.browser.ServerIconTest", + "tests": "2", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "0.023" + }, + { + "name": "com.osiris.autoplug.client.browser.ServerStatusIconIntegrationTest", + "tests": "2", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "0.013" + }, + { + "name": "com.osiris.autoplug.client.launcher.DownloadProgressTest", + "tests": "9", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "0.086" + }, + { + "name": "com.osiris.autoplug.client.launcher.JavaRuntimeManagerTest", + "tests": "4", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "0.016" + }, + { + "name": "com.osiris.autoplug.client.launcher.LegacyWorldLaunchTest", + "tests": "6", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "7.34" + }, + { + "name": "com.osiris.autoplug.client.launcher.MicrosoftAccountServiceTest", + "tests": "5", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "1.182" + }, + { + "name": "com.osiris.autoplug.client.launcher.MinecraftLauncherTest", + "tests": "12", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "0.115" + }, + { + "name": "com.osiris.autoplug.client.launcher.ParallelDownloadsTest", + "tests": "7", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "1.788" + }, + { + "name": "com.osiris.autoplug.client.profiles.FabricDefaultProfileTest", + "tests": "8", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "0.137" + }, + { + "name": "com.osiris.autoplug.client.profiles.LauncherAuthenticationRaceTest", + "tests": "10", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "0.082" + }, + { + "name": "com.osiris.autoplug.client.profiles.LauncherCommandsTest", + "tests": "6", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "0.076" + }, + { + "name": "com.osiris.autoplug.client.profiles.LauncherDefaultsTest", + "tests": "5", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "0.105" + }, + { + "name": "com.osiris.autoplug.client.profiles.LauncherPreferencesTest", + "tests": "5", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "0.069" + }, + { + "name": "com.osiris.autoplug.client.profiles.LocalClientLifecycleTest", + "tests": "2", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "0.54" + }, + { + "name": "com.osiris.autoplug.client.profiles.ProfileLoaderCompatibilityTest", + "tests": "6", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "0.02" + }, + { + "name": "com.osiris.autoplug.client.profiles.ProfileStoreTest", + "tests": "4", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "0.025" + }, + { + "name": "com.osiris.autoplug.client.profiles.ProfileUpdatesTest", + "tests": "8", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "0.07" + }, + { + "name": "com.osiris.autoplug.client.profiles.ProviderCompatibilityTest", + "tests": "2", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "0.007" + }, + { + "name": "com.osiris.autoplug.client.profiles.WorldActivityOrderingTest", + "tests": "2", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "0.025" + }, + { + "name": "com.osiris.autoplug.client.ui.DashboardPanelTest", + "tests": "16", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "1.937" + }, + { + "name": "com.osiris.autoplug.client.ui.DashboardServerRowsTest", + "tests": "3", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "0.86" + }, + { + "name": "com.osiris.autoplug.client.ui.LaunchChoicePanelTest", + "tests": "5", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "0.01" + }, + { + "name": "com.osiris.autoplug.client.ui.ManagedWorldPanelTest", + "tests": "6", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "0.058" + }, + { + "name": "com.osiris.autoplug.client.ui.OverallProgressTest", + "tests": "2", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "0.002" + }, + { + "name": "com.osiris.autoplug.client.worlds.AutoPlugWorldBootstrapTest", + "tests": "4", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "0.095" + }, + { + "name": "com.osiris.autoplug.client.worlds.LocalWorldStoreTest", + "tests": "15", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "0.226" + }, + { + "name": "com.osiris.autoplug.client.worlds.MinecraftServerInstallerTest", + "tests": "2", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "0.036" + }, + { + "name": "com.osiris.autoplug.client.worlds.UpnpSharingServiceTest", + "tests": "5", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "0.057" + }, + { + "name": "com.osiris.autoplug.client.worlds.WorldServiceTest", + "tests": "9", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "2.551" + }, + { + "name": "com.osiris.autoplug.client.worlds.WorldStartupCancellationTest", + "tests": "6", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "0.049" + }, + { + "name": "com.osiris.autoplug.client.worlds.WorldStoreTest", + "tests": "2", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "0.012" + }, + { + "name": "com.osiris.autoplug.client.ServerProcessObservationTest", + "tests": "3", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "0.004" + }, + { + "name": "com.osiris.autoplug.client.tasks.updater.LegacyDownloadProgressTest", + "tests": "13", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "0.113" + }, + { + "name": "com.osiris.autoplug.client.tasks.updater.mods.ModrinthArtifactIdentityTest", + "tests": "5", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "0.029" + } + ] + }, + "native": { + "totals": { + "tests": 6, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + "suites": [ + { + "name": "com.osiris.autoplug.client.ui.DashboardAuthenticationLifecycleTest", + "tests": "3", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "2.483" + }, + { + "name": "com.osiris.autoplug.client.ui.DashboardDisplayableTest", + "tests": "1", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "3.038" + }, + { + "name": "com.osiris.autoplug.client.ui.DashboardProfileCompatibilityTest", + "tests": "1", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "0.246" + }, + { + "name": "com.osiris.autoplug.client.ui.ManagedWorldStartupNativeTest", + "tests": "1", + "failures": "0", + "errors": "0", + "skipped": "0", + "time": "0.138" + } + ] + } +} diff --git a/docs/verification/derived-test-selector.txt b/docs/verification/derived-test-selector.txt new file mode 100644 index 00000000..32cd1fae --- /dev/null +++ b/docs/verification/derived-test-selector.txt @@ -0,0 +1 @@ +MineStatJsonTest,ServerBrowserTest,ServerFavoriteHistoryTest,ServerIconTest,ServerStatusIconIntegrationTest,DownloadProgressTest,JavaRuntimeManagerTest,LegacyWorldLaunchTest,MicrosoftAccountServiceTest,MinecraftLauncherTest,ParallelDownloadsTest,FabricDefaultProfileTest,LauncherAuthenticationRaceTest,LauncherCommandsTest,LauncherDefaultsTest,LauncherPreferencesTest,LocalClientLifecycleTest,ProfileLoaderCompatibilityTest,ProfileStoreTest,ProfileUpdatesTest,ProviderCompatibilityTest,WorldActivityOrderingTest,DashboardPanelTest,DashboardServerRowsTest,LaunchChoicePanelTest,ManagedWorldPanelTest,OverallProgressTest,AutoPlugWorldBootstrapTest,LocalWorldStoreTest,MinecraftServerInstallerTest,UpnpSharingServiceTest,WorldServiceTest,WorldStartupCancellationTest,WorldStoreTest,ServerProcessObservationTest,LegacyDownloadProgressTest,ModrinthArtifactIdentityTest diff --git a/pom.xml b/pom.xml index f0521ae4..9994694c 100644 --- a/pom.xml +++ b/pom.xml @@ -355,6 +355,24 @@ AUTO-GENERATED FILE, CHANGES SHOULD BE DONE IN ./JPM.java or ./src/main/java//JP 9 9 + + + default-compile + com/osiris/autoplug/legacy/** + + + legacy-world-helper + compile + compile + + 8 + 8 + 8 + com/osiris/autoplug/legacy/** + false + + + org.apache.maven.plugins diff --git a/src/main/java/JPM.java b/src/main/java/JPM.java index 499a666a..c2dd5f7b 100644 --- a/src/main/java/JPM.java +++ b/src/main/java/JPM.java @@ -110,6 +110,17 @@ public ThisProject(List args) throws Exception { // Add custom plugins plugins.add(new AutoPlugPropertiesPlugin()); + // Minecraft 1.16.5 runs on Java 8. Only this dependency-free startup + // helper targets Java 8; the launcher itself retains its Java 9 floor. + CompilerPlugin.get.onBeforeToXML(details -> { + details.addExecution("default-compile", null).putConfiguration("excludes exclude", "com/osiris/autoplug/legacy/**"); + Execution helper = details.addExecution("legacy-world-helper", "compile").addGoal("compile"); + helper.putConfiguration("release", "8"); + helper.putConfiguration("source", "8"); + helper.putConfiguration("target", "8"); + helper.putConfiguration("includes include", "com/osiris/autoplug/legacy/**"); + helper.putConfiguration("useIncrementalCompilation", "false"); + }); // Execute build File cwd = new File(System.getProperty("user.dir")); diff --git a/src/main/java/com/osiris/autoplug/client/Main.java b/src/main/java/com/osiris/autoplug/client/Main.java index 4ea6f1fb..3d6d2744 100644 --- a/src/main/java/com/osiris/autoplug/client/Main.java +++ b/src/main/java/com/osiris/autoplug/client/Main.java @@ -39,6 +39,8 @@ import com.osiris.autoplug.client.network.online.ConMain; import com.osiris.autoplug.client.tasks.SSHManager; import com.osiris.autoplug.client.ui.MainWindow; +import com.osiris.autoplug.client.profiles.LauncherEntry; +import com.osiris.autoplug.client.profiles.LauncherServices; import com.osiris.autoplug.client.utils.GD; import static com.osiris.autoplug.client.utils.GD.WORKING_DIR; import com.osiris.autoplug.client.utils.UpdateCheckerThread; @@ -57,6 +59,18 @@ public class Main { //public static NonBlockingPipedInputStream PIPED_IN; public static ConMain CON; public static SSHManager sshManager; + public static LauncherServices LAUNCHER; + + /** One service instance for console commands, startup UI, and later tray enablement. */ + public static synchronized LauncherServices getLauncher() throws Exception { + if (LAUNCHER == null) { + LAUNCHER = new LauncherServices(java.nio.file.Paths.get(System.getProperty("autoplug.home", System.getProperty("user.home") + "/.autoplug")), AL::info); + Runtime.getRuntime().addShutdownHook(new Thread(LAUNCHER::close, "launcher-cleanup")); + } + MainWindow.setLauncherActions(LAUNCHER); + MainWindow.setBrowserService(LAUNCHER.getServers()); + return LAUNCHER; + } public static UpdateCheckerThread UPDATE_CHECKER_THREAD = null; @@ -67,6 +81,16 @@ public class Main { * - test: enables test mode
*/ public static void main(String[] _args) { + try { + if (com.osiris.autoplug.client.worlds.AutoPlugWorldBootstrap.configureIfChild(_args)) _args = new String[]{"skipSystemCheck"}; + } catch (Exception e) { System.err.println("Managed AutoPlug world could not start: " + e.getMessage()); System.exit(2); return; } + + LauncherEntry launcherEntry = new LauncherEntry(); + if (launcherEntry.isLauncherCommand(_args)) { + int result = launcherEntry.run(_args); + if (result != 0) System.exit(result); + return; + } List args = new ArrayList<>(); if (_args != null) @@ -113,6 +137,9 @@ public static void main(String[] _args) { system.checkReadWritePermissions(); system.checkInternetAccess(); system.addShutDownHook(); + } else if (com.osiris.autoplug.client.worlds.AutoPlugWorldBootstrap.isChild()) { + // The parent already prepared the world; retain graceful stop even when skipping network/system prompts. + new SystemChecker().addShutDownHook(); } // Set default SysOut to TeeOutput, for the OnlineConsole @@ -268,6 +295,7 @@ public static void main(String[] _args) { try { if (generalConfig.autoplug_system_tray.asBoolean()) { now = System.currentTimeMillis(); + getLauncher(); new MainWindow(generalConfig); AL.info("Started system-tray GUI, took " + (System.currentTimeMillis() - now) + "ms"); } @@ -276,7 +304,8 @@ public static void main(String[] _args) { } try { - if (generalConfig.autoplug_start_on_boot.asBoolean()) + if (com.osiris.autoplug.client.worlds.AutoPlugWorldBootstrap.isChild()) { /* Parent owns child lifetime; never change global startup registration. */ } + else if (generalConfig.autoplug_start_on_boot.asBoolean()) new UtilsNative().enableStartOnBootIfNeeded(new UtilsJar().getThisJar()); else new UtilsNative().disableStartOnBootIfNeeded(); } catch (Exception e) { @@ -312,16 +341,15 @@ public static void main(String[] _args) { Server.start(); // Execute arguments as commands if existing - String argsString = ""; + StringBuilder argumentCommand = new StringBuilder(); for (String arg : args) { - argsString += arg + " "; - } - if (argsString.contains(".")) { - String[] commands = argsString.split("\\."); // Split by dots - for (String c : commands) { - Commands.execute("." + c); + // Only command tokens begin a new command; version numbers, hosts and paths contain dots too. + if (arg.matches("\\.[A-Za-z].*") && argumentCommand.length() > 0) { + Commands.execute(argumentCommand.toString().trim()); argumentCommand.setLength(0); } + if (argumentCommand.length() > 0 || arg.startsWith(".")) argumentCommand.append(arg).append(' '); } + if (argumentCommand.length() > 0) Commands.execute(argumentCommand.toString().trim()); // We have to keep this main Thread running. // If we don't, the NonBlockingPipedInputStream stops working diff --git a/src/main/java/com/osiris/autoplug/client/Server.java b/src/main/java/com/osiris/autoplug/client/Server.java index 54e57ee8..39f671f5 100644 --- a/src/main/java/com/osiris/autoplug/client/Server.java +++ b/src/main/java/com/osiris/autoplug/client/Server.java @@ -40,13 +40,21 @@ public final class Server { private static final AtomicBoolean isKill = new AtomicBoolean(false); + private static final AtomicBoolean isRestarting = new AtomicBoolean(false); @Nullable public static AsyncInputStream ASYNC_SERVER_IN; - private static Process process; + private static volatile Process process; + // Guarded by Server.class: once exit is chosen, releasing the lock must not admit a new start. + private static boolean autoStopRequested; private static Thread threadServerAliveChecker; private static boolean colorServerLog; public static File getServerExecutable() throws NotLoadedException, YamlReaderException, YamlWriterException, IOException, IllegalKeyException, DuplicateKeyException, IllegalListException { + List managed = com.osiris.autoplug.client.worlds.AutoPlugWorldBootstrap.childCommand(); + if (managed != null) { + int jar = managed.indexOf("-jar"); + return new File(jar >= 0 && jar + 1 < managed.size() ? managed.get(jar + 1) : managed.get(0)); + } File serverExe = null; while (true) { serverExe = new UtilsJar().determineServerJar(); @@ -101,6 +109,7 @@ public static File getServerExecutable() throws NotLoadedException, YamlReaderEx } public static synchronized void start() { + if (autoStopRequested) return; try { try { colorServerLog = new LoggerConfig().color_server_log.asBoolean(); @@ -129,15 +138,17 @@ public static synchronized void start() { } } - public static void restart() { + public static synchronized void restart() { + if (autoStopRequested) return; //Before starting make backups and check for updates AL.info("Restarting server..."); try { + isRestarting.set(true); stop(); start(); } catch (Exception e) { AL.warn(e); - } + } finally { isRestarting.set(false); } } /** @@ -287,9 +298,12 @@ private static void createProcess() throws Exception { // but messes input up, because there are 2 scanners on the same stream. // That's why we pause the current Terminal, which disables the user from entering console commands. // If AutoPlug-Plugin is installed the user can executed AutoPlug commands through in-game or console. - List commands = new UtilsString().splitBySpacesAndQuotes(startCommand); - for (int i = 0; i < commands.size(); i++) { - commands.set(i, commands.get(i).replaceAll("\"", "")); // Processbuilder does not support quotes + List commands = com.osiris.autoplug.client.worlds.AutoPlugWorldBootstrap.childCommand(); + if (commands == null) { + commands = new UtilsString().splitBySpacesAndQuotes(startCommand); + for (int i = 0; i < commands.size(); i++) { + commands.set(i, commands.get(i).replaceAll("\"", "")); // Processbuilder does not support quotes + } } AL.debug(Server.class, "Starting server with commands: " + commands); //TERMINAL.pause(true); @@ -333,32 +347,44 @@ private static void createProcess() throws Exception { if (threadServerAliveChecker == null) { threadServerAliveChecker = new Thread(() -> { try { - boolean lastIsRunningCheck = false; + Process lastObserved = process; + boolean lastIsRunningCheck = lastObserved != null; boolean currentIsRunningCheck; while (true) { Thread.sleep(2000); - currentIsRunningCheck = Server.isRunning(); + Process observed = process; + currentIsRunningCheck = observed != null && observed.isAlive(); + if (observed != lastObserved) { + lastObserved = observed; + lastIsRunningCheck = observed != null; + } if (!currentIsRunningCheck && lastIsRunningCheck) { - AL.info("Server was stopped."); - if (new GeneralConfig().autoplug_auto_stop.asBoolean()) { - AL.info("Stopping AutoPlug too, since 'autoplug-stop' is enabled."); - System.exit(0); - } else { - AL.info("To stop AutoPlug too, enter '.stop both'."); - } - - if (process.exitValue() != 0) { - if (isKill.get()) { - isKill.set(false); + boolean exitAutoPlug = false; + synchronized (Server.class) { + Integer exitCode = observedExitCode(observed); + if (exitCode == null) continue; // A restart replaced this process while we were observing its exit. + AL.info("Server was stopped."); + if (new GeneralConfig().autoplug_auto_stop.asBoolean() && requestAutomaticStop(observed)) { + AL.info("Stopping AutoPlug too, since 'autoplug-stop' is enabled."); + exitAutoPlug = true; } else { - AL.warn("Server crash was detected! Exit-Code should be 0, but is '" + process.exitValue() + "'!"); - if (new GeneralConfig().server_restart_on_crash.asBoolean()) { - AL.info("Restart on crash is enabled, thus the server is restarting..."); - Server.start(); - } + AL.info("To stop AutoPlug too, enter '.stop both'."); } + if (!exitAutoPlug && exitCode != 0) { + if (isKill.get()) { + isKill.set(false); + } else { + AL.warn("Server crash was detected! Exit-Code should be 0, but is '" + exitCode + "'!"); + if (new GeneralConfig().server_restart_on_crash.asBoolean()) { + AL.info("Restart on crash is enabled, thus the server is restarting..."); + Server.start(); + } + } + } } + // Shutdown hooks may call synchronized Server.stop(); never wait for them with its monitor held. + if (exitAutoPlug) { System.exit(0); return; } } lastIsRunningCheck = currentIsRunningCheck; } @@ -370,6 +396,19 @@ private static void createProcess() throws Exception { } } + /** An exit belongs to one process generation; a newly started server must never inherit it. */ + static synchronized Integer observedExitCode(Process observed) { + if (observed == null || observed != process || observed.isAlive() || isRestarting.get()) return null; + return observed.exitValue(); + } + + /** Reserve automatic exit while holding the lifecycle lock; actual JVM exit happens after releasing it. */ + static synchronized boolean requestAutomaticStop(Process observed) { + if (autoStopRequested || observedExitCode(observed) == null) return false; + autoStopRequested = true; + return true; + } + public static String getFileNameWithoutExt(String fileNameWithExt) throws NotLoadedException { return fileNameWithExt.replaceFirst("[.][^.]+$", ""); // Removes the file extension } diff --git a/src/main/java/com/osiris/autoplug/client/browser/SavedServer.java b/src/main/java/com/osiris/autoplug/client/browser/SavedServer.java new file mode 100644 index 00000000..69b18b19 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/browser/SavedServer.java @@ -0,0 +1,16 @@ +package com.osiris.autoplug.client.browser; + +public final class SavedServer { + public final String name, address; + /** Last successful client launch targeting this favorite, in epoch milliseconds. */ + public final long lastJoinedAt; + public SavedServer(String name, String address) { + this(name, address, 0); + } + public SavedServer(String name, String address, long lastJoinedAt) { + this.address = ServerAddress.parse(address).toString(); + this.name = name == null || name.trim().isEmpty() || "Minecraft Server".equalsIgnoreCase(name.trim()) ? this.address : name.trim(); + this.lastJoinedAt = Math.max(0, lastJoinedAt); + } + @Override public String toString() { return name + " (" + address + ")"; } +} diff --git a/src/main/java/com/osiris/autoplug/client/browser/ServerAddress.java b/src/main/java/com/osiris/autoplug/client/browser/ServerAddress.java new file mode 100644 index 00000000..bfd9c9d9 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/browser/ServerAddress.java @@ -0,0 +1,44 @@ +package com.osiris.autoplug.client.browser; + +import java.net.IDN; +import java.util.Locale; + +/** Parses Minecraft addresses without resolving DNS or contacting a server. */ +public final class ServerAddress { + public final String host; + public final int port; + private ServerAddress(String host, int port) { this.host = host; this.port = port; } + + public static ServerAddress parse(String value) { + if (value == null || value.trim().isEmpty()) throw new IllegalArgumentException("Enter a server address."); + String text = value.trim(), host = text; + int port = 25565; + if (text.startsWith("[")) { + int end = text.indexOf(']'); + if (end < 2) throw new IllegalArgumentException("Invalid IPv6 address."); + host = text.substring(1, end); + if (end + 1 < text.length()) { + if (text.charAt(end + 1) != ':') throw new IllegalArgumentException("Invalid server address."); + port = parsePort(text.substring(end + 2)); + } + if (!host.contains(":")) throw new IllegalArgumentException("Brackets are only used for IPv6 addresses."); + } else if (text.indexOf(':') == text.lastIndexOf(':') && text.contains(":")) { + host = text.substring(0, text.indexOf(':')); + port = parsePort(text.substring(text.indexOf(':') + 1)); + } + if (host.isEmpty() || host.length() > 253 || host.chars().anyMatch(c -> Character.isWhitespace(c) || c == '/' || c == '\\' || c == '?' || c == '#' || c == '@')) + throw new IllegalArgumentException("Use a hostname or IP address, optionally followed by :port."); + if (host.contains(":")) { + if (!host.matches("[0-9a-fA-F:.%a-zA-Z_-]+")) throw new IllegalArgumentException("Invalid IPv6 address."); + } else host = IDN.toASCII(host, IDN.USE_STD3_ASCII_RULES); + return new ServerAddress(host.toLowerCase(Locale.ROOT), port); + } + private static int parsePort(String value) { + try { + int port = Integer.parseInt(value); + if (port < 1 || port > 65535) throw new NumberFormatException(); + return port; + } catch (NumberFormatException e) { throw new IllegalArgumentException("The port must be between 1 and 65535."); } + } + @Override public String toString() { return (host.contains(":") ? "[" + host + "]" : host) + (port == 25565 ? "" : ":" + port); } +} diff --git a/src/main/java/com/osiris/autoplug/client/browser/ServerBrowserService.java b/src/main/java/com/osiris/autoplug/client/browser/ServerBrowserService.java new file mode 100644 index 00000000..c1a48490 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/browser/ServerBrowserService.java @@ -0,0 +1,91 @@ +package com.osiris.autoplug.client.browser; + +import com.google.gson.*; +import com.osiris.autoplug.client.utils.MineStat; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; + +/** Local favorites plus an adapter to AutoPlug's existing status ping. */ +public final class ServerBrowserService { + private final Path storage, vanilla; + public ServerBrowserService(Path storage, Path vanilla) { this.storage = storage; this.vanilla = vanilla; } + public static ServerBrowserService defaults() { + Path home = Paths.get(System.getProperty("user.home")); + String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); + Path minecraft; + if (os.contains("win") && System.getenv("APPDATA") != null) minecraft = Paths.get(System.getenv("APPDATA"), ".minecraft"); + else if (os.contains("mac")) minecraft = home.resolve("Library/Application Support/minecraft"); + else minecraft = home.resolve(".minecraft"); + return new ServerBrowserService(home.resolve(".autoplug/servers.json"), minecraft.resolve("servers.dat")); + } + public Path vanillaFile() { return vanilla; } + public synchronized List list() throws IOException { + List result = new ArrayList<>(); + if (!Files.exists(storage)) return result; + if (Files.size(storage) > 4 * 1024 * 1024) throw new IOException("The server favorites file is too large."); + try (Reader reader = Files.newBufferedReader(storage, StandardCharsets.UTF_8)) { + JsonArray array = JsonParser.parseReader(reader).getAsJsonArray(); + for (JsonElement value : array) { + JsonObject entry = value.getAsJsonObject(); + result.add(new SavedServer(entry.get("name").getAsString(), entry.get("address").getAsString(), + entry.has("lastJoinedAt") && !entry.get("lastJoinedAt").isJsonNull() ? entry.get("lastJoinedAt").getAsLong() : 0)); + } + } catch (RuntimeException e) { throw new IOException("Could not read server favorites. The original file was preserved.", e); } + return result; + } + public synchronized void add(String name, String address) throws IOException { + SavedServer added = new SavedServer(name, address); + List servers = list(); + long lastJoined = 0; + for (SavedServer server : servers) if (server.address.equalsIgnoreCase(added.address)) lastJoined = Math.max(lastJoined, server.lastJoinedAt); + servers.removeIf(server -> server.address.equalsIgnoreCase(added.address)); + servers.add(new SavedServer(added.name, added.address, lastJoined)); save(servers); + } + /** Record only after a client starts; a ping or selection must not reorder recent play. */ + public synchronized void recordJoined(String address) throws IOException { + recordJoined(address, System.currentTimeMillis()); + } + synchronized void recordJoined(String address, long at) throws IOException { + String canonical = ServerAddress.parse(address).toString(); + List servers = list(); boolean found = false; + for (int i = 0; i < servers.size(); i++) { + SavedServer server = servers.get(i); + if (server.address.equalsIgnoreCase(canonical)) { + servers.set(i, new SavedServer(server.name, server.address, Math.max(server.lastJoinedAt, at))); found = true; + } + } + if (found) save(servers); // A removed favorite must not be resurrected by a late launch callback. + } + public synchronized void remove(String address) throws IOException { + List servers = list(); servers.removeIf(s -> s.address.equalsIgnoreCase(address)); save(servers); + } + public synchronized int importVanilla() throws IOException { + List servers = list(); + Set existing = new HashSet<>(); + for (SavedServer server : servers) existing.add(server.address.toLowerCase(Locale.ROOT)); + int added = 0; + for (SavedServer server : new VanillaServersReader().read(vanilla)) { + if (existing.add(server.address.toLowerCase(Locale.ROOT))) { servers.add(server); added++; } + } + if (added > 0) save(servers); + return added; + } + private void save(List servers) throws IOException { + Path parent = storage.toAbsolutePath().getParent(); Files.createDirectories(parent); + Path temp = Files.createTempFile(parent, "servers-", ".json.tmp"); + try { + try (Writer writer = Files.newBufferedWriter(temp, StandardCharsets.UTF_8)) { new GsonBuilder().setPrettyPrinting().create().toJson(servers, writer); } + try { Files.move(temp, storage, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } + catch (AtomicMoveNotSupportedException e) { Files.move(temp, storage, StandardCopyOption.REPLACE_EXISTING); } + } finally { Files.deleteIfExists(temp); } + } + public ServerStatus ping(SavedServer server) { + ServerAddress address = ServerAddress.parse(server.address); + MineStat ping = new MineStat(address.host, address.port, 3, MineStat.Request.JSON); + boolean online = ping.pingResult == MineStat.Retval.SUCCESS && ping.isServerUp(); + return new ServerStatus(online, ping.getStrippedMotd(), ping.getVersion(), ping.getCurrentPlayers(), ping.getMaximumPlayers(), + ping.getProtocol(), ping.getLatency(), ping.pingResult.name(), online ? ServerIcon.decode(ping.getFavicon()) : null); + } +} diff --git a/src/main/java/com/osiris/autoplug/client/browser/ServerIcon.java b/src/main/java/com/osiris/autoplug/client/browser/ServerIcon.java new file mode 100644 index 00000000..4248b0ce --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/browser/ServerIcon.java @@ -0,0 +1,37 @@ +package com.osiris.autoplug.client.browser; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.Base64; +import java.util.Iterator; +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.MemoryCacheImageInputStream; + +/** Bounded Minecraft status favicon decoder. Never fetches a URL or creates disk cache files. */ +final class ServerIcon { + private static final String PREFIX = "data:image/png;base64,"; + private static final int MAX_BYTES = 96 * 1024; + private ServerIcon() { } + + static BufferedImage decode(String data) { + if (data == null || !data.startsWith(PREFIX) || data.length() > PREFIX.length() + MAX_BYTES * 4 / 3) return null; + try { + byte[] bytes = Base64.getDecoder().decode(data.substring(PREFIX.length())); + if (bytes.length < 24 || bytes.length > MAX_BYTES) return null; + byte[] signature = {(byte)137, 80, 78, 71, 13, 10, 26, 10}; + for (int i = 0; i < signature.length; i++) if (bytes[i] != signature[i]) return null; + try (MemoryCacheImageInputStream input = new MemoryCacheImageInputStream(new ByteArrayInputStream(bytes))) { + Iterator readers = ImageIO.getImageReadersByFormatName("png"); + if (!readers.hasNext()) return null; + ImageReader reader = readers.next(); + try { + reader.setInput(input, true, true); + if (reader.getWidth(0) != 64 || reader.getHeight(0) != 64) return null; + return reader.read(0); + } finally { reader.dispose(); } + } + } catch (IOException | RuntimeException invalid) { return null; } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/browser/ServerStatus.java b/src/main/java/com/osiris/autoplug/client/browser/ServerStatus.java new file mode 100644 index 00000000..680a226c --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/browser/ServerStatus.java @@ -0,0 +1,20 @@ +package com.osiris.autoplug.client.browser; + +import java.awt.image.BufferedImage; + +public final class ServerStatus { + public final boolean online; + public final String motd, version, message; + public final int players, capacity, protocol; + public final long latency; + /** Validated 64x64 PNG from the status response, decoded off the Swing thread. */ + public final BufferedImage icon; + public ServerStatus(boolean online, String motd, String version, int players, int capacity, int protocol, long latency, String message) { + this(online, motd, version, players, capacity, protocol, latency, message, null); + } + public ServerStatus(boolean online, String motd, String version, int players, int capacity, int protocol, long latency, String message, BufferedImage icon) { + this.online = online; this.motd = motd; this.version = version; this.players = players; + this.capacity = capacity; this.protocol = protocol; this.latency = latency; this.message = message; + this.icon = icon; + } +} diff --git a/src/main/java/com/osiris/autoplug/client/browser/VanillaServersReader.java b/src/main/java/com/osiris/autoplug/client/browser/VanillaServersReader.java new file mode 100644 index 00000000..23967928 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/browser/VanillaServersReader.java @@ -0,0 +1,105 @@ +package com.osiris.autoplug.client.browser; + +import java.io.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.zip.GZIPInputStream; + +/** Small read-only NBT decoder. Vanilla's file and unknown tags are never rewritten. */ +public final class VanillaServersReader { + private static final int MAX_BYTES = 16 * 1024 * 1024, MAX_ITEMS = 10000, MAX_DEPTH = 32; + + public List read(Path file) throws IOException { + if (!Files.exists(file)) return Collections.emptyList(); + Map root = readCompound(file); + List result = new ArrayList<>(); + Object servers = root.get("servers"); + if (!(servers instanceof List)) return result; + for (Object value : (List) servers) { + if (!(value instanceof Map)) continue; + Map entry = (Map) value; + if (!(entry.get("ip") instanceof String)) continue; + try { + result.add(new SavedServer(entry.get("name") instanceof String ? (String) entry.get("name") : "", (String) entry.get("ip"))); + } catch (IllegalArgumentException ignored) { /* An invalid favorite must not hide valid entries. */ } + } + return result; + } + + /** Shared bounded decoder for servers.dat and read-only local level.dat metadata. */ + @SuppressWarnings("unchecked") + public Map readCompound(Path file) throws IOException { + if (Files.size(file) > MAX_BYTES) throw new IOException("NBT file exceeds the 16 MiB import limit."); + try (BufferedInputStream raw = new BufferedInputStream(Files.newInputStream(file))) { + raw.mark(2); + int first = raw.read(), second = raw.read(); + raw.reset(); + InputStream decoded = first == 0x1f && second == 0x8b ? new GZIPInputStream(raw) : raw; + try (DataInputStream input = new DataInputStream(new LimitedStream(decoded))) { + if (input.readUnsignedByte() != 10) throw new IOException("NBT file must contain a compound."); + input.readUTF(); + Map root = (Map) readValue(input, 10, 0, new int[]{100000}); + if (input.read() != -1) throw new IOException("Trailing data in NBT file."); + return root; + } + } + } + + private Object readValue(DataInputStream input, int type, int depth, int[] budget) throws IOException { + if (depth > MAX_DEPTH) throw new IOException("NBT nesting is too deep."); + if (--budget[0] < 0) throw new IOException("NBT contains too many values."); + switch (type) { + case 1: return input.readByte(); + case 2: return input.readShort(); + case 3: return input.readInt(); + case 4: return input.readLong(); + case 5: return input.readFloat(); + case 6: return input.readDouble(); + case 7: skip(input, length(input, MAX_BYTES)); return null; + case 8: return input.readUTF(); + case 9: + int childType = input.readUnsignedByte(), count = length(input, MAX_ITEMS); + if (childType > 12 || (childType == 0 && count != 0)) throw new IOException("Invalid NBT list type."); + List values = new ArrayList<>(count); + for (int i = 0; i < count; i++) values.add(readValue(input, childType, depth + 1, budget)); + return values; + case 10: + Map compound = new LinkedHashMap<>(); + for (int i = 0; i <= MAX_ITEMS; i++) { + int child = input.readUnsignedByte(); + if (child == 0) return compound; + if (i == MAX_ITEMS) throw new IOException("Too many NBT compound entries."); + String name = input.readUTF(); + if (compound.containsKey(name)) throw new IOException("Duplicate NBT compound entry: " + name); + compound.put(name, readValue(input, child, depth + 1, budget)); + } + throw new IOException("Invalid NBT compound."); + case 11: skip(input, length(input, MAX_BYTES / 4) * 4); return null; + case 12: skip(input, length(input, MAX_BYTES / 8) * 8); return null; + default: throw new IOException("Unknown NBT tag type: " + type); + } + } + private int length(DataInputStream in, int limit) throws IOException { + int size = in.readInt(); + if (size < 0 || size > limit) throw new IOException("Invalid NBT collection length."); + return size; + } + private void skip(DataInputStream in, int bytes) throws IOException { + byte[] buffer = new byte[Math.min(bytes, 8192)]; + while (bytes > 0) { int n = Math.min(bytes, buffer.length); in.readFully(buffer, 0, n); bytes -= n; } + } + private static final class LimitedStream extends FilterInputStream { + private long remaining = MAX_BYTES; + LimitedStream(InputStream in) { super(in); } + @Override public int read() throws IOException { + if (remaining <= 0) throw new IOException("Decoded NBT exceeds the import limit."); + int value = in.read(); if (value >= 0) remaining--; return value; + } + @Override public int read(byte[] b, int off, int len) throws IOException { + if (len == 0) return 0; + if (remaining <= 0) throw new IOException("Decoded NBT exceeds the import limit."); + int n = in.read(b, off, (int) Math.min(len, remaining)); if (n > 0) remaining -= n; return n; + } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/configs/GeneralConfig.java b/src/main/java/com/osiris/autoplug/client/configs/GeneralConfig.java index b8f3d60d..92be3bc5 100644 --- a/src/main/java/com/osiris/autoplug/client/configs/GeneralConfig.java +++ b/src/main/java/com/osiris/autoplug/client/configs/GeneralConfig.java @@ -57,7 +57,8 @@ public GeneralConfig(boolean save) throws IOException, DuplicateKeyException, Ya } try { - if (this.autoplug_start_on_boot.asBoolean()) + if (com.osiris.autoplug.client.worlds.AutoPlugWorldBootstrap.isChild()) { /* Parent owns this isolated instance. */ } + else if (this.autoplug_start_on_boot.asBoolean()) new UtilsNative().enableStartOnBootIfNeeded(new UtilsJar().getThisJar()); else new UtilsNative().disableStartOnBootIfNeeded(); } catch (Exception ex) { @@ -67,6 +68,7 @@ public GeneralConfig(boolean save) throws IOException, DuplicateKeyException, Ya try { if (this.autoplug_system_tray.asBoolean()) { boolean firstStart = MainWindow.GET == null; + com.osiris.autoplug.client.Main.getLauncher(); new MainWindow(this); if (firstStart) AL.info("Started system-tray GUI."); } else { @@ -208,6 +210,8 @@ public GeneralConfig(boolean save) throws IOException, DuplicateKeyException, Ya @Override public Yaml validateValues() { + if (com.osiris.autoplug.client.worlds.AutoPlugWorldBootstrap.isChild()) + server_start_command.setComments(com.osiris.autoplug.client.worlds.AutoPlugWorldBootstrap.COMMAND_POLICY); return this; } } diff --git a/src/main/java/com/osiris/autoplug/client/configs/RestarterConfig.java b/src/main/java/com/osiris/autoplug/client/configs/RestarterConfig.java index 3103e819..6bf7848b 100644 --- a/src/main/java/com/osiris/autoplug/client/configs/RestarterConfig.java +++ b/src/main/java/com/osiris/autoplug/client/configs/RestarterConfig.java @@ -128,6 +128,10 @@ public RestarterConfig() throws IOException, DuplicateKeyException, YamlReaderEx @Override public Yaml validateValues() { + if (com.osiris.autoplug.client.worlds.AutoPlugWorldBootstrap.isChild()) { + restarter_enabled.setValues("false").setComments(com.osiris.autoplug.client.worlds.AutoPlugWorldBootstrap.RESTARTER_POLICY); + c_restarter_enabled.setValues("false").setComments(com.osiris.autoplug.client.worlds.AutoPlugWorldBootstrap.RESTARTER_POLICY); + } //Get the config string list //Split each time up into hours and min to validate them diff --git a/src/main/java/com/osiris/autoplug/client/configs/UpdaterConfig.java b/src/main/java/com/osiris/autoplug/client/configs/UpdaterConfig.java index b38a6ac7..74f86216 100644 --- a/src/main/java/com/osiris/autoplug/client/configs/UpdaterConfig.java +++ b/src/main/java/com/osiris/autoplug/client/configs/UpdaterConfig.java @@ -242,6 +242,10 @@ public UpdaterConfig() throws IOException, DuplicateKeyException, YamlReaderExce @Override public Yaml validateValues() { + if (com.osiris.autoplug.client.worlds.AutoPlugWorldBootstrap.isChild()) { + for (YamlSection section : java.util.Arrays.asList(global_recurring_checks, self_updater, java_updater, server_updater, plugins_updater, mods_updater)) + section.setValues("false").setComments(com.osiris.autoplug.client.worlds.AutoPlugWorldBootstrap.UPDATER_POLICY); + } String selfP = self_updater_profile.asString(); String jP = java_updater_profile.asString(); String sP = server_updater_profile.asString(); 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..81118e4f 100644 --- a/src/main/java/com/osiris/autoplug/client/console/Commands.java +++ b/src/main/java/com/osiris/autoplug/client/console/Commands.java @@ -76,6 +76,15 @@ public final class Commands { */ public static boolean execute(@NotNull String command) { + if (command != null && (command.trim().startsWith(".profiles") || command.trim().startsWith(".mc ") + || (command.trim().startsWith(".check ") || command.trim().startsWith(".update ")) && command.contains("--profile"))) { + try { + com.osiris.autoplug.client.profiles.LauncherCommands launcher = new com.osiris.autoplug.client.profiles.LauncherCommands(Main.getLauncher(), AL::info); + launcher.execute(launcher.tokenize(command)); + } catch (Exception e) { AL.warn("Launcher command failed: " + e.getMessage()); } + return true; + } + String first = ""; @@ -97,6 +106,9 @@ public static boolean execute(@NotNull String command) { if (command.equals(".help") || command.equals(".h")) { AL.info(""); AL.info(".help | Prints out this (Shortcut: .h)"); + AL.info(".profiles list/create/clone/delete/template/add | Manage isolated client/server collections"); + AL.info(".check|.update mods|plugins --profile | Review/apply profile updates; migration requires confirmation"); + AL.info(".mc launch [--server host:port] | .mc servers list/import/add | .mc worlds list/create/launch/share/stop"); 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)"); @@ -292,12 +304,12 @@ public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IO new TaskServerUpdater("ServerUpdater", myManager.manager).start(); new UtilsTasks().printResultsWhenDone(myManager.manager); return true; - } else if (command.equals(".check plugins") || command.equals(".cp")) { + } else if (command.equals(".check plugins") || command.equals(".cp") || command.equals(".update plugins")) { MyBThreadManager myManager = new UtilsTasks().createManagerAndPrinter(); new TaskPluginsUpdater("PluginsUpdater", myManager.manager).start(); new UtilsTasks().printResultsWhenDone(myManager.manager); return true; - } else if (command.equals(".check mods") || command.equals(".cm")) { + } else if (command.equals(".check mods") || command.equals(".cm") || command.equals(".update mods")) { MyBThreadManager myManager = new UtilsTasks().createManagerAndPrinter(); new TaskModsUpdater("ModsUpdater", myManager.manager).start(); new UtilsTasks().printResultsWhenDone(myManager.manager); diff --git a/src/main/java/com/osiris/autoplug/client/console/ThreadUserInput.java b/src/main/java/com/osiris/autoplug/client/console/ThreadUserInput.java index 3fd88698..7775c2e7 100644 --- a/src/main/java/com/osiris/autoplug/client/console/ThreadUserInput.java +++ b/src/main/java/com/osiris/autoplug/client/console/ThreadUserInput.java @@ -34,6 +34,18 @@ public ThreadUserInput() { @Override public void run() { super.run(); + if (com.osiris.autoplug.client.worlds.AutoPlugWorldBootstrap.isChild()) { + // Managed instances have piped stdin; a terminal line editor can swallow pipe commands. + try (java.io.BufferedReader input = new java.io.BufferedReader(new java.io.InputStreamReader(System.in, java.nio.charset.StandardCharsets.UTF_8))) { + String line; + while ((line = input.readLine()) != null) { + for (Consumer listener : onReadLine) listener.accept(line); + if (!Commands.execute(line)) Server.submitCommand(line); + } + } catch (Exception e) { AL.warn(e); } + finally { System.exit(0); } // Parent pipe closed: invoke the normal server-saving shutdown hook. + return; + } try { //Scanner scanner = new Scanner(System.in); // Old LineReader lineReader = LineReaderBuilder.builder() diff --git a/src/main/java/com/osiris/autoplug/client/launcher/AccountStore.java b/src/main/java/com/osiris/autoplug/client/launcher/AccountStore.java new file mode 100644 index 00000000..535cfa5b --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/launcher/AccountStore.java @@ -0,0 +1,80 @@ +package com.osiris.autoplug.client.launcher; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.AclEntry; +import java.nio.file.attribute.AclEntryPermission; +import java.nio.file.attribute.AclEntryType; +import java.nio.file.attribute.AclFileAttributeView; +import java.nio.file.attribute.PosixFilePermission; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; + +/** Optional local account persistence. Call save only when the user chooses to remember an account. */ +public final class AccountStore { + private final Path file; + public AccountStore(Path file) { this.file = file.toAbsolutePath().normalize(); } + public synchronized List list() throws IOException { + if (!Files.exists(file)) return new ArrayList<>(); + List result = new ArrayList<>(); + try { + JsonArray accounts = LauncherFiles.readJson(file).getAsJsonArray("accounts"); + for (JsonElement value : accounts) { + JsonObject a = value.getAsJsonObject(); + result.add(new MinecraftAccount(a.get("username").getAsString(), a.get("uuid").getAsString(), + a.get("accessToken").getAsString(), a.get("refreshToken").getAsString(), + a.get("clientId").getAsString(), a.get("xuid").getAsString(), a.get("offline").getAsBoolean(), + Instant.parse(a.get("expiresAt").getAsString()))); + } + } catch (RuntimeException e) { throw new IOException("Account settings could not be read. Sign in again or restore the settings file.", e); } + return result; + } + public synchronized void save(MinecraftAccount account) throws IOException { + List accounts = list(); + accounts.removeIf(existing -> existing.uuid.equals(account.uuid) && existing.offline == account.offline); + accounts.add(account); + write(accounts); + } + public synchronized void remove(String uuid) throws IOException { + List accounts = list(); + accounts.removeIf(account -> account.uuid.equals(uuid.replace("-", ""))); + write(accounts); + } + private void write(List accounts) throws IOException { + Files.createDirectories(file.getParent()); + JsonArray array = new JsonArray(); + for (MinecraftAccount account : accounts) { + JsonObject value = new JsonObject(); + value.addProperty("username", account.username); value.addProperty("uuid", account.uuid); + value.addProperty("accessToken", account.accessToken); value.addProperty("refreshToken", account.refreshToken); + value.addProperty("clientId", account.clientId); value.addProperty("xuid", account.xuid); + value.addProperty("offline", account.offline); value.addProperty("expiresAt", account.expiresAt.toString()); + array.add(value); + } + JsonObject root = new JsonObject(); root.add("accounts", array); + Path temporary = Files.createTempFile(file.getParent(), ".accounts-", ".tmp"); + try { + restrict(temporary); + Files.write(temporary, root.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)); + LauncherFiles.move(temporary, file); + } finally { Files.deleteIfExists(temporary); } + } + static void restrict(Path path) throws IOException { + if (Files.getFileStore(path).supportsFileAttributeView("posix")) + Files.setPosixFilePermissions(path, EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)); + else { + AclFileAttributeView view = Files.getFileAttributeView(path, AclFileAttributeView.class); + if (view == null) throw new IOException("This filesystem cannot protect account credentials. Choose a local account/profile folder with owner-only file permissions."); + view.setAcl(Collections.singletonList(AclEntry.newBuilder().setType(AclEntryType.ALLOW) + .setPrincipal(Files.getOwner(path)).setPermissions(EnumSet.allOf(AclEntryPermission.class)).build())); + } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/launcher/DownloadProgress.java b/src/main/java/com/osiris/autoplug/client/launcher/DownloadProgress.java new file mode 100644 index 00000000..6e7fb3d8 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/launcher/DownloadProgress.java @@ -0,0 +1,158 @@ +package com.osiris.autoplug.client.launcher; + +import com.osiris.jlib.logger.AL; + +import java.net.URI; +import java.net.URLDecoder; +import java.util.Locale; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Download telemetry shared by launcher workers and the existing updater tasks. */ +public final class DownloadProgress { + private static final CopyOnWriteArrayList> LISTENERS = new CopyOnWriteArrayList<>(); + private static final AtomicLong NEXT_ID = new AtomicLong(); + private static final AtomicInteger ACTIVE = new AtomicInteger(); + private static final Pattern URL = Pattern.compile("https?://[^\\s<>\"']+", Pattern.CASE_INSENSITIVE); + + private DownloadProgress() { } + + /** Events can arrive on parallel download workers. Subscribers must marshal UI work to the EDT. */ + public static AutoCloseable subscribe(Consumer listener) { + LISTENERS.add(listener); + return () -> LISTENERS.remove(listener); + } + + public static Transfer begin(String description, String url) { + Transfer transfer = new Transfer(description, url); + log(transfer.message + " from " + transfer.sourceUrl); + transfer.emit(0, -1, false); + return transfer; + } + + /** Forward URLs printed by official loader subprocesses, which perform their own downloads. */ + public static String installerOutput(String line) { + String safe = sanitizeUrls(line); + Matcher urls = URL.matcher(line); + String lastSource = null; + while (urls.find()) { + lastSource = sourceUrl(urls.group()); + log("Loader source: " + lastSource); + publish(safe, lastSource, -1, -1, false, false, 0); + } + if (lastSource == null) publish(safe, null, -1, -1, false, false, 0); + return safe; + } + + public static String sanitizeUrls(String text) { + Matcher urls = URL.matcher(text == null ? "" : text); + StringBuffer safe = new StringBuffer(); + while (urls.find()) urls.appendReplacement(safe, Matcher.quoteReplacement(sourceUrl(urls.group()))); + urls.appendTail(safe); + return safe.toString(); + } + + /** Retain useful source paths/query filters without exposing signed links or embedded credentials. */ + public static String sourceUrl(String url) { + try { + URI uri = new URI(url); + if (!("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme())) + || uri.getRawAuthority() == null) return "[invalid source URL]"; + String authority = uri.getRawAuthority(); + int credentials = authority.lastIndexOf('@'); + if (credentials >= 0) authority = "[redacted]@" + authority.substring(credentials + 1); + StringBuilder safe = new StringBuilder(uri.getScheme()).append("://").append(authority); + if (uri.getRawPath() != null) safe.append(uri.getRawPath()); + String query = uri.getRawQuery(); + if (query != null) { + safe.append('?'); + String[] parameters = query.split("&", -1); + for (int i = 0; i < parameters.length; i++) { + if (i > 0) safe.append('&'); + String parameter = parameters[i]; + int equals = parameter.indexOf('='); + String name = equals < 0 ? parameter : parameter.substring(0, equals); + String key = URLDecoder.decode(name, "UTF-8").toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", ""); + boolean sensitive = key.contains("token") || key.contains("password") || key.contains("secret") + || key.contains("credential") || key.contains("signature") || key.contains("apikey") + || key.equals("key") || key.equals("auth") || key.equals("authorization") + || key.equals("code") || key.equals("sig") || key.equals("ticket") || key.equals("policy") + || key.equals("jwt") || key.equals("session") || key.equals("sessionid") || key.equals("cookie") + || key.equals("keypairid"); + safe.append(sensitive ? name + "=[redacted]" : parameter); + } + } + // Fragments are not sent to the server and can carry OAuth tokens. + if (uri.getRawFragment() != null) safe.append("#[redacted]"); + return safe.toString(); + } catch (Exception ignored) { return "[invalid source URL]"; } + } + + private static synchronized void publish(String message, String sourceUrl, long downloadedBytes, long totalBytes, + boolean complete, boolean finished, long transferId) { + // Snapshot the count at dispatch, not earlier on another worker, so queued events cannot restore stale counts. + Event event = new Event(message, sourceUrl, downloadedBytes, totalBytes, complete, finished, transferId); + for (Consumer listener : LISTENERS) { + try { listener.accept(event); } + catch (RuntimeException ignored) { /* A closed view must not fail a download. */ } + } + } + + private static void log(String message) { + // Downloads are also used before Main starts the application logger (and by standalone tools). + synchronized (AL.class) { + if (AL.isStarted && AL.STRIPPED_OUT != null) AL.info(message); + else System.out.println(message); + } + } + + public static final class Event { + public final String message, sourceUrl; + public final long downloadedBytes, totalBytes; + /** Stable per-transfer ID; zero denotes installer-owned work with no byte counters. */ + public final long transferId; + public final int activeTransfers; + public final boolean complete, finished; + private Event(String message, String sourceUrl, long downloadedBytes, long totalBytes, boolean complete, boolean finished, long transferId) { + this.message = message; this.sourceUrl = sourceUrl; this.downloadedBytes = downloadedBytes; + this.totalBytes = totalBytes; this.complete = complete; this.finished = finished; + this.transferId = transferId; this.activeTransfers = ACTIVE.get(); + } + } + + public static final class Transfer implements AutoCloseable { + private final String message, sourceUrl; + private final long id = NEXT_ID.incrementAndGet(); + private final AtomicBoolean finished = new AtomicBoolean(); + private long lastUpdate; + private Transfer(String message, String url) { + this.message = sanitizeUrls(message); this.sourceUrl = sourceUrl(url); + ACTIVE.incrementAndGet(); + } + public void update(long downloaded, long total) { + long now = System.nanoTime(); + if (now - lastUpdate >= 150_000_000L) { lastUpdate = now; emit(downloaded, total, false); } + } + public void complete(long downloaded, long total) { + if (finished.compareAndSet(false, true)) { ACTIVE.decrementAndGet(); emit(downloaded, total, true); } + } + @Override public void close() { + if (finished.compareAndSet(false, true)) { ACTIVE.decrementAndGet(); emit(-1, -1, false); } + } + public void redirect(String url) { + String redirected = sourceUrl(url); + if (!redirected.equals(sourceUrl)) { + log("Download redirected to " + redirected); + publish("Download redirected", redirected, 0, -1, false, false, id); + } + } + private void emit(long downloaded, long total, boolean complete) { + publish(message, sourceUrl, downloaded, total, complete, finished.get(), id); + } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/launcher/JavaRuntimeManager.java b/src/main/java/com/osiris/autoplug/client/launcher/JavaRuntimeManager.java new file mode 100644 index 00000000..0241934f --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/launcher/JavaRuntimeManager.java @@ -0,0 +1,195 @@ +package com.osiris.autoplug.client.launcher; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.osiris.autoplug.client.tasks.updater.java.AdoptV3API; +import com.osiris.autoplug.client.utils.UtilsCrypto; +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.stream.Stream; +import java.util.zip.GZIPInputStream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** Version-scoped extension of AutoPlug's Adoptium JRE manager, without changing server settings. */ +public class JavaRuntimeManager { + private final Path runtimeRoot; + private final Map overrides = new HashMap<>(); + + public JavaRuntimeManager(Path runtimeRoot) { this.runtimeRoot = runtimeRoot.toAbsolutePath().normalize(); } + public Path getRuntimeRoot() { return runtimeRoot; } + + /** Configure an existing Java executable. The version is checked when resolved. */ + public synchronized void setRuntime(int major, Path executable) { + if (executable == null) overrides.remove(major); + else overrides.put(major, executable.toAbsolutePath().normalize()); + } + + public synchronized Path resolve(int requiredMajor, Consumer progress) throws Exception { + if (requiredMajor < 8) throw new IOException("Unsupported Java runtime version: " + requiredMajor); + Consumer log = progress == null ? value -> { } : progress; + Path custom = overrides.get(requiredMajor); + if (custom != null) { + if (!Files.isRegularFile(custom) || majorVersion(custom) != requiredMajor) + throw new IOException("Configured runtime is not Java " + requiredMajor + ": " + custom); + return custom; + } + Path current = Paths.get(System.getProperty("java.home"), "bin", executableName()); + if (Files.isRegularFile(current) && majorVersion(current) == requiredMajor) return current; + Path destination = runtimeRoot.resolve("java-" + requiredMajor); + Path cached = findJava(destination, requiredMajor); + if (cached != null) return cached; + // Read the existing server runtime, but never replace it or modify its global configuration. + Path legacy = findJava(Paths.get(System.getProperty("user.dir"), "autoplug", "system", "jre"), requiredMajor); + if (legacy != null) return legacy; + log.accept("Installing Java " + requiredMajor + " using AutoPlug's Adoptium provider"); + AdoptV3API api = new AdoptV3API(); + AdoptV3API.OperatingSystemArchitectureType arch = architecture(); + AdoptV3API.OperatingSystemType os = operatingSystem(); + AtomicReference release = new AtomicReference<>(); + boolean lts = true; + for (boolean onlyLts : new boolean[]{true, false}) { + api.getReleases(arch, false, AdoptV3API.ImageType.JDK, true, onlyLts, os, 50, + AdoptV3API.VendorProjectType.JDK, AdoptV3API.ReleaseType.GENERAL_AVAILABILITY, page -> { + for (JsonElement element : page.getAsJsonArray("versions")) { + JsonObject candidate = element.getAsJsonObject(); + if (candidate.get("major").getAsInt() == requiredMajor) { release.set(candidate); return false; } + } + return true; + }); + if (release.get() != null) { lts = onlyLts; break; } + } + if (release.get() == null) throw new IOException("Adoptium has no supported Java " + requiredMajor + " build for this computer."); + JsonArray details = api.getVersionInformation(release.get().get("semver").getAsString(), arch, false, + AdoptV3API.ImageType.JDK, true, lts, os, 20, AdoptV3API.VendorProjectType.JDK, + AdoptV3API.ReleaseType.GENERAL_AVAILABILITY); + if (details.size() == 0) throw new IOException("No Java " + requiredMajor + " package was returned."); + JsonObject binary = details.get(0).getAsJsonObject().getAsJsonArray("binaries").get(0).getAsJsonObject(); + JsonObject pack = binary.getAsJsonObject("package"); + String checksum = pack.get("checksum").getAsString(); + String name = pack.get("name").getAsString(); + Path archive = LauncherFiles.child(runtimeRoot.resolve("downloads"), name); + if (Files.exists(archive) && !checksum.equalsIgnoreCase(UtilsCrypto.fastSHA256(archive.toFile()))) Files.delete(archive); + LauncherFiles.download(pack.get("link").getAsString(), archive, null, pack.get("size").getAsLong(), log); + if (!checksum.equalsIgnoreCase(UtilsCrypto.fastSHA256(archive.toFile()))) { + Files.deleteIfExists(archive); + throw new IOException("Java runtime checksum mismatch."); + } + Files.createDirectories(runtimeRoot); + Path staging = Files.createTempDirectory(runtimeRoot, ".java-" + requiredMajor + "-"); + try { + extract(archive, staging); + Path executable = findJava(staging, requiredMajor); + if (executable == null) throw new IOException("Downloaded archive does not contain Java " + requiredMajor + "."); + Path relative = staging.relativize(executable); + if (Files.exists(destination)) deleteTree(destination); + LauncherFiles.move(staging, destination); + return destination.resolve(relative); + } finally { if (Files.exists(staging)) deleteTree(staging); } + } + + static int majorVersion(Path java) throws IOException { + Path home = java.getParent().getParent(); + Path release = home.resolve("release"); + if (Files.isRegularFile(release)) { + Properties values = new Properties(); + try (InputStream in = Files.newInputStream(release)) { values.load(in); } + return parseMajor(values.getProperty("JAVA_VERSION", "")); + } + // Some Java 8 distributions place the executable in jre/bin and release in the JDK root. + if (home.getFileName().toString().equals("jre") && Files.isRegularFile(home.getParent().resolve("release"))) + return majorVersion(home.getParent().resolve("bin").resolve(executableName())); + return -1; + } + static int parseMajor(String version) { + String value = version.replace("\"", ""); + if (value.startsWith("1.")) value = value.substring(2); + java.util.regex.Matcher match = java.util.regex.Pattern.compile("^(\\d+)").matcher(value); + return match.find() ? Integer.parseInt(match.group(1)) : -1; + } + private static Path findJava(Path root, int major) throws IOException { + if (!Files.isDirectory(root)) return null; + try (Stream paths = Files.walk(root, 6)) { + for (Path candidate : (Iterable) paths.filter(p -> p.getFileName().toString().equals(executableName()))::iterator) + if (Files.isRegularFile(candidate) && majorVersion(candidate) == major) return candidate; + } + return null; + } + static String executableName() { return System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("win") ? "java.exe" : "java"; } + private static AdoptV3API.OperatingSystemType operatingSystem() throws IOException { + String name = System.getProperty("os.name").toLowerCase(Locale.ROOT); + if (name.contains("win")) return AdoptV3API.OperatingSystemType.WINDOWS; + if (name.contains("mac")) return AdoptV3API.OperatingSystemType.MAC; + if (name.contains("linux")) return AdoptV3API.OperatingSystemType.LINUX; + throw new IOException("Automatic Java installation is unavailable on " + name + "; configure an existing runtime."); + } + private static AdoptV3API.OperatingSystemArchitectureType architecture() throws IOException { + String arch = System.getProperty("os.arch").toLowerCase(Locale.ROOT); + if (arch.equals("amd64") || arch.equals("x86_64")) return AdoptV3API.OperatingSystemArchitectureType.X64; + if (arch.equals("aarch64") || arch.equals("arm64")) return AdoptV3API.OperatingSystemArchitectureType.AARCH64; + if (arch.equals("x86") || arch.equals("i386")) return AdoptV3API.OperatingSystemArchitectureType.X86; + for (AdoptV3API.OperatingSystemArchitectureType value : AdoptV3API.OperatingSystemArchitectureType.values()) + if (value.name().equalsIgnoreCase(arch)) return value; + throw new IOException("Unsupported Java architecture " + arch + "; configure an existing runtime."); + } + static void extract(Path archive, Path target) throws IOException { + Files.createDirectories(target); + if (archive.getFileName().toString().endsWith(".zip")) { + try (ZipInputStream zip = new ZipInputStream(Files.newInputStream(archive))) { + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + Path destination = LauncherFiles.child(target, entry.getName()); + if (entry.isDirectory()) Files.createDirectories(destination); + else { Files.createDirectories(destination.getParent()); Files.copy(zip, destination, StandardCopyOption.REPLACE_EXISTING); } + } + } + } else { + List links = new ArrayList<>(); + try (TarArchiveInputStream tar = new TarArchiveInputStream(new GZIPInputStream(Files.newInputStream(archive)))) { + TarArchiveEntry entry; + while ((entry = tar.getNextTarEntry()) != null) { + Path destination = LauncherFiles.child(target, entry.getName()); + if (entry.isDirectory()) Files.createDirectories(destination); + else if (entry.isSymbolicLink() || entry.isLink()) { + Path linkTarget = entry.isLink() ? LauncherFiles.child(target, entry.getLinkName()) + : destination.getParent().resolve(entry.getLinkName()).normalize(); + if (!linkTarget.startsWith(target.toAbsolutePath().normalize())) throw new IOException("Unsafe runtime archive link."); + links.add(new Path[]{destination, linkTarget}); + } else if (entry.isFile()) { + Files.createDirectories(destination.getParent()); + Files.copy(tar, destination, StandardCopyOption.REPLACE_EXISTING); + if ((entry.getMode() & 0111) != 0) destination.toFile().setExecutable(true, false); + } + } + } + for (Path[] link : links) { + Files.createDirectories(link[0].getParent()); + Files.createSymbolicLink(link[0], link[0].getParent().relativize(link[1])); + } + } + } + private static void deleteTree(Path root) throws IOException { + try (Stream stream = Files.walk(root)) { + Path[] paths = stream.sorted(Comparator.reverseOrder()).toArray(Path[]::new); + for (Path path : paths) Files.deleteIfExists(path); + } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/launcher/LaunchRequest.java b/src/main/java/com/osiris/autoplug/client/launcher/LaunchRequest.java new file mode 100644 index 00000000..76350d85 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/launcher/LaunchRequest.java @@ -0,0 +1,69 @@ +package com.osiris.autoplug.client.launcher; + +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Locale; +import java.util.Objects; + +/** Profile-scoped inputs; no launcher operation changes user.dir or the global server. */ +public final class LaunchRequest { + public final Path gameDir; + public final String version; + public final String loader; + public final String loaderVersion; + public final MinecraftAccount account; + public final String serverHost; + public final int serverPort; + public final String singleplayerWorld; + public final boolean fullscreen; + + public LaunchRequest(Path gameDir, String version, String loader, String loaderVersion, + MinecraftAccount account, String serverHost, int serverPort) { + this(gameDir, version, loader, loaderVersion, account, serverHost, serverPort, null); + } + + public LaunchRequest(Path gameDir, String version, String loader, String loaderVersion, + MinecraftAccount account, String serverHost, int serverPort, String singleplayerWorld) { + this(gameDir, version, loader, loaderVersion, account, serverHost, serverPort, singleplayerWorld, true); + } + + public LaunchRequest(Path gameDir, String version, String loader, String loaderVersion, + MinecraftAccount account, String serverHost, int serverPort, boolean fullscreen) { + this(gameDir, version, loader, loaderVersion, account, serverHost, serverPort, null, fullscreen); + } + + public LaunchRequest(Path gameDir, String version, String loader, String loaderVersion, + MinecraftAccount account, String serverHost, int serverPort, String singleplayerWorld, boolean fullscreen) { + this.gameDir = Objects.requireNonNull(gameDir, "gameDir").toAbsolutePath().normalize(); + this.version = safeVersion(version); + this.loader = loader == null ? "VANILLA" : loader.toUpperCase(Locale.ROOT); + if (!Arrays.asList("VANILLA", "FABRIC", "QUILT", "FORGE", "NEOFORGE").contains(this.loader)) + throw new IllegalArgumentException("Unsupported client loader: " + loader); + this.loaderVersion = loaderVersion == null || loaderVersion.trim().isEmpty() ? null : safeVersion(loaderVersion); + this.account = Objects.requireNonNull(account, "account"); + this.serverHost = serverHost == null || serverHost.trim().isEmpty() ? null : serverHost.trim(); + if (this.serverHost != null && (serverPort < 1 || serverPort > 65535)) + throw new IllegalArgumentException("Server port must be between 1 and 65535."); + if (this.serverHost != null && (this.serverHost.contains("\n") || this.serverHost.contains("\r"))) + throw new IllegalArgumentException("Invalid server address."); + this.serverPort = serverPort; + if (singleplayerWorld != null && (singleplayerWorld.isEmpty() || singleplayerWorld.equals(".") || singleplayerWorld.equals("..") + || singleplayerWorld.indexOf('/') >= 0 || singleplayerWorld.indexOf('\\') >= 0 || singleplayerWorld.indexOf(':') >= 0 + || singleplayerWorld.chars().anyMatch(Character::isISOControl))) + throw new IllegalArgumentException("Singleplayer world must be a save directory name."); + if (singleplayerWorld != null && this.serverHost != null) + throw new IllegalArgumentException("Choose either a local world or a multiplayer server."); + this.singleplayerWorld = singleplayerWorld; + this.fullscreen = fullscreen; + } + + static String safeVersion(String value) { + if (value == null || value.isEmpty() || value.equals(".") || value.contains("..") + || !value.matches("[A-Za-z0-9._+ -]+")) + throw new IllegalArgumentException("Invalid Minecraft or loader version: " + value); + return value; + } + public Path getGameDir() { return gameDir; } + public String getVersion() { return version; } + public String getLoader() { return loader; } +} diff --git a/src/main/java/com/osiris/autoplug/client/launcher/LauncherFiles.java b/src/main/java/com/osiris/autoplug/client/launcher/LauncherFiles.java new file mode 100644 index 00000000..5f7efe51 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/launcher/LauncherFiles.java @@ -0,0 +1,146 @@ +package com.osiris.autoplug.client.launcher; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.osiris.autoplug.client.utils.UtilsCrypto; +import okhttp3.OkHttpClient; +import okhttp3.Call; +import okhttp3.Request; +import okhttp3.Response; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +/** Shared, verified cache downloads using the application's existing HTTP and hash libraries. */ +final class LauncherFiles { + static final OkHttpClient HTTP = new OkHttpClient.Builder().connectTimeout(20, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS).followRedirects(true).build(); + private static final Object[] DOWNLOAD_LOCKS = new Object[64]; + static { for (int i = 0; i < DOWNLOAD_LOCKS.length; i++) DOWNLOAD_LOCKS[i] = new Object(); } + + private LauncherFiles() { } + static JsonObject json(String url) throws IOException { + try { return JsonParser.parseString(text(url)).getAsJsonObject(); } + catch (RuntimeException e) { throw new IOException("Invalid JSON metadata from " + DownloadProgress.sourceUrl(url), e); } + } + static String text(String url) throws IOException { + Call call = HTTP.newCall(new Request.Builder().url(url).header("User-Agent", "AutoPlug/10 native-launcher").build()); + try (DownloadProgress.Transfer transfer = DownloadProgress.begin("Fetching metadata", url); + ParallelDownloads.TrackedCall tracked = ParallelDownloads.track(call); + Response response = call.execute()) { + transfer.redirect(response.request().url().toString()); + if (!response.isSuccessful() || response.body() == null) throw new IOException("HTTP " + response.code() + " fetching " + DownloadProgress.sourceUrl(url)); + if (response.body().contentLength() > 32 * 1024 * 1024) throw new IOException("Metadata too large: " + DownloadProgress.sourceUrl(url)); + try (InputStream in = response.body().byteStream(); java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream()) { + byte[] buffer = new byte[8192]; int count; long total = 0; + while ((count = in.read(buffer)) != -1) { + if (Thread.currentThread().isInterrupted()) throw new java.io.InterruptedIOException("Download cancelled."); + total += count; + if (total > 32 * 1024 * 1024) throw new IOException("Metadata too large: " + DownloadProgress.sourceUrl(url)); + out.write(buffer, 0, count); transfer.update(total, response.body().contentLength()); + } + transfer.complete(total, response.body().contentLength()); + return new String(out.toByteArray(), StandardCharsets.UTF_8); + } + } + } + static Path child(Path root, String relative) throws IOException { + Path absolute = root.toAbsolutePath().normalize(); + Path result = absolute.resolve(relative).normalize(); + if (!result.startsWith(absolute) || result.equals(absolute)) throw new IOException("Invalid cache path: " + relative); + return result; + } + static void move(Path source, Path target) throws IOException { + try { Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } + catch (AtomicMoveNotSupportedException e) { Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); } + } + static void writeJson(Path path, JsonObject json) throws IOException { + Files.createDirectories(path.getParent()); + Path temporary = Files.createTempFile(path.getParent(), ".metadata-", ".tmp"); + try { Files.write(temporary, json.toString().getBytes(StandardCharsets.UTF_8)); move(temporary, path); } + finally { Files.deleteIfExists(temporary); } + } + static JsonObject readJson(Path path) throws IOException { + try { return JsonParser.parseString(new String(Files.readAllBytes(path), StandardCharsets.UTF_8)).getAsJsonObject(); } + catch (RuntimeException e) { throw new IOException("Invalid cached metadata: " + path, e); } + } + static boolean valid(Path path, String sha1, long size) { + try { return Files.isRegularFile(path) && (size < 0 || Files.size(path) == size) + && (sha1 == null || sha1.equalsIgnoreCase(UtilsCrypto.fastSHA1(path.toFile()))); } + catch (Exception e) { return false; } + } + static Path download(String url, Path destination, String sha1, long size, Consumer progress) throws IOException { + synchronized (DOWNLOAD_LOCKS[(destination.toAbsolutePath().normalize().hashCode() & 0x7fffffff) % DOWNLOAD_LOCKS.length]) { + return downloadLocked(url, destination, sha1, size, progress); + } + } + private static Path downloadLocked(String url, Path destination, String sha1, long size, Consumer progress) throws IOException { + for (int attempt = 1; ; attempt++) { + try { return downloadAttempt(url, destination, sha1, size, progress); } + catch (IOException failure) { + if (Thread.currentThread().isInterrupted() || failure instanceof java.io.InterruptedIOException + && !(failure instanceof java.net.SocketTimeoutException) + || failure instanceof java.nio.file.FileSystemException + || failure instanceof HttpFailure && !((HttpFailure) failure).retryable() + || attempt >= 3) throw failure; + if (progress != null) progress.accept("Retrying " + destination.getFileName() + " (" + (attempt + 1) + + "/3) from " + DownloadProgress.sourceUrl(url)); + try { Thread.sleep(attempt * 200L); } + catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new java.io.InterruptedIOException("Download cancelled."); + } + } + } + } + private static Path downloadAttempt(String url, Path destination, String sha1, long size, Consumer progress) throws IOException { + if (valid(destination, sha1, size)) return destination; + if (Thread.currentThread().isInterrupted()) throw new java.io.InterruptedIOException("Download cancelled."); + Files.createDirectories(destination.getParent()); + Path temporary = Files.createTempFile(destination.getParent(), ".download-", ".part"); + String description = "Downloading " + destination.getFileName(); + DownloadProgress.Transfer transfer = DownloadProgress.begin(description, url); + long total = 0, expected = size; + try (DownloadProgress.Transfer ignored = transfer) { + if (progress != null) progress.accept(description + " from " + DownloadProgress.sourceUrl(url)); + Call call = HTTP.newCall(new Request.Builder().url(url).header("User-Agent", "AutoPlug/10 native-launcher").build()); + try (ParallelDownloads.TrackedCall tracked = ParallelDownloads.track(call); Response response = call.execute()) { + transfer.redirect(response.request().url().toString()); + if (!response.isSuccessful() || response.body() == null) throw new HttpFailure(response.code(), url); + if (expected < 0) expected = response.body().contentLength(); + try (InputStream in = response.body().byteStream(); OutputStream out = Files.newOutputStream(temporary)) { + byte[] buffer = new byte[65536]; + int count; + while ((count = in.read(buffer)) != -1) { + if (Thread.currentThread().isInterrupted()) throw new java.io.InterruptedIOException("Download cancelled."); + total += count; + if (size >= 0 && total > size) throw new IOException("Unexpected download size: " + destination.getFileName()); + out.write(buffer, 0, count); + transfer.update(total, expected); + } + } + } + if (!valid(temporary, sha1, size)) throw new IOException("Checksum or length mismatch: " + destination.getFileName()); + if (Thread.currentThread().isInterrupted()) throw new java.io.InterruptedIOException("Download cancelled."); + move(temporary, destination); + transfer.complete(total, expected); + return destination; + } finally { Files.deleteIfExists(temporary); } + } + private static final class HttpFailure extends IOException { + private final int status; + HttpFailure(int status, String url) { + super("HTTP " + status + " fetching " + DownloadProgress.sourceUrl(url)); + this.status = status; + } + boolean retryable() { return status == 408 || status == 429 || status >= 500; } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/launcher/LegacyWorldLaunch.java b/src/main/java/com/osiris/autoplug/client/launcher/LegacyWorldLaunch.java new file mode 100644 index 00000000..54bed681 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/launcher/LegacyWorldLaunch.java @@ -0,0 +1,163 @@ +package com.osiris.autoplug.client.launcher; + +import com.google.gson.JsonObject; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.HashMap; +import java.util.Arrays; +import java.util.Map; +import java.util.Properties; +import java.util.function.Consumer; +import java.util.jar.Attributes; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Adapts exact publisher mappings to vanilla's integrated-world entry API. */ +final class LegacyWorldLaunch { + static final String AGENT = "com/osiris/autoplug/legacy/LegacyWorldAgent.class"; + final Path agent; + final Path configuration; + + private LegacyWorldLaunch(Path agent, Path configuration) { this.agent = agent; this.configuration = configuration; } + + static LegacyWorldLaunch prepare(Path cache, JsonObject metadata, LaunchRequest request, Consumer progress) throws IOException { + if (!"VANILLA".equals(request.loader)) + throw new IOException("Legacy automatic world entry requires the original vanilla game; modded saves must use their matching modded setup."); + JsonObject downloads = metadata.getAsJsonObject("downloads"); + progress.accept("Preparing automatic entry for the selected legacy world"); + Properties names; + if (downloads != null && downloads.has("client_mappings")) { + JsonObject descriptor = downloads.getAsJsonObject("client_mappings"); + Path mapping = cache.resolve("versions").resolve(LaunchRequest.safeVersion(request.version)).resolve("client-mappings.txt"); + LauncherFiles.download(descriptor.get("url").getAsString(), mapping, + descriptor.get("sha1").getAsString(), descriptor.get("size").getAsLong(), progress); + if (Files.size(mapping) > 32 * 1024 * 1024) throw new IOException("Minecraft mapping file exceeds the supported size."); + names = resolve(new String(Files.readAllBytes(mapping), StandardCharsets.UTF_8)); + } else { + names = bundled(request.version, downloads == null || !downloads.has("client") ? "" : downloads.getAsJsonObject("client").get("sha1").getAsString()); + } + names.setProperty("world", request.singleplayerWorld); + names.setProperty("gameDir", request.gameDir.toString()); + Path agent = packageAgent(cache.resolve("legacy-world-helper")); + Path directory = request.gameDir.resolve(".autoplug"); + Files.createDirectories(directory); + Path configuration = Files.createTempFile(directory, "legacy-world-", ".properties"); + try { + AccountStore.restrict(configuration); + try (OutputStream output = Files.newOutputStream(configuration)) { names.store(output, "AutoPlug exact-version world entry"); } + return new LegacyWorldLaunch(agent, configuration); + } catch (IOException failure) { Files.deleteIfExists(configuration); throw failure; } + } + + String argument() { return "-javaagent:" + agent + "=" + configuration; } + + static Properties bundled(String version, String clientSha1) throws IOException { + Properties result = new Properties(); + try (InputStream input = LegacyWorldLaunch.class.getResourceAsStream("/launcher/legacy/" + LaunchRequest.safeVersion(version) + ".properties")) { + if (input == null) throw new IOException("No verified automatic world-entry adapter is available for Minecraft " + version + "."); + result.load(input); + } + if (!clientSha1.equals(result.getProperty("clientSha1"))) throw new IOException("The legacy Minecraft client differs from the verified adapter."); + return result; + } + + static Properties resolve(String mappings) throws IOException { + Map classes = new HashMap<>(); + Map> members = new HashMap<>(); + Pattern classLine = Pattern.compile("^([^ #].*) -> ([^ ]+):$"); + Pattern methodLine = Pattern.compile("^\\s+(?:\\d+:\\d+:)?[^ ]+ ([^ (]+\\([^)]*\\))(?::\\d+(?::\\d+)?)? -> ([^ ]+)$"); + Pattern fieldLine = Pattern.compile("^\\s+[^ ]+ ([^ ()]+) -> ([^ ]+)$"); + String owner = null; + for (String line : mappings.split("\\r?\\n")) { + Matcher type = classLine.matcher(line); + if (type.matches()) { owner = type.group(1); classes.put(owner, type.group(2)); members.put(owner, new HashMap<>()); } + else if (owner != null) { + Matcher method = methodLine.matcher(line), field = fieldLine.matcher(line); + if (method.matches()) members.get(owner).put(method.group(1), method.group(2)); + else if (field.matches()) members.get(owner).put(field.group(1), field.group(2)); + } + } + String minecraft = "net.minecraft.client.Minecraft"; + Map client = members.get(minecraft); + if (client == null) throw new IOException("Publisher mappings do not contain the vanilla Minecraft client."); + Properties result = new Properties(); + require(result, "minecraft", classes.get(minecraft)); + require(result, "title", classes.get("net.minecraft.client.gui.screens.TitleScreen")); + require(result, "instance", client.get("getInstance()")); + require(result, "screen", client.get("screen")); + require(result, "overlay", client.get("getOverlay()")); + if (client.containsKey("loadLevel(java.lang.String)")) { + require(result, "load", client.get("loadLevel(java.lang.String)")); + } else if (client.containsKey("selectLevel(java.lang.String,java.lang.String,net.minecraft.world.level.LevelSettings)")) { + require(result, "load", client.get("selectLevel(java.lang.String,java.lang.String,net.minecraft.world.level.LevelSettings)")); + require(result, "levelSource", client.get("getLevelSource()")); + require(result, "settingsType", classes.get("net.minecraft.world.level.LevelSettings")); + Map storage = members.get("net.minecraft.world.level.storage.LevelStorageSource"); + Map summary = members.get("net.minecraft.world.level.storage.LevelSummary"); + require(result, "levelList", storage == null ? null : storage.get("getLevelList()")); + require(result, "levelExists", storage == null ? null : storage.get("levelExists(java.lang.String)")); + require(result, "levelId", summary == null ? null : summary.get("getLevelId()")); + require(result, "levelName", summary == null ? null : summary.get("getLevelName()")); + } else { + require(result, "flows", client.get("createWorldOpenFlows()")); + Map flows = members.get("net.minecraft.client.gui.screens.worldselection.WorldOpenFlows"); + if (flows != null && flows.containsKey("loadLevel(java.lang.String)")) { + require(result, "load", flows.get("loadLevel(java.lang.String)")); + } else { + require(result, "load", flows == null ? null : flows.get("loadLevel(net.minecraft.client.gui.screens.Screen,java.lang.String)")); + require(result, "screenType", classes.get("net.minecraft.client.gui.screens.Screen")); + } + } + return result; + } + + private static void require(Properties result, String key, String value) throws IOException { + if (value == null || value.isEmpty()) throw new IOException("This Minecraft mapping does not expose the supported automatic world entry API (" + key + ")."); + result.setProperty(key, value); + } + + static Path packageAgent(Path directory) throws IOException { + byte[] bytes; + try (InputStream input = LegacyWorldLaunch.class.getResourceAsStream("/" + AGENT)) { + if (input == null) throw new IOException("AutoPlug's Java 8 world-entry helper is missing from this build."); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; int count; + while ((count = input.read(buffer)) >= 0) output.write(buffer, 0, count); + bytes = output.toByteArray(); + } + if (bytes.length < 8 || bytes[6] != 0 || bytes[7] != 52) + throw new IOException("World-entry helper must be compiled for Java 8."); + String hash = com.osiris.autoplug.client.utils.UtilsCrypto.calculateSHA1Hash(bytes); + Files.createDirectories(directory); + Path target = directory.resolve("autoplug-world-entry-" + hash + ".jar"); + Manifest manifest = new Manifest(); + manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); + manifest.getMainAttributes().putValue("Premain-Class", AGENT.replace('/', '.').replace(".class", "")); + ByteArrayOutputStream packaged = new ByteArrayOutputStream(); + try (JarOutputStream jar = new JarOutputStream(packaged)) { + JarEntry manifestEntry = new JarEntry("META-INF/MANIFEST.MF"); manifestEntry.setTime(0); + jar.putNextEntry(manifestEntry); manifest.write(jar); jar.closeEntry(); + JarEntry classEntry = new JarEntry(AGENT); classEntry.setTime(0); + jar.putNextEntry(classEntry); jar.write(bytes); jar.closeEntry(); + } + byte[] expected = packaged.toByteArray(); + // Validate the complete cached JAR and reuse identical bytes, including + // when another running Java 8 client holds it open on Windows. + if (Files.isRegularFile(target) && Files.size(target) == expected.length && Arrays.equals(expected, Files.readAllBytes(target))) return target.toAbsolutePath(); + Path temporary = Files.createTempFile(directory, "helper-", ".jar"); + try { + Files.write(temporary, expected); + Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING); + return target.toAbsolutePath(); + } finally { Files.deleteIfExists(temporary); } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/launcher/LoaderInstaller.java b/src/main/java/com/osiris/autoplug/client/launcher/LoaderInstaller.java new file mode 100644 index 00000000..08113acc --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/launcher/LoaderInstaller.java @@ -0,0 +1,298 @@ +package com.osiris.autoplug.client.launcher; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.jar.JarFile; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +/** Adapters for official loader metadata and standalone installers, with no linked launcher code. */ +final class LoaderInstaller { + private final Path cache; + LoaderInstaller(Path cache) { this.cache = cache; } + + JsonObject clientProfile(LaunchRequest request, JsonObject vanilla, Path client, Path java, Consumer log) throws Exception { + if (request.loader.equals("VANILLA")) return null; + if (request.loader.equals("FABRIC") || request.loader.equals("QUILT")) { + String base = metaBase(request.loader); + String version = request.loaderVersion == null ? latestMetaLoader(base, request.version) : request.loaderVersion; + Path file = cache.resolve("loader-metadata").resolve(request.loader.toLowerCase() + "-" + MinecraftLauncher.safeId(request.version) + "-" + MinecraftLauncher.safeId(version) + ".json"); + JsonObject profile = cachedJson(base + "/loader/" + encode(request.version) + "/" + encode(version) + "/profile/json", file); + if (!request.version.equals(MinecraftLauncher.string(profile, "inheritsFrom", ""))) + throw new IOException("Loader metadata is for a different Minecraft version."); + return profile; + } + Installer installer = forgeInstaller(request.version, request.loader, request.loaderVersion, log); + Path home = cache.resolve("loader-installations").resolve(installer.id); + Path marker = home.resolve("autoplug-installed.json"); + if (Files.isRegularFile(marker)) { importLibraries(home.resolve("libraries")); return LauncherFiles.readJson(marker); } + Files.createDirectories(home); + Path baseVersion = home.resolve("versions").resolve(request.version); + MinecraftLauncher.linkOrCopy(client, baseVersion.resolve(request.version + ".jar")); + LauncherFiles.writeJson(baseVersion.resolve(request.version + ".json"), vanilla); + Path launcherProfiles = home.resolve("launcher_profiles.json"); + if (!Files.exists(launcherProfiles)) { + JsonObject profiles = new JsonObject(); profiles.add("profiles", new JsonObject()); + LauncherFiles.writeJson(launcherProfiles, profiles); + } + log.accept("Installing " + request.loader + " " + installer.version + " and its client processors"); + run(java, Arrays.asList("-jar", installer.jar.toString(), "--installClient", home.toString()), home, log); + JsonObject profile = installedClientProfile(home, request.version); + importLibraries(home.resolve("libraries")); + // Some old installers publish a version jar alongside their version JSON. + Path versionDir = home.resolve("versions").resolve(profile.get("id").getAsString()); + if (Files.isDirectory(versionDir)) { + try (Stream paths = Files.list(versionDir)) { + for (Path path : paths.filter(Files::isRegularFile).collect(Collectors.toList())) + MinecraftLauncher.linkOrCopy(path, cache.resolve("versions").resolve(versionDir.getFileName()).resolve(path.getFileName())); + } + } + LauncherFiles.writeJson(marker, profile); + return profile; + } + + PreparedLaunch server(String gameVersion, String loader, String loaderVersion, Path directory, + Path java, int major, Consumer log) throws Exception { + Files.createDirectories(directory); + if (loader.equals("QUILT")) { + String version = loaderVersion == null || loaderVersion.isEmpty() ? latestMetaLoader(metaBase(loader), gameVersion) : LaunchRequest.safeVersion(loaderVersion); + Path installerMetadata = cache.resolve("loader-metadata").resolve("quilt-installer.json"); + JsonObject info; + if (Files.isRegularFile(installerMetadata)) info = LauncherFiles.readJson(installerMetadata); + else { + JsonArray installers = JsonParser.parseString(LauncherFiles.text(metaBase(loader) + "/installer")).getAsJsonArray(); + if (installers.size() == 0) throw new IOException("Quilt did not return an installer."); + info = installers.get(0).getAsJsonObject(); LauncherFiles.writeJson(installerMetadata, info); + } + Path jar = cache.resolve("installers").resolve("quilt-" + LaunchRequest.safeVersion(info.get("version").getAsString()) + ".jar"); + String sha = info.has("hashes") ? MinecraftLauncher.string(info.getAsJsonObject("hashes"), "sha1", null) : null; + LauncherFiles.download(info.get("url").getAsString(), jar, sha, info.has("file_size") ? info.get("file_size").getAsLong() : -1, log); + Path marker = directory.resolve(".autoplug-quilt-version"); + String key = gameVersion + ":" + version; + if (!Files.isRegularFile(marker) || !new String(Files.readAllBytes(marker), StandardCharsets.UTF_8).equals(key)) { + run(java, Arrays.asList("-jar", jar.toString(), "install", "server", gameVersion, version, + "--install-dir=" + directory, "--download-server"), directory, log); + Files.write(marker, key.getBytes(StandardCharsets.UTF_8)); + } + Path launchJar = directory.resolve("quilt-server-launch.jar"); + if (!Files.isRegularFile(launchJar)) throw new IOException("Quilt installer did not produce quilt-server-launch.jar."); + return new PreparedLaunch(java, directory, Arrays.asList("-Xmx2G", "-jar", launchJar.toString(), "nogui"), gameVersion + "-quilt-" + version, major); + } + if (!loader.equals("FORGE") && !loader.equals("NEOFORGE")) throw new IOException("Unsupported dedicated loader: " + loader); + Installer installer = forgeInstaller(gameVersion, loader, loaderVersion, log); + Path marker = directory.resolve(".autoplug-forge-version"); + if (!Files.isRegularFile(marker) || !new String(Files.readAllBytes(marker), StandardCharsets.UTF_8).equals(installer.id)) { + log.accept("Installing " + loader + " dedicated server"); + run(java, Arrays.asList("-jar", installer.jar.toString(), "--installServer", directory.toString()), directory, log); + Files.write(marker, installer.id.getBytes(StandardCharsets.UTF_8)); + } + Path libraries = directory.resolve("libraries"); + String argsName = MinecraftLauncher.osName().equals("windows") ? "win_args.txt" : "unix_args.txt"; + List argsFiles = new ArrayList<>(); + if (Files.isDirectory(libraries)) try (Stream paths = Files.walk(libraries)) { + argsFiles = paths.filter(p -> p.getFileName().toString().equals(argsName)) + .filter(p -> p.toString().contains(installer.version)).collect(Collectors.toList()); + } + if (argsFiles.size() == 1) { + List arguments = new ArrayList<>(); arguments.add("-Xmx2G"); + Path custom = directory.resolve("user_jvm_args.txt"); + if (Files.isRegularFile(custom)) arguments.add("@" + custom); + arguments.add("@" + argsFiles.get(0)); arguments.add("nogui"); + return new PreparedLaunch(java, directory, arguments, installer.id, major); + } + try (Stream paths = Files.list(directory)) { + for (Path candidate : paths.filter(p -> p.getFileName().toString().endsWith(".jar")).sorted().collect(Collectors.toList())) { + String name = candidate.getFileName().toString(); + if ((!name.startsWith("forge-") && !name.startsWith("neoforge-")) || name.contains("installer")) continue; + try (JarFile jar = new JarFile(candidate.toFile())) { + if (jar.getManifest() != null && jar.getManifest().getMainAttributes().getValue("Main-Class") != null) + return new PreparedLaunch(java, directory, Arrays.asList("-Xmx2G", "-jar", candidate.toString(), "nogui"), installer.id, major); + } + } + } + throw new IOException("Official " + loader + " installer did not produce an unambiguous server launch command. See autoplug-loader-install.log."); + } + + private Installer forgeInstaller(String game, String loader, String desired, Consumer log) throws Exception { + String version = desired == null || desired.trim().isEmpty() ? null : LaunchRequest.safeVersion(desired); + String base, coordinate; + if (loader.equals("FORGE")) { + if (version == null) { + JsonObject promotions = freshJson("https://files.minecraftforge.net/net/minecraftforge/forge/promotions_slim.json", cache.resolve("forge-promotions.json")).getAsJsonObject("promos"); + version = MinecraftLauncher.string(promotions, game + "-recommended", MinecraftLauncher.string(promotions, game + "-latest", null)); + if (version == null) throw new IOException("Forge does not publish a loader for Minecraft " + game + "."); + } + if (!version.startsWith(game + "-")) version = game + "-" + version; + base = "https://maven.minecraftforge.net/"; + coordinate = "net.minecraftforge:forge:" + version + ":installer"; + } else { + base = "https://maven.neoforged.net/releases/"; + boolean legacy = game.equals("1.20.1"); + String artifact = legacy ? "forge" : "neoforge"; + if (version == null) { + String prefix = legacy ? game + "-" : neoPrefix(game); + version = latestMavenVersion(base + "net/neoforged/" + artifact + "/maven-metadata.xml", prefix); + if (version == null) throw new IOException("NeoForge does not publish a loader for Minecraft " + game + "."); + } + if (legacy && !version.startsWith(game + "-")) version = game + "-" + version; + coordinate = "net.neoforged:" + artifact + ":" + version + ":installer"; + } + String relative = MinecraftLauncher.mavenPath(coordinate); + Path jar = LauncherFiles.child(cache.resolve("installers"), relative); + String hash = null; + Path checksumFile = jar.resolveSibling(jar.getFileName() + ".sha1"); + if (Files.isRegularFile(checksumFile)) hash = new String(Files.readAllBytes(checksumFile), StandardCharsets.US_ASCII).trim(); + else { + hash = LauncherFiles.text(base + relative + ".sha1").trim().split("\\s+")[0]; + if (!hash.matches("[a-fA-F0-9]{40}")) throw new IOException("Invalid official installer checksum."); + Files.createDirectories(checksumFile.getParent()); Files.write(checksumFile, hash.getBytes(StandardCharsets.US_ASCII)); + } + LauncherFiles.download(base + relative, jar, hash, -1, log); + validateInstallerGame(jar, game); + return new Installer(loader.toLowerCase() + "-" + version, version, jar); + } + static String neoPrefix(String game) throws IOException { + String[] parts = game.split("\\."); + if (parts.length >= 2 && parts[0].equals("1")) return parts[1] + "." + (parts.length > 2 ? parts[2] : "0") + "."; + if (parts.length >= 2 && parts[0].matches("\\d+") && parts[1].matches("\\d+")) return parts[0] + "." + parts[1] + "."; + throw new IOException("Choose an explicit NeoForge loader version for " + game + "."); + } + private String latestMavenVersion(String url, String prefix) throws Exception { + 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, ""); + Path cached = cache.resolve("neoforge-" + prefix.replace('.', '-') + "versions.xml"); + String xml; + try { xml = LauncherFiles.text(url); Files.createDirectories(cache); Files.write(cached, xml.getBytes(StandardCharsets.UTF_8)); } + catch (IOException e) { if (!Files.exists(cached)) throw e; xml = new String(Files.readAllBytes(cached), StandardCharsets.UTF_8); } + org.w3c.dom.NodeList nodes = factory.newDocumentBuilder().parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))).getElementsByTagName("version"); + String latest = null; + for (int i = 0; i < nodes.getLength(); i++) { + String value = nodes.item(i).getTextContent(); + if (value.startsWith(prefix) && (latest == null || compareVersions(value, latest) > 0)) latest = value; + } + return latest; + } + static int compareVersions(String first, String second) { + String[] a = first.split("[.\\-+]"); String[] b = second.split("[.\\-+]"); + for (int i = 0; i < Math.max(a.length, b.length); i++) { + String left = i < a.length ? a[i] : ""; String right = i < b.length ? b[i] : ""; + int result; + if (left.matches("\\d+") && right.matches("\\d+")) result = new java.math.BigInteger(left).compareTo(new java.math.BigInteger(right)); + else if (left.isEmpty() || right.isEmpty()) result = left.isEmpty() ? 1 : -1; + else result = left.compareTo(right); + if (result != 0) return result; + } + return 0; + } + private static void validateInstallerGame(Path jar, String game) throws IOException { + try (ZipFile zip = new ZipFile(jar.toFile())) { + ZipEntry entry = zip.getEntry("install_profile.json"); + if (entry == null) throw new IOException("Official installer has no install profile."); + JsonObject profile; + try (InputStream in = zip.getInputStream(entry)) { profile = JsonParser.parseReader(new java.io.InputStreamReader(in, StandardCharsets.UTF_8)).getAsJsonObject(); } + String actual = MinecraftLauncher.string(profile, "minecraft", ""); + if (actual.isEmpty() && profile.has("versionInfo")) actual = MinecraftLauncher.string(profile.getAsJsonObject("versionInfo"), "inheritsFrom", ""); + if (!actual.isEmpty() && !actual.equals(game)) throw new IOException("Requested loader is for Minecraft " + actual + ", not " + game + "."); + } + } + private static JsonObject installedClientProfile(Path home, String game) throws IOException { + List candidates = new ArrayList<>(); + try (Stream paths = Files.walk(home.resolve("versions"), 2)) { + for (Path file : paths.filter(p -> p.getFileName().toString().endsWith(".json")).collect(Collectors.toList())) { + JsonObject metadata = LauncherFiles.readJson(file); + if (game.equals(MinecraftLauncher.string(metadata, "inheritsFrom", "")) && !game.equals(MinecraftLauncher.string(metadata, "id", ""))) candidates.add(metadata); + } + } + if (candidates.size() != 1) throw new IOException("Official installer did not produce a unique client profile. See autoplug-loader-install.log."); + return candidates.get(0); + } + private void importLibraries(Path source) throws IOException { + if (!Files.isDirectory(source)) return; + try (Stream paths = Files.walk(source)) { + for (Path file : paths.filter(Files::isRegularFile).collect(Collectors.toList())) + MinecraftLauncher.linkOrCopy(file, LauncherFiles.child(cache.resolve("libraries"), source.relativize(file).toString())); + } + } + private String latestMetaLoader(String base, String game) throws IOException { + Path file = cache.resolve("loader-metadata").resolve((base.contains("quilt") ? "quilt" : "fabric") + "-latest-" + MinecraftLauncher.safeId(game) + ".json"); + JsonArray versions; + try { + versions = JsonParser.parseString(LauncherFiles.text(base + "/loader/" + encode(game))).getAsJsonArray(); + JsonObject saved = new JsonObject(); saved.add("versions", versions); LauncherFiles.writeJson(file, saved); + } catch (IOException e) { + if (!Files.exists(file)) throw e; + versions = LauncherFiles.readJson(file).getAsJsonArray("versions"); + } + for (JsonElement element : versions) { + JsonObject loader = element.getAsJsonObject().getAsJsonObject("loader"); + if (!loader.has("stable") || loader.get("stable").getAsBoolean()) return loader.get("version").getAsString(); + } + throw new IOException("No stable loader is available for Minecraft " + game + ". Choose a specific loader version."); + } + private JsonObject cachedJson(String url, Path file) throws IOException { + if (Files.isRegularFile(file)) return LauncherFiles.readJson(file); + JsonObject json = LauncherFiles.json(url); LauncherFiles.writeJson(file, json); return json; + } + private JsonObject freshJson(String url, Path file) throws IOException { + try { JsonObject json = LauncherFiles.json(url); LauncherFiles.writeJson(file, json); return json; } + catch (IOException e) { if (Files.exists(file)) return LauncherFiles.readJson(file); throw e; } + } + static void run(Path java, List arguments, Path directory, Consumer log) throws IOException, InterruptedException { + Files.createDirectories(directory); + List command = new ArrayList<>(); command.add(java.toString()); command.add("-Djava.awt.headless=true"); command.addAll(arguments); + Path output = directory.resolve("autoplug-loader-install.log"); + Process process = new ProcessBuilder(command).directory(directory.toFile()).redirectErrorStream(true).start(); + java.util.concurrent.atomic.AtomicReference outputFailure = new java.util.concurrent.atomic.AtomicReference<>(); + // Drain while the installer runs: its downloads and substeps must not disappear into a file until exit. + Thread reader = new Thread(() -> { + try (java.io.BufferedReader lines = new java.io.BufferedReader(new java.io.InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)); + java.io.BufferedWriter writer = Files.newBufferedWriter(output, StandardCharsets.UTF_8)) { + String line; + while ((line = lines.readLine()) != null) { + String safe = DownloadProgress.installerOutput(line); + writer.write(safe); writer.newLine(); writer.flush(); + if (log != null) log.accept(safe); + } + } catch (IOException e) { outputFailure.set(e); } + }, "autoplug-loader-output"); + reader.setDaemon(true); + reader.start(); + try { + if (!process.waitFor(20, TimeUnit.MINUTES)) throw new IOException("Loader installation timed out. See " + output); + reader.join(5000); + if (reader.isAlive()) throw new IOException("Loader output did not finish. See " + output); + if (outputFailure.get() != null) throw new IOException("Unable to record loader progress. See " + output, outputFailure.get()); + if (process.exitValue() != 0) throw new IOException("Loader installer exited with " + process.exitValue() + ". See " + output); + if (log != null) log.accept("Official loader installer completed"); + } finally { + if (process.isAlive()) { process.descendants().forEach(ProcessHandle::destroy); process.destroyForcibly(); } + } + } + private static String metaBase(String loader) { return loader.equals("QUILT") ? "https://meta.quiltmc.org/v3/versions" : "https://meta.fabricmc.net/v2/versions"; } + private static String encode(String value) throws IOException { return URLEncoder.encode(value, "UTF-8").replace("+", "%20"); } + private static final class Installer { + final String id, version; final Path jar; + Installer(String id, String version, Path jar) { this.id = id; this.version = version; this.jar = jar; } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/launcher/MicrosoftAccountService.java b/src/main/java/com/osiris/autoplug/client/launcher/MicrosoftAccountService.java new file mode 100644 index 00000000..99070667 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/launcher/MicrosoftAccountService.java @@ -0,0 +1,156 @@ +package com.osiris.autoplug.client.launcher; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import okhttp3.FormBody; +import okhttp3.MediaType; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +import java.io.IOException; +import java.time.Instant; +import java.util.Objects; + +/** Microsoft device authorization and the Xbox-to-Minecraft token exchange. No application ID is borrowed. */ +public class MicrosoftAccountService { + private static final String AUTHORITY = "https://login.microsoftonline.com/consumers/oauth2/v2.0/"; + private static final String SCOPES = "XboxLive.signin offline_access"; + private static final MediaType JSON = MediaType.parse("application/json"); + interface Transport { JsonObject execute(Request request, boolean allowError) throws IOException; } + private final Transport transport; + public MicrosoftAccountService() { this(MicrosoftAccountService::execute); } + MicrosoftAccountService(Transport transport) { this.transport = transport; } + + public static final class DeviceLogin { + public final String userCode; + public final String verificationUri; + public final String message; + public final Instant expiresAt; + private final String clientId; + private final String deviceCode; + private final int intervalSeconds; + DeviceLogin(String clientId, JsonObject json) { + this.clientId = clientId; + userCode = json.get("user_code").getAsString(); + verificationUri = json.get("verification_uri").getAsString(); + message = json.has("message") ? json.get("message").getAsString() : "Open " + verificationUri + " and enter " + userCode; + deviceCode = json.get("device_code").getAsString(); + intervalSeconds = json.has("interval") ? Math.min(60, Math.max(1, json.get("interval").getAsInt())) : 5; + expiresAt = Instant.now().plusSeconds(json.get("expires_in").getAsLong()); + } + } + + public DeviceLogin beginLogin(String clientId) throws IOException { + if (clientId == null || !clientId.matches("[A-Za-z0-9-]{8,128}")) + throw new IllegalArgumentException("Configure AutoPlug's registered Microsoft public application client ID first."); + JsonObject response = post(AUTHORITY + "devicecode", new FormBody.Builder() + .add("client_id", clientId).add("scope", SCOPES).build(), false); + return new DeviceLogin(clientId, response); + } + + /** Run on a worker thread. Interrupting the worker cancels polling. */ + public MinecraftAccount completeLogin(DeviceLogin login) throws IOException, InterruptedException { + Objects.requireNonNull(login, "login"); + int interval = login.intervalSeconds; + while (Instant.now().isBefore(login.expiresAt)) { + Thread.sleep(interval * 1000L); + JsonObject result = post(AUTHORITY + "token", new FormBody.Builder() + .add("client_id", login.clientId) + .add("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + .add("device_code", login.deviceCode).build(), true); + if (!result.has("error")) return exchange(login.clientId, result); + String error = result.get("error").getAsString(); + if (error.equals("authorization_pending")) continue; + if (error.equals("slow_down")) { interval = Math.min(60, interval + 5); continue; } + throw authError(error); + } + throw new IOException("Microsoft sign-in expired. Start sign-in again."); + } + + public MinecraftAccount refresh(MinecraftAccount account) throws IOException { + if (account.offline) return account; + if (account.refreshToken.isEmpty()) throw new IOException("Microsoft sign-in is required again."); + JsonObject result = post(AUTHORITY + "token", new FormBody.Builder() + .add("client_id", account.clientId).add("scope", SCOPES) + .add("grant_type", "refresh_token").add("refresh_token", account.refreshToken).build(), false); + if (!result.has("refresh_token")) result.addProperty("refresh_token", account.refreshToken); + return exchange(account.clientId, result); + } + + private MinecraftAccount exchange(String clientId, JsonObject microsoft) throws IOException { + JsonObject properties = new JsonObject(); + properties.addProperty("AuthMethod", "RPS"); + properties.addProperty("SiteName", "user.auth.xboxlive.com"); + properties.addProperty("RpsTicket", "d=" + microsoft.get("access_token").getAsString()); + JsonObject userRequest = new JsonObject(); + userRequest.add("Properties", properties); + userRequest.addProperty("RelyingParty", "http://auth.xboxlive.com"); + userRequest.addProperty("TokenType", "JWT"); + JsonObject xbox = postJson("https://user.auth.xboxlive.com/user/authenticate", userRequest); + + JsonArray tokens = new JsonArray(); + tokens.add(xbox.get("Token").getAsString()); + JsonObject xstsProperties = new JsonObject(); + xstsProperties.addProperty("SandboxId", "RETAIL"); + 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 = postJson("https://xsts.auth.xboxlive.com/xsts/authorize", xstsRequest); + JsonObject claims = xsts.getAsJsonObject("DisplayClaims").getAsJsonArray("xui").get(0).getAsJsonObject(); + String userHash = claims.get("uhs").getAsString(); + String xuid = claims.has("xid") ? claims.get("xid").getAsString() : ""; + JsonObject minecraftRequest = new JsonObject(); + minecraftRequest.addProperty("identityToken", "XBL3.0 x=" + userHash + ";" + xsts.get("Token").getAsString()); + JsonObject minecraft = postJson("https://api.minecraftservices.com/authentication/login_with_xbox", minecraftRequest); + String token = minecraft.get("access_token").getAsString(); + JsonObject entitlements = getAuthorized("https://api.minecraftservices.com/entitlements/mcstore", token); + if (!entitlements.has("items") || entitlements.getAsJsonArray("items").size() == 0) + throw new IOException("This Microsoft account has no Minecraft Java Edition entitlement."); + JsonObject profile = getAuthorized("https://api.minecraftservices.com/minecraft/profile", token); + return new MinecraftAccount(profile.get("name").getAsString(), profile.get("id").getAsString(), token, + microsoft.has("refresh_token") ? microsoft.get("refresh_token").getAsString() : "", clientId, xuid, false, + Instant.now().plusSeconds(minecraft.get("expires_in").getAsLong())); + } + + private JsonObject postJson(String url, JsonObject body) throws IOException { + return post(url, RequestBody.create(JSON, body.toString()), false); + } + private JsonObject post(String url, RequestBody body, boolean allowError) throws IOException { + return transport.execute(new Request.Builder().url(url).header("Accept", "application/json") + .header("x-xbl-contract-version", "1").post(body).build(), allowError); + } + private JsonObject getAuthorized(String url, String token) throws IOException { + return transport.execute(new Request.Builder().url(url).header("Authorization", "Bearer " + token).build(), false); + } + private static JsonObject execute(Request request, boolean allowError) throws IOException { + try (Response response = LauncherFiles.HTTP.newCall(request).execute()) { + if (response.body() == null) throw new IOException("Empty authentication response."); + JsonObject result; + try { result = JsonParser.parseString(response.body().string()).getAsJsonObject(); } + catch (RuntimeException e) { throw new IOException("Invalid authentication response (HTTP " + response.code() + ")."); } + if (!response.isSuccessful() && !(allowError && result.has("error"))) { + if (result.has("XErr")) { + long xboxError = result.get("XErr").getAsLong(); + if (xboxError == 2148916233L) throw new IOException("Create an Xbox profile for this Microsoft account before signing in."); + if (xboxError == 2148916238L) throw new IOException("This child account needs an Xbox family organizer's permission."); + } + if (response.code() == 403 && request.url().host().equals("api.minecraftservices.com")) + throw new IOException("Minecraft services rejected access. Check that AutoPlug's Microsoft app registration is approved and the account is eligible."); + if (result.has("error") && result.get("error").isJsonPrimitive()) throw authError(result.get("error").getAsString()); + throw new IOException("Authentication failed (HTTP " + response.code() + "). Check account ownership and Microsoft app registration."); + } + return result; + } + } + private static IOException authError(String code) { + if (code.equals("authorization_declined") || code.equals("access_denied")) return new IOException("Microsoft sign-in was cancelled."); + if (code.equals("expired_token")) return new IOException("Microsoft sign-in expired. Try again."); + if (code.equals("invalid_grant")) return new IOException("Microsoft sign-in is required again."); + // Do not surface remote token-bearing response bodies in logs or dialogs. + return new IOException("Microsoft authentication failed. Check the registered client ID and try signing in again."); + } +} diff --git a/src/main/java/com/osiris/autoplug/client/launcher/MinecraftAccount.java b/src/main/java/com/osiris/autoplug/client/launcher/MinecraftAccount.java new file mode 100644 index 00000000..8d877c7f --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/launcher/MinecraftAccount.java @@ -0,0 +1,46 @@ +package com.osiris.autoplug.client.launcher; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.UUID; + +/** A launch identity. Credentials are deliberately excluded from diagnostic output. */ +public final class MinecraftAccount { + public final String username; + public final String uuid; + public final String accessToken; + public final String refreshToken; + public final String clientId; + public final String xuid; + public final boolean offline; + public final Instant expiresAt; + + public MinecraftAccount(String username, String uuid, String accessToken, String refreshToken, + String clientId, String xuid, boolean offline, Instant expiresAt) { + if (username == null || !username.matches("[A-Za-z0-9_]{1,16}")) + throw new IllegalArgumentException("Minecraft names must contain 1-16 letters, digits or underscores."); + if (uuid == null || !uuid.replace("-", "").matches("[a-fA-F0-9]{32}")) + throw new IllegalArgumentException("Invalid Minecraft account UUID."); + if (!offline && (accessToken == null || accessToken.isEmpty())) + throw new IllegalArgumentException("A Microsoft account requires a Minecraft access token."); + this.username = username; + this.uuid = uuid.replace("-", ""); + this.accessToken = accessToken == null ? "0" : accessToken; + this.refreshToken = refreshToken == null ? "" : refreshToken; + this.clientId = clientId == null ? "" : clientId; + this.xuid = xuid == null ? "" : xuid; + this.offline = offline; + this.expiresAt = expiresAt == null ? Instant.EPOCH : expiresAt; + } + + public static MinecraftAccount offline(String username) { + UUID id = UUID.nameUUIDFromBytes(("OfflinePlayer:" + username).getBytes(StandardCharsets.UTF_8)); + return new MinecraftAccount(username, id.toString(), "0", "", "", "", true, Instant.MAX); + } + + public boolean needsRefresh() { return !offline && expiresAt.isBefore(Instant.now().plusSeconds(60)); } + public String getUsername() { return username; } + public String getUuid() { return uuid; } + public boolean isOffline() { return offline; } + @Override public String toString() { return username + (offline ? " (offline)" : " (Microsoft)"); } +} diff --git a/src/main/java/com/osiris/autoplug/client/launcher/MinecraftLauncher.java b/src/main/java/com/osiris/autoplug/client/launcher/MinecraftLauncher.java new file mode 100644 index 00000000..f4928bde --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/launcher/MinecraftLauncher.java @@ -0,0 +1,487 @@ +package com.osiris.autoplug.client.launcher; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.osiris.autoplug.client.utils.UtilsString; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.Callable; +import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** Native, profile-isolated Minecraft launcher implemented from the publishers' metadata formats. */ +public class MinecraftLauncher { + static final String MANIFEST_URL = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"; + private final Path cacheRoot; + private final JavaRuntimeManager runtimes; + private final String manifestUrl; + private final String assetBaseUrl; + private final LoaderInstaller loaders; + + public MinecraftLauncher(Path cacheRoot, JavaRuntimeManager runtimes) { this(cacheRoot, runtimes, MANIFEST_URL); } + MinecraftLauncher(Path cacheRoot, JavaRuntimeManager runtimes, String manifestUrl) { + this(cacheRoot, runtimes, manifestUrl, "https://resources.download.minecraft.net/"); + } + MinecraftLauncher(Path cacheRoot, JavaRuntimeManager runtimes, String manifestUrl, String assetBaseUrl) { + this.cacheRoot = cacheRoot.toAbsolutePath().normalize(); + this.runtimes = Objects.requireNonNull(runtimes, "runtimes"); + this.manifestUrl = manifestUrl; + this.assetBaseUrl = assetBaseUrl; + this.loaders = new LoaderInstaller(this.cacheRoot); + } + + public synchronized PreparedLaunch prepare(LaunchRequest request, Consumer progress) throws Exception { + Consumer log = ParallelDownloads.serialized(progress); + if (request.account.needsRefresh()) throw new IOException("Microsoft session expired. Refresh the account before launching."); + Files.createDirectories(request.gameDir); + JsonObject vanilla = version(request.version, log); + int major = requiredJava(vanilla); + Path java = runtimes.resolve(major, log); + Path client = clientPath(vanilla, request.version); + // Forge processors require the client jar before producing their merged profile. + // Vanilla, Fabric and Quilt can download it alongside all other game files. + boolean installerNeedsClient = request.loader.equals("FORGE") || request.loader.equals("NEOFORGE"); + if (installerNeedsClient) downloadClient(vanilla, request.version, log); + JsonObject loader = loaders.clientProfile(request, vanilla, client, java, log); + JsonObject metadata = loader == null ? vanilla : merge(vanilla, loader); + String id = metadata.get("id").getAsString(); + Path natives = LauncherFiles.child(request.gameDir.resolve(".autoplug").resolve("natives"), safeId(id) + "-" + osName() + "-" + System.getProperty("os.arch")); + Files.createDirectories(natives); + List> downloads = new ArrayList<>(); + List> extraction = new ArrayList<>(); + if (!installerNeedsClient) downloads.add(() -> { downloadClient(vanilla, request.version, log); return null; }); + List classpath = planLibraries(metadata, natives, downloads, extraction, log); + Path assets = cacheRoot.resolve("assets"); + String assetId = planAssets(metadata, assets, request.gameDir, downloads, log); + Path logging = null; + if (metadata.has("logging") && metadata.getAsJsonObject("logging").has("client")) { + JsonObject descriptor = metadata.getAsJsonObject("logging").getAsJsonObject("client").getAsJsonObject("file"); + logging = LauncherFiles.child(cacheRoot.resolve("logging"), descriptor.get("id").getAsString()); + Path target = logging; + downloads.add(() -> { download(descriptor, target, log); return null; }); + } + ParallelDownloads.run("Minecraft files", downloads, log); + // Native archives can share output filenames. Keep the publisher's extraction order. + for (Callable extract : extraction) extract.call(); + classpath.add(clientJarForProfile(client, metadata, cacheRoot)); + List args = buildArguments(metadata, request, classpath, natives, assets, assetId, logging); + List temporaryFiles = new ArrayList<>(); + if (request.singleplayerWorld != null && !args.contains("--quickPlaySingleplayer")) { + LegacyWorldLaunch legacy = LegacyWorldLaunch.prepare(cacheRoot, vanilla, request, log); + args.add(0, legacy.argument()); + temporaryFiles.add(legacy.configuration); + log.accept("Minecraft " + request.version + " will enter the selected save automatically through its integrated-world loading flow."); + } + log.accept("Minecraft " + id + " is ready (Java " + major + ")"); + return new PreparedLaunch(java, request.gameDir, args, id, major, temporaryFiles); + } + + /** Minecraft's output is written inside this profile and is never mixed with another world's console. */ + public Process launch(PreparedLaunch prepared) throws IOException { + List cleanup = new ArrayList<>(prepared.temporaryFiles); + try { return start(prepared, cleanup); } + catch (IOException | RuntimeException failure) { cleanup.forEach(MinecraftLauncher::deleteTemporary); throw failure; } + } + + private Process start(PreparedLaunch prepared, List cleanup) throws IOException { + Path logs = prepared.gameDir.resolve("logs"); + Files.createDirectories(logs); + // Loader classpaths can exceed Windows' command-line limit. Java 9+ accepts a private + // argument file; preserve the inspectable PreparedLaunch contract and remove it on exit. + List command = prepared.command(); + Path argumentsFile = null; + if (prepared.javaMajor >= 9) { + Path state = prepared.gameDir.resolve(".autoplug"); Files.createDirectories(state); + argumentsFile = Files.createTempFile(state, "launch-", ".args"); + cleanup.add(argumentsFile); + try { + AccountStore.restrict(argumentsFile); + List lines = new ArrayList<>(); + for (String argument : prepared.arguments) lines.add(quoteArgumentFile(argument)); + java.nio.charset.Charset encoding = java.nio.charset.Charset.forName(System.getProperty("native.encoding", java.nio.charset.Charset.defaultCharset().name())); + Files.write(argumentsFile, lines, encoding); + command = java.util.Arrays.asList(prepared.executable.toString(), "@" + argumentsFile); + } catch (IOException | RuntimeException e) { Files.deleteIfExists(argumentsFile); throw e; } + } + try { + Process process = new ProcessBuilder(command).directory(prepared.gameDir.toFile()).redirectErrorStream(true) + .redirectOutput(ProcessBuilder.Redirect.appendTo(logs.resolve("autoplug-launcher.log").toFile())).start(); + if (!cleanup.isEmpty()) { + cleanup.forEach(path -> path.toFile().deleteOnExit()); + // A non-daemon session thread also makes one-shot CLI launches wait for their + // client and guarantees cleanup before normal JVM shutdown. A daemon future can + // be lost when the launching CLI exits immediately after the game does. + Thread cleanupThread = new Thread(() -> { + try { process.waitFor(); } + catch (InterruptedException e) { Thread.currentThread().interrupt(); } + finally { cleanup.forEach(MinecraftLauncher::deleteTemporary); } + }, "Minecraft session cleanup"); + cleanupThread.setDaemon(false); + cleanupThread.start(); + } + return process; + } catch (IOException e) { cleanup.forEach(MinecraftLauncher::deleteTemporary); throw e; } + } + + private static void deleteTemporary(Path path) { + try { Files.deleteIfExists(path); } catch (IOException ignored) { path.toFile().deleteOnExit(); } + } + + static String quoteArgumentFile(String argument) { + return "\"" + argument.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t") + "\""; + } + + /** Resolves metadata only, without downloading Minecraft assets or installing Java. */ + public int requiredJavaMajor(String gameVersion, Consumer progress) throws IOException { + return requiredJava(version(LaunchRequest.safeVersion(gameVersion), progress == null ? value -> { } : progress)); + } + + public JsonObject getVersionMetadata(String gameVersion, Consumer progress) throws IOException { + return version(LaunchRequest.safeVersion(gameVersion), progress == null ? value -> { } : progress).deepCopy(); + } + + /** Installs dedicated Quilt/Forge/NeoForge servers without starting them or accepting their EULA. */ + public synchronized PreparedLaunch prepareServer(String gameVersion, String loader, String loaderVersion, + Path worldDirectory, Consumer progress) throws Exception { + String version = LaunchRequest.safeVersion(gameVersion); + Consumer log = progress == null ? value -> { } : progress; + JsonObject metadata = version(version, log); + int major = requiredJava(metadata); + Path java = runtimes.resolve(major, log); + return loaders.server(version, loader.toUpperCase(Locale.ROOT), loaderVersion, worldDirectory.toAbsolutePath().normalize(), java, major, log); + } + + JsonObject version(String requested, Consumer progress) throws IOException { + Path versions = cacheRoot.resolve("versions"); + Path destination = LauncherFiles.child(versions, safeId(requested) + "/" + safeId(requested) + ".json"); + JsonObject manifest; + Path cachedManifest = cacheRoot.resolve("version_manifest_v2.json"); + try { + manifest = LauncherFiles.json(manifestUrl); + LauncherFiles.writeJson(cachedManifest, manifest); + } catch (IOException e) { + if (Files.exists(cachedManifest)) manifest = LauncherFiles.readJson(cachedManifest); + else if (Files.exists(destination)) return LauncherFiles.readJson(destination); + else throw e; + } + for (JsonElement value : manifest.getAsJsonArray("versions")) { + JsonObject entry = value.getAsJsonObject(); + if (!entry.get("id").getAsString().equals(requested)) continue; + LauncherFiles.download(entry.get("url").getAsString(), destination, string(entry, "sha1", null), -1, progress); + JsonObject result = LauncherFiles.readJson(destination); + if (!result.get("id").getAsString().equals(requested)) throw new IOException("Minecraft metadata version mismatch."); + return result; + } + throw new IOException("Minecraft version not found: " + requested); + } + static int requiredJava(JsonObject metadata) { + return metadata.has("javaVersion") ? metadata.getAsJsonObject("javaVersion").get("majorVersion").getAsInt() : 8; + } + private Path clientPath(JsonObject metadata, String id) throws IOException { + if (!metadata.has("downloads") || !metadata.getAsJsonObject("downloads").has("client")) + throw new IOException("This Minecraft version does not publish a client download."); + return LauncherFiles.child(cacheRoot.resolve("versions"), safeId(id) + "/" + safeId(id) + ".jar"); + } + private Path downloadClient(JsonObject metadata, String id, Consumer progress) throws IOException { + return download(metadata.getAsJsonObject("downloads").getAsJsonObject("client"), clientPath(metadata, id), progress); + } + static Path download(JsonObject descriptor, Path path, Consumer progress) throws IOException { + return LauncherFiles.download(descriptor.get("url").getAsString(), path, string(descriptor, "sha1", null), + descriptor.has("size") ? descriptor.get("size").getAsLong() : -1, progress); + } + static Path clientJarForProfile(Path vanillaJar, JsonObject metadata, Path cache) throws IOException { + if (!metadata.has("inheritsFrom")) return vanillaJar; + // The effective version jar name must match ${version_name}. Forge/NeoForge's + // module bootstrap ignore list uses that name to exclude the unpatched vanilla jar. + String id = safeId(metadata.get("id").getAsString()); + Path jar = LauncherFiles.child(cache.resolve("versions"), id + "/" + id + ".jar"); + linkOrCopy(vanillaJar, jar); + return jar; + } + List libraries(JsonObject metadata, Path natives, Consumer progress) throws IOException { + Consumer log = ParallelDownloads.serialized(progress); + List> downloads = new ArrayList<>(), extraction = new ArrayList<>(); + List classpath = planLibraries(metadata, natives, downloads, extraction, log); + ParallelDownloads.run("Minecraft libraries", downloads, log); + for (Callable extract : extraction) { + try { extract.call(); } + catch (IOException e) { throw e; } + catch (Exception e) { throw new IOException("Native library extraction failed.", e); } + } + return classpath; + } + private List planLibraries(JsonObject metadata, Path natives, List> tasks, + List> extraction, Consumer progress) throws IOException { + List classpath = new ArrayList<>(); + if (!metadata.has("libraries")) return classpath; + for (JsonElement entry : metadata.getAsJsonArray("libraries")) { + JsonObject library = entry.getAsJsonObject(); + if (!allowed(library, Collections.emptyMap())) continue; + JsonObject downloads = library.has("downloads") ? library.getAsJsonObject("downloads") : new JsonObject(); + if (downloads.has("artifact")) { + JsonObject artifact = downloads.getAsJsonObject("artifact"); + Path target = LauncherFiles.child(cacheRoot.resolve("libraries"), artifact.get("path").getAsString()); + classpath.add(target); + tasks.add(() -> { download(artifact, target, progress); return null; }); + } else if (!library.has("natives") || !downloads.has("classifiers")) { + String relative = mavenPath(library.get("name").getAsString()); + String base = string(library, "url", "https://libraries.minecraft.net/"); + Path artifact = LauncherFiles.child(cacheRoot.resolve("libraries"), relative); + if (base.isEmpty()) { + if (!Files.isRegularFile(artifact)) throw new IOException("Loader installer did not generate required library " + relative); + classpath.add(artifact); + } else { + classpath.add(artifact); + tasks.add(() -> { + LauncherFiles.download(base + (base.endsWith("/") ? "" : "/") + relative, artifact, + string(library, "sha1", null), library.has("size") ? library.get("size").getAsLong() : -1, progress); + return null; + }); + } + } + if (library.has("natives") && library.getAsJsonObject("natives").has(osName())) { + String classifier = library.getAsJsonObject("natives").get(osName()).getAsString() + .replace("${arch}", System.getProperty("os.arch").contains("64") ? "64" : "32"); + if (!downloads.has("classifiers") || !downloads.getAsJsonObject("classifiers").has(classifier)) + throw new IOException("Native library missing for " + osName() + ": " + library.get("name").getAsString()); + JsonObject nativeArtifact = downloads.getAsJsonObject("classifiers").getAsJsonObject(classifier); + Path jar = LauncherFiles.child(cacheRoot.resolve("libraries"), nativeArtifact.get("path").getAsString()); + tasks.add(() -> { download(nativeArtifact, jar, progress); return null; }); + List exclusions = new ArrayList<>(); exclusions.add("META-INF/"); + if (library.has("extract") && library.getAsJsonObject("extract").has("exclude")) + for (JsonElement exclude : library.getAsJsonObject("extract").getAsJsonArray("exclude")) exclusions.add(exclude.getAsString()); + extraction.add(() -> { extractNatives(jar, natives, exclusions); return null; }); + } + } + return classpath; + } + String assets(JsonObject metadata, Path assets, Path gameDir, Consumer progress) throws IOException { + Consumer log = ParallelDownloads.serialized(progress); + List> tasks = new ArrayList<>(); + String id = planAssets(metadata, assets, gameDir, tasks, log); + ParallelDownloads.run("Minecraft assets", tasks, log); + return id; + } + private String planAssets(JsonObject metadata, Path assets, Path gameDir, List> tasks, + Consumer progress) throws IOException { + if (!metadata.has("assetIndex")) return string(metadata, "assets", "legacy"); + JsonObject descriptor = metadata.getAsJsonObject("assetIndex"); + String id = descriptor.get("id").getAsString(); + Path index = LauncherFiles.child(assets.resolve("indexes"), safeId(id) + ".json"); + download(descriptor, index, progress); + JsonObject data = LauncherFiles.readJson(index); + boolean virtual = data.has("virtual") && data.get("virtual").getAsBoolean(); + boolean resources = data.has("map_to_resources") && data.get("map_to_resources").getAsBoolean(); + for (Map.Entry entry : data.getAsJsonObject("objects").entrySet()) { + JsonObject object = entry.getValue().getAsJsonObject(); + String hash = object.get("hash").getAsString(); + if (!hash.matches("[0-9a-fA-F]{40}")) throw new IOException("Invalid asset hash."); + String relative = hash.substring(0, 2) + "/" + hash; + Path destination = LauncherFiles.child(assets.resolve("objects"), relative); + Path virtualTarget = virtual ? LauncherFiles.child(assets.resolve("virtual").resolve(safeId(id)), entry.getKey()) : null; + Path resourceTarget = resources ? LauncherFiles.child(gameDir.resolve("resources"), entry.getKey()) : null; + tasks.add(() -> { + Path artifact = LauncherFiles.download(assetBaseUrl + relative, destination, hash, object.get("size").getAsLong(), progress); + if (virtualTarget != null) linkOrCopy(artifact, virtualTarget); + if (resourceTarget != null) linkOrCopy(artifact, resourceTarget); + return null; + }); + } + return id; + } + static void linkOrCopy(Path source, Path target) throws IOException { + if (Files.exists(target) && (Files.isSameFile(source, target) || (Files.size(target) == Files.size(source) + && com.osiris.autoplug.client.utils.UtilsCrypto.fastSHA1(source.toFile()).equals(com.osiris.autoplug.client.utils.UtilsCrypto.fastSHA1(target.toFile()))))) return; + Files.createDirectories(target.getParent()); + Files.deleteIfExists(target); + try { Files.createLink(target, source); } + catch (IOException | UnsupportedOperationException e) { Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); } + } + static void extractNatives(Path jar, Path target, List exclusions) throws IOException { + try (java.util.zip.ZipFile zip = new java.util.zip.ZipFile(jar.toFile())) { + java.util.Enumeration entries = zip.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + String name = entry.getName(); + if (entry.isDirectory() || exclusions.stream().anyMatch(name::startsWith)) continue; + Path output = LauncherFiles.child(target, name); + if (sameNative(output, entry)) continue; + Files.createDirectories(output.getParent()); + try (java.io.InputStream input = zip.getInputStream(entry)) { Files.copy(input, output, StandardCopyOption.REPLACE_EXISTING); } + } + } + } + private static boolean sameNative(Path output, ZipEntry entry) throws IOException { + if (!Files.isRegularFile(output) || Files.size(output) != entry.getSize()) return false; + java.util.zip.CRC32 crc = new java.util.zip.CRC32(); + try (java.io.InputStream input = Files.newInputStream(output)) { + byte[] buffer = new byte[65536]; int size; + while ((size = input.read(buffer)) != -1) crc.update(buffer, 0, size); + } + return crc.getValue() == entry.getCrc(); + } + static List buildArguments(JsonObject metadata, LaunchRequest request, List classpath, + Path natives, Path assets, String assetId, Path logging) throws Exception { + Map values = new HashMap<>(); + String id = metadata.get("id").getAsString(); + values.put("auth_player_name", request.account.username); values.put("version_name", id); + values.put("game_directory", request.gameDir.toString()); values.put("assets_root", assets.toString()); + values.put("assets_index_name", assetId); values.put("auth_uuid", request.account.uuid); + values.put("auth_access_token", request.account.accessToken); values.put("auth_session", request.account.accessToken); + values.put("auth_xuid", request.account.xuid); values.put("clientid", request.account.clientId); + values.put("user_type", request.account.offline ? "legacy" : "msa"); values.put("user_properties", "{}"); + values.put("profile_properties", "{}"); values.put("version_type", string(metadata, "type", "release")); + values.put("natives_directory", natives.toString()); values.put("launcher_name", "AutoPlug"); + values.put("launcher_version", "10.2.0"); values.put("classpath_separator", File.pathSeparator); + values.put("library_directory", assets.getParent().resolve("libraries").toString()); + values.put("game_assets", assets.resolve("virtual").resolve(assetId).toString()); + List classpathStrings = new ArrayList<>(); for (Path path : classpath) classpathStrings.add(path.toString()); + values.put("classpath", String.join(File.pathSeparator, classpathStrings)); + boolean quickPlay = request.serverHost != null && supportsQuickPlay(metadata, "${quickPlayMultiplayer}"); + boolean quickSingleplayer = request.singleplayerWorld != null && supportsQuickPlay(metadata, "${quickPlaySingleplayer}"); + Map features = new HashMap<>(); + features.put("is_quick_play_multiplayer", quickPlay); + features.put("is_quick_play_singleplayer", quickSingleplayer); + features.put("has_quick_plays_support", quickPlay || quickSingleplayer); + if (quickSingleplayer) values.put("quickPlaySingleplayer", request.singleplayerWorld); + if (quickPlay) { + String host = request.serverHost.contains(":") && !request.serverHost.startsWith("[") ? "[" + request.serverHost + "]" : request.serverHost; + values.put("quickPlayMultiplayer", host + ":" + request.serverPort); + } + if (quickPlay || quickSingleplayer) + values.put("quickPlayPath", request.gameDir.resolve("logs").resolve("quick-play.json").toString()); + List args = new ArrayList<>(); + args.add("-Xmx2G"); + if (metadata.has("arguments") && metadata.getAsJsonObject("arguments").has("jvm")) + expand(metadata.getAsJsonObject("arguments").getAsJsonArray("jvm"), values, features, args); + else { + if (osName().equals("osx")) args.add("-XstartOnFirstThread"); + args.add("-Djava.library.path=" + natives); args.add("-cp"); args.add(values.get("classpath")); + } + if (logging != null) { + String argument = metadata.getAsJsonObject("logging").getAsJsonObject("client").get("argument").getAsString(); + args.add(argument.replace("${path}", logging.toString())); + } + args.add(metadata.get("mainClass").getAsString()); + if (metadata.has("arguments") && metadata.getAsJsonObject("arguments").has("game")) + expand(metadata.getAsJsonObject("arguments").getAsJsonArray("game"), values, features, args); + else if (metadata.has("minecraftArguments")) { + for (String argument : new UtilsString().splitBySpacesAndQuotes(metadata.get("minecraftArguments").getAsString())) + args.add(substitute(argument, values)); + } else throw new IOException("Minecraft metadata has no game launch arguments."); + if (request.serverHost != null && !quickPlay) { + args.add("--server"); args.add(request.serverHost); args.add("--port"); args.add(Integer.toString(request.serverPort)); + } + if (request.fullscreen && !args.contains("--fullscreen")) args.add("--fullscreen"); + return args; + } + private static boolean supportsQuickPlay(JsonObject metadata, String placeholder) { + if (!metadata.has("arguments") || !metadata.getAsJsonObject("arguments").has("game")) return false; + for (JsonElement argument : metadata.getAsJsonObject("arguments").getAsJsonArray("game")) { + JsonElement value = argument.isJsonPrimitive() ? argument : argument.getAsJsonObject().get("value"); + if (value == null) continue; + if (value.isJsonPrimitive() && placeholder.equals(value.getAsString())) return true; + if (value.isJsonArray()) for (JsonElement element : value.getAsJsonArray()) + if (element.isJsonPrimitive() && placeholder.equals(element.getAsString())) return true; + } + return false; + } + private static void expand(JsonArray array, Map values, Map features, List output) throws IOException { + for (JsonElement item : array) { + if (item.isJsonPrimitive()) output.add(substitute(item.getAsString(), values)); + else { + JsonObject argument = item.getAsJsonObject(); + if (!allowed(argument, features)) continue; + JsonElement value = argument.get("value"); + if (value.isJsonArray()) for (JsonElement element : value.getAsJsonArray()) output.add(substitute(element.getAsString(), values)); + else output.add(substitute(value.getAsString(), values)); + } + } + } + static String substitute(String argument, Map values) throws IOException { + Matcher matcher = Pattern.compile("\\$\\{([^}]+)}").matcher(argument); + StringBuffer result = new StringBuffer(); + while (matcher.find()) { + String value = values.get(matcher.group(1)); + if (value == null) throw new IOException("Unsupported Minecraft launch parameter: " + matcher.group(1)); + matcher.appendReplacement(result, Matcher.quoteReplacement(value)); + } + matcher.appendTail(result); + return result.toString(); + } + static boolean allowed(JsonObject object, Map features) { + if (!object.has("rules")) return true; + boolean allow = false; + for (JsonElement value : object.getAsJsonArray("rules")) { + JsonObject rule = value.getAsJsonObject(); + if (rule.has("os")) { + JsonObject os = rule.getAsJsonObject("os"); + if (os.has("name") && !os.get("name").getAsString().equals(osName())) continue; + if (os.has("arch") && !System.getProperty("os.arch").matches(os.get("arch").getAsString())) continue; + if (os.has("version") && !Pattern.compile(os.get("version").getAsString()).matcher(System.getProperty("os.version")).find()) continue; + } + boolean matches = true; + if (rule.has("features")) for (Map.Entry feature : rule.getAsJsonObject("features").entrySet()) + if (features.getOrDefault(feature.getKey(), false) != feature.getValue().getAsBoolean()) { matches = false; break; } + if (matches) allow = rule.get("action").getAsString().equals("allow"); + } + return allow; + } + static JsonObject merge(JsonObject parent, JsonObject child) { + JsonObject merged = parent.deepCopy(); + for (Map.Entry field : child.entrySet()) { + if (field.getKey().equals("libraries")) { + Map libraries = new LinkedHashMap<>(); + for (JsonElement value : parent.getAsJsonArray("libraries")) libraries.put(libraryKey(value), value); + for (JsonElement value : field.getValue().getAsJsonArray()) libraries.put(libraryKey(value), value); + JsonArray array = new JsonArray(); libraries.values().forEach(array::add); merged.add("libraries", array); + } else if (field.getKey().equals("arguments")) { + JsonObject args = parent.has("arguments") ? parent.getAsJsonObject("arguments").deepCopy() : new JsonObject(); + for (Map.Entry arg : field.getValue().getAsJsonObject().entrySet()) { + JsonArray array = args.has(arg.getKey()) ? args.getAsJsonArray(arg.getKey()) : new JsonArray(); + for (JsonElement value : arg.getValue().getAsJsonArray()) array.add(value); + args.add(arg.getKey(), array); + } + merged.add("arguments", args); + } else merged.add(field.getKey(), field.getValue().deepCopy()); + } + return merged; + } + private static String libraryKey(JsonElement value) { + String[] parts = value.getAsJsonObject().get("name").getAsString().split(":"); + return parts[0] + ":" + parts[1] + (parts.length > 3 ? ":" + parts[3] : ""); + } + static String mavenPath(String coordinate) throws IOException { + String[] extension = coordinate.split("@", 2); + String[] parts = extension[0].split(":"); + if (parts.length < 3 || parts.length > 4) throw new IOException("Invalid Maven artifact: " + coordinate); + for (String part : parts) if (part.isEmpty() || part.contains("/") || part.contains("\\") || part.contains("..")) + throw new IOException("Invalid Maven artifact: " + coordinate); + String suffix = extension.length == 2 ? extension[1] : "jar"; + if (!suffix.matches("[A-Za-z0-9]+")) throw new IOException("Invalid artifact extension."); + return parts[0].replace('.', '/') + "/" + parts[1] + "/" + parts[2] + "/" + parts[1] + "-" + parts[2] + + (parts.length == 4 ? "-" + parts[3] : "") + "." + suffix; + } + static String osName() { + String value = System.getProperty("os.name").toLowerCase(Locale.ROOT); + return value.contains("win") ? "windows" : value.contains("mac") ? "osx" : "linux"; + } + static String safeId(String id) { return LaunchRequest.safeVersion(id); } + static String string(JsonObject object, String key, String fallback) { return object.has(key) && !object.get(key).isJsonNull() ? object.get(key).getAsString() : fallback; } +} diff --git a/src/main/java/com/osiris/autoplug/client/launcher/ParallelDownloads.java b/src/main/java/com/osiris/autoplug/client/launcher/ParallelDownloads.java new file mode 100644 index 00000000..bbd9517b --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/launcher/ParallelDownloads.java @@ -0,0 +1,112 @@ +package com.osiris.autoplug.client.launcher; + +import okhttp3.Call; + +import java.io.IOException; +import java.io.InterruptedIOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletionService; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorCompletionService; +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.function.Consumer; + +/** Bounded work queue shared by libraries, native archives, client jars and assets. */ +final class ParallelDownloads { + static final int WORKERS = 8; + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + + private ParallelDownloads() { } + + static Consumer serialized(Consumer progress) { + if (progress == null) return value -> { }; + return value -> { synchronized (progress) { progress.accept(value); } }; + } + + static void run(String description, List> tasks, Consumer progress) throws IOException { + Consumer log = serialized(progress); + log.accept(description + ": 0/" + tasks.size()); + if (tasks.isEmpty()) return; + if (Thread.currentThread().isInterrupted()) throw new InterruptedIOException("Minecraft preparation cancelled."); + Cancellation cancellation = new Cancellation(); + ExecutorService executor = Executors.newFixedThreadPool(Math.min(WORKERS, tasks.size()), task -> { + Thread thread = new Thread(task, "Minecraft file download"); + thread.setDaemon(true); + return thread; + }); + CompletionService completion = new ExecutorCompletionService<>(executor); + List> pending = new ArrayList<>(WORKERS); + int submitted = 0; + try { + // Never enqueue the entire asset index: keep only one task per worker outstanding. + for (; submitted < Math.min(WORKERS, tasks.size()); submitted++) + pending.add(submit(completion, cancellation, tasks.get(submitted))); + for (int done = 1; done <= tasks.size(); done++) { + Future finished = completion.take(); + pending.remove(finished); + finished.get(); + log.accept(description + ": " + done + "/" + tasks.size()); + if (submitted < tasks.size()) pending.add(submit(completion, cancellation, tasks.get(submitted++))); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new InterruptedIOException("Minecraft preparation cancelled."); + } catch (ExecutionException e) { + if (e.getCause() instanceof IOException) throw (IOException) e.getCause(); + if (e.getCause() instanceof Error) throw (Error) e.getCause(); + throw new IOException(description + " preparation failed.", e.getCause()); + } finally { + // Interrupting a Future alone does not interrupt OkHttp's blocking socket read. + cancellation.cancel(); + for (Future future : pending) future.cancel(true); + executor.shutdownNow(); + boolean interrupted = Thread.interrupted(); + try { executor.awaitTermination(5, TimeUnit.SECONDS); } + catch (InterruptedException e) { interrupted = true; } + finally { if (interrupted) Thread.currentThread().interrupt(); } + } + } + + private static Future submit(CompletionService completion, Cancellation cancellation, Callable task) { + return completion.submit(() -> { + CURRENT.set(cancellation); + try { + if (cancellation.cancelled.get()) throw new InterruptedIOException("Download cancelled."); + return task.call(); + } finally { CURRENT.remove(); } + }); + } + + static TrackedCall track(Call call) { return new TrackedCall(call, CURRENT.get()); } + + static final class TrackedCall implements AutoCloseable { + private final Call call; + private final Cancellation cancellation; + private TrackedCall(Call call, Cancellation cancellation) { + this.call = call; + this.cancellation = cancellation; + if (cancellation != null) { + cancellation.calls.add(call); + if (cancellation.cancelled.get()) call.cancel(); + } + } + @Override public void close() { if (cancellation != null) cancellation.calls.remove(call); } + } + + private static final class Cancellation { + final AtomicBoolean cancelled = new AtomicBoolean(); + final Set calls = ConcurrentHashMap.newKeySet(); + void cancel() { + cancelled.set(true); + for (Call call : calls) call.cancel(); + } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/launcher/PreparedLaunch.java b/src/main/java/com/osiris/autoplug/client/launcher/PreparedLaunch.java new file mode 100644 index 00000000..796d901d --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/launcher/PreparedLaunch.java @@ -0,0 +1,45 @@ +package com.osiris.autoplug.client.launcher; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** Fully resolved command; argument boundaries are retained, including paths with spaces. */ +public final class PreparedLaunch { + public final Path executable; + public final Path gameDir; + public final List arguments; + public final String version; + public final int javaMajor; + final List temporaryFiles; + + public PreparedLaunch(Path executable, Path gameDir, List arguments, String version, int javaMajor) { + this(executable, gameDir, arguments, version, javaMajor, Collections.emptyList()); + } + PreparedLaunch(Path executable, Path gameDir, List arguments, String version, int javaMajor, List temporaryFiles) { + this.executable = executable.toAbsolutePath().normalize(); + this.gameDir = gameDir.toAbsolutePath().normalize(); + this.arguments = Collections.unmodifiableList(new ArrayList<>(arguments)); + this.version = version; + this.javaMajor = javaMajor; + this.temporaryFiles = Collections.unmodifiableList(new ArrayList<>(temporaryFiles)); + } + public List command() { + List result = new ArrayList<>(); + result.add(executable.toString()); + result.addAll(arguments); + return result; + } + public List redactedCommand() { + List result = command(); + for (int i = 0; i + 1 < result.size(); i++) { + if (result.get(i).equals("--accessToken") || result.get(i).equals("--session")) result.set(++i, ""); + } + return result; + } + public Path getExecutable() { return executable; } + public Path getGameDir() { return gameDir; } + public List getArguments() { return arguments; } + @Override public String toString() { return redactedCommand().toString(); } +} diff --git a/src/main/java/com/osiris/autoplug/client/network/local/ConPluginCommandReceive.java b/src/main/java/com/osiris/autoplug/client/network/local/ConPluginCommandReceive.java index 298bf3cb..fab904e1 100644 --- a/src/main/java/com/osiris/autoplug/client/network/local/ConPluginCommandReceive.java +++ b/src/main/java/com/osiris/autoplug/client/network/local/ConPluginCommandReceive.java @@ -44,7 +44,8 @@ public ConPluginCommandReceive() { while (local_server_socket == null) { try { AL.debug(this.getClass(), "Binding on localhost:" + port + " for AutoPlug-Plugin..."); - local_server_socket = new ServerSocket(port); + local_server_socket = com.osiris.autoplug.client.worlds.AutoPlugWorldBootstrap.isChild() + ? new ServerSocket(port, 50, java.net.InetAddress.getLoopbackAddress()) : new ServerSocket(port); AL.debug(this.getClass(), "Success!"); } catch (IOException e) { AL.debug(this.getClass(), "Failed to bind on port " + port + "! " + e.getMessage()); diff --git a/src/main/java/com/osiris/autoplug/client/profiles/ArtifactCache.java b/src/main/java/com/osiris/autoplug/client/profiles/ArtifactCache.java new file mode 100644 index 00000000..ac5398dc --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/profiles/ArtifactCache.java @@ -0,0 +1,59 @@ +package com.osiris.autoplug.client.profiles; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.*; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** Content-addressed, immutable JAR cache. Replacements never write through existing links. */ +public class ArtifactCache { + private final Path root; + public ArtifactCache(Path root) throws IOException { this.root = root.toAbsolutePath().normalize(); Files.createDirectories(this.root); } + public Path getRoot() { return root; } + public Path store(Path source) throws IOException { + String hash = digest(source); + Path target = root.resolve("jars").resolve(hash.substring(0, 2)).resolve(hash + ".jar"); + Files.createDirectories(target.getParent()); + if (!Files.exists(target)) { + Path tmp = Files.createTempFile(target.getParent(), "artifact-", ".tmp"); + try { + Files.copy(source, tmp, StandardCopyOption.REPLACE_EXISTING); + if (!hash.equals(digest(tmp))) throw new IOException("Artifact changed while being cached"); + try { Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE); } + catch (FileAlreadyExistsException e) { /* Another process cached identical bytes. */ } + catch (AtomicMoveNotSupportedException e) { Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING); } + } finally { Files.deleteIfExists(tmp); } + } else if (!hash.equals(digest(target))) throw new IOException("Cached artifact failed integrity check: " + target); + return target; + } + public void link(Path cached, Path destination) throws IOException { + if (!cached.toAbsolutePath().normalize().startsWith(root) || Files.isSymbolicLink(cached)) + throw new IOException("Artifact is outside the cache"); + Files.createDirectories(destination.getParent()); + Path temp = Files.createTempFile(destination.getParent(), ".link-", ".tmp"); + Files.delete(temp); + try { + try { Files.createLink(temp, cached); } + catch (IOException | UnsupportedOperationException e) { + // Symlinks need privileges on some Windows installations; copies are the portable fallback. + try { Files.createSymbolicLink(temp, cached); } + catch (IOException | UnsupportedOperationException denied) { Files.copy(cached, temp); } + } + try { Files.move(temp, destination, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } + catch (AtomicMoveNotSupportedException e) { Files.move(temp, destination, StandardCopyOption.REPLACE_EXISTING); } + } finally { Files.deleteIfExists(temp); } + } + public String digest(Path file) throws IOException { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + try (InputStream in = Files.newInputStream(file)) { + byte[] buf = new byte[65536]; int n; + while ((n = in.read(buf)) != -1) md.update(buf, 0, n); + } + StringBuilder out = new StringBuilder(); + for (byte b : md.digest()) out.append(String.format("%02x", b & 255)); + return out.toString(); + } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/profiles/CollectionEntry.java b/src/main/java/com/osiris/autoplug/client/profiles/CollectionEntry.java new file mode 100644 index 00000000..1d406d47 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/profiles/CollectionEntry.java @@ -0,0 +1,8 @@ +package com.osiris.autoplug.client.profiles; + +/** Provider identity belongs to the collection, not a global updater configuration. */ +public class CollectionEntry { + public String file, name, version, author, modrinthId, curseforgeId, githubRepo, githubAsset, customCheckUrl, customDownloadUrl; + public int spigotId, bukkitId; + public boolean excluded; +} diff --git a/src/main/java/com/osiris/autoplug/client/profiles/FabricDefaultProfile.java b/src/main/java/com/osiris/autoplug/client/profiles/FabricDefaultProfile.java new file mode 100644 index 00000000..222d80a8 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/profiles/FabricDefaultProfile.java @@ -0,0 +1,197 @@ +package com.osiris.autoplug.client.profiles; + +import com.google.gson.*; +import com.osiris.autoplug.client.tasks.updater.mods.ModrinthAPI; +import com.osiris.autoplug.client.tasks.updater.search.SearchResult; +import java.io.*; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.time.Instant; +import java.util.*; +import java.util.function.Consumer; +import java.util.zip.ZipFile; + +/** A deliberately small client utility recipe. No shader packs, maps or gameplay changes. */ +public final class FabricDefaultProfile { + public static final String PRESET = "fabric-client-utilities-v1"; + static final List ROOTS = Collections.unmodifiableList(Arrays.asList( + "fabric-api", "sodium", "entityculling", "immediatelyfast", "modmenu")); + public interface Catalog { + JsonObject project(String id) throws Exception; + JsonArray versions(String project, String gameVersion) throws Exception; + JsonObject version(String id) throws Exception; + } + private final ProfileStore profiles; + private final Catalog catalog; + private final ProfileUpdates.Downloader downloader; + public FabricDefaultProfile(ProfileStore profiles, ProfileUpdates updates) { + this(profiles, new Catalog() { + private final ModrinthAPI api = new ModrinthAPI(); + public JsonObject project(String id) throws Exception { return api.projectMetadata(id); } + public JsonArray versions(String id, String game) throws Exception { return api.versionsFor(id, game, "fabric"); } + public JsonObject version(String id) throws Exception { return api.versionMetadata(id); } + }, updates::downloadVerified); + } + FabricDefaultProfile(ProfileStore profiles, Catalog catalog, ProfileUpdates.Downloader downloader) { + this.profiles = profiles; this.catalog = catalog; this.downloader = downloader; + } + /** Caller holds the profile lease through preparation and the launched process. */ + public void prepare(Profile profile, Consumer progress) throws Exception { + if (profile.builtinPreset == null || profile.builtinPresetInstalled) return; + if (!PRESET.equals(profile.builtinPreset) || profile.type != ProfileType.MODS || !"FABRIC".equals(profile.loader)) + throw new IOException("Unsupported default mod recipe for this profile"); + Consumer report = progress == null ? ignored -> { } : progress; + Path stage = profile.getDirectory().resolve(".updates").resolve("fabric-default-" + UUID.randomUUID()); + List activated = new ArrayList<>(); + byte[] oldProfile = Files.readAllBytes(profile.getDirectory().resolve("profile.json")); + Path collectionPath = profile.getDirectory().resolve("collection.json"); + byte[] oldCollection = Files.exists(collectionPath) ? Files.readAllBytes(collectionPath) : null; + try { + report.accept("Checking complete Fabric utility pack for Minecraft " + profile.gameVersion); + Resolver resolver = new Resolver(profile); + for (String root : ROOTS) resolver.resolve(root, null); + resolver.checkConflicts(); + if (Files.isSymbolicLink(stage.getParent())) throw new IOException("Profile update directory must not be a symbolic link"); + Files.createDirectories(stage); + ProfileCollection collection = new ProfileCollection().scan(profile); + if (!collection.entries.isEmpty()) throw new IOException("The uninstalled default profile already contains user mods. Use an empty default profile to avoid changing that collection."); + Map ready = new LinkedHashMap<>(); Set filenames = new HashSet<>(); + for (Artifact artifact : resolver.selected.values()) { + SearchResult release = artifact.release; + Path destination = collection.resolve(profile, release.fileName); + if (!filenames.add(release.fileName.toLowerCase(Locale.ROOT))) throw new IOException("Conflicting mod filenames: " + release.fileName); + if (Files.exists(destination)) throw new IOException("Utility pack would overwrite an existing mod: " + release.fileName + ". Use a separate profile."); + Path download = stage.resolve(release.fileName); + report.accept("Downloading Fabric utility " + artifact.name + " " + release.latestVersion); + downloader.download(release, download); + // Injected downloaders also pass through the same integrity/JAR checks in tests. + validateDownloaded(release, download); + ready.put(destination, profiles.getCache().store(download)); + CollectionEntry entry = new CollectionEntry(); entry.file = release.fileName; entry.name = artifact.name; + entry.version = release.latestVersion; entry.modrinthId = artifact.projectId; + collection.entries.add(entry); + } + // All releases, dependencies and bytes are valid before any mod becomes active. + if (!new ProfileCollection().scan(profile).entries.isEmpty()) throw new IOException("Profile collection changed during utility download; retry with an empty default profile"); + for (Map.Entry item : ready.entrySet()) { + if (Files.exists(item.getKey())) throw new IOException("Profile changed during utility download: " + item.getKey().getFileName()); + profiles.getCache().link(item.getValue(), item.getKey()); activated.add(item.getKey()); + } + collection.save(profile); + profile.builtinPresetInstalled = true; + profile.migrationSummary = "Fabric client utilities installed for " + profile.gameVersion + ": Fabric API, Sodium, Entity Culling, ImmediatelyFast, Mod Menu and required libraries. Updates use this profile's collection."; + profiles.save(profile); + report.accept("Fabric utility pack ready (" + resolver.selected.size() + " mods and libraries)"); + } catch (Exception e) { + profile.builtinPresetInstalled = false; + for (Path file : activated) try { Files.deleteIfExists(file); } catch (IOException rollback) { e.addSuppressed(rollback); } + try { restore(profile.getDirectory().resolve("profile.json"), oldProfile); } catch (IOException rollback) { e.addSuppressed(rollback); } + try { if (oldCollection == null) Files.deleteIfExists(collectionPath); else restore(collectionPath, oldCollection); } + catch (IOException rollback) { e.addSuppressed(rollback); } + throw new IOException("Fabric utility pack is not ready for Minecraft " + profile.gameVersion + ". Nothing will launch. " + e.getMessage(), e); + } finally { + if (Files.isDirectory(stage)) { + try (DirectoryStream files = Files.newDirectoryStream(stage)) { for (Path file : files) Files.deleteIfExists(file); } + Files.deleteIfExists(stage); + } + } + } + private void restore(Path path, byte[] bytes) throws IOException { + Path temporary = Files.createTempFile(path.getParent(), ".preset-rollback-", ".tmp"); + try { + Files.write(temporary, bytes); + try { Files.move(temporary, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } + catch (AtomicMoveNotSupportedException e) { Files.move(temporary, path, StandardCopyOption.REPLACE_EXISTING); } + } finally { Files.deleteIfExists(temporary); } + } + private void validateDownloaded(SearchResult release, Path path) throws Exception { + ProfileUpdates.verify(release, path); + try (ZipFile jar = new ZipFile(path.toFile())) { + java.util.zip.ZipEntry metadata = jar.getEntry("fabric.mod.json"); + if (metadata == null || metadata.getSize() > 1024 * 1024) throw new IOException("Mod has no bounded Fabric metadata: " + release.fileName); + try (InputStream input = jar.getInputStream(metadata)) { + byte[] bytes = new byte[1024 * 1024 + 1]; + int length = input.readNBytes(bytes, 0, bytes.length); + if (length > 1024 * 1024) throw new IOException("Fabric metadata exceeds 1 MiB"); + JsonObject mod = JsonParser.parseString(new String(bytes, 0, length, StandardCharsets.UTF_8)).getAsJsonObject(); + if (mod.has("environment") && "server".equals(mod.get("environment").getAsString())) + throw new IOException("Server-only artifact rejected: " + release.fileName); + } + } + } + private class Resolver { + final Profile profile; + final Map selected = new LinkedHashMap<>(); + final Map projects = new HashMap<>(); + Resolver(Profile profile) { this.profile = profile; } + Artifact resolve(String projectId, String pinnedVersion) throws Exception { + JsonObject pinned = pinnedVersion == null ? null : catalog.version(pinnedVersion); + if (pinned != null && !pinnedVersion.equals(string(pinned, "id"))) throw new IOException("Provider returned the wrong required dependency version"); + if (projectId == null && pinned != null) projectId = string(pinned, "project_id"); + if (projectId == null) throw new IOException("Required dependency has no project or version identity"); + JsonObject project = projects.get(projectId); + if (project == null) { project = catalog.project(projectId); projects.put(projectId, project); } + String id = string(project, "id"), name = string(project, "title"); projects.put(id, project); + if (!"mod".equals(string(project, "project_type")) || !Arrays.asList("required", "optional").contains(string(project, "client_side")) + || !Arrays.asList("unsupported", "optional").contains(string(project, "server_side"))) + throw new IOException(name + " is not a client-compatible mod usable without server changes"); + Artifact existing = selected.get(id); + if (existing != null) { + if (pinnedVersion != null && !pinnedVersion.equals(string(existing.version, "id"))) + throw new IOException("Conflicting required versions of " + name); + return existing; + } + if (selected.size() >= 32) throw new IOException("Utility dependency graph is unexpectedly large"); + JsonObject version = pinned; + if (version == null) for (JsonElement element : catalog.versions(id, profile.gameVersion)) { + JsonObject candidate = element.getAsJsonObject(); + if (compatible(candidate, id) && (version == null || Instant.parse(string(candidate, "date_published")).isAfter(Instant.parse(string(version, "date_published"))))) version = candidate; + } + if (version == null || !compatible(version, id)) + throw new IOException("No stable Fabric release of " + name + " for exact Minecraft " + profile.gameVersion); + JsonArray one = new JsonArray(); one.add(version); + SearchResult release = new ModrinthAPI().compatibleResult(one, List.of("fabric"), profile.gameVersion, profile.getDirectory().resolve(".not-installed")); + release.modrinthProjectId = id; + URI uri = URI.create(release.downloadUrl); + if (!"https".equals(uri.getScheme()) || !"cdn.modrinth.com".equals(uri.getHost()) || uri.getUserInfo() != null) + throw new IOException("Utility artifact is not hosted on Modrinth's HTTPS CDN"); + if (release.fileSize <= 0 || (release.sha512 == null && release.sha1 == null)) throw new IOException("Utility artifact has no verifiable size/checksum"); + Artifact artifact = new Artifact(id, name, version, release); selected.put(id, artifact); + JsonArray dependencies = version.getAsJsonArray("dependencies"); + if (dependencies != null) for (JsonElement element : dependencies) { + JsonObject dependency = element.getAsJsonObject(); + if ("required".equals(string(dependency, "dependency_type"))) resolve(nullable(dependency, "project_id"), nullable(dependency, "version_id")); + } + return artifact; + } + boolean compatible(JsonObject version, String projectId) throws IOException { + return projectId.equals(string(version, "project_id")) && "release".equals(string(version, "version_type")) + && (!version.has("status") || "listed".equals(string(version, "status"))) + && contains(version.getAsJsonArray("loaders"), "fabric") && contains(version.getAsJsonArray("game_versions"), profile.gameVersion); + } + void checkConflicts() throws IOException { + for (Artifact artifact : selected.values()) { + JsonArray dependencies = artifact.version.getAsJsonArray("dependencies"); + if (dependencies == null) continue; + for (JsonElement element : dependencies) { + JsonObject dependency = element.getAsJsonObject(); + if (!"incompatible".equals(string(dependency, "dependency_type"))) continue; + String project = nullable(dependency, "project_id"), version = nullable(dependency, "version_id"); + for (Artifact other : selected.values()) if ((project == null || project.equals(other.projectId)) + && (version == null || version.equals(string(other.version, "id")))) + throw new IOException("Provider declares incompatible utilities: " + artifact.name + " / " + other.name); + } + } + } + } + private static boolean contains(JsonArray values, String value) { if (values != null) for (JsonElement item : values) if (value.equals(item.getAsString())) return true; return false; } + private static String nullable(JsonObject object, String key) { return !object.has(key) || object.get(key).isJsonNull() ? null : object.get(key).getAsString(); } + private static String string(JsonObject object, String key) throws IOException { + String value = nullable(object, key); if (value == null || value.isEmpty()) throw new IOException("Provider metadata missing " + key); return value; + } + private static class Artifact { + final String projectId, name; final JsonObject version; final SearchResult release; + Artifact(String projectId, String name, JsonObject version, SearchResult release) { this.projectId = projectId; this.name = name; this.version = version; this.release = release; } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/profiles/JsonFiles.java b/src/main/java/com/osiris/autoplug/client/profiles/JsonFiles.java new file mode 100644 index 00000000..191b040d --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/profiles/JsonFiles.java @@ -0,0 +1,31 @@ +package com.osiris.autoplug.client.profiles; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; + +/** Atomic metadata replacement prevents a crash from truncating the previous configuration. */ +public class JsonFiles { + private final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + public T read(Path path, Class type) throws IOException { + if (Files.size(path) > 8 * 1024 * 1024) throw new IOException("Metadata exceeds 8 MiB: " + path); + try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { + T result = gson.fromJson(reader, type); + if (result == null) throw new IOException("Empty metadata: " + path); + return result; + } catch (com.google.gson.JsonParseException e) { throw new IOException("Invalid metadata: " + path, e); } + } + public void write(Path path, Object value) throws IOException { + Files.createDirectories(path.getParent()); + Path tmp = Files.createTempFile(path.getParent(), ".metadata-", ".tmp"); + try { + try (Writer writer = Files.newBufferedWriter(tmp, StandardCharsets.UTF_8)) { gson.toJson(value, writer); } + try { Files.move(tmp, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } + catch (AtomicMoveNotSupportedException e) { Files.move(tmp, path, StandardCopyOption.REPLACE_EXISTING); } + } finally { Files.deleteIfExists(tmp); } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/profiles/LauncherCommands.java b/src/main/java/com/osiris/autoplug/client/profiles/LauncherCommands.java new file mode 100644 index 00000000..8b6df386 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/profiles/LauncherCommands.java @@ -0,0 +1,164 @@ +package com.osiris.autoplug.client.profiles; + +import com.osiris.autoplug.client.browser.SavedServer; +import com.osiris.autoplug.client.browser.ServerAddress; +import com.osiris.autoplug.client.ui.LauncherActions; +import java.io.IOException; +import java.nio.file.*; +import java.util.*; +import java.util.function.Consumer; + +/** CLI and GUI call the same application services; arguments containing dots remain intact. */ +public class LauncherCommands { + private final LauncherServices services; + private final Consumer output; + public LauncherCommands(LauncherServices services, Consumer output) { this.services = services; this.output = output; } + public boolean handles(List args) { + if (args.isEmpty()) return false; + return args.get(0).equals(".profiles") || args.get(0).equals(".mc") + || (args.get(0).equals(".check") || args.get(0).equals(".update")) && args.contains("--profile"); + } + public void execute(List args) throws Exception { + if (!handles(args)) throw new IOException("Not a profile or Minecraft launcher command"); + if (args.get(0).equals(".profiles")) profiles(args); + else if (args.get(0).equals(".mc")) minecraft(args); + else { + String id = option(args, "--profile", null); require(id, "--profile is required"); + String collection = at(args, 1, "mods or plugins is required"); + Profile profile = services.getProfiles().get(id); + if (!collection.equals(profile.type.collectionDirectory())) throw new IOException("Collection type does not match the selected profile"); + if (args.get(0).equals(".check")) output.accept(services.checkProfile(id)); + else { + output.accept(services.checkProfile(id)); + if (profile.migrationPending && !args.contains("--yes")) throw new IOException("Review .check " + collection + " --profile " + id + " then repeat .update with --yes to confirm migration"); + output.accept(services.updateProfile(id)); + } + } + } + private void profiles(List args) throws Exception { + String verb = at(args, 1, help()); + switch (verb) { + case "list": + String type = option(args, "--type", null); + for (LauncherActions.ProfileInfo p : services.profiles()) if (type == null || normalizeType(type).equals(p.type)) + output.accept(p.id + " | " + p.name + " | " + p.type + " | " + p.gameVersion + " | " + p.loader + (p.template ? " | template" : "") + (p.launchable ? "" : " | migration pending")); + break; + case "create": + LauncherActions.ProfileInfo created = services.createProfile(at(args, 2, "Name required"), at(args, 3, "Game version required"), at(args, 4, "Loader required"), normalizeType(option(args, "--type", "mods")), args.contains("--template")); + output.accept("Created " + created.id + " in " + created.directory); break; + case "clone": + String id = at(args, 2, "Source id required"), version = at(args, 3, "Target game version required"); + Profile source = services.getProfiles().get(id); + LauncherActions.ProfileInfo cloned = services.cloneProfile(id, option(args, "--name", source.name + " " + version), version, option(args, "--loader", source.loader)); + output.accept("Cloned " + cloned.id + "\n" + cloned.migrationSummary); + if (!cloned.launchable && args.contains("--yes")) output.accept(services.updateProfile(cloned.id)); + else if (!cloned.launchable) output.accept("Review this plan and use .update " + source.type.collectionDirectory() + " --profile " + cloned.id + " --yes to finish migration."); + break; + case "delete": + if (!args.contains("--yes")) throw new IOException("Repeat with --yes to move this profile into recoverable trash"); + services.deleteProfile(at(args, 2, "Profile id required")); output.accept("Profile moved to trash."); break; + case "template": services.setTemplate(at(args, 2, "Profile id required"), !args.contains("--off")); output.accept("Template updated."); break; + case "add": + services.addArtifact(at(args, 2, "Profile id required"), Paths.get(at(args, 3, "JAR path required")), option(args, "--modrinth", null)); + output.accept("Artifact added to profile collection."); break; + default: throw new IOException(help()); + } + } + private void minecraft(List args) throws Exception { + String verb = at(args, 1, help()); + switch (verb) { + case "launch": + String host = option(args, "--server", null); int port = Integer.parseInt(option(args, "--port", "25565")); + if (host != null) { ServerAddress address = ServerAddress.parse(host); host = address.host; if (!args.contains("--port")) port = address.port; } + services.launchProfile(at(args, 2, "Client profile id required"), host, port); break; + case "servers": + String action = at(args, 2, "Use .mc servers list, import, or add "); + if (action.equals("list")) { + services.getServers().importVanilla(); + for (SavedServer s : services.getServers().list()) { + com.osiris.autoplug.client.browser.ServerStatus ping = services.getServers().ping(s); + output.accept(s.name + " | " + s.address + " | " + (ping.online ? ping.version + " | " + ping.players + "/" + ping.capacity + " | " + ping.latency + " ms | " + ping.motd : ping.message)); + } + } + else if (action.equals("import")) output.accept("Imported " + services.getServers().importVanilla() + " servers."); + else if (action.equals("add")) services.getServers().add(at(args, 3, "Name required"), at(args, 4, "Address required")); + else throw new IOException("Unknown server action"); break; + case "worlds": worlds(args); break; + case "account": + if (at(args, 2, "Use offline or microsoft ").equals("offline")) services.useOfflineAccount(at(args, 3, "Offline name required")); + else if (args.get(2).equals("microsoft")) { + LauncherActions.SettingsInfo settings = services.settings(); settings.microsoftClientId = at(args, 3, "Registered Microsoft application client ID required"); + settings.rememberAccount = args.contains("--remember"); services.saveSettings(settings); services.signInMicrosoft(output); + } else throw new IOException("Unknown account action"); + break; + default: throw new IOException(help()); + } + } + private void worlds(List args) throws Exception { + String action = at(args, 2, "Use .mc worlds list/create/launch/share/stop"); + switch (action) { + case "list": for (LauncherActions.WorldInfo w : services.worlds()) output.accept(w.id + " | " + w.name + " | " + + (w.local ? "singleplayer | " + (w.gameVersion.isEmpty() ? "version unknown" : w.gameVersion) : "managed server") + + " | " + (w.running ? "running" : "stopped")); break; + case "create": + LauncherActions.WorldInfo w = services.createWorld(at(args, 3, "Name required"), at(args, 4, "Server profile id required"), at(args, 5, "Client profile id required")); + if (args.contains("--accept-eula")) services.setWorldEulaAccepted(w.id, true); + output.accept("Created world " + w.id + (args.contains("--accept-eula") ? "" : "; EULA acceptance required before launch (https://aka.ms/MinecraftEULA).")); break; + case "launch": + String id = at(args, 3, "World id required"); + LauncherActions.WorldInfo selected = world(id); + if (selected.local) { + if (args.contains("--share") || args.contains("--accept-eula")) + throw new IOException("Singleplayer saves do not use AutoPlug server sharing or server EULA flags."); + String version = option(args, "--version", selected.gameVersion); + require(version, "This save has no recorded version. Use --version ; no upgrade is selected automatically."); + output.accept(services.launchLocalWorld(id, version)); + } else { + if (args.contains("--version")) throw new IOException("Managed worlds use their attached profiles; --version is for singleplayer saves."); + if (args.contains("--accept-eula")) services.setWorldEulaAccepted(id, true); + services.launchWorld(id, args.contains("--share")); + } + break; + case "share": output.accept(services.shareWorld(managedWorld(at(args, 3, "World id required")).id)); break; + case "stop": services.getWorldService().stopWorld(managedWorld(at(args, 3, "World id required")).id); break; + default: throw new IOException("Unknown world action"); + } + } + private LauncherActions.WorldInfo world(String id) throws Exception { + for (LauncherActions.WorldInfo world : services.worlds()) { + if (world.id.equals(id)) return world; + // Owning a save gives its visible entry a persistent world-UUID, but + // the documented local: CLI alias continues to identify it. + if (world.local && id.startsWith("local:") && id.equals("local:" + Paths.get(world.directory).getFileName())) return world; + } + throw new IOException("World not found: " + id); + } + private LauncherActions.WorldInfo managedWorld(String id) throws Exception { + LauncherActions.WorldInfo world = world(id); + if (world.local) throw new IOException("This is a singleplayer save. Use Minecraft's menu to leave it or open it to LAN."); + return world; + } + public String help() { + return ".profiles list/create/clone/delete/template/add; .check|.update mods|plugins --profile ; " + + ".mc launch [--server host[:port]]; .mc servers list/import/add; " + + ".mc worlds list/create/launch/share/stop; .mc account offline|microsoft"; + } + private String normalizeType(String value) { return value.toUpperCase(Locale.ROOT).replace('-', '_'); } + private String option(List args, String option, String fallback) throws IOException { + int index = args.indexOf(option); return index < 0 ? fallback : at(args, index + 1, option + " requires a value"); + } + private String at(List args, int index, String message) throws IOException { if (index >= args.size() || args.get(index).startsWith("--")) throw new IOException(message); return args.get(index); } + private void require(String value, String message) throws IOException { if (value == null || value.isEmpty()) throw new IOException(message); } + /** Quoting for interactive console input; argv passed to main needs no re-tokenization. */ + public List tokenize(String input) throws IOException { + List result = new ArrayList<>(); StringBuilder token = new StringBuilder(); char quote = 0; boolean started = false; + for (int i = 0; i < input.length(); i++) { + char c = input.charAt(i); + if (quote != 0) { if (c == quote) quote = 0; else token.append(c); started = true; } + else if (c == '"' || c == '\'') { quote = c; started = true; } + else if (Character.isWhitespace(c)) { if (started) { result.add(token.toString()); token.setLength(0); started = false; } } + else { token.append(c); started = true; } + } + if (quote != 0) throw new IOException("Unclosed quote"); if (started) result.add(token.toString()); return result; + } +} diff --git a/src/main/java/com/osiris/autoplug/client/profiles/LauncherEntry.java b/src/main/java/com/osiris/autoplug/client/profiles/LauncherEntry.java new file mode 100644 index 00000000..c64f9737 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/profiles/LauncherEntry.java @@ -0,0 +1,28 @@ +package com.osiris.autoplug.client.profiles; + +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.List; + +/** Standalone commands do not initialize the legacy server or network services. */ +public class LauncherEntry { + public boolean isLauncherCommand(String[] args) { + if (args == null || args.length == 0) return false; + List list = Arrays.asList(args); + return args[0].equals(".profiles") || args[0].equals(".mc") || (args[0].equals(".check") || args[0].equals(".update")) && list.contains("--profile"); + } + public int run(String[] args) { + java.nio.file.Path root = Paths.get(System.getProperty("autoplug.home", System.getProperty("user.home") + "/.autoplug")); + try { + java.nio.file.Files.createDirectories(root.resolve("logs")); + com.osiris.jlib.logger.AL.start("AP", false, root.resolve("logs/launcher.log").toFile(), true, false); + } catch (Exception e) { System.err.println("Could not initialize launcher logging: " + e.getMessage()); return 1; } + try (LauncherServices services = new LauncherServices(root, System.out::println)) { + Thread hook = new Thread(services::close, "launcher-cleanup"); Runtime.getRuntime().addShutdownHook(hook); + try { + new LauncherCommands(services, System.out::println).execute(Arrays.asList(args)); services.waitForSessions(); return 0; + } finally { Runtime.getRuntime().removeShutdownHook(hook); } + } catch (Exception e) { System.err.println("AutoPlug: " + e.getMessage()); return 1; } + finally { com.osiris.jlib.logger.AL.stop(); } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/profiles/LauncherServices.java b/src/main/java/com/osiris/autoplug/client/profiles/LauncherServices.java new file mode 100644 index 00000000..46fe5b18 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/profiles/LauncherServices.java @@ -0,0 +1,447 @@ +package com.osiris.autoplug.client.profiles; + +import com.osiris.autoplug.client.browser.ServerBrowserService; +import com.osiris.autoplug.client.launcher.*; +import com.osiris.autoplug.client.ui.LauncherActions; +import com.osiris.autoplug.client.worlds.*; +import java.io.IOException; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; +import java.util.zip.ZipFile; + +/** Application boundary shared by the dashboard and CLI. */ +public class LauncherServices implements LauncherActions, AutoCloseable { + public class State { + public SettingsInfo settings = new SettingsInfo(); + public String offlineName = "Player", selectedAccount = ""; + public Map launchChoices = new LinkedHashMap<>(); + } + private final Path root; + private final Consumer progress; + private volatile Consumer progressListener = ignored -> { }; + private final ProfileStore profiles; + private final ProfileUpdates updates; + private final FabricDefaultProfile fabricDefaults; + private final WorldStore worlds; + private final LocalWorldStore localWorlds; + private final WorldService worldService; + private final ServerBrowserService servers; + private final JavaRuntimeManager runtimes; + private final MinecraftLauncher launcher; + private final AccountStore accounts; + private final MicrosoftAccountService microsoft; + private long authenticationGeneration; + private Thread authenticationWorker; + private boolean authenticationClosed; + private final Map clients = new ConcurrentHashMap<>(); + private final Map localClients = new ConcurrentHashMap<>(); + private final Map plans = new ConcurrentHashMap<>(); + private final JsonFiles json = new JsonFiles(); + private State state; + private volatile MinecraftAccount account; + // World preparation and its client callback run on the requesting worker. Keep + // its identity stable if a settings worker switches accounts during installation. + private final ThreadLocal worldAccount = new ThreadLocal<>(); + + public LauncherServices(Path root, Consumer progress) throws Exception { + this(root, LocalWorldStore.defaultMinecraftDirectory(), progress); + } + /** An explicit Minecraft directory keeps local-save discovery testable without touching user saves. */ + public LauncherServices(Path root, Path minecraftDirectory, Consumer progress) throws Exception { + this(root, minecraftDirectory, progress, new MicrosoftAccountService()); + } + /** Injectable provider for deterministic account lifecycle tests. */ + LauncherServices(Path root, Path minecraftDirectory, Consumer progress, MicrosoftAccountService microsoft) throws Exception { + this.microsoft = Objects.requireNonNull(microsoft); + this.root = root.toAbsolutePath().normalize(); + Consumer log = progress == null ? ignored -> { } : progress; + this.progress = message -> { + try { log.accept(message); } catch (RuntimeException ignored) { } + try { progressListener.accept(message); } catch (RuntimeException ignored) { } + }; + Files.createDirectories(this.root); + profiles = new ProfileStore(this.root.resolve("profiles")); updates = new ProfileUpdates(profiles); + fabricDefaults = new FabricDefaultProfile(profiles, updates); + worlds = new WorldStore(this.root.resolve("worlds")); + localWorlds = new LocalWorldStore(minecraftDirectory, this.progress); + runtimes = new JavaRuntimeManager(this.root.resolve("cache/runtimes")); + launcher = new MinecraftLauncher(this.root.resolve("cache"), runtimes); + accounts = new AccountStore(this.root.resolve("accounts.json")); + state = Files.exists(this.root.resolve("settings.json")) ? json.read(this.root.resolve("settings.json"), State.class) : new State(); + if (state.settings == null) state.settings = new SettingsInfo(); + if (state.launchChoices == null) state.launchChoices = new LinkedHashMap<>(); + if (state.settings.javaPaths == null) state.settings.javaPaths = new LinkedHashMap<>(); + account = MinecraftAccount.offline(state.offlineName == null ? "Player" : state.offlineName); + if (state.settings.rememberAccount) for (MinecraftAccount saved : accounts.list()) if (saved.uuid.equals(state.selectedAccount)) account = saved; + if (!state.settings.rememberAccount) forgetStoredAccounts(); + applyRuntimeSettings(state.settings); + servers = new ServerBrowserService(this.root.resolve("servers.json"), minecraftDirectory.resolve("servers.dat")); + MinecraftServerInstaller installer = new MinecraftServerInstaller(profiles.getCache(), + version -> runtimes.resolve(launcher.requiredJavaMajor(version, this.progress), this.progress), this.progress, + (profile, directory) -> { + PreparedLaunch prepared = launcher.prepareServer(profile.gameVersion, profile.loader, profile.loaderVersion, directory, this.progress); + return new ServerLaunch(prepared.command(), prepared.gameDir); + }); + worldService = new WorldService(profiles, worlds, installer, (profile, host, port) -> launchClient(profile.id, host, port)); + worldService.setPreferredPort(state.settings.port); + worldService.setSharingEnabled(state.settings.upnp); + ensureDefaultProfiles(""); + } + public Path getRoot() { return root; } + public ProfileStore getProfiles() { return profiles; } + public WorldService getWorldService() { return worldService; } + public ServerBrowserService getServers() { return servers; } + @Override public void onProgress(Consumer listener) { progressListener = listener == null ? ignored -> { } : listener; } + @Override public synchronized ProfileInfo ensureDefaultProfiles(String version) throws Exception { + Profile selected = selectedDefault(); + String target = version == null ? "" : version.trim(); + if (target.isEmpty()) { + Profile configured = configuredDefault(); + if (configured != null) target = configured.gameVersion; + else { + for (Profile profile : profiles.list()) if (profile.type.isClient() && !profile.template && !profile.migrationPending) { target = profile.gameVersion; break; } + if (target.isEmpty()) target = "1.21.1"; // Same initial version as the profile creation form; no network needed. + } + } + Profile client = profiles.ensureDefault(target, ProfileType.MODS); + profiles.ensureDefault(target, ProfileType.MODS_SERVER); + profiles.ensureFabricDefault(target); + if (selected == null) { state.settings.defaultProfile = client.id; saveState(); } + return info(client); + } + private Profile selectedDefault() { + Profile profile = configuredDefault(); + return profile != null && !profile.template && !profile.migrationPending ? profile : null; + } + private Profile configuredDefault() { + String id = state.settings.defaultProfile; + if (id == null || id.isEmpty()) return null; + try { Profile profile = profiles.get(id); return profile.type.isClient() ? profile : null; } + catch (IOException e) { return null; } + } + @Override public List profiles() throws Exception { + List result = new ArrayList<>(); for (Profile p : profiles.list()) result.add(info(p)); return result; + } + @Override public ProfileInfo createProfile(String name, String version, String loader, String type, boolean template) throws Exception { + Profile p = profiles.create(name, version, loader, ProfileType.valueOf(type.toUpperCase(Locale.ROOT).replace('-', '_'))); + p.template = template; profiles.save(p); return info(p); + } + @Override public ProfileInfo cloneProfile(String sourceId, String name, String targetVersion, String loader) throws Exception { + requireIdle(sourceId); + Profile p = profiles.cloneProfile(sourceId, name, targetVersion, loader); + if (p.migrationPending) { p.migrationSummary = checkProfile(p.id); profiles.save(p); } + return info(p); + } + @Override public void deleteProfile(String id) throws Exception { + requireIdle(id); + for (VirtualWorld w : worlds.list()) if (id.equals(w.serverProfileId) || id.equals(w.clientProfileId)) + throw new IOException("Profile is referenced by world '" + w.name + "'. Change or remove that world first."); + profiles.delete(id); plans.remove(id); + } + @Override public String checkProfile(String id) throws Exception { + requireIdle(id); ProfileUpdates.Plan plan = updates.check(id); plans.put(id, plan); return plan.summary(); + } + @Override public String updateProfile(String id) throws Exception { + requireIdle(id); ProfileUpdates.Plan plan = plans.remove(id); + if (plan == null) throw new IOException("Check this profile and review its update plan before applying"); + return updates.apply(plan, true); + } + @Override public void setTemplate(String id, boolean template) throws Exception { + Profile p = profiles.get(id); + try (ProfileLease ignored = new ProfileLease(p.getDirectory())) { p.template = template; profiles.save(p); } + } + public void addArtifact(String id, Path source, String modrinthId) throws Exception { + requireIdle(id); Profile p = profiles.get(id); + if (!source.getFileName().toString().endsWith(".jar")) throw new IOException("Select a JAR artifact"); + try (ZipFile ignored = new ZipFile(source.toFile()); ProfileLease lease = new ProfileLease(p.getDirectory())) { + Path destination = new ProfileCollection().resolve(p, source.getFileName().toString()); + if (Files.exists(destination)) throw new IOException("That artifact filename already exists in this profile"); + profiles.getCache().link(profiles.getCache().store(source), destination); + ProfileCollection collection = new ProfileCollection().scan(p); + for (CollectionEntry entry : collection.entries) if (entry.file.equals(source.getFileName().toString()) && modrinthId != null) entry.modrinthId = modrinthId; + collection.save(p); plans.remove(id); + } + } + @Override public void addArtifact(String id, String source, String modrinthId) throws Exception { + addArtifact(id, Paths.get(source), modrinthId == null || modrinthId.trim().isEmpty() ? null : modrinthId.trim()); + } + @Override public List worlds() throws Exception { + List result = new ArrayList<>(); + Map activity = new HashMap<>(); + try { + for (LocalWorld world : localWorlds.list()) { + LocalWorld owned = world; + try { owned = worlds.ownedLocal(world, false); } + catch (IOException e) { progress.accept("Could not load ownership for '" + world.name + "': " + e.getMessage()); } + WorldInfo view = info(owned); result.add(view); + activity.put(view.id, worldActivity(view, 0)); + } + } catch (IOException | SecurityException e) { + progress.accept("Could not list local Minecraft saves: " + e.getMessage()); + } + for (VirtualWorld w : worlds.list()) { + WorldInfo view = info(w); result.add(view); + activity.put(view.id, worldActivity(view, w.createdAt)); + } + result.sort(Comparator.comparingLong(world -> activity.getOrDefault(world.id, 0L)).reversed() + .thenComparing(world -> world.name, String.CASE_INSENSITIVE_ORDER).thenComparing(world -> world.id)); + return result; + } + /** A bounded background metadata read: creation, modification and LastPlayed all count as activity. */ + static long worldActivity(WorldInfo world, long createdAt) { + long latest = Math.max(Math.max(0, createdAt), world.lastPlayed); + try { + Path directory = Paths.get(world.directory); + for (Path path : Arrays.asList(directory, directory.resolve("level.dat"), directory.resolve("world/level.dat"))) { + try { + java.nio.file.attribute.BasicFileAttributes attributes = Files.readAttributes(path, + java.nio.file.attribute.BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + if (attributes.isSymbolicLink()) continue; + latest = Math.max(latest, Math.max(attributes.creationTime().toMillis(), attributes.lastModifiedTime().toMillis())); + } catch (IOException | SecurityException ignored) { } + } + } catch (InvalidPathException | NullPointerException ignored) { } + return latest; + } + @Override public String launchLocalWorld(String id, String version) throws Exception { + return launchLocalWorld(id, version, ""); + } + @Override public String launchLocalWorld(String id, String version, String profileId) throws Exception { + LocalWorld world = worlds.getLocal(id, localWorlds); + String selected = localWorlds.launchVersion(world, version); + Profile chosen = profileId == null || profileId.isEmpty() ? profiles.ensureDefault(selected, ProfileType.MODS) : profiles.get(profileId); + if (!chosen.type.isClient() || chosen.template || chosen.migrationPending || !"VANILLA".equals(chosen.loader) || !selected.equals(chosen.gameVersion)) + throw new IOException("Existing local saves require a ready Vanilla profile with exactly the save's selected version."); + // All standard saves share one game directory and its settings/native files. + // Retain one lease for that directory until the child exits, including preparation failures. + ProfileLease lease = new ProfileLease(world.gameDirectory); + try { + MinecraftAccount current = currentAccount(); + PreparedLaunch prepared = launcher.prepare(new LaunchRequest(world.gameDirectory, selected, "VANILLA", null, + current, null, 0, world.saveName(), state.settings.fullscreen), progress); + LocalWorld fresh = worlds.getLocal(id, localWorlds); + if (!fresh.gameVersion.equals(world.gameVersion) || fresh.modded != world.modded) + throw new IOException("The save's version metadata changed while preparing Minecraft. Refresh and select it again."); + localWorlds.launchVersion(fresh, selected); + LocalWorld owned = worlds.registerLocal(fresh); + Process process = launcher.launch(prepared); trackLocalClient(owned.id, process, lease); + try { rememberProfile("world:" + id, chosen.id); } + catch (IOException e) { progress.accept("Minecraft started, but the launch choice could not be saved: " + e.getMessage()); } + if (prepared.arguments.contains("--quickPlaySingleplayer")) + return "Started Minecraft " + selected + " with Quick Play for '" + world.name + "'."; + return "Started Minecraft " + selected + " with automatic entry for '" + world.name + "'."; + } catch (Exception e) { lease.close(); throw e; } + } + /** Existing saves must be closed from Minecraft so its integrated server can save normally. */ + void trackLocalClient(String id, Process process, ProfileLease lease) { + localClients.put(id, process); + process.onExit().thenRun(() -> { + localClients.remove(id, process); + try { lease.close(); } + catch (IOException e) { progress.accept("Could not release local Minecraft lock: " + e.getMessage()); } + }); + } + @Override public WorldInfo createWorld(String name, String serverProfileId, String clientProfileId) throws Exception { + if (clientProfileId == null || clientProfileId.trim().isEmpty()) { + String version = serverProfileId == null || serverProfileId.trim().isEmpty() ? "" : profiles.get(serverProfileId).gameVersion; + clientProfileId = ensureDefaultProfiles(version).id; + } + if (serverProfileId == null || serverProfileId.trim().isEmpty()) + serverProfileId = profiles.ensureDefault(profiles.get(clientProfileId).gameVersion, ProfileType.MODS_SERVER).id; + Profile server = profiles.get(serverProfileId), client = profiles.get(clientProfileId); + if (server.type.isClient() || !client.type.isClient()) throw new IOException("Choose a server profile and a client profile"); + if (!server.gameVersion.equals(client.gameVersion)) throw new IOException("World server and client must use the same game version"); + if (client.template) throw new IOException("Clone the client template before attaching it to a world"); + if (server.type == ProfileType.MODS_SERVER && !"VANILLA".equals(server.loader) && !server.loader.equals(client.loader)) throw new IOException("Modded world and client loaders must match"); + if (server.type == ProfileType.MODS_SERVER && server.loaderVersion != null && client.loaderVersion != null && !server.loaderVersion.equals(client.loaderVersion)) + throw new IOException("Pinned world and client loader versions must match"); + return info(worlds.create(name, serverProfileId, clientProfileId)); + } + @Override public void setWorldEulaAccepted(String id, boolean accepted) throws Exception { worlds.setEulaAccepted(id, accepted); } + @Override public void launchWorld(String id, boolean share) throws Exception { + MinecraftAccount current = currentAccount(); + worldAccount.set(current); + try { + WorldSession session = worldService.launch(id, share, !current.offline); + progress.accept("World ready on localhost:" + session.getPort()); + if (share) progress.accept(session.getShareResult().getMessage()); + } finally { worldAccount.remove(); } + } + @Override public synchronized void launchWorld(String id, boolean share, String profileId) throws Exception { + VirtualWorld world = worlds.get(id); + Profile client = profiles.get(profileId), server = profiles.get(world.serverProfileId); + if (!client.type.isClient() || client.template || client.migrationPending || !client.gameVersion.equals(server.gameVersion) + || (server.type == ProfileType.MODS_SERVER && !"VANILLA".equals(server.loader) && !server.loader.equals(client.loader))) + throw new IOException("Choose a ready client matching this world's Minecraft version and mod loader."); + if (!world.clientProfileId.equals(profileId) && !worldService.activeSessions().isEmpty()) { + for (WorldSession session : worldService.activeSessions()) if (session.getWorld().id.equals(id)) + throw new IOException("Stop this world before changing its client profile."); + } + world.clientProfileId = profileId; worlds.save(world); + launchWorld(id, share); + try { rememberProfile("world:" + id, profileId); } + catch (IOException e) { progress.accept("World started, but the launch choice could not be saved: " + e.getMessage()); } + } + @Override public synchronized String preferredProfile(String target) { + String id = state.launchChoices.getOrDefault(target, ""); + if (id.isEmpty()) return ""; + try { Profile p = profiles.get(id); return p.type.isClient() && !p.template && !p.migrationPending ? id : ""; } + catch (IOException e) { return ""; } + } + @Override public synchronized void rememberProfile(String target, String profileId) throws IOException { + if (target == null || target.length() > 1024 || !(target.startsWith("server:") || target.startsWith("world:"))) throw new IOException("Invalid launch target"); + Profile profile = profiles.get(profileId); + if (!profile.type.isClient() || profile.template || profile.migrationPending) throw new IOException("Choose a ready client profile"); + state.launchChoices.put(target, profileId); saveState(); + } + @Override public String shareWorld(String id) { + ShareResult result = worldService.shareWorld(id); return result.isShared() ? result.address + "\n" + result.message : result.message; + } + @Override public String serverLog(String id) throws Exception { return worldService.serverLog(id); } + @Override public void serverCommand(String id, String command) throws Exception { worldService.serverCommand(id, command); } + @Override public void stopWorld(String id) { worldService.stopWorld(id); } + @Override public void restartWorld(String id) throws Exception { worldService.restartWorld(id); } + @Override public void launchProfile(String id, String host, int port) throws Exception { launchClient(id, host, port); } + private Process launchClient(String id, String host, int port) throws Exception { + if (id == null || id.trim().isEmpty()) { + synchronized (this) { Profile selected = selectedDefault(); id = selected == null ? ensureDefaultProfiles("").id : selected.id; } + } + Profile profile = profiles.get(id); + if (!profile.type.isClient()) throw new IOException("Choose a MODS client profile"); + if (profile.template) throw new IOException("Clone the template before launching it"); + if (profile.migrationPending) throw new IOException("Review and apply profile migration before launching"); + ProfileLease lease = new ProfileLease(profile.getDirectory()); + try { + fabricDefaults.prepare(profile, progress); + MinecraftAccount current = worldAccount.get(); + if (current == null) current = currentAccount(); + else if (current.needsRefresh()) current = microsoft.refresh(current); + PreparedLaunch prepared = launcher.prepare(new LaunchRequest(profile.getDirectory(), profile.gameVersion, profile.loader, profile.loaderVersion, current, host, port, state.settings.fullscreen), progress); + Process process = launcher.launch(prepared); clients.put(profile.id, process); + process.onExit().thenRun(() -> { clients.remove(profile.id, process); try { lease.close(); } catch (IOException e) { progress.accept("Could not release profile lock: " + e.getMessage()); } }); + return process; + } catch (Exception e) { lease.close(); throw e; } + } + private synchronized MinecraftAccount currentAccount() throws Exception { + if (account.needsRefresh()) { + account = microsoft.refresh(account); + if (state.settings.rememberAccount) accounts.save(account); + } + return account; + } + @Override public synchronized SettingsInfo settings() { + SettingsInfo copy = new SettingsInfo(); + copy.java8 = state.settings.java8; copy.java17 = state.settings.java17; copy.java21 = state.settings.java21; + copy.javaPaths = new LinkedHashMap<>(state.settings.javaPaths); copy.defaultProfile = state.settings.defaultProfile; + copy.port = state.settings.port; copy.upnp = state.settings.upnp; copy.microsoftClientId = state.settings.microsoftClientId; + copy.fullscreen = state.settings.fullscreen; copy.rememberAccount = state.settings.rememberAccount; copy.account = account.username + (account.offline ? " (offline)" : " (Microsoft)"); return copy; + } + @Override public synchronized void saveSettings(SettingsInfo settings) throws Exception { + if (settings.port < 1 || settings.port > 65535) throw new IOException("Port must be between 1 and 65535"); + if (settings.defaultProfile != null && !settings.defaultProfile.isEmpty()) profiles.get(settings.defaultProfile); + for (Integer major : state.settings.javaPaths.keySet()) runtimes.setRuntime(major, null); + applyRuntimeSettings(settings); + worldService.setPreferredPort(settings.port); + worldService.setSharingEnabled(settings.upnp); + if (authenticationWorker != null && (!Objects.equals(settings.microsoftClientId, state.settings.microsoftClientId) + || settings.rememberAccount != state.settings.rememberAccount)) cancelMicrosoftSignIn(); + if (!settings.rememberAccount) forgetStoredAccounts(); + state.settings = settings; saveState(); + } + private void applyRuntimeSettings(SettingsInfo settings) { + if (settings.javaPaths == null) settings.javaPaths = new LinkedHashMap<>(); + Map values = new LinkedHashMap<>(settings.javaPaths); + values.put(8, settings.java8); values.put(17, settings.java17); values.put(21, settings.java21); + for (Map.Entry value : values.entrySet()) { + if (value.getKey() < 8) throw new IllegalArgumentException("Java major must be at least 8"); + String path = value.getValue(); runtimes.setRuntime(value.getKey(), path == null || path.trim().isEmpty() ? null : Paths.get(path)); + } + } + @Override public synchronized long beginAccountChange() { + if (authenticationClosed) throw new java.util.concurrent.CancellationException("Launcher is closed"); + cancelMicrosoftSignIn(); + return authenticationGeneration; + } + @Override public synchronized void cancelMicrosoftSignIn() { + authenticationGeneration++; + Thread worker = authenticationWorker; authenticationWorker = null; + if (worker != null && worker != Thread.currentThread()) worker.interrupt(); + } + private void requireCurrentAuthentication(long attempt) { + if (authenticationClosed || attempt != authenticationGeneration || Thread.currentThread().isInterrupted()) + throw new java.util.concurrent.CancellationException("Microsoft sign-in cancelled"); + } + @Override public void signInMicrosoft(Consumer instructions) throws Exception { + signInMicrosoft(beginAccountChange(), null, instructions); + } + @Override public void signInMicrosoft(long attempt, SettingsInfo settings, Consumer instructions) throws Exception { + String clientId; + synchronized (this) { + requireCurrentAuthentication(attempt); + if (settings != null) saveSettings(settings); + requireCurrentAuthentication(attempt); + authenticationWorker = Thread.currentThread(); clientId = state.settings.microsoftClientId; + } + try { + MicrosoftAccountService.DeviceLogin device = microsoft.beginLogin(clientId); + synchronized (this) { requireCurrentAuthentication(attempt); instructions.accept(device.message); } + MinecraftAccount signedIn = microsoft.completeLogin(device); + synchronized (this) { + requireCurrentAuthentication(attempt); + if (state.settings.rememberAccount) accounts.save(signedIn); + account = signedIn; state.selectedAccount = signedIn.uuid; saveState(); + } + } catch (Exception failure) { + synchronized (this) { requireCurrentAuthentication(attempt); } + throw failure; + } finally { + synchronized (this) { + if (attempt == authenticationGeneration && authenticationWorker == Thread.currentThread()) authenticationWorker = null; + } + } + } + @Override public synchronized void useOfflineAccount(long attempt, String name) throws Exception { + requireCurrentAuthentication(attempt); useOfflineAccount(name); + } + @Override public synchronized void useOfflineAccount(String name) throws Exception { + MinecraftAccount offline = MinecraftAccount.offline(name); + cancelMicrosoftSignIn(); + account = offline; state.offlineName = name; state.selectedAccount = ""; saveState(); + } + private void saveState() throws IOException { json.write(root.resolve("settings.json"), state); } + private void forgetStoredAccounts() throws IOException { for (MinecraftAccount saved : accounts.list()) accounts.remove(saved.uuid); } + private void requireIdle(String id) throws IOException { + if (clients.containsKey(id) || worldService.isProfileInUse(id)) throw new IOException("Stop the running client/world before changing this profile"); + } + private ProfileInfo info(Profile p) { return new ProfileInfo(p.id, p.name, p.gameVersion, p.loader, p.type.name(), p.getDirectory().toString(), p.template, p.migrationSummary == null ? "" : p.migrationSummary, !p.migrationPending); } + private WorldInfo info(VirtualWorld w) throws IOException { + boolean running = worldService.activeSessions().stream().anyMatch(s -> s.getWorld().id.equals(w.id)); + Path directory = worlds.getDirectory(w.id); + if (Files.isRegularFile(directory.resolve("world/level.dat"), LinkOption.NOFOLLOW_LINKS)) { + try { + LocalWorld metadata = LocalWorldStore.inspect(w.id, directory.resolve("world"), directory); + return new WorldInfo(w.id, w.name, w.serverProfileId, w.clientProfileId, directory.toString(), metadata.icon == null ? w.thumbnail : metadata.icon.toString(), running, + false, metadata.gameVersion, metadata.modded, metadata.lastPlayed, metadata.sizeBytes, metadata.sizeComplete, + metadata.dataPacks, metadata.cheats, metadata.hardcore, false); + } catch (IOException e) { progress.accept("Could not read world details for '" + w.name + "': " + e.getMessage()); } + } + return new WorldInfo(w.id, w.name, w.serverProfileId, w.clientProfileId, directory.toString(), w.thumbnail, running, false, profiles.get(w.serverProfileId).gameVersion); + } + private WorldInfo info(LocalWorld world) { + return new WorldInfo(world.id, world.name, "", "", world.directory.toString(), world.icon == null ? null : world.icon.toString(), + localClients.containsKey(world.id), true, world.gameVersion, world.modded, world.lastPlayed, world.sizeBytes, world.sizeComplete, + world.dataPacks, world.cheats, world.hardcore, world.quickPlayEligible); + } + public void waitForSessions() throws InterruptedException { + while (!clients.isEmpty() || !localClients.isEmpty() || !worldService.activeSessions().isEmpty()) Thread.sleep(250); + } + @Override public void close() { + synchronized (this) { authenticationClosed = true; cancelMicrosoftSignIn(); } + worldService.close(); + for (Process process : clients.values()) if (process.isAlive()) process.destroy(); + // Never destroy localClients: abrupt termination can corrupt an original user's save. + // Their exit callbacks retain/release the lease while this launcher JVM remains alive. + } +} diff --git a/src/main/java/com/osiris/autoplug/client/profiles/Profile.java b/src/main/java/com/osiris/autoplug/client/profiles/Profile.java new file mode 100644 index 00000000..87deac2c --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/profiles/Profile.java @@ -0,0 +1,26 @@ +package com.osiris.autoplug.client.profiles; + +import java.nio.file.Path; + +/** Persisted profile identity; its directory is always derived by the store. */ +public class Profile { + public String id, name, gameVersion, loader, loaderVersion; + public ProfileType type; + public boolean template; + public boolean migrationPending; + public String migrationSummary = ""; + /** A bundled recipe is installed once; later user edits are never re-seeded. */ + public String builtinPreset; + public boolean builtinPresetInstalled; + private transient Path directory; + + public String getId() { return id; } + public String getName() { return name; } + public String getGameVersion() { return gameVersion; } + public String getLoader() { return loader; } + public String getLoaderVersion() { return loaderVersion; } + public ProfileType getType() { return type; } + public boolean isTemplate() { return template; } + public Path getDirectory() { return directory; } + void setDirectory(Path directory) { this.directory = directory; } +} diff --git a/src/main/java/com/osiris/autoplug/client/profiles/ProfileCollection.java b/src/main/java/com/osiris/autoplug/client/profiles/ProfileCollection.java new file mode 100644 index 00000000..79a4b627 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/profiles/ProfileCollection.java @@ -0,0 +1,48 @@ +package com.osiris.autoplug.client.profiles; + +import com.osiris.autoplug.client.tasks.updater.mods.MinecraftMod; +import com.osiris.autoplug.client.tasks.updater.plugins.MinecraftPlugin; +import com.osiris.autoplug.client.utils.UtilsMinecraft; +import java.io.IOException; +import java.nio.file.*; +import java.util.*; + +public class ProfileCollection { + public List entries = new ArrayList<>(); + public ProfileCollection scan(Profile profile) throws IOException { + Path dir = profile.getDirectory().resolve(profile.type.collectionDirectory()); + Files.createDirectories(dir); + Path metadata = profile.getDirectory().resolve("collection.json"); + ProfileCollection result = Files.exists(metadata) ? new JsonFiles().read(metadata, ProfileCollection.class) : new ProfileCollection(); + Map found = new LinkedHashMap<>(); + if (profile.type == ProfileType.PLUGINS) { + for (MinecraftPlugin p : new UtilsMinecraft().getPlugins(dir.toFile())) { + CollectionEntry entry = new CollectionEntry(); entry.file = Paths.get(p.installationPath).getFileName().toString(); + entry.name = p.getName(); entry.version = p.getVersion(); entry.author = p.getAuthor(); + entry.modrinthId = p.modrinthId; entry.spigotId = p.spigotId; entry.bukkitId = p.bukkitId; found.put(entry.file, entry); + } + } else for (MinecraftMod m : new UtilsMinecraft().getMods(dir.toFile())) { + CollectionEntry entry = new CollectionEntry(); entry.file = Paths.get(m.installationPath).getFileName().toString(); + entry.name = m.getName(); entry.version = m.getVersion(); entry.author = m.getAuthor(); + entry.modrinthId = m.modrinthId; entry.curseforgeId = m.curseforgeId; found.put(entry.file, entry); + } + // Include unreadable/unknown jars so migrations never silently leave them active. + try (DirectoryStream jars = Files.newDirectoryStream(dir, "*.jar")) { + for (Path jar : jars) if (!found.containsKey(jar.getFileName().toString())) { + CollectionEntry entry = new CollectionEntry(); entry.file = jar.getFileName().toString(); entry.name = entry.file; found.put(entry.file, entry); + } + } + for (CollectionEntry configured : result.entries) { + resolve(profile, configured.file); // Reject persisted paths outside the selected collection. + if (found.containsKey(configured.file)) found.put(configured.file, configured); + } + result.entries = new ArrayList<>(found.values()); return result; + } + public Path resolve(Profile profile, String file) throws IOException { + if (file == null || file.contains("/") || file.contains("\\") || !file.endsWith(".jar") || file.equals(".jar")) throw new IOException("Invalid collection artifact filename"); + Path directory = profile.getDirectory().resolve(profile.type.collectionDirectory()); + if (Files.isSymbolicLink(directory)) throw new IOException("Collection directory must not be a link"); + return directory.resolve(file); + } + public void save(Profile profile) throws IOException { new JsonFiles().write(profile.getDirectory().resolve("collection.json"), this); } +} diff --git a/src/main/java/com/osiris/autoplug/client/profiles/ProfileLease.java b/src/main/java/com/osiris/autoplug/client/profiles/ProfileLease.java new file mode 100644 index 00000000..0b81dea6 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/profiles/ProfileLease.java @@ -0,0 +1,35 @@ +package com.osiris.autoplug.client.profiles; + +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.channels.OverlappingFileLockException; +import java.nio.file.Path; +import java.nio.file.Files; +import java.nio.file.StandardOpenOption; + +/** An OS lock, shared by update/launch/delete operations across AutoPlug processes. */ +public class ProfileLease implements AutoCloseable { + private final FileChannel channel; + private final FileLock lock; + public ProfileLease(Path directory) throws IOException { + Path normalized = directory.toAbsolutePath().normalize(); + if (!Files.isDirectory(normalized)) throw new IOException("Profile directory does not exist: " + normalized); + // Windows cannot rename a directory containing an open lock file. A sibling + // lock keeps delete/restore atomic without releasing protection before rename. + Path locks = normalized.getParent().resolve(".autoplug-locks"); + Files.createDirectories(locks); + channel = FileChannel.open(locks.resolve(normalized.getFileName() + ".lock"), StandardOpenOption.CREATE, StandardOpenOption.WRITE); + FileLock acquired = null; + try { + acquired = channel.tryLock(); + if (acquired == null) throw new IOException("Profile is in use: " + directory.getFileName()); + if (!Files.isDirectory(normalized)) throw new IOException("Profile directory no longer exists: " + normalized); + } catch (IOException | OverlappingFileLockException e) { + channel.close(); + throw new IOException("Profile is in use: " + directory.getFileName(), e); + } + lock = acquired; + } + @Override public void close() throws IOException { try { if (lock.isValid()) lock.release(); } finally { channel.close(); } } +} diff --git a/src/main/java/com/osiris/autoplug/client/profiles/ProfileStore.java b/src/main/java/com/osiris/autoplug/client/profiles/ProfileStore.java new file mode 100644 index 00000000..f7b31de3 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/profiles/ProfileStore.java @@ -0,0 +1,126 @@ +package com.osiris.autoplug.client.profiles; + +import java.io.IOException; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.*; + +/** Independent profile metadata/configuration, with shared immutable artifact storage. */ +public class ProfileStore { + private final Path root; + private final JsonFiles json = new JsonFiles(); + private final ArtifactCache cache; + public ProfileStore(Path root) throws IOException { + this(root, new ArtifactCache(root.toAbsolutePath().normalize().getParent().resolve("cache"))); + } + public ProfileStore(Path root, ArtifactCache cache) throws IOException { + this.root = root.toAbsolutePath().normalize(); this.cache = cache; Files.createDirectories(this.root); + } + public Path getRoot() { return root; } + public ArtifactCache getCache() { return cache; } + public List list() throws IOException { + List result = new ArrayList<>(); + try (DirectoryStream dirs = Files.newDirectoryStream(root)) { + for (Path dir : dirs) if (Files.isRegularFile(dir.resolve("profile.json")) && !Files.isSymbolicLink(dir)) + result.add(get(dir.getFileName().toString())); + } + result.sort(Comparator.comparing(p -> p.name.toLowerCase(Locale.ROOT))); + return result; + } + public Profile get(String id) throws IOException { + Path dir = directory(id); + Profile profile = json.read(dir.resolve("profile.json"), Profile.class); + if (!id.equals(profile.id)) throw new IOException("Profile identity does not match its directory"); + validate(profile); profile.setDirectory(dir); return profile; + } + public Profile create(String name, String version, String loader, ProfileType type) throws IOException { + Profile p = new Profile(); p.id = UUID.randomUUID().toString(); p.name = name; + p.gameVersion = version; p.loader = loader.toUpperCase(Locale.ROOT); p.type = type; + validate(p); p.setDirectory(directory(p.id)); + Files.createDirectory(p.getDirectory()); + Files.createDirectories(p.getDirectory().resolve(type.collectionDirectory())); + save(p); return p; + } + /** Defaults are version-specific; never retarget or overwrite an existing user profile. */ + public Profile ensureDefault(String version, ProfileType type) throws IOException { + if (type != ProfileType.MODS && type != ProfileType.MODS_SERVER) throw new IOException("Invalid default profile type"); + String name = type.isClient() ? "Default (VANILLA)" : "Default"; + try (ProfileLease ignored = new ProfileLease(root)) { + for (Profile profile : list()) if (name.equals(profile.name) && version.equals(profile.gameVersion) + && "VANILLA".equals(profile.loader) && profile.type == type && !profile.template && !profile.migrationPending) + return profile; + return create(name, version, "VANILLA", type); + } + } + public void save(Profile p) throws IOException { + validate(p); p.setDirectory(directory(p.id)); json.write(p.getDirectory().resolve("profile.json"), p); + } + /** Creates metadata offline. The complete compatible utility pack is prepared on first launch. */ + public Profile ensureFabricDefault(String version) throws IOException { + try (ProfileLease ignored = new ProfileLease(root)) { + for (Profile profile : list()) if (FabricDefaultProfile.PRESET.equals(profile.builtinPreset) + && version.equals(profile.gameVersion) && "FABRIC".equals(profile.loader) + && profile.type == ProfileType.MODS && !profile.template && !profile.migrationPending) return profile; + Profile profile = create("Default (FABRIC)", version, "FABRIC", ProfileType.MODS); + profile.builtinPreset = FabricDefaultProfile.PRESET; + profile.migrationSummary = "Client utility mods install on first launch: Fabric API, Sodium, Entity Culling, ImmediatelyFast and Mod Menu, with required libraries. Exact-version availability is checked before launch."; + save(profile); return profile; + } + } + public Profile cloneProfile(String sourceId, String targetVersion) throws IOException { + Profile source = get(sourceId); + return cloneProfile(sourceId, source.name + " " + targetVersion, targetVersion, source.loader); + } + public Profile cloneProfile(String sourceId, String name, String version, String loader) throws IOException { + Profile source = get(sourceId); + try (ProfileLease ignored = new ProfileLease(source.getDirectory())) { + Profile target = create(name, version, loader, source.type); + target.loaderVersion = Objects.equals(source.loader, target.loader) && Objects.equals(source.gameVersion, version) ? source.loaderVersion : null; + if ("FABRIC".equals(target.loader)) { target.builtinPreset = source.builtinPreset; target.builtinPresetInstalled = source.builtinPresetInstalled; } + target.migrationPending = !Objects.equals(source.gameVersion, version) || !Objects.equals(source.loader, target.loader); + Set skip = new HashSet<>(Arrays.asList("profile.json", ".profile.lock", ".updates", ".autoplug", "logs", "crash-reports", "saves", "world", "versions", "libraries", "assets", "natives", "launcher_profiles.json", "accounts.json", "usercache.json")); + Files.walkFileTree(source.getDirectory(), new SimpleFileVisitor() { + @Override public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { + Path relative = source.getDirectory().relativize(dir); + if (!relative.toString().isEmpty() && skip.contains(relative.getName(0).toString())) return FileVisitResult.SKIP_SUBTREE; + Files.createDirectories(target.getDirectory().resolve(relative)); return FileVisitResult.CONTINUE; + } + @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Path relative = source.getDirectory().relativize(file); + if (skip.contains(relative.getName(0).toString())) return FileVisitResult.CONTINUE; + Path dest = target.getDirectory().resolve(relative); + if (file.getFileName().toString().endsWith(".jar")) cache.link(cache.store(file), dest); + else { + if (Files.isSymbolicLink(file)) throw new IOException("Refusing to clone linked configuration: " + relative); + Files.copy(file, dest, StandardCopyOption.REPLACE_EXISTING); + } + return FileVisitResult.CONTINUE; + } + }); + save(target); return target; + } + } + /** Move to a recoverable trash directory instead of recursively deleting user configuration. */ + public void delete(String id) throws IOException { + Profile p = get(id); + try (ProfileLease ignored = new ProfileLease(p.getDirectory())) { + Path trash = root.getParent().resolve("trash"); Files.createDirectories(trash); + Files.move(p.getDirectory(), trash.resolve(id + "-" + System.currentTimeMillis())); + } + } + private Path directory(String id) throws IOException { + if (id == null || !id.matches("[a-zA-Z0-9_-]{1,100}")) throw new IOException("Invalid profile id"); + Path path = root.resolve(id).normalize(); + if (!path.getParent().equals(root) || Files.isSymbolicLink(path)) throw new IOException("Invalid profile directory"); + return path; + } + private void validate(Profile p) throws IOException { + if (p.name == null || p.name.trim().isEmpty() || p.name.length() > 120 || p.type == null) + throw new IOException("Profile requires a name and type"); + if (p.gameVersion == null || !p.gameVersion.matches("[A-Za-z0-9_.-]{1,80}")) throw new IOException("Invalid game version"); + if (p.loader == null || !Arrays.asList("VANILLA", "FABRIC", "QUILT", "FORGE", "NEOFORGE", "PAPER", "SPIGOT", "PURPUR", "BUKKIT", "VELOCITY", "BUNGEE").contains(p.loader)) + throw new IOException("Unsupported loader: " + p.loader); + if (!p.type.supportsLoader(p.loader)) + throw new IOException("Loader " + p.loader + " cannot load a " + p.type + " collection; choose a compatible profile type and loader"); + } +} diff --git a/src/main/java/com/osiris/autoplug/client/profiles/ProfileType.java b/src/main/java/com/osiris/autoplug/client/profiles/ProfileType.java new file mode 100644 index 00000000..a9ed45a4 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/profiles/ProfileType.java @@ -0,0 +1,16 @@ +package com.osiris.autoplug.client.profiles; + +import java.util.Arrays; + +public enum ProfileType { + MODS, PLUGINS, MODS_SERVER; + + public String collectionDirectory() { return this == PLUGINS ? "plugins" : "mods"; } + public boolean isClient() { return this == MODS; } + + /** One compatibility rule for persisted profiles, CLI and the dashboard. */ + public boolean supportsLoader(String loader) { + boolean pluginLoader = Arrays.asList("PAPER", "SPIGOT", "PURPUR", "BUKKIT", "VELOCITY", "BUNGEE").contains(loader); + return this == PLUGINS ? pluginLoader : !pluginLoader; + } +} diff --git a/src/main/java/com/osiris/autoplug/client/profiles/ProfileUpdates.java b/src/main/java/com/osiris/autoplug/client/profiles/ProfileUpdates.java new file mode 100644 index 00000000..44235edf --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/profiles/ProfileUpdates.java @@ -0,0 +1,217 @@ +package com.osiris.autoplug.client.profiles; + +import com.osiris.autoplug.client.tasks.updater.TaskDownload; +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 com.osiris.betterthread.BThreadManager; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.*; +import java.security.MessageDigest; +import java.util.*; +import java.util.zip.ZipFile; + +/** Shared updater engine adapter. Every writable path is scoped to the selected profile/cache. */ +public class ProfileUpdates { + public interface Resolver { SearchResult resolve(Profile profile, CollectionEntry entry, Path installed) throws Exception; } + public interface Downloader { void download(SearchResult result, Path destination) throws Exception; } + public enum Status { CURRENT, REPLACE, INCOMPATIBLE, UNVERIFIED, EXCLUDED, ERROR } + public class Change { + public CollectionEntry entry; + public SearchResult release; + public Status status; + public String reason, originalDigest; + } + public class Plan { + public Profile profile; + public ProfileCollection collection; + private String collectionFingerprint; + public List changes = new ArrayList<>(); + public boolean hasErrors() { return changes.stream().anyMatch(c -> c.status == Status.ERROR); } + public String summary() { + StringBuilder out = new StringBuilder(profile.name + " — " + profile.gameVersion + " / " + profile.loader + "\n"); + for (Change c : changes) out.append(c.entry.file).append(": ").append(c.status).append(" — ").append(c.reason).append('\n'); + if (changes.isEmpty()) out.append("Collection is empty.\n"); + if (profile.migrationPending) out.append("Migration: incompatible or unverified JARs will become .jar.disabled after confirmation. Configuration files are preserved.\n"); + if (hasErrors()) out.append("A provider lookup failed. No changes can be applied until it succeeds.\n"); + return out.toString(); + } + } + private final ProfileStore profiles; + private final Resolver resolver; + private final Downloader downloader; + public ProfileUpdates(ProfileStore profiles) { + this.profiles = profiles; this.resolver = this::findRelease; + this.downloader = (release, file) -> { + if (release.downloadUrl == null || !release.downloadUrl.startsWith("https://")) throw new IOException("A secure download URL is required"); + new TaskDownload("Profile artifact", new BThreadManager(), release.downloadUrl, file.toFile(), true).runAtStart(); + }; + } + public ProfileUpdates(ProfileStore profiles, Resolver resolver, Downloader downloader) { + this.profiles = profiles; this.resolver = resolver; this.downloader = downloader; + } + public Plan check(String id) throws Exception { + Profile profile = profiles.get(id); + try (ProfileLease ignored = new ProfileLease(profile.getDirectory())) { return plan(profile); } + } + private Plan plan(Profile profile) throws Exception { + Plan plan = new Plan(); plan.profile = profile; plan.collection = new ProfileCollection().scan(profile); + plan.collectionFingerprint = fingerprint(plan.collection); + for (CollectionEntry entry : plan.collection.entries) { + Change change = new Change(); change.entry = entry; plan.changes.add(change); + Path file = plan.collection.resolve(profile, entry.file); + change.originalDigest = profiles.getCache().digest(file); + if (entry.excluded) { + change.status = profile.migrationPending ? Status.UNVERIFIED : Status.EXCLUDED; + change.reason = "Excluded from provider updates"; continue; + } + try { + change.release = resolver.resolve(profile, entry, file); + if (change.release == null) { change.status = Status.UNVERIFIED; change.reason = "No version-aware provider identity configured"; } + else if (change.release.type == SearchResult.Type.API_ERROR) { + change.status = Status.ERROR; change.reason = change.release.exception == null ? "Provider request failed" : change.release.exception.getMessage(); + } else if (change.release.type == SearchResult.Type.RESOURCE_NOT_FOUND) { + change.status = Status.INCOMPATIBLE; change.reason = "No release for this game version and loader"; + } else if (change.release.type == SearchResult.Type.UP_TO_DATE) { + change.status = Status.CURRENT; change.reason = "Matches compatible release " + change.release.latestVersion; + } else if (change.release.type == SearchResult.Type.UPDATE_AVAILABLE && !change.release.isPremium) { + change.status = Status.REPLACE; change.reason = "Install compatible release " + change.release.latestVersion; + } else { change.status = Status.UNVERIFIED; change.reason = "Provider cannot supply an installable compatible artifact"; } + } catch (Exception e) { change.status = Status.ERROR; change.reason = e.getMessage(); } + } + return plan; + } + /** The caller must display the returned check plan and obtain confirmation before invoking. */ + public String update(String id, boolean allowDisable) throws Exception { return apply(check(id), allowDisable); } + public String apply(Plan plan, boolean allowDisable) throws Exception { + Profile current = profiles.get(plan.profile.id); + try (ProfileLease ignored = new ProfileLease(current.getDirectory())) { + if (!Objects.equals(current.gameVersion, plan.profile.gameVersion) || !Objects.equals(current.loader, plan.profile.loader) + || !Objects.equals(current.loaderVersion, plan.profile.loaderVersion) || current.type != plan.profile.type + || current.migrationPending != plan.profile.migrationPending) throw new IOException("Profile changed; check again"); + if (!Objects.equals(plan.collectionFingerprint, fingerprint(new ProfileCollection().scan(current)))) throw new IOException("Collection or provider settings changed; check again"); + if (plan.hasErrors()) throw new IOException("Provider lookup failed. Nothing was changed.\n" + plan.summary()); + if (current.migrationPending && !allowDisable && plan.changes.stream().anyMatch(c -> c.status == Status.INCOMPATIBLE || c.status == Status.UNVERIFIED)) + throw new IOException("Confirmation required before disabling incompatible or unverified JARs.\n" + plan.summary()); + Map replacements = new LinkedHashMap<>(); + Path staging = current.getDirectory().resolve(".updates").resolve(UUID.randomUUID().toString()); + Files.createDirectories(staging); + // Stage and verify every download before changing any installed JAR. + for (Change c : plan.changes) { + Path file = plan.collection.resolve(current, c.entry.file); + if (!Objects.equals(c.originalDigest, profiles.getCache().digest(file))) throw new IOException("Collection changed; check again: " + c.entry.file); + if (c.status == Status.REPLACE) { + Path temp = staging.resolve(c.entry.file); + downloader.download(c.release, temp); verify(c.release, temp); + replacements.put(c, profiles.getCache().store(temp)); + } + if (current.migrationPending && (c.status == Status.INCOMPATIBLE || c.status == Status.UNVERIFIED) + && Files.exists(file.resolveSibling(file.getFileName() + ".disabled"))) + throw new IOException("A disabled backup already exists; rename it before migration: " + file); + } + Map originals = new LinkedHashMap<>(); List disabled = new ArrayList<>(); + Path collectionFile = current.getDirectory().resolve("collection.json"), profileFile = current.getDirectory().resolve("profile.json"); + byte[] oldCollection = Files.exists(collectionFile) ? Files.readAllBytes(collectionFile) : null; + byte[] oldProfile = Files.readAllBytes(profileFile); + // Downloads may take time; recheck external edits immediately before the transaction. + if (!Objects.equals(plan.collectionFingerprint, fingerprint(new ProfileCollection().scan(current)))) throw new IOException("Collection changed during download; check again"); + for (Change c : plan.changes) if (!Objects.equals(c.originalDigest, profiles.getCache().digest(plan.collection.resolve(current, c.entry.file)))) throw new IOException("Artifact changed during download; check again: " + c.entry.file); + try { + for (Change c : plan.changes) { + Path file = plan.collection.resolve(current, c.entry.file); + if (replacements.containsKey(c)) { + originals.put(file, profiles.getCache().store(file)); + profiles.getCache().link(replacements.get(c), file); + c.entry.version = c.release.latestVersion; + } else if (current.migrationPending && (c.status == Status.INCOMPATIBLE || c.status == Status.UNVERIFIED)) { + Path dest = file.resolveSibling(file.getFileName() + ".disabled"); + Files.move(file, dest); disabled.add(dest); + } else profiles.getCache().link(profiles.getCache().store(file), file); + } + for (Change c : plan.changes) if (c.release != null && c.release.modrinthProjectId != null) + c.entry.modrinthId = c.release.modrinthProjectId; + plan.collection.save(current); + current.migrationPending = false; current.migrationSummary = plan.summary(); profiles.save(current); + } catch (Exception e) { + for (Map.Entry original : originals.entrySet()) try { profiles.getCache().link(original.getValue(), original.getKey()); } catch (Exception rollback) { e.addSuppressed(rollback); } + for (Path file : disabled) try { Files.move(file, file.resolveSibling(file.getFileName().toString().replaceFirst("\\.disabled$", ""))); } catch (Exception rollback) { e.addSuppressed(rollback); } + try { if (oldCollection == null) Files.deleteIfExists(collectionFile); else restoreMetadata(collectionFile, oldCollection); } catch (Exception rollback) { e.addSuppressed(rollback); } + try { restoreMetadata(profileFile, oldProfile); } catch (Exception rollback) { e.addSuppressed(rollback); } + throw e; + } + for (Path temp : replacements.keySet().stream().map(c -> staging.resolve(c.entry.file)).toArray(Path[]::new)) Files.deleteIfExists(temp); + Files.deleteIfExists(staging); + return "Applied updates.\n" + plan.summary(); + } + } + private String fingerprint(ProfileCollection collection) { + List entries = new ArrayList<>(collection.entries); entries.sort(Comparator.comparing(e -> e.file)); + return new com.google.gson.Gson().toJson(entries); + } + private void restoreMetadata(Path path, byte[] content) throws IOException { + Path temporary = Files.createTempFile(path.getParent(), ".rollback-", ".tmp"); + try { + Files.write(temporary, content); + try { Files.move(temporary, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } + catch (AtomicMoveNotSupportedException e) { Files.move(temporary, path, StandardCopyOption.REPLACE_EXISTING); } + } finally { Files.deleteIfExists(temporary); } + } + private SearchResult findRelease(Profile profile, CollectionEntry entry, Path file) throws Exception { + ResourceFinder finder = new ResourceFinder(); + SearchResult modrinth = null; + if (entry.modrinthId != null && !entry.modrinthId.isEmpty()) { + modrinth = finder.findCompatibleModrinthArtifact(compatibleLoaders(profile), entry.modrinthId, profile.gameVersion, file); + if (modrinth.type != SearchResult.Type.RESOURCE_NOT_FOUND) return modrinth; + } + // Embedded mod ids and plugin names are not necessarily provider project ids. + // An exact hash lookup avoids guessing identities for generic collections. + String identified = finder.findModrinthProject(file); + if (identified != null && !identified.equals(entry.modrinthId)) { + modrinth = finder.findCompatibleModrinthArtifact(compatibleLoaders(profile), identified, profile.gameVersion, file); + if (modrinth.type != SearchResult.Type.RESOURCE_NOT_FOUND) return modrinth; + } + if (profile.type != ProfileType.PLUGINS && entry.curseforgeId != null) { + MinecraftMod mod = new MinecraftMod(file.toString(), entry.name, entry.version, entry.author, null, entry.curseforgeId, entry.customDownloadUrl); + return finder.findModByCurseforgeId(new InstalledModLoader(profile.loader), mod, profile.gameVersion, false); + } + if (modrinth != null) return modrinth; + // Generic Spigot/GitHub/custom releases lack target-game compatibility metadata. + // They remain available for ordinary updates, but a migration must get an explicit compatible identity. + if (profile.migrationPending) return null; + MinecraftPlugin plugin = new MinecraftPlugin(file.toString(), entry.name, entry.version, entry.author, entry.spigotId, entry.bukkitId, entry.customDownloadUrl); + plugin.githubRepoName = entry.githubRepo; plugin.githubAssetName = entry.githubAsset; plugin.customCheckURL = entry.customCheckUrl; + if (entry.customCheckUrl != null) return finder.findByCustomCheckURL(plugin); + if (entry.githubRepo != null) return finder.findByGithubUrl(plugin); + if (entry.spigotId > 0) return finder.findPluginBySpigotId(plugin); + if (entry.bukkitId > 0) return finder.findPluginByBukkitId(plugin); + return null; + } + private List compatibleLoaders(Profile profile) { + switch (profile.loader) { + case "PURPUR": return Arrays.asList("purpur", "paper", "spigot", "bukkit"); + case "PAPER": return Arrays.asList("paper", "spigot", "bukkit"); + case "SPIGOT": return Arrays.asList("spigot", "bukkit"); + case "QUILT": return Arrays.asList("quilt", "fabric"); + default: return List.of(profile.loader.toLowerCase(Locale.ROOT)); + } + } + static void verify(SearchResult result, Path path) throws Exception { + if (!Files.isRegularFile(path) || Files.size(path) == 0) throw new IOException("Downloaded artifact is empty"); + if (result.fileSize >= 0 && Files.size(path) != result.fileSize) throw new IOException("Downloaded artifact size mismatch"); + String expected = result.sha512 != null ? result.sha512 : result.sha1; + if (expected != null) { + MessageDigest digest = MessageDigest.getInstance(result.sha512 != null ? "SHA-512" : "SHA-1"); + try (InputStream in = Files.newInputStream(path)) { byte[] buf = new byte[65536]; int n; while ((n = in.read(buf)) != -1) digest.update(buf, 0, n); } + StringBuilder actual = new StringBuilder(); for (byte b : digest.digest()) actual.append(String.format("%02x", b & 255)); + if (!expected.equalsIgnoreCase(actual.toString())) throw new IOException("Downloaded artifact checksum mismatch"); + } + try (ZipFile jar = new ZipFile(path.toFile())) { if (!jar.entries().hasMoreElements()) throw new IOException("Downloaded JAR is empty"); } + } + /** Shared provider download and artifact verification for first-install recipes. */ + void downloadVerified(SearchResult result, Path destination) throws Exception { + downloader.download(result, destination); verify(result, destination); + } +} diff --git a/src/main/java/com/osiris/autoplug/client/tasks/updater/TaskDownload.java b/src/main/java/com/osiris/autoplug/client/tasks/updater/TaskDownload.java index f76bd0ec..e3fd0761 100644 --- a/src/main/java/com/osiris/autoplug/client/tasks/updater/TaskDownload.java +++ b/src/main/java/com/osiris/autoplug/client/tasks/updater/TaskDownload.java @@ -9,6 +9,7 @@ package com.osiris.autoplug.client.tasks.updater; import com.osiris.autoplug.client.utils.UtilsCrypto; +import com.osiris.autoplug.client.launcher.DownloadProgress; import com.osiris.betterthread.BThread; import com.osiris.betterthread.BThreadManager; import com.osiris.jlib.logger.AL; @@ -61,50 +62,44 @@ public void runAtStart() throws Exception { super.runAtStart(); final String fileName = dest.getName(); - setStatus("Downloading " + fileName + "... (0mb/0mb)"); - AL.debug(this.getClass(), "Downloading " + fileName + " from: " + url); + final String source = DownloadProgress.sourceUrl(url); + setStatus("Downloading " + fileName + " from " + source); Request request = new Request.Builder().url(url) .header("User-Agent", "AutoPlug Client/" + new Random().nextInt() + " - https://autoplug.one") .build(); - Response response = new OkHttpClient.Builder().followRedirects(true).build().newCall(request).execute(); - ResponseBody body = null; - try { + try (DownloadProgress.Transfer transfer = DownloadProgress.begin("Downloading " + fileName, url); + Response response = new OkHttpClient.Builder().followRedirects(true).build().newCall(request).execute()) { + transfer.redirect(response.request().url().toString()); if (response.code() != 200) - throw new Exception("Download of '" + dest.getName() + "' failed! Code: " + response.code() + " Message: " + response.message() + " Url: " + url); + throw new Exception("Download of '" + dest.getName() + "' failed! Code: " + response.code() + " Message: " + response.message() + " Url: " + source); - body = response.body(); + ResponseBody body = response.body(); if (body == null) throw new Exception("Download of '" + dest.getName() + "' failed because of null response body!"); validateContentType(dest.getName(), body.contentType(), ignoreContentType, allowedSubContentTypes); long completeFileSize = body.contentLength(); - setMax(completeFileSize); + setMax(Math.max(1, completeFileSize)); - BufferedInputStream in = new BufferedInputStream(body.byteStream()); - FileOutputStream fos = new FileOutputStream(dest); - BufferedOutputStream bout = new BufferedOutputStream(fos, 1024); - byte[] data = new byte[1024]; long downloadedFileSize = 0; - int x = 0; - while ((x = in.read(data, 0, 1024)) >= 0) { - downloadedFileSize += x; - - setStatus("Downloading " + fileName + "... (" + downloadedFileSize / (1024 * 1024) + "mb/" + completeFileSize / (1024 * 1024) + "mb)"); - setNow(downloadedFileSize); - - bout.write(data, 0, x); + try (BufferedInputStream in = new BufferedInputStream(body.byteStream()); + BufferedOutputStream bout = new BufferedOutputStream(new FileOutputStream(dest), 65536)) { + byte[] data = new byte[65536]; + int x; + while ((x = in.read(data)) >= 0) { + if (Thread.currentThread().isInterrupted()) throw new java.io.InterruptedIOException("Download cancelled."); + downloadedFileSize += x; + String amount = downloadedFileSize / (1024 * 1024) + "mb" + + (completeFileSize >= 0 ? "/" + completeFileSize / (1024 * 1024) + "mb" : ""); + setStatus("Downloading " + fileName + " (" + amount + ") from " + source); + if (completeFileSize > 0) setNow(downloadedFileSize); + bout.write(data, 0, x); + transfer.update(downloadedFileSize, completeFileSize); + } } - - setStatus("Downloaded " + fileName + " (" + downloadedFileSize / (1024 * 1024) + "mb/" + completeFileSize / (1024 * 1024) + "mb)"); - bout.close(); - in.close(); - body.close(); - response.close(); - } catch (Exception e) { - if (body != null) body.close(); - response.close(); - throw e; + setStatus("Downloaded " + fileName + " from " + source); + transfer.complete(downloadedFileSize, completeFileSize); } } diff --git a/src/main/java/com/osiris/autoplug/client/tasks/updater/java/AdoptV3API.java b/src/main/java/com/osiris/autoplug/client/tasks/updater/java/AdoptV3API.java index 8399cba8..21fb9e83 100644 --- a/src/main/java/com/osiris/autoplug/client/tasks/updater/java/AdoptV3API.java +++ b/src/main/java/com/osiris/autoplug/client/tasks/updater/java/AdoptV3API.java @@ -10,6 +10,7 @@ import com.google.gson.JsonArray; import com.google.gson.JsonObject; +import com.osiris.autoplug.client.launcher.DownloadProgress; import com.osiris.jlib.json.Json; import com.osiris.jlib.json.exceptions.HttpErrorException; import com.osiris.jlib.json.exceptions.WrongJsonTypeException; @@ -68,10 +69,15 @@ public String getVersionInformationUrl(String releaseVersionName, OperatingSyste public JsonArray getVersionInformation(String releaseVersionName, OperatingSystemArchitectureType osArchitectureType, boolean isLargeHeapSize, ImageType imageType, boolean isHotspotImpl, boolean isOnlyLTS, OperatingSystemType osType, int maxItems, VendorProjectType vendorProject, ReleaseType releaseType) throws WrongJsonTypeException, IOException, HttpErrorException { - return Json.getAsJsonArray(getVersionInformationUrl( + String url = getVersionInformationUrl( releaseVersionName, osArchitectureType, isLargeHeapSize, imageType, isHotspotImpl, isOnlyLTS, osType, maxItems, vendorProject, releaseType - )); + ); + try (DownloadProgress.Transfer transfer = DownloadProgress.begin("Fetching Java package metadata", url)) { + JsonArray result = Json.getAsJsonArray(url); + transfer.complete(-1, -1); + return result; + } } /** @@ -113,7 +119,12 @@ public void getReleases(OperatingSystemArchitectureType osArchitectureType, bool while (true) { // Loop through all pages until last request gives 404 error code url = getReleasesUrl(page, osArchitectureType, isLargeHeapSize, imageType, isHotspotImpl, isOnlyLTS, osType, maxItems, vendorProject, releaseType); - Boolean shouldContinue = onNewPage.apply(Json.getAsObject(url)); + JsonObject response; + try (DownloadProgress.Transfer transfer = DownloadProgress.begin("Fetching Java releases", url)) { + response = Json.getAsObject(url); + transfer.complete(-1, -1); + } + Boolean shouldContinue = onNewPage.apply(response); if (!shouldContinue) break; page++; } diff --git a/src/main/java/com/osiris/autoplug/client/tasks/updater/java/TaskJavaDownload.java b/src/main/java/com/osiris/autoplug/client/tasks/updater/java/TaskJavaDownload.java index 40ad5e7b..2e3344ba 100644 --- a/src/main/java/com/osiris/autoplug/client/tasks/updater/java/TaskJavaDownload.java +++ b/src/main/java/com/osiris/autoplug/client/tasks/updater/java/TaskJavaDownload.java @@ -8,6 +8,7 @@ package com.osiris.autoplug.client.tasks.updater.java; +import com.osiris.autoplug.client.launcher.DownloadProgress; import com.osiris.betterthread.BThread; import com.osiris.betterthread.BThreadManager; import com.osiris.betterthread.BWarning; @@ -59,19 +60,21 @@ public void runAtStart() throws Exception { super.runAtStart(); String fileName = dest.getName(); - setStatus("Downloading " + fileName + "... (0mb/0mb)"); - AL.debug(this.getClass(), "Downloading " + fileName + " from: " + url); + final String source = DownloadProgress.sourceUrl(url); + setStatus("Downloading " + fileName + " from " + source); + AL.debug(this.getClass(), "Downloading " + fileName + " from: " + source); Request request = new Request.Builder().url(url) .header("User-Agent", "AutoPlug Client/" + new Random().nextInt() + " - https://autoplug.one") .build(); - Response response = new OkHttpClient().newCall(request).execute(); - ResponseBody body = null; - try { + try (DownloadProgress.Transfer transfer = DownloadProgress.begin("Downloading " + fileName, url); + Response response = new OkHttpClient().newCall(request).execute()) { + transfer.redirect(response.request().url().toString()); + final String currentSource = DownloadProgress.sourceUrl(response.request().url().toString()); if (response.code() != 200) - throw new Exception("Download of '" + fileName + "' failed! Code: " + response.code() + " Message: " + response.message() + " Url: " + url); + throw new Exception("Download of '" + fileName + "' failed! Code: " + response.code() + " Message: " + response.message() + " Url: " + currentSource); - body = response.body(); + ResponseBody body = response.body(); if (body == null) throw new Exception("Download of '" + fileName + "' failed because of null response body!"); else if (body.contentType() == null) @@ -123,35 +126,29 @@ else if (!body.contentType().subtype().equals("java-archive") newDest.createNewFile(); long completeFileSize = body.contentLength(); - setMax(completeFileSize); + setMax(Math.max(1, completeFileSize)); - BufferedInputStream in = new BufferedInputStream(body.byteStream()); - FileOutputStream fos = new FileOutputStream(dest); - BufferedOutputStream bout = new BufferedOutputStream(fos, 1024); - byte[] data = new byte[1024]; long downloadedFileSize = 0; - int x = 0; - while ((x = in.read(data, 0, 1024)) >= 0) { - downloadedFileSize += x; - - setStatus("Downloading " + fileName + "... (" + downloadedFileSize / (1024 * 1024) + "mb/" + completeFileSize / (1024 * 1024) + "mb)"); - setNow(downloadedFileSize); - - bout.write(data, 0, x); + try (BufferedInputStream in = new BufferedInputStream(body.byteStream()); + BufferedOutputStream bout = new BufferedOutputStream(new FileOutputStream(dest), 1024)) { + byte[] data = new byte[1024]; + int x; + while ((x = in.read(data)) != -1) { + if (Thread.currentThread().isInterrupted()) + throw new java.io.InterruptedIOException("Download interrupted"); + downloadedFileSize += x; + setStatus("Downloading " + fileName + " from " + currentSource + + " (" + downloadedFileSize + "/" + (completeFileSize < 0 ? "?" : completeFileSize) + " bytes)"); + if (completeFileSize > 0) setNow(downloadedFileSize); + transfer.update(downloadedFileSize, completeFileSize); + bout.write(data, 0, x); + } } - setStatus("Downloaded " + fileName + " (" + downloadedFileSize / (1024 * 1024) + "mb/" + completeFileSize / (1024 * 1024) + "mb)"); - bout.close(); - in.close(); - body.close(); - response.close(); - Files.copy(dest.toPath(), newDest.toPath(), StandardCopyOption.REPLACE_EXISTING); - } catch (Exception e) { - if (body != null) body.close(); - response.close(); - throw e; + setStatus("Downloaded " + fileName + " (" + downloadedFileSize + " bytes) from " + currentSource); + transfer.complete(downloadedFileSize, completeFileSize); } } 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..177a7c3c 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 @@ -96,7 +96,10 @@ public SearchResult searchUpdate(InstalledModLoader modLoader, MinecraftMod mod, } // If the release has no fabric or forge tag, then we expect only forge support. - if (modLoader.isFabric || modLoader.isQuilt) { // FABRIC or QUILT + if (modLoader.isNeoForge) { + for (JsonElement el : tempRelease.get("gameVersions").getAsJsonArray()) + if ("neoforge".equalsIgnoreCase(el.getAsString())) isModLoaderCompatible = true; + } else if (modLoader.isFabric || modLoader.isQuilt) { // FABRIC or QUILT for (JsonElement el : tempRelease.get("gameVersions").getAsJsonArray()) { // check if game versions contain fabric if (StringUtils.containsIgnoreCase(el.getAsString(), "fabric")) { isModLoaderCompatible = true; @@ -113,7 +116,7 @@ public SearchResult searchUpdate(InstalledModLoader modLoader, MinecraftMod mod, isModLoaderCompatible = true; // since no fabric/forge tag == forge is supported, // we only need to check if it has no fabric tag for (JsonElement el : tempRelease.get("gameVersions").getAsJsonArray()) { - if (StringUtils.containsIgnoreCase(el.getAsString(), "fabric")) { + if (StringUtils.containsIgnoreCase(el.getAsString(), "fabric") || StringUtils.containsIgnoreCase(el.getAsString(), "neoforge")) { isModLoaderCompatible = false; break; } @@ -125,21 +128,12 @@ public SearchResult searchUpdate(InstalledModLoader modLoader, MinecraftMod mod, break; } } - if (release == null) - throw new Exception("Failed to find a single release of this mod for mc version " + mcVersion); - try { - latest = release.get("fileName").getAsString().replaceAll("[^0-9.]", ""); // Before passing over remove everything except numbers and dots - } catch (Exception e) { - throw new Exception("Failed to determine latest mod version!", e); - } - if (new File(mod.installationPath).lastModified() < fileDateToMs(release.get("fileDate").getAsString())) - resultType = SearchResult.Type.UPDATE_AVAILABLE; - downloadUrl = release.get("downloadUrl").getAsString(); - try { - String fileName = release.get("fileName").getAsString(); - type = fileName.substring(fileName.lastIndexOf(".")); - } catch (Exception e) { + if (release == null) { + SearchResult missing = new SearchResult(null, SearchResult.Type.RESOURCE_NOT_FOUND, null, null, ".jar", null, null, false); + missing.mod = mod; + return missing; } + return compatibleArtifactResult(release, mod); } catch (Exception e) { exception = e; resultType = SearchResult.Type.API_ERROR; @@ -150,6 +144,30 @@ public SearchResult searchUpdate(InstalledModLoader modLoader, MinecraftMod mod, return result; } + /** A copied file's timestamp says nothing about its version; compare publisher hashes. */ + public SearchResult compatibleArtifactResult(JsonObject release, MinecraftMod mod) throws Exception { + String filename = release.get("fileName").getAsString(); + if (!release.has("downloadUrl") || release.get("downloadUrl").isJsonNull()) + throw new IOException("The publisher does not permit direct downloads for this release"); + SearchResult result = new SearchResult(null, SearchResult.Type.UPDATE_AVAILABLE, filename.replaceAll("[^0-9.]", ""), + release.get("downloadUrl").getAsString(), ".jar", null, null, false); + result.mod = mod; result.fileName = filename; + result.fileSize = release.has("fileLength") ? release.get("fileLength").getAsLong() : -1; + if (release.has("hashes")) for (JsonElement value : release.getAsJsonArray("hashes")) { + JsonObject hash = value.getAsJsonObject(); + if (hash.get("algo").getAsInt() == 1) result.sha1 = hash.get("value").getAsString(); + } + if (result.sha1 != null && Files.isRegularFile(Paths.get(mod.installationPath))) { + java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-1"); + try (java.io.InputStream in = Files.newInputStream(Paths.get(mod.installationPath))) { + byte[] buffer = new byte[65536]; int n; while ((n = in.read(buffer)) != -1) digest.update(buffer, 0, n); + } + StringBuilder actual = new StringBuilder(); for (byte b : digest.digest()) actual.append(String.format("%02x", b & 255)); + if (result.sha1.equalsIgnoreCase(actual.toString())) result.type = SearchResult.Type.UP_TO_DATE; + } + return result; + } + private long fileDateToMs(String fileDate) { return Instant.parse(fileDate).toEpochMilli(); } 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..d58cb529 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,15 @@ import java.io.File; public class InstalledModLoader { - public boolean isForge, isFabric, isQuilt; + public boolean isForge, isFabric, isQuilt, isNeoForge; + + /** Explicit profile context; does not inspect or change the process working directory. */ + public InstalledModLoader(String loader) { + isForge = "FORGE".equalsIgnoreCase(loader); + isFabric = "FABRIC".equalsIgnoreCase(loader); + isQuilt = "QUILT".equalsIgnoreCase(loader); + isNeoForge = "NEOFORGE".equalsIgnoreCase(loader); + } 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..e42a40f5 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 @@ -12,16 +12,172 @@ 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; +import com.osiris.autoplug.client.utils.UtilsCrypto; import com.osiris.jlib.json.Json; import com.osiris.jlib.logger.AL; import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.Instant; import java.util.List; public class ModrinthAPI { private final String baseUrl = "https://api.modrinth.com/v2"; + @FunctionalInterface interface ConnectionFactory { HttpURLConnection open(URL url) throws IOException; } + private final ConnectionFactory connections; + + public ModrinthAPI() { this(url -> (HttpURLConnection) url.openConnection()); } + ModrinthAPI(ConnectionFactory connections) { this.connections = java.util.Objects.requireNonNull(connections); } + + /** + * Resolve the exact installed bytes through Modrinth's version-file endpoint. + * A 404 means the artifact is unknown; a provider failure is not a missing identity. + * This lookup does not modify the artifact or any collection metadata. + */ + public String findProjectForArtifact(Path installed) throws IOException { + if (installed == null || !Files.isRegularFile(installed)) throw new IOException("Installed artifact is not a regular file: " + installed); + String sha1 = UtilsCrypto.fastSHA1(installed.toFile()); + if (sha1 == null || !sha1.matches("[a-fA-F0-9]{40}")) throw new IOException("Could not hash installed artifact: " + installed); + sha1 = sha1.toLowerCase(java.util.Locale.ROOT); + HttpURLConnection connection = openConnection("/version_file/" + sha1 + "?algorithm=sha1"); + try { + int status = connection.getResponseCode(); + if (status == 404) return null; + if (status != 200) throw new IOException("Modrinth artifact lookup returned HTTP " + status); + try (Reader reader = new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8)) { + JsonObject version = com.google.gson.JsonParser.parseReader(reader).getAsJsonObject(); + com.google.gson.JsonElement project = version.get("project_id"); + if (project == null || !project.isJsonPrimitive() || !project.getAsJsonPrimitive().isString() + || !project.getAsString().matches("[A-Za-z0-9]{1,128}")) + throw new IOException("Modrinth artifact response has no valid project_id"); + return project.getAsString(); + } catch (com.google.gson.JsonParseException | IllegalStateException e) { + throw new IOException("Invalid Modrinth artifact response", e); + } + } finally { connection.disconnect(); } + } + + private HttpURLConnection openConnection(String path) throws IOException { + HttpURLConnection connection = connections.open(new URL(baseUrl + path)); + connection.setConnectTimeout(15000); + connection.setReadTimeout(20000); + connection.setRequestProperty("User-Agent", "AutoPlug-Client/10.2 (https://github.com/Osiris-Team/AutoPlug-Client)"); + connection.setRequestProperty("Accept", "application/json"); + return connection; + } + + public JsonObject projectMetadata(String id) throws IOException { + return metadata("/project/" + checkedId(id)).getAsJsonObject(); + } + public com.google.gson.JsonArray versionsFor(String project, String gameVersion, String loader) throws IOException { + String query = "loaders=" + java.net.URLEncoder.encode(new com.google.gson.Gson().toJson(List.of(loader)), "UTF-8") + + "&game_versions=" + java.net.URLEncoder.encode(new com.google.gson.Gson().toJson(List.of(gameVersion)), "UTF-8"); + return metadata("/project/" + checkedId(project) + "/version?" + query).getAsJsonArray(); + } + public JsonObject versionMetadata(String id) throws IOException { return metadata("/version/" + checkedId(id)).getAsJsonObject(); } + private String checkedId(String id) throws IOException { + if (id == null || !id.matches("[A-Za-z0-9_-]{1,128}")) throw new IOException("Invalid Modrinth identifier"); + return id; + } + private com.google.gson.JsonElement metadata(String path) throws IOException { + HttpURLConnection connection = openConnection(path); + try (com.osiris.autoplug.client.launcher.DownloadProgress.Transfer transfer = + com.osiris.autoplug.client.launcher.DownloadProgress.begin("Fetching Modrinth metadata", baseUrl + path)) { + int status = connection.getResponseCode(); + transfer.redirect(connection.getURL().toString()); + if (status != 200) throw new IOException("Modrinth metadata returned HTTP " + status + " for " + path); + long total = connection.getContentLengthLong(); + if (total > 8 * 1024 * 1024) throw new IOException("Modrinth metadata exceeds 8 MiB"); + try (java.io.InputStream input = connection.getInputStream(); java.io.ByteArrayOutputStream content = new java.io.ByteArrayOutputStream()) { + byte[] buffer = new byte[8192]; int count; + while ((count = input.read(buffer)) != -1) { + if (Thread.currentThread().isInterrupted()) throw new java.io.InterruptedIOException("Modrinth lookup cancelled"); + if (content.size() + count > 8 * 1024 * 1024) throw new IOException("Modrinth metadata exceeds 8 MiB"); + content.write(buffer, 0, count); transfer.update(content.size(), total); + } + com.google.gson.JsonElement result = com.google.gson.JsonParser.parseString(new String(content.toByteArray(), StandardCharsets.UTF_8)); + transfer.complete(content.size(), total); return result; + } catch (com.google.gson.JsonParseException | IllegalStateException e) { throw new IOException("Invalid Modrinth metadata", e); } + } finally { connection.disconnect(); } + } + + /** + * Exact target-version lookup for both upgrades and downgrades. Unlike the legacy + * timestamp comparison, this compares artifact hashes and never treats a failed + * request as proof that a compatible release does not exist. + */ + public SearchResult searchCompatible(List loaders, String id, String version, java.nio.file.Path installed) { + SearchResult result = new SearchResult(null, SearchResult.Type.API_ERROR, null, null, ".jar", null, null, false); + try { + if (id == null || !id.matches("[A-Za-z0-9_-]+")) throw new IllegalArgumentException("A Modrinth project id or slug is required"); + String query = "loaders=" + java.net.URLEncoder.encode(new com.google.gson.Gson().toJson(loaders), "UTF-8") + + "&game_versions=" + java.net.URLEncoder.encode(new com.google.gson.Gson().toJson(List.of(version)), "UTF-8"); + HttpURLConnection connection = openConnection("/project/" + id + "/version?" + query); + try { + if (connection.getResponseCode() == 404) { + result.type = SearchResult.Type.RESOURCE_NOT_FOUND; + return result; + } + if (connection.getResponseCode() != 200) throw new java.io.IOException("Modrinth returned HTTP " + connection.getResponseCode()); + try (java.io.Reader reader = new java.io.InputStreamReader(connection.getInputStream(), java.nio.charset.StandardCharsets.UTF_8)) { + SearchResult found = compatibleResult(com.google.gson.JsonParser.parseReader(reader).getAsJsonArray(), loaders, version, installed); + if (found.type == SearchResult.Type.UP_TO_DATE || found.type == SearchResult.Type.UPDATE_AVAILABLE) + found.modrinthProjectId = id; + return found; + } + } finally { connection.disconnect(); } + } catch (Exception e) { result.exception = e; return result; } + } + + /** Parse provider fixtures independently of network access. */ + public SearchResult compatibleResult(com.google.gson.JsonArray releases, List loaders, String version, java.nio.file.Path installed) throws Exception { + SearchResult result = new SearchResult(null, SearchResult.Type.RESOURCE_NOT_FOUND, null, null, ".jar", null, null, false); + JsonObject selected = null; + for (com.google.gson.JsonElement element : releases) { + JsonObject release = element.getAsJsonObject(); + boolean versionMatch = false, loaderMatch = false; + for (com.google.gson.JsonElement v : release.getAsJsonArray("game_versions")) if (version.equals(v.getAsString())) versionMatch = true; + for (com.google.gson.JsonElement l : release.getAsJsonArray("loaders")) if (loaders.contains(l.getAsString())) loaderMatch = true; + if (versionMatch && loaderMatch && (selected == null || Instant.parse(release.get("date_published").getAsString()).isAfter(Instant.parse(selected.get("date_published").getAsString())))) selected = release; + } + if (selected == null) return result; + JsonObject file = null; + for (com.google.gson.JsonElement element : selected.getAsJsonArray("files")) { + JsonObject candidate = element.getAsJsonObject(); + if (!candidate.get("filename").getAsString().endsWith(".jar")) continue; + if (file == null || candidate.has("primary") && candidate.get("primary").getAsBoolean()) file = candidate; + if (candidate.has("primary") && candidate.get("primary").getAsBoolean()) break; + } + if (file == null) throw new java.io.IOException("Compatible release has no JAR artifact"); + result.latestVersion = selected.get("version_number").getAsString(); + result.downloadUrl = file.get("url").getAsString(); result.fileName = file.get("filename").getAsString(); + result.fileSize = file.has("size") ? file.get("size").getAsLong() : -1; + JsonObject hashes = file.getAsJsonObject("hashes"); + if (hashes != null) { + result.sha512 = hashes.has("sha512") ? hashes.get("sha512").getAsString() : null; + result.sha1 = hashes.has("sha1") ? hashes.get("sha1").getAsString() : null; + } + result.type = SearchResult.Type.UPDATE_AVAILABLE; + String expected = result.sha512 != null ? result.sha512 : result.sha1; + if (expected != null && java.nio.file.Files.isRegularFile(installed)) { + java.security.MessageDigest digest = java.security.MessageDigest.getInstance(result.sha512 != null ? "SHA-512" : "SHA-1"); + try (java.io.InputStream in = java.nio.file.Files.newInputStream(installed)) { + byte[] buffer = new byte[65536]; int count; + while ((count = in.read(buffer)) != -1) digest.update(buffer, 0, count); + } + StringBuilder actual = new StringBuilder(); for (byte b : digest.digest()) actual.append(String.format("%02x", b & 255)); + if (expected.equalsIgnoreCase(actual.toString())) result.type = SearchResult.Type.UP_TO_DATE; + } + return result; + } private boolean isInt(String s) { try { diff --git a/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/TaskModDownload.java b/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/TaskModDownload.java index 77b3e2bd..e86a9715 100644 --- a/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/TaskModDownload.java +++ b/src/main/java/com/osiris/autoplug/client/tasks/updater/mods/TaskModDownload.java @@ -10,6 +10,7 @@ import com.osiris.autoplug.client.tasks.updater.search.SearchResult; import com.osiris.autoplug.client.utils.GD; +import com.osiris.autoplug.client.launcher.DownloadProgress; import com.osiris.betterthread.BThread; import com.osiris.betterthread.BThreadManager; import com.osiris.jlib.logger.AL; @@ -118,24 +119,26 @@ public void download() throws Exception { if (!dir.exists()) dir.mkdirs(); dest = new File(dir + "/" + plName + "-[" + plLatestVersion + "].jar"); - AL.debug(this.getClass(), "Downloading " + dest.getName() + " to '" + dest.getAbsolutePath() + "' from '" + url + "'"); + final String source = DownloadProgress.sourceUrl(url); + AL.debug(this.getClass(), "Downloading " + dest.getName() + " to '" + dest.getAbsolutePath() + "' from '" + source + "'"); if (dest.exists()) dest.delete(); dest.createNewFile(); final String fileName = dest.getName(); - setStatus("Downloading " + fileName + "... (0kb/0kb)"); + setStatus("Downloading " + fileName + " from " + source); Request request = new Request.Builder().url(url) .header("User-Agent", "AutoPlug-Client - https://autoplug.one") .build(); - Response response = new OkHttpClient().newCall(request).execute(); - ResponseBody body = null; - try { + try (DownloadProgress.Transfer transfer = DownloadProgress.begin("Downloading " + fileName, url); + Response response = new OkHttpClient().newCall(request).execute()) { + transfer.redirect(response.request().url().toString()); + final String currentSource = DownloadProgress.sourceUrl(response.request().url().toString()); if (response.code() != 200) - throw new Exception("Download error for " + plName + " code: " + response.code() + " message: " + response.message() + " url: " + url); + throw new Exception("Download error for " + plName + " code: " + response.code() + " message: " + response.message() + " url: " + currentSource); - body = response.body(); + ResponseBody body = response.body(); if (body == null) throw new Exception("Download of '" + dest.getName() + "' failed because of null response body!"); else if (body.contentType() == null) @@ -150,32 +153,27 @@ else if (!ignoreContentType && ( throw new Exception("Download of '" + dest.getName() + "' failed because of invalid sub-content type: " + body.contentType().subtype()); long completeFileSize = body.contentLength(); - setMax(completeFileSize); + setMax(Math.max(1, completeFileSize)); - BufferedInputStream in = new BufferedInputStream(body.byteStream()); - FileOutputStream fos = new FileOutputStream(dest); - BufferedOutputStream bout = new BufferedOutputStream(fos, 1024); - byte[] data = new byte[1024]; long downloadedFileSize = 0; - int x = 0; - while ((x = in.read(data, 0, 1024)) >= 0) { - downloadedFileSize += x; - - setStatus("Downloading " + fileName + "... (" + downloadedFileSize / 1024 + "kb/" + completeFileSize / 1024 + "kb)"); - setNow(downloadedFileSize); - - bout.write(data, 0, x); + try (BufferedInputStream in = new BufferedInputStream(body.byteStream()); + BufferedOutputStream bout = new BufferedOutputStream(new FileOutputStream(dest), 1024)) { + byte[] data = new byte[1024]; + int x; + while ((x = in.read(data)) != -1) { + if (Thread.currentThread().isInterrupted()) + throw new java.io.InterruptedIOException("Download interrupted"); + downloadedFileSize += x; + setStatus("Downloading " + fileName + " from " + currentSource + + " (" + downloadedFileSize + "/" + (completeFileSize < 0 ? "?" : completeFileSize) + " bytes)"); + if (completeFileSize > 0) setNow(downloadedFileSize); + transfer.update(downloadedFileSize, completeFileSize); + bout.write(data, 0, x); + } } - setStatus("Downloaded " + fileName + " (" + downloadedFileSize / 1024 + "kb/" + completeFileSize / 1024 + "kb)"); - bout.close(); - in.close(); - body.close(); - response.close(); - } catch (Exception e) { - if (body != null) body.close(); - response.close(); - throw e; + setStatus("Downloaded " + fileName + " (" + downloadedFileSize + " bytes) from " + currentSource); + transfer.complete(downloadedFileSize, completeFileSize); } } 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..b7bfe0c4 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,16 @@ public class ResourceFinder { + /** Exact artifact identity; callers persist it only after an approved update is applied. */ + public String findModrinthProject(java.nio.file.Path installed) throws java.io.IOException { + return new ModrinthAPI().findProjectForArtifact(installed); + } + + /** Exact loader/game-version path used by isolated client and server profiles. */ + public SearchResult findCompatibleModrinthArtifact(List loaders, String project, String gameVersion, java.nio.file.Path installed) { + return new ModrinthAPI().searchCompatible(loaders, project, gameVersion, installed); + } + /** * 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/TaskPluginDownload.java b/src/main/java/com/osiris/autoplug/client/tasks/updater/plugins/TaskPluginDownload.java index dcdf7570..53f74730 100644 --- a/src/main/java/com/osiris/autoplug/client/tasks/updater/plugins/TaskPluginDownload.java +++ b/src/main/java/com/osiris/autoplug/client/tasks/updater/plugins/TaskPluginDownload.java @@ -11,6 +11,7 @@ import com.osiris.autoplug.client.tasks.updater.search.SearchResult; import com.osiris.autoplug.client.utils.GD; import com.osiris.autoplug.client.utils.StringComparator; +import com.osiris.autoplug.client.launcher.DownloadProgress; import com.osiris.betterthread.BThread; import com.osiris.betterthread.BThreadManager; import com.osiris.jlib.UtilsFiles; @@ -125,24 +126,26 @@ public void download() throws Exception { if (!dir.exists()) dir.mkdirs(); dest = new File(dir + "/" + plName + "-[" + plLatestVersion + "].jar"); - AL.debug(this.getClass(), "Downloading " + dest.getName() + " to '" + dest.getAbsolutePath() + "' from '" + url + "'"); + final String source = DownloadProgress.sourceUrl(url); + AL.debug(this.getClass(), "Downloading " + dest.getName() + " to '" + dest.getAbsolutePath() + "' from '" + source + "'"); if (dest.exists()) dest.delete(); dest.createNewFile(); final String fileName = dest.getName(); - setStatus("Downloading " + fileName + "... (0kb/0kb)"); + setStatus("Downloading " + fileName + " from " + source); Request request = new Request.Builder().url(url) .header("User-Agent", "AutoPlug-Client - https://autoplug.one") .build(); - Response response = new OkHttpClient().newCall(request).execute(); - ResponseBody body = null; - try { + try (DownloadProgress.Transfer transfer = DownloadProgress.begin("Downloading " + fileName, url); + Response response = new OkHttpClient().newCall(request).execute()) { + transfer.redirect(response.request().url().toString()); + final String currentSource = DownloadProgress.sourceUrl(response.request().url().toString()); if (response.code() != 200) - throw new Exception("Download error for " + plName + " code: " + response.code() + " message: " + response.message() + " url: " + url); + throw new Exception("Download error for " + plName + " code: " + response.code() + " message: " + response.message() + " url: " + currentSource); - body = response.body(); + ResponseBody body = response.body(); if (body == null) throw new Exception("Download of '" + dest.getName() + "' failed because of null response body!"); else if (body.contentType() == null) @@ -161,39 +164,38 @@ else if (!ignoreContentType && ( boolean isZip = false, isTar = false; if (body.contentType().subtype().equals("zip")) { dest = new File(dir + "/" + plName + "-[" + plLatestVersion + "].zip"); - AL.debug(this.getClass(), "Downloading " + dest.getName() + " to '" + dest.getAbsolutePath() + "' from '" + url + "'"); + AL.debug(this.getClass(), "Downloading " + dest.getName() + " to '" + dest.getAbsolutePath() + "' from '" + currentSource + "'"); if (dest.exists()) dest.delete(); dest.createNewFile(); } else if (body.contentType().subtype().equals("x-gtar")) { dest = new File(dir + "/" + plName + "-[" + plLatestVersion + "].tar.gz"); - AL.debug(this.getClass(), "Downloading " + dest.getName() + " to '" + dest.getAbsolutePath() + "' from '" + url + "'"); + AL.debug(this.getClass(), "Downloading " + dest.getName() + " to '" + dest.getAbsolutePath() + "' from '" + currentSource + "'"); if (dest.exists()) dest.delete(); dest.createNewFile(); } long completeFileSize = body.contentLength(); - setMax(completeFileSize); + setMax(Math.max(1, completeFileSize)); - BufferedInputStream in = new BufferedInputStream(body.byteStream()); - FileOutputStream fos = new FileOutputStream(dest); - BufferedOutputStream bout = new BufferedOutputStream(fos, 1024); - byte[] data = new byte[1024]; long downloadedFileSize = 0; - int x = 0; - while ((x = in.read(data, 0, 1024)) >= 0) { - downloadedFileSize += x; - - setStatus("Downloading " + fileName + "... (" + downloadedFileSize / 1024 + "kb/" + completeFileSize / 1024 + "kb)"); - setNow(downloadedFileSize); - - bout.write(data, 0, x); + try (BufferedInputStream in = new BufferedInputStream(body.byteStream()); + BufferedOutputStream bout = new BufferedOutputStream(new FileOutputStream(dest), 1024)) { + byte[] data = new byte[1024]; + int x; + while ((x = in.read(data)) != -1) { + if (Thread.currentThread().isInterrupted()) + throw new java.io.InterruptedIOException("Download interrupted"); + downloadedFileSize += x; + setStatus("Downloading " + fileName + " from " + currentSource + + " (" + downloadedFileSize + "/" + (completeFileSize < 0 ? "?" : completeFileSize) + " bytes)"); + if (completeFileSize > 0) setNow(downloadedFileSize); + transfer.update(downloadedFileSize, completeFileSize); + bout.write(data, 0, x); + } } - setStatus("Downloaded " + fileName + " (" + downloadedFileSize / 1024 + "kb/" + completeFileSize / 1024 + "kb)"); - bout.close(); - in.close(); - body.close(); - response.close(); + setStatus("Downloaded " + fileName + " (" + downloadedFileSize + " bytes) from " + currentSource); + transfer.complete(downloadedFileSize, completeFileSize); // Zip/Tar support if (isTar || isZip) { @@ -226,10 +228,6 @@ else if (!ignoreContentType && ( " and selected " + dest.getName()); } - } catch (Exception e) { - if (body != null) body.close(); - response.close(); - throw e; } } 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..373e151b 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 @@ -38,6 +38,11 @@ public static boolean isMatchFound(SearchResult sr) { public Type type; public Exception exception; public String fileName; + /** Optional provider integrity metadata used by isolated profile migrations. */ + public String sha512, sha1; + public long fileSize = -1; + /** Resolved provider identity, available to persist after successful application. */ + public String modrinthProjectId; /** * @param type All codes:
diff --git a/src/main/java/com/osiris/autoplug/client/ui/DashboardPanel.java b/src/main/java/com/osiris/autoplug/client/ui/DashboardPanel.java new file mode 100644 index 00000000..94693a9b --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/ui/DashboardPanel.java @@ -0,0 +1,1193 @@ +package com.osiris.autoplug.client.ui; + +import com.osiris.autoplug.client.browser.*; +import com.osiris.autoplug.client.launcher.DownloadProgress; +import com.osiris.autoplug.client.profiles.ProfileType; +import com.osiris.autoplug.client.ui.LauncherActions.*; +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import javax.swing.table.DefaultTableModel; +import java.awt.*; +import java.awt.datatransfer.StringSelection; +import java.io.File; +import java.util.List; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; + +/** Five-view Swing dashboard. Network, launcher and disk operations never run on the EDT. */ +public final class DashboardPanel extends JPanel implements AutoCloseable { + // FlatLaf caches shared borders when the first controls are created. + { DashboardTheme.installDefaults(); } + private final LauncherActions actions; + private final Consumer launchChoicePresenter; + private final ServerBrowserService browser; + private final ExecutorService workers = Executors.newFixedThreadPool(4, runnable -> { + Thread thread = new Thread(runnable, "AutoPlug-Dashboard"); thread.setDaemon(true); return thread; + }); + private final AtomicBoolean closed = new AtomicBoolean(); + private final AtomicInteger running = new AtomicInteger(); + private final CardLayout cards = new CardLayout(); + private final JPanel pages = new JPanel(cards); + private final Map navigationButtons = new LinkedHashMap<>(); + private JTabbedPane serverTabs; + private ManagedWorldPanel managedWorldPanel; + private final JLabel status = new JLabel("Ready"); + private final JLabel navigationTitle = new JLabel("Server Browser"); + private final JProgressBar progress = new JProgressBar(0, 100); + private final OverallProgress overall = new OverallProgress(); + private final javax.swing.Timer progressClock = new javax.swing.Timer(400, e -> { + if (running.get() > 0 || this.activeDownloads > 0) progress.setValue(overall.tick()); + }); + private boolean operationFailed; + private final JTextField sourceUrl = new JTextField(); + private final JTextArea activity = textArea(5); + private final JLabel serverEmpty = new JLabel("Add a server or import Minecraft favorites to get started.", SwingConstants.CENTER); + private final ServerGrid serverCards = new ServerGrid(); + private final JTextField serverSearch = new JTextField(18); + private final JComboBox serverSort = new JComboBox<>(new String[]{"Last joined", "Favorite order", "Name", "Address", "Status", "MOTD", "Players", "Version", "Latency"}); + private final JToggleButton reverseServerSort = new JToggleButton("Reverse order"); + private final Map serverViews = new LinkedHashMap<>(); + private String selectedServerAddress; + private final DefaultTableModel profileModel = model("Name", "Minecraft", "Loader", "Type", "Template", "Ready"); + private final JTable profileTable = table(profileModel); + private final JPanel worldCards = new WorldList(); + private final Map serverStatuses = new HashMap<>(); + private final Map serverPingRequests = new HashMap<>(); + private long nextServerPingRequest; + private List servers = Collections.emptyList(); + private List profiles = Collections.emptyList(); + private List worlds = Collections.emptyList(); + private int pingGeneration; + private final JComboBox typeFilter = new JComboBox<>(new String[]{"All profiles", "MODS", "PLUGINS", "MODS_SERVER"}); + private List visibleProfiles = Collections.emptyList(); + private final JTextArea profileDetails = textArea(6); + private final JTextField java8 = new JTextField(), java17 = new JTextField(), java21 = new JTextField(); + private final JTextField extraJava = new JTextField(), clientId = new JTextField(), offlineName = new JTextField(); + private final JComboBox defaultProfile = new JComboBox<>(); + private final JSpinner port = new JSpinner(new SpinnerNumberModel(25565, 1, 65535, 1)); + private final JCheckBox upnp = new JCheckBox("Use UPnP when I explicitly share a world"); + private final JCheckBox rememberAccount = new JCheckBox("Remember Microsoft account on this computer"); + private final JCheckBox fullscreen = new JCheckBox("Launch Minecraft in fullscreen"); + private final JLabel account = new JLabel("Loading account…"); + private ServerConsolePanel console; + private SettingsInfo loadedSettings; + private JDialog signInDialog; + private long signInGeneration; + private JDialog launchDialog; + private LaunchChoicePanel launchChoices; + private final AutoCloseable downloadSubscription; + private int activeDownloads; + + public DashboardPanel(LauncherActions actions, ServerBrowserService browser) { this(actions, browser, true); } + + /** The legacy controls can be omitted for a standalone preview without initializing a server. */ + public DashboardPanel(LauncherActions actions, ServerBrowserService browser, boolean includeLegacyControls) { + this(actions, browser, includeLegacyControls, null); + } + DashboardPanel(LauncherActions actions, ServerBrowserService browser, boolean includeLegacyControls, Consumer launchChoicePresenter) { + super(new BorderLayout(0, 0)); + this.launchChoicePresenter = launchChoicePresenter; + this.actions = Objects.requireNonNull(actions); this.browser = Objects.requireNonNull(browser); + setBorder(new EmptyBorder(12, 12, 10, 12)); pages.setOpaque(false); + setPreferredSize(new Dimension(1100, 720)); + JPanel navigation = DashboardTheme.surface(null, 16); navigation.setLayout(new BoxLayout(navigation, BoxLayout.Y_AXIS)); + navigation.setBorder(new EmptyBorder(24, 16, 20, 16)); navigation.setPreferredSize(new Dimension(195, 600)); + JLabel brand = new JLabel("AutoPlug"); brand.setFont(brand.getFont().deriveFont(Font.BOLD, 25f)); + navigation.add(brand); navigation.add(Box.createVerticalStrut(5)); + navigationTitle.setName("navigation-title"); navigationTitle.setFont(navigationTitle.getFont().deriveFont(Font.BOLD, 13f)); + navigationTitle.setMaximumSize(new Dimension(Integer.MAX_VALUE, 24)); navigationTitle.setAlignmentX(Component.LEFT_ALIGNMENT); + DashboardTheme.tint(navigationTitle, true); + navigation.add(navigationTitle); navigation.add(Box.createVerticalStrut(30)); + ButtonGroup group = new ButtonGroup(); + String[] names = {"Server Browser", "Worlds", "Profiles", "Server Manager", "Settings"}; + JPanel[] views = {serverPage(), worldsPage(), profilesPage(), managerPage(includeLegacyControls), settingsPage()}; + for (int i = 0; i < names.length; i++) { + String name = names[i]; pages.add(views[i], name); + JToggleButton button = new JToggleButton(name); button.setHorizontalAlignment(SwingConstants.LEFT); + button.setIcon(DashboardTheme.icon(name)); button.setToolTipText(name); + DashboardTheme.navigation(button); + button.setPreferredSize(new Dimension(170, 43)); + button.setMaximumSize(new Dimension(Integer.MAX_VALUE, 43)); button.setAlignmentX(Component.LEFT_ALIGNMENT); + button.addActionListener(e -> { showPage(name); if (name.equals("Worlds")) refreshWorlds(); }); + navigationButtons.put(name, button); + group.add(button); navigation.add(button); navigation.add(Box.createVerticalStrut(8)); + if (i == 0) button.setSelected(true); + } + navigation.add(Box.createVerticalGlue()); + JLabel footer = new JLabel("Your worlds. Your profiles."); footer.setFont(footer.getFont().deriveFont(11f)); navigation.add(footer); + add(navigation, BorderLayout.WEST); add(pages, BorderLayout.CENTER); + JPanel statusBar = DashboardTheme.surface(new BorderLayout(10, 4), 8); + status.putClientProperty("html.disable", Boolean.TRUE); status.setName("activity-step"); + progress.setName("activity-progress"); progress.setPreferredSize(new Dimension(140, 34)); + progress.setLayout(new BorderLayout()); progress.setStringPainted(false); + progress.setForeground(new Color(DashboardTheme.dark() ? 0x254B78 : 0xC9DEFF)); + status.setBorder(new EmptyBorder(5, 10, 5, 10)); progress.add(status); + progress.setToolTipText("Overall progress is estimated until all steps finish; the activity details show measured downloads."); + JPanel step = DashboardTheme.transparent(new BorderLayout(10, 0)); step.add(progress); + sourceUrl.setName("download-source"); sourceUrl.setEditable(false); sourceUrl.setVisible(false); + sourceUrl.setToolTipText("Current download source — select and copy the full URL"); sourceUrl.getAccessibleContext().setAccessibleName("Download source URL"); + JButton details = iconButton("Activity details", "activity", () -> information("Activity & download sources", activity.getText())); + statusBar.add(step, BorderLayout.CENTER); statusBar.add(details, BorderLayout.EAST); statusBar.add(sourceUrl, BorderLayout.SOUTH); + add(statusBar, BorderLayout.SOUTH); + actions.onProgress(this::receiveProgress); + actions.onProgressValue(this::receiveProgressValue); + downloadSubscription = DownloadProgress.subscribe(this::receiveDownloadProgress); + refreshProfiles(); refreshWorlds(); refreshSettings(); + run("Importing Minecraft favorites", () -> { browser.importVanilla(); return browser.list(); }, this::showServers); + } + + @Override protected void paintComponent(Graphics graphics) { + super.paintComponent(graphics); DashboardTheme.canvas(graphics, getWidth(), getHeight()); + } + @Override public void updateUI() { super.updateUI(); DashboardTheme.refreshColors(this); } + + private JPanel serverPage() { + JPanel page = page("Server Browser", "Your Minecraft favorites, with live status and matching local profiles."); + JPanel content = DashboardTheme.transparent(new BorderLayout(0, 12)); + JPanel controls = DashboardTheme.surface(new BorderLayout(0, 6), 10); + controls.add(toolbar(primaryButton("Add server", this::addServer), button("Import Minecraft", () -> run("Importing favorites", () -> { + int count = browser.importVanilla(); return count; + }, count -> { status.setText("Imported " + count + " new favorites from " + browser.vanillaFile()); refreshServers(); })), + button("Refresh status", this::refreshServers)), BorderLayout.NORTH); + serverSearch.setName("server-search"); serverSearch.setToolTipText("Search favorites by name, address or server message"); + serverSearch.getAccessibleContext().setAccessibleName("Search servers"); + serverSearch.putClientProperty("JTextField.placeholderText", "Search your favorites"); + serverSort.setName("server-sort"); serverSort.getAccessibleContext().setAccessibleName("Sort servers"); + reverseServerSort.setName("server-sort-descending"); reverseServerSort.setIcon(DashboardTheme.icon("sort")); + reverseServerSort.setToolTipText("Reverse the selected sort order"); + controls.add(toolbar(new JLabel("Search"), serverSearch, new JLabel("Sort"), serverSort, reverseServerSort), BorderLayout.SOUTH); + serverSearch.getDocument().addDocumentListener(new javax.swing.event.DocumentListener() { + public void insertUpdate(javax.swing.event.DocumentEvent e) { arrangeServerCards(); } + public void removeUpdate(javax.swing.event.DocumentEvent e) { arrangeServerCards(); } + public void changedUpdate(javax.swing.event.DocumentEvent e) { arrangeServerCards(); } + }); + serverSort.addActionListener(e -> arrangeServerCards()); reverseServerSort.addActionListener(e -> arrangeServerCards()); + content.add(controls, BorderLayout.NORTH); + JPanel list = DashboardTheme.transparent(new BorderLayout(0, 10)); + serverCards.setName("server-cards"); + DashboardTheme.tint(serverEmpty, false); serverEmpty.setBorder(new EmptyBorder(40, 12, 40, 12)); + list.add(serverEmpty, BorderLayout.NORTH); list.add(DashboardTheme.scroll(serverCards)); content.add(list, BorderLayout.CENTER); + JTextArea note = textArea(1); note.setText("Server details come from live pings. Join uses your local profiles and keeps version checks in place."); + DashboardTheme.tint(note, false); content.add(note, BorderLayout.SOUTH); + serverTabs = new JTabbedPane(); serverTabs.setName("server-browser-tabs"); + managedWorldPanel = new ManagedWorldPanel(actions, world -> launchManagedWorld(world, false), this::openFolder); + serverTabs.addTab("Favorites", content); serverTabs.addTab("Managed worlds & consoles", managedWorldPanel); + serverTabs.addChangeListener(e -> { if (serverTabs.getSelectedIndex() == 1) managedWorldPanel.refresh(); }); + page.add(serverTabs, BorderLayout.CENTER); return page; + } + private void showPage(String name) { + cards.show(pages, name); navigationTitle.setText(name); + JToggleButton selected = navigationButtons.get(name); if (selected != null) selected.setSelected(true); + } + private void manageWorld(WorldInfo world) { + showPage("Server Browser"); serverTabs.setSelectedIndex(1); managedWorldPanel.select(world.id); + } + + private void addServer() { + JTextField name = new JTextField(), address = new JTextField(); + if (!form("Add a Minecraft server", fields("Name", name, "Address", address))) return; + try { + SavedServer server = new SavedServer(name.getText(), address.getText()); + run("Saving favorite", () -> { browser.add(server.name, server.address); return browser.list(); }, this::showServers); + } catch (IllegalArgumentException e) { error(e); } + } + private void removeServer() { + SavedServer selected = selectedServer(); if (selected == null) return; + if (!confirm("Remove favorite", "Remove “" + selected.name + "” from AutoPlug favorites?\nMinecraft's servers.dat remains unchanged.")) return; + run("Removing favorite", () -> { browser.remove(selected.address); return browser.list(); }, this::showServers); + } + private void refreshServers() { run("Loading favorites", browser::list, this::showServers); } + private void copyServerAddress() { + SavedServer server = selectedServer(); if (server == null) return; + Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new StringSelection(server.address), null); + status.setText("Copied " + server.address); + } + private void editServer() { + SavedServer selected = selectedServer(); if (selected == null) return; + JTextField name = new JTextField(selected.name), address = new JTextField(selected.address); + if (!form("Edit favorite", fields("Name", name, "Address", address))) return; + try { + SavedServer edited = new SavedServer(name.getText(), address.getText()); + run("Saving favorite", () -> { + browser.add(edited.name, edited.address); + if (!selected.address.equalsIgnoreCase(edited.address)) browser.remove(selected.address); + return browser.list(); + }, this::showServers); + } catch (IllegalArgumentException e) { error(e); } + } + private void pingSelected() { + SavedServer selected = selectedServer(); if (selected == null) return; + int generation = pingGeneration; long request = beginServerPing(selected); + run("Checking " + selected.name, () -> browser.ping(selected), ping -> showPing(generation, selected, request, ping)); + } + private void showServers(List result) { + displayServers(result, Collections.emptyMap()); + int generation = pingGeneration; + for (SavedServer server : servers) { + long request = beginServerPing(server); + run("Checking " + server.name, () -> browser.ping(server), ping -> showPing(generation, server, request, ping)); + } + } + /** Render a snapshot without network work; also used by deterministic preview fixtures. */ + int displayServers(List result, Map statuses) { + ++pingGeneration; servers = new ArrayList<>(result); serverStatuses.clear(); serverStatuses.putAll(statuses); serverPingRequests.clear(); + serverCards.removeAll(); serverViews.clear(); + for (SavedServer server : servers) { + ServerCard card = new ServerCard(server); serverViews.put(server.address, card); serverCards.add(card.panel); + card.update(serverStatuses.get(server.address)); + } + arrangeServerCards(); + return pingGeneration; + } + void showPing(int generation, SavedServer server, ServerStatus ping) { + if (closed.get() || generation != pingGeneration) return; + ServerCard card = serverViews.get(server.address); if (card == null) return; + serverStatuses.put(server.address, ping); card.update(ping); arrangeServerCards(); + } + /** Called on the EDT; a newer manual check supersedes an in-flight check of the same favorite. */ + long beginServerPing(SavedServer server) { + long request = ++nextServerPingRequest; serverPingRequests.put(server.address, request); + serverStatuses.remove(server.address); + ServerCard card = serverViews.get(server.address); if (card != null) card.update(null); + return request; + } + void showPing(int generation, SavedServer server, long request, ServerStatus ping) { + if (!Objects.equals(serverPingRequests.get(server.address), request)) return; + showPing(generation, server, ping); + } + private SavedServer selectedServer() { + ServerCard selected = serverViews.get(selectedServerAddress); + if (selected != null && selected.panel.isVisible()) return selected.server; + status.setText("Select a server first."); return null; + } + private void arrangeServerCards() { + String query = serverSearch.getText().trim().toLowerCase(Locale.ROOT); + List ordered = new ArrayList<>(serverViews.values()); + String sort = String.valueOf(serverSort.getSelectedItem()); + Comparator order = (left, right) -> 0; + if ("Last joined".equals(sort)) order = Comparator.comparingLong(card -> card.server.lastJoinedAt).reversed(); + else if ("Name".equals(sort)) order = Comparator.comparing(card -> card.server.name, String.CASE_INSENSITIVE_ORDER); + else if ("Address".equals(sort)) order = Comparator.comparing(card -> card.server.address, String.CASE_INSENSITIVE_ORDER); + else if ("Status".equals(sort)) order = Comparator.comparing(card -> card.state.getText()); + else if ("MOTD".equals(sort)) order = Comparator.comparing(card -> card.message.getText(), String.CASE_INSENSITIVE_ORDER); + else if ("Version".equals(sort)) order = Comparator.comparing(card -> card.version.getText(), String.CASE_INSENSITIVE_ORDER); + else if ("Players".equals(sort)) order = Comparator.comparingInt(card -> { + ServerStatus value = serverStatuses.get(card.server.address); return value != null && value.online ? value.players : -1; + }); + else if ("Latency".equals(sort)) order = Comparator.comparingLong(card -> { + ServerStatus value = serverStatuses.get(card.server.address); return value != null && value.online ? value.latency : Long.MAX_VALUE; + }); + if (!"Favorite order".equals(sort)) ordered.sort(order.thenComparing(card -> card.server.address)); + if (reverseServerSort.isSelected()) Collections.reverse(ordered); + ServerCard firstVisible = null; int index = 0; + for (ServerCard card : ordered) { + serverCards.setComponentZOrder(card.panel, index++); + String text = card.server.name + " " + card.server.address + " " + card.message.getText() + " " + card.version.getText(); + boolean visible = query.isEmpty() || text.toLowerCase(Locale.ROOT).contains(query); + card.panel.setVisible(visible); if (visible && firstVisible == null) firstVisible = card; + } + ServerCard selected = serverViews.get(selectedServerAddress); + if (selected == null || !selected.panel.isVisible()) selectedServerAddress = firstVisible == null ? null : firstVisible.server.address; + for (ServerCard card : ordered) card.selectionChanged(); + serverEmpty.setText(servers.isEmpty() ? "Add a server or import Minecraft favorites to get started." : "No favorites match your search."); + serverEmpty.setVisible(firstVisible == null); + serverCards.revalidate(); serverCards.repaint(); + } + private void selectServer(SavedServer server, boolean focus) { + selectedServerAddress = server.address; + for (ServerCard card : serverViews.values()) card.selectionChanged(); + ServerCard selected = serverViews.get(server.address); + if (selected != null && focus) { + selected.select.requestFocusInWindow(); + serverCards.scrollRectToVisible(selected.panel.getBounds()); + } + } + private void navigateServer(SavedServer server, int offset) { + List visible = new ArrayList<>(); + for (Component component : serverCards.getComponents()) if (component.isVisible()) + for (ServerCard card : serverViews.values()) if (card.panel == component) visible.add(card); + for (int i = 0; i < visible.size(); i++) if (visible.get(i).server.address.equals(server.address)) { + int next = Math.max(0, Math.min(visible.size() - 1, i + offset)); selectServer(visible.get(next).server, true); return; + } + } + private final class ServerCard { + final SavedServer server; + final JPanel panel = DashboardTheme.surface(new BorderLayout(18, 8), 16); + final JToggleButton select; + final JLabel state = serverStatusBadge(); + final JLabel icon = new JLabel(DashboardTheme.serverIcon(), SwingConstants.CENTER); + final JTextArea message = textArea(2); + final JLabel players = serverDetail("Players: —", "players"), version = serverDetail("Version: —", "version"), latency = serverDetail("Latency: —", "ping"); + ServerCard(SavedServer server) { + this.server = server; panel.setName("server-" + server.address); + panel.getAccessibleContext().setAccessibleName("Favorite server " + server.name); + JPanel thumbnail = DashboardTheme.transparent(new GridBagLayout()); + icon.setName("server-icon"); icon.setPreferredSize(new Dimension(96, 96)); + icon.setToolTipText("Server icon (shown when supplied by the server)"); + icon.getAccessibleContext().setAccessibleName("Server icon for " + server.name); + thumbnail.add(icon); panel.add(thumbnail, BorderLayout.WEST); + JPanel details = DashboardTheme.transparent(new BorderLayout(0, 6)); + JPanel heading = DashboardTheme.transparent(new BorderLayout(0, 4)); + JPanel stateRow = DashboardTheme.transparent(new FlowLayout(FlowLayout.LEFT, 0, 0)); + state.setName("server-status"); stateRow.add(state); heading.add(stateRow, BorderLayout.NORTH); + JPanel identity = DashboardTheme.transparent(new BorderLayout(0, 2)); + select = new JToggleButton(); select.putClientProperty("html.disable", Boolean.TRUE); select.setText(server.name); + select.setName("select-server-" + server.address); select.setHorizontalAlignment(SwingConstants.LEFT); + select.setFont(select.getFont().deriveFont(Font.BOLD, 18f)); select.setMargin(new Insets(2, 5, 2, 5)); + select.setToolTipText("Server: " + server.name + " — " + server.address); select.getAccessibleContext().setAccessibleName("Select server " + server.name); + select.getAccessibleContext().setAccessibleDescription("Arrow keys move between cards; Enter joins this server; Space selects it."); + select.addActionListener(e -> selectServer(server, false)); + select.addFocusListener(new java.awt.event.FocusAdapter() { @Override public void focusGained(java.awt.event.FocusEvent e) { selectServer(server, false); } }); + identity.add(select, BorderLayout.NORTH); + JLabel address = serverDetail(server.address, "address"); address.setName("server-address"); identity.add(address, BorderLayout.SOUTH); + heading.add(identity, BorderLayout.CENTER); details.add(heading, BorderLayout.NORTH); + JPanel body = DashboardTheme.transparent(new BorderLayout(0, 2)); + message.setName("server-message"); message.setFocusable(false); message.setPreferredSize(new Dimension(100, 40)); DashboardTheme.tint(message, false); + body.add(message, BorderLayout.CENTER); + JPanel facts = toolbar(players, version, latency); + players.setName("server-players"); version.setName("server-version"); latency.setName("server-latency"); + body.add(facts, BorderLayout.SOUTH); details.add(body, BorderLayout.CENTER); + JPanel controls = toolbar(primaryButton("Join", () -> { selectServer(server, false); joinSelected(); }), + iconButton("Ping selected", "ping", () -> { selectServer(server, false); pingSelected(); }), + iconButton("Copy address", "copy", () -> { selectServer(server, false); copyServerAddress(); }), + iconButton("Edit server", "edit", () -> { selectServer(server, false); editServer(); }), + iconButton("Remove", "delete", () -> { selectServer(server, false); removeServer(); })); + for (Component child : controls.getComponents()) if (child instanceof AbstractButton) { + AbstractButton action = (AbstractButton) child; + action.getAccessibleContext().setAccessibleDescription(action.getToolTipText() + " for " + server.name + " at " + server.address); + action.addFocusListener(new java.awt.event.FocusAdapter() { @Override public void focusGained(java.awt.event.FocusEvent e) { selectServer(server, false); } }); + } + details.add(controls, BorderLayout.SOUTH); panel.add(details, BorderLayout.CENTER); + bindServerKey("LEFT", "previous-server", () -> navigateServer(server, -1)); + bindServerKey("RIGHT", "next-server", () -> navigateServer(server, 1)); + bindServerKey("UP", "previous-server-row", () -> navigateServer(server, -serverCards.columns())); + bindServerKey("DOWN", "next-server-row", () -> navigateServer(server, serverCards.columns())); + bindServerKey("ENTER", "join-server", () -> { selectServer(server, false); joinSelected(); }); + java.awt.event.MouseAdapter click = new java.awt.event.MouseAdapter() { + @Override public void mousePressed(java.awt.event.MouseEvent e) { selectServer(server, true); } + }; + for (Component child : new Component[]{panel, thumbnail, heading, icon, address, body, stateRow, state, message, facts, players, version, latency}) child.addMouseListener(click); + } + private void bindServerKey(String key, String name, Runnable action) { + select.getInputMap(JComponent.WHEN_FOCUSED).put(KeyStroke.getKeyStroke(key), name); + select.getActionMap().put(name, new AbstractAction() { @Override public void actionPerformed(java.awt.event.ActionEvent event) { action.run(); } }); + } + void selectionChanged() { + boolean selected = server.address.equals(selectedServerAddress); select.setSelected(selected); + panel.putClientProperty("AutoPlug.selected", selected); panel.repaint(); + } + void update(ServerStatus ping) { + state.setText(ping == null ? "Checking…" : ping.online ? "Online" : "Offline · Unavailable"); + state.putClientProperty("server-online", ping == null ? null : ping.online); + state.setIcon(DashboardTheme.icon(ping != null && ping.online ? "online" : ping == null ? "ping" : "offline-server")); + icon.setIcon(ping != null && ping.icon != null ? new ImageIcon(ping.icon) : DashboardTheme.serverIcon()); + String detail = ping == null ? "Checking this server’s status…" : ping.online ? Objects.toString(ping.motd, "") : Objects.toString(ping.message, "Status unavailable"); + message.setText(detail); message.setCaretPosition(0); message.setToolTipText("Server message: " + detail); message.getAccessibleContext().setAccessibleName("Server message: " + detail); + players.setText("Players: " + (ping != null && ping.online ? ping.players + " / " + ping.capacity : "—")); + version.setText("Minecraft: " + (ping != null && ping.online ? Objects.toString(ping.version, "—") : "—")); + latency.setText("Latency: " + (ping != null && ping.online ? ping.latency + " ms" : "—")); + for (JLabel fact : new JLabel[]{players, version, latency}) fact.setToolTipText(fact.getText()); + } + } + private static JLabel serverStatusBadge() { + JLabel badge = new JLabel("Checking…") { + @Override protected void paintComponent(Graphics graphics) { + Boolean online = (Boolean) getClientProperty("server-online"); + boolean dark = DashboardTheme.dark(); + Color foreground = online == null ? DashboardTheme.muted() : online + ? new Color(dark ? 0x94E2B0 : 0x17643D) : new Color(dark ? 0xFFB0B0 : 0xA5262F); + Color background = online == null ? new Color(dark ? 0x303D50 : 0xEDF1F6) : online + ? new Color(dark ? 0x173E2B : 0xE1F5E8) : new Color(dark ? 0x51272C : 0xFDE7E9); + if (!foreground.equals(getForeground())) setForeground(foreground); + Graphics2D g = (Graphics2D) graphics.create(); + try { + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g.setColor(background); g.fillRoundRect(0, 0, getWidth(), getHeight(), 12, 12); + } finally { g.dispose(); } + super.paintComponent(graphics); + } + }; + badge.setBorder(new EmptyBorder(3, 9, 3, 9)); badge.setFont(badge.getFont().deriveFont(Font.BOLD, 11f)); + return badge; + } + private static JLabel serverDetail(String text, String icon) { + JLabel label = new JLabel(); label.putClientProperty("html.disable", Boolean.TRUE); label.setText(text); + label.setIcon(DashboardTheme.icon(icon)); label.setHorizontalAlignment(SwingConstants.LEFT); + label.setToolTipText("Details: " + text); DashboardTheme.tint(label, false); return label; + } + private static final class ServerGrid extends JPanel implements Scrollable { + private static final int GAP = 12, CARD_HEIGHT = 246; + private int previousColumns; + ServerGrid() { super(null); setOpaque(false); } + int columns() { return 1; } + @Override public void doLayout() { + int columns = columns(), width = Math.max(0, (getWidth() - GAP * (columns - 1)) / columns), index = 0; + for (Component card : getComponents()) if (card.isVisible()) { + card.setBounds((index % columns) * (width + GAP), (index / columns) * (CARD_HEIGHT + GAP), width, CARD_HEIGHT); index++; + } + if (previousColumns != columns) { previousColumns = columns; revalidate(); } + } + @Override public Dimension getPreferredSize() { + int count = 0; for (Component card : getComponents()) if (card.isVisible()) count++; + int rows = (count + columns() - 1) / columns(); return new Dimension(0, Math.max(0, rows * (CARD_HEIGHT + GAP) - GAP)); + } + @Override public Dimension getPreferredScrollableViewportSize() { return new Dimension(800, 520); } + @Override public int getScrollableUnitIncrement(Rectangle visible, int orientation, int direction) { return 24; } + @Override public int getScrollableBlockIncrement(Rectangle visible, int orientation, int direction) { return Math.max(24, visible.height - 24); } + @Override public boolean getScrollableTracksViewportWidth() { return true; } + @Override public boolean getScrollableTracksViewportHeight() { return false; } + } + private void joinSelected() { + SavedServer server = selectedServer(); if (server == null) return; + joinServer(server); + } + private void joinServer(SavedServer server) { + ServerStatus ping = serverStatuses.get(server.address); + String targetVersion = ping != null && ping.online ? gameVersionFromStatus(ping.version) : ""; + List clients = clientProfiles(); + if (clients.isEmpty()) { + run("Preparing your default Minecraft profile", () -> { actions.ensureDefaultProfiles(targetVersion); return actions.profiles(); }, result -> { + profiles = result; showProfiles(); refreshDefaultProfiles(); + if (clientProfiles().isEmpty()) information("Default profile unavailable", "AutoPlug could not prepare the default client. Check the activity details and try again."); + else joinServer(server); + }); return; + } + ProfileInfo base = preferredBase(clients); + if (ping != null && ping.online) { + ProfileInfo match = matchingClientProfile(clients, targetVersion, base.loader); + List compatible = new ArrayList<>(); + for (ProfileInfo p : clients) if (p.launchable && !p.template && targetVersion.equals(p.gameVersion)) compatible.add(p); + if (!compatible.isEmpty()) { + chooseProfile("server:" + server.address, "Join " + server.name, compatible, match, selected -> launchOnServer(server, selected)); return; + } + JComboBox baseChoice = new JComboBox<>(clients.toArray(new ProfileInfo[0])); baseChoice.setSelectedItem(base); + JTextField target = new JTextField(targetVersion); + JPanel details = fields("Server version", new JLabel(ping.version), "Base profile / loader", baseChoice, "Target Minecraft version", target); + Object[] options = {"Clone and join", "Choose existing", "Cancel"}; + int option = JOptionPane.showOptionDialog(this, details, "No matching " + base.loader + " profile", JOptionPane.DEFAULT_OPTION, JOptionPane.QUESTION_MESSAGE, null, options, options[0]); + if (option == 0) { + ProfileInfo selectedBase = (ProfileInfo) baseChoice.getSelectedItem(); String version = target.getText().trim(); + if (selectedBase == null || version.isEmpty()) return; + String name = selectedBase.name + " " + version; + run("Preparing a matching profile", () -> actions.cloneProfile(selectedBase.id, name, version, selectedBase.loader), cloned -> finishJoinMigration(server, cloned)); + return; + } + if (option != 1) return; + } + chooseProfile("server:" + server.address, "Join " + server.name, clients, base, selected -> { + if (!ready(selected)) return; + if (ping != null && ping.online && !versionMatches(ping.version, selected.gameVersion) + && !confirm("Version differs", "The server reports " + ping.version + " but this profile uses " + selected.gameVersion + ".\nAttempt to connect with this profile?")) return; + launchOnServer(server, selected); + }); + } + private void chooseProfile(String target, String title, List candidates, ProfileInfo fallback, Consumer selected) { + List available = new ArrayList<>(candidates); available.removeIf(p -> !p.launchable || p.template); + if (available.isEmpty()) { information("No ready profile", "Create or finish migrating a compatible client profile first."); return; } + run("Loading your previous launch choice", () -> actions.preferredProfile(target), remembered -> { + ProfileInfo preferred = null; + for (ProfileInfo p : available) if (p.id.equals(remembered)) preferred = p; + if (preferred == null && loadedSettings != null) for (ProfileInfo p : available) if (p.id.equals(loadedSettings.defaultProfile)) preferred = p; + boolean auto = preferred != null; + if (preferred == null && fallback != null && available.stream().anyMatch(p -> p.id.equals(fallback.id))) { + preferred = fallback; if (target.startsWith("world:")) auto = true; + } + if (launchDialog != null) launchDialog.dispose(); if (launchChoices != null) launchChoices.close(); + if (launchChoicePresenter != null) { + launchChoices = new LaunchChoicePanel(available, preferred, auto, selected); + launchChoicePresenter.accept(launchChoices); return; + } + JDialog dialog = new JDialog(SwingUtilities.getWindowAncestor(this), title, Dialog.ModalityType.APPLICATION_MODAL); + launchDialog = dialog; + LaunchChoicePanel panel = new LaunchChoicePanel(available, preferred, auto, profile -> { + dialog.dispose(); selected.accept(profile); + }); + launchChoices = panel; dialog.add(panel); dialog.pack(); dialog.setResizable(true); dialog.setLocationRelativeTo(this); + dialog.addWindowListener(new java.awt.event.WindowAdapter() { @Override public void windowClosed(java.awt.event.WindowEvent e) { panel.close(); } }); + dialog.setVisible(true); panel.close(); + }); + } + private ProfileInfo preferredBase(List clients) { + if (loadedSettings != null) for (ProfileInfo profile : clients) if (profile.id.equals(loadedSettings.defaultProfile)) return profile; + for (ProfileInfo profile : clients) if (profile.template) return profile; + return clients.get(0); + } + private void finishJoinMigration(SavedServer server, ProfileInfo cloned) { + refreshProfiles(); + if (cloned.launchable) { launchOnServer(server, cloned); return; } + run("Planning compatible assets", () -> actions.checkProfile(cloned.id), plan -> { + if (!confirmText("Review migration before joining", plan + "\n\nApply this migration, then join “" + server.name + "”?\nCancel keeps the new profile pending for later review.")) return; + run("Applying profile migration", () -> actions.updateProfile(cloned.id), summary -> { + information("Migration summary", summary); + run("Verifying migrated profile", actions::profiles, updated -> { + profiles = updated; showProfiles(); refreshDefaultProfiles(); + for (ProfileInfo profile : updated) if (profile.id.equals(cloned.id)) { launchOnServer(server, profile); return; } + information("Profile unavailable", "The migrated profile could not be found."); + }); + }); + }); + } + private void launchOnServer(SavedServer server, ProfileInfo profile) { + if (!ready(profile)) return; + ServerAddress address = ServerAddress.parse(server.address); + run("Launching " + profile.name, () -> { + actions.launchProfile(profile.id, address.host, address.port); + try { actions.rememberProfile("server:" + server.address, profile.id); browser.recordJoined(server.address); } + catch (Exception e) { receiveProgress("Minecraft started, but the last-used choice could not be saved: " + message(e)); } + return null; + }, ignored -> { status.setText("Launch requested for " + server.name); refreshServers(); }); + } + static String gameVersionFromStatus(String version) { + if (version == null) return ""; + java.util.regex.Matcher match = java.util.regex.Pattern.compile("(? profiles, String version, String loader) { + if (version == null || version.isEmpty() || loader == null) return null; + for (ProfileInfo profile : profiles) if ("MODS".equalsIgnoreCase(profile.type) && profile.gameVersion.equals(version) + && profile.loader.equalsIgnoreCase(loader) && profile.launchable && !profile.template) return profile; + return null; + } + static boolean versionMatches(String serverVersion, String profileVersion) { + if (serverVersion == null || profileVersion == null || profileVersion.isEmpty()) return false; + return java.util.regex.Pattern.compile("(? showProfiles()); + content.add(toolbar(typeFilter, primaryButton("Create", this::createProfile), button("Clone / migrate", () -> cloneProfile(selectedProfile(), null)), + button("Check", () -> checkOrUpdate(false)), button("Update", () -> checkOrUpdate(true)), button("Refresh", this::refreshProfiles)), BorderLayout.NORTH); + content.add(tableScroll(profileTable), BorderLayout.CENTER); + JPanel bottom = DashboardTheme.transparent(new BorderLayout(0, 8)); + bottom.add(toolbar(button("Modrinth", () -> openLink("https://modrinth.com/mods")), + button("CurseForge", () -> openLink("https://www.curseforge.com/minecraft")), + button("AutoPlug mods", () -> openLink("https://autoplug.one/installer/mods"))), BorderLayout.NORTH); + bottom.setBorder(new EmptyBorder(12, 0, 0, 0)); bottom.add(DashboardTheme.scroll(profileDetails), BorderLayout.CENTER); + bottom.add(toolbar(primaryButton("Launch client", () -> { + ProfileInfo profile = selectedProfile(); if (!ready(profile)) return; + if (!"MODS".equalsIgnoreCase(profile.type)) { information("Client profiles", "Choose a MODS profile to launch the Minecraft client."); return; } + run("Launching client", () -> { actions.launchProfile(profile.id, null, 25565); return null; }, ignored -> {}); + }), button("Toggle template", () -> { + ProfileInfo profile = selectedProfile(); if (profile == null) return; + run("Saving template", () -> { actions.setTemplate(profile.id, !profile.template); return null; }, ignored -> refreshProfiles()); + }), button("Add JAR", this::addArtifact), button("Open folder", () -> { + ProfileInfo profile = selectedProfile(); if (profile != null) openFolder(profile.directory); + }), button("Delete", () -> { + ProfileInfo profile = selectedProfile(); if (profile == null) return; + if (!confirm("Delete profile", "Move “" + profile.name + "” and its isolated profile directory to AutoPlug's trash?")) return; + run("Deleting profile", () -> { actions.deleteProfile(profile.id); return null; }, ignored -> refreshProfiles()); + })), BorderLayout.SOUTH); + content.add(bottom, BorderLayout.SOUTH); page.add(content, BorderLayout.CENTER); + profileTable.getSelectionModel().addListSelectionListener(e -> { + int row = profileTable.getSelectedRow(); + if (row >= 0 && row < visibleProfiles.size()) { + ProfileInfo profile = visibleProfiles.get(profileTable.convertRowIndexToModel(row)); + profileDetails.setText(profile.directory + "\n" + (profile.launchable ? "Ready to launch" : "Needs migration review") + + (profile.migrationSummary == null || profile.migrationSummary.isEmpty() ? "" : "\n" + profile.migrationSummary)); + profileDetails.setCaretPosition(0); + String assetFolder = "PLUGINS".equalsIgnoreCase(profile.type) ? "plugins" : "mods"; + run("Checking profile content", () -> { + File folder = new File(profile.directory, assetFolder); + String[] jars = folder.list((parent, name) -> name.toLowerCase(Locale.ROOT).endsWith(".jar")); + return !folder.exists() || (jars != null && jars.length == 0); + }, empty -> { + int selected = profileTable.getSelectedRow(); + if (empty && selected >= 0 && selected < visibleProfiles.size() + && visibleProfiles.get(profileTable.convertRowIndexToModel(selected)).id.equals(profile.id)) { + profileDetails.append("\nNo " + assetFolder + " added yet. Use Add JAR to build your pack, or keep this profile empty."); + profileDetails.setCaretPosition(0); + } + }); + } + }); return page; + } + private void createProfile() { + JTextField name = new JTextField(), version = new JTextField("1.21.1"); + JComboBox loader = loaders("VANILLA"), type = new JComboBox<>(new String[]{"MODS", "PLUGINS", "MODS_SERVER"}); + filterLoaders(loader, "MODS"); + type.addActionListener(e -> filterLoaders(loader, String.valueOf(type.getSelectedItem()))); + JCheckBox template = new JCheckBox("Use as a reusable template"); + if (!form("Create profile", fields("Name", name, "Minecraft version", version, "Loader", loader, "Asset type", type, "Template", template))) return; + if (!required(name, version)) return; + String profileName = name.getText().trim(), gameVersion = version.getText().trim(), loaderName = String.valueOf(loader.getSelectedItem()), typeName = String.valueOf(type.getSelectedItem()); + boolean isTemplate = template.isSelected(); + run("Creating profile", () -> actions.createProfile(profileName, gameVersion, loaderName, typeName, isTemplate), this::profileCreated); + } + private void cloneProfile(ProfileInfo source, String suggestedVersion) { + if (source == null) return; + JComboBox base = new JComboBox<>(profiles.toArray(new ProfileInfo[0])); base.setSelectedItem(source); + JTextField name = new JTextField(source.name + " copy"), version = new JTextField(suggestedVersion == null ? source.gameVersion : suggestedVersion); + JComboBox loader = loaders(source.loader); + filterLoaders(loader, source.type); + base.addActionListener(e -> { ProfileInfo chosen = (ProfileInfo) base.getSelectedItem(); if (chosen != null) filterLoaders(loader, chosen.type); }); + if (!form("Clone and migrate profile", fields("Base profile", base, "New name", name, "Target version", version, "Target loader", loader))) return; + if (!required(name, version)) return; + ProfileInfo chosen = (ProfileInfo) base.getSelectedItem(); if (chosen == null) return; + if (!confirm("Confirm profile migration", "Clone “" + chosen.name + "” for " + version.getText().trim() + " / " + loader.getSelectedItem() + "?\nAutoPlug will check upgrades and downgrades. Applying the migration may disable incompatible assets.\nReview and apply the migration before launching.")) return; + String profileName = name.getText().trim(), gameVersion = version.getText().trim(), loaderName = String.valueOf(loader.getSelectedItem()); + run("Cloning and checking assets", () -> actions.cloneProfile(chosen.id, profileName, gameVersion, loaderName), this::profileCreated); + } + private void profileCreated(ProfileInfo profile) { + refreshProfiles(); + if (profile != null) information("Profile created: " + profile.name, + (profile.migrationSummary == null || profile.migrationSummary.trim().isEmpty() ? "Created isolated profile at:\n" + profile.directory : profile.migrationSummary) + + (profile.launchable ? "" : "\n\nReview and apply updates to finish migration. This profile cannot launch until its migration is resolved.")); + } + private void checkOrUpdate(boolean update) { + ProfileInfo profile = selectedProfile(); if (profile == null) return; + if (update) { + run("Checking profile before update", () -> actions.checkProfile(profile.id), summary -> { + if (!confirmText("Apply profile update", summary + "\n\nApply these changes? Incompatible assets may be disabled.")) return; + run("Updating profile", () -> actions.updateProfile(profile.id), result -> { information("Update summary", result); refreshProfiles(); }); + }); + } else run("Checking profile", () -> actions.checkProfile(profile.id), summary -> information("Compatibility and updates", summary)); + } + private void addArtifact() { + ProfileInfo profile = selectedProfile(); if (profile == null) return; + JFileChooser chooser = new JFileChooser(); chooser.setDialogTitle("Add a JAR to " + profile.name); + chooser.setFileFilter(new javax.swing.filechooser.FileNameExtensionFilter("Minecraft mod or plugin (*.jar)", "jar")); + chooser.setAcceptAllFileFilterUsed(false); chooser.setMultiSelectionEnabled(false); + if (chooser.showOpenDialog(this) != JFileChooser.APPROVE_OPTION) return; + File file = chooser.getSelectedFile(); + JTextField modrinth = new JTextField(); + if (!form("Add " + file.getName(), fields("Destination profile", new JLabel(profile.name), "Modrinth project ID / slug (optional)", modrinth))) return; + importArtifact(profile, file.getAbsolutePath(), modrinth.getText().trim()); + } + void importArtifact(ProfileInfo profile, String jarPath, String modrinthId) { + run("Adding mod or plugin", () -> { actions.addArtifact(profile.id, jarPath, modrinthId.isEmpty() ? null : modrinthId); return null; }, ignored -> { + refreshProfiles(); status.setText("Added " + new File(jarPath).getName() + " to " + profile.name + ". Use Check to review compatibility and updates."); + }); + } + private void refreshProfiles() { run("Loading profiles", actions::profiles, result -> { profiles = result; showProfiles(); refreshDefaultProfiles(); }); } + private void showProfiles() { + String filter = String.valueOf(typeFilter.getSelectedItem()); + visibleProfiles = new ArrayList<>(); profileModel.setRowCount(0); profileDetails.setText(""); + for (ProfileInfo profile : profiles) if (filter.equals("All profiles") || profile.type.replace('-', '_').equalsIgnoreCase(filter)) { + visibleProfiles.add(profile); profileModel.addRow(new Object[]{profile.name, profile.gameVersion, profile.loader, profile.type, profile.template ? "Yes" : "", profile.launchable ? "Ready" : "Review needed"}); + } + } + private ProfileInfo selectedProfile() { + int row = profileTable.getSelectedRow(); if (row < 0) { status.setText("Select a profile first."); return null; } + return visibleProfiles.get(profileTable.convertRowIndexToModel(row)); + } + private boolean ready(ProfileInfo profile) { + if (profile == null) return false; + if (!profile.launchable) { information("Migration needs review", profile.migrationSummary + "\nResolve the listed issues before launching this profile."); return false; } + if (profile.template) { information("Template profile", "Clone this template into a working profile before launching it."); return false; } + return true; + } + private List clientProfiles() { + List result = new ArrayList<>(); for (ProfileInfo p : profiles) if ("MODS".equalsIgnoreCase(p.type)) result.add(p); return result; + } + + private JPanel worldsPage() { + JPanel page = page("Worlds", "Your Minecraft saves and managed worlds, together in one place."); + JPanel content = DashboardTheme.transparent(new BorderLayout(0, 12)); + JPanel controls = DashboardTheme.surface(new BorderLayout(), 8); + controls.add(toolbar(primaryButton("Create world", this::createWorld), button("Refresh", this::refreshWorlds))); + content.add(controls, BorderLayout.NORTH); + worldCards.setLayout(DashboardTheme.verticalStack()); + worldCards.setOpaque(false); + JScrollPane scroll = DashboardTheme.scroll(worldCards); + content.add(scroll, BorderLayout.CENTER); + JPanel note = DashboardTheme.surface(new BorderLayout(), 12); + note.add(new JLabel("Singleplayer opens your original save. Managed worlds support optional sharing.")); + content.add(note, BorderLayout.SOUTH); page.add(content, BorderLayout.CENTER); return page; + } + private void createWorld() { createWorld(false); } + private void createWorld(boolean defaultsChecked) { + List serverProfiles = new ArrayList<>(); + for (ProfileInfo p : profiles) if (!"MODS".equalsIgnoreCase(p.type) && p.launchable) serverProfiles.add(p); + List clientProfiles = clientProfiles(); clientProfiles.removeIf(p -> !p.launchable || p.template); + if (serverProfiles.isEmpty() || clientProfiles.isEmpty()) { + if (defaultsChecked) { information("Default profiles unavailable", "AutoPlug could not prepare matching defaults. Check the activity details and try again."); return; } + run("Preparing default world profiles", () -> { actions.ensureDefaultProfiles(""); return actions.profiles(); }, result -> { + profiles = result; showProfiles(); refreshDefaultProfiles(); createWorld(true); + }); return; + } + JTextField name = new JTextField(); JComboBox server = new JComboBox<>(serverProfiles.toArray(new ProfileInfo[0])); + JComboBox client = new JComboBox<>(clientProfiles.toArray(new ProfileInfo[0])); + JCheckBox eula = new JCheckBox("I accept the Minecraft EULA"); + if (!form("Create managed world", fields("World name", name, "Server profile", server, "Client profile", client, "Minecraft EULA", toolbar(eula, button("Read EULA", this::openEula))))) return; + if (!required(name)) return; + ProfileInfo serverProfile = (ProfileInfo) server.getSelectedItem(), clientProfile = (ProfileInfo) client.getSelectedItem(); + if (serverProfile == null || clientProfile == null) return; + boolean accepted = eula.isSelected(); + String worldName = name.getText().trim(); + run("Creating isolated world", () -> { + WorldInfo world = actions.createWorld(worldName, serverProfile.id, clientProfile.id); + if (accepted) actions.setWorldEulaAccepted(world.id, true); return world; + }, world -> refreshWorlds()); + } + private void refreshWorlds() { run("Loading worlds", actions::worlds, result -> { worlds = result; showWorlds(); }); } + private void showWorlds() { + worldCards.removeAll(); + if (worlds.isEmpty()) { + JPanel empty = DashboardTheme.surface(new BorderLayout(0, 12), 24); empty.setBorder(new EmptyBorder(60, 24, 60, 24)); + JLabel title = new JLabel("A world of your own", SwingConstants.CENTER); title.setFont(title.getFont().deriveFont(Font.BOLD, 22f)); + empty.add(title, BorderLayout.NORTH); empty.add(new JLabel("Minecraft saves appear here automatically, or create a managed world.", SwingConstants.CENTER), BorderLayout.CENTER); + worldCards.add(empty); + } + for (WorldInfo world : worlds) { + JPanel card = DashboardTheme.surface(new BorderLayout(18, 8), 16); + card.setName("world-" + world.id); card.setAlignmentX(Component.LEFT_ALIGNMENT); + JLabel image = DashboardTheme.worldThumbnail(); image.setToolTipText(world.name); + image.getAccessibleContext().setAccessibleName("World icon for " + world.name); + if (world.thumbnail != null && !world.thumbnail.isEmpty()) run("Loading world thumbnail", () -> { + File file = new File(world.thumbnail); if (!file.isFile() || file.length() > 8 * 1024 * 1024) return null; + java.awt.image.BufferedImage bitmap = javax.imageio.ImageIO.read(file); + if (bitmap == null) return null; + double scale = 96.0 / Math.max(bitmap.getWidth(), bitmap.getHeight()); + return new ImageIcon(bitmap.getScaledInstance(Math.max(1, (int) (bitmap.getWidth() * scale)), Math.max(1, (int) (bitmap.getHeight() * scale)), Image.SCALE_SMOOTH)); + }, icon -> { if (icon != null) { image.setText(""); image.setIcon(icon); } }); + JPanel thumbnail = DashboardTheme.transparent(new GridBagLayout()); thumbnail.add(image); + card.add(thumbnail, BorderLayout.WEST); + JPanel details = DashboardTheme.transparent(new BorderLayout(0, 6)); + JPanel heading = DashboardTheme.transparent(new BorderLayout(0, 4)); + JLabel kind = DashboardTheme.badge(world.local ? "Singleplayer" : "Managed world" + (world.running ? " · Running" : "")); + JPanel badgeRow = DashboardTheme.transparent(new FlowLayout(FlowLayout.LEFT, 0, 0)); badgeRow.add(kind); + if (world.metadataAvailable && world.quickPlayEligible) { + JLabel quickPlay = DashboardTheme.badge("Quick Play"); quickPlay.setToolTipText("This version supports direct singleplayer Quick Play."); + badgeRow.add(Box.createHorizontalStrut(6)); badgeRow.add(quickPlay); + } + JLabel name = new JLabel(); name.putClientProperty("html.disable", Boolean.TRUE); name.setText(world.name); name.setFont(name.getFont().deriveFont(Font.BOLD, 18f)); + heading.add(badgeRow, BorderLayout.NORTH); heading.add(name, BorderLayout.CENTER); + details.add(heading, BorderLayout.NORTH); JTextArea description = textArea(world.metadataAvailable ? 4 : 3); + DashboardTheme.tint(description, false); + description.setText((world.local ? "Minecraft " + (knownWorldVersion(world) ? world.gameVersion : "version unknown — choose before launch") + : "Minecraft " + (knownWorldVersion(world) ? world.gameVersion : "version unknown") + " · Server: " + profileName(world.serverProfileId) + " · Client: " + profileName(world.clientProfileId)) + + "\n" + worldMetadata(world) + "\n" + world.directory); + description.setToolTipText(world.directory + (world.dataPacks.isEmpty() ? "" : " | Datapacks: " + String.join(", ", world.dataPacks))); + details.add(description, BorderLayout.CENTER); + if (world.local) details.add(toolbar(primaryButton("Launch", () -> launchLocalWorld(world)), button("Open folder", () -> openFolder(world.directory))), BorderLayout.SOUTH); + else details.add(toolbar(primaryButton("Play locally", () -> launchManagedWorld(world, false)), + button("Server console", () -> manageWorld(world)), + button("Share", () -> { + if (!confirm("Share this world", "Share “" + world.name + "” beyond this PC?\nThis may open a router port using UPnP and expose the server to the internet.\nOnly share the join address with people you trust.")) return; + if (!world.running) { launchManagedWorld(world, true); return; } + run("Preparing world sharing", () -> { + return actions.shareWorld(world.id); + }, address -> { showShare(address); refreshWorlds(); }); + }), button("Open folder", () -> openFolder(world.directory)), button("Minecraft EULA", () -> { + JCheckBox accept = new JCheckBox("I accept the Minecraft EULA"); + if (!form("Minecraft EULA", fields("Read the terms", button("Open Minecraft EULA", this::openEula), "Your agreement", accept)) || !accept.isSelected()) return; + run("Saving EULA acceptance", () -> { actions.setWorldEulaAccepted(world.id, true); return null; }, ignored -> {}); + })), BorderLayout.SOUTH); + card.add(details, BorderLayout.CENTER); + nameWorldActions(card, world.name); + worldCards.add(card); worldCards.add(Box.createVerticalStrut(12)); + } + GridBagConstraints remaining = new GridBagConstraints(); + remaining.gridx = 0; remaining.gridy = GridBagConstraints.RELATIVE; remaining.weightx = 1; remaining.weighty = 1; + remaining.fill = GridBagConstraints.BOTH; + worldCards.add(Box.createVerticalGlue(), remaining); worldCards.revalidate(); worldCards.repaint(); + } + + static String worldMetadata(WorldInfo world) { + if (!world.metadataAvailable) return "Additional save metadata unavailable"; + String played = world.lastPlayed <= 0 ? "Unknown" : DateTimeFormatter.ofPattern("d MMM yyyy, HH:mm").withZone(ZoneId.systemDefault()).format(Instant.ofEpochMilli(world.lastPlayed)); + String size = world.sizeBytes < 0 ? "Unknown" : (world.sizeComplete ? "" : "At least ") + readableSize(world.sizeBytes, !world.sizeComplete); + return "Last played: " + played + " · Size: " + size + "\n" + + (world.modded ? "Modded" : "Vanilla") + " · Cheats: " + (world.cheats ? "On" : "Off") + + " · Hardcore: " + (world.hardcore ? "On" : "Off") + " · Datapacks: " + world.dataPacks.size(); + } + static String readableSize(long bytes) { return readableSize(bytes, false); } + private static String readableSize(long bytes, boolean lowerBound) { + if (bytes < 1024) return bytes + " B"; + double value = bytes; String[] units = {"B", "KiB", "MiB", "GiB", "TiB"}; int unit = 0; + while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit++; } + return String.format(Locale.ROOT, "%.1f %s", lowerBound ? Math.floor(value * 10) / 10 : value, units[unit]); + } + private static void nameWorldActions(Container container, String name) { + for (Component component : container.getComponents()) { + if (component instanceof AbstractButton) { + AbstractButton button = (AbstractButton) component; + button.getAccessibleContext().setAccessibleName(button.getText() + " — " + name); + button.setToolTipText(button.getText() + " — " + name); + } + if (component instanceof Container) nameWorldActions((Container) component, name); + } + } + + private static boolean knownWorldVersion(WorldInfo world) { return world.gameVersion != null && !world.gameVersion.trim().isEmpty(); } + private void launchManagedWorld(WorldInfo world, boolean share) { + List compatible = new ArrayList<>(); ProfileInfo server = null, prior = null; + for (ProfileInfo p : profiles) { if (p.id.equals(world.serverProfileId)) server = p; if (p.id.equals(world.clientProfileId)) prior = p; } + for (ProfileInfo p : clientProfiles()) { + String version = server == null ? world.gameVersion : server.gameVersion; + if (version != null && !version.isEmpty() && !version.equals(p.gameVersion)) continue; + if (server != null && "MODS_SERVER".equals(server.type) && !"VANILLA".equals(server.loader) && !server.loader.equals(p.loader)) continue; + compatible.add(p); + } + if (world.running) { manageWorld(world); return; } + chooseProfile("world:" + world.id, "Play " + world.name, compatible, prior, chosen -> + run("Starting world " + world.name, () -> { actions.launchWorld(world.id, share, chosen.id); return share ? actions.shareWorld(world.id) : ""; }, result -> { + if (share) showShare(result); refreshWorlds(); if (managedWorldPanel != null) managedWorldPanel.refresh(); + })); + } + private void launchLocalWorld(WorldInfo world) { + String version = world.gameVersion; + if (!knownWorldVersion(world)) { + JTextField choice = new JTextField(); + JTextArea explanation = textArea(3); + explanation.setText("This save has no recorded Minecraft version. Enter the version you last used for it. Choose carefully: opening a save in another version can upgrade or damage it. Back up the save first."); + JPanel prompt = new JPanel(new BorderLayout(0, 12)); prompt.add(explanation, BorderLayout.NORTH); + prompt.add(fields("Minecraft version", choice), BorderLayout.CENTER); + if (!form("Choose this save's existing version", prompt) || !required(choice)) return; + version = choice.getText().trim(); + } + final String selectedVersion = version; + run("Preparing compatible local-world profiles", () -> { actions.ensureDefaultProfiles(selectedVersion); return actions.profiles(); }, result -> { + profiles = result; showProfiles(); refreshDefaultProfiles(); + List compatible = new ArrayList<>(); + for (ProfileInfo p : clientProfiles()) if (selectedVersion.equals(p.gameVersion) && "VANILLA".equals(p.loader)) compatible.add(p); + chooseProfile("world:" + world.id, "Launch " + world.name, compatible, null, chosen -> + run("Launching " + world.name + " with Minecraft " + selectedVersion, + () -> actions.launchLocalWorld(world.id, selectedVersion, chosen.id), text -> { status.setText(text); status.setToolTipText(text); })); + }); + } + private String profileName(String id) { for (ProfileInfo profile : profiles) if (profile.id.equals(id)) return profile.name; return id; } + private void showShare(String text) { + JTextArea area = textArea(6); area.setText(text); JPanel panel = new JPanel(new BorderLayout(0, 8)); panel.add(new JScrollPane(area)); + panel.add(button("Copy details", () -> Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new StringSelection(text), null)), BorderLayout.SOUTH); + JOptionPane.showMessageDialog(this, panel, "World sharing", JOptionPane.INFORMATION_MESSAGE); + } + + private JPanel managerPage(boolean includeLegacyControls) { + JPanel page = page("Server Manager", "The existing AutoPlug server, console, update tasks and backups."); + JPanel content = content(); + content.add(toolbar(commandButton("Start", ".start"), commandButton("Stop", ".stop"), commandButton("Restart", ".restart"), + commandButton("Run tasks", ".run tasks"), commandButton("Back up", ".backup")), BorderLayout.NORTH); + if (includeLegacyControls) { + JTabbedPane tabs = new JTabbedPane(); console = new ServerConsolePanel(tabs); tabs.addTab("Console & task output", console); + try { tabs.addTab("Plugins, mods & server settings", new ServerPanel(tabs)); } + catch (Exception e) { JTextArea problem = textArea(5); problem.setText("Existing server panels could not load: " + e.getMessage()); tabs.addTab("Server settings", new JScrollPane(problem)); } + JPanel maintenance = new JPanel(); maintenance.setLayout(new BoxLayout(maintenance, BoxLayout.Y_AXIS)); maintenance.setBorder(new EmptyBorder(20, 20, 20, 20)); + maintenance.add(new JLabel("Run the same update tasks available in the AutoPlug console.")); maintenance.add(Box.createVerticalStrut(16)); + maintenance.add(toolbar(commandButton("Check server", ".check server"), commandButton("Check plugins", ".check plugins"), commandButton("Check mods", ".check mods"), commandButton("Check Java", ".check java"))); + maintenance.add(Box.createVerticalStrut(24)); maintenance.add(new JLabel("Backups use your existing backup settings and require the server to be stopped.")); + maintenance.add(toolbar(commandButton("Create backup", ".backup"), button("Open configuration", () -> openFolder(new File(System.getProperty("user.dir"), "autoplug").getAbsolutePath())))); + maintenance.add(Box.createVerticalGlue()); tabs.addTab("Tasks & backups", maintenance); content.add(tabs, BorderLayout.CENTER); + } else content.add(new JLabel("Server controls are available in the running AutoPlug application.", SwingConstants.CENTER), BorderLayout.CENTER); + page.add(content, BorderLayout.CENTER); return page; + } + private JButton commandButton(String label, String command) { + return button(label, () -> { + if ((command.equals(".stop") || command.equals(".restart")) && !confirm(label + " server", label + " the existing AutoPlug server?")) return; + run(label, () -> { ServerConsolePanel.executeCommand(command); return null; }, ignored -> {}); + }); + } + + private JPanel settingsPage() { + JPanel page = page("Settings", "Runtime choices, default profiles, local networking and Minecraft accounts."); + JPanel identity = settingsGroup("Minecraft account"); identity.setName("settings-account"); + identity.add(stackedFields("Current account", account, "Microsoft app client ID", clientId, "Offline player name", offlineName)); + rememberAccount.setText("Remember my Microsoft account"); identity.add(rememberAccount); + identity.add(toolbar(primaryButton("Sign in with Microsoft", () -> { + if (!confirm("Microsoft sign-in", "Start Microsoft device sign-in?\nYou will authorize the displayed app in your browser. AutoPlug never asks for your Microsoft password.")) return; + if (clientId.getText().trim().isEmpty()) { information("Microsoft client ID", "Enter and save the public client ID of your Microsoft application before signing in."); return; } + SettingsInfo settings; + try { settings = settingsFromForm(); } catch (IllegalArgumentException e) { error(e); return; } + cancelSignIn(); + long displayAttempt = signInGeneration, attempt = actions.beginAccountChange(); + run("Waiting for Microsoft sign-in", () -> { + actions.signInMicrosoft(attempt, settings, instructions -> SwingUtilities.invokeLater(() -> { + if (!closed.get() && displayAttempt == signInGeneration) showSignIn(instructions); + })); return null; + }, ignored -> { if (displayAttempt == signInGeneration) { disposeSignIn(); refreshSettings(); } }); + }), button("Use offline name", () -> { + String name = offlineName.getText().trim(); + if (!name.matches("[A-Za-z0-9_]{1,16}")) { information("Offline player name", "Use 1–16 letters, numbers or underscores."); return; } + cancelSignIn(); + long attempt = actions.beginAccountChange(); + run("Switching to offline account", () -> { actions.useOfflineAccount(attempt, name); return null; }, ignored -> refreshSettings()); + }))); + JTextArea note = textArea(2); note.setText("Offline mode is for local/offline-enabled play. Online servers usually require a licensed Microsoft account."); + DashboardTheme.tint(note, false); note.setBorder(new EmptyBorder(10, 0, 0, 0)); identity.add(note); + JPanel defaults = settingsGroup("Defaults and sharing"); defaults.setName("settings-defaults"); + defaults.add(stackedFields("Default client profile", defaultProfile, "Preferred server port", port)); + defaults.add(fullscreen); + upnp.setText("Use UPnP when I choose Share"); defaults.add(upnp); + JTextArea sharing = textArea(2); sharing.setText("Sharing is always explicit. UPnP opens a router port only after you choose Share."); + DashboardTheme.tint(sharing, false); sharing.setBorder(new EmptyBorder(10, 0, 0, 0)); defaults.add(sharing); + JPanel advanced = settingsGroup(null); advanced.setName("settings-advanced"); + JToggleButton disclosure = new JToggleButton("Advanced"); disclosure.setIcon(DashboardTheme.icon("advanced")); + disclosure.setToolTipText("Show optional Java runtime overrides"); disclosure.getAccessibleContext().setAccessibleName("Advanced runtime settings"); + disclosure.setAlignmentX(Component.LEFT_ALIGNMENT); advanced.add(disclosure); + JPanel runtime = DashboardTheme.transparent(new BorderLayout(0, 8)); runtime.setName("advanced-runtime-fields"); + JTextArea hint = textArea(2); hint.setText("Java is resolved automatically. Override paths only when you need a specific installation."); DashboardTheme.tint(hint, false); + runtime.add(hint, BorderLayout.NORTH); + runtime.add(stackedFields("Java 8 executable", java8, "Java 17 executable", java17, "Java 21 executable", java21, "Other runtimes (major=path; …)", extraJava)); + runtime.setVisible(false); advanced.add(runtime); + alignGroup(identity); alignGroup(defaults); alignGroup(advanced); + disclosure.addActionListener(e -> { runtime.setVisible(disclosure.isSelected()); disclosure.setText(disclosure.isSelected() ? "Advanced −" : "Advanced"); page.revalidate(); }); + JPanel right = DashboardTheme.transparent(DashboardTheme.verticalStack()); + right.add(defaults); right.add(Box.createVerticalStrut(12)); right.add(advanced); + ResponsiveSettings settings = new ResponsiveSettings(identity, right); settings.setName("responsive-settings"); + page.add(DashboardTheme.scroll(settings), BorderLayout.CENTER); + JPanel save = DashboardTheme.surface(new BorderLayout(), 8); + save.add(toolbar(primaryButton("Save settings", this::saveSettings), button("Reload", this::refreshSettings))); + page.add(save, BorderLayout.SOUTH); return page; + } + + private static JPanel settingsGroup(String title) { + JPanel panel = DashboardTheme.surface(DashboardTheme.verticalStack(), 16); + panel.setAlignmentX(Component.LEFT_ALIGNMENT); + if (title != null) panel.add(section(title)); + return panel; + } + private static void alignGroup(JPanel panel) { + for (Component child : panel.getComponents()) if (child instanceof JComponent) ((JComponent) child).setAlignmentX(Component.LEFT_ALIGNMENT); + } + + private static JPanel stackedFields(Object... fields) { + JPanel panel = DashboardTheme.transparent(new GridBagLayout()); panel.setAlignmentX(Component.LEFT_ALIGNMENT); + for (int i = 0; i < fields.length; i += 2) { + JComponent control = (JComponent) fields[i + 1]; + JLabel label = new JLabel(String.valueOf(fields[i])); label.setLabelFor(control); DashboardTheme.tint(label, false); + GridBagConstraints c = new GridBagConstraints(); c.gridx = 0; c.gridy = i; c.weightx = 1; c.fill = GridBagConstraints.HORIZONTAL; c.insets = new Insets(i == 0 ? 0 : 8, 0, 4, 0); + panel.add(label, c); c.gridy++; c.insets = new Insets(0, 0, 0, 0); panel.add(control, c); + control.setMinimumSize(new Dimension(80, control.getPreferredSize().height)); + } + return panel; + } + + /** Reflows real controls, preserving edits; vertical scrolling remains available at narrow sizes. */ + private static final class ResponsiveSettings extends JPanel implements Scrollable { + ResponsiveSettings(JPanel left, JPanel right) { super(null); setOpaque(false); add(left); add(right); } + private boolean wide(int width) { return width >= 780; } + @Override public void doLayout() { + int width = getWidth(); int column = wide(width) ? (width - 12) / 2 : width; int y = 0; + for (int i = 0; i < getComponentCount(); i++) { + Component child = getComponent(i); child.setSize(column, child.getPreferredSize().height); + int height = child.getPreferredSize().height; + child.setBounds(wide(width) ? i * (column + 12) : 0, wide(width) ? 0 : y, column, height); y += height + 12; + } + } + @Override public Dimension getPreferredSize() { + int width = getParent() == null ? 800 : getParent().getWidth(); int height = 0; + for (Component child : getComponents()) height = wide(width) ? Math.max(height, child.getPreferredSize().height) : height + child.getPreferredSize().height + 12; + return new Dimension(600, height); + } + public Dimension getPreferredScrollableViewportSize() { return getPreferredSize(); } + public int getScrollableUnitIncrement(Rectangle visible, int orientation, int direction) { return 16; } + public int getScrollableBlockIncrement(Rectangle visible, int orientation, int direction) { return visible.height - 16; } + public boolean getScrollableTracksViewportWidth() { return true; } + public boolean getScrollableTracksViewportHeight() { return false; } + } + private static final class WorldList extends JPanel implements Scrollable { + public Dimension getPreferredScrollableViewportSize() { return getPreferredSize(); } + public int getScrollableUnitIncrement(Rectangle visible, int orientation, int direction) { return 16; } + public int getScrollableBlockIncrement(Rectangle visible, int orientation, int direction) { return visible.height - 16; } + public boolean getScrollableTracksViewportWidth() { return true; } + public boolean getScrollableTracksViewportHeight() { return false; } + } + private void refreshSettings() { run("Loading settings", actions::settings, this::showSettings); } + private void showSettings(SettingsInfo settings) { + loadedSettings = settings; java8.setText(settings.javaPaths.getOrDefault(8, settings.java8)); java17.setText(settings.javaPaths.getOrDefault(17, settings.java17)); java21.setText(settings.javaPaths.getOrDefault(21, settings.java21)); + StringJoiner extra = new StringJoiner("; "); settings.javaPaths.forEach((major, path) -> { if (major != 8 && major != 17 && major != 21) extra.add(major + "=" + path); }); extraJava.setText(extra.toString()); + clientId.setText(settings.microsoftClientId); account.setText(settings.account); port.setValue(Math.max(1, Math.min(65535, settings.port))); upnp.setSelected(settings.upnp); rememberAccount.setSelected(settings.rememberAccount); fullscreen.setSelected(settings.fullscreen); refreshDefaultProfiles(); + } + private void refreshDefaultProfiles() { + String selected = loadedSettings == null ? "" : loadedSettings.defaultProfile; + defaultProfile.removeAllItems(); defaultProfile.addItem(null); + for (ProfileInfo p : clientProfiles()) { defaultProfile.addItem(p); if (p.id.equals(selected)) defaultProfile.setSelectedItem(p); } + } + private SettingsInfo settingsFromForm() { + SettingsInfo settings = new SettingsInfo(); settings.java8 = java8.getText().trim(); settings.java17 = java17.getText().trim(); settings.java21 = java21.getText().trim(); + if (!settings.java8.isEmpty()) settings.javaPaths.put(8, settings.java8); if (!settings.java17.isEmpty()) settings.javaPaths.put(17, settings.java17); if (!settings.java21.isEmpty()) settings.javaPaths.put(21, settings.java21); + for (String entry : extraJava.getText().split(";")) { + if (entry.trim().isEmpty()) continue; String[] pair = entry.trim().split("=", 2); + try { int major = Integer.parseInt(pair[0].trim()); if (major < 1 || pair.length != 2 || pair[1].trim().isEmpty()) throw new IllegalArgumentException(); settings.javaPaths.put(major, pair[1].trim()); } + catch (RuntimeException e) { throw new IllegalArgumentException("Other runtimes must use major=path, separated by semicolons."); } + } + ProfileInfo profile = (ProfileInfo) defaultProfile.getSelectedItem(); settings.defaultProfile = profile == null ? "" : profile.id; + settings.port = (Integer) port.getValue(); settings.upnp = upnp.isSelected(); settings.rememberAccount = rememberAccount.isSelected(); settings.fullscreen = fullscreen.isSelected(); settings.microsoftClientId = clientId.getText().trim(); settings.account = account.getText(); return settings; + } + private void saveSettings() { + try { SettingsInfo settings = settingsFromForm(); cancelSignIn(); run("Saving settings", () -> { actions.saveSettings(settings); return settings; }, this::showSettings); } + catch (IllegalArgumentException e) { error(e); } + } + private void disposeSignIn() { + if (signInDialog != null) { signInDialog.dispose(); signInDialog = null; } + } + private void cancelSignIn() { + signInGeneration++; actions.cancelMicrosoftSignIn(); disposeSignIn(); + } + private void showSignIn(String instructions) { + disposeSignIn(); + signInDialog = new JDialog(SwingUtilities.getWindowAncestor(this), "Authorize Microsoft sign-in", Dialog.ModalityType.MODELESS); + signInDialog.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE); + signInDialog.addWindowListener(new java.awt.event.WindowAdapter() { + @Override public void windowClosing(java.awt.event.WindowEvent event) { cancelSignIn(); } + }); + JTextArea text = textArea(8); text.setText(instructions); text.setBorder(new EmptyBorder(20, 20, 20, 20)); + signInDialog.add(new JScrollPane(text)); + signInDialog.add(button("Cancel sign-in", this::cancelSignIn), BorderLayout.SOUTH); + signInDialog.setSize(560, 300); signInDialog.setLocationRelativeTo(this); signInDialog.setVisible(true); + } + + private void run(String description, Callable work, Consumer success) { + if (closed.get()) return; + int active = running.incrementAndGet(); + if (active == 1) { sourceUrl.setVisible(false); setActivity(description + "…"); } + if (active == 1 && activeDownloads == 0) { overall.start(); operationFailed = false; progress.setValue(overall.value()); progressClock.start(); } + progress.setVisible(true); + try { workers.submit(() -> { + try { + T result = work.call(); SwingUtilities.invokeLater(() -> { + if (closed.get()) return; + String before = status.getText(); + try { success.accept(result); } + finally { finishOperation(); if (running.get() == 0 && Objects.equals(before, status.getText())) setActivity(description + " complete"); } + }); + } catch (Exception e) { SwingUtilities.invokeLater(() -> { + if (closed.get()) return; + if (e instanceof CancellationException || e instanceof InterruptedException) { + // An obsolete sign-in must not mark its replacement as failed. + if (running.get() <= 1) operationFailed = true; + finishOperation(); if (running.get() == 0) setActivity(description + " cancelled"); return; + } + operationFailed = true; finishOperation(); setActivity(description + " failed: " + message(e)); error(e); + }); } + }); } catch (RejectedExecutionException ignored) { finishOperation(); } + } + private void finishOperation() { + if (running.decrementAndGet() <= 0 && activeDownloads == 0) { progressClock.stop(); if (!operationFailed) progress.setValue(overall.complete()); } + } + private void receiveProgress(String message) { + if (message == null || message.trim().isEmpty()) return; + SwingUtilities.invokeLater(() -> { + if (closed.get()) return; + setActivity(message); + java.util.regex.Matcher url = java.util.regex.Pattern.compile("https?://[^\\s<>]+", java.util.regex.Pattern.CASE_INSENSITIVE).matcher(message); + if (url.find()) { sourceUrl.setText(url.group()); sourceUrl.setCaretPosition(0); sourceUrl.setVisible(true); revalidate(); } + }); + } + private void receiveDownloadProgress(DownloadProgress.Event event) { + SwingUtilities.invokeLater(() -> { + if (closed.get()) return; + int before = activeDownloads; activeDownloads = event.activeTransfers; + if (before == 0 && activeDownloads > 0 && running.get() == 0) { overall.start(); operationFailed = false; progress.setValue(overall.value()); progressClock.start(); } + String label = event.message + (event.complete ? " — complete" : event.finished ? " — stopped" : ""); + if (!Objects.equals(status.getText(), label)) setActivity(label); + if (event.sourceUrl != null && !event.sourceUrl.isEmpty()) { + if (!Objects.equals(sourceUrl.getText(), event.sourceUrl)) activity.append("Source: " + event.sourceUrl + "\n"); + sourceUrl.setText(event.sourceUrl); sourceUrl.setCaretPosition(0); sourceUrl.setVisible(true); + } + boolean measured = event.totalBytes > 0 && event.downloadedBytes >= 0; + if (measured) { + int percent = (int) Math.min(100, 100.0 * event.downloadedBytes / event.totalBytes); + progress.setToolTipText("Overall progress is estimated. Current file: " + percent + "% · " + readableSize(event.downloadedBytes) + " / " + readableSize(event.totalBytes)); + } + if (event.finished && !event.complete && running.get() == 0) operationFailed = true; + if (event.finished && running.get() == 0 && activeDownloads == 0) { progressClock.stop(); if (!operationFailed) progress.setValue(overall.complete()); } + revalidate(); + }); + } + private void receiveProgressValue(Integer percent) { + SwingUtilities.invokeLater(() -> { + if (closed.get() || running.get() == 0) return; + boolean measured = percent != null && percent >= 0 && percent <= 100; + if (measured) progress.setToolTipText("Overall progress is estimated. Current step: " + percent + "%"); + }); + } + private void setActivity(String message) { + status.setText(message); status.setToolTipText(message); + activity.append(message + "\n"); + if (activity.getDocument().getLength() > 100000) activity.setText(activity.getText().substring(activity.getText().length() - 80000)); + } + private void openFolder(String directory) { run("Opening folder", () -> { if (!Desktop.isDesktopSupported()) throw new UnsupportedOperationException("Opening folders is unavailable on this system."); Desktop.getDesktop().open(new File(directory)); return null; }, ignored -> {}); } + private void openLink(String url) { run("Opening mod downloads", () -> { if (!Desktop.isDesktopSupported()) throw new UnsupportedOperationException("Open " + url + " in your browser."); Desktop.getDesktop().browse(java.net.URI.create(url)); return null; }, ignored -> {}); } + private void openEula() { run("Opening Minecraft EULA", () -> { if (!Desktop.isDesktopSupported()) throw new UnsupportedOperationException("Open https://aka.ms/MinecraftEULA in your browser."); Desktop.getDesktop().browse(java.net.URI.create("https://aka.ms/MinecraftEULA")); return null; }, ignored -> {}); } + private void error(Exception e) { information("Could not complete action", message(e)); } + private static String message(Exception e) { return e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage(); } + private void information(String title, String message) { JTextArea text = textArea(9); text.setText(message); text.setCaretPosition(0); JScrollPane scroll = new JScrollPane(text); scroll.setPreferredSize(new Dimension(590, 260)); JOptionPane.showMessageDialog(this, scroll, title, JOptionPane.INFORMATION_MESSAGE); } + private boolean confirm(String title, String message) { return JOptionPane.showConfirmDialog(this, message, title, JOptionPane.OK_CANCEL_OPTION, JOptionPane.WARNING_MESSAGE) == JOptionPane.OK_OPTION; } + private boolean confirmText(String title, String message) { JTextArea text = textArea(12); text.setText(message); JScrollPane scroll = new JScrollPane(text); scroll.setPreferredSize(new Dimension(650, 350)); return JOptionPane.showConfirmDialog(this, scroll, title, JOptionPane.OK_CANCEL_OPTION, JOptionPane.WARNING_MESSAGE) == JOptionPane.OK_OPTION; } + private boolean form(String title, JPanel fields) { fields.setPreferredSize(new Dimension(530, Math.max(160, fields.getPreferredSize().height))); return JOptionPane.showConfirmDialog(this, fields, title, JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE) == JOptionPane.OK_OPTION; } + private boolean required(JTextField... fields) { for (JTextField field : fields) if (field.getText().trim().isEmpty()) { information("Missing details", "Complete all required fields before continuing."); return false; } return true; } + private static JComboBox loaders(String selected) { JComboBox result = new JComboBox<>(new String[]{"VANILLA", "FABRIC", "QUILT", "FORGE", "NEOFORGE", "PAPER", "SPIGOT", "PURPUR"}); result.setSelectedItem(selected); return result; } + static void filterLoaders(JComboBox loader, String type) { + Object selected = loader.getSelectedItem(); + ProfileType profileType = ProfileType.valueOf(type.replace('-', '_').toUpperCase(Locale.ROOT)); + loader.removeAllItems(); + for (String name : new String[]{"VANILLA", "FABRIC", "QUILT", "FORGE", "NEOFORGE", "PAPER", "SPIGOT", "PURPUR", "BUKKIT", "VELOCITY", "BUNGEE"}) + if (profileType.supportsLoader(name)) loader.addItem(name); + if (selected != null && profileType.supportsLoader(selected.toString())) loader.setSelectedItem(selected); + else loader.setSelectedItem(profileType == ProfileType.PLUGINS ? "PAPER" : "VANILLA"); + } + private static JLabel section(String title) { JLabel label = new JLabel(title); label.setFont(label.getFont().deriveFont(Font.BOLD, 16f)); label.setBorder(new EmptyBorder(0, 0, 10, 0)); label.setAlignmentX(Component.LEFT_ALIGNMENT); return label; } + private static JPanel page(String title, String description) { + JPanel page = DashboardTheme.transparent(new BorderLayout(0, 16)); page.setBorder(new EmptyBorder(0, 18, 12, 0)); + page.setName("page-" + title); page.getAccessibleContext().setAccessibleName(title); page.getAccessibleContext().setAccessibleDescription(description); return page; + } + private static JPanel content() { return DashboardTheme.surface(new BorderLayout(0, 12), 16); } + private static JPanel toolbar(JComponent... components) { JPanel panel = DashboardTheme.transparent(new WrapLayout()); for (JComponent component : components) panel.add(component); panel.setAlignmentX(Component.LEFT_ALIGNMENT); return panel; } + private static JButton button(String text, Runnable action) { JButton button = new JButton(text, DashboardTheme.icon("Add server".equals(text) ? "favorite" : text)); button.setToolTipText(text); button.getAccessibleContext().setAccessibleName(text); button.setMargin(new Insets(6, 10, 6, 10)); button.addActionListener(e -> action.run()); return button; } + private static JButton iconButton(String text, String icon, Runnable action) { JButton button = button(text, action); button.setText(""); button.setIcon(DashboardTheme.icon(icon)); return button; } + private static JButton primaryButton(String text, Runnable action) { return DashboardTheme.primary(button(text, action)); } + private static JTextArea textArea(int rows) { JTextArea area = new JTextArea(rows, 30); area.setEditable(false); area.setOpaque(false); area.setLineWrap(true); area.setWrapStyleWord(true); area.setFont(UIManager.getFont("Label.font")); return area; } + private static DefaultTableModel model(String... columns) { return new DefaultTableModel(columns, 0) { @Override public boolean isCellEditable(int row, int column) { return false; } }; } + private static JTable table(DefaultTableModel model) { + JTable table = new JTable(model); table.setRowHeight(35); table.setFillsViewportHeight(true); + table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); table.setAutoCreateRowSorter(true); table.getTableHeader().setReorderingAllowed(false); + javax.swing.table.DefaultTableCellRenderer text = new javax.swing.table.DefaultTableCellRenderer(); + text.putClientProperty("html.disable", Boolean.TRUE); table.setDefaultRenderer(Object.class, text); + return table; + } + private static JScrollPane tableScroll(JTable table) { JScrollPane scroll = DashboardTheme.scroll(table); scroll.setColumnHeaderView(table.getTableHeader()); return scroll; } + private static JPanel fields(Object... fields) { + JPanel panel = DashboardTheme.transparent(new GridBagLayout()); panel.setAlignmentX(Component.LEFT_ALIGNMENT); + for (int i = 0; i < fields.length; i += 2) { + GridBagConstraints label = new GridBagConstraints(); label.gridx = 0; label.gridy = i / 2; label.anchor = GridBagConstraints.WEST; label.insets = new Insets(5, 0, 5, 14); + panel.add(new JLabel(String.valueOf(fields[i])), label); + GridBagConstraints value = new GridBagConstraints(); value.gridx = 1; value.gridy = i / 2; value.weightx = 1; value.fill = GridBagConstraints.HORIZONTAL; value.insets = new Insets(5, 0, 5, 0); + panel.add((Component) fields[i + 1], value); + } + panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, panel.getPreferredSize().height + 16)); return panel; + } + private static final class WrapLayout extends FlowLayout { + private int lastWidth = -1; + WrapLayout() { super(FlowLayout.LEFT, 6, 3); } + @Override public void layoutContainer(Container target) { + super.layoutContainer(target); + if (lastWidth != target.getWidth()) { + lastWidth = target.getWidth(); + if (target.getParent() instanceof JComponent) ((JComponent) target.getParent()).revalidate(); + } + } + @Override public Dimension preferredLayoutSize(Container target) { + int width = target.getWidth(); + if (width <= 0) return super.preferredLayoutSize(target); + Insets inset = target.getInsets(); int available = width - inset.left - inset.right - getHgap() * 2; + int rowWidth = 0, rowHeight = 0, height = getVgap() * 2, maxWidth = 0; + for (Component child : target.getComponents()) if (child.isVisible()) { + Dimension size = child.getPreferredSize(); + if (rowWidth > 0 && rowWidth + getHgap() + size.width > available) { height += rowHeight + getVgap(); maxWidth = Math.max(maxWidth, rowWidth); rowWidth = 0; rowHeight = 0; } + rowWidth += (rowWidth == 0 ? 0 : getHgap()) + size.width; rowHeight = Math.max(rowHeight, size.height); + } + return new Dimension(Math.max(maxWidth, rowWidth) + inset.left + inset.right + getHgap() * 2, height + rowHeight + inset.top + inset.bottom); + } + } + @Override public void close() { + if (!closed.compareAndSet(false, true)) return; cancelSignIn(); workers.shutdownNow(); + progressClock.stop(); if (launchChoices != null) launchChoices.close(); if (launchDialog != null) launchDialog.dispose(); + if (managedWorldPanel != null) managedWorldPanel.close(); + progress.setIndeterminate(false); progress.setVisible(false); + actions.onProgress(null); actions.onProgressValue(null); + try { downloadSubscription.close(); } catch (Exception ignored) { } + if (console != null) console.close(); if (signInDialog != null) signInDialog.dispose(); + } +} diff --git a/src/main/java/com/osiris/autoplug/client/ui/DashboardTheme.java b/src/main/java/com/osiris/autoplug/client/ui/DashboardTheme.java new file mode 100644 index 00000000..b90a83f1 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/ui/DashboardTheme.java @@ -0,0 +1,261 @@ +package com.osiris.autoplug.client.ui; + +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import javax.swing.plaf.ColorUIResource; +import java.awt.*; +import java.awt.geom.RoundRectangle2D; +import java.awt.geom.Path2D; + +/** Neutral dashboard surfaces with a consistent blue accent in light and dark themes. */ +final class DashboardTheme { + static final int ARC = 16; + private static final Color BLUE = new Color(0x0066FF); + + private DashboardTheme() {} + + static void installDefaults() { + UIManager.put("Button.arc", ARC); + UIManager.put("Component.arc", ARC); + UIManager.put("TextComponent.arc", ARC); + UIManager.put("ProgressBar.arc", ARC); + UIManager.put("Component.focusColor", BLUE); + UIManager.put("Component.focusedBorderColor", BLUE); + UIManager.put("Button.focusedBorderColor", BLUE); + UIManager.put("ToggleButton.selectedBackground", BLUE); + UIManager.put("ToggleButton.selectedForeground", Color.WHITE); + UIManager.put("ToggleButton.focusedBorderColor", BLUE); + UIManager.put("TabbedPane.underlineColor", BLUE); + UIManager.put("ProgressBar.foreground", BLUE); + UIManager.put("CheckBox.icon.selectedBackground", BLUE); + UIManager.put("CheckBox.icon.selectedBorderColor", BLUE); + UIManager.put("CheckBox.icon.checkmarkColor", Color.WHITE); + UIManager.put("Table.selectionBackground", BLUE); + UIManager.put("Table.selectionForeground", Color.WHITE); + UIManager.put("TableHeader.background", new ColorUIResource(dark() ? 0x2B3443 : 0xF3F6FB)); + UIManager.put("TableHeader.foreground", new ColorUIResource(dark() ? 0xE4EAF5 : 0x34435A)); + UIManager.put("Table.gridColor", new ColorUIResource(dark() ? 0x3A4555 : 0xE4EAF3)); + } + + static boolean dark() { + Color background = UIManager.getColor("Panel.background"); + return background != null && background.getRed() + background.getGreen() + background.getBlue() < 420; + } + + static Color muted() { return dark() ? new Color(0xBCC8DA) : new Color(0x526176); } + static Color accent() { return dark() ? new Color(0x8AB8FF) : BLUE; } + + /** Small original line icons inherit the control foreground in either theme. */ + static Icon icon(String action) { + return new Icon() { + public int getIconWidth() { return 18; } + public int getIconHeight() { return 18; } + public void paintIcon(Component component, Graphics graphics, int x, int y) { + Graphics2D g = (Graphics2D) graphics.create(); + try { + g.translate(x, y); g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g.setColor(component.isEnabled() ? component.getForeground() : muted()); + g.setStroke(new BasicStroke(1.6f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); + String key = action.toLowerCase(java.util.Locale.ROOT); + if (key.equals("players")) { + g.drawOval(3, 2, 5, 5); g.drawOval(10, 4, 4, 4); g.drawArc(1, 9, 10, 11, 0, 180); g.drawArc(9, 10, 8, 9, 0, 150); + } else if (key.equals("version")) { + g.drawRoundRect(2, 3, 14, 12, 2, 2); g.drawLine(2, 7, 16, 7); g.fillOval(4, 4, 1, 1); g.drawLine(5, 10, 8, 10); g.drawLine(5, 12, 12, 12); + } else if (key.equals("address")) { + g.drawRoundRect(2, 5, 9, 7, 4, 4); g.drawRoundRect(7, 8, 9, 7, 4, 4); + } else if (key.equals("sort")) { + g.drawLine(5, 3, 5, 15); g.drawLine(2, 6, 5, 3); g.drawLine(8, 6, 5, 3); g.drawLine(12, 3, 12, 15); g.drawLine(9, 12, 12, 15); g.drawLine(15, 12, 12, 15); + } else if (key.equals("online")) { + g.drawOval(2, 2, 14, 14); g.drawPolyline(new int[]{5, 8, 13}, new int[]{9, 12, 6}, 3); + } else if (key.equals("offline-server")) { + g.drawOval(2, 2, 14, 14); g.drawLine(5, 9, 13, 9); + } else if (key.contains("play") || key.contains("launch") || key.contains("join") || key.equals("start")) { + Path2D p = new Path2D.Float(); p.moveTo(5, 3); p.lineTo(15, 9); p.lineTo(5, 15); p.closePath(); g.fill(p); + } else if (key.contains("folder") || key.contains("configuration")) { + Path2D p = new Path2D.Float(); p.moveTo(2, 5); p.lineTo(7, 5); p.lineTo(9, 7); p.lineTo(16, 7); p.lineTo(15, 15); p.lineTo(2, 15); p.closePath(); g.draw(p); + } else if (key.contains("delete") || key.contains("remove")) { + g.drawLine(3, 5, 15, 5); g.drawLine(7, 2, 11, 2); g.drawRoundRect(5, 5, 8, 11, 2, 2); g.drawLine(8, 8, 8, 13); g.drawLine(10, 8, 10, 13); + } else if (key.contains("copy") || key.contains("clone")) { + g.drawRoundRect(6, 6, 9, 10, 2, 2); g.drawLine(3, 12, 3, 2); g.drawLine(3, 2, 11, 2); + } else if (key.contains("edit")) { + g.drawLine(4, 13, 13, 4); g.drawLine(7, 15, 15, 7); g.drawLine(13, 4, 15, 7); g.drawLine(4, 13, 3, 16); g.drawLine(3, 16, 7, 15); + } else if (key.contains("share")) { + g.drawLine(5, 9, 13, 4); g.drawLine(5, 9, 13, 14); g.drawOval(2, 7, 4, 4); g.drawOval(12, 1, 4, 4); g.drawOval(12, 12, 4, 4); + } else if (key.contains("refresh") || key.contains("reload") || key.contains("update") || key.contains("restart")) { + g.drawArc(3, 3, 12, 12, 40, 285); g.drawLine(15, 2, 15, 7); g.drawLine(11, 7, 15, 7); + } else if (key.contains("ping") || key.contains("activity") || key.contains("check")) { + g.drawPolyline(new int[]{1, 5, 7, 10, 12, 17}, new int[]{10, 10, 4, 15, 9, 9}, 6); + } else if (key.contains("import") || key.contains("add jar")) { + g.drawLine(9, 2, 9, 11); g.drawLine(5, 7, 9, 11); g.drawLine(13, 7, 9, 11); g.drawPolyline(new int[]{3, 3, 15, 15}, new int[]{12, 16, 16, 12}, 4); + } else if (key.contains("eula")) { + g.drawRoundRect(3, 2, 12, 14, 2, 2); g.drawLine(6, 6, 12, 6); g.drawLine(6, 9, 12, 9); g.drawLine(6, 12, 10, 12); + } else if (key.contains("settings") || key.contains("advanced")) { + for (int row = 4; row <= 14; row += 5) { g.drawLine(2, row, 16, row); g.fillOval(row == 9 ? 10 : 4, row - 2, 4, 4); } + } else if (key.contains("world")) { + g.drawOval(2, 2, 14, 14); g.drawOval(6, 2, 6, 14); g.drawLine(2, 9, 16, 9); + } else if (key.contains("server")) { + g.drawRoundRect(2, 2, 14, 6, 2, 2); g.drawRoundRect(2, 10, 14, 6, 2, 2); g.fillOval(4, 4, 2, 2); g.fillOval(4, 12, 2, 2); + } else if (key.contains("favorite") || key.contains("template")) { + Path2D p = new Path2D.Float(); for (int n = 0; n < 10; n++) { double angle = -Math.PI / 2 + n * Math.PI / 5, r = n % 2 == 0 ? 7 : 3.2; double px = 9 + Math.cos(angle) * r, py = 9 + Math.sin(angle) * r; if (n == 0) p.moveTo(px, py); else p.lineTo(px, py); } p.closePath(); g.draw(p); + } else if (key.contains("profile") || key.contains("sign in") || key.contains("offline")) { + g.drawOval(6, 2, 6, 6); g.drawArc(3, 10, 12, 11, 0, 180); + } else if (key.contains("save") || key.contains("back")) { + g.drawRoundRect(3, 2, 12, 14, 2, 2); g.drawRect(6, 3, 6, 4); g.drawRect(6, 11, 6, 5); + } else if (key.equals("stop")) g.fillRoundRect(4, 4, 10, 10, 2, 2); + else { g.drawLine(3, 9, 15, 9); g.drawLine(9, 3, 9, 15); } + } finally { g.dispose(); } + } + }; + } + + static JButton primary(JButton button) { + button.setBackground(BLUE); button.setForeground(Color.WHITE); + button.setFont(button.getFont().deriveFont(Font.BOLD)); + return button; + } + + static void navigation(JToggleButton button) { + button.putClientProperty("AutoPlug.navigation", Boolean.TRUE); + button.addItemListener(event -> refreshNavigation(button)); + refreshNavigation(button); + } + + private static void refreshNavigation(JToggleButton button) { + button.setBackground(button.isSelected() ? BLUE : (dark() ? new Color(0x273142) : new Color(0xF6F8FC))); + button.setForeground(button.isSelected() ? Color.WHITE : (dark() ? new Color(0xE4EAF5) : new Color(0x34435A))); + } + + static JLabel badge(String text) { + JLabel label = new JLabel(text) { + @Override protected void paintComponent(Graphics graphics) { + Graphics2D g = (Graphics2D) graphics.create(); + try { + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g.setColor(dark() ? new Color(36, 75, 128, 150) : new Color(0xEAF2FF)); + g.fillRoundRect(0, 0, getWidth(), getHeight(), ARC, ARC); + } finally { g.dispose(); } + super.paintComponent(graphics); + } + }; + label.setBorder(new EmptyBorder(2, 8, 2, 8)); tint(label, true); + label.setFont(label.getFont().deriveFont(Font.BOLD, 11f)); return label; + } + + static void tint(JComponent component, boolean accent) { + component.putClientProperty("AutoPlug.accent", accent); + component.setForeground(accent ? accent() : muted()); + } + + static void refreshColors(Component component) { + if (component instanceof JComponent) { + Object accent = ((JComponent) component).getClientProperty("AutoPlug.accent"); + if (accent instanceof Boolean) component.setForeground((Boolean) accent ? accent() : muted()); + if (component instanceof JToggleButton && Boolean.TRUE.equals(((JComponent) component).getClientProperty("AutoPlug.navigation"))) + refreshNavigation((JToggleButton) component); + } + if (component instanceof Container) for (Component child : ((Container) component).getComponents()) refreshColors(child); + } + + static JPanel transparent(LayoutManager layout) { + JPanel panel = new JPanel(layout); panel.setOpaque(false); return panel; + } + + /** Wrapped text may invalidate ancestors during measurement; avoid BoxLayout's mutable size caches. */ + static GridBagLayout verticalStack() { return new VerticalStackLayout(); } + + private static final class VerticalStackLayout extends GridBagLayout { + VerticalStackLayout() { + defaultConstraints.gridx = 0; + defaultConstraints.gridy = GridBagConstraints.RELATIVE; + defaultConstraints.weightx = 1; + defaultConstraints.fill = GridBagConstraints.HORIZONTAL; + defaultConstraints.anchor = GridBagConstraints.NORTHWEST; + } + } + + static JPanel surface(LayoutManager layout, int padding) { + JPanel panel = new Surface(layout); + panel.setBorder(new EmptyBorder(padding, padding, padding, padding)); + return panel; + } + + static JScrollPane scroll(Component view) { + JScrollPane scroll = new JScrollPane(view); + scroll.setOpaque(false); scroll.getViewport().setOpaque(false); scroll.setBorder(null); + scroll.getVerticalScrollBar().setUnitIncrement(16); + return scroll; + } + + static void canvas(Graphics graphics, int width, int height) { + if (width <= 0 || height <= 0) return; + Graphics2D g = (Graphics2D) graphics.create(); + try { + g.setColor(dark() ? new Color(0x181E29) : new Color(0xF5F7FB)); + g.fillRect(0, 0, width, height); + } finally { g.dispose(); } + } + + static JLabel worldThumbnail() { + JLabel label = new JLabel() { + @Override protected void paintComponent(Graphics graphics) { + Graphics2D g = (Graphics2D) graphics.create(); + try { + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g.clip(new RoundRectangle2D.Float(0, 0, getWidth(), getHeight(), ARC, ARC)); + g.setColor(dark() ? new Color(0x263750) : new Color(0xEEF4FF)); + g.fillRect(0, 0, getWidth(), getHeight()); + if (getIcon() == null) { + // A neutral world symbol while no saved thumbnail is available. + g.setColor(accent()); g.setStroke(new BasicStroke(1.7f)); + int x = getWidth() / 2 - 21, y = getHeight() / 2 - 21; + g.drawOval(x, y, 42, 42); g.drawOval(x + 11, y, 20, 42); + g.drawLine(x, y + 21, x + 42, y + 21); + g.drawArc(x + 3, y + 6, 36, 12, 180, 180); + g.drawArc(x + 3, y + 25, 36, 12, 0, 180); + } + super.paintComponent(g); + } finally { g.dispose(); } + } + }; + label.setHorizontalAlignment(SwingConstants.CENTER); + label.setPreferredSize(new Dimension(96, 96)); return label; + } + + /** An original fallback symbol, not a fetched server logo. */ + static Icon serverIcon() { + return new Icon() { + public int getIconWidth() { return 52; } + public int getIconHeight() { return 52; } + public void paintIcon(Component component, Graphics graphics, int x, int y) { + Graphics2D g = (Graphics2D) graphics.create(); + try { + g.translate(x, y); g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g.setColor(dark() ? new Color(0x263E61) : new Color(0xEAF2FF)); g.fillRoundRect(0, 0, 52, 52, ARC, ARC); + g.setColor(accent()); g.setStroke(new BasicStroke(1.8f)); + g.drawRoundRect(12, 12, 28, 12, 4, 4); g.drawRoundRect(12, 29, 28, 12, 4, 4); + g.fillOval(17, 17, 3, 3); g.fillOval(17, 34, 3, 3); g.drawLine(25, 18, 34, 18); g.drawLine(25, 35, 34, 35); + } finally { g.dispose(); } + } + }; + } + + private static final class Surface extends JPanel { + Surface(LayoutManager layout) { super(layout); setOpaque(false); } + @Override public Dimension getMaximumSize() { return new Dimension(Integer.MAX_VALUE, getPreferredSize().height); } + @Override protected void paintComponent(Graphics graphics) { + Graphics2D g = (Graphics2D) graphics.create(); + try { + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + Color top = dark() ? new Color(39, 49, 65, 242) : new Color(255, 255, 255, 245); + Color bottom = dark() ? new Color(32, 41, 56, 230) : new Color(250, 252, 255, 225); + g.setPaint(new GradientPaint(0, 0, top, 0, Math.max(1, getHeight()), bottom)); + g.fillRoundRect(0, 0, getWidth(), getHeight(), ARC, ARC); + boolean selected = Boolean.TRUE.equals(getClientProperty("AutoPlug.selected")); + g.setColor(selected ? accent() : dark() ? new Color(149, 171, 204, 55) : new Color(209, 219, 234, 190)); + g.setStroke(new BasicStroke(selected ? 2f : 1f)); + g.draw(new RoundRectangle2D.Float(1f, 1f, getWidth() - 2f, getHeight() - 2f, ARC, ARC)); + } finally { g.dispose(); } + super.paintComponent(graphics); + } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/ui/LaunchChoicePanel.java b/src/main/java/com/osiris/autoplug/client/ui/LaunchChoicePanel.java new file mode 100644 index 00000000..1dcd00b8 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/ui/LaunchChoicePanel.java @@ -0,0 +1,69 @@ +package com.osiris.autoplug.client.ui; + +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import java.awt.*; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.LongSupplier; +import com.osiris.autoplug.client.ui.LauncherActions.ProfileInfo; + +/** One large target per choice, with an explicit, cancelable remembered-choice countdown. */ +final class LaunchChoicePanel extends JPanel implements AutoCloseable { + private final Timer timer; + private final LongSupplier clock; + private final Consumer choose; + private final ProfileInfo preferred; + private final JButton wait; + private final long deadline; + private boolean waiting, finished; + + LaunchChoicePanel(List profiles, ProfileInfo preferred, boolean autoLaunch, + Consumer choose) { + this(profiles, preferred, autoLaunch, choose, System::nanoTime); + } + LaunchChoicePanel(List profiles, ProfileInfo preferred, boolean autoLaunch, + Consumer choose, LongSupplier clock) { + super(new BorderLayout(0, 12)); this.clock = clock; this.choose = choose; this.preferred = preferred; + setBorder(new EmptyBorder(16, 16, 16, 16)); + deadline = clock.getAsLong() + 2_000_000_000L; + waiting = autoLaunch && preferred != null && preferred.launchable && !preferred.template; + JLabel heading = new JLabel(waiting ? "Launching with " + preferred.name : "Choose your client profile"); + heading.putClientProperty("html.disable", Boolean.TRUE); add(heading, BorderLayout.NORTH); + JPanel choices = new Choices(); + for (ProfileInfo profile : profiles) { + JButton button = new JButton(); button.putClientProperty("html.disable", Boolean.TRUE); button.setText(profile.toString()); + button.setName("launch-choice-" + profile.id); button.setHorizontalAlignment(SwingConstants.LEFT); + button.setPreferredSize(new Dimension(470, 54)); button.setEnabled(profile.launchable && !profile.template); + button.setToolTipText(profile.migrationSummary); + button.addActionListener(e -> select(profile)); choices.add(button); + } + JScrollPane scroll = new JScrollPane(choices); scroll.setBorder(BorderFactory.createEmptyBorder()); + scroll.setPreferredSize(new Dimension(500, Math.min(340, Math.max(62, profiles.size() * 62)))); add(scroll); + wait = new JButton(); wait.setName("abort-auto-launch"); wait.setPreferredSize(new Dimension(500, 52)); + wait.setBackground(new Color(177, 40, 49)); wait.setForeground(Color.WHITE); wait.setOpaque(true); + wait.addActionListener(e -> { cancelCountdown(); heading.setText("Choose your client profile"); }); + add(wait, BorderLayout.SOUTH); + timer = new Timer(100, e -> tick()); + tick(); if (waiting) timer.start(); + } + void tick() { + if (!waiting || finished) { wait.setVisible(false); return; } + long remaining = deadline - clock.getAsLong(); + if (remaining <= 0) { select(preferred); return; } + wait.setText("Wait, let me select something else… (" + Math.max(1, (remaining + 999_999_999L) / 1_000_000_000L) + "s)"); + } + void cancelCountdown() { waiting = false; timer.stop(); wait.setVisible(false); revalidate(); } + private void select(ProfileInfo profile) { + if (finished) return; finished = true; waiting = false; if (timer != null) timer.stop(); choose.accept(profile); + } + @Override public void close() { finished = true; waiting = false; timer.stop(); } + private static final class Choices extends JPanel implements Scrollable { + Choices() { super(new GridLayout(0, 1, 0, 8)); } + public Dimension getPreferredScrollableViewportSize() { return getPreferredSize(); } + public int getScrollableUnitIncrement(Rectangle r, int o, int d) { return 24; } + public int getScrollableBlockIncrement(Rectangle r, int o, int d) { return Math.max(24, r.height - 24); } + public boolean getScrollableTracksViewportWidth() { return true; } + public boolean getScrollableTracksViewportHeight() { return false; } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/ui/LauncherActions.java b/src/main/java/com/osiris/autoplug/client/ui/LauncherActions.java new file mode 100644 index 00000000..ddb4ff77 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/ui/LauncherActions.java @@ -0,0 +1,110 @@ +package com.osiris.autoplug.client.ui; + +import java.util.Collections; +import java.util.List; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Consumer; + +/** Application services used by the dashboard. Calls run outside Swing's event thread. */ +public interface LauncherActions { + /** Replaces the dashboard's activity listener; null releases it. Callbacks may arrive on any thread. */ + default void onProgress(Consumer listener) {} + /** Optional measured current-step progress (0–100); null or a negative value means indeterminate. */ + default void onProgressValue(Consumer listener) {} + /** Ensures compatible ready vanilla client/server defaults without replacing a valid user selection. */ + default ProfileInfo ensureDefaultProfiles(String gameVersion) throws Exception { throw unavailable(); } + default List profiles() throws Exception { return Collections.emptyList(); } + default ProfileInfo createProfile(String name, String version, String loader, String type, boolean template) throws Exception { throw unavailable(); } + default ProfileInfo cloneProfile(String sourceId, String name, String targetVersion, String loader) throws Exception { throw unavailable(); } + default void deleteProfile(String id) throws Exception { throw unavailable(); } + default String checkProfile(String id) throws Exception { throw unavailable(); } + default String updateProfile(String id) throws Exception { throw unavailable(); } + default void setTemplate(String id, boolean template) throws Exception { throw unavailable(); } + default void addArtifact(String profileId, String jarPath, String modrinthId) throws Exception { throw unavailable(); } + default List worlds() throws Exception { return Collections.emptyList(); } + default WorldInfo createWorld(String name, String serverProfileId, String clientProfileId) throws Exception { throw unavailable(); } + default void setWorldEulaAccepted(String id, boolean accepted) throws Exception { throw unavailable(); } + default void launchWorld(String id, boolean share) throws Exception { throw unavailable(); } + default void launchWorld(String id, boolean share, String profileId) throws Exception { launchWorld(id, share); } + /** Starts an existing singleplayer save and returns the backend's launch status. */ + default String launchLocalWorld(String id, String version) throws Exception { throw unavailable(); } + default String launchLocalWorld(String id, String version, String profileId) throws Exception { return launchLocalWorld(id, version); } + default String preferredProfile(String target) throws Exception { return ""; } + default void rememberProfile(String target, String profileId) throws Exception { } + default String shareWorld(String id) throws Exception { throw unavailable(); } + default String serverLog(String id) throws Exception { return "Start this managed world to view its AutoPlug console."; } + default void serverCommand(String id, String command) throws Exception { throw unavailable(); } + default void stopWorld(String id) throws Exception { throw unavailable(); } + default void restartWorld(String id) throws Exception { throw unavailable(); } + default void launchProfile(String profileId, String host, int port) throws Exception { throw unavailable(); } + default SettingsInfo settings() throws Exception { return new SettingsInfo(); } + default void saveSettings(SettingsInfo settings) throws Exception { throw unavailable(); } + /** Deliver device sign-in instructions before waiting for the account provider. Never log tokens. */ + default void signInMicrosoft(Consumer instructions) throws Exception { throw unavailable(); } + /** Reserve an account change on the calling UI thread, before its worker is queued. */ + default long beginAccountChange() { return 0; } + default void cancelMicrosoftSignIn() {} + default void signInMicrosoft(long attempt, SettingsInfo settings, Consumer instructions) throws Exception { + if (settings != null) saveSettings(settings); + signInMicrosoft(instructions); + } + default void useOfflineAccount(long attempt, String name) throws Exception { useOfflineAccount(name); } + default void useOfflineAccount(String name) throws Exception { throw unavailable(); } + + static UnsupportedOperationException unavailable() { + return new UnsupportedOperationException("The launcher service is not available yet."); + } + + final class ProfileInfo { + public final String id, name, gameVersion, loader, type, directory, migrationSummary; + public final boolean template, launchable; + public ProfileInfo(String id, String name, String gameVersion, String loader, String type, String directory, boolean template) { + this(id, name, gameVersion, loader, type, directory, template, "", true); + } + public ProfileInfo(String id, String name, String gameVersion, String loader, String type, String directory, boolean template, String migrationSummary, boolean launchable) { + this.id = id; this.name = name; this.gameVersion = gameVersion; this.loader = loader; + this.type = type; this.directory = directory; this.template = template; + this.migrationSummary = migrationSummary; this.launchable = launchable; + } + @Override public String toString() { return name + " · " + gameVersion + " / " + loader; } + } + + final class WorldInfo { + public final String id, name, serverProfileId, clientProfileId, directory, thumbnail, gameVersion; + public final boolean running, local; + public final boolean modded, sizeComplete, cheats, hardcore, quickPlayEligible, metadataAvailable; + public final long lastPlayed, sizeBytes; + public final List dataPacks; + public WorldInfo(String id, String name, String serverProfileId, String clientProfileId, String directory, String thumbnail, boolean running) { + this(id, name, serverProfileId, clientProfileId, directory, thumbnail, running, false, ""); + } + public WorldInfo(String id, String name, String serverProfileId, String clientProfileId, String directory, String thumbnail, boolean running, boolean local, String gameVersion) { + this(id, name, serverProfileId, clientProfileId, directory, thumbnail, running, local, gameVersion, + false, -1, -1, false, Collections.emptyList(), false, false, false, false); + } + public WorldInfo(String id, String name, String serverProfileId, String clientProfileId, String directory, String thumbnail, boolean running, boolean local, String gameVersion, + boolean modded, long lastPlayed, long sizeBytes, boolean sizeComplete, List dataPacks, boolean cheats, boolean hardcore, boolean quickPlayEligible) { + this(id, name, serverProfileId, clientProfileId, directory, thumbnail, running, local, gameVersion, + modded, lastPlayed, sizeBytes, sizeComplete, dataPacks, cheats, hardcore, quickPlayEligible, true); + } + private WorldInfo(String id, String name, String serverProfileId, String clientProfileId, String directory, String thumbnail, boolean running, boolean local, String gameVersion, + boolean modded, long lastPlayed, long sizeBytes, boolean sizeComplete, List dataPacks, boolean cheats, boolean hardcore, boolean quickPlayEligible, boolean metadataAvailable) { + this.id = id; this.name = name; this.serverProfileId = serverProfileId; this.clientProfileId = clientProfileId; this.directory = directory; + this.thumbnail = thumbnail; this.running = running; this.local = local; this.gameVersion = gameVersion == null ? "" : gameVersion; + this.modded = modded; this.lastPlayed = lastPlayed; this.sizeBytes = sizeBytes; this.sizeComplete = sizeComplete; + this.dataPacks = Collections.unmodifiableList(new java.util.ArrayList<>(dataPacks == null ? Collections.emptyList() : dataPacks)); + this.cheats = cheats; this.hardcore = hardcore; this.quickPlayEligible = quickPlayEligible; this.metadataAvailable = metadataAvailable; + } + @Override public String toString() { return name; } + } + + final class SettingsInfo { + public String java8 = "", java17 = "", java21 = "", defaultProfile = "", account = "Offline", microsoftClientId = ""; + public Map javaPaths = new LinkedHashMap<>(); + public int port = 25565; + public boolean upnp = true; + public boolean rememberAccount = false; + public boolean fullscreen = true; + } +} 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..bdd640d9 100644 --- a/src/main/java/com/osiris/autoplug/client/ui/MainWindow.java +++ b/src/main/java/com/osiris/autoplug/client/ui/MainWindow.java @@ -1,175 +1,102 @@ -/* - * Copyright (c) 2022-2023 Osiris-Team. - * All rights reserved. - * - * This software is copyrighted work, licensed under the terms - * of the MIT-License. Consult the "LICENSE" file for details. - */ - +/* Copyright (c) 2022-2026 Osiris-Team. Licensed under the MIT License. */ package com.osiris.autoplug.client.ui; import com.formdev.flatlaf.FlatDarculaLaf; import com.formdev.flatlaf.FlatDarkLaf; import com.formdev.flatlaf.FlatLightLaf; +import com.osiris.autoplug.client.browser.ServerBrowserService; import com.osiris.autoplug.client.configs.GeneralConfig; -import com.osiris.autoplug.client.ui.utils.MyMouseListener; import com.osiris.autoplug.client.utils.GD; -import com.osiris.betterlayout.BLayout; import com.osiris.betterlayout.utils.UIDebugWindow; import com.osiris.jlib.logger.AL; - +import javax.imageio.ImageIO; import javax.swing.*; import java.awt.*; +import java.awt.event.ActionEvent; import java.awt.event.KeyEvent; -import java.awt.event.KeyListener; -import java.awt.geom.RoundRectangle2D; +import java.awt.image.BufferedImage; import java.io.File; import java.io.InputStream; -import java.nio.file.Files; +import java.util.Objects; +/** One dashboard shared by the tray and the desktop window. Closing the window hides it. */ public class MainWindow extends JFrame { - /** - * There should always be only one instance of {@link MainWindow}. - */ - public static MainWindow GET = null; + public static volatile MainWindow GET; public static final Object lockUI = new Object(); + private static volatile LauncherActions launcherActions = new LauncherActions() {}; + private static volatile ServerBrowserService browserService = ServerBrowserService.defaults(); public TrayIcon trayIcon; + private DashboardPanel dashboard; - public MainWindow(GeneralConfig generalConfig) throws Exception { - synchronized (lockUI){ - if (GET != null) { - GET.close(); // There should only be one, thus stop old - } - GET = this; - } - initTheme(generalConfig); - start(); - this.addKeyListener(new KeyListener() { - @Override - public void keyTyped(KeyEvent e) { - } - - @Override - public void keyPressed(KeyEvent e) { - if (e.getKeyCode() == KeyEvent.VK_F12) - new UIDebugWindow(GET); - } - - @Override - public void keyReleased(KeyEvent e) { - } - }); + public static void setLauncherActions(LauncherActions actions) { + launcherActions = Objects.requireNonNull(actions); + SwingUtilities.invokeLater(() -> { if (GET != null) GET.installDashboard(); }); } + public static void setBrowserService(ServerBrowserService browser) { browserService = Objects.requireNonNull(browser); } - public void initTheme() { - initTheme(null); + public MainWindow(GeneralConfig generalConfig) throws Exception { + Runnable initialize = () -> { + synchronized (lockUI) { if (GET != null) GET.close(); GET = this; } + initTheme(generalConfig); + try { start(); } catch (Exception e) { AL.warn("Could not initialize the dashboard", e); } + }; + if (SwingUtilities.isEventDispatchThread()) initialize.run(); else SwingUtilities.invokeAndWait(initialize); } + public void initTheme() { initTheme(null); } public void initTheme(GeneralConfig generalConfig) { + if (!SwingUtilities.isEventDispatchThread()) { + final GeneralConfig configuration = generalConfig; + SwingUtilities.invokeLater(() -> initTheme(configuration)); return; + } try { if (generalConfig == null) generalConfig = new GeneralConfig(); - if (generalConfig.autoplug_system_tray_theme.asString().equals("light")) { - if (!FlatLightLaf.setup()) throw new Exception("Returned false!"); - } else if (generalConfig.autoplug_system_tray_theme.asString().equals("dark")) { - if (!FlatDarkLaf.setup()) throw new Exception("Returned false!"); - } else if (generalConfig.autoplug_system_tray_theme.asString().equals("darcula")) { - if (!FlatDarculaLaf.setup()) throw new Exception("Returned false!"); - } else { - AL.warn("The selected theme '" + generalConfig.autoplug_system_tray_theme.asString() + "' is not a valid option! Using default."); - if (!FlatLightLaf.setup()) throw new Exception("Returned false!"); - } - } catch (Exception e) { - AL.warn("Failed to init GUI theme!", e); - } + String theme = generalConfig.autoplug_system_tray_theme.asString(); + if ("dark".equals(theme)) FlatDarkLaf.setup(); + else if ("darcula".equals(theme)) FlatDarculaLaf.setup(); + else FlatLightLaf.setup(); + DashboardTheme.installDefaults(); + if (isDisplayable()) SwingUtilities.invokeLater(() -> SwingUtilities.updateComponentTreeUI(this)); + } catch (Exception e) { AL.warn("Failed to initialize the dashboard theme", e); } } public void close() { - if (SystemTray.isSupported()) - SystemTray.getSystemTray().remove(trayIcon); - this.dispose(); - GET = null; + if (!SwingUtilities.isEventDispatchThread()) { SwingUtilities.invokeLater(this::close); return; } + if (dashboard != null) dashboard.close(); + if (trayIcon != null && SystemTray.isSupported()) SystemTray.getSystemTray().remove(trayIcon); + trayIcon = null; dispose(); if (GET == this) GET = null; } public void start() throws Exception { + setDefaultCloseOperation(JFrame.HIDE_ON_CLOSE); setName("AutoPlug-Dashboard"); setTitle("AutoPlug — Minecraft Dashboard"); + setMinimumSize(new Dimension(950, 620)); + installDashboard(); pack(); setLocationRelativeTo(null); + getRootPane().getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke(KeyEvent.VK_F12, 0), "ui-debug"); + getRootPane().getActionMap().put("ui-debug", new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { new UIDebugWindow(MainWindow.this); } }); + Image image = loadIcon(); if (image != null) setIconImage(image); if (SystemTray.isSupported()) { - SystemTray tray = SystemTray.getSystemTray(); - File icon = new File(GD.WORKING_DIR + "/autoplug/system/icon.png"); - if (!icon.exists()) { - icon.getParentFile().mkdirs(); - icon.createNewFile(); - InputStream link = (getClass().getResourceAsStream("/autoplug-icon.png")); - Files.copy(link, icon.toPath()); - } - Image image = Toolkit.getDefaultToolkit().getImage(icon.getAbsolutePath()); - trayIcon = new TrayIcon(image, "AutoPlug", null); - trayIcon.addMouseListener(new MyMouseListener().onClick(event -> { - this.setVisible(true); - })); - trayIcon.setImageAutoSize(true); - try { - tray.add(trayIcon); - } catch (AWTException e) { - AL.warn("Failed to create system tray GUI: Exception occurred.", e); - } - - TrayIcon finalTrayIcon = trayIcon; - /* // TODO causes dead lock and the jvm doesnt close: - // TODO find alternative to remove the icon. - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - try { - AL.info("removing"); - tray.remove(finalTrayIcon); - AL.info("aaa"); - } catch (Exception e) { - e.printStackTrace(); - } - })); - - */ - - this.setIconImage(image); - initUI(); - } else throw new Exception("Failed to create system tray GUI: Not supported on your system."); + if (image == null) image = new BufferedImage(24, 24, BufferedImage.TYPE_INT_ARGB); + PopupMenu menu = new PopupMenu(); MenuItem open = new MenuItem("Open AutoPlug Dashboard"); + open.addActionListener(e -> openDashboard()); menu.add(open); + trayIcon = new TrayIcon(image, "AutoPlug", menu); trayIcon.setImageAutoSize(true); + trayIcon.addActionListener(e -> openDashboard()); + trayIcon.addMouseListener(new java.awt.event.MouseAdapter() { @Override public void mouseClicked(java.awt.event.MouseEvent e) { if (e.getButton() == java.awt.event.MouseEvent.BUTTON1) openDashboard(); } }); + try { SystemTray.getSystemTray().add(trayIcon); } + catch (AWTException e) { trayIcon = null; AL.warn("Tray unavailable; opening the desktop dashboard", e); } + } + setVisible(trayIcon == null); } - 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); - 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)); - this.setLocation((screenWidth / 2) - (width / 2), (screenHeight / 2) - (height / 2)); // Position frame in mid of screen - this.setSize(width, height); - this.setVisible(false); - - BLayout thisLy = new BLayout(this); - this.setContentPane(thisLy); - thisLy.access(() -> { - // Add stuff to main window - JLabel titleAutoPlug = new JLabel(), titleTray = new JLabel(); - titleAutoPlug.setText("AutoPlug"); - titleAutoPlug.putClientProperty("FlatLaf.style", "font: 200% $semibold.font"); - thisLy.addH(titleAutoPlug); - - titleTray.setText(" | Tray"); - titleTray.putClientProperty("FlatLaf.style", "font: 200% $light.font"); - thisLy.addH(titleTray).delPadding().paddingTop(); - - JTabbedPane mainTab = new JTabbedPane(); - thisLy.addV(mainTab).height(80).widthFull(); - mainTab.setTabLayoutPolicy(JTabbedPane.SCROLL_TAB_LAYOUT); - - // Tab panels/layouts - try { - mainTab.addTab("Server", new ServerPanel(mainTab)); - mainTab.addTab("Client", new ClientPanel(mainTab)); - //tabbedPane.addChangeListener(e -> selectedTabChanged()); - } catch (Exception e) { - AL.warn(e); - } - }); + private void installDashboard() { + if (dashboard != null) dashboard.close(); + dashboard = new DashboardPanel(launcherActions, browserService); setContentPane(dashboard); revalidate(); repaint(); + } + private void openDashboard() { SwingUtilities.invokeLater(() -> { setVisible(true); setState(Frame.NORMAL); toFront(); requestFocus(); }); } + private Image loadIcon() { + File custom = new File(GD.WORKING_DIR, "autoplug/system/icon.png"); + try { if (custom.isFile() && custom.length() > 0) { Image image = ImageIO.read(custom); if (image != null) return image; } } + catch (Exception e) { AL.warn("Could not read the custom tray icon", e); } + try (InputStream stream = getClass().getResourceAsStream("/autoplug-icon.png")) { return stream == null ? null : ImageIO.read(stream); } + catch (Exception e) { AL.warn("Could not read the dashboard icon", e); return null; } } } diff --git a/src/main/java/com/osiris/autoplug/client/ui/ManagedWorldPanel.java b/src/main/java/com/osiris/autoplug/client/ui/ManagedWorldPanel.java new file mode 100644 index 00000000..f31f747e --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/ui/ManagedWorldPanel.java @@ -0,0 +1,101 @@ +package com.osiris.autoplug.client.ui; + +import com.osiris.autoplug.client.ui.LauncherActions.WorldInfo; +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import java.awt.*; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +/** Console and lifecycle controls always address the selected world's isolated AutoPlug instance. */ +final class ManagedWorldPanel extends JPanel implements AutoCloseable { + private final LauncherActions actions; + private final Consumer start; + private final Consumer open; + private final JComboBox worlds = new JComboBox<>(); + private final JTextArea output = new JTextArea(); + private final JTextField command = new JTextField(); + private final JLabel state = new JLabel("Choose a managed world"); + private final ExecutorService worker = Executors.newSingleThreadExecutor(r -> { Thread t = new Thread(r, "AutoPlug-world-console"); t.setDaemon(true); return t; }); + private final AtomicBoolean reading = new AtomicBoolean(), closed = new AtomicBoolean(); + private final Timer timer = new Timer(1800, e -> { if (isShowing()) refreshLog(); }); + private String wanted; + private boolean updatingChoices; + ManagedWorldPanel(LauncherActions actions, Consumer start, Consumer open) { + super(new BorderLayout(0, 10)); this.actions = actions; this.start = start; this.open = open; + setName("managed-world-console"); setBorder(new EmptyBorder(12, 4, 4, 4)); setOpaque(false); + worlds.setName("managed-world-selector"); worlds.setRenderer(new DefaultListCellRenderer() { + @Override public Component getListCellRendererComponent(JList l, Object value, int i, boolean selected, boolean focus) { + JLabel label = (JLabel) super.getListCellRendererComponent(l, "", i, selected, focus); + label.putClientProperty("html.disable", Boolean.TRUE); + if (value instanceof WorldInfo) { WorldInfo w = (WorldInfo) value; label.setText(w.name + (w.running ? " · Running" : " · Stopped")); } + return label; + } + }); + JPanel top = new JPanel(new BorderLayout(8, 8)); top.setOpaque(false); top.add(worlds); top.add(button("Refresh", this::refresh), BorderLayout.EAST); + JPanel controls = new JPanel(new GridLayout(2, 4, 6, 6)); controls.setOpaque(false); + controls.add(button("Play / start", () -> { WorldInfo w = selected(); if (w != null) start.accept(w); })); + controls.add(button("Stop", () -> action(w -> actions.stopWorld(w.id)))); + controls.add(button("Restart", () -> action(w -> actions.restartWorld(w.id)))); + controls.add(button("Back up", () -> send(".backup"))); + controls.add(button("AutoPlug help", () -> send(".help"))); + controls.add(button("Server status", () -> send("list"))); + controls.add(button("Configuration", () -> { WorldInfo w = selected(); if (w != null) open.accept(new java.io.File(w.directory, "autoplug").toString()); })); + controls.add(button("World folder", () -> { WorldInfo w = selected(); if (w != null) open.accept(w.directory); })); + top.add(controls, BorderLayout.SOUTH); add(top, BorderLayout.NORTH); + output.setName("managed-world-output"); output.setEditable(false); output.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); + output.setText("Each world has its own AutoPlug copy, console, configuration and server process.\nChoose a world and Play / start.\nServer and mod versions are managed through Profiles."); + add(new JScrollPane(output)); + JPanel bottom = new JPanel(new BorderLayout(8, 6)); bottom.setOpaque(false); + command.setName("managed-world-command"); command.putClientProperty("JTextField.placeholderText", "Server command or .help for AutoPlug commands"); + command.addActionListener(e -> submit()); bottom.add(command); bottom.add(button("Send", this::submit), BorderLayout.EAST); + state.putClientProperty("html.disable", Boolean.TRUE); bottom.add(state, BorderLayout.SOUTH); add(bottom, BorderLayout.SOUTH); + worlds.addActionListener(e -> { if (!updatingChoices) { output.setText(""); refreshLog(); } }); timer.start(); + } + private JButton button(String text, Runnable action) { JButton b = new JButton(text); b.addActionListener(e -> action.run()); return b; } + private WorldInfo selected() { return (WorldInfo) worlds.getSelectedItem(); } + void select(String id) { wanted = id; refresh(); } + void refresh() { + submitWork(() -> { List result = new ArrayList<>(); for (WorldInfo w : actions.worlds()) if (!w.local) result.add(w); return result; }, result -> { + String previous = wanted != null ? wanted : selected() == null ? null : selected().id; + updatingChoices = true; + try { worlds.removeAllItems(); for (WorldInfo w : result) { worlds.addItem(w); if (w.id.equals(previous)) worlds.setSelectedItem(w); } } + finally { updatingChoices = false; } + wanted = null; if (result.isEmpty()) state.setText("Create a managed world in Worlds to get started."); refreshLog(); + }); + } + private void submit() { String value = command.getText().trim(); if (value.isEmpty()) return; send(value); command.setText(""); } + private void send(String text) { action(w -> actions.serverCommand(w.id, text)); } + private interface Operation { void run(WorldInfo world) throws Exception; } + private void action(Operation operation) { + WorldInfo world = selected(); if (world == null) return; + state.setText("Working on " + world.name + "…"); + submitWork(() -> { operation.run(world); return world.id; }, id -> { if (selected() != null && selected().id.equals(id)) state.setText("Command completed"); refresh(); }, + e -> { if (selected() != null && selected().id.equals(world.id)) state.setText("Could not complete action: " + e.getMessage()); }); + } + void refreshLog() { + WorldInfo world = selected(); if (world == null || !reading.compareAndSet(false, true)) return; + try { worker.submit(() -> { + String text; + try { text = actions.serverLog(world.id); } catch (Exception e) { text = "Console unavailable: " + e.getMessage(); } + String log = text; SwingUtilities.invokeLater(() -> { reading.set(false); if (closed.get() || selected() == null) return; + if (!selected().id.equals(world.id)) { refreshLog(); return; } + if (!output.getText().equals(log)) { boolean follow = output.getCaretPosition() >= Math.max(0, output.getDocument().getLength() - 2); output.setText(log); if (follow) output.setCaretPosition(output.getDocument().getLength()); } + state.setText(world.name + (world.running ? " · Running" : " · Stopped") + " · latest 64 KiB of console output"); + }); + }); } catch (RejectedExecutionException e) { reading.set(false); } + } + private void submitWork(Callable task, Consumer success) { + submitWork(task, success, e -> state.setText("Could not complete action: " + e.getMessage())); + } + private void submitWork(Callable task, Consumer success, Consumer failure) { + if (closed.get()) return; + try { worker.submit(() -> { try { T result = task.call(); SwingUtilities.invokeLater(() -> { if (!closed.get()) success.accept(result); }); } + catch (Exception e) { SwingUtilities.invokeLater(() -> { if (!closed.get()) failure.accept(e); }); } }); } + catch (RejectedExecutionException ignored) { } + } + @Override public void close() { closed.set(true); timer.stop(); worker.shutdownNow(); } +} diff --git a/src/main/java/com/osiris/autoplug/client/ui/OverallProgress.java b/src/main/java/com/osiris/autoplug/client/ui/OverallProgress.java new file mode 100644 index 00000000..2d20a721 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/ui/OverallProgress.java @@ -0,0 +1,10 @@ +package com.osiris.autoplug.client.ui; + +/** A monotonic estimate for work whose complete byte/work count is not known in advance. */ +final class OverallProgress { + private double value; + void start() { value = 2; } + int tick() { if (value < 100) value = Math.min(95, value + (95 - value) * 0.008); return value(); } + int value() { return (int) value; } + int complete() { value = 100; return 100; } +} diff --git a/src/main/java/com/osiris/autoplug/client/ui/ServerConsolePanel.java b/src/main/java/com/osiris/autoplug/client/ui/ServerConsolePanel.java index f75ba633..ef034811 100644 --- a/src/main/java/com/osiris/autoplug/client/ui/ServerConsolePanel.java +++ b/src/main/java/com/osiris/autoplug/client/ui/ServerConsolePanel.java @@ -1,112 +1,78 @@ -/* - * Copyright (c) 2022-2024 Osiris-Team. - * All rights reserved. - * - * This software is copyrighted work, licensed under the terms - * of the MIT-License. Consult the "LICENSE" file for details. - */ - +/* Copyright (c) 2022-2026 Osiris-Team. Licensed under the MIT License. */ package com.osiris.autoplug.client.ui; +import com.osiris.autoplug.client.Server; import com.osiris.autoplug.client.console.Commands; import com.osiris.autoplug.client.ui.utils.HintTextField; -import com.osiris.autoplug.client.ui.utils.MyMouseListener; -import com.osiris.betterlayout.BLayout; +import com.osiris.autoplug.client.utils.io.AsyncInputStream; +import com.osiris.jlib.events.MessageEvent; import com.osiris.jlib.logger.AL; +import com.osiris.jlib.logger.Message; import com.osiris.jlib.logger.MessageFormatter; - import javax.swing.*; +import javax.swing.border.EmptyBorder; import java.awt.*; -import java.awt.event.KeyEvent; -import java.awt.event.KeyListener; -import java.util.ArrayList; - -public class ServerConsolePanel extends BLayout { - - public JLabel labelConsole = new JLabel("Console"); - public BLayout txtConsole; - public HintTextField txtSendCommand = new HintTextField("Send command..."); - private final JButton execute; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +/** Bounded console for AutoPlug task messages and the current dedicated server output. */ +public class ServerConsolePanel extends JPanel implements AutoCloseable { + public final JLabel labelConsole = new JLabel("Console"); + public final JTextArea txtConsole = new JTextArea(); + public final HintTextField txtSendCommand = new HintTextField("Send command…"); + private final ConcurrentLinkedQueue pending = new ConcurrentLinkedQueue<>(); + private final AtomicInteger queued = new AtomicInteger(); + private final MessageEvent logListener = message -> append(MessageFormatter.formatForFile(message)); + private final Consumer serverListener = this::append; + private final ExecutorService commandExecutor = Executors.newSingleThreadExecutor(r -> { Thread t = new Thread(r, "AutoPlug-ConsoleCommands"); t.setDaemon(true); return t; }); + private final Timer timer; + private AsyncInputStream observed; + private volatile boolean closed; public ServerConsolePanel(Container parent) { - super(parent); - - execute = new JButton("Execute"); - BLayout jPanel = new BLayout(this, 100, 30); -// jPanel.setLayout(new FlowLayout()); - jPanel.addH(txtSendCommand); - jPanel.addH(execute); - - //TODO this.addV(getBtnMinecraftLaunch()); - - txtConsole = new BLayout(this, 100, 80); - txtConsole.defaultCompStyles.delPadding(); - txtConsole.makeScrollable(); - //TODO txtConsole.getScrollPane().getVerticalScrollBar().setUnitIncrement(16); - - this.addV(txtSendCommand); - this.addV(jPanel); - - AL.actionsOnMessageEvent.add(msg -> { - txtConsole.access(() -> { - for (JLabel jLabel : toLabel(MessageFormatter.formatForFile(msg))) { - txtConsole.addV(jLabel); - } - //txtConsole.setText(txtConsole.getText() + + "\n"); - }); - txtConsole.scrollToEndV(); - }); - txtSendCommand.addKeyListener(new KeyListener() { - @Override - public void keyTyped(KeyEvent e) { - if (e.getKeyChar() == KeyEvent.VK_ENTER) { - txtConsole.access(() -> { - txtConsole.addV(new JLabel(txtSendCommand.getText())); - }); - txtConsole.scrollToEndV(); - AL.info("Received System-Tray command: '" + txtSendCommand.getText() + "'"); - Commands.execute(txtSendCommand.getText()); - txtSendCommand.setText(""); - } - } - - @Override - public void keyPressed(KeyEvent e) { - - } - - @Override - public void keyReleased(KeyEvent e) { - - } - }); - - execute.addActionListener(e -> { - txtConsole.access(() -> { - txtConsole.addV(new JLabel(txtSendCommand.getText())); - }); - txtConsole.scrollToEndV(); - AL.info("Received System-Tray command: '" + txtSendCommand.getText() + "'"); - Commands.execute(txtSendCommand.getText()); - txtSendCommand.setText(""); - }); + super(new BorderLayout(0, 8)); setBorder(new EmptyBorder(12, 12, 12, 12)); + txtConsole.setEditable(false); txtConsole.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); + add(new JScrollPane(txtConsole), BorderLayout.CENTER); + JPanel command = new JPanel(new BorderLayout(8, 0)); command.add(txtSendCommand, BorderLayout.CENTER); + JButton execute = new JButton("Send"); execute.addActionListener(e -> submit()); txtSendCommand.addActionListener(e -> submit()); + command.add(execute, BorderLayout.EAST); add(command, BorderLayout.SOUTH); + AL.actionsOnMessageEvent.add(logListener); + timer = new Timer(150, e -> drain()); timer.start(); } - - private JButton getBtnMinecraftLaunch() { - JButton btn = new JButton("Launch Minecraft"); - btn.addMouseListener(new MyMouseListener().onClick(event -> { - - })); - return btn; + public static void executeCommand(String command) throws Exception { + if (command == null || command.trim().isEmpty()) return; + if (!Commands.execute(command)) Server.submitCommand(command); } - - 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)); + private void submit() { + String command = txtSendCommand.getText(); if (command.trim().isEmpty() || closed) return; + txtSendCommand.setText(""); append("> " + command); + commandExecutor.submit(() -> { try { executeCommand(command); } catch (Exception e) { append("Command failed: " + e.getMessage()); } }); + } + private void append(String text) { + if (closed || text == null) return; + if (queued.incrementAndGet() > 1000) { queued.decrementAndGet(); return; } + if (text.length() > 16384) text = text.substring(0, 16384) + " [line truncated]"; + pending.add(text.replaceAll("\\u001B\\[[;\\d]*[ -/]*[@-~]", "")); + } + private void drain() { + if (closed) return; + AsyncInputStream current = Server.ASYNC_SERVER_IN; + if (observed != current) { + if (observed != null) observed.listeners.remove(serverListener); + observed = current; if (observed != null) observed.listeners.add(serverListener); } - return list; + StringBuilder batch = new StringBuilder(); String line; + while ((line = pending.poll()) != null) { queued.decrementAndGet(); batch.append(line).append('\n'); } + if (batch.length() == 0) return; + txtConsole.append(batch.toString()); + int excess = txtConsole.getDocument().getLength() - 250000; + if (excess > 0) try { txtConsole.getDocument().remove(0, excess); } catch (javax.swing.text.BadLocationException ignored) {} + txtConsole.setCaretPosition(txtConsole.getDocument().getLength()); + } + @Override public void close() { + closed = true; timer.stop(); AL.actionsOnMessageEvent.remove(logListener); + if (observed != null) observed.listeners.remove(serverListener); observed = null; + pending.clear(); queued.set(0); commandExecutor.shutdownNow(); } } diff --git a/src/main/java/com/osiris/autoplug/client/utils/MineStat.java b/src/main/java/com/osiris/autoplug/client/utils/MineStat.java index 8e23edf9..6e67ff53 100644 --- a/src/main/java/com/osiris/autoplug/client/utils/MineStat.java +++ b/src/main/java/com/osiris/autoplug/client/utils/MineStat.java @@ -55,6 +55,8 @@ public class MineStat { * Minecraft version the server is running */ private String version; + private String favicon; + private int protocol = -1; /** * Current number of players on the server */ @@ -178,26 +180,31 @@ public String stripMotdFormatting(String motd) { } public String stripMotdFormatting(JsonObject motd) { - StringBuilder strippedMotd = new StringBuilder(); - - if (motd.isJsonPrimitive()) { - return motd.getAsString(); - } + return stripMotdFormatting((JsonElement) motd); + } - JsonObject motdObj = motd.getAsJsonObject(); - if (motdObj.has("text")) { - strippedMotd.append(motdObj.get("text").getAsString()); - } + public String stripMotdFormatting(JsonElement motd) { + return stripMotdFormatting(motd, 0); + } - if (motdObj.has("extra") && motdObj.get("extra").isJsonArray()) { - for (JsonElement extraElem : motdObj.get("extra").getAsJsonArray()) { - strippedMotd.append(stripMotdFormatting(extraElem.getAsJsonObject())); - } + private String stripMotdFormatting(JsonElement motd, int depth) { + if (motd == null || motd.isJsonNull() || depth > 32) return ""; + if (motd.isJsonPrimitive()) return stripMotdFormatting(motd.getAsString()); + StringBuilder text = new StringBuilder(); + if (motd.isJsonArray()) { + for (JsonElement child : motd.getAsJsonArray()) text.append(stripMotdFormatting(child, depth + 1)); + } else if (motd.isJsonObject()) { + JsonObject object = motd.getAsJsonObject(); + if (object.has("text")) text.append(stripMotdFormatting(object.get("text"), depth + 1)); + if (object.has("translate")) text.append(stripMotdFormatting(object.get("translate"), depth + 1)); + if (object.has("extra")) text.append(stripMotdFormatting(object.get("extra"), depth + 1)); } - - return strippedMotd.toString(); + return text.toString(); } + public int getProtocol() { return protocol; } + public String getFavicon() { return favicon; } + public String getVersion() { return version; } @@ -519,50 +526,55 @@ public boolean isDataValid() { * 'description': {'text': 'A Minecraft Server'}} */ public Retval jsonRequest(String address, int port, int timeout) { - try { - String[] serverData = null; - byte[] rawServerData = null; - Socket clientSocket = new Socket(); + serverUp = false; + protocol = -1; + favicon = null; + try (Socket clientSocket = new Socket()) { long startTime = System.currentTimeMillis(); - clientSocket.connect(new InetSocketAddress(getAddress(), getPort()), getTimeout()); - setLatency(System.currentTimeMillis() - startTime); + clientSocket.connect(new InetSocketAddress(address, port), timeout); + clientSocket.setSoTimeout(timeout); ByteArrayOutputStream baos = new ByteArrayOutputStream(); DataOutputStream payload = new DataOutputStream(baos); DataOutputStream dos = new DataOutputStream(clientSocket.getOutputStream()); DataInputStream dis = new DataInputStream(new BufferedInputStream(clientSocket.getInputStream())); payload.writeByte(0x00); // handshake packet sendVarInt(payload, 0x00); // protocol version - sendVarInt(payload, address.length()); // packed remote address length as varint - payload.writeBytes(address); // remote address as string + byte[] hostBytes = address.getBytes(StandardCharsets.UTF_8); + sendVarInt(payload, hostBytes.length); // packed remote address length as varint + payload.write(hostBytes); // remote address as UTF-8 string payload.writeShort(port); // remote port as short sendVarInt(payload, 0x01); // state packet sendVarInt(dos, baos.size()); // payload size as varint dos.write(baos.toByteArray()); // send payload dos.writeByte(0x01); // size dos.writeByte(0x00); // ping packet - int totalLength = recvVarInt(dis); // total response size - int packetID = recvVarInt(dis); // packet ID - int jsonLength = recvVarInt(dis); // JSON response size - byte[] rawData = new byte[jsonLength]; // storage for JSON data - - dis.readFully(rawData); // fill byte array with JSON data - - // Close sockets - if (!clientSocket.isClosed()) { - clientSocket.close(); - } + dos.flush(); + int totalLength = readStatusVarInt(dis); + if (totalLength < 3 || totalLength > 1024 * 1024) return Retval.UNKNOWN; + byte[] packet = new byte[totalLength]; + dis.readFully(packet); + DataInputStream response = new DataInputStream(new ByteArrayInputStream(packet)); + if (readStatusVarInt(response) != 0) return Retval.UNKNOWN; + int jsonLength = readStatusVarInt(response); + if (jsonLength < 1 || jsonLength != response.available()) return Retval.UNKNOWN; + byte[] rawData = new byte[jsonLength]; + response.readFully(rawData); + setLatency(System.currentTimeMillis() - startTime); // Populate object from JSON data - JsonObject jobj = new Gson().fromJson(new String(rawData), JsonObject.class); + JsonObject jobj = new Gson().fromJson(new String(rawData, StandardCharsets.UTF_8), JsonObject.class); setMotd(jobj.get("description").toString()); - setStrippedMotd(stripMotdFormatting(jobj.get("description").getAsJsonObject())); + setStrippedMotd(stripMotdFormatting(jobj.get("description"))); setVersion(jobj.get("version").getAsJsonObject().get("name").getAsString()); + protocol = jobj.get("version").getAsJsonObject().get("protocol").getAsInt(); setCurrentPlayers(jobj.get("players").getAsJsonObject().get("online").getAsInt()); setMaximumPlayers(jobj.get("players").getAsJsonObject().get("max").getAsInt()); - serverUp = true; + JsonElement icon = jobj.get("favicon"); + if (icon != null && icon.isJsonPrimitive() && icon.getAsJsonPrimitive().isString()) favicon = icon.getAsString(); setRequestType("SLP 1.7 (JSON)"); if (!isDataValid()) return Retval.UNKNOWN; + serverUp = true; } catch (ConnectException ce) { return Retval.CONNFAIL; } catch (SocketException se) { @@ -579,6 +591,18 @@ public Retval jsonRequest(String address, int port, int timeout) { return Retval.SUCCESS; } + /** Unlike the legacy helper, preserve socket timeouts for an accurate ping result. */ + private int readStatusVarInt(DataInputStream input) throws IOException { + int result = 0; + for (int index = 0; index < 5; index++) { + int value = input.readUnsignedByte(); + if (index == 4 && (value & 0xf0) != 0) throw new IOException("Status packet varint exceeds 32 bits."); + result |= (value & 0x7f) << (index * 7); + if ((value & 0x80) == 0) return result; + } + throw new IOException("Status packet varint is too long."); + } + public enum Retval { SUCCESS(0), CONNFAIL(-1), TIMEOUT(-2), UNKNOWN(-3); @@ -606,4 +630,4 @@ public int getRequest() { return request; } } -} \ No newline at end of file +} 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..ff52e775 100644 --- a/src/main/java/com/osiris/autoplug/client/utils/UtilsMinecraft.java +++ b/src/main/java/com/osiris/autoplug/client/utils/UtilsMinecraft.java @@ -163,7 +163,7 @@ public List getPlugins(File dir) throws FileNotFoundException { if (found) break; } } catch (Exception e) { - AL.warn("Failed to get details of " + jar.getName(), e); + warnUnreadable(jar, e); } } return plugins; @@ -222,21 +222,7 @@ public List getMods(File dir) throws FileNotFoundException { // name = jar.getName(); //} // Don't do this, because the jars name contains its version and generally it wouldn't be nice String version = obj.get("version").getAsString(); - JsonElement authorRaw = obj.get("author"); - JsonElement authorsRaw = obj.get("authors"); - - String author = null; - if (authorRaw != null && !authorRaw.isJsonNull()) - author = authorRaw.getAsString(); - else { - try { - // Old fabric.mod.json authors was a string array - author = authorsRaw.getAsJsonArray().get(0).getAsString(); // Returns only the first author - } catch (Exception e) { - // New fabric.mod.json authors is a json objects array - author = authorsRaw.getAsJsonArray().get(0).getAsJsonObject().get("name").getAsString(); - } - } + String author = fabricAuthor(obj); // Also check for ids in the config String modrinthId = obj.get("id").getAsString(); @@ -245,10 +231,30 @@ public List getMods(File dir) throws FileNotFoundException { if (found) break; } } catch (Exception e) { - AL.warn("Failed to get details of " + jar.getName(), e); + warnUnreadable(jar, e); } } return mods; } + private String fabricAuthor(JsonObject metadata) { + JsonElement author = metadata.get("author"); + if (author == null || author.isJsonNull()) { + JsonElement authors = metadata.get("authors"); + if (authors == null || !authors.isJsonArray() || authors.getAsJsonArray().size() == 0) return null; + author = authors.getAsJsonArray().get(0); + } + if (author.isJsonObject()) author = author.getAsJsonObject().get("name"); + return author != null && author.isJsonPrimitive() ? author.getAsString() : null; + } + + private void warnUnreadable(File jar, Exception error) { + String message = "Failed to get details of " + jar.getName(); + // The standalone launcher scans collections before the legacy logger is initialized. + synchronized (AL.class) { + if (AL.isStarted && AL.STRIPPED_OUT != null) AL.warn(message, error); + else System.err.println(message + ": " + error.getMessage()); + } + } + } diff --git a/src/main/java/com/osiris/autoplug/client/worlds/AutoPlugWorldBootstrap.java b/src/main/java/com/osiris/autoplug/client/worlds/AutoPlugWorldBootstrap.java new file mode 100644 index 00000000..be58e14f --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/worlds/AutoPlugWorldBootstrap.java @@ -0,0 +1,113 @@ +package com.osiris.autoplug.client.worlds; + +import com.osiris.autoplug.client.Main; +import com.osiris.autoplug.client.profiles.JsonFiles; +import com.osiris.autoplug.client.profiles.Profile; +import com.osiris.dyml.Yaml; +import java.io.*; +import java.nio.file.*; +import java.util.*; +import java.util.jar.JarFile; + +/** Each managed world runs a real, isolated copy of AutoPlug's server wrapper. */ +public final class AutoPlugWorldBootstrap { + public static final String CHILD_ARGUMENT = "--managed-world-child"; + public static final String UPDATER_POLICY = "Managed world: automatic legacy updates stay disabled to preserve the selected profile, exact game version and runtime. Use the parent launcher's profile update workflow."; + public static final String RESTARTER_POLICY = "Managed world: the parent launcher owns this session. Scheduled restarts stay disabled; use Restart in its server console."; + public static final String COMMAND_POLICY = "Managed world: informational preview. The parent launcher supplies the exact argument vector in managed-server-command.json on each launch."; + private static volatile List childCommand; + private final Path sourceJar, wrapperJava; + static class LaunchMetadata { public List command = new ArrayList<>(); } + + public AutoPlugWorldBootstrap() { this.sourceJar = null; this.wrapperJava = null; } + AutoPlugWorldBootstrap(Path sourceJar, Path wrapperJava) { this.sourceJar = sourceJar; this.wrapperJava = wrapperJava; } + + public ServerLaunch wrap(Profile profile, ServerLaunch server) throws Exception { + Path source = sourceJar == null ? Paths.get(Main.class.getProtectionDomain().getCodeSource().getLocation().toURI()) : sourceJar; + if (!Files.isRegularFile(source) || !source.getFileName().toString().endsWith(".jar")) + throw new IOException("Managed worlds require the packaged AutoPlug JAR. Run the built AutoPlug-Client.jar, rather than a classes directory."); + try (JarFile jar = new JarFile(source.toFile())) { + if (jar.getEntry("com/osiris/autoplug/client/Main.class") == null) throw new IOException("The wrapper JAR does not contain AutoPlug"); + } + Path java = wrapperJava == null ? Paths.get(System.getProperty("java.home"), "bin", System.getProperty("os.name").startsWith("Windows") ? "java.exe" : "java") : wrapperJava; + java = java.toAbsolutePath().normalize(); + if (!Files.isRegularFile(java)) throw new IOException("AutoPlug wrapper Java executable is unavailable: " + java); + validateCommand(server.command); + Path config = server.directory.resolve("autoplug"); + if (Files.isSymbolicLink(config)) throw new IOException("World AutoPlug configuration must not be a symbolic link"); + Files.createDirectories(config); + Path copy = config.resolve("AutoPlug-Client.jar"); + if (Files.isSymbolicLink(copy)) throw new IOException("World AutoPlug JAR must not be a symbolic link"); + Path temporary = Files.createTempFile(config, ".autoplug-copy-", ".jar"); + try { + Files.copy(source, temporary, StandardCopyOption.REPLACE_EXISTING); + try { Files.move(temporary, copy, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } + catch (AtomicMoveNotSupportedException e) { Files.move(temporary, copy, StandardCopyOption.REPLACE_EXISTING); } + } finally { Files.deleteIfExists(temporary); } + LaunchMetadata metadata = new LaunchMetadata(); metadata.command.addAll(server.command); + Path commandFile = config.resolve("managed-server-command.json"); + if (Files.isSymbolicLink(commandFile)) throw new IOException("Managed command file must not be a link"); + new JsonFiles().write(commandFile, metadata); + configure(config, profile, server.command); + List command = Arrays.asList(java.toString(), "-Djava.awt.headless=true", + "-Dautoplug.home=" + config.resolve("launcher"), "-jar", copy.toString(), CHILD_ARGUMENT); + return new ServerLaunch(command, server.directory, ".stop both", ".restart"); + } + + private void configure(Path directory, Profile profile, List command) throws Exception { + Yaml general = yaml(directory, "general"); + general.put("general", "autoplug", "target-software").setValues("MINECRAFT_SERVER"); + general.put("general", "autoplug", "auto-stop").setValues("true"); + general.put("general", "autoplug", "start-on-boot").setValues("false"); + general.put("general", "autoplug", "system-tray", "enable").setValues("false"); + general.put("general", "server", "auto-start").setValues("true"); + general.put("general", "server", "auto-eula").setValues("false"); + general.put("general", "server", "restart-on-crash").setValues("false"); + general.put("general", "server", "version").setValues(profile.gameVersion); + general.put("general", "server", "start-command").setValues(displayCommand(command)) + .setComments(COMMAND_POLICY); + general.put("general", "server", "stop-command").setValues("stop"); + general.put("general", "directory-cleaner", "enabled").setDefValues("false"); + general.save(); + Yaml updater = yaml(directory, "updater"); + for (String name : Arrays.asList("global-recurring-checks", "self-updater", "java-updater", "server-updater", "plugins-updater", "mods-updater")) + updater.put("updater", name, "enable").setValues("false").setComments(UPDATER_POLICY); + updater.save(); + Yaml restarter = yaml(directory, "restarter"); + restarter.put("restarter", "daily-restarter", "enable").setValues("false").setComments(RESTARTER_POLICY); + restarter.put("restarter", "custom-restarter", "enable").setValues("false").setComments(RESTARTER_POLICY); restarter.save(); + Yaml backup = yaml(directory, "backup"); backup.put("backup", "enable").setDefValues("false"); backup.save(); + } + private Yaml yaml(Path directory, String name) throws Exception { + Path path = directory.resolve(name + ".yml"); + if (Files.isSymbolicLink(path)) throw new IOException("World configuration must not be a symbolic link: " + name); + Yaml yaml = new Yaml(path.toString()); yaml.load(); return yaml; + } + private String displayCommand(List command) { + // This is informational only; Server uses the exact persisted vector for this child. + List parts = new ArrayList<>(); for (String value : command) parts.add('"' + value.replace("\"", "\\\"") + '"'); + return String.join(" ", parts); + } + public static boolean configureIfChild(String[] args) throws IOException { + if (args == null || args.length == 0 || !CHILD_ARGUMENT.equals(args[0])) return false; + if (args.length != 1) throw new IOException("Managed child accepts only its world-local command file"); + childCommand = readCommand(Paths.get(System.getProperty("user.dir"))); + return true; + } + static List readCommand(Path directory) throws IOException { + Path config = directory.resolve("autoplug"), file = config.resolve("managed-server-command.json"); + if (Files.isSymbolicLink(config) || Files.isSymbolicLink(file)) throw new IOException("Managed command path must not be linked"); + LaunchMetadata metadata = new JsonFiles().read(file, LaunchMetadata.class); + validateCommand(metadata.command); + return Collections.unmodifiableList(new ArrayList<>(metadata.command)); + } + private static void validateCommand(List command) throws IOException { + if (command == null || command.isEmpty() || command.size() > 256) throw new IOException("Invalid managed server argument vector"); + for (String argument : command) if (argument == null || argument.length() > 32768 || argument.indexOf('\0') >= 0) + throw new IOException("Invalid managed server argument"); + Path executable = Paths.get(command.get(0)); + if (!executable.isAbsolute() || !Files.isRegularFile(executable)) throw new IOException("Managed server executable must be an existing absolute path"); + } + public static boolean isChild() { return childCommand != null; } + public static List childCommand() { return childCommand; } +} diff --git a/src/main/java/com/osiris/autoplug/client/worlds/ClientLauncher.java b/src/main/java/com/osiris/autoplug/client/worlds/ClientLauncher.java new file mode 100644 index 00000000..b0d90f83 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/worlds/ClientLauncher.java @@ -0,0 +1,8 @@ +package com.osiris.autoplug.client.worlds; + +import com.osiris.autoplug.client.profiles.Profile; + +@FunctionalInterface +public interface ClientLauncher { + Process launch(Profile profile, String host, int port) throws Exception; +} diff --git a/src/main/java/com/osiris/autoplug/client/worlds/LevelDatReader.java b/src/main/java/com/osiris/autoplug/client/worlds/LevelDatReader.java new file mode 100644 index 00000000..f3ed929d --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/worlds/LevelDatReader.java @@ -0,0 +1,64 @@ +package com.osiris.autoplug.client.worlds; + +import com.osiris.autoplug.client.browser.VanillaServersReader; +import java.io.IOException; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** Retains only display/version metadata from the shared bounded NBT decoder. */ +final class LevelDatReader { + static final class Metadata { + String name = "", version = ""; + boolean modded, cheats, hardcore; + long lastPlayed = -1; + List dataPacks = new ArrayList<>(); + } + + Metadata read(Path path) throws IOException { + Map root = new VanillaServersReader().readCompound(path); + if (!(root.get("Data") instanceof Map)) throw new IOException("level.dat has no Data compound"); + Map data = (Map) root.get("Data"); + Metadata result = new Metadata(); + if (data.containsKey("LevelName") && !(data.get("LevelName") instanceof String)) throw new IOException("Invalid LevelName tag"); + if (data.containsKey("Version") && !(data.get("Version") instanceof Map)) throw new IOException("Invalid Version tag"); + if (data.containsKey("WasModded") && !(data.get("WasModded") instanceof Number)) throw new IOException("Invalid WasModded tag"); + if (data.get("LevelName") instanceof String) result.name = (String) data.get("LevelName"); + if (data.get("Version") instanceof Map) { + Object name = ((Map) data.get("Version")).get("Name"); + if (name != null && !(name instanceof String)) throw new IOException("Invalid version Name tag"); + if (name instanceof String) result.version = (String) name; + } + result.modded = data.get("WasModded") instanceof Number && ((Number) data.get("WasModded")).intValue() != 0; + result.cheats = numericFlag(data, "allowCommands"); + result.hardcore = numericFlag(data, "hardcore"); + Object lastPlayed = data.get("LastPlayed"); + if (lastPlayed != null && !(lastPlayed instanceof Number)) throw new IOException("Invalid LastPlayed tag"); + if (lastPlayed instanceof Number) result.lastPlayed = Math.max(-1, ((Number) lastPlayed).longValue()); + Object packs = data.get("DataPacks"); + if (packs != null && !(packs instanceof Map)) throw new IOException("Invalid DataPacks tag"); + if (packs instanceof Map) { + Object enabled = ((Map) packs).get("Enabled"); + if (enabled != null && !(enabled instanceof List)) throw new IOException("Invalid enabled datapacks tag"); + if (enabled instanceof List) for (Object pack : (List) enabled) { + if (!(pack instanceof String)) throw new IOException("Invalid datapack name"); + if (result.dataPacks.size() == 256) break; + String name = (String) pack; + result.dataPacks.add(name.length() > 256 ? name.substring(0, 256) + "…" : name); + } + } + for (String marker : Arrays.asList("FML", "fml", "ForgeData", "forge", "neoforge", "fabric", "Mods", "mods", "ModList")) + if (root.containsKey(marker) || data.containsKey(marker)) result.modded = true; + Object brands = data.get("ServerBrands"); + if (brands instanceof List) for (Object brand : (List) brands) + if (brand instanceof String && !"vanilla".equals(brand)) result.modded = true; + return result; + } + private static boolean numericFlag(Map data, String key) throws IOException { + Object value = data.get(key); + if (value != null && !(value instanceof Number)) throw new IOException("Invalid " + key + " tag"); + return value instanceof Number && ((Number) value).intValue() != 0; + } +} diff --git a/src/main/java/com/osiris/autoplug/client/worlds/LocalWorld.java b/src/main/java/com/osiris/autoplug/client/worlds/LocalWorld.java new file mode 100644 index 00000000..e805c3c6 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/worlds/LocalWorld.java @@ -0,0 +1,38 @@ +package com.osiris.autoplug.client.worlds; + +import java.nio.file.Path; +import java.util.Collections; +import java.util.ArrayList; +import java.util.List; + +/** A read-only view of an existing Minecraft save; it is never a managed server. */ +public final class LocalWorld { + public final String id, name, gameVersion; + public final Path directory, gameDirectory, icon; + public final boolean modded; + public final boolean cheats, hardcore, quickPlayEligible, sizeComplete; + public final long lastPlayed, sizeBytes; + public final List dataPacks; + + LocalWorld(String id, String name, String gameVersion, Path directory, Path gameDirectory, Path icon, boolean modded) { + this(id, name, gameVersion, directory, gameDirectory, icon, modded, -1, -1, false, Collections.emptyList(), false, false); + } + LocalWorld(String id, String name, String gameVersion, Path directory, Path gameDirectory, Path icon, boolean modded, + long lastPlayed, long sizeBytes, boolean sizeComplete, List dataPacks, boolean cheats, boolean hardcore) { + this.id = id; this.name = name; this.gameVersion = gameVersion; + this.directory = directory; this.gameDirectory = gameDirectory; this.icon = icon; this.modded = modded; + this.lastPlayed = lastPlayed; this.sizeBytes = sizeBytes; this.sizeComplete = sizeComplete; + this.dataPacks = Collections.unmodifiableList(new ArrayList<>(dataPacks)); this.cheats = cheats; this.hardcore = hardcore; + this.quickPlayEligible = !modded && supportsQuickPlay(gameVersion); + } + LocalWorld withId(String id) { return new LocalWorld(id, name, gameVersion, directory, gameDirectory, icon, modded, lastPlayed, sizeBytes, sizeComplete, dataPacks, cheats, hardcore); } + private static boolean supportsQuickPlay(String version) { + java.util.regex.Matcher match = java.util.regex.Pattern.compile("^(\\d+)\\.(\\d+)(?:\\.\\d+)?(?:[- ].*)?$").matcher(version); + if (!match.matches()) return false; + try { return Integer.parseInt(match.group(1)) > 1 || (Integer.parseInt(match.group(1)) == 1 && Integer.parseInt(match.group(2)) >= 20); } + catch (NumberFormatException e) { return false; } + } + + /** The directory name, rather than LevelName, is what Minecraft Quick Play accepts. */ + public String saveName() { return directory.getFileName().toString(); } +} diff --git a/src/main/java/com/osiris/autoplug/client/worlds/LocalWorldStore.java b/src/main/java/com/osiris/autoplug/client/worlds/LocalWorldStore.java new file mode 100644 index 00000000..c6453541 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/worlds/LocalWorldStore.java @@ -0,0 +1,140 @@ +package com.osiris.autoplug.client.worlds; + +import com.osiris.autoplug.client.browser.ServerBrowserService; +import java.io.*; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.*; +import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Discovers standard local saves in place. Discovery never writes to the saves. */ +public final class LocalWorldStore { + private static final String PREFIX = "local:"; + private final Path minecraft; + private final Consumer progress; + + public LocalWorldStore(Path minecraft) { this(minecraft, ignored -> { }); } + public LocalWorldStore(Path minecraft, Consumer progress) { + this.minecraft = minecraft.toAbsolutePath().normalize(); + this.progress = progress == null ? ignored -> { } : progress; + } + public static Path defaultMinecraftDirectory() { return ServerBrowserService.defaults().vanillaFile().getParent(); } + public Path getMinecraftDirectory() { return minecraft; } + + public List list() throws IOException { + Path saves = minecraft.resolve("saves"); + if (!Files.exists(saves, LinkOption.NOFOLLOW_LINKS)) return Collections.emptyList(); + validateSaves(saves); + List result = new ArrayList<>(); + try (DirectoryStream children = Files.newDirectoryStream(saves)) { + int count = 0; + for (Path child : children) { + if (++count > 10000) throw new IOException("Too many entries in the Minecraft saves folder"); + if (!Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)) continue; + if (!Files.exists(child.resolve("level.dat"), LinkOption.NOFOLLOW_LINKS)) continue; + try { result.add(get(PREFIX + child.getFileName())); } + catch (IOException | IllegalArgumentException e) { progress.accept("Skipped local save '" + child.getFileName() + "': " + e.getMessage()); } + } + } + result.sort(Comparator.comparing((LocalWorld world) -> world.name, String.CASE_INSENSITIVE_ORDER).thenComparing(world -> world.id)); + return result; + } + + public LocalWorld get(String id) throws IOException { + if (id == null || !id.startsWith(PREFIX)) throw new IOException("Invalid local world identifier"); + String name = id.substring(PREFIX.length()); + if (name.isEmpty() || name.equals(".") || name.equals("..") || name.indexOf('/') >= 0 || name.indexOf('\\') >= 0 + || name.indexOf(':') >= 0 || name.chars().anyMatch(Character::isISOControl)) + throw new IOException("Invalid local world directory"); + Path saves = minecraft.resolve("saves"); validateSaves(saves); + Path directory = saves.resolve(name).normalize(); + if (!directory.getParent().equals(saves) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS) + || Files.isSymbolicLink(directory) || !directory.toRealPath().equals(saves.toRealPath().resolve(name))) + throw new IOException("Linked or external save directories are not supported"); + return inspect(id, directory, minecraft); + } + + /** Read only bounded metadata; used for both existing local saves and managed server saves. */ + public static LocalWorld inspect(String id, Path directory, Path gameDirectory) throws IOException { + directory = directory.toAbsolutePath().normalize(); + if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(directory) + || !directory.toRealPath().equals(directory.getParent().toRealPath().resolve(directory.getFileName()))) + throw new IOException("Linked or external save directories are not supported"); + Path level = directory.resolve("level.dat"); + if (!regularChild(level, directory)) throw new IOException("Missing or linked level.dat"); + LevelDatReader.Metadata metadata = new LevelDatReader().read(level); + String display = metadata.name.trim(); + if (display.isEmpty()) display = directory.getFileName().toString(); + Path icon = directory.resolve("icon.png"); + try { if (!safeIcon(icon, directory)) icon = null; } + catch (IOException e) { icon = null; } // An unreadable thumbnail must not hide a valid save. + long[] size = directorySize(directory, 10000); + return new LocalWorld(id, display, metadata.version, directory, gameDirectory, icon, metadata.modded, + metadata.lastPlayed, size[0], size[1] == 1, metadata.dataPacks, metadata.cheats, metadata.hardcore); + } + + /** A lower bound is returned if traversal hits its entry/depth/time budget or inaccessible data. */ + static long[] directorySize(Path directory, int maxEntries) { + long[] result = {0, 1}; int[] entries = {0}; long deadline = System.nanoTime() + 100_000_000L; + try { + Files.walkFileTree(directory, EnumSet.noneOf(FileVisitOption.class), 32, new SimpleFileVisitor() { + private FileVisitResult budget() { + if (++entries[0] > maxEntries || System.nanoTime() > deadline) { result[1] = 0; return FileVisitResult.TERMINATE; } + return FileVisitResult.CONTINUE; + } + @Override public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { return budget(); } + @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + if (budget() == FileVisitResult.TERMINATE) return FileVisitResult.TERMINATE; + if (attrs.isRegularFile()) result[0] += attrs.size(); else result[1] = 0; + return FileVisitResult.CONTINUE; + } + @Override public FileVisitResult visitFileFailed(Path file, IOException error) { result[1] = 0; return budget(); } + @Override public FileVisitResult postVisitDirectory(Path dir, IOException error) { if (error != null) result[1] = 0; return FileVisitResult.CONTINUE; } + }); + } catch (IOException | SecurityException e) { result[1] = 0; } + return result; + } + + /** Preserve the exact recorded build; require an explicit choice for older unknown saves. */ + public String launchVersion(LocalWorld world, String selected) throws IOException { + String version = canonicalVersion(selected == null ? "" : selected.trim()); + if (world.modded) throw new IOException("This save was marked as modded. Open it with its original modded launcher to preserve its content."); + if (!world.gameVersion.isEmpty()) { + String recorded = canonicalVersion(world.gameVersion); + if (version.isEmpty()) version = recorded; + if (!version.equals(recorded)) throw new IOException("This save records Minecraft " + world.gameVersion + "; selecting another version could change it. Use the recorded version."); + } + if (version.isEmpty()) throw new IOException("This save does not record a Minecraft version. Choose its original version explicitly."); + if (version.equals(".") || version.contains("..") || !version.matches("[A-Za-z0-9._+ -]+")) + throw new IOException("The save's Minecraft version is not a valid published version identifier"); + return version; + } + + /** Publisher display names and manifest IDs can identify the same prerelease build. */ + private static String canonicalVersion(String version) { + Matcher display = Pattern.compile("^([0-9]+\\.[0-9]+(?:\\.[0-9]+)?) (Pre-release|Release Candidate) ([1-9][0-9]*)$", Pattern.CASE_INSENSITIVE).matcher(version); + if (display.matches()) return display.group(1) + (display.group(2).equalsIgnoreCase("Pre-release") ? "-pre" : "-rc") + display.group(3); + Matcher id = Pattern.compile("^([0-9]+\\.[0-9]+(?:\\.[0-9]+)?)-(pre|rc)([1-9][0-9]*)$", Pattern.CASE_INSENSITIVE).matcher(version); + return id.matches() ? id.group(1) + "-" + id.group(2).toLowerCase(Locale.ROOT) + id.group(3) : version; + } + + private void validateSaves(Path saves) throws IOException { + if (!Files.isDirectory(saves, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(saves) + || !saves.toRealPath().equals(minecraft.toRealPath().resolve("saves"))) + throw new IOException("Linked or external Minecraft saves folders are not supported"); + } + private static boolean regularChild(Path file, Path directory) throws IOException { + return Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(file) + && file.toRealPath().equals(directory.toRealPath().resolve(file.getFileName())); + } + private static boolean safeIcon(Path icon, Path directory) throws IOException { + if (!regularChild(icon, directory) || Files.size(icon) < 24 || Files.size(icon) > 1024 * 1024) return false; + try (DataInputStream input = new DataInputStream(Files.newInputStream(icon))) { + if (input.readLong() != 0x89504e470d0a1a0aL || input.readInt() != 13 || input.readInt() != 0x49484452) return false; + int width = input.readInt(), height = input.readInt(); + return width > 0 && width <= 1024 && height > 0 && height <= 1024; + } catch (EOFException e) { return false; } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/worlds/ManagedServer.java b/src/main/java/com/osiris/autoplug/client/worlds/ManagedServer.java new file mode 100644 index 00000000..477df99c --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/worlds/ManagedServer.java @@ -0,0 +1,68 @@ +package com.osiris.autoplug.client.worlds; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.List; +import java.util.stream.Collectors; + +/** Owns exactly one server process and performs bounded, graceful shutdown. */ +public final class ManagedServer implements AutoCloseable { + private final Process process; + private final long stopTimeoutMillis; + private final String stopCommand, restartCommand; + private final AtomicBoolean closed = new AtomicBoolean(); + public ManagedServer(Process process, long stopTimeoutMillis) { + this(process, stopTimeoutMillis, "stop", null); + } + public ManagedServer(Process process, long stopTimeoutMillis, String stopCommand, String restartCommand) { + this.process = process; + this.stopTimeoutMillis = stopTimeoutMillis; + this.stopCommand = stopCommand; this.restartCommand = restartCommand; + } + public Process process() { return process; } + public boolean isAlive() { return process.isAlive(); } + public synchronized void command(String command) throws IOException { + if (closed.get() || !isAlive()) throw new IOException("This world's server is not running"); + if (command == null || command.trim().isEmpty() || command.length() > 8192 || command.indexOf('\n') >= 0 || command.indexOf('\r') >= 0 || command.indexOf('\0') >= 0) + throw new IOException("Enter one nonempty console command"); + process.getOutputStream().write((command + "\n").getBytes(StandardCharsets.UTF_8)); process.getOutputStream().flush(); + } + public void restart() throws IOException { + if (restartCommand == null) throw new IOException("This process does not support in-place AutoPlug restart"); + command(restartCommand); + } + + @Override public void close() { + if (!closed.compareAndSet(false, true)) return; + List descendants = process.descendants().collect(Collectors.toList()); + try { + if (process.isAlive()) { + // Serialize the final line with any console command already in flight. + // Do not hold this lock while waiting for the child to shut down. + synchronized (this) { + process.getOutputStream().write((stopCommand + "\n").getBytes(StandardCharsets.UTF_8)); + process.getOutputStream().flush(); + } + if (process.waitFor(stopTimeoutMillis, TimeUnit.MILLISECONDS)) return; + } + } catch (IOException ignored) { + // A broken stdin must not leave an owned server running. + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + if (process.isAlive()) { + // A wrapper restart may have created a new child while graceful shutdown was waiting. + process.descendants().forEach(handle -> { if (!descendants.contains(handle)) descendants.add(handle); }); + descendants.forEach(ProcessHandle::destroy); + process.destroy(); + try { process.waitFor(1000, TimeUnit.MILLISECONDS); } + catch (InterruptedException e) { Thread.currentThread().interrupt(); } + descendants.forEach(ProcessHandle::destroyForcibly); + if (process.isAlive()) process.destroyForcibly(); + } + descendants.stream().filter(ProcessHandle::isAlive).forEach(ProcessHandle::destroyForcibly); + } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/worlds/MinecraftServerInstaller.java b/src/main/java/com/osiris/autoplug/client/worlds/MinecraftServerInstaller.java new file mode 100644 index 00000000..29a91b45 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/worlds/MinecraftServerInstaller.java @@ -0,0 +1,198 @@ +package com.osiris.autoplug.client.worlds; + +import com.google.gson.*; +import com.osiris.autoplug.client.profiles.ArtifactCache; +import com.osiris.autoplug.client.profiles.Profile; +import com.osiris.autoplug.client.profiles.ProfileLease; +import io.github.projectunified.mcserverupdater.UpdateBuilder; +import io.github.projectunified.mcserverupdater.UpdateStatus; + +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.regex.Pattern; +import java.util.jar.JarFile; +import java.util.stream.Collectors; + +/** Reuses the bundled server updaters, with isolated install work and shared immutable JARs. */ +public final class MinecraftServerInstaller implements ServerInstaller { + @FunctionalInterface public interface JavaRuntime { Path resolve(String gameVersion) throws Exception; } + @FunctionalInterface public interface ArtifactProvider { Path acquire(Profile profile, Path workspace, Path javaExecutable) throws Exception; } + private final ArtifactCache cache; + private final JavaRuntime runtimes; + private final Consumer progress; + private final ServerInstaller moddedInstaller; + private final ArtifactProvider provider; + + public MinecraftServerInstaller(ArtifactCache cache, JavaRuntime runtimes) { + this(cache, runtimes, ignored -> { }, null); + } + public MinecraftServerInstaller(ArtifactCache cache, JavaRuntime runtimes, Consumer progress, ServerInstaller moddedInstaller) { + this.cache = Objects.requireNonNull(cache); this.runtimes = Objects.requireNonNull(runtimes); + this.progress = Objects.requireNonNull(progress); this.moddedInstaller = moddedInstaller; + this.provider = this::acquire; + } + public MinecraftServerInstaller(ArtifactCache cache, JavaRuntime runtimes, ArtifactProvider provider) { + this.cache = Objects.requireNonNull(cache); this.runtimes = Objects.requireNonNull(runtimes); + this.provider = Objects.requireNonNull(provider); this.progress = ignored -> { }; this.moddedInstaller = null; + } + + @Override public ServerLaunch prepare(Profile profile, Path worldDirectory) throws Exception { + Files.createDirectories(worldDirectory); + try (ProfileLease ignored = new ProfileLease(profile.getDirectory())) { copyProfile(profile, worldDirectory); } + String software = profile.getLoader().toLowerCase(Locale.ROOT); + if (Arrays.asList("forge", "neoforge", "quilt").contains(software)) { + if (moddedInstaller == null) throw new IOException("No native server installer configured for " + software); + return moddedInstaller.prepare(profile, worldDirectory); + } + if (Arrays.asList("velocity", "bungee", "bungeecord").contains(software)) + throw new IOException("Proxy profiles cannot host a Minecraft world; select Paper, Spigot or a modded server"); + Path java = runtimes.resolve(profile.getGameVersion()); + String key = safe(software) + "-" + safe(profile.getGameVersion()) + "-" + safe(profile.getLoaderVersion() == null ? "default" : profile.getLoaderVersion()); + Path work = cache.getRoot().resolve("server-installers").resolve(key); + Files.createDirectories(work); + try (ProfileLease ignored = new ProfileLease(work)) { + Path jar = work.resolve("server.jar"); + if (!Files.isRegularFile(jar) || Files.size(jar) == 0) { + Path acquired = provider.acquire(profile, work, java); + if (acquired == null || !Files.isRegularFile(acquired)) throw new IOException("Server installer produced no JAR"); + try (JarFile ignoredJar = new JarFile(acquired.toFile())) { /* Reject error pages and incomplete downloads. */ } + cache.link(cache.store(acquired), jar); + } + cache.link(cache.store(jar), worldDirectory.resolve("server.jar")); + } + return new ServerLaunch(Arrays.asList(java.toString(), "-Xms512M", "-Xmx2G", "-jar", "server.jar", "nogui"), worldDirectory); + } + + private void copyProfile(Profile profile, Path worldDirectory) throws IOException { + Set roots = new HashSet<>(Arrays.asList("mods", "plugins", "config", "defaultconfigs", "server.properties", + "bukkit.yml", "spigot.yml", "paper.yml", "permissions.yml", "whitelist.json", "ops.json", "banned-players.json", + "banned-ips.json", "server-icon.png")); + Set jars = new TreeSet<>(); + Path source = profile.getDirectory(); + Files.walkFileTree(source, new SimpleFileVisitor() { + @Override public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attrs) throws IOException { + Path relative = source.relativize(directory); + if (!relative.toString().isEmpty() && !roots.contains(relative.getName(0).toString())) return FileVisitResult.SKIP_SUBTREE; + Path target = worldDirectory.resolve(relative); + if (Files.isSymbolicLink(target)) throw new IOException("Linked world configuration directory: " + relative); + Files.createDirectories(target); + return FileVisitResult.CONTINUE; + } + @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Path relative = source.relativize(file); + if (!roots.contains(relative.getName(0).toString())) return FileVisitResult.CONTINUE; + Path target = worldDirectory.resolve(relative); + if (file.getFileName().toString().endsWith(".jar")) { + cache.link(cache.store(file), target); + jars.add(relative.toString().replace('\\', '/')); + } else { + if (Files.isSymbolicLink(file) || Files.isSymbolicLink(target)) throw new IOException("Linked profile configuration: " + relative); + // World-specific settings survive profile refreshes. + if (!Files.exists(target)) Files.copy(file, target); + } + return FileVisitResult.CONTINUE; + } + }); + Path manifest = worldDirectory.resolve(".autoplug-profile-jars"); + if (Files.isSymbolicLink(manifest)) throw new IOException("Invalid world artifact manifest"); + if (Files.exists(manifest)) { + for (String old : Files.readAllLines(manifest, StandardCharsets.UTF_8)) { + Path path = worldDirectory.resolve(old).normalize(); + if (!jars.contains(old) && path.startsWith(worldDirectory) && old.endsWith(".jar") + && (old.startsWith("mods/") || old.startsWith("plugins/"))) Files.deleteIfExists(path); + } + } + Files.write(manifest, jars, StandardCharsets.UTF_8); + } + + private Path acquire(Profile profile, Path work, Path java) throws Exception { + String software = profile.getLoader().toLowerCase(Locale.ROOT); + if (software.equals("bukkit")) software = "spigot"; + Path output = work.resolve("download.jar"); + if (software.equals("vanilla")) { + JsonObject manifest = json("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json").getAsJsonObject(); + String metadata = null; + for (JsonElement item : manifest.getAsJsonArray("versions")) { + JsonObject version = item.getAsJsonObject(); + if (profile.getGameVersion().equals(version.get("id").getAsString())) metadata = version.get("url").getAsString(); + } + if (metadata == null) throw new IOException("Unknown Minecraft version " + profile.getGameVersion()); + JsonObject details = json(metadata).getAsJsonObject(); + if (!details.getAsJsonObject("downloads").has("server")) throw new IOException("This version has no dedicated server download"); + JsonObject server = details.getAsJsonObject("downloads").getAsJsonObject("server"); + download(server.get("url").getAsString(), output); + String expected = server.get("sha1").getAsString(); + if (!expected.equalsIgnoreCase(sha1(output))) { Files.deleteIfExists(output); throw new IOException("Server download checksum mismatch"); } + } else if (software.equals("fabric") && profile.getLoaderVersion() != null && !profile.getLoaderVersion().isEmpty()) { + JsonArray installers = json("https://meta.fabricmc.net/v2/versions/installer").getAsJsonArray(); + String installer = null; + for (JsonElement entry : installers) if (entry.getAsJsonObject().get("stable").getAsBoolean()) { + installer = entry.getAsJsonObject().get("version").getAsString(); break; + } + if (installer == null) throw new IOException("No stable Fabric installer available"); + download("https://meta.fabricmc.net/v2/versions/loader/" + safe(profile.getGameVersion()) + "/" + + safe(profile.getLoaderVersion()) + "/" + safe(installer) + "/server/jar", output); + } else { + if (!UpdateBuilder.getUpdaterNames().contains(software)) throw new IOException("Unsupported world server software: " + software); + if (software.equals("spigot")) { + // The existing Spigot updater accepts the Java path via a system property. Isolate that + // property in a worker JVM instead of changing it for concurrent AutoPlug operations. + String currentJava = Paths.get(System.getProperty("java.home"), "bin", isWindows() ? "java.exe" : "java").toString(); + String classpath = Arrays.stream(System.getProperty("java.class.path").split(Pattern.quote(File.pathSeparator))) + .map(p -> Paths.get(p).toAbsolutePath().toString()).collect(Collectors.joining(File.pathSeparator)); + Process worker = new ProcessBuilder(currentJava, "-DMCServerUpdater.javaExecutable=" + java, + "-cp", classpath, ServerInstallerWorker.class.getName(), software, profile.getGameVersion(), + work.toString(), output.toString()).directory(work.toFile()).redirectErrorStream(true) + .redirectOutput(work.resolve("build.log").toFile()).start(); + try { + if (!worker.waitFor(15, TimeUnit.MINUTES)) throw new IOException("Spigot build timed out; see " + work.resolve("build.log")); + if (worker.exitValue() != 0) throw new IOException("Spigot build failed; see " + work.resolve("build.log")); + } finally { if (worker.isAlive()) { worker.descendants().forEach(ProcessHandle::destroyForcibly); worker.destroyForcibly(); } } + } else { + UpdateStatus status = UpdateBuilder.updateProject(software).version(profile.getGameVersion()) + .workingDirectory(work.toFile()).outputFile(output.toFile()).debugConsumer(progress::accept).execute(); + if (!status.isSuccessStatus()) throw new IOException("Server installation failed: " + status.getMessage(), status.getThrowable()); + } + } + return output; + } + + private static String safe(String value) { + if (value == null || !value.matches("[A-Za-z0-9_.+-]{1,100}")) throw new IllegalArgumentException("Invalid server version or loader"); + return value; + } + private static boolean isWindows() { return System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("win"); } + private static JsonElement json(String url) throws IOException { + try (InputStream input = connection(url).getInputStream(); Reader reader = new InputStreamReader(input, StandardCharsets.UTF_8)) { + return JsonParser.parseReader(reader); + } + } + private static HttpURLConnection connection(String url) throws IOException { + URL parsed = new URL(url); + if (!"https".equals(parsed.getProtocol())) throw new IOException("Server download must use HTTPS"); + HttpURLConnection connection = (HttpURLConnection) parsed.openConnection(); + connection.setConnectTimeout(15000); connection.setReadTimeout(30000); + connection.setRequestProperty("User-Agent", "AutoPlug-Client"); + return connection; + } + private static void download(String url, Path output) throws IOException { + Path temporary = Files.createTempFile(output.getParent(), "server-", ".part"); + try { + try (InputStream input = connection(url).getInputStream()) { Files.copy(input, temporary, StandardCopyOption.REPLACE_EXISTING); } + Files.move(temporary, output, StandardCopyOption.REPLACE_EXISTING); + } finally { Files.deleteIfExists(temporary); } + } + private static String sha1(Path file) throws Exception { + java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-1"); + try (InputStream in = Files.newInputStream(file)) { byte[] bytes = new byte[65536]; int n; while ((n = in.read(bytes)) != -1) digest.update(bytes, 0, n); } + StringBuilder result = new StringBuilder(); for (byte b : digest.digest()) result.append(String.format("%02x", b & 255)); + return result.toString(); + } +} diff --git a/src/main/java/com/osiris/autoplug/client/worlds/ServerInstaller.java b/src/main/java/com/osiris/autoplug/client/worlds/ServerInstaller.java new file mode 100644 index 00000000..5b4ecd0b --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/worlds/ServerInstaller.java @@ -0,0 +1,9 @@ +package com.osiris.autoplug.client.worlds; + +import com.osiris.autoplug.client.profiles.Profile; +import java.nio.file.Path; + +@FunctionalInterface +public interface ServerInstaller { + ServerLaunch prepare(Profile profile, Path worldDirectory) throws Exception; +} diff --git a/src/main/java/com/osiris/autoplug/client/worlds/ServerInstallerWorker.java b/src/main/java/com/osiris/autoplug/client/worlds/ServerInstallerWorker.java new file mode 100644 index 00000000..66039d42 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/worlds/ServerInstallerWorker.java @@ -0,0 +1,15 @@ +package com.osiris.autoplug.client.worlds; + +import io.github.projectunified.mcserverupdater.UpdateBuilder; +import io.github.projectunified.mcserverupdater.UpdateStatus; + +/** Isolated worker for existing updaters which need their own JVM system properties. */ +public final class ServerInstallerWorker { + private ServerInstallerWorker() { } + public static void main(String[] args) throws Exception { + if (args.length != 4) throw new IllegalArgumentException("Expected software, version, work directory, output file"); + UpdateStatus status = UpdateBuilder.updateProject(args[0]).version(args[1]).workingDirectory(args[2]) + .outputFile(args[3]).debugConsumer(System.out::println).execute(); + if (!status.isSuccessStatus()) throw new IllegalStateException(status.getMessage(), status.getThrowable()); + } +} diff --git a/src/main/java/com/osiris/autoplug/client/worlds/ServerLaunch.java b/src/main/java/com/osiris/autoplug/client/worlds/ServerLaunch.java new file mode 100644 index 00000000..044687f5 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/worlds/ServerLaunch.java @@ -0,0 +1,22 @@ +package com.osiris.autoplug.client.worlds; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** An argument vector, never a shell command. */ +public final class ServerLaunch { + public final List command; + public final Path directory; + public final String stopCommand, restartCommand; + public ServerLaunch(List command, Path directory) { + this(command, directory, "stop", null); + } + public ServerLaunch(List command, Path directory, String stopCommand, String restartCommand) { + if (command == null || command.isEmpty()) throw new IllegalArgumentException("Server command is empty"); + this.command = Collections.unmodifiableList(new ArrayList<>(command)); + this.directory = directory.toAbsolutePath().normalize(); + this.stopCommand = stopCommand; this.restartCommand = restartCommand; + } +} diff --git a/src/main/java/com/osiris/autoplug/client/worlds/ShareResult.java b/src/main/java/com/osiris/autoplug/client/worlds/ShareResult.java new file mode 100644 index 00000000..fb10b841 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/worlds/ShareResult.java @@ -0,0 +1,10 @@ +package com.osiris.autoplug.client.worlds; + +public final class ShareResult { + public final String address; + public final String message; + public ShareResult(String address, String message) { this.address = address; this.message = message; } + public boolean isShared() { return address != null; } + public String getAddress() { return address; } + public String getMessage() { return message; } +} diff --git a/src/main/java/com/osiris/autoplug/client/worlds/SharingService.java b/src/main/java/com/osiris/autoplug/client/worlds/SharingService.java new file mode 100644 index 00000000..eebc6886 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/worlds/SharingService.java @@ -0,0 +1,12 @@ +package com.osiris.autoplug.client.worlds; + +/** Sharing is acquired only on an explicit request and owned by its world session. */ +@FunctionalInterface +public interface SharingService { + Lease open(int loopbackPort) throws Exception; + interface Lease extends AutoCloseable { + String address(); + default boolean isActive() { return true; } + @Override void close() throws Exception; + } +} diff --git a/src/main/java/com/osiris/autoplug/client/worlds/UpnpSharingService.java b/src/main/java/com/osiris/autoplug/client/worlds/UpnpSharingService.java new file mode 100644 index 00000000..5ee2b6ca --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/worlds/UpnpSharingService.java @@ -0,0 +1,272 @@ +package com.osiris.autoplug.client.worlds; + +import org.w3c.dom.*; +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.*; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Optional IGD sharing. The Minecraft process itself always stays on loopback. */ +public final class UpnpSharingService implements SharingService { + public interface Gateway { + InetAddress localAddress(); + String externalAddress() throws Exception; + void addMapping(int externalPort, int internalPort, String description, int leaseSeconds) throws Exception; + void removeMapping(int externalPort, int internalPort, String description) throws Exception; + } + @FunctionalInterface public interface Discovery { Gateway discover() throws Exception; } + private final Discovery discovery; + public UpnpSharingService() { this(new IgdDiscovery()); } + public UpnpSharingService(Discovery discovery) { this.discovery = Objects.requireNonNull(discovery); } + + @Override public Lease open(int loopbackPort) throws Exception { + Gateway gateway = discovery.discover(); + if (gateway == null) throw new IOException("No UPnP internet gateway found"); + String externalAddress = gateway.externalAddress(); + InetAddress address = InetAddress.getByName(externalAddress); + byte[] bytes = address.getAddress(); + if (!(address instanceof Inet4Address) || address.isAnyLocalAddress() || address.isLoopbackAddress() + || address.isSiteLocalAddress() || address.isLinkLocalAddress() + || ((bytes[0] & 255) == 100 && (bytes[1] & 255) >= 64 && (bytes[1] & 255) <= 127)) + throw new IOException("The router has no public IPv4 address (double NAT or carrier-grade NAT)"); + Forwarder forwarder = new Forwarder(gateway.localAddress(), loopbackPort); + String description = "AutoPlug-" + UUID.randomUUID().toString().substring(0, 12); + int externalPort = forwarder.port(); + try { + gateway.addMapping(externalPort, forwarder.port(), description, 3600); + forwarder.start(); + return new MappingLease(gateway, forwarder, externalAddress + ":" + externalPort, externalPort, description); + } catch (Exception e) { + forwarder.close(); + // A lost response can mean AddPortMapping succeeded. Ownership is checked before deletion. + try { gateway.removeMapping(externalPort, forwarder.port(), description); } catch (Exception cleanup) { e.addSuppressed(cleanup); } + throw e; + } + } + + private static final class MappingLease implements Lease { + private final Gateway gateway; + private final Forwarder forwarder; + private final String address; + private final int externalPort; + private final String description; + private final ScheduledExecutorService renewer = Executors.newSingleThreadScheduledExecutor(daemon("world-UPnP-renew")); + private final AtomicBoolean closed = new AtomicBoolean(); + MappingLease(Gateway gateway, Forwarder forwarder, String address, int externalPort, String description) { + this.gateway = gateway; this.forwarder = forwarder; this.address = address; + this.externalPort = externalPort; this.description = description; + renewer.scheduleWithFixedDelay(() -> { + synchronized (this) { + if (closed.get()) return; + try { gateway.addMapping(externalPort, forwarder.port(), description, 3600); } + catch (Exception e) { try { close(); } catch (Exception ignored) { } } + } + }, 1800, 1800, TimeUnit.SECONDS); + } + public String address() { return address; } + public boolean isActive() { return !closed.get(); } + public synchronized void close() throws Exception { + if (!closed.compareAndSet(false, true)) return; + renewer.shutdownNow(); + forwarder.close(); + gateway.removeMapping(externalPort, forwarder.port(), description); + } + } + + /** Bounded connection count; no public listener is opened until an explicit share request. */ + static final class Forwarder implements AutoCloseable { + private final ServerSocket listener; + private final int port; + private final int targetPort; + private final ExecutorService workers = Executors.newCachedThreadPool(daemon("world-share")); + private final Set sockets = ConcurrentHashMap.newKeySet(); + private final Semaphore connections = new Semaphore(32); + Forwarder(InetAddress address, int targetPort) throws IOException { + listener = new ServerSocket(0, 16, address); + port = listener.getLocalPort(); + this.targetPort = targetPort; + } + int port() { return port; } + void start() { + workers.execute(() -> { + while (!listener.isClosed()) { + try { + Socket incoming = listener.accept(); + if (!connections.tryAcquire()) { incoming.close(); continue; } + sockets.add(incoming); + workers.execute(() -> forward(incoming)); + } catch (IOException | RejectedExecutionException e) { close(); return; } + } + }); + } + private void forward(Socket incoming) { + Socket target = new Socket(); + sockets.add(target); + try { + target.connect(new InetSocketAddress("127.0.0.1", targetPort), 3000); + incoming.setSoTimeout(120000); target.setSoTimeout(120000); + Future outward = workers.submit(() -> copy(incoming, target)); + copy(target, incoming); + outward.cancel(true); + } catch (IOException | RejectedExecutionException ignored) { + } finally { + closeSocket(incoming); closeSocket(target); + sockets.remove(incoming); sockets.remove(target); connections.release(); + } + } + private static void copy(Socket from, Socket to) { + try { + byte[] buffer = new byte[16384]; int count; + InputStream input = from.getInputStream(); OutputStream output = to.getOutputStream(); + while ((count = input.read(buffer)) != -1) { output.write(buffer, 0, count); output.flush(); } + to.shutdownOutput(); + } catch (IOException ignored) { closeSocket(from); closeSocket(to); } + } + public void close() { + try { listener.close(); } catch (IOException ignored) { } + for (Socket socket : sockets) closeSocket(socket); + workers.shutdownNow(); + } + private static void closeSocket(Socket socket) { try { socket.close(); } catch (IOException ignored) { } } + } + + private static ThreadFactory daemon(String name) { + return runnable -> { Thread thread = new Thread(runnable, name); thread.setDaemon(true); return thread; }; + } + + static final class IgdDiscovery implements Discovery { + public Gateway discover() throws Exception { + String request = "M-SEARCH * HTTP/1.1\r\nHOST: 239.255.255.250:1900\r\nMAN: \"ssdp:discover\"\r\nMX: 2\r\nST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n\r\n"; + byte[] data = request.getBytes(StandardCharsets.US_ASCII); + try (DatagramSocket socket = new DatagramSocket()) { + socket.setSoTimeout(2000); + socket.send(new DatagramPacket(data, data.length, InetAddress.getByName("239.255.255.250"), 1900)); + long end = System.nanoTime() + TimeUnit.SECONDS.toNanos(3); + while (System.nanoTime() < end) { + DatagramPacket reply = new DatagramPacket(new byte[8192], 8192); + try { socket.receive(reply); } catch (SocketTimeoutException e) { break; } + InetAddress router = reply.getAddress(); + if (!router.isSiteLocalAddress()) continue; + String response = new String(reply.getData(), 0, reply.getLength(), StandardCharsets.US_ASCII); + for (String line : response.split("\r?\n")) { + if (!line.toLowerCase(Locale.ROOT).startsWith("location:")) continue; + try { + URL location = checkedUrl(new URL(line.substring(9).trim()), router); + Document description = xml(http(location, null, null)); + NodeList services = description.getElementsByTagNameNS("*", "service"); + for (int i = 0; i < services.getLength(); i++) { + Element service = (Element) services.item(i); + String type = value(service, "serviceType"); + if (type != null && (type.matches("urn:schemas-upnp-org:service:WANIPConnection:[12]") + || type.equals("urn:schemas-upnp-org:service:WANPPPConnection:1"))) { + URL control = checkedUrl(new URL(location, value(service, "controlURL")), router); + try (DatagramSocket route = new DatagramSocket()) { + route.connect(router, 1900); + return new SoapGateway(control, type, route.getLocalAddress()); + } + } + } + } catch (IOException ignored) { /* Try another advertised gateway. */ } + } + } + } + throw new IOException("No supported UPnP gateway responded"); + } + } + + static final class SoapGateway implements Gateway { + private final URL control; + private final String type; + private final InetAddress local; + SoapGateway(URL control, String type, InetAddress local) { this.control = control; this.type = type; this.local = local; } + public InetAddress localAddress() { return local; } + public String externalAddress() throws Exception { return value(call("GetExternalIPAddress", ""), "NewExternalIPAddress"); } + public void addMapping(int external, int internal, String description, int lease) throws Exception { + String keys = element("NewRemoteHost", "") + element("NewExternalPort", external) + element("NewProtocol", "TCP"); + try { + Element existing = call("GetSpecificPortMappingEntry", keys); + if (!Integer.toString(internal).equals(value(existing, "NewInternalPort")) + || !local.getHostAddress().equals(value(existing, "NewInternalClient")) + || !description.equals(value(existing, "NewPortMappingDescription"))) + throw new IOException("The requested external port already belongs to another mapping"); + } catch (SoapFault e) { if (!"714".equals(e.code)) throw e; } + call("AddPortMapping", keys + element("NewInternalPort", internal) + + element("NewInternalClient", local.getHostAddress()) + element("NewEnabled", 1) + + element("NewPortMappingDescription", description) + element("NewLeaseDuration", lease)); + } + public void removeMapping(int external, int internal, String description) throws Exception { + String keys = element("NewRemoteHost", "") + element("NewExternalPort", external) + element("NewProtocol", "TCP"); + try { + Element existing = call("GetSpecificPortMappingEntry", keys); + if (Integer.toString(internal).equals(value(existing, "NewInternalPort")) + && local.getHostAddress().equals(value(existing, "NewInternalClient")) + && description.equals(value(existing, "NewPortMappingDescription"))) call("DeletePortMapping", keys); + } catch (SoapFault e) { if (!"714".equals(e.code)) throw e; } + } + private Element call(String action, String fields) throws Exception { + String envelope = "" + fields + ""; + Element root = xml(http(control, type + "#" + action, envelope.getBytes(StandardCharsets.UTF_8))).getDocumentElement(); + String error = value(root, "errorCode"); + if (error != null) throw new SoapFault(error, value(root, "errorDescription")); + return root; + } + } + + static final class SoapFault extends IOException { + final String code; + SoapFault(String code, String message) { super("UPnP " + code + ": " + message); this.code = code; } + } + private static URL checkedUrl(URL url, InetAddress router) throws IOException { + if (!"http".equals(url.getProtocol()) || url.getUserInfo() != null + || !InetAddress.getByName(url.getHost()).equals(router)) throw new IOException("Invalid gateway endpoint"); + // Pin the discovered LAN IP rather than resolving an advertised hostname again. + return new URL("http", router.getHostAddress(), url.getPort(), url.getFile()); + } + static Document xml(byte[] bytes) throws Exception { + 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, ""); + return factory.newDocumentBuilder().parse(new ByteArrayInputStream(bytes)); + } + private static String value(Element root, String name) { + NodeList values = root.getElementsByTagNameNS("*", name); + return values.getLength() == 0 ? null : values.item(0).getTextContent().trim(); + } + private static String element(String name, Object value) { + String text = String.valueOf(value).replace("&", "&").replace("<", "<").replace(">", ">"); + return "<" + name + ">" + text + ""; + } + private static byte[] http(URL url, String action, byte[] body) throws IOException { + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setConnectTimeout(2000); connection.setReadTimeout(3000); connection.setInstanceFollowRedirects(false); + try { + if (body != null) { + connection.setRequestMethod("POST"); connection.setDoOutput(true); + connection.setRequestProperty("Content-Type", "text/xml; charset=\"utf-8\""); + connection.setRequestProperty("SOAPAction", "\"" + action + "\""); + try (OutputStream output = connection.getOutputStream()) { output.write(body); } + } + int code = connection.getResponseCode(); + InputStream input = code >= 400 ? connection.getErrorStream() : connection.getInputStream(); + if (input == null || (code != 200 && code != 500)) throw new IOException("Gateway HTTP status " + code); + try (InputStream in = input; ByteArrayOutputStream out = new ByteArrayOutputStream()) { + byte[] buffer = new byte[8192]; int count; + while ((count = in.read(buffer)) != -1) { + if (out.size() + count > 1024 * 1024) throw new IOException("Gateway response too large"); + out.write(buffer, 0, count); + } + return out.toByteArray(); + } + } finally { connection.disconnect(); } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/worlds/VirtualWorld.java b/src/main/java/com/osiris/autoplug/client/worlds/VirtualWorld.java new file mode 100644 index 00000000..40cd0897 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/worlds/VirtualWorld.java @@ -0,0 +1,20 @@ +package com.osiris.autoplug.client.worlds; + +/** A world owns its saves independently of the profiles used to run it. */ +public final class VirtualWorld { + public String id; + public String name; + public String serverProfileId; + public String clientProfileId; + public String thumbnail; + public long createdAt; + public boolean eulaAccepted; + + public String getId() { return id; } + public String getName() { return name; } + public String getServerProfileId() { return serverProfileId; } + public String getClientProfileId() { return clientProfileId; } + public String getThumbnail() { return thumbnail; } + public boolean isEulaAccepted() { return eulaAccepted; } + @Override public String toString() { return name; } +} diff --git a/src/main/java/com/osiris/autoplug/client/worlds/WorldService.java b/src/main/java/com/osiris/autoplug/client/worlds/WorldService.java new file mode 100644 index 00000000..2a43f8b5 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/worlds/WorldService.java @@ -0,0 +1,270 @@ +package com.osiris.autoplug.client.worlds; + +import com.osiris.autoplug.client.profiles.Profile; +import com.osiris.autoplug.client.profiles.ProfileStore; +import com.osiris.autoplug.client.profiles.ProfileType; +import com.osiris.autoplug.client.profiles.ProfileLease; +import com.osiris.autoplug.client.utils.MineStat; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.BindException; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** Local world orchestration independent of AutoPlug's legacy singleton server. */ +public final class WorldService implements AutoCloseable { + @FunctionalInterface public interface Probe { boolean isReady(String host, int port) throws Exception; } + @FunctionalInterface public interface ProcessFactory { Process start(ServerLaunch launch) throws IOException; } + @FunctionalInterface interface LaunchWrapper { ServerLaunch wrap(Profile profile, ServerLaunch launch) throws Exception; } + + private final ProfileStore profiles; + private final WorldStore worlds; + private final ServerInstaller installer; + private final ClientLauncher launcher; + private final SharingService sharing; + private final Probe probe; + private final ProcessFactory processes; + private final Duration readyTimeout; + private final Map sessions = new ConcurrentHashMap<>(); + private final Map preparing = new ConcurrentHashMap<>(); + private final Object lifecycle = new Object(); + private final Thread shutdownHook; + private volatile boolean closed; + private int preferredPort = 25565; + private boolean sharingEnabled = true; + private LaunchWrapper wrapper = (profile, launch) -> launch; + + public WorldService(ProfileStore profiles, WorldStore worlds, ServerInstaller installer, ClientLauncher launcher) { + this(profiles, worlds, installer, launcher, new UpnpSharingService(), + (host, port) -> new MineStat(host, port, 1, MineStat.Request.JSON).isServerUp(), + launch -> new ProcessBuilder(launch.command).directory(launch.directory.toFile()) + .redirectErrorStream(true).redirectOutput(ProcessBuilder.Redirect.appendTo( + launch.directory.resolve("autoplug-server.log").toFile())).start(), Duration.ofMinutes(3)); + wrapper = new AutoPlugWorldBootstrap()::wrap; + } + + public WorldService(ProfileStore profiles, WorldStore worlds, ServerInstaller installer, ClientLauncher launcher, + SharingService sharing, Probe probe, ProcessFactory processes, Duration readyTimeout) { + this.profiles = Objects.requireNonNull(profiles); + this.worlds = Objects.requireNonNull(worlds); + this.installer = Objects.requireNonNull(installer); + this.launcher = Objects.requireNonNull(launcher); + this.sharing = Objects.requireNonNull(sharing); + this.probe = Objects.requireNonNull(probe); + this.processes = Objects.requireNonNull(processes); + this.readyTimeout = Objects.requireNonNull(readyTimeout); + if (readyTimeout.isNegative() || readyTimeout.isZero()) throw new IllegalArgumentException("Readiness timeout must be positive"); + shutdownHook = new Thread(this::close, "AutoPlug-world-cleanup"); + Runtime.getRuntime().addShutdownHook(shutdownHook); + } + + public synchronized WorldSession launch(String worldId, boolean share) throws Exception { + return launch(worldId, share, true); + } + + public synchronized WorldSession launch(String worldId, boolean share, boolean authenticatedAccount) throws Exception { + if (closed) throw new IllegalStateException("World service is closed"); + WorldSession existing = sessions.get(worldId); + if (existing != null && !existing.isClosed()) { + if (share) existing.share(); + return existing; + } + VirtualWorld world = worlds.get(worldId); + if (!world.eulaAccepted) throw new IllegalStateException("Accept the Minecraft EULA for this world before launching: https://aka.ms/MinecraftEULA"); + Profile serverProfile = profiles.get(world.serverProfileId); + Profile clientProfile = profiles.get(world.clientProfileId); + validateProfiles(serverProfile, clientProfile); + // The same client profile cannot be mutated or launched twice at the same time. + for (WorldSession session : sessions.values()) { + if (!session.isClosed() && world.clientProfileId.equals(session.getWorld().clientProfileId)) + throw new IllegalStateException("This client profile is already running another world"); + } + Path directory = worlds.getDirectory(world.id); + Files.createDirectories(directory); + PendingLaunch attempt = new PendingLaunch(world); + synchronized (lifecycle) { + if (closed) throw new IllegalStateException("World service is closed"); + preparing.put(world.id, attempt); + } + ProfileLease worldLease = null; + try { + checkCancelled(attempt); + worldLease = new ProfileLease(directory); + return startWorld(world, serverProfile, clientProfile, directory, worldLease, share, authenticatedAccount, attempt); + } catch (Exception | Error e) { + if (worldLease != null) try { worldLease.close(); } catch (IOException cleanup) { e.addSuppressed(cleanup); } + throw e; + } finally { + synchronized (lifecycle) { preparing.remove(world.id, attempt); } + } + } + + private WorldSession startWorld(VirtualWorld world, Profile serverProfile, Profile clientProfile, Path directory, + ProfileLease worldLease, boolean share, boolean authenticatedAccount, PendingLaunch attempt) throws Exception { + int port = availablePort(preferredPort); + ServerLaunch launch = installer.prepare(serverProfile, directory); + checkCancelled(attempt); + if (!directory.toAbsolutePath().normalize().equals(launch.directory)) throw new IOException("Installer returned a different world directory"); + configureLocalServer(directory, port, authenticatedAccount); + launch = wrapper.wrap(serverProfile, launch); + checkCancelled(attempt); + if (!directory.toAbsolutePath().normalize().equals(launch.directory)) throw new IOException("Wrapper returned a different world directory"); + ManagedServer server = new ManagedServer(processes.start(launch), 30000, launch.stopCommand, launch.restartCommand); + attempt.server = server; + Process client = null; + WorldSession session = null; + try { + checkCancelled(attempt); + long deadline = System.nanoTime() + readyTimeout.toNanos(); + boolean ready = false; + while (System.nanoTime() < deadline) { + checkCancelled(attempt); + if (!server.isAlive()) throw new IOException("Server exited before becoming ready; see " + directory.resolve("autoplug-server.log")); + if (probe.isReady("127.0.0.1", port)) { ready = true; break; } + Thread.sleep(200); + } + if (!ready) throw new IOException("Server readiness timed out; see " + directory.resolve("autoplug-server.log")); + checkCancelled(attempt); + client = launcher.launch(clientProfile, "127.0.0.1", port); + if (client == null) throw new IOException("Client launcher did not return its process"); + checkCancelled(attempt); + Process ownedClient = client; + synchronized (lifecycle) { + checkCancelled(attempt); + session = new WorldSession(world, server, client, port, sharing, worldLease, authenticatedAccount, sharingEnabled, + () -> sessions.computeIfPresent(world.id, (id, value) -> value.client() == ownedClient ? null : value)); + sessions.put(world.id, session); + } + session.monitor(); + if (share) session.share(); + checkCancelled(attempt); + return session; + } catch (Exception | Error e) { + if (client != null) client.destroyForcibly(); + // Preserve cancellation while still allowing the graceful server stop to wait. + boolean interrupted = Thread.interrupted(); + try { if (session != null) session.close(); else server.close(); } + finally { if (interrupted) Thread.currentThread().interrupt(); } + throw e; + } + } + + private static final class PendingLaunch { + final VirtualWorld world; + final Thread thread = Thread.currentThread(); + volatile boolean cancelled; + volatile ManagedServer server; + PendingLaunch(VirtualWorld world) { this.world = world; } + void cancel() { cancelled = true; thread.interrupt(); } + } + private void checkCancelled(PendingLaunch attempt) throws InterruptedException { + if (closed || attempt.cancelled || Thread.currentThread().isInterrupted()) + throw new InterruptedException("World launch was cancelled"); + } + + private static void validateProfiles(Profile server, Profile client) { + if (server.getType() == ProfileType.MODS) throw new IllegalArgumentException("A world requires a plugins or mods-server profile"); + if (server.migrationPending || client.migrationPending) throw new IllegalArgumentException("Review the profile migration summary before launching"); + if (client.getType() != ProfileType.MODS || client.isTemplate()) throw new IllegalArgumentException("Select a playable client profile, not a template"); + if (!Objects.equals(server.getGameVersion(), client.getGameVersion())) throw new IllegalArgumentException("Server and client game versions must match"); + if (server.getType() == ProfileType.MODS_SERVER && !"VANILLA".equals(server.getLoader()) + && !Objects.equals(server.getLoader(), client.getLoader())) + throw new IllegalArgumentException("Server and client mod loaders must match"); + } + + private static void configureLocalServer(Path directory, int port, boolean authenticatedAccount) throws IOException { + Path file = directory.resolve("server.properties"); + Properties properties = new Properties(); + if (Files.isSymbolicLink(file)) throw new IOException("server.properties must be owned by this world"); + if (Files.exists(file)) try (InputStream in = Files.newInputStream(file)) { properties.load(in); } + properties.setProperty("server-ip", "127.0.0.1"); + properties.setProperty("server-port", Integer.toString(port)); + properties.setProperty("enable-rcon", "false"); + properties.setProperty("enable-query", "false"); + properties.setProperty("online-mode", Boolean.toString(authenticatedAccount)); + // Level paths must remain in this world's own directory. + properties.setProperty("level-name", "world"); + try (OutputStream out = Files.newOutputStream(file)) { properties.store(out, "AutoPlug local virtual world"); } + Path eula = directory.resolve("eula.txt"); + if (Files.isSymbolicLink(eula)) throw new IOException("eula.txt must be owned by this world"); + Files.write(eula, "eula=true\n".getBytes(StandardCharsets.UTF_8)); + } + + public ShareResult shareWorld(String worldId) { + WorldSession session = sessions.get(worldId); + return session == null ? new ShareResult(null, "Launch this world before sharing it.") : session.share(); + } + /** Applies to subsequent launches; running worlds keep their chosen port. */ + public synchronized void setPreferredPort(int port) { + if (port < 1 || port > 65535) throw new IllegalArgumentException("Preferred port must be between 1 and 65535"); + preferredPort = port; + } + /** Disabling UPnP also releases any mappings owned by already-running worlds. */ + public synchronized void setSharingEnabled(boolean enabled) { + sharingEnabled = enabled; + for (WorldSession session : sessions.values()) session.setSharingEnabled(enabled); + } + private static int availablePort(int preferred) throws IOException { + InetAddress loopback = InetAddress.getByName("127.0.0.1"); + try (ServerSocket socket = new ServerSocket(preferred, 1, loopback)) { return socket.getLocalPort(); } + catch (BindException busy) { + try (ServerSocket socket = new ServerSocket(0, 1, loopback)) { return socket.getLocalPort(); } + } + } + public void stopWorld(String worldId) { + PendingLaunch attempt; WorldSession session; + synchronized (lifecycle) { + attempt = preparing.get(worldId); session = sessions.get(worldId); + if (attempt != null) attempt.cancel(); + } + if (session != null) session.close(); + else if (attempt != null && attempt.server != null) attempt.server.close(); + } + public void serverCommand(String worldId, String command) throws IOException { running(worldId).server().command(command); } + public void restartWorld(String worldId) throws IOException { running(worldId).server().restart(); } + private WorldSession running(String worldId) throws IOException { + WorldSession session = sessions.get(worldId); + if (session == null || session.isClosed()) throw new IOException("Launch this world before using its console"); + return session; + } + /** A bounded tail of this world's own AutoPlug and Minecraft output. */ + public String serverLog(String worldId) throws IOException { + worlds.get(worldId); Path file = worlds.getDirectory(worldId).resolve("autoplug-server.log"); + if (Files.isSymbolicLink(file)) throw new IOException("World console log must not be linked"); + if (!Files.exists(file)) return "No console output yet. Launch this world first."; + try (java.io.RandomAccessFile log = new java.io.RandomAccessFile(file.toFile(), "r")) { + long length = log.length(), start = Math.max(0, length - 65536); log.seek(start); + byte[] bytes = new byte[(int) (length - start)]; log.readFully(bytes); + String text = new String(bytes, StandardCharsets.UTF_8); + if (start > 0 && text.indexOf('\n') >= 0) text = text.substring(text.indexOf('\n') + 1); + return text; + } + } + public Collection activeSessions() { return Collections.unmodifiableList(new ArrayList<>(sessions.values())); } + public boolean isProfileInUse(String profileId) { + return preparing.values().stream().anyMatch(p -> profileId.equals(p.world.clientProfileId) || profileId.equals(p.world.serverProfileId)) + || sessions.values().stream().anyMatch(s -> !s.isClosed() && (profileId.equals(s.getWorld().clientProfileId) || profileId.equals(s.getWorld().serverProfileId))); + } + @Override public void close() { + List pending; List running; + synchronized (lifecycle) { + if (closed) return; + closed = true; + pending = new ArrayList<>(preparing.values()); running = new ArrayList<>(sessions.values()); + for (PendingLaunch attempt : pending) attempt.cancel(); + } + for (WorldSession session : running) session.close(); + for (PendingLaunch attempt : pending) if (attempt.server != null) attempt.server.close(); + if (Thread.currentThread() != shutdownHook) { + try { Runtime.getRuntime().removeShutdownHook(shutdownHook); } + catch (IllegalStateException ignored) { /* JVM is already shutting down. */ } + } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/worlds/WorldSession.java b/src/main/java/com/osiris/autoplug/client/worlds/WorldSession.java new file mode 100644 index 00000000..c0361e8a --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/worlds/WorldSession.java @@ -0,0 +1,108 @@ +package com.osiris.autoplug.client.worlds; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** All resources opened for a world have one idempotent owner. */ +public final class WorldSession implements AutoCloseable { + private final VirtualWorld world; + private final ManagedServer server; + private final Process client; + private final int port; + private final SharingService sharing; + private final AutoCloseable worldLease; + private final boolean authenticatedAccount; + private boolean sharingEnabled; + private final Runnable onClose; + private final AtomicBoolean closed = new AtomicBoolean(); + private SharingService.Lease shareLease; + private ShareResult shareResult = new ShareResult(null, "Local world; sharing is off."); + + WorldSession(VirtualWorld world, ManagedServer server, Process client, int port, + SharingService sharing, AutoCloseable worldLease, boolean authenticatedAccount, boolean sharingEnabled, Runnable onClose) { + this.world = world; + this.server = server; + this.client = client; + this.port = port; + this.sharing = sharing; + this.worldLease = worldLease; + this.authenticatedAccount = authenticatedAccount; + this.sharingEnabled = sharingEnabled; + this.onClose = onClose; + } + + void monitor() { + client.onExit().thenRun(this::close); + server.process().onExit().thenRun(this::close); + } + + public VirtualWorld getWorld() { return world; } + public Process client() { return client; } + public ManagedServer server() { return server; } + public int getPort() { return port; } + public boolean isClosed() { return closed.get(); } + public synchronized ShareResult getShareResult() { + if (shareLease != null && !shareLease.isActive()) + return new ShareResult(null, "Sharing expired or the gateway stopped responding. Share again to retry."); + return shareResult; + } + + public synchronized ShareResult share() { + if (closed.get()) return new ShareResult(null, "This world is no longer running."); + if (!sharingEnabled) { + shareResult = new ShareResult(null, "UPnP sharing is disabled in Settings. Enable it and choose Share again, or use a TCP tunnel to 127.0.0.1:" + port + "."); + return shareResult; + } + if (!authenticatedAccount) { + shareResult = new ShareResult(null, "Offline worlds stay local. Sign in with a licensed Microsoft account and relaunch this world before sharing."); + return shareResult; + } + if (shareLease != null && shareLease.isActive()) return shareResult; + shareLease = null; + try { + shareLease = sharing.open(port); + shareResult = new ShareResult(shareLease.address(), "Sharing is active until this world closes."); + } catch (Exception e) { + shareResult = new ShareResult(null, "Automatic sharing unavailable: " + e.getMessage() + + ". The world remains local. Use a TCP tunnel to 127.0.0.1:" + port + + " or configure a LAN forwarding endpoint before manual port forwarding."); + } + return shareResult; + } + + synchronized void setSharingEnabled(boolean enabled) { + sharingEnabled = enabled; + if (!enabled && shareLease != null) { + try { shareLease.close(); } + catch (Exception e) { shareResult = new ShareResult(null, "Sharing disabled; gateway cleanup failed: " + e.getMessage()); shareLease = null; return; } + shareLease = null; + shareResult = new ShareResult(null, "UPnP sharing was disabled in Settings. The world remains local."); + } + } + + @Override public void close() { + if (!closed.compareAndSet(false, true)) return; + synchronized (this) { + if (shareLease != null) { + try { shareLease.close(); } + catch (Exception e) { shareResult = new ShareResult(null, "Sharing cleanup failed: " + e.getMessage()); } + shareLease = null; + } + } + try { + if (client.isAlive()) { + client.destroy(); + if (!client.waitFor(2000, TimeUnit.MILLISECONDS)) client.destroyForcibly(); + } + } catch (InterruptedException e) { + client.destroyForcibly(); + Thread.currentThread().interrupt(); + } finally { + try { server.close(); } + finally { + try { worldLease.close(); } catch (Exception ignored) { } + onClose.run(); + } + } + } +} diff --git a/src/main/java/com/osiris/autoplug/client/worlds/WorldStore.java b/src/main/java/com/osiris/autoplug/client/worlds/WorldStore.java new file mode 100644 index 00000000..9097ddbe --- /dev/null +++ b/src/main/java/com/osiris/autoplug/client/worlds/WorldStore.java @@ -0,0 +1,173 @@ +package com.osiris.autoplug.client.worlds; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import java.io.IOException; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** Persistent metadata; never places a world save inside a reusable profile. */ +public final class WorldStore { + private static final class LocalReference { + String id, localId, directory; + } + private final Path root; + private final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + public WorldStore(Path root) throws IOException { + this.root = root.toAbsolutePath().normalize(); + Files.createDirectories(this.root); + } + + public synchronized VirtualWorld create(String name, String serverProfileId, String clientProfileId) throws IOException { + return create(name, serverProfileId, clientProfileId, false); + } + + public synchronized VirtualWorld create(String name, String serverProfileId, String clientProfileId, + boolean eulaAccepted) throws IOException { + if (name == null || name.trim().isEmpty()) throw new IllegalArgumentException("World name is required"); + if (serverProfileId == null || clientProfileId == null) throw new IllegalArgumentException("Both profiles are required"); + VirtualWorld world = new VirtualWorld(); + world.id = UUID.randomUUID().toString(); + world.name = name.trim(); + world.serverProfileId = serverProfileId; + world.clientProfileId = clientProfileId; + world.createdAt = System.currentTimeMillis(); + world.eulaAccepted = eulaAccepted; + Files.createDirectories(getDirectory(world.id)); + save(world); + return world; + } + + public synchronized List list() throws IOException { + List worlds = new ArrayList<>(); + try (Stream paths = Files.list(root)) { + for (Path path : paths.filter(p -> Files.isDirectory(p, LinkOption.NOFOLLOW_LINKS)).collect(Collectors.toList())) { + if (Files.isRegularFile(path.resolve("world.json"), LinkOption.NOFOLLOW_LINKS)) + worlds.add(get(path.getFileName().toString())); + } + } + worlds.sort(Comparator.comparingLong(w -> w.createdAt)); + return worlds; + } + + /** Register ownership without moving, copying, linking or rewriting the user's save. */ + public synchronized LocalWorld registerLocal(LocalWorld world) throws IOException { + return ownedLocal(world, true); + } + + /** Discovery only consults an existing entry, so opening the browser remains read-only. */ + public synchronized LocalWorld ownedLocal(LocalWorld world, boolean create) throws IOException { + String source = world.directory.toRealPath().toString(); + String id = "world-" + UUID.nameUUIDFromBytes(source.getBytes(StandardCharsets.UTF_8)); + Path directory = metadataDirectory(id), file = directory.resolve("local-world.json"); + if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) { + LocalReference reference = readLocal(id); + if (!source.equals(reference.directory) || !("local:" + world.saveName()).equals(reference.localId)) + throw new IOException("Local world ownership does not match the save"); + return world.withId(id); + } + if (!create) return world; + if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) { + // An interrupted metadata write may leave an empty directory. Reuse it, + // but never adopt a directory containing unrecognized user data. + try (DirectoryStream entries = Files.newDirectoryStream(directory)) { + if (entries.iterator().hasNext()) throw new IOException("World ownership directory already contains unrecognized data"); + } + } else Files.createDirectory(directory); + LocalReference reference = new LocalReference(); reference.id = id; + reference.localId = "local:" + world.saveName(); reference.directory = source; + Path temporary = Files.createTempFile(directory, "local-world-", ".tmp"); + try { + Files.write(temporary, gson.toJson(reference).getBytes(StandardCharsets.UTF_8)); + try { Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE); } + catch (AtomicMoveNotSupportedException e) { Files.move(temporary, file); } + } finally { Files.deleteIfExists(temporary); } + return world.withId(id); + } + + public synchronized LocalWorld getLocal(String id, LocalWorldStore localWorlds) throws IOException { + if (id != null && id.startsWith("local:")) return ownedLocal(localWorlds.get(id), false); + LocalReference reference = readLocal(id); + LocalWorld world = localWorlds.get(reference.localId); + if (!world.directory.toRealPath().toString().equals(reference.directory)) throw new IOException("Local world moved or reference is invalid"); + LocalWorld owned = ownedLocal(world, false); + if (!owned.id.equals(id)) throw new IOException("Local world ownership identity does not match"); + return owned; + } + + private LocalReference readLocal(String id) throws IOException { + if (id == null || !id.matches("world-[a-f0-9-]{36}")) throw new IOException("Invalid owned local world identifier"); + Path file = metadataDirectory(id).resolve("local-world.json"); + if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(file) || Files.size(file) > 16384) + throw new IOException("Missing or invalid local world ownership metadata"); + try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { + LocalReference reference = gson.fromJson(reader, LocalReference.class); + if (reference == null || !id.equals(reference.id) || reference.localId == null || reference.directory == null) + throw new IOException("Invalid local world ownership metadata"); + return reference; + } catch (com.google.gson.JsonParseException e) { throw new IOException("Invalid local world ownership metadata", e); } + } + + public synchronized VirtualWorld get(String id) throws IOException { + Path file = metadataDirectory(id).resolve("world.json"); + try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { + VirtualWorld world = gson.fromJson(reader, VirtualWorld.class); + if (world == null || !id.equals(world.id) || world.serverProfileId == null || world.clientProfileId == null) + throw new IOException("Invalid world metadata: " + file); + Path icon = getDirectory(id).resolve("world/icon.png"); + if (!Files.isRegularFile(icon, LinkOption.NOFOLLOW_LINKS)) icon = getDirectory(id).resolve("server-icon.png"); + world.thumbnail = Files.isRegularFile(icon, LinkOption.NOFOLLOW_LINKS) ? icon.toString() : null; + return world; + } catch (com.google.gson.JsonParseException e) { + throw new IOException("Invalid world metadata: " + file, e); + } + } + + public synchronized void setEulaAccepted(String id, boolean accepted) throws IOException { + VirtualWorld world = get(id); + world.eulaAccepted = accepted; + save(world); + } + + public synchronized void save(VirtualWorld world) throws IOException { + Path directory = metadataDirectory(world.id); + Files.createDirectories(directory); + Path temporary = Files.createTempFile(directory, "world-", ".tmp"); + try { + Files.write(temporary, gson.toJson(world).getBytes(StandardCharsets.UTF_8)); + try { Files.move(temporary, directory.resolve("world.json"), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); } + catch (AtomicMoveNotSupportedException e) { Files.move(temporary, directory.resolve("world.json"), StandardCopyOption.REPLACE_EXISTING); } + } finally { Files.deleteIfExists(temporary); } + } + + /** Call only after closing any session using this world. */ + public synchronized void delete(String id) throws IOException { + Path directory = metadataDirectory(id); + if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) return; + Path trash = root.resolve(".trash"); + Files.createDirectories(trash); + Files.move(directory, trash.resolve(id + "-" + System.currentTimeMillis())); + } + + public Path getDirectory(String id) throws IOException { + Path directory = metadataDirectory(id).resolve("server"); + if (Files.isSymbolicLink(directory)) throw new IOException("World directory must not be a symbolic link"); + return directory; + } + + private Path metadataDirectory(String id) throws IOException { + if (id == null || !id.matches("[a-zA-Z0-9_-]{1,80}")) throw new IllegalArgumentException("Invalid world id"); + Path directory = root.resolve(id).normalize(); + if (!directory.startsWith(root) || Files.isSymbolicLink(directory)) throw new IOException("Unsafe world directory"); + if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS) + && (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS) || !directory.toRealPath().equals(root.toRealPath().resolve(id)))) + throw new IOException("Linked or external world ownership directories are not supported"); + return directory; + } +} diff --git a/src/main/java/com/osiris/autoplug/legacy/LegacyWorldAgent.java b/src/main/java/com/osiris/autoplug/legacy/LegacyWorldAgent.java new file mode 100644 index 00000000..0377ebd0 --- /dev/null +++ b/src/main/java/com/osiris/autoplug/legacy/LegacyWorldAgent.java @@ -0,0 +1,156 @@ +package com.osiris.autoplug.legacy; + +import java.io.InputStream; +import java.io.IOException; +import java.lang.instrument.Instrumentation; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.LinkOption; +import java.nio.file.Paths; +import java.util.Properties; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Java 8 bootstrap for unmodified, pre-Quick-Play vanilla clients. No transformer, + * replacement game classes, save conversion, or input/keyboard automation is used. + * Member names come from exact-version publisher mappings or a pinned, bytecode-verified adapter. + */ +public final class LegacyWorldAgent { + private LegacyWorldAgent() { } + + public static void premain(String configuration, Instrumentation instrumentation) throws Exception { + final Properties names = new Properties(); + try (InputStream input = Files.newInputStream(Paths.get(configuration))) { names.load(input); } + for (String key : new String[]{"minecraft", "instance", "screen", "title", "load", "world", "gameDir"}) + if (names.getProperty(key, "").isEmpty()) throw new IllegalArgumentException("Missing legacy launch setting: " + key); + String world = names.getProperty("world"); + if (world.equals(".") || world.equals("..") || world.indexOf('/') >= 0 || world.indexOf('\\') >= 0 || world.indexOf(':') >= 0) + throw new IllegalArgumentException("Invalid save folder."); + validateSave(names); + final AtomicBoolean finished = new AtomicBoolean(); + final AtomicBoolean queued = new AtomicBoolean(); + Thread watcher = new Thread(() -> { + long deadline = System.nanoTime() + 300_000_000_000L; + try { + Class minecraft = null; + while (!finished.get() && System.nanoTime() < deadline) { + // Do not proactively load game classes. Discover classes loaded by + // the normal entry point; reflective access also waits for class init. + if (minecraft == null) for (Class loaded : instrumentation.getAllLoadedClasses()) + if (loaded.getName().equals(names.getProperty("minecraft"))) { minecraft = loaded; break; } + if (minecraft != null) { + Object instance = method(minecraft, names.getProperty("instance")).invoke(null); + if (instance != null && queued.compareAndSet(false, true)) { + Runnable action = () -> { + try { + if (!finished.get()) tryOpen(instance, names, () -> { + if (!finished.compareAndSet(false, true)) throw new IllegalStateException("Automatic world entry was cancelled before handoff."); + }); + } catch (Throwable failure) { + finished.set(true); + reportFailure(failure); + } finally { queued.set(false); } + }; + if (names.getProperty("scheduler") != null) + method(minecraft, names.getProperty("scheduler"), Runnable.class).invoke(instance, action); + else if (instance instanceof Executor) ((Executor) instance).execute(action); + else throw new IllegalStateException("Minecraft does not expose the expected main-thread executor."); + } + } + Thread.sleep(250); + } + if (finished.compareAndSet(false, true)) { + System.err.println("[AutoPlug] Automatic world entry timed out while waiting for Minecraft's title screen. Check the game's startup or consent screen; no save was changed by the helper."); + } + } catch (Throwable failure) { finished.set(true); reportFailure(failure); } + }, "AutoPlug legacy world entry"); + watcher.setDaemon(true); + watcher.start(); + System.out.println("[AutoPlug] Automatic world entry is waiting for Minecraft to finish loading."); + } + + /** Must run on the Minecraft executor. Returning false preserves startup/consent screens. */ + public static boolean tryOpen(Object minecraft, Properties names) throws Exception { + return tryOpen(minecraft, names, () -> { }); + } + + private static boolean tryOpen(Object minecraft, Properties names, Runnable beforeLoad) throws Exception { + Field screenField = minecraft.getClass().getDeclaredField(names.getProperty("screen")); + screenField.setAccessible(true); + Object screen = screenField.get(minecraft); + if (screen == null || !screen.getClass().getName().equals(names.getProperty("title"))) return false; + if (names.getProperty("overlay") != null && method(minecraft.getClass(), names.getProperty("overlay")).invoke(minecraft) != null) return false; + // Recheck after a potentially long game startup. A removed or replaced save + // must not become a newly created world or an external-directory write. + validateSave(names); + String flow = names.getProperty("flows", ""); + if (names.getProperty("levelSource") != null) { + Object source = method(minecraft.getClass(), names.getProperty("levelSource")).invoke(minecraft); + String folder = names.getProperty("world"); + if (!Boolean.TRUE.equals(method(source.getClass(), names.getProperty("levelExists"), String.class).invoke(source, folder))) + throw new IOException("The selected save no longer exists; refusing to create a replacement."); + Object summaries = method(source.getClass(), names.getProperty("levelList")).invoke(source); + String displayName = null; + for (Object summary : (Iterable) summaries) { + if (folder.equals(method(summary.getClass(), names.getProperty("levelId")).invoke(summary))) { + displayName = (String) method(summary.getClass(), names.getProperty("levelName")).invoke(summary); break; + } + } + if (displayName == null) throw new IOException("Minecraft could not read the selected save's original name."); + Class settings = Class.forName(names.getProperty("settingsType"), false, minecraft.getClass().getClassLoader()); + // 1.14/1.15 write this display name back. Match WorldListEntry exactly; + // substituting the directory name would silently rename the save. + beforeLoad.run(); + method(minecraft.getClass(), names.getProperty("load"), String.class, String.class, settings).invoke(minecraft, folder, displayName, null); + } else if (flow.isEmpty()) { + beforeLoad.run(); + method(minecraft.getClass(), names.getProperty("load"), String.class).invoke(minecraft, names.getProperty("world")); + } else { + Object opener = method(minecraft.getClass(), flow).invoke(minecraft); + if (names.getProperty("screenType") == null) { + beforeLoad.run(); + method(opener.getClass(), names.getProperty("load"), String.class).invoke(opener, names.getProperty("world")); + } else { + Class screenType = Class.forName(names.getProperty("screenType"), false, minecraft.getClass().getClassLoader()); + beforeLoad.run(); + method(opener.getClass(), names.getProperty("load"), screenType, String.class).invoke(opener, screen, names.getProperty("world")); + } + } + System.out.println("[AutoPlug] Requested the selected save through Minecraft's normal integrated-world loading flow."); + return true; + } + + private static Method method(Class owner, String name, Class... parameters) throws Exception { + for (Class current = owner; current != null; current = current.getSuperclass()) { + try { + Method result = current.getDeclaredMethod(name, parameters); + result.setAccessible(true); + return result; + } catch (NoSuchMethodException ignored) { } + } + throw new NoSuchMethodException(owner.getName() + "." + name); + } + + private static void validateSave(Properties names) throws IOException { + Path game = Paths.get(names.getProperty("gameDir")).toAbsolutePath().normalize(); + Path saves = game.resolve("saves"), world = saves.resolve(names.getProperty("world")); + Path level = world.resolve("level.dat"); + if (!Files.isDirectory(saves, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(saves) + || !saves.toRealPath().equals(game.toRealPath().resolve("saves")) + || !Files.isDirectory(world, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(world) + || !world.toRealPath().equals(saves.toRealPath().resolve(world.getFileName())) + || !Files.isRegularFile(level, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(level) + || !level.toRealPath().equals(world.toRealPath().resolve("level.dat"))) + throw new IOException("The original save is missing or linked; automatic world entry was cancelled."); + } + + private static void reportFailure(Throwable failure) { + Throwable cause = failure instanceof InvocationTargetException ? ((InvocationTargetException) failure).getTargetException() : failure; + System.err.println("[AutoPlug] Automatic world entry failed: " + cause.getClass().getSimpleName() + ": " + cause.getMessage()); + cause.printStackTrace(System.err); + } +} diff --git a/src/main/resources/launcher/legacy/1.12.2.properties b/src/main/resources/launcher/legacy/1.12.2.properties new file mode 100644 index 00000000..3df5f830 --- /dev/null +++ b/src/main/resources/launcher/legacy/1.12.2.properties @@ -0,0 +1,17 @@ +# Narrow compatibility adapter verified against the original Mojang client bytecode. +# MCP SRG names: https://maven.minecraftforge.net/de/oceanlabs/mcp/mcp/1.12.2/mcp-1.12.2-srg.zip +# Friendly names: https://maven.minecraftforge.net/de/oceanlabs/mcp/mcp_stable/39-1.12/mcp_stable-39-1.12.zip +# No game classes are redistributed. See legacy-adapter-1.12.2-evidence.txt in tests. +clientSha1=0f275bc1547d01fa5f56ba34bdc87d981ee12daf +minecraft=bib +instance=z +screen=m +title=blr +scheduler=a +load=a +levelSource=g +settingsType=amx +levelList=b +levelExists=f +levelId=a +levelName=b diff --git a/src/main/resources/launcher/legacy/1.13.2.properties b/src/main/resources/launcher/legacy/1.13.2.properties new file mode 100644 index 00000000..f70424cf --- /dev/null +++ b/src/main/resources/launcher/legacy/1.13.2.properties @@ -0,0 +1,15 @@ +# Verified Mojang client bytecode + Forge MCPConfig 1.13.2 TSRG mappings. +# See src/test/resources/launcher/legacy-adapter-1.13.2-evidence.txt for sources. +clientSha1=30bfe37a8db404db11c7edf02cb5165817afb4d9 +minecraft=cft +instance=s +screen=m +title=cki +scheduler=a +load=a +levelSource=g +settingsType=ayd +levelList=b +levelExists=f +levelId=a +levelName=b diff --git a/src/main/resources/launcher/legacy/1.7.10.properties b/src/main/resources/launcher/legacy/1.7.10.properties new file mode 100644 index 00000000..fec7f02b --- /dev/null +++ b/src/main/resources/launcher/legacy/1.7.10.properties @@ -0,0 +1,15 @@ +# Verified Mojang client bytecode + Forge MCP 1.7.10 SRG mappings. +# See src/test/resources/launcher/legacy-adapter-1.7.10-evidence.txt for sources. +clientSha1=e80d9b3bf5085002218d4be59e668bac718abbc6 +minecraft=bao +instance=B +screen=n +title=bee +scheduler=a +load=a +levelSource=d +settingsType=ahj +levelList=b +levelExists=f +levelId=a +levelName=b diff --git a/src/main/resources/launcher/legacy/1.8.9.properties b/src/main/resources/launcher/legacy/1.8.9.properties new file mode 100644 index 00000000..c94dcac6 --- /dev/null +++ b/src/main/resources/launcher/legacy/1.8.9.properties @@ -0,0 +1,15 @@ +# Verified Mojang client bytecode + Forge MCP 1.8.9 SRG mappings. +# See src/test/resources/launcher/legacy-adapter-1.8.9-evidence.txt for sources. +clientSha1=3870888a6c3d349d3771a3e9d16c9bf5e076b908 +minecraft=ave +instance=A +screen=m +title=aya +scheduler=a +load=a +levelSource=f +settingsType=adp +levelList=b +levelExists=f +levelId=a +levelName=b diff --git a/src/test/java/com/osiris/autoplug/client/ServerProcessObservationTest.java b/src/test/java/com/osiris/autoplug/client/ServerProcessObservationTest.java new file mode 100644 index 00000000..e8f0dbd1 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/ServerProcessObservationTest.java @@ -0,0 +1,69 @@ +package com.osiris.autoplug.client; + +import org.junit.jupiter.api.Test; +import java.io.*; +import java.lang.reflect.Field; +import java.util.concurrent.atomic.AtomicBoolean; +import static org.junit.jupiter.api.Assertions.*; + +class ServerProcessObservationTest { + @Test void staleExitCannotInspectOrStopTheReplacementProcess() throws Exception { + Field field = Server.class.getDeclaredField("process"); field.setAccessible(true); + Object original = field.get(null); + StubProcess stopped = new StubProcess(false, 7), replacement = new StubProcess(true, 0); + try { + field.set(null, stopped); + assertEquals(Integer.valueOf(7), Server.observedExitCode(stopped)); + // The checker captured stopped, then .restart replaced the global process before exit handling. + field.set(null, replacement); + assertNull(Server.observedExitCode(stopped)); + assertNull(Server.observedExitCode(replacement)); + assertFalse(Server.requestAutomaticStop(stopped)); + assertFalse(Server.requestAutomaticStop(replacement)); + assertEquals(0, replacement.exitReads, "A live replacement has no exit value to inspect"); + assertFalse(replacement.destroyed); + } finally { field.set(null, original); } + } + + @Test void automaticExitReservationBlocksNewStartAfterLifecycleLockIsReleased() throws Exception { + Field field = Server.class.getDeclaredField("process"); field.setAccessible(true); + Field stopping = Server.class.getDeclaredField("autoStopRequested"); stopping.setAccessible(true); + Object original = field.get(null); boolean originalStopping = stopping.getBoolean(null); + StubProcess stopped = new StubProcess(false, 0); + try { + field.set(null, stopped); stopping.setBoolean(null, false); + assertTrue(Server.requestAutomaticStop(stopped)); + assertFalse(Thread.holdsLock(Server.class)); + Server.start(); Server.restart(); + assertSame(stopped, field.get(null), "Releasing the monitor before System.exit must not allow a replacement server"); + assertFalse(Server.requestAutomaticStop(stopped), "The exit reservation is idempotent"); + assertFalse(stopped.destroyed); + } finally { field.set(null, original); stopping.setBoolean(null, originalStopping); } + } + + @Test void intentionalRestartSuppressesStoppedGenerationUntilNewProcessStarts() throws Exception { + Field field = Server.class.getDeclaredField("process"); field.setAccessible(true); + Field restartingField = Server.class.getDeclaredField("isRestarting"); restartingField.setAccessible(true); + AtomicBoolean restarting = (AtomicBoolean) restartingField.get(null); + Object original = field.get(null); boolean originallyRestarting = restarting.get(); + StubProcess stopped = new StubProcess(false, 0); + try { + field.set(null, stopped); restarting.set(true); + assertNull(Server.observedExitCode(stopped)); assertEquals(0, stopped.exitReads); + restarting.set(false); + assertEquals(Integer.valueOf(0), Server.observedExitCode(stopped)); + } finally { field.set(null, original); restarting.set(originallyRestarting); } + } + + private static class StubProcess extends Process { + final boolean alive; final int code; int exitReads; boolean destroyed; + StubProcess(boolean alive, int code) { this.alive = alive; this.code = code; } + @Override public boolean isAlive() { return alive; } + @Override public int exitValue() { exitReads++; if (alive) throw new IllegalThreadStateException("Still running"); return code; } + @Override public int waitFor() { return code; } + @Override public void destroy() { destroyed = true; } + @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]); } + } +} diff --git a/src/test/java/com/osiris/autoplug/client/browser/MineStatJsonTest.java b/src/test/java/com/osiris/autoplug/client/browser/MineStatJsonTest.java new file mode 100644 index 00000000..e3a25768 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/browser/MineStatJsonTest.java @@ -0,0 +1,67 @@ +package com.osiris.autoplug.client.browser; + +import com.osiris.autoplug.client.utils.MineStat; +import org.junit.jupiter.api.Test; +import java.io.*; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.concurrent.*; +import static org.junit.jupiter.api.Assertions.*; + +class MineStatJsonTest { + @Test void readsStringAndNestedArrayMotdsWithUnicodeAndProtocol() throws Exception { + for (String description : new String[]{"\"§aWelcome 世界\"", "{\"text\":\"§aWelcome \",\"extra\":[\"世界\",{\"text\":\"\"}]}"}) { + String json = "{\"description\":" + description + ",\"version\":{\"name\":\"Paper 1.21.1\",\"protocol\":767},\"players\":{\"online\":3,\"max\":20}}"; + MineStat ping = fixture(output -> response(output, json)); + assertEquals(MineStat.Retval.SUCCESS, ping.pingResult); assertTrue(ping.isServerUp()); + assertEquals("Welcome 世界", ping.getStrippedMotd()); assertEquals("Paper 1.21.1", ping.getVersion()); + assertEquals(767, ping.getProtocol()); assertEquals(3, ping.getCurrentPlayers()); assertEquals(20, ping.getMaximumPlayers()); + } + } + + @Test void rejectsOversizedOrInvalidJsonPacketsWithoutAllocation() throws Exception { + MineStat oversized = fixture(output -> { varInt(output, Integer.MAX_VALUE); output.flush(); }); + assertEquals(MineStat.Retval.UNKNOWN, oversized.pingResult); assertFalse(oversized.isServerUp()); + MineStat malformed = fixture(output -> response(output, "{\"description\":\"hi\",\"players\":null}")); + assertEquals(MineStat.Retval.UNKNOWN, malformed.pingResult); assertFalse(malformed.isServerUp()); + MineStat wrongPacket = fixture(output -> { output.write(new byte[]{3, 1, 1, 'x'}); output.flush(); }); + assertEquals(MineStat.Retval.UNKNOWN, wrongPacket.pingResult); + } + + @Test void slowServerCannotBlockTheBrowserIndefinitely() throws Exception { + assertTimeoutPreemptively(Duration.ofSeconds(4), () -> { + MineStat ping = fixture(output -> { try { Thread.sleep(1400); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); + assertEquals(MineStat.Retval.TIMEOUT, ping.pingResult); assertFalse(ping.isServerUp()); + }); + } + + private MineStat fixture(Reply reply) throws Exception { + ExecutorService executor = Executors.newSingleThreadExecutor(); + try (ServerSocket server = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) { + server.setSoTimeout(3000); + Future future = executor.submit(() -> { + try (Socket socket = server.accept()) { + socket.setSoTimeout(3000); + DataInputStream input = new DataInputStream(socket.getInputStream()); + int length = readVarInt(input); assertTrue(length > 3 && length < 512); + byte[] handshake = new byte[length]; input.readFully(handshake); + assertEquals(1, input.readUnsignedByte()); assertEquals(0, input.readUnsignedByte()); + reply.send(new DataOutputStream(socket.getOutputStream())); + } catch (IOException e) { throw new UncheckedIOException(e); } + }); + MineStat result = new MineStat(server.getInetAddress().getHostAddress(), server.getLocalPort(), 1, MineStat.Request.JSON); + future.get(4, TimeUnit.SECONDS); return result; + } finally { executor.shutdownNow(); } + } + private void response(DataOutputStream output, String json) throws IOException { + byte[] payload = json.getBytes(StandardCharsets.UTF_8); ByteArrayOutputStream packet = new ByteArrayOutputStream(); + DataOutputStream data = new DataOutputStream(packet); data.writeByte(0); varInt(data, payload.length); data.write(payload); + varInt(output, packet.size()); + // Deliberately fragmented packet exercises readFully instead of relying on one socket read. + for (byte value : packet.toByteArray()) { output.write(value); output.flush(); } + } + private static int readVarInt(DataInputStream input) throws IOException { int result = 0; for (int shift = 0; shift < 35; shift += 7) { int value = input.readUnsignedByte(); result |= (value & 127) << shift; if ((value & 128) == 0) return result; } throw new IOException("Invalid varint"); } + private static void varInt(DataOutputStream output, int value) throws IOException { do { int next = value & 127; value >>>= 7; output.writeByte(value == 0 ? next : next | 128); } while (value != 0); } + @FunctionalInterface private interface Reply { void send(DataOutputStream output) throws IOException; } +} diff --git a/src/test/java/com/osiris/autoplug/client/browser/ServerBrowserTest.java b/src/test/java/com/osiris/autoplug/client/browser/ServerBrowserTest.java new file mode 100644 index 00000000..1efd8a75 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/browser/ServerBrowserTest.java @@ -0,0 +1,84 @@ +package com.osiris.autoplug.client.browser; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.List; +import java.util.zip.GZIPOutputStream; +import static org.junit.jupiter.api.Assertions.*; + +class ServerBrowserTest { + @TempDir Path directory; + + @Test void parsesDefaultExplicitAndIpv6AddressesWithoutDns() { + assertEquals("example.com", ServerAddress.parse(" EXAMPLE.com:25565 ").toString()); + ServerAddress ipv6 = ServerAddress.parse("[2001:db8::1]:25566"); + assertEquals("2001:db8::1", ipv6.host); assertEquals(25566, ipv6.port); + assertEquals("[::1]", ServerAddress.parse("::1").toString()); + assertThrows(IllegalArgumentException.class, () -> ServerAddress.parse("https://example.com")); + assertThrows(IllegalArgumentException.class, () -> ServerAddress.parse("example.com:65536")); + assertThrows(IllegalArgumentException.class, () -> ServerAddress.parse("example.com:0")); + assertThrows(IllegalArgumentException.class, () -> ServerAddress.parse("a b:1")); + } + + @Test void importsRawAndCompressedNbtWithoutChangingVanillaFile() throws Exception { + byte[] raw = favorites(); + Path vanilla = directory.resolve("servers.dat"); Files.write(vanilla, raw); + List read = new VanillaServersReader().read(vanilla); + assertEquals(2, read.size()); assertEquals("Friends 世界", read.get(0).name); + assertEquals("example.com", read.get(0).address); assertEquals("[::1]:25566", read.get(1).address); + ServerBrowserService service = new ServerBrowserService(directory.resolve("autoplug/servers.json"), vanilla); + assertEquals(2, service.importVanilla()); assertEquals(0, service.importVanilla()); + assertArrayEquals(raw, Files.readAllBytes(vanilla)); + service.add("Renamed favorite", "EXAMPLE.com:25565"); + List saved = new ServerBrowserService(directory.resolve("autoplug/servers.json"), vanilla).list(); + assertEquals(2, saved.size()); assertEquals("Renamed favorite", saved.get(1).name); + service.remove("example.com"); assertEquals(1, service.list().size()); + Path compressed = directory.resolve("compressed.dat"); + try (OutputStream output = new GZIPOutputStream(Files.newOutputStream(compressed))) { output.write(raw); } + assertEquals(2, new VanillaServersReader().read(compressed).size()); + } + + @Test void corruptFavoritesArePreservedInsteadOfOverwritten() throws Exception { + Path storage = directory.resolve("servers.json"); byte[] malformed = "{broken".getBytes(StandardCharsets.UTF_8); Files.write(storage, malformed); + ServerBrowserService service = new ServerBrowserService(storage, directory.resolve("missing.dat")); + assertThrows(IOException.class, () -> service.add("new", "example.com")); + assertArrayEquals(malformed, Files.readAllBytes(storage)); + assertTrue(new VanillaServersReader().read(directory.resolve("missing.dat")).isEmpty()); + } + + @Test void rejectsHostileNbtLengthsDepthAndTruncation() throws Exception { + Path file = directory.resolve("invalid.dat"); + try (DataOutputStream out = new DataOutputStream(Files.newOutputStream(file))) { + out.writeByte(10); out.writeUTF(""); out.writeByte(9); out.writeUTF("servers"); out.writeByte(10); out.writeInt(Integer.MAX_VALUE); + } + assertThrows(IOException.class, () -> new VanillaServersReader().read(file)); + try (DataOutputStream out = new DataOutputStream(Files.newOutputStream(file))) { + for (int i = 0; i < 40; i++) { out.writeByte(10); out.writeUTF("nested"); } + } + assertThrows(IOException.class, () -> new VanillaServersReader().read(file)); + Files.write(file, new byte[]{10, 0, 0, 8, 0}); + assertThrows(IOException.class, () -> new VanillaServersReader().read(file)); + } + + private byte[] favorites() throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (DataOutputStream out = new DataOutputStream(bytes)) { + out.writeByte(10); out.writeUTF(""); + // Exercise unrelated array/list/scalar types commonly added by mods. + out.writeByte(7); out.writeUTF("unknownBytes"); out.writeInt(2); out.write(new byte[]{1, 2}); + out.writeByte(11); out.writeUTF("unknownInts"); out.writeInt(1); out.writeInt(42); + out.writeByte(12); out.writeUTF("unknownLongs"); out.writeInt(1); out.writeLong(42); + out.writeByte(9); out.writeUTF("servers"); out.writeByte(10); out.writeInt(3); + string(out, "name", "Friends 世界"); string(out, "ip", "EXAMPLE.com:25565"); + out.writeByte(1); out.writeUTF("acceptTextures"); out.writeByte(1); out.writeByte(0); + string(out, "name", "Local IPv6"); string(out, "ip", "[::1]:25566"); out.writeByte(0); + string(out, "name", "Malformed favorite"); string(out, "ip", "https://invalid.example"); out.writeByte(0); + out.writeByte(0); + } + return bytes.toByteArray(); + } + private void string(DataOutputStream out, String key, String value) throws IOException { out.writeByte(8); out.writeUTF(key); out.writeUTF(value); } +} diff --git a/src/test/java/com/osiris/autoplug/client/browser/ServerFavoriteHistoryTest.java b/src/test/java/com/osiris/autoplug/client/browser/ServerFavoriteHistoryTest.java new file mode 100644 index 00000000..21722667 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/browser/ServerFavoriteHistoryTest.java @@ -0,0 +1,37 @@ +package com.osiris.autoplug.client.browser; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import static org.junit.jupiter.api.Assertions.*; + +class ServerFavoriteHistoryTest { + @TempDir Path directory; + + @Test void oldFavoritesGainPersistentJoinHistoryWithoutLosingItOnRename() throws Exception { + Path file = directory.resolve("servers.json"); + Files.write(file, "[{\"name\":\"Minecraft Server\",\"address\":\"EXAMPLE.com\"}]".getBytes(StandardCharsets.UTF_8)); + ServerBrowserService browser = new ServerBrowserService(file, directory.resolve("servers.dat")); + SavedServer original = browser.list().get(0); + assertEquals("example.com", original.name); assertEquals(0, original.lastJoinedAt); + browser.recordJoined("EXAMPLE.COM:25565", 1000); + browser.add("Friends", "example.com"); + SavedServer reopened = new ServerBrowserService(file, directory.resolve("servers.dat")).list().get(0); + assertEquals("Friends", reopened.name); assertEquals(1000, reopened.lastJoinedAt); + browser.recordJoined("example.com", 900); + assertEquals(1000, browser.list().get(0).lastJoinedAt, "An older completion must not move activity backwards"); + } + + @Test void lateLaunchNeverRecreatesRemovedFavoriteAndBlankNamesUseAddress() throws Exception { + Path file = directory.resolve("servers.json"); + ServerBrowserService browser = new ServerBrowserService(file, directory.resolve("servers.dat")); + browser.add("Minecraft Server", "[::1]:25566"); + assertEquals("[::1]:25566", browser.list().get(0).name); + browser.remove("[::1]:25566"); byte[] afterRemoval = Files.readAllBytes(file); + browser.recordJoined("[::1]:25566", 2000); + assertArrayEquals(afterRemoval, Files.readAllBytes(file)); assertTrue(browser.list().isEmpty()); + assertEquals("example.com", new SavedServer(" ", "example.com").name); + assertEquals("My Minecraft Server", new SavedServer("My Minecraft Server", "example.com").name); + } +} diff --git a/src/test/java/com/osiris/autoplug/client/browser/ServerIconTest.java b/src/test/java/com/osiris/autoplug/client/browser/ServerIconTest.java new file mode 100644 index 00000000..a7058be9 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/browser/ServerIconTest.java @@ -0,0 +1,32 @@ +package com.osiris.autoplug.client.browser; + +import org.junit.jupiter.api.Test; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.util.Base64; +import javax.imageio.ImageIO; +import static org.junit.jupiter.api.Assertions.*; + +class ServerIconTest { + @Test void decodesStatusPngAndPreservesItsPixels() throws Exception { + BufferedImage image = new BufferedImage(64, 64, BufferedImage.TYPE_INT_ARGB); + image.setRGB(7, 11, 0xFF23875A); + BufferedImage decoded = ServerIcon.decode(data(image, "png")); + assertNotNull(decoded); assertEquals(64, decoded.getWidth()); assertEquals(64, decoded.getHeight()); + assertEquals(0xFF23875A, decoded.getRGB(7, 11)); + } + + @Test void rejectsUrlsNonPngWrongDimensionsOversizeAndMalformedContent() throws Exception { + assertNull(ServerIcon.decode("https://example.invalid/server-icon.png")); + assertNull(ServerIcon.decode(null)); assertNull(ServerIcon.decode("data:image/png;base64,!!!")); + assertNull(ServerIcon.decode(data(new BufferedImage(65, 64, BufferedImage.TYPE_INT_RGB), "png"))); + assertNull(ServerIcon.decode(data(new BufferedImage(64, 64, BufferedImage.TYPE_INT_RGB), "gif").replace("image/gif", "image/png"))); + assertNull(ServerIcon.decode("data:image/png;base64," + Base64.getEncoder().encodeToString(new byte[96 * 1024 + 1]))); + assertNull(ServerIcon.decode("data:image/png;base64," + Base64.getEncoder().encodeToString(new byte[]{(byte)137,80,78,71,13,10,26,10}))); + } + + private static String data(BufferedImage image, String format) throws Exception { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); assertTrue(ImageIO.write(image, format, bytes)); + return "data:image/" + format + ";base64," + Base64.getEncoder().encodeToString(bytes.toByteArray()); + } +} diff --git a/src/test/java/com/osiris/autoplug/client/browser/ServerStatusIconIntegrationTest.java b/src/test/java/com/osiris/autoplug/client/browser/ServerStatusIconIntegrationTest.java new file mode 100644 index 00000000..8ce8580e --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/browser/ServerStatusIconIntegrationTest.java @@ -0,0 +1,61 @@ +package com.osiris.autoplug.client.browser; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.*; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Base64; +import java.util.concurrent.*; +import static org.junit.jupiter.api.Assertions.*; + +class ServerStatusIconIntegrationTest { + @TempDir Path directory; + + @Test void browserReadsEmbeddedIconFromTheSameStatusResponse() throws Exception { + BufferedImage image = new BufferedImage(64, 64, BufferedImage.TYPE_INT_RGB); image.setRGB(1, 2, 0x336699); + ByteArrayOutputStream png = new ByteArrayOutputStream(); ImageIO.write(image, "png", png); + ServerStatus result = ping("data:image/png;base64," + Base64.getEncoder().encodeToString(png.toByteArray())); + assertTrue(result.online); assertNotNull(result.icon); assertEquals(0xFF336699, result.icon.getRGB(1, 2)); + assertEquals("Welcome", result.motd); assertEquals(3, result.players); + } + + @Test void invalidOptionalFaviconDoesNotHideAnOtherwiseOnlineServer() throws Exception { + ServerStatus result = ping("https://example.invalid/untrusted.png"); + assertTrue(result.online); assertNull(result.icon); assertEquals("1.21.1", result.version); + } + + private ServerStatus ping(String favicon) throws Exception { + ExecutorService executor = Executors.newSingleThreadExecutor(); + try (ServerSocket server = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) { + server.setSoTimeout(4000); + Future reply = executor.submit(() -> { + try (Socket client = server.accept()) { + client.setSoTimeout(4000); DataInputStream input = new DataInputStream(client.getInputStream()); + int handshake = readVarInt(input); assertTrue(handshake > 3 && handshake < 512); + byte[] data = new byte[handshake]; input.readFully(data); + assertEquals(1, input.readUnsignedByte()); assertEquals(0, input.readUnsignedByte()); + String json = "{\"description\":\"Welcome\",\"version\":{\"name\":\"1.21.1\",\"protocol\":767}," + + "\"players\":{\"online\":3,\"max\":20},\"favicon\":\"" + favicon + "\"}"; + byte[] body = json.getBytes(StandardCharsets.UTF_8); ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream packet = new DataOutputStream(bytes); packet.writeByte(0); writeVarInt(packet, body.length); packet.write(body); + DataOutputStream output = new DataOutputStream(client.getOutputStream()); writeVarInt(output, bytes.size()); output.write(bytes.toByteArray()); output.flush(); + } catch (IOException e) { throw new UncheckedIOException(e); } + }); + String host = server.getInetAddress().getHostAddress(); + String address = (host.contains(":") ? "[" + host + "]" : host) + ":" + server.getLocalPort(); + ServerStatus result = new ServerBrowserService(directory.resolve("servers.json"), directory.resolve("servers.dat")).ping(new SavedServer("Fixture", address)); + reply.get(5, TimeUnit.SECONDS); return result; + } finally { executor.shutdownNow(); } + } + private static int readVarInt(DataInputStream input) throws IOException { + int result = 0; for (int shift = 0; shift < 35; shift += 7) { int value = input.readUnsignedByte(); result |= (value & 127) << shift; if ((value & 128) == 0) return result; } + throw new IOException("Invalid varint"); + } + private static void writeVarInt(DataOutputStream output, int value) throws IOException { + do { int next = value & 127; value >>>= 7; output.writeByte(value == 0 ? next : next | 128); } while (value != 0); + } +} diff --git a/src/test/java/com/osiris/autoplug/client/launcher/DownloadProgressTest.java b/src/test/java/com/osiris/autoplug/client/launcher/DownloadProgressTest.java new file mode 100644 index 00000000..518e8438 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/launcher/DownloadProgressTest.java @@ -0,0 +1,188 @@ +package com.osiris.autoplug.client.launcher; + +import com.osiris.autoplug.client.tasks.updater.TaskDownload; +import com.osiris.betterthread.BThreadManager; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +class DownloadProgressTest { + @TempDir Path temporary; + + @Test void sourceUrlsRetainUsefulFiltersAndRedactCredentials() { + String original = "https://name:secret@example.test/download?version=1.21.1&arch=x64&access_token=private&X-Amz-Signature=signature&api%5Fkey=private#access_token=private"; + String safe = DownloadProgress.sourceUrl(original); + assertEquals("https://[redacted]@example.test/download?version=1.21.1&arch=x64&access_token=[redacted]&X-Amz-Signature=[redacted]&api%5Fkey=[redacted]#[redacted]", safe); + assertFalse(safe.contains("secret")); + assertFalse(safe.contains("private")); + assertEquals("https://example.test/a%20b.jar?version=1.21&loader=fabric", DownloadProgress.sourceUrl("https://example.test/a%20b.jar?version=1.21&loader=fabric")); + assertEquals("[invalid source URL]", DownloadProgress.sourceUrl("not-a-url?token=private")); + assertEquals("Source HTTPS://example.test/file?token=[redacted]", DownloadProgress.sanitizeUrls("Source HTTPS://example.test/file?token=private")); + } + + @Test void metadataFetchEmitsSourceAndUnknownLengthProgress() throws Exception { + HttpServer server = server("{\"versions\":[]}", true, 200); + List events = new CopyOnWriteArrayList<>(); + String source = url(server) + "/manifest?channel=release"; + try (AutoCloseable ignored = DownloadProgress.subscribe(events::add)) { + assertTrue(LauncherFiles.json(source).has("versions")); + } finally { server.stop(0); } + assertEquals(source, events.get(0).sourceUrl); + assertEquals(-1, events.get(0).totalBytes); + DownloadProgress.Event last = events.get(events.size() - 1); + assertTrue(last.complete); + assertTrue(last.downloadedBytes > 0); + assertEquals(-1, last.totalBytes); + } + + @Test void downloadsEmitMeasuredProgressAndCacheHitsDoNotClaimDownloads() throws Exception { + String content = "verified fixture"; + HttpServer server = server(content, false, 200); + List events = new CopyOnWriteArrayList<>(); + List callbacks = new ArrayList<>(); + String source = url(server) + "/client.jar?version=1.21&token=private"; + Path target = temporary.resolve("client.jar"); + try (AutoCloseable ignored = DownloadProgress.subscribe(events::add)) { + LauncherFiles.download(source, target, null, content.length(), callbacks::add); + int before = events.size(); + LauncherFiles.download(source, target, null, content.length(), callbacks::add); + assertEquals(before, events.size()); + } finally { server.stop(0); } + assertEquals(content, new String(Files.readAllBytes(target), StandardCharsets.UTF_8)); + assertTrue(events.stream().anyMatch(event -> event.totalBytes == content.length() && !event.complete)); + assertTrue(events.get(events.size() - 1).complete); + assertTrue(callbacks.get(0).contains("from " + url(server))); + assertFalse(callbacks.toString().contains("private")); + assertFalse(events.stream().anyMatch(event -> event.sourceUrl != null && event.sourceUrl.contains("private"))); + } + + @Test void assetWorkerDownloadsReachSubscribersEvenWithoutCallbacks() throws Exception { + HttpServer server = server("asset", false, 200); + List events = new CopyOnWriteArrayList<>(); + ExecutorService workers = Executors.newFixedThreadPool(3); + try (AutoCloseable ignored = DownloadProgress.subscribe(events::add)) { + List> futures = new ArrayList<>(); + for (int i = 0; i < 3; i++) { + final int id = i; + futures.add(workers.submit(() -> { + try { LauncherFiles.download(url(server) + "/objects/" + id, temporary.resolve("asset-" + id), null, 5, value -> { }); } + catch (IOException e) { throw new IllegalStateException(e); } + })); + } + for (Future future : futures) future.get(); + } finally { workers.shutdownNow(); server.stop(0); } + assertEquals(3, events.stream().filter(event -> event.complete).count()); + for (int i = 0; i < 3; i++) { + String suffix = "/objects/" + i; + assertTrue(events.stream().anyMatch(event -> event.sourceUrl.endsWith(suffix))); + } + } + + @Test void failedDownloadsExposeSafeSourceAndNeverCompletion() throws Exception { + HttpServer server = server("missing", false, 404); + List events = new ArrayList<>(); + try (AutoCloseable ignored = DownloadProgress.subscribe(events::add)) { + IOException failure = assertThrows(IOException.class, () -> LauncherFiles.download(url(server) + "/library?token=private", temporary.resolve("missing.jar"), null, -1, null)); + assertTrue(failure.getMessage().contains("token=[redacted]")); + assertFalse(failure.getMessage().contains("private")); + assertFalse(events.stream().anyMatch(event -> event.complete)); + assertFalse(Files.exists(temporary.resolve("missing.jar"))); + } finally { server.stop(0); } + } + + @Test void updaterArtifactsPublishSourceAndBytes() throws Exception { + HttpServer server = server("plugin", false, 200); + List events = new ArrayList<>(); + Path target = temporary.resolve("plugin.jar"); + String source = url(server) + "/plugin.jar?token=private"; + try (AutoCloseable ignored = DownloadProgress.subscribe(events::add)) { + new TaskDownload("test", new BThreadManager(), source, target.toFile(), true).runAtStart(); + } finally { server.stop(0); } + assertTrue(events.stream().anyMatch(event -> event.sourceUrl.equals(DownloadProgress.sourceUrl(source)))); + DownloadProgress.Event last = events.get(events.size() - 1); + assertTrue(last.complete); assertEquals(6, last.downloadedBytes); assertEquals(6, last.totalBytes); + } + + @Test void officialInstallerOutputIsForwardedAndPersistedWithoutTokens() throws Exception { + List events = new CopyOnWriteArrayList<>(); + List logs = new CopyOnWriteArrayList<>(); + Path java = Paths.get(System.getProperty("java.home"), "bin", JavaRuntimeManager.executableName()); + try (AutoCloseable ignored = DownloadProgress.subscribe(events::add)) { + LoaderInstaller.run(java, Arrays.asList("-cp", System.getProperty("java.class.path"), InstallerFixture.class.getName()), temporary, logs::add); + } + assertTrue(events.stream().anyMatch(event -> event.sourceUrl != null && event.sourceUrl.contains("/library.jar?version=1&token=[redacted]"))); + String file = new String(Files.readAllBytes(temporary.resolve("autoplug-loader-install.log")), StandardCharsets.UTF_8); + assertTrue(file.contains("Installing processors")); + assertFalse(file.contains("private")); + assertFalse(logs.toString().contains("private")); + } + + @Test void listenerDisposalAndFailureDoNotAffectOtherObservers() throws Exception { + AtomicInteger events = new AtomicInteger(); + try (AutoCloseable faulty = DownloadProgress.subscribe(event -> { throw new IllegalStateException("closed view"); })) { + AutoCloseable listener = DownloadProgress.subscribe(event -> events.incrementAndGet()); + try (DownloadProgress.Transfer transfer = DownloadProgress.begin("Downloading", "https://example.test/file")) { + transfer.complete(1, 1); + } + listener.close(); + try (DownloadProgress.Transfer transfer = DownloadProgress.begin("Downloading", "https://example.test/file2")) { + transfer.complete(1, 1); + } + } + assertEquals(2, events.get()); + } + + @Test void overlappingAndFailedTransfersHaveStableIdsAndAccurateActiveCount() throws Exception { + List events = new ArrayList<>(); + try (AutoCloseable ignored = DownloadProgress.subscribe(events::add); + DownloadProgress.Transfer first = DownloadProgress.begin("First", "https://example.test/a"); + DownloadProgress.Transfer second = DownloadProgress.begin("Second", "https://example.test/b")) { + assertEquals(2, events.get(1).activeTransfers); + assertNotEquals(events.get(0).transferId, events.get(1).transferId); + first.complete(10, 10); + assertEquals(1, events.get(2).activeTransfers); + assertEquals(events.get(0).transferId, events.get(2).transferId); + second.close(); + DownloadProgress.Event stopped = events.get(3); + assertEquals(0, stopped.activeTransfers); + assertTrue(stopped.finished); + assertFalse(stopped.complete); + } + } + + private static HttpServer server(String content, boolean chunked, int status) throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + byte[] bytes = content.getBytes(StandardCharsets.UTF_8); + server.createContext("/", exchange -> { + exchange.getResponseHeaders().set("Content-Type", "application/octet-stream"); + exchange.sendResponseHeaders(status, chunked ? 0 : bytes.length); + try (java.io.OutputStream output = exchange.getResponseBody()) { output.write(bytes); } + }); + server.start(); return server; + } + private static String url(HttpServer server) { return "http://127.0.0.1:" + server.getAddress().getPort(); } + + public static class InstallerFixture { + public static void main(String[] args) { + System.out.println("Downloading https://example.test/library.jar?version=1&token=private"); + System.out.println("Installing processors"); + } + } +} diff --git a/src/test/java/com/osiris/autoplug/client/launcher/JavaRuntimeManagerTest.java b/src/test/java/com/osiris/autoplug/client/launcher/JavaRuntimeManagerTest.java new file mode 100644 index 00000000..b64b2abf --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/launcher/JavaRuntimeManagerTest.java @@ -0,0 +1,42 @@ +package com.osiris.autoplug.client.launcher; + +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.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import static org.junit.jupiter.api.Assertions.*; + +class JavaRuntimeManagerTest { + @TempDir Path temporary; + @Test void javaVersionParsingDoesNotConfuseJava8AndModernVersions() { + assertEquals(8, JavaRuntimeManager.parseMajor("\"1.8.0_462\"")); + assertEquals(21, JavaRuntimeManager.parseMajor("\"21.0.12.1\"")); + assertEquals(25, JavaRuntimeManager.parseMajor("25-ea")); + } + @Test void explicitRuntimeMustMatchRequestedMajorWithoutMutatingOtherInstalls() throws Exception { + Path home = temporary.resolve("jdk-17"); Files.createDirectories(home.resolve("bin")); + Path java = home.resolve("bin").resolve(JavaRuntimeManager.executableName()); Files.write(java, new byte[]{0}); + Files.write(home.resolve("release"), "JAVA_VERSION=\"17.0.16\"".getBytes(StandardCharsets.UTF_8)); + JavaRuntimeManager manager = new JavaRuntimeManager(temporary.resolve("managed")); + manager.setRuntime(17, java); assertEquals(java, manager.resolve(17, ignored -> { })); + manager.setRuntime(21, java); assertThrows(IOException.class, () -> manager.resolve(21, ignored -> { })); + assertTrue(Files.exists(home.resolve("release"))); assertFalse(Files.exists(temporary.resolve("managed"))); + } + @Test void extractionRejectsArchiveTraversal() throws Exception { + Path archive = temporary.resolve("runtime.zip"); + try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(archive))) { zip.putNextEntry(new ZipEntry("../outside")); zip.write(1); } + assertThrows(IOException.class, () -> JavaRuntimeManager.extract(archive, temporary.resolve("runtime"))); + assertFalse(Files.exists(temporary.resolve("outside"))); + } + @Test void neoForgeSelectionUsesCorrectGameVersionPrefixAndNumericOrder() throws Exception { + assertEquals("21.1.", LoaderInstaller.neoPrefix("1.21.1")); + assertEquals("20.2.", LoaderInstaller.neoPrefix("1.20.2")); + assertEquals("26.3.", LoaderInstaller.neoPrefix("26.3")); + assertTrue(LoaderInstaller.compareVersions("21.1.100", "21.1.99") > 0); + assertTrue(LoaderInstaller.compareVersions("21.1.100", "21.1.100-beta") > 0); + } +} diff --git a/src/test/java/com/osiris/autoplug/client/launcher/LegacyWorldClientFixture.java b/src/test/java/com/osiris/autoplug/client/launcher/LegacyWorldClientFixture.java new file mode 100644 index 00000000..d3bc84b1 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/launcher/LegacyWorldClientFixture.java @@ -0,0 +1,78 @@ +package com.osiris.autoplug.client.launcher; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.Executor; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +/** A forked client contract fixture: no Minecraft install, credentials or save writes. */ +public final class LegacyWorldClientFixture implements Executor { + private static LegacyWorldClientFixture instance; + private final BlockingQueue queue = new LinkedBlockingQueue<>(); + public Object screen = new Object(); + private Object overlay = new Object(); + private int opened; + private int checked; + private boolean legacyScheduler; + private int scheduled; + + public static LegacyWorldClientFixture getInstance() { return instance; } + public Object getOverlay() { return overlay; } + public void execute(Runnable work) { queue.add(work); } + public Object schedule(Runnable work) { scheduled++; queue.add(work); return null; } + public void loadLevel(String world) throws Exception { record(world, "direct"); } + public Flows createWorldOpenFlows() { return new Flows(this); } + public Source getLevelSource() { return new Source(); } + public void selectLevel(String folder, String name, Settings settings) throws Exception { + if (!"Original display name".equals(name) || settings != null) throw new AssertionError("Save renamed or new settings supplied"); + record(folder, legacyScheduler ? "original-scheduler" : "original-name"); + } + private void record(String world, String route) throws Exception { + if (!Thread.currentThread().getName().equals("main")) throw new AssertionError("Wrong thread"); + if (!(screen instanceof TitleScreen) || (!legacyScheduler && (overlay != null || checked < 2))) throw new AssertionError("Startup screen bypassed"); + if (legacyScheduler && scheduled < 2) throw new AssertionError("Native scheduler was bypassed"); + if (++opened != 1) throw new AssertionError("Duplicate world entry"); + Files.write(Paths.get("entered.txt"), (world + "|" + route + "|main").getBytes(StandardCharsets.UTF_8)); + } + public static class TitleScreen { } + public static class Settings { } + public static class Flows { + private final LegacyWorldClientFixture client; + public Flows(LegacyWorldClientFixture client) { this.client = client; } + public void loadLevel(String world) throws Exception { client.record(world, "flow"); } + public void loadLevel(TitleScreen screen, String world) throws Exception { + if (screen != client.screen) throw new AssertionError("Native return screen lost"); + client.record(world, "screen-flow"); + } + } + public static class Source extends BaseSource { + public java.util.List getLevelList() { return Collections.singletonList(new Summary()); } + } + public static class BaseSource { + public boolean levelExists(String folder) { return "World folder ü".equals(folder); } + } + public static class Summary { + public String getLevelId() { return "World folder ü"; } + public String getLevelName() { return "Original display name"; } + } + public static void main(String[] args) throws Exception { + instance = new LegacyWorldClientFixture(); + instance.legacyScheduler = args.length > 0 && args[0].equals("original-scheduler"); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(15); + while (instance.opened == 0 && System.nanoTime() < deadline) { + Runnable task = instance.queue.poll(1, TimeUnit.SECONDS); + if (task == null) continue; + task.run(); + instance.checked++; + if (instance.checked == 1) instance.screen = new TitleScreen(); + if (instance.checked >= 2) instance.overlay = null; + } + if (instance.opened != 1) throw new AssertionError("No world entry"); + Runnable unexpected = instance.queue.poll(500, TimeUnit.MILLISECONDS); + if (unexpected != null) { unexpected.run(); if (instance.opened != 1) throw new AssertionError("Repeated entry"); } + } +} diff --git a/src/test/java/com/osiris/autoplug/client/launcher/LegacyWorldLaunchTest.java b/src/test/java/com/osiris/autoplug/client/launcher/LegacyWorldLaunchTest.java new file mode 100644 index 00000000..b73da364 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/launcher/LegacyWorldLaunchTest.java @@ -0,0 +1,147 @@ +package com.osiris.autoplug.client.launcher; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import com.google.gson.JsonObject; +import com.sun.net.httpserver.HttpServer; +import java.net.InetSocketAddress; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Collections; +import java.util.Properties; +import java.util.concurrent.TimeUnit; +import java.util.jar.JarFile; +import static org.junit.jupiter.api.Assertions.*; + +class LegacyWorldLaunchTest { + @TempDir Path temporary; + + @Test void exactPublisherMappingsCoverLegacyReleasesAndBothSnapshotFlowApis() throws Exception { + for (String version : Arrays.asList("1.14.4", "1.15.2", "1.16", "1.16.5", "1.17.1", "1.18.2", "1.19", "1.19.2", "1.19.3", "1.19.4", "22w11a", "22w13a")) { + try (InputStream input = getClass().getResourceAsStream("/launcher/legacy-mappings-" + version + ".txt")) { + assertNotNull(input, version); + Properties names = LegacyWorldLaunch.resolve(new String(input.readAllBytes(), StandardCharsets.UTF_8)); + assertFalse(names.getProperty("minecraft").contains("net.minecraft"), version); + assertEquals("a", names.getProperty("load"), version); + assertEquals(version.startsWith("1.14") || version.startsWith("1.15"), names.containsKey("levelSource"), version); + if (version.equals("22w11a")) { assertNotNull(names.getProperty("flows")); assertNull(names.getProperty("screenType")); } + if (version.startsWith("1.19")) assertNotNull(names.getProperty("screenType"), version); + } + } + assertThrows(java.io.IOException.class, () -> LegacyWorldLaunch.resolve("unrecognized -> a:\n")); + } + + @Test void bundledAgentIsJava8AndIdenticalCachedJarIsNotReplaced() throws Exception { + Path jar = LegacyWorldLaunch.packageAgent(temporary); + java.nio.file.attribute.FileTime marker = java.nio.file.attribute.FileTime.fromMillis(123456789); + Files.setLastModifiedTime(jar, marker); + assertEquals(jar, LegacyWorldLaunch.packageAgent(temporary)); + assertEquals(marker, Files.getLastModifiedTime(jar)); + try (JarFile packaged = new JarFile(jar.toFile()); InputStream bytes = packaged.getInputStream(packaged.getJarEntry(LegacyWorldLaunch.AGENT))) { + byte[] header = new byte[8]; assertEquals(8, bytes.read(header)); assertEquals(52, header[7]); + assertEquals("com.osiris.autoplug.legacy.LegacyWorldAgent", packaged.getManifest().getMainAttributes().getValue("Premain-Class")); + } + Files.write(jar, new byte[]{1, 2, 3}); + LegacyWorldLaunch.packageAgent(temporary); + try (JarFile restored = new JarFile(jar.toFile())) { assertNotNull(restored.getJarEntry(LegacyWorldLaunch.AGENT)); } + } + + @Test void preparationVerifiesPublisherMappingsAndReusesTheExactCacheOffline() throws Exception { + byte[] mappings; + try (InputStream input = getClass().getResourceAsStream("/launcher/legacy-mappings-1.18.2.txt")) { mappings = input.readAllBytes(); } + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/mappings", exchange -> { + exchange.sendResponseHeaders(200, mappings.length); + try (OutputStream output = exchange.getResponseBody()) { output.write(mappings); } + }); + server.start(); + JsonObject descriptor = new JsonObject(); + descriptor.addProperty("url", "http://127.0.0.1:" + server.getAddress().getPort() + "/mappings"); + descriptor.addProperty("sha1", com.osiris.autoplug.client.utils.UtilsCrypto.calculateSHA1Hash(mappings)); + descriptor.addProperty("size", mappings.length); + JsonObject metadata = new JsonObject(), downloads = new JsonObject(); downloads.add("client_mappings", descriptor); metadata.add("downloads", downloads); + Path cache = temporary.resolve("verified cache"), game = temporary.resolve("original game"); + LaunchRequest request = new LaunchRequest(game, "1.18.2", "VANILLA", null, MinecraftAccount.offline("Player"), null, 0, "Original folder"); + LegacyWorldLaunch prepared; + try { prepared = LegacyWorldLaunch.prepare(cache, metadata, request, ignored -> { }); } + finally { server.stop(0); } + Properties names = new Properties(); try (InputStream input = Files.newInputStream(prepared.configuration)) { names.load(input); } + assertEquals("dyr", names.getProperty("minecraft")); assertEquals("Original folder", names.getProperty("world")); + assertEquals(game.toString(), names.getProperty("gameDir")); + assertTrue(prepared.argument().startsWith("-javaagent:")); + assertEquals(prepared.agent, LegacyWorldLaunch.prepare(cache, metadata, request, ignored -> { }).agent); + assertThrows(java.io.IOException.class, () -> LegacyWorldLaunch.prepare(cache, metadata, + new LaunchRequest(game, "1.18.2", "FABRIC", "test", MinecraftAccount.offline("Player"), null, 0, "Original folder"), ignored -> { })); + Files.write(cache.resolve("versions/1.18.2/client-mappings.txt"), new byte[]{0}); + assertThrows(java.io.IOException.class, () -> LegacyWorldLaunch.prepare(cache, metadata, request, ignored -> { })); + } + + @Test void startupAgentWaitsForTitleAndOverlayThenUsesMainThreadForAllNativeApis() throws Exception { + for (String route : Arrays.asList("direct", "flow", "screen-flow", "original-name", "original-scheduler")) { + Path game = Files.createDirectories(temporary.resolve("game with spaces " + route)); + Properties names = fixtureNames(route); + Files.createDirectories(game.resolve("saves/World folder ü")); + Files.write(game.resolve("saves/World folder ü/level.dat"), new byte[]{1}); + names.setProperty("gameDir", game.toString()); + Path config = game.resolve("launch ü.properties"); + try (OutputStream out = Files.newOutputStream(config)) { names.store(out, "fixture"); } + Path agent = LegacyWorldLaunch.packageAgent(temporary.resolve("helper with spaces")); + String classpath = Paths.get(LegacyWorldClientFixture.class.getProtectionDomain().getCodeSource().getLocation().toURI()).toString(); + String java = Paths.get(System.getProperty("java.home"), "bin", JavaRuntimeManager.executableName()).toString(); + PreparedLaunch prepared = new PreparedLaunch(Paths.get(java), game, + Arrays.asList("-javaagent:" + agent + "=" + config, "-cp", classpath, LegacyWorldClientFixture.class.getName(), route), + "fixture", 21, Collections.singletonList(config)); + Process process = new MinecraftLauncher(temporary.resolve("cache"), new JavaRuntimeManager(temporary.resolve("runtimes"))).launch(prepared); + assertTrue(process.waitFor(20, TimeUnit.SECONDS), route); + assertEquals(0, process.exitValue(), () -> { try { return new String(Files.readAllBytes(game.resolve("logs/autoplug-launcher.log")), StandardCharsets.UTF_8); } catch (Exception e) { return e.toString(); } }); + assertEquals("World folder ü|" + route + "|main", new String(Files.readAllBytes(game.resolve("entered.txt")), StandardCharsets.UTF_8)); + for (int i = 0; i < 100 && Files.exists(config); i++) Thread.sleep(10); + assertFalse(Files.exists(config), "helper config cleanup"); + } + } + + @Test void prePublisherMappingAdapterIsPinnedToTheVerifiedOriginalClient() throws Exception { + Properties adapter = LegacyWorldLaunch.bundled("1.12.2", "0f275bc1547d01fa5f56ba34bdc87d981ee12daf"); + assertEquals("bib", adapter.getProperty("minecraft")); + assertEquals("a", adapter.getProperty("scheduler")); + assertNull(adapter.getProperty("overlay")); + assertThrows(java.io.IOException.class, () -> LegacyWorldLaunch.bundled("1.12.2", "different-client")); + assertThrows(java.io.IOException.class, () -> LegacyWorldLaunch.bundled("unverified-build", "")); + for (String version : Arrays.asList("1.7.10", "1.8.9", "1.12.2", "1.13.2")) { + try (InputStream source = getClass().getResourceAsStream("/launcher/legacy/" + version + ".properties")) { + Properties expected = new Properties(); expected.load(source); + Properties actual = LegacyWorldLaunch.bundled(version, expected.getProperty("clientSha1")); + assertEquals("a", actual.getProperty("scheduler")); + assertNotNull(actual.getProperty("levelName")); + } + } + } + + @Test void temporaryHelperConfigurationIsRemovedWhenStartingFails() throws Exception { + Path config = Files.createTempFile(temporary, "legacy-", ".properties"); + PreparedLaunch launch = new PreparedLaunch(temporary.resolve("missing-java"), temporary, + Collections.emptyList(), "fixture", 8, Collections.singletonList(config)); + MinecraftLauncher launcher = new MinecraftLauncher(temporary.resolve("cache"), new JavaRuntimeManager(temporary.resolve("runtimes"))); + assertThrows(java.io.IOException.class, () -> launcher.launch(launch)); + assertFalse(Files.exists(config)); + } + + private Properties fixtureNames(String route) { + Properties p = new Properties(); String base = LegacyWorldClientFixture.class.getName(); + p.setProperty("minecraft", base); p.setProperty("instance", "getInstance"); p.setProperty("title", base + "$TitleScreen"); + p.setProperty("screen", "screen"); p.setProperty("overlay", "getOverlay"); p.setProperty("world", "World folder ü"); p.setProperty("load", "loadLevel"); + if (route.contains("flow")) p.setProperty("flows", "createWorldOpenFlows"); + if (route.equals("screen-flow")) p.setProperty("screenType", base + "$TitleScreen"); + if (route.startsWith("original-")) { + p.setProperty("load", "selectLevel"); p.setProperty("levelSource", "getLevelSource"); p.setProperty("settingsType", base + "$Settings"); + p.setProperty("levelExists", "levelExists"); p.setProperty("levelList", "getLevelList"); p.setProperty("levelId", "getLevelId"); p.setProperty("levelName", "getLevelName"); + } + if (route.equals("original-scheduler")) { p.setProperty("scheduler", "schedule"); p.remove("overlay"); } + return p; + } +} diff --git a/src/test/java/com/osiris/autoplug/client/launcher/MicrosoftAccountServiceTest.java b/src/test/java/com/osiris/autoplug/client/launcher/MicrosoftAccountServiceTest.java new file mode 100644 index 00000000..39500bb6 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/launcher/MicrosoftAccountServiceTest.java @@ -0,0 +1,110 @@ +package com.osiris.autoplug.client.launcher; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import okhttp3.FormBody; +import okhttp3.Request; +import okio.Buffer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class MicrosoftAccountServiceTest { + @TempDir Path temporary; + private static final String CLIENT_ID = "00000000-0000-0000-0000-000000000001"; + private static final String UUID = "00000000000000000000000000000001"; + + @Test void deviceLoginExchangesAllTokensAndChecksOwnership() throws Exception { + Exchange transport = new Exchange(); + MicrosoftAccountService service = new MicrosoftAccountService(transport); + MicrosoftAccountService.DeviceLogin login = service.beginLogin(CLIENT_ID); + assertEquals("ABCD-EFGH", login.userCode); + assertEquals("https://www.microsoft.com/link", login.verificationUri); + MinecraftAccount account = service.completeLogin(login); + assertEquals("LicensedPlayer", account.username); + assertEquals("minecraft-token", account.accessToken); + assertEquals("rotated-refresh", account.refreshToken); + assertEquals("12345", account.xuid); + assertFalse(account.needsRefresh()); + assertEquals(Arrays.asList("/consumers/oauth2/v2.0/devicecode", "/consumers/oauth2/v2.0/token", "/user/authenticate", "/xsts/authorize", "/authentication/login_with_xbox", "/entitlements/mcstore", "/minecraft/profile"), transport.paths); + } + + @Test void refreshUsesRegisteredClientAndRotatesRefreshToken() throws Exception { + Exchange transport = new Exchange(); + MinecraftAccount expired = new MinecraftAccount("LicensedPlayer", UUID, "expired", "old-refresh", CLIENT_ID, "12345", false, Instant.EPOCH); + assertTrue(expired.needsRefresh()); + MinecraftAccount account = new MicrosoftAccountService(transport).refresh(expired); + assertEquals("rotated-refresh", account.refreshToken); + assertEquals("minecraft-token", account.accessToken); + assertFalse(transport.paths.contains("/consumers/oauth2/v2.0/devicecode")); + } + + @Test void unlicensedAccountNeverBecomesLaunchIdentity() { + Exchange transport = new Exchange(); transport.licensed = false; + MinecraftAccount expired = new MinecraftAccount("LicensedPlayer", UUID, "expired", "old-refresh", CLIENT_ID, "12345", false, Instant.EPOCH); + IOException error = assertThrows(IOException.class, () -> new MicrosoftAccountService(transport).refresh(expired)); + assertTrue(error.getMessage().contains("entitlement")); + assertFalse(transport.paths.contains("/minecraft/profile")); + } + + @Test void accountPersistenceAndDiagnosticOutputDoNotExposeTokens() throws Exception { + MinecraftAccount account = new MinecraftAccount("LicensedPlayer", UUID, "secret-minecraft", "secret-refresh", CLIENT_ID, "12345", false, Instant.now().plusSeconds(3600)); + AccountStore store = new AccountStore(temporary.resolve("accounts.json")); + store.save(account); store.save(account); + assertEquals(1, store.list().size()); assertEquals("secret-refresh", store.list().get(0).refreshToken); + PreparedLaunch command = new PreparedLaunch(temporary.resolve("java"), temporary, + Arrays.asList("Main", "--accessToken", account.accessToken), "1.21.1", 21); + assertFalse(command.toString().contains("secret")); assertFalse(account.toString().contains("secret")); + assertTrue(command.command().contains("secret-minecraft")); + store.remove(UUID); assertTrue(store.list().isEmpty()); + } + + @Test void offlineAccountsHaveDeterministicDistinctIdentities() { + MinecraftAccount one = MinecraftAccount.offline("PlayerOne"); + assertEquals(one.uuid, MinecraftAccount.offline("PlayerOne").uuid); + assertNotEquals(one.uuid, MinecraftAccount.offline("PlayerTwo").uuid); + assertFalse(one.needsRefresh()); assertTrue(one.offline); + assertThrows(IllegalArgumentException.class, () -> MinecraftAccount.offline("bad player")); + } + + private static final class Exchange implements MicrosoftAccountService.Transport { + final List paths = new ArrayList<>(); boolean licensed = true; + @Override public JsonObject execute(Request request, boolean allowError) throws IOException { + String path = request.url().encodedPath(); paths.add(path); + if (path.endsWith("/devicecode")) { + FormBody form = (FormBody) request.body(); assertEquals(CLIENT_ID, form.value(0)); + return json("{\"user_code\":\"ABCD-EFGH\",\"device_code\":\"device-secret\",\"verification_uri\":\"https://www.microsoft.com/link\",\"expires_in\":120,\"interval\":1}"); + } + if (path.endsWith("/token")) { + FormBody form = (FormBody) request.body(); assertEquals(CLIENT_ID, form.value(0)); + return json("{\"access_token\":\"microsoft-token\",\"refresh_token\":\"rotated-refresh\"}"); + } + if (path.equals("/user/authenticate")) { + assertTrue(body(request).contains("d=microsoft-token")); + return json("{\"Token\":\"xbox-token\",\"DisplayClaims\":{\"xui\":[{\"uhs\":\"user-hash\"}]}}"); + } + if (path.equals("/xsts/authorize")) { + assertTrue(body(request).contains("xbox-token")); assertTrue(body(request).contains("rp://api.minecraftservices.com/")); + return json("{\"Token\":\"xsts-token\",\"DisplayClaims\":{\"xui\":[{\"uhs\":\"user-hash\",\"xid\":\"12345\"}]}}"); + } + if (path.equals("/authentication/login_with_xbox")) { + assertEquals("XBL3.0 x=user-hash;xsts-token", JsonParser.parseString(body(request)).getAsJsonObject().get("identityToken").getAsString()); + return json("{\"access_token\":\"minecraft-token\",\"expires_in\":86400}"); + } + assertEquals("Bearer minecraft-token", request.header("Authorization")); + if (path.equals("/entitlements/mcstore")) return json(licensed ? "{\"items\":[{\"name\":\"game_minecraft\"}]}" : "{\"items\":[]}"); + if (path.equals("/minecraft/profile")) return json("{\"id\":\"" + UUID + "\",\"name\":\"LicensedPlayer\"}"); + throw new AssertionError("Unexpected endpoint " + path); + } + private static String body(Request request) throws IOException { Buffer buffer = new Buffer(); request.body().writeTo(buffer); return buffer.readUtf8(); } + private static JsonObject json(String value) { return JsonParser.parseString(value).getAsJsonObject(); } + } +} diff --git a/src/test/java/com/osiris/autoplug/client/launcher/MinecraftLauncherTest.java b/src/test/java/com/osiris/autoplug/client/launcher/MinecraftLauncherTest.java new file mode 100644 index 00000000..160820bd --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/launcher/MinecraftLauncherTest.java @@ -0,0 +1,212 @@ +package com.osiris.autoplug.client.launcher; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.*; + +class MinecraftLauncherTest { + @TempDir Path temporary; + + // Unmodified publisher metadata retrieved 2026-10-04 from Mojang's version_manifest_v2, + // meta.fabricmc.net/v2/versions/loader/1.21.1/0.16.10/profile/json and + // meta.quiltmc.org/v3/versions/loader/1.20.1/0.26.4/profile/json. Tests never need the network. + static JsonObject fixture(String name) { + InputStream stream = MinecraftLauncherTest.class.getResourceAsStream("/launcher/" + name + ".json"); + assertNotNull(stream, name); + try (InputStreamReader reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) { + return JsonParser.parseReader(reader).getAsJsonObject(); + } catch (IOException e) { throw new AssertionError(e); } + } + + @Test void manifestRuntimeRequirementsIncludeJava16() { + assertEquals(8, MinecraftLauncher.requiredJava(fixture("1.16.5"))); + assertEquals(16, MinecraftLauncher.requiredJava(fixture("1.17.1"))); + assertEquals(17, MinecraftLauncher.requiredJava(fixture("1.20.1"))); + assertEquals(21, MinecraftLauncher.requiredJava(fixture("1.21.1"))); + } + + @Test void officialLoaderProfilesPreserveVanillaArgumentsAndOverrideMainClass() throws Exception { + for (String loader : Arrays.asList("fabric-1.21.1", "quilt-1.20.1")) { + String version = loader.contains("1.21.1") ? "1.21.1" : "1.20.1"; + JsonObject merged = MinecraftLauncher.merge(fixture(version), fixture(loader)); + assertEquals(fixture(loader).get("mainClass"), merged.get("mainClass")); + List args = args(merged, new LaunchRequest(temporary.resolve("game with spaces"), version, "VANILLA", null, MinecraftAccount.offline("Player"), "localhost", 25570)); + assertTrue(args.contains("--username")); + assertEquals(1, Collections.frequency(args, "-cp")); + assertTrue(args.contains(temporary.resolve("game with spaces").toString())); + assertTrue(args.contains("localhost:25570")); + assertFalse(args.stream().anyMatch(a -> a.contains("${"))); + } + } + + @Test void directJoinUsesManifestQuickPlayAndLegacyServerFlags() throws Exception { + LaunchRequest old = new LaunchRequest(temporary, "1.16.5", "VANILLA", null, MinecraftAccount.offline("Player"), "example.test", 25570); + List legacy = args(fixture("1.16.5"), old); + assertEquals("example.test", legacy.get(legacy.indexOf("--server") + 1)); + assertEquals("25570", legacy.get(legacy.indexOf("--port") + 1)); + LaunchRequest modern = new LaunchRequest(temporary, "1.21.1", "VANILLA", null, MinecraftAccount.offline("Player"), "::1", 25570); + List quick = args(fixture("1.21.1"), modern); + assertEquals("[::1]:25570", quick.get(quick.indexOf("--quickPlayMultiplayer") + 1)); + assertFalse(quick.contains("--demo")); + assertFalse(quick.contains("--quickPlayRealms")); + } + + @Test void conditionalArgumentsUseLastMatchingRule() { + JsonObject rules = JsonParser.parseString("{\"rules\":[{\"action\":\"allow\"},{\"action\":\"disallow\",\"os\":{\"name\":\"" + MinecraftLauncher.osName() + "\"}}]}").getAsJsonObject(); + assertFalse(MinecraftLauncher.allowed(rules, Collections.emptyMap())); + JsonObject feature = JsonParser.parseString("{\"rules\":[{\"action\":\"allow\",\"features\":{\"is_demo_user\":true}}]}").getAsJsonObject(); + assertFalse(MinecraftLauncher.allowed(feature, Collections.emptyMap())); + assertTrue(MinecraftLauncher.allowed(feature, Collections.singletonMap("is_demo_user", true))); + } + + @Test void localQuickPlayUsesSaveFolderAndRetainsOriginalGameDirectory() throws Exception { + Path original = temporary.resolve(".minecraft"); + LaunchRequest request = new LaunchRequest(original, "1.21.1", "VANILLA", null, + MinecraftAccount.offline("Player"), null, 0, "My world folder"); + List args = args(fixture("1.21.1"), request); + assertEquals("My world folder", args.get(args.indexOf("--quickPlaySingleplayer") + 1)); + assertEquals(original.toString(), args.get(args.indexOf("--gameDir") + 1)); + assertEquals(original.resolve("logs/quick-play.json").toString(), args.get(args.indexOf("--quickPlayPath") + 1)); + assertFalse(args.contains("--quickPlayMultiplayer")); + assertFalse(args.contains("--quickPlayRealms")); + assertFalse(args.contains("--server")); + } + + @Test void legacyLocalWorldKeepsExactVersionWithoutInventingDirectLaunchFlags() throws Exception { + LaunchRequest request = new LaunchRequest(temporary, "1.16.5", "VANILLA", null, + MinecraftAccount.offline("Player"), null, 0, "Legacy world"); + List args = args(fixture("1.16.5"), request); + assertEquals("1.16.5", args.get(args.indexOf("--version") + 1)); + assertFalse(args.contains("--quickPlaySingleplayer")); + assertFalse(args.contains("--server")); + assertFalse(args.stream().anyMatch(value -> value.contains("${"))); + } + + @Test void localWorldRequestsRejectTraversalAndAmbiguousTargets() { + for (String invalid : Arrays.asList("", ".", "..", "../world", "nested/world", "nested\\world", "C:world", "world\n")) + assertThrows(IllegalArgumentException.class, () -> new LaunchRequest(temporary, "1.21.1", "VANILLA", null, + MinecraftAccount.offline("Player"), null, 0, invalid)); + assertThrows(IllegalArgumentException.class, () -> new LaunchRequest(temporary, "1.21.1", "VANILLA", null, + MinecraftAccount.offline("Player"), "localhost", 25565, "World")); + } + + @Test void mavenCoordinatesAndCachePathsRejectTraversal() throws Exception { + assertEquals("net/fabricmc/intermediary/1.21.1/intermediary-1.21.1.jar", MinecraftLauncher.mavenPath("net.fabricmc:intermediary:1.21.1")); + assertEquals("g/a/1/a-1-natives.jar", MinecraftLauncher.mavenPath("g:a:1:natives")); + assertThrows(IOException.class, () -> MinecraftLauncher.mavenPath("g:a:../../outside")); + assertThrows(IOException.class, () -> LauncherFiles.child(temporary, "../outside")); + Path zip = temporary.resolve("native.jar"); + try (ZipOutputStream out = new ZipOutputStream(Files.newOutputStream(zip))) { out.putNextEntry(new ZipEntry("../escape.dll")); out.write(1); } + assertThrows(IOException.class, () -> MinecraftLauncher.extractNatives(zip, temporary.resolve("natives"), Collections.emptyList())); + assertFalse(Files.exists(temporary.resolve("escape.dll"))); + } + + @Test void unknownPlaceholdersFailInsteadOfLaunchingBrokenCommand() { + assertThrows(IOException.class, () -> MinecraftLauncher.substitute("${unknown}", Collections.emptyMap())); + } + + @Test void unchangedNativesAreNotOverwrittenWhileAnotherProcessMayHaveThemLoaded() throws Exception { + Path jar = temporary.resolve("native.jar"); + try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(jar))) { zip.putNextEntry(new ZipEntry("native.dll")); zip.write(new byte[]{1, 2, 3}); } + Path directory = temporary.resolve("profile-natives"); + MinecraftLauncher.extractNatives(jar, directory, Collections.emptyList()); + Path dll = directory.resolve("native.dll"); + java.nio.file.attribute.FileTime before = java.nio.file.attribute.FileTime.fromMillis(123456000); + Files.setLastModifiedTime(dll, before); + MinecraftLauncher.extractNatives(jar, directory, Collections.emptyList()); + assertEquals(before, Files.getLastModifiedTime(dll)); + Files.write(dll, new byte[]{9, 9, 9}); + MinecraftLauncher.extractNatives(jar, directory, Collections.emptyList()); + assertArrayEquals(new byte[]{1, 2, 3}, Files.readAllBytes(dll)); + } + + @Test void inheritedClientJarMatchesNeoForgeModuleIgnoreList() throws Exception { + // This fixture was produced by the official NeoForge 21.1.172 installer. Keeping the + // vanilla jar's original filename causes duplicate packages in its module layers. + JsonObject metadata = MinecraftLauncher.merge(fixture("1.21.1"), fixture("neoforge-1.21.1")); + Path vanilla = temporary.resolve("1.21.1.jar"); Files.write(vanilla, new byte[]{1, 2, 3}); + Path effective = MinecraftLauncher.clientJarForProfile(vanilla, metadata, temporary.resolve("cache")); + assertEquals("neoforge-21.1.172.jar", effective.getFileName().toString()); + assertArrayEquals(Files.readAllBytes(vanilla), Files.readAllBytes(effective)); + LaunchRequest request = new LaunchRequest(temporary.resolve("profile"), "1.21.1", "NEOFORGE", "21.1.172", MinecraftAccount.offline("Player"), null, 0); + List args = MinecraftLauncher.buildArguments(metadata, request, Collections.singletonList(effective), temporary.resolve("natives"), temporary.resolve("assets"), "17", null); + assertTrue(args.contains("-DignoreList=client-extra," + effective.getFileName())); + } + + @Test void offlineLaunchPreparesDownloadsVerifiesAndReusesCacheWithoutNetwork() throws Exception { + byte[] jar = clientJar(); + byte[] assets = "{\"objects\":{}}".getBytes(StandardCharsets.UTF_8); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + String base = "http://127.0.0.1:" + server.getAddress().getPort(); + JsonObject metadata = JsonParser.parseString("{\"id\":\"fixture\",\"mainClass\":\"com.osiris.autoplug.client.launcher.OfflineClientFixture\",\"libraries\":[],\"javaVersion\":{\"majorVersion\":21},\"arguments\":{\"jvm\":[\"-cp\",\"${classpath}\"],\"game\":[\"--username\",\"${auth_player_name}\",\"--gameDir\",\"${game_directory}\",\"--accessToken\",\"${auth_access_token}\"]}}").getAsJsonObject(); + JsonObject downloads = new JsonObject(); downloads.add("client", descriptor(base + "/client.jar", jar)); metadata.add("downloads", downloads); + JsonObject assetIndex = descriptor(base + "/assets.json", assets); assetIndex.addProperty("id", "fixture"); metadata.add("assetIndex", assetIndex); + byte[] manifestBody = metadata.toString().getBytes(StandardCharsets.UTF_8); + JsonObject entry = descriptor(base + "/fixture.json", manifestBody); entry.addProperty("id", "fixture"); + JsonObject manifest = new JsonObject(); JsonArray versions = new JsonArray(); versions.add(entry); manifest.add("versions", versions); + serve(server, "/manifest", manifest.toString().getBytes(StandardCharsets.UTF_8)); + serve(server, "/fixture.json", manifestBody); serve(server, "/client.jar", jar); serve(server, "/assets.json", assets); + server.start(); + Path java = Paths.get(System.getProperty("java.home"), "bin", JavaRuntimeManager.executableName()); + JavaRuntimeManager runtime = new JavaRuntimeManager(temporary.resolve("runtimes")) { + @Override public Path resolve(int major, java.util.function.Consumer log) { return java; } + }; + MinecraftLauncher launcher = new MinecraftLauncher(temporary.resolve("cache"), runtime, base + "/manifest"); + LaunchRequest request = new LaunchRequest(temporary.resolve("isolated profile"), "fixture", "VANILLA", null, MinecraftAccount.offline("OfflinePlayer"), null, 0); + PreparedLaunch prepared; + try { prepared = launcher.prepare(request, ignored -> { }); } + finally { server.stop(0); } + Process process = launcher.launch(prepared); + assertTrue(process.waitFor(20, TimeUnit.SECONDS)); assertEquals(0, process.exitValue()); + Path observedDirectory = Paths.get(new String(Files.readAllBytes(request.gameDir.resolve("fixture-working-directory.txt")), StandardCharsets.UTF_8)); + assertTrue(Files.isSameFile(request.gameDir, observedDirectory), "The child must use the isolated profile, including canonical macOS /private paths"); + assertEquals(prepared.command(), launcher.prepare(request, ignored -> { }).command()); + assertFalse(Files.exists(temporary.resolve("fixture-working-directory.txt"))); + } + + private List args(JsonObject metadata, LaunchRequest request) throws Exception { + return MinecraftLauncher.buildArguments(metadata, request, Collections.singletonList(temporary.resolve("client with spaces.jar")), + temporary.resolve("natives"), temporary.resolve("assets"), "17", null); + } + private static JsonObject descriptor(String url, byte[] content) { + JsonObject result = new JsonObject(); result.addProperty("url", url); result.addProperty("size", content.length); + result.addProperty("sha1", com.osiris.autoplug.client.utils.UtilsCrypto.calculateSHA1Hash(content)); return result; + } + private static void serve(HttpServer server, String path, byte[] bytes) { + server.createContext(path, exchange -> { exchange.sendResponseHeaders(200, bytes.length); try (java.io.OutputStream out = exchange.getResponseBody()) { out.write(bytes); } }); + } + private static byte[] clientJar() throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + String name = "com/osiris/autoplug/client/launcher/OfflineClientFixture.class"; + try (JarOutputStream jar = new JarOutputStream(bytes); InputStream in = MinecraftLauncherTest.class.getResourceAsStream("/" + name)) { + assertNotNull(in); jar.putNextEntry(new JarEntry(name)); byte[] buffer = new byte[4096]; int count; + while ((count = in.read(buffer)) != -1) jar.write(buffer, 0, count); + } + return bytes.toByteArray(); + } +} diff --git a/src/test/java/com/osiris/autoplug/client/launcher/OfflineClientFixture.java b/src/test/java/com/osiris/autoplug/client/launcher/OfflineClientFixture.java new file mode 100644 index 00000000..cd75b1cf --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/launcher/OfflineClientFixture.java @@ -0,0 +1,9 @@ +package com.osiris.autoplug.client.launcher; + +/** Harmless isolated child JVM used to verify the complete offline launch path. */ +public class OfflineClientFixture { + public static void main(String[] args) throws Exception { + java.nio.file.Path directory = java.nio.file.Paths.get(System.getProperty("user.dir")); + java.nio.file.Files.write(directory.resolve("fixture-working-directory.txt"), directory.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } +} diff --git a/src/test/java/com/osiris/autoplug/client/launcher/ParallelDownloadsTest.java b/src/test/java/com/osiris/autoplug/client/launcher/ParallelDownloadsTest.java new file mode 100644 index 00000000..7c30c973 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/launcher/ParallelDownloadsTest.java @@ -0,0 +1,358 @@ +package com.osiris.autoplug.client.launcher; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.osiris.autoplug.client.utils.UtilsCrypto; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.*; + +class ParallelDownloadsTest { + @TempDir Path temporary; + + @Test void completePreparationCombinesFilesInBoundedQueueAndReusesVerifiedCache() throws Exception { + try (FixtureServer server = new FixtureServer()) { + JsonObject metadata = baseMetadata(); + JsonArray libraries = new JsonArray(); + List expectedClasspath = new ArrayList<>(); + for (int i = 0; i < 12; i++) { + String path = "test/library-" + i + ".jar"; + JsonObject artifact = server.descriptor("/library-" + i, bytes("library " + i), true); + artifact.addProperty("path", path); + JsonObject downloads = new JsonObject(); downloads.add("artifact", artifact); + JsonObject library = new JsonObject(); library.addProperty("name", "test:library:" + i); library.add("downloads", downloads); + libraries.add(library); + expectedClasspath.add(temporary.resolve("cache/libraries").resolve(path).toString()); + } + metadata.add("libraries", libraries); + JsonObject clientDownloads = new JsonObject(); clientDownloads.add("client", server.descriptor("/client", bytes("client"), true)); + metadata.add("downloads", clientDownloads); + JsonObject objects = new JsonObject(); + for (int i = 0; i < 4; i++) { + byte[] contents = bytes("asset " + i); + String hash = UtilsCrypto.calculateSHA1Hash(contents); + server.add("/objects/" + hash.substring(0, 2) + "/" + hash, contents, true); + JsonObject object = new JsonObject(); object.addProperty("hash", hash); object.addProperty("size", contents.length); + objects.add("sounds/" + i, object); + } + JsonObject index = new JsonObject(); index.add("objects", objects); + index.addProperty("virtual", true); index.addProperty("map_to_resources", true); + JsonObject assetIndex = server.descriptor("/index", bytes(index.toString()), false); assetIndex.addProperty("id", "fixture"); + metadata.add("assetIndex", assetIndex); + JsonObject loggingClient = new JsonObject(); + JsonObject loggingFile = server.descriptor("/logging", bytes("logging"), true); loggingFile.addProperty("id", "fixture.xml"); + loggingClient.add("file", loggingFile); loggingClient.addProperty("argument", "-Dlog4j.configurationFile=${path}"); + JsonObject logging = new JsonObject(); logging.add("client", loggingClient); metadata.add("logging", logging); + JsonObject version = server.descriptor("/version", bytes(metadata.toString()), false); version.addProperty("id", "fixture"); + JsonArray versions = new JsonArray(); versions.add(version); + JsonObject manifest = new JsonObject(); manifest.add("versions", versions); server.add("/manifest", bytes(manifest.toString()), false); + MinecraftLauncher launcher = launcher(server); + LaunchRequest request = new LaunchRequest(temporary.resolve("game"), "fixture", "VANILLA", null, MinecraftAccount.offline("Player"), null, 0); + List callbacks = new ArrayList<>(); + List events = new CopyOnWriteArrayList<>(); + PreparedLaunch prepared; + try (AutoCloseable ignored = DownloadProgress.subscribe(events::add)) { + prepared = launcher.prepare(request, callbacks::add); + } + assertTrue(server.maximum.get() >= 2, "Downloads must overlap"); + assertTrue(server.maximum.get() <= ParallelDownloads.WORKERS, "Active requests must remain bounded"); + assertEquals(18, server.artifactRequests.get()); + assertBatch(callbacks, "Minecraft files", 18); + expectedClasspath.add(temporary.resolve("cache/versions/fixture/fixture.jar").toString()); + assertEquals(String.join(java.io.File.pathSeparator, expectedClasspath), prepared.arguments.get(prepared.arguments.indexOf("-cp") + 1)); + assertTrue(prepared.arguments.contains("--fullscreen")); + for (int i = 0; i < 4; i++) { + assertArrayEquals(bytes("asset " + i), Files.readAllBytes(temporary.resolve("game/resources/sounds/" + i))); + assertArrayEquals(bytes("asset " + i), Files.readAllBytes(temporary.resolve("cache/assets/virtual/fixture/sounds/" + i))); + } + assertTrue(events.stream().anyMatch(event -> event.sourceUrl != null && event.sourceUrl.contains("/objects/") && event.complete)); + assertEquals(0, events.get(events.size() - 1).activeTransfers); + server.stop(); + // All metadata/artifacts have been cached. A second preparation remains offline-capable. + assertEquals(prepared.command(), launcher.prepare(request, value -> { }).command()); + assertNoPartialFiles(); + } + } + + @Test void mavenAndNativeDownloadsPreserveClasspathAndOrderedExtraction() throws Exception { + try (FixtureServer server = new FixtureServer()) { + JsonObject metadata = baseMetadata(); + JsonArray libraries = new JsonArray(); + for (int i = 0; i < 2; i++) { + JsonObject library = new JsonObject(); library.addProperty("name", "fixture:native:" + i); + JsonObject nativeMap = new JsonObject(); nativeMap.addProperty(MinecraftLauncher.osName(), "native"); library.add("natives", nativeMap); + JsonObject nativeArtifact = server.descriptor("/native-" + i, nativeJar("native " + i), true); + nativeArtifact.addProperty("path", "native-" + i + ".jar"); + JsonObject classifiers = new JsonObject(); classifiers.add("native", nativeArtifact); + JsonObject downloads = new JsonObject(); downloads.add("classifiers", classifiers); library.add("downloads", downloads); + libraries.add(library); + } + for (int i = 0; i < 8; i++) { + JsonObject library = new JsonObject(); library.addProperty("name", "fixture:maven:" + i); + library.addProperty("url", server.base() + "/maven/"); + byte[] content = bytes("maven " + i); + library.addProperty("sha1", UtilsCrypto.calculateSHA1Hash(content)); library.addProperty("size", content.length); + server.add("/maven/fixture/maven/" + i + "/maven-" + i + ".jar", content, true); + libraries.add(library); + } + Path generated = temporary.resolve("cache/libraries/fixture/generated/1/generated-1.jar"); + Files.createDirectories(generated.getParent()); Files.write(generated, bytes("generated")); + JsonObject installed = new JsonObject(); installed.addProperty("name", "fixture:generated:1"); installed.addProperty("url", ""); libraries.add(installed); + metadata.add("libraries", libraries); + List classpath = launcher(server).libraries(metadata, temporary.resolve("natives"), value -> { }); + assertEquals(9, classpath.size()); + for (int i = 0; i < 8; i++) assertEquals("maven-" + i + ".jar", classpath.get(i).getFileName().toString()); + assertEquals(generated, classpath.get(8)); + assertArrayEquals(bytes("native 1"), Files.readAllBytes(temporary.resolve("natives/shared-native.bin"))); + assertFalse(Files.exists(temporary.resolve("natives/META-INF/ignored"))); + assertTrue(server.maximum.get() >= 2); + } + } + + @Test void transientHttpAndCorruptBodiesRetryWithoutReplacingExistingFileEarly() throws Exception { + try (FixtureServer server = new FixtureServer()) { + byte[] original = new byte[]{7, 7, 7}, wanted = new byte[]{1, 2, 3}; + Path target = temporary.resolve("artifact.jar"); Files.write(target, original); + AtomicInteger attempts = new AtomicInteger(); + AtomicBoolean originalPreserved = new AtomicBoolean(true); + server.server.createContext("/retry", exchange -> { + if (!Arrays.equals(original, Files.readAllBytes(target))) originalPreserved.set(false); + int attempt = attempts.incrementAndGet(); + respond(exchange, attempt == 1 ? 503 : 200, attempt == 2 ? new byte[]{9, 9, 9} : wanted); + }); + List callbacks = new ArrayList<>(); + List events = new CopyOnWriteArrayList<>(); + try (AutoCloseable ignored = DownloadProgress.subscribe(events::add)) { + ParallelDownloads.run("Fixture files", Collections.singletonList(() -> { + LauncherFiles.download(server.base() + "/retry", target, UtilsCrypto.calculateSHA1Hash(wanted), wanted.length, callbacks::add); + return null; + }), callbacks::add); + } + assertEquals(3, attempts.get()); assertTrue(originalPreserved.get()); + assertArrayEquals(wanted, Files.readAllBytes(target)); + assertEquals(2, callbacks.stream().filter(value -> value.startsWith("Retrying ")).count()); + assertEquals(1, events.stream().filter(event -> event.complete).count()); + assertBatch(callbacks, "Fixture files", 1); + assertNoPartialFiles(); + } + } + + @Test void exhaustedRetriesPropagateFailureWithoutPublishingCompletion() throws Exception { + try (FixtureServer server = new FixtureServer()) { + AtomicInteger attempts = new AtomicInteger(); + server.server.createContext("/unavailable", exchange -> { + attempts.incrementAndGet(); respond(exchange, 503, bytes("unavailable")); + }); + Path target = temporary.resolve("existing.jar"); + byte[] original = bytes("previous file"); Files.write(target, original); + List callbacks = new ArrayList<>(); + List events = new CopyOnWriteArrayList<>(); + try (AutoCloseable ignored = DownloadProgress.subscribe(events::add)) { + IOException failure = assertThrows(IOException.class, () -> ParallelDownloads.run("Fixture files", Collections.singletonList(() -> { + LauncherFiles.download(server.base() + "/unavailable", target, UtilsCrypto.calculateSHA1Hash(bytes("wanted")), 6, null); + return null; + }), callbacks::add)); + assertTrue(failure.getMessage().contains("HTTP 503")); + } + assertEquals(3, attempts.get()); + assertArrayEquals(original, Files.readAllBytes(target)); + assertEquals(Collections.singletonList("Fixture files: 0/1"), callbacks); + assertFalse(events.stream().anyMatch(event -> event.complete)); + assertEquals(0, events.get(events.size() - 1).activeTransfers); + assertNoPartialFiles(); + } + } + + @Test void permanentFailureCancelsBlockedSocketsAndDoesNotStartQueuedFiles() throws Exception { + try (FixtureServer server = new FixtureServer()) { + CountDownLatch blocked = new CountDownLatch(2), release = new CountDownLatch(1); + AtomicInteger requests = new AtomicInteger(); + server.server.createContext("/failure", exchange -> { + requests.incrementAndGet(); + await(blocked); + respond(exchange, 404, bytes("missing")); + }); + server.server.createContext("/blocked", exchange -> { + requests.incrementAndGet(); + exchange.sendResponseHeaders(200, 1024); + exchange.getResponseBody().write(1); exchange.getResponseBody().flush(); blocked.countDown(); + await(release); exchange.close(); + }); + List> jobs = new ArrayList<>(); + for (int i = 0; i < 40; i++) { + int index = i; + jobs.add(() -> { LauncherFiles.download(server.base() + (index == 0 ? "/failure" : "/blocked"), + temporary.resolve("file-" + index), null, 1024, null); return null; }); + } + List callbacks = new ArrayList<>(); + List events = new CopyOnWriteArrayList<>(); + try (AutoCloseable ignored = DownloadProgress.subscribe(events::add)) { + assertTimeoutPreemptively(Duration.ofSeconds(8), () -> { + IOException failure = assertThrows(IOException.class, () -> ParallelDownloads.run("Fixture files", jobs, callbacks::add)); + assertTrue(failure.getMessage().contains("HTTP 404")); + }); + } finally { release.countDown(); } + assertTrue(requests.get() <= ParallelDownloads.WORKERS); + assertFalse(callbacks.contains("Fixture files: 40/40")); + assertFalse(events.stream().anyMatch(event -> event.complete)); + assertEquals(0, events.get(events.size() - 1).activeTransfers); + assertNoPartialFiles(); + for (int i = 0; i < 40; i++) assertFalse(Files.exists(temporary.resolve("file-" + i))); + } + } + + @Test void callerInterruptionCancelsActiveRequestAndRetainsInterruptStatus() throws Exception { + try (FixtureServer server = new FixtureServer()) { + CountDownLatch started = new CountDownLatch(1), release = new CountDownLatch(1), stopped = new CountDownLatch(1); + server.server.createContext("/interrupt", exchange -> { + exchange.sendResponseHeaders(200, 1024); exchange.getResponseBody().write(1); exchange.getResponseBody().flush(); + started.countDown(); await(release); exchange.close(); + }); + AtomicReference failure = new AtomicReference<>(); + AtomicBoolean interrupted = new AtomicBoolean(); + Thread caller = new Thread(() -> { + try { + ParallelDownloads.run("Fixture files", Collections.singletonList(() -> { + LauncherFiles.download(server.base() + "/interrupt", temporary.resolve("interrupted"), null, 1024, null); return null; + }), value -> { }); + } catch (Throwable e) { failure.set(e); interrupted.set(Thread.currentThread().isInterrupted()); } + finally { stopped.countDown(); } + }); + caller.setDaemon(true); caller.start(); + try { + assertTrue(started.await(3, TimeUnit.SECONDS)); caller.interrupt(); + assertTrue(stopped.await(7, TimeUnit.SECONDS), "Cancellation must stop a blocked socket read"); + assertTrue(failure.get() instanceof InterruptedIOException); assertTrue(interrupted.get()); + assertFalse(Files.exists(temporary.resolve("interrupted"))); assertNoPartialFiles(); + } finally { release.countDown(); caller.interrupt(); } + } + } + + @Test void fullscreenDefaultsToEnabledForLegacyAndModernLoadersAndCanBeDisabled() throws Exception { + for (String version : Arrays.asList("1.16.5", "1.21.1")) { + JsonObject vanilla = MinecraftLauncherTest.fixture(version); + for (String loader : Arrays.asList("VANILLA", "FABRIC", "FORGE", "NEOFORGE", "QUILT")) { + LaunchRequest defaults = new LaunchRequest(temporary, version, loader, null, MinecraftAccount.offline("Player"), null, 0); + LaunchRequest windowed = new LaunchRequest(temporary, version, loader, null, MinecraftAccount.offline("Player"), null, 0, false); + assertTrue(defaults.fullscreen); + List args = arguments(vanilla, defaults); + assertEquals(1, Collections.frequency(args, "--fullscreen")); + assertTrue(args.indexOf("--fullscreen") > args.indexOf(vanilla.get("mainClass").getAsString())); + assertFalse(arguments(vanilla, windowed).contains("--fullscreen")); + } + } + assertFalse(new LaunchRequest(temporary, "1.21.1", "VANILLA", null, MinecraftAccount.offline("Player"), null, 0, "World", false).fullscreen); + } + + private List arguments(JsonObject metadata, LaunchRequest request) throws Exception { + return MinecraftLauncher.buildArguments(metadata, request, Collections.singletonList(temporary.resolve("client.jar")), + temporary.resolve("natives"), temporary.resolve("assets"), "fixture", null); + } + private MinecraftLauncher launcher(FixtureServer server) { + JavaRuntimeManager runtime = new JavaRuntimeManager(temporary.resolve("runtimes")) { + @Override public Path resolve(int major, java.util.function.Consumer progress) { + return Paths.get(System.getProperty("java.home"), "bin", JavaRuntimeManager.executableName()); + } + }; + return new MinecraftLauncher(temporary.resolve("cache"), runtime, server.base() + "/manifest", server.base() + "/objects/"); + } + private static JsonObject baseMetadata() { + JsonObject metadata = new JsonObject(); metadata.addProperty("id", "fixture"); + metadata.addProperty("mainClass", "net.minecraft.client.main.Main"); metadata.addProperty("minecraftArguments", "--username ${auth_player_name}"); + return metadata; + } + private static void assertBatch(List callbacks, String label, int total) { + List actual = new ArrayList<>(); + for (String callback : callbacks) if (callback.startsWith(label + ": ")) actual.add(callback); + assertEquals(total + 1, actual.size()); + for (int i = 0; i <= total; i++) assertEquals(label + ": " + i + "/" + total, actual.get(i)); + } + private void assertNoPartialFiles() throws IOException { + try (java.util.stream.Stream files = Files.walk(temporary)) { + assertFalse(files.anyMatch(path -> path.getFileName().toString().endsWith(".part"))); + } + } + private static byte[] bytes(String value) { return value.getBytes(StandardCharsets.UTF_8); } + private static byte[] nativeJar(String value) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(bytes)) { + zip.putNextEntry(new ZipEntry("shared-native.bin")); zip.write(bytes(value)); zip.closeEntry(); + zip.putNextEntry(new ZipEntry("META-INF/ignored")); zip.write(1); zip.closeEntry(); + } + return bytes.toByteArray(); + } + private static void await(CountDownLatch latch) { + try { latch.await(5, TimeUnit.SECONDS); } + catch (InterruptedException e) { Thread.currentThread().interrupt(); } + } + private static void respond(HttpExchange exchange, int status, byte[] contents) throws IOException { + exchange.sendResponseHeaders(status, contents.length); + try (java.io.OutputStream out = exchange.getResponseBody()) { out.write(contents); } + finally { exchange.close(); } + } + private static final class FixtureServer implements AutoCloseable { + final HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + final ExecutorService workers = Executors.newCachedThreadPool(); + final AtomicInteger active = new AtomicInteger(), maximum = new AtomicInteger(), artifactRequests = new AtomicInteger(); + final Map bodies = new ConcurrentHashMap<>(); + final java.util.Set delayed = ConcurrentHashMap.newKeySet(); + private boolean stopped; + FixtureServer() throws IOException { + server.setExecutor(workers); + server.createContext("/", exchange -> { + String path = exchange.getRequestURI().getPath(); + byte[] content = bodies.get(path); + if (content == null) { respond(exchange, 404, bytes("missing")); return; } + if (delayed.contains(path)) { + artifactRequests.incrementAndGet(); + maximum.accumulateAndGet(active.incrementAndGet(), Math::max); + try { Thread.sleep(80); } + catch (InterruptedException e) { Thread.currentThread().interrupt(); } + finally { active.decrementAndGet(); } + } + respond(exchange, 200, content); + }); + server.start(); + } + String base() { return "http://127.0.0.1:" + server.getAddress().getPort(); } + void add(String path, byte[] body, boolean delay) { bodies.put(path, body); if (delay) delayed.add(path); } + JsonObject descriptor(String path, byte[] body, boolean delay) { + add(path, body, delay); + JsonObject descriptor = new JsonObject(); descriptor.addProperty("url", base() + path); + descriptor.addProperty("sha1", UtilsCrypto.calculateSHA1Hash(body)); descriptor.addProperty("size", body.length); + return descriptor; + } + void stop() { if (!stopped) { server.stop(0); stopped = true; } } + @Override public void close() { stop(); workers.shutdownNow(); } + } +} diff --git a/src/test/java/com/osiris/autoplug/client/profiles/FabricDefaultProfileTest.java b/src/test/java/com/osiris/autoplug/client/profiles/FabricDefaultProfileTest.java new file mode 100644 index 00000000..87152e26 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/profiles/FabricDefaultProfileTest.java @@ -0,0 +1,177 @@ +package com.osiris.autoplug.client.profiles; + +import com.google.gson.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.security.MessageDigest; +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.zip.*; +import static org.junit.jupiter.api.Assertions.*; + +class FabricDefaultProfileTest { + @TempDir Path temporary; + + @Test void bootstrapIsVersionSpecificAndPreservesRenamedCustomizedDefault() throws Exception { + ProfileStore store = store(); Profile first = store.ensureFabricDefault("1.21.1"); + first.name = "My utilities"; store.save(first); + Files.write(first.getDirectory().resolve("options.txt"), "fullscreen:true".getBytes(StandardCharsets.UTF_8)); + assertEquals(first.id, store.ensureFabricDefault("1.21.1").id); + Profile other = store.ensureFabricDefault("1.20.1"); assertNotEquals(first.id, other.id); + assertEquals(2, store.list().size()); assertEquals("My utilities", store.get(first.id).name); + assertEquals("fullscreen:true", new String(Files.readAllBytes(first.getDirectory().resolve("options.txt")), StandardCharsets.UTF_8)); + assertEquals("FABRIC", other.loader); assertEquals(ProfileType.MODS, other.type); + assertFalse(first.builtinPresetInstalled); + } + + @Test void exactClientPackIncludesOnlyRequiredDependenciesAndInstallsOnce() throws Exception { + ProfileStore store = store(); Profile profile = store.ensureFabricDefault("1.21.1"); Fixture fixture = new Fixture(); + fixture.add("placeholder", "optional", "optional", "*"); + fixture.dependency("modmenu", "placeholder", "placeholder-release", "required"); + fixture.dependency("entityculling", "fabric-api", null, "required"); + fixture.dependency("placeholder", "fabric-api", null, "required"); + fixture.dependency("fabric-api", "placeholder", null, "required"); // Real cycle shape; each project must be visited once. + fixture.dependency("modmenu", "unwanted-optional", null, "optional"); + fixture.dependency("modmenu", "unwanted-embedded", null, "embedded"); + prepare(store, profile, fixture); + assertEquals(6, fixture.downloads.size()); assertEquals(6, new HashSet<>(fixture.downloads).size()); + Profile installed = store.get(profile.id); assertTrue(installed.builtinPresetInstalled); + ProfileCollection collection = new ProfileCollection().scan(installed); + assertEquals(6, collection.entries.size()); + assertTrue(collection.entries.stream().anyMatch(e -> "placeholder".equals(e.modrinthId))); + assertTrue(collection.entries.stream().allMatch(e -> e.modrinthId != null && e.version != null)); + Files.delete(installed.getDirectory().resolve("mods/sodium.jar")); // Deliberate user customization must not be re-seeded. + int metadataLookups = fixture.requestedGames.size(); + prepare(store, installed, fixture); + assertEquals(6, fixture.downloads.size()); assertFalse(Files.exists(installed.getDirectory().resolve("mods/sodium.jar"))); + assertEquals(metadataLookups, fixture.requestedGames.size(), "Installed presets must launch offline without provider lookups"); + assertTrue(fixture.requestedGames.stream().allMatch("1.21.1"::equals)); + } + + @Test void noExactStableReleaseOrPinnedWrongGameCannotProducePartialPack() throws Exception { + for (boolean pinned : new boolean[]{false, true}) { + ProfileStore store = new ProfileStore(temporary.resolve("profiles-" + pinned)); + Profile profile = store.ensureFabricDefault("1.21.1"); Fixture fixture = new Fixture(); + if (pinned) { + fixture.add("library", "optional", "optional", "*"); + fixture.releases.get("library").add("game_versions", array("1.20.1")); + fixture.dependency("modmenu", "library", "library-release", "required"); + } else { + fixture.releases.get("sodium").add("game_versions", array("1.21.2")); + } + IOException error = assertThrows(IOException.class, () -> prepare(store, profile, fixture)); + assertTrue(error.getMessage().contains("exact Minecraft 1.21.1")); + assertTrue(fixture.downloads.isEmpty()); assertEmptyMods(profile); assertFalse(store.get(profile.id).builtinPresetInstalled); + } + } + + @Test void serverRequiredDependencyAndServerOnlyJarAreRejected() throws Exception { + for (boolean inMetadata : new boolean[]{true, false}) { + ProfileStore store = new ProfileStore(temporary.resolve("profiles-" + inMetadata)); + Profile profile = store.ensureFabricDefault("1.21.1"); Fixture fixture = new Fixture(); + fixture.add("library", "optional", inMetadata ? "required" : "optional", inMetadata ? "*" : "server"); + fixture.dependency("modmenu", "library", null, "required"); + assertThrows(IOException.class, () -> prepare(store, profile, fixture)); + assertEmptyMods(profile); assertFalse(store.get(profile.id).builtinPresetInstalled); + if (inMetadata) assertTrue(fixture.downloads.isEmpty()); + } + } + + @Test void interruptedDownloadAndFailedCommitLeaveNoInstalledSubsetAndCanRetry() throws Exception { + AtomicBoolean failCommit = new AtomicBoolean(); + ProfileStore store = new ProfileStore(temporary.resolve("profiles")) { + @Override public void save(Profile profile) throws IOException { if (failCommit.get()) throw new IOException("fixture metadata failure"); super.save(profile); } + }; + Profile profile = store.ensureFabricDefault("1.21.1"); Fixture fixture = new Fixture(); + fixture.failAfter = 2; + assertThrows(IOException.class, () -> prepare(store, profile, fixture)); + assertEmptyMods(profile); assertFalse(store.get(profile.id).builtinPresetInstalled); + fixture.failAfter = -1; failCommit.set(true); + assertThrows(IOException.class, () -> prepare(store, store.get(profile.id), fixture)); + assertEmptyMods(profile); assertFalse(store.get(profile.id).builtinPresetInstalled); + assertFalse(Files.exists(profile.getDirectory().resolve("collection.json"))); + failCommit.set(false); prepare(store, store.get(profile.id), fixture); + assertTrue(store.get(profile.id).builtinPresetInstalled); + assertEquals(5, new ProfileCollection().scan(store.get(profile.id)).entries.size()); + } + + @Test void providerConflictAndExistingUserCollectionArePreserved() throws Exception { + ProfileStore store = store(); Profile profile = store.ensureFabricDefault("1.21.1"); Fixture fixture = new Fixture(); + fixture.dependency("sodium", "entityculling", null, "incompatible"); + assertThrows(IOException.class, () -> prepare(store, profile, fixture)); + assertTrue(fixture.downloads.isEmpty()); assertEmptyMods(profile); + fixture.releases.get("sodium").add("dependencies", new JsonArray()); + Path custom = profile.getDirectory().resolve("mods/custom.jar"); byte[] bytes = fixture.jars.get("sodium"); Files.write(custom, bytes); + assertThrows(IOException.class, () -> prepare(store, profile, fixture)); + assertArrayEquals(bytes, Files.readAllBytes(custom)); assertFalse(store.get(profile.id).builtinPresetInstalled); + } + + @Test void malformedJarStillAppearsInCollectionBeforeApplicationLoggerStarts() throws Exception { + ProfileStore store = store(); Profile profile = store.ensureFabricDefault("1.21.1"); + Files.write(profile.getDirectory().resolve("mods/broken.jar"), "not a zip".getBytes(StandardCharsets.UTF_8)); + ProfileCollection collection = new ProfileCollection().scan(profile); + assertEquals(1, collection.entries.size()); assertEquals("broken.jar", collection.entries.get(0).file); + } + + @Test void emptyProviderReleaseListAndCorruptedDownloadNeverActivateSubset() throws Exception { + for (boolean emptyProvider : new boolean[]{true, false}) { + ProfileStore store = new ProfileStore(temporary.resolve("profiles-" + emptyProvider)); + Profile profile = store.ensureFabricDefault("1.21.1"); Fixture fixture = new Fixture(); + if (emptyProvider) fixture.emptyVersionsFor = "sodium"; + else fixture.jars.put("sodium", new byte[]{1, 2, 3}); + IOException failure = assertThrows(IOException.class, () -> prepare(store, profile, fixture)); + assertTrue(failure.getMessage().contains("Nothing will launch")); + assertFalse(store.get(profile.id).builtinPresetInstalled); assertEmptyMods(profile); + if (emptyProvider) assertTrue(fixture.downloads.isEmpty()); + } + } + + private ProfileStore store() throws Exception { return new ProfileStore(temporary.resolve("profiles")); } + private static void assertEmptyMods(Profile profile) throws Exception { + try (java.util.stream.Stream files = Files.list(profile.getDirectory().resolve("mods"))) { assertEquals(0, files.count()); } + } + private static void prepare(ProfileStore store, Profile profile, Fixture fixture) throws Exception { + try (ProfileLease ignored = new ProfileLease(profile.getDirectory())) { + new FabricDefaultProfile(store, fixture, (release, path) -> { + if (fixture.failAfter >= 0 && fixture.downloads.size() >= fixture.failAfter) throw new IOException("fixture connection lost"); + fixture.downloads.add(release.modrinthProjectId); Files.write(path, fixture.jars.get(release.modrinthProjectId)); + }).prepare(profile, ignoredMessage -> { }); + } + } + private static JsonArray array(String... values) { JsonArray array = new JsonArray(); for (String value : values) array.add(value); return array; } + private static class Fixture implements FabricDefaultProfile.Catalog { + final Map projects = new LinkedHashMap<>(), releases = new LinkedHashMap<>(); + final Map jars = new HashMap<>(); + final List downloads = new ArrayList<>(), requestedGames = new ArrayList<>(); + int failAfter = -1; + String emptyVersionsFor; + Fixture() throws Exception { for (String root : FabricDefaultProfile.ROOTS) add(root, root.equals("fabric-api") ? "optional" : "required", root.equals("fabric-api") ? "optional" : "unsupported", root.equals("fabric-api") ? "*" : "client"); } + void add(String id, String client, String server, String environment) throws Exception { + JsonObject project = new JsonObject(); project.addProperty("id", id); project.addProperty("title", id); project.addProperty("project_type", "mod"); + project.addProperty("client_side", client); project.addProperty("server_side", server); projects.put(id, project); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ZipOutputStream jar = new ZipOutputStream(bytes)) { + jar.putNextEntry(new ZipEntry("fabric.mod.json")); + jar.write(("{\"schemaVersion\":1,\"id\":\"" + id.replace('-', '_') + "\",\"version\":\"1.0\",\"name\":\"" + id + "\",\"environment\":\"" + environment + "\"}").getBytes(StandardCharsets.UTF_8)); jar.closeEntry(); + } + byte[] content = bytes.toByteArray(); jars.put(id, content); + JsonObject version = new JsonObject(); version.addProperty("id", id + "-release"); version.addProperty("project_id", id); + version.addProperty("version_number", "1.0+1.21.1"); version.addProperty("version_type", "release"); version.addProperty("status", "listed"); + version.addProperty("date_published", "2026-10-01T00:00:00Z"); version.add("game_versions", array("1.21.1")); version.add("loaders", array("fabric")); version.add("dependencies", new JsonArray()); + JsonObject file = new JsonObject(); file.addProperty("filename", id + ".jar"); file.addProperty("url", "https://cdn.modrinth.com/data/" + id + "/mod.jar"); file.addProperty("primary", true); file.addProperty("size", content.length); + StringBuilder hash = new StringBuilder(); for (byte b : MessageDigest.getInstance("SHA-512").digest(content)) hash.append(String.format("%02x", b & 255)); + JsonObject hashes = new JsonObject(); hashes.addProperty("sha512", hash.toString()); file.add("hashes", hashes); + JsonArray files = new JsonArray(); files.add(file); version.add("files", files); releases.put(id, version); + } + void dependency(String from, String project, String version, String type) { + JsonObject dependency = new JsonObject(); dependency.addProperty("project_id", project); dependency.addProperty("version_id", version); dependency.addProperty("dependency_type", type); + releases.get(from).getAsJsonArray("dependencies").add(dependency); + } + public JsonObject project(String id) throws Exception { if (!projects.containsKey(id)) throw new IOException("Unexpected dependency " + id); return projects.get(id); } + public JsonArray versions(String id, String gameVersion) { requestedGames.add(gameVersion); JsonArray result = new JsonArray(); if (!id.equals(emptyVersionsFor)) result.add(releases.get(id)); return result; } + public JsonObject version(String id) throws Exception { for (JsonObject version : releases.values()) if (id.equals(version.get("id").getAsString())) return version; throw new IOException("Unknown version " + id); } + } +} diff --git a/src/test/java/com/osiris/autoplug/client/profiles/LauncherAuthenticationRaceTest.java b/src/test/java/com/osiris/autoplug/client/profiles/LauncherAuthenticationRaceTest.java new file mode 100644 index 00000000..82114f70 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/profiles/LauncherAuthenticationRaceTest.java @@ -0,0 +1,164 @@ +package com.osiris.autoplug.client.profiles; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.osiris.autoplug.client.launcher.*; +import com.osiris.autoplug.client.ui.LauncherActions.SettingsInfo; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.lang.reflect.Constructor; +import java.nio.file.*; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import static org.junit.jupiter.api.Assertions.*; + +/** No network or real credentials: production service state transitions with a latched fake provider. */ +class LauncherAuthenticationRaceTest { + @TempDir Path temporary; + static class Attempt { + final CountDownLatch beginEntered = new CountDownLatch(1), beginRelease = new CountDownLatch(1); + final CountDownLatch polling = new CountDownLatch(1), release = new CountDownLatch(1), done = new CountDownLatch(1); + volatile Throwable error; + Attempt(boolean delayBegin) { if (!delayBegin) beginRelease.countDown(); } + } + static class FakeAuth extends MicrosoftAccountService { + final Map attempts = new ConcurrentHashMap<>(); + @Override public DeviceLogin beginLogin(String clientId) { + Attempt attempt = attempts.get(Thread.currentThread().getName()); + attempt.beginEntered.countDown(); awaitUninterruptibly(attempt.beginRelease); + try { + Constructor constructor = DeviceLogin.class.getDeclaredConstructor(String.class, JsonObject.class); + constructor.setAccessible(true); + return constructor.newInstance("fixture-app-id", JsonParser.parseString("{\"user_code\":\"FAKE-CODE\",\"device_code\":\"fixture-only\",\"verification_uri\":\"https://example.invalid/fixture\",\"expires_in\":120,\"interval\":1}").getAsJsonObject()); + } catch (Exception e) { throw new AssertionError(e); } + } + @Override public MinecraftAccount completeLogin(DeviceLogin login) throws InterruptedException { + String name = Thread.currentThread().getName(); Attempt attempt = attempts.get(name); + attempt.polling.countDown(); awaitUninterruptibly(attempt.release); + // Deliberately emulate an in-flight request which finishes despite cancellation. + return new MinecraftAccount(name, name.equals("LoginA") ? "00000000000000000000000000000001" : "00000000000000000000000000000002", + "fixture-access", "fixture-refresh", "fixture-app-id", "", false, Instant.MAX); + } + } + static void awaitUninterruptibly(CountDownLatch latch) { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while (latch.getCount() > 0) try { + if (System.nanoTime() > deadline) throw new AssertionError("Fixture release timed out"); + latch.await(100, TimeUnit.MILLISECONDS); + } catch (InterruptedException ignored) { } + } + static void await(CountDownLatch latch) throws Exception { assertTrue(latch.await(5, TimeUnit.SECONDS), "Fixture milestone timed out"); } + LauncherServices service(FakeAuth fake) throws Exception { + LauncherServices service = new LauncherServices(temporary.resolve("app"), temporary.resolve("minecraft"), ignored -> {}, fake); + SettingsInfo settings = service.settings(); settings.rememberAccount = true; settings.microsoftClientId = "fixture-app-id"; service.saveSettings(settings); + return service; + } + Attempt start(LauncherServices service, FakeAuth fake, String name, boolean delayBegin, Consumer instructions) { + Attempt attempt = new Attempt(delayBegin); fake.attempts.put(name, attempt); + new Thread(() -> { + try { service.signInMicrosoft(instructions); } catch (Throwable e) { attempt.error = e; } + finally { attempt.done.countDown(); } + }, name).start(); + return attempt; + } + void selected(LauncherServices service, String label, String uuid, int remembered) throws Exception { + assertEquals(label, service.settings().account); + JsonObject persisted = JsonParser.parseString(new String(Files.readAllBytes(temporary.resolve("app/settings.json")), java.nio.charset.StandardCharsets.UTF_8)).getAsJsonObject(); + assertEquals(uuid, persisted.get("selectedAccount").getAsString(), "Disk choice must also stay current"); + assertEquals(remembered, new AccountStore(temporary.resolve("app/accounts.json")).list().size(), "Obsolete tokens must not be persisted"); + } + @Test void offlineChoiceSurvivesLateLogin() throws Exception { + FakeAuth fake = new FakeAuth(); try (LauncherServices service = service(fake)) { + Attempt a = start(service, fake, "LoginA", false, ignored -> {}); + try { await(a.polling); service.useOfflineAccount("LocalPlayer"); a.release.countDown(); await(a.done); + selected(service, "LocalPlayer (offline)", "", 0); assertInstanceOf(CancellationException.class, a.error); + } finally { a.beginRelease.countDown(); a.release.countDown(); await(a.done); } + } + } + @Test void latestLoginSurvivesReverseCompletion() throws Exception { + FakeAuth fake = new FakeAuth(); try (LauncherServices service = service(fake)) { + Attempt a = start(service, fake, "LoginA", false, ignored -> {}), b = null; + try { await(a.polling); b = start(service, fake, "LoginB", false, ignored -> {}); await(b.polling); + b.release.countDown(); await(b.done); assertNull(b.error); a.release.countDown(); await(a.done); + selected(service, "LoginB (Microsoft)", "00000000000000000000000000000002", 1); + assertInstanceOf(CancellationException.class, a.error); + } finally { a.release.countDown(); if (b != null) b.release.countDown(); await(a.done); if (b != null) await(b.done); } + } + } + @Test void dialogCancellationSurvivesLateLogin() throws Exception { + FakeAuth fake = new FakeAuth(); try (LauncherServices service = service(fake)) { + Attempt a = start(service, fake, "LoginA", false, ignored -> {}); + try { await(a.polling); service.cancelMicrosoftSignIn(); a.release.countDown(); await(a.done); selected(service, "Player (offline)", "", 0); } + finally { a.release.countDown(); await(a.done); } + } + } + @Test void serviceCloseSurvivesLateLogin() throws Exception { + FakeAuth fake = new FakeAuth(); LauncherServices service = service(fake); + Attempt a = start(service, fake, "LoginA", false, ignored -> {}); + try { await(a.polling); service.close(); a.release.countDown(); await(a.done); selected(service, "Player (offline)", "", 0); } + finally { a.release.countDown(); await(a.done); service.close(); } + } + @Test void lateDeviceInstructionsAreSuppressedAfterOfflineChoice() throws Exception { + FakeAuth fake = new FakeAuth(); AtomicInteger instructions = new AtomicInteger(); try (LauncherServices service = service(fake)) { + Attempt a = start(service, fake, "LoginA", true, ignored -> instructions.incrementAndGet()); + try { await(a.beginEntered); service.useOfflineAccount("LocalPlayer"); a.beginRelease.countDown(); a.release.countDown(); await(a.done); + assertEquals(0, instructions.get()); selected(service, "LocalPlayer (offline)", "", 0); + } finally { a.beginRelease.countDown(); a.release.countDown(); await(a.done); } + } + } + @Test void queuedLoginCannotStartAfterOfflineClick() throws Exception { + FakeAuth fake = new FakeAuth(); try (LauncherServices service = service(fake)) { + long attempt = service.beginAccountChange(); SettingsInfo form = service.settings(); + service.useOfflineAccount("LocalPlayer"); + Attempt queued = new Attempt(false); queued.release.countDown(); fake.attempts.put(Thread.currentThread().getName(), queued); + assertThrows(CancellationException.class, () -> service.signInMicrosoft(attempt, form, ignored -> {})); + selected(service, "LocalPlayer (offline)", "", 0); + } + } + @Test void queuedOfflineChoiceCannotCancelNewerLogin() throws Exception { + FakeAuth fake = new FakeAuth(); try (LauncherServices service = service(fake)) { + long offlineAttempt = service.beginAccountChange(); + Attempt b = start(service, fake, "LoginB", false, ignored -> {}); + try { await(b.polling); + assertThrows(CancellationException.class, () -> service.useOfflineAccount(offlineAttempt, "LocalPlayer")); + b.release.countDown(); await(b.done); assertNull(b.error); + selected(service, "LoginB (Microsoft)", "00000000000000000000000000000002", 1); + } finally { b.release.countDown(); await(b.done); } + } + } + @Test void cancellationInterruptsPollingWorker() throws Exception { + FakeAuth fake = new FakeAuth() { + @Override public MinecraftAccount completeLogin(DeviceLogin login) throws InterruptedException { + Attempt attempt = attempts.get(Thread.currentThread().getName()); + attempt.polling.countDown(); attempt.release.await(); return super.completeLogin(login); + } + }; + try (LauncherServices service = service(fake)) { + Attempt a = start(service, fake, "LoginA", false, ignored -> {}); + try { await(a.polling); service.cancelMicrosoftSignIn(); + assertTrue(a.done.await(2, TimeUnit.SECONDS), "Cancellation must interrupt polling without waiting for a response"); + assertInstanceOf(CancellationException.class, a.error); selected(service, "Player (offline)", "", 0); + } finally { a.release.countDown(); await(a.done); } + } + } + @Test void changedAuthPreferencesRejectLateLogin() throws Exception { + FakeAuth fake = new FakeAuth(); try (LauncherServices service = service(fake)) { + Attempt a = start(service, fake, "LoginA", false, ignored -> {}); + try { await(a.polling); SettingsInfo settings = service.settings(); settings.microsoftClientId = "other-fixture-app"; + settings.rememberAccount = false; service.saveSettings(settings); a.release.countDown(); await(a.done); + selected(service, "Player (offline)", "", 0); assertInstanceOf(CancellationException.class, a.error); + } finally { a.release.countDown(); await(a.done); } + } + } + @Test void currentLoginStillCommitsAndPersists() throws Exception { + FakeAuth fake = new FakeAuth(); try (LauncherServices service = service(fake)) { + Attempt a = start(service, fake, "LoginA", false, ignored -> {}); + try { await(a.polling); a.release.countDown(); await(a.done); assertNull(a.error); + selected(service, "LoginA (Microsoft)", "00000000000000000000000000000001", 1); + } finally { a.release.countDown(); await(a.done); } + } + } +} diff --git a/src/test/java/com/osiris/autoplug/client/profiles/LauncherCommandsTest.java b/src/test/java/com/osiris/autoplug/client/profiles/LauncherCommandsTest.java new file mode 100644 index 00000000..b06bbbc5 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/profiles/LauncherCommandsTest.java @@ -0,0 +1,129 @@ +package com.osiris.autoplug.client.profiles; + +import com.osiris.autoplug.client.ui.LauncherActions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.nio.file.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class LauncherCommandsTest { + @TempDir Path temp; + @Test void consoleTokenizerPreservesVersionsAddressesAndWindowsPaths() throws Exception { + LauncherCommands commands = new LauncherCommands(null, ignored -> { }); + assertEquals(Arrays.asList(".profiles", "create", "My Pack", "1.21.1", "FABRIC"), commands.tokenize(".profiles create \"My Pack\" 1.21.1 FABRIC")); + assertEquals(Arrays.asList(".profiles", "add", "pack", "C:\\Game Files\\mod.jar"), commands.tokenize(".profiles add pack \"C:\\Game Files\\mod.jar\"")); + assertEquals("127.0.0.1:25570", commands.tokenize(".mc launch pack --server 127.0.0.1:25570").get(4)); + assertThrows(java.io.IOException.class, () -> commands.tokenize(".profiles create \"unterminated")); + } + @Test void commandsPersistProfilesAndSettingsAcrossInvocationsWithoutGlobalConfig() throws Exception { + List output = new ArrayList<>(); String id; + try (LauncherServices services = new LauncherServices(temp, temp.resolve("minecraft"), output::add)) { + LauncherCommands commands = new LauncherCommands(services, output::add); + commands.execute(commands.tokenize(".profiles create \"My Pack\" 1.21.1 FABRIC --template")); + id = services.profiles().stream().filter(p -> p.name.equals("My Pack")).findFirst().get().id; + commands.execute(commands.tokenize(".mc account offline FixtureUser")); + LauncherActions.SettingsInfo settings = services.settings(); settings.defaultProfile = id; settings.port = 25570; settings.upnp = false; services.saveSettings(settings); + assertThrows(java.io.IOException.class, () -> services.launchProfile(id, null, 25565)); + assertFalse(Files.exists(temp.resolve("autoplug"))); // No legacy server initialization. + assertFalse(Files.exists(temp.resolve("accounts.json"))); // Offline setting creates no credential store. + } + try (LauncherServices services = new LauncherServices(temp, temp.resolve("minecraft"), output::add)) { + assertEquals("FixtureUser (offline)", services.settings().account); + assertEquals(25570, services.settings().port); assertFalse(services.settings().upnp); + assertNotEquals(id, services.settings().defaultProfile, "Templates must not remain the selected launch default"); + assertFalse(services.getProfiles().get(services.settings().defaultProfile).template); + assertTrue(services.getProfiles().get(id).template); + LauncherCommands commands = new LauncherCommands(services, output::add); + commands.execute(commands.tokenize(".profiles clone " + id + " 1.20.1 --name Downgrade --yes")); + assertEquals(5, services.profiles().size()); // Three version-matched defaults plus the user's source and clone. + assertTrue(services.profiles().stream().anyMatch(p -> p.name.equals("Downgrade") && p.launchable)); + } + } + @Test void updateNeedsReviewedPlanAndWorldProfileCompatibilityIsCheckedEarly() throws Exception { + try (LauncherServices services = new LauncherServices(temp, temp.resolve("minecraft"), ignored -> { })) { + LauncherActions.ProfileInfo client = services.createProfile("Client", "1.21.1", "FABRIC", "MODS", false); + LauncherActions.ProfileInfo server = services.createProfile("Server", "1.21.1", "FORGE", "MODS_SERVER", false); + assertThrows(java.io.IOException.class, () -> services.updateProfile(client.id)); + assertThrows(java.io.IOException.class, () -> services.createWorld("Invalid", server.id, client.id)); + assertTrue(services.worlds().isEmpty()); + services.checkProfile(client.id); services.updateProfile(client.id); + } + } + @Test void disablingRememberClearsAccountsEvenAfterSwitchingOffline() throws Exception { + try (LauncherServices services = new LauncherServices(temp, temp.resolve("minecraft"), ignored -> { })) { + LauncherActions.SettingsInfo settings = services.settings(); settings.rememberAccount = true; services.saveSettings(settings); + com.osiris.autoplug.client.launcher.AccountStore store = new com.osiris.autoplug.client.launcher.AccountStore(temp.resolve("accounts.json")); + store.save(new com.osiris.autoplug.client.launcher.MinecraftAccount("FixtureOne", "123456781234123412341234567890ab", "test-access", "test-refresh", "test-client", "", false, java.time.Instant.now().plusSeconds(3600))); + store.save(new com.osiris.autoplug.client.launcher.MinecraftAccount("FixtureTwo", "abcdefab1234123412341234567890ab", "test-access", "test-refresh", "test-client", "", false, java.time.Instant.now().plusSeconds(3600))); + services.useOfflineAccount("Offline"); + settings = services.settings(); settings.rememberAccount = false; services.saveSettings(settings); + assertTrue(store.list().isEmpty()); + } + } + @Test void localWorldLaunchUsesRecordedVersionAndCannotStartOrShareAServer() throws Exception { + List output = new ArrayList<>(), launches = new ArrayList<>(); + try (LauncherServices services = new LauncherServices(temp, temp.resolve("minecraft"), output::add) { + @Override public List worlds() { + return Arrays.asList( + new LauncherActions.WorldInfo("local:New World", "My save", "", "", "fixture", "", false, true, "1.21.1"), + new LauncherActions.WorldInfo("local:Legacy", "Legacy", "", "", "fixture", "", false, true, "")); + } + @Override public String launchLocalWorld(String id, String version) { launches.add(id + " | " + version); return "Select Singleplayer in Minecraft."; } + @Override public void launchWorld(String id, boolean share) { fail("A local save must not start a dedicated server"); } + @Override public void setWorldEulaAccepted(String id, boolean accepted) { fail("A local save must not write a server EULA"); } + }) { + LauncherCommands commands = new LauncherCommands(services, output::add); + commands.execute(commands.tokenize(".mc worlds launch \"local:New World\"")); + assertEquals(Collections.singletonList("local:New World | 1.21.1"), launches); + assertEquals("Select Singleplayer in Minecraft.", output.get(output.size() - 1)); + assertThrows(java.io.IOException.class, () -> commands.execute(commands.tokenize(".mc worlds launch local:Legacy"))); + commands.execute(commands.tokenize(".mc worlds launch local:Legacy --version 1.12.2")); + assertEquals("local:Legacy | 1.12.2", launches.get(1)); + assertThrows(java.io.IOException.class, () -> commands.execute(commands.tokenize(".mc worlds launch \"local:New World\" --share"))); + assertThrows(java.io.IOException.class, () -> commands.execute(commands.tokenize(".mc worlds launch \"local:New World\" --accept-eula"))); + assertThrows(java.io.IOException.class, () -> commands.execute(commands.tokenize(".mc worlds share \"local:New World\""))); + assertThrows(java.io.IOException.class, () -> commands.execute(commands.tokenize(".mc worlds stop \"local:New World\""))); + assertEquals(2, launches.size()); + commands.execute(commands.tokenize(".mc worlds list")); + assertTrue(output.stream().anyMatch(line -> line.contains("singleplayer | 1.21.1"))); + } + } + + @Test void ownedLocalSaveRetainsCliAliasWithoutPermittingServerFlags() throws Exception { + Path minecraft = temp.resolve("minecraft"), directory = Files.createDirectories(minecraft.resolve("saves/Already owned")); + try (java.io.DataOutputStream out = new java.io.DataOutputStream(Files.newOutputStream(directory.resolve("level.dat")))) { + out.writeByte(10); out.writeUTF(""); out.writeByte(10); out.writeUTF("Data"); + out.writeByte(8); out.writeUTF("LevelName"); out.writeUTF("Owned fixture"); + out.writeByte(10); out.writeUTF("Version"); out.writeByte(8); out.writeUTF("Name"); out.writeUTF("1.21.1"); + out.writeByte(0); out.writeByte(0); out.writeByte(0); + } + byte[] original = Files.readAllBytes(directory.resolve("level.dat")); + com.osiris.autoplug.client.worlds.WorldStore store = new com.osiris.autoplug.client.worlds.WorldStore(temp.resolve("worlds")); + String ownedId = store.registerLocal(new com.osiris.autoplug.client.worlds.LocalWorldStore(minecraft).get("local:Already owned")).id; + List launches = new ArrayList<>(); + try (LauncherServices services = new LauncherServices(temp, minecraft, null) { + @Override public String launchLocalWorld(String id, String version) throws Exception { + assertEquals(ownedId, store.getLocal(id, new com.osiris.autoplug.client.worlds.LocalWorldStore(minecraft)).id); + launches.add(id + " | " + version); return "Started fixture without launching a process"; + } + @Override public void launchWorld(String id, boolean share) { fail("Local alias must not start a server"); } + @Override public void setWorldEulaAccepted(String id, boolean accepted) { fail("Local alias must not accept server EULA"); } + @Override public String shareWorld(String id) { fail("Local alias must not expose the save"); return ""; } + }) { + assertEquals(1, services.worlds().size()); assertEquals(ownedId, services.worlds().get(0).id); + LauncherCommands commands = new LauncherCommands(services, ignored -> { }); + commands.execute(commands.tokenize(".mc worlds launch \"local:Already owned\"")); + commands.execute(commands.tokenize(".mc worlds launch " + ownedId)); + assertEquals(Arrays.asList("local:Already owned | 1.21.1", ownedId + " | 1.21.1"), launches); + for (String flags : Arrays.asList(" --share", " --accept-eula")) + assertThrows(java.io.IOException.class, () -> commands.execute(commands.tokenize(".mc worlds launch \"local:Already owned\"" + flags))); + for (String action : Arrays.asList("share", "stop")) + assertThrows(java.io.IOException.class, () -> commands.execute(commands.tokenize(".mc worlds " + action + " \"local:Already owned\""))); + assertThrows(java.io.IOException.class, () -> commands.execute(commands.tokenize(".mc worlds launch \"local:../Already owned\""))); + assertEquals(2, launches.size()); + } + assertArrayEquals(original, Files.readAllBytes(directory.resolve("level.dat"))); + assertFalse(Files.exists(directory.resolve("eula.txt"))); + } +} diff --git a/src/test/java/com/osiris/autoplug/client/profiles/LauncherDefaultsTest.java b/src/test/java/com/osiris/autoplug/client/profiles/LauncherDefaultsTest.java new file mode 100644 index 00000000..f19af2ae --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/profiles/LauncherDefaultsTest.java @@ -0,0 +1,109 @@ +package com.osiris.autoplug.client.profiles; + +import com.osiris.autoplug.client.ui.LauncherActions; +import com.osiris.autoplug.client.worlds.WorldStore; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.nio.file.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +class LauncherDefaultsTest { + @TempDir Path temporary; + + @Test void firstRunHasMatchingClientAndServerWithoutAcceptingEula() throws Exception { + Path root = temporary.resolve("autoplug"); + try (LauncherServices service = new LauncherServices(root, temporary.resolve("minecraft"), null)) { + assertEquals(3, service.profiles().size()); + Profile client = service.getProfiles().get(service.settings().defaultProfile); + assertEquals("Default (VANILLA)", client.name); + assertEquals("VANILLA", client.loader); assertEquals(ProfileType.MODS, client.type); + assertEquals("1.21.1", client.gameVersion); assertFalse(client.template); + Profile fabric = service.getProfiles().list().stream().filter(p -> "FABRIC".equals(p.loader)).findFirst().orElseThrow(() -> new AssertionError("Missing Fabric default")); + assertEquals("Default (FABRIC)", fabric.name); assertEquals(client.gameVersion, fabric.gameVersion); + assertEquals(FabricDefaultProfile.PRESET, fabric.builtinPreset); assertFalse(fabric.builtinPresetInstalled); + assertTrue(Files.isDirectory(client.getDirectory().resolve("mods"))); + LauncherActions.WorldInfo world = service.createWorld("First world", "", ""); + Profile server = service.getProfiles().get(world.serverProfileId); + assertEquals("Default", server.name); assertEquals(client.gameVersion, server.gameVersion); + assertEquals(ProfileType.MODS_SERVER, server.type); + assertFalse(new WorldStore(root.resolve("worlds")).get(world.id).eulaAccepted); + assertFalse(Files.exists(Paths.get(world.directory).resolve("eula.txt"))); + assertFalse(Files.exists(temporary.resolve("minecraft"))); + } + } + + @Test void requestedVersionCreatesIndependentDefaultsAndIsIdempotent() throws Exception { + Path root = temporary.resolve("autoplug"); String original, requested; + try (LauncherServices service = new LauncherServices(root, temporary.resolve("minecraft"), null)) { + original = service.settings().defaultProfile; + requested = service.ensureDefaultProfiles("1.20.1").id; + assertNotEquals(original, requested); + assertEquals(requested, service.ensureDefaultProfiles("1.20.1").id); + assertEquals(original, service.settings().defaultProfile, "Choosing a server version must not overwrite the selected user default"); + assertEquals(6, service.profiles().size()); + LauncherActions.WorldInfo world = service.createWorld("Other version", null, requested); + assertEquals("1.20.1", service.getProfiles().get(world.serverProfileId).gameVersion); + assertThrows(java.io.IOException.class, () -> service.ensureDefaultProfiles("../escape")); + assertEquals(6, service.profiles().size()); + } + try (LauncherServices reopened = new LauncherServices(root, temporary.resolve("minecraft"), null)) { + assertEquals(original, reopened.settings().defaultProfile); + assertEquals(requested, reopened.ensureDefaultProfiles("1.20.1").id); + assertEquals(6, reopened.profiles().size()); + } + } + + @Test void existingSelectedUserProfileAndItsFilesArePreservedOnBootstrap() throws Exception { + Path root = temporary.resolve("autoplug"); String selected; byte[] metadata; + try (LauncherServices service = new LauncherServices(root, temporary.resolve("minecraft"), null)) { + selected = service.createProfile("Personal pack", "1.19.4", "FABRIC", "MODS", false).id; + LauncherActions.SettingsInfo settings = service.settings(); settings.defaultProfile = selected; service.saveSettings(settings); + Path directory = service.getProfiles().get(selected).getDirectory(); + metadata = Files.readAllBytes(directory.resolve("profile.json")); + Files.write(directory.resolve("options.txt"), Arrays.asList("keep-me")); + } + try (LauncherServices service = new LauncherServices(root, temporary.resolve("minecraft"), null)) { + assertEquals(selected, service.settings().defaultProfile); + Profile profile = service.getProfiles().get(selected); + assertArrayEquals(metadata, Files.readAllBytes(profile.getDirectory().resolve("profile.json"))); + assertEquals(Collections.singletonList("keep-me"), Files.readAllLines(profile.getDirectory().resolve("options.txt"))); + assertEquals("1.19.4", service.ensureDefaultProfiles("").gameVersion); + assertEquals(7, service.profiles().size()); + } + } + + @Test void progressSubscriptionsReceiveSamePhaseAsLogAndCanDetach() throws Exception { + Path minecraft = Files.createDirectories(temporary.resolve("minecraft")); + Files.write(minecraft.resolve("saves"), new byte[]{1}); + List log = new ArrayList<>(), ui = new ArrayList<>(); + try (LauncherServices service = new LauncherServices(temporary.resolve("autoplug"), minecraft, log::add)) { + service.onProgress(ui::add); service.worlds(); + assertEquals(log, ui); assertEquals(1, ui.size()); + service.onProgress(null); service.worlds(); + assertEquals(1, ui.size()); assertEquals(2, log.size()); + service.onProgress(message -> { throw new IllegalStateException("Disposed UI"); }); + assertDoesNotThrow(service::worlds); assertEquals(3, log.size()); + } + } + + @Test void unavailableSelectedDefaultsAreReplacedWithoutMutatingTheOriginalProfile() throws Exception { + for (boolean template : new boolean[]{true, false}) { + Path root = temporary.resolve(template ? "template" : "migration"); String unavailable; byte[] metadata; + try (LauncherServices service = new LauncherServices(root, temporary.resolve("minecraft"), null)) { + Profile profile = service.getProfiles().create("Not ready", "1.20.4", "FABRIC", ProfileType.MODS); + profile.template = template; profile.migrationPending = !template; service.getProfiles().save(profile); + unavailable = profile.id; metadata = Files.readAllBytes(profile.getDirectory().resolve("profile.json")); + LauncherActions.SettingsInfo settings = service.settings(); settings.defaultProfile = unavailable; service.saveSettings(settings); + } + try (LauncherServices service = new LauncherServices(root, temporary.resolve("minecraft"), null)) { + Profile selected = service.getProfiles().get(service.settings().defaultProfile); + assertNotEquals(unavailable, selected.id); assertFalse(selected.template); assertFalse(selected.migrationPending); + assertEquals("VANILLA", selected.loader); assertEquals("1.20.4", selected.gameVersion); + assertEquals(selected.id, service.ensureDefaultProfiles("").id); + assertEquals(7, service.profiles().size()); + assertArrayEquals(metadata, Files.readAllBytes(service.getProfiles().get(unavailable).getDirectory().resolve("profile.json"))); + } + } + } +} diff --git a/src/test/java/com/osiris/autoplug/client/profiles/LauncherPreferencesTest.java b/src/test/java/com/osiris/autoplug/client/profiles/LauncherPreferencesTest.java new file mode 100644 index 00000000..4073b560 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/profiles/LauncherPreferencesTest.java @@ -0,0 +1,129 @@ +package com.osiris.autoplug.client.profiles; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.osiris.autoplug.client.ui.LauncherActions; +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.*; + +/** Real persisted settings/profile state, with no game, authentication or provider network access. */ +class LauncherPreferencesTest { + @TempDir Path temporary; + + @Test void lastChoicesAreTargetSpecificPersistAcrossRestartAndDoNotChangeGlobalDefault() throws Exception { + String vanilla, fabric, selectedDefault; + try (LauncherServices services = open()) { + selectedDefault = services.settings().defaultProfile; + vanilla = services.createProfile("Personal vanilla", "1.21.1", "VANILLA", "MODS", false).id; + fabric = services.getProfiles().ensureFabricDefault("1.21.1").id; + services.rememberProfile("server:play.example.test:25565", vanilla); + services.rememberProfile("world:local-world-1", vanilla); + services.rememberProfile("server:play.example.test:25565", fabric); + services.rememberProfile("server:play.example.test:25566", vanilla); + assertEquals(fabric, services.preferredProfile("server:play.example.test:25565")); + assertEquals(vanilla, services.preferredProfile("world:local-world-1")); + assertEquals(vanilla, services.preferredProfile("server:play.example.test:25566")); + assertEquals("", services.preferredProfile("world:unvisited")); + assertEquals(selectedDefault, services.settings().defaultProfile); + } + try (LauncherServices reopened = open()) { + assertEquals(fabric, reopened.preferredProfile("server:play.example.test:25565")); + assertEquals(vanilla, reopened.preferredProfile("world:local-world-1")); + assertEquals(vanilla, reopened.preferredProfile("server:play.example.test:25566")); + assertEquals(selectedDefault, reopened.settings().defaultProfile); + } + } + + @Test void deletedTemplatesAndMigratingChoicesAreNotOfferedForAutomaticLaunch() throws Exception { + String deleted, template, migrating; + try (LauncherServices services = open()) { + deleted = services.createProfile("Delete this", "1.21.1", "VANILLA", "MODS", false).id; + template = services.createProfile("Make template", "1.21.1", "VANILLA", "MODS", false).id; + migrating = services.createProfile("Migrate later", "1.21.1", "FABRIC", "MODS", false).id; + services.rememberProfile("server:deleted", deleted); + services.rememberProfile("world:template", template); + services.rememberProfile("world:migrating", migrating); + services.deleteProfile(deleted); + services.setTemplate(template, true); + Profile changing = services.getProfiles().get(migrating); + changing.migrationPending = true; services.getProfiles().save(changing); + assertEquals("", services.preferredProfile("server:deleted")); + assertEquals("", services.preferredProfile("world:template")); + assertEquals("", services.preferredProfile("world:migrating")); + } + try (LauncherServices reopened = open()) { + assertEquals("", reopened.preferredProfile("server:deleted")); + assertEquals("", reopened.preferredProfile("world:template")); + assertEquals("", reopened.preferredProfile("world:migrating")); + assertTrue(reopened.getProfiles().get(template).template); + assertTrue(reopened.getProfiles().get(migrating).migrationPending); + } + } + + @Test void invalidChoicesCannotReplacePreviouslySavedPlayableChoice() throws Exception { + try (LauncherServices services = open()) { + String playable = services.settings().defaultProfile; + String server = services.getProfiles().ensureDefault("1.21.1", ProfileType.MODS_SERVER).id; + String template = services.createProfile("Template", "1.21.1", "FABRIC", "MODS", true).id; + services.rememberProfile("server:example", playable); + byte[] before = Files.readAllBytes(temporary.resolve("autoplug/settings.json")); + assertThrows(IOException.class, () -> services.rememberProfile("server:example", server)); + assertThrows(IOException.class, () -> services.rememberProfile("server:example", template)); + assertThrows(IOException.class, () -> services.rememberProfile("server:example", "deleted-profile")); + assertThrows(IOException.class, () -> services.rememberProfile("invalid-target", playable)); + assertThrows(IOException.class, () -> services.rememberProfile(null, playable)); + assertEquals(playable, services.preferredProfile("server:example")); + assertArrayEquals(before, Files.readAllBytes(temporary.resolve("autoplug/settings.json"))); + } + } + + @Test void fullscreenDefaultsOnAndExplicitWindowedChoiceSurvivesRestarts() throws Exception { + try (LauncherServices services = open()) { + assertTrue(services.settings().fullscreen); + LauncherActions.SettingsInfo settings = services.settings(); + settings.fullscreen = false; settings.port = 25570; + services.saveSettings(settings); + assertFalse(services.settings().fullscreen); + LauncherActions.SettingsInfo detached = services.settings(); + detached.fullscreen = true; detached.javaPaths.put(16, "not-saved"); + assertFalse(services.settings().fullscreen); + assertFalse(services.settings().javaPaths.containsKey(16)); + } + try (LauncherServices reopened = open()) { + assertFalse(reopened.settings().fullscreen); + assertEquals(25570, reopened.settings().port); + LauncherActions.SettingsInfo settings = reopened.settings(); settings.fullscreen = true; reopened.saveSettings(settings); + } + try (LauncherServices reopened = open()) { assertTrue(reopened.settings().fullscreen); } + } + + @Test void olderSettingsWithoutNewFieldsMigrateToFullscreenAndEmptyRememberedChoices() throws Exception { + String selected; + try (LauncherServices services = open()) { + selected = services.settings().defaultProfile; + services.rememberProfile("world:old-choice", selected); + } + Path settings = temporary.resolve("autoplug/settings.json"); + JsonObject old = JsonParser.parseString(new String(Files.readAllBytes(settings), StandardCharsets.UTF_8)).getAsJsonObject(); + old.remove("launchChoices"); old.getAsJsonObject("settings").remove("fullscreen"); + Files.write(settings, old.toString().getBytes(StandardCharsets.UTF_8)); + try (LauncherServices reopened = open()) { + assertTrue(reopened.settings().fullscreen); + assertEquals(selected, reopened.settings().defaultProfile); + assertEquals("", reopened.preferredProfile("world:old-choice")); + reopened.rememberProfile("world:new-choice", selected); + } + try (LauncherServices reopened = open()) { assertEquals(selected, reopened.preferredProfile("world:new-choice")); } + } + + private LauncherServices open() throws Exception { + return new LauncherServices(temporary.resolve("autoplug"), temporary.resolve("minecraft"), null); + } +} diff --git a/src/test/java/com/osiris/autoplug/client/profiles/LocalClientLifecycleTest.java b/src/test/java/com/osiris/autoplug/client/profiles/LocalClientLifecycleTest.java new file mode 100644 index 00000000..2d083b8c --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/profiles/LocalClientLifecycleTest.java @@ -0,0 +1,69 @@ +package com.osiris.autoplug.client.profiles; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.*; +import static org.junit.jupiter.api.Assertions.*; + +class LocalClientLifecycleTest { + @TempDir Path temporary; + + @Test void closingLauncherLeavesLocalGameAliveAndRetainsLeaseUntilGameExits() throws Exception { + Path minecraft = Files.createDirectories(temporary.resolve("minecraft")); + LauncherServices services = new LauncherServices(temporary.resolve("autoplug"), minecraft, ignored -> { }); + FixtureProcess process = new FixtureProcess(); + ProfileLease lease = new ProfileLease(minecraft); + services.trackLocalClient("local:Fixture", process, lease); + try { + services.close(); + assertFalse(process.destroyed, "Closing AutoPlug must not terminate the local integrated server"); + assertTrue(process.isAlive()); + assertThrows(IOException.class, () -> new ProfileLease(minecraft)); + process.exitNormally(); + try (ProfileLease released = new ProfileLease(minecraft)) { assertNotNull(released); } + } finally { process.exitNormally(); services.close(); } + } + + @Test void waitingForSessionsIncludesLocalGameUntilItsNormalExit() throws Exception { + Path minecraft = Files.createDirectories(temporary.resolve("minecraft")); + LauncherServices services = new LauncherServices(temporary.resolve("autoplug"), minecraft, ignored -> { }); + FixtureProcess process = new FixtureProcess(); + services.trackLocalClient("local:Fixture", process, new ProfileLease(minecraft)); + ExecutorService worker = Executors.newSingleThreadExecutor(); + CountDownLatch started = new CountDownLatch(1); + Future waiting = worker.submit(() -> { + started.countDown(); + try { services.waitForSessions(); } + catch (InterruptedException e) { Thread.currentThread().interrupt(); } + }); + try { + assertTrue(started.await(2, TimeUnit.SECONDS)); + assertThrows(TimeoutException.class, () -> waiting.get(350, TimeUnit.MILLISECONDS)); + process.exitNormally(); + waiting.get(2, TimeUnit.SECONDS); + assertFalse(process.destroyed); + } finally { process.exitNormally(); worker.shutdownNow(); services.close(); } + } + + /** No OS process or Minecraft world is launched by this lifecycle regression. */ + private static final class FixtureProcess extends Process { + private final CompletableFuture exited = new CompletableFuture<>(); + private volatile boolean alive = true, destroyed; + @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() throws InterruptedException { + try { exited.get(); return 0; } + catch (ExecutionException e) { throw new AssertionError(e); } + } + @Override public int exitValue() { if (alive) throw new IllegalThreadStateException(); return 0; } + @Override public boolean isAlive() { return alive; } + @Override public CompletableFuture onExit() { return exited; } + @Override public void destroy() { destroyed = true; exitNormally(); } + @Override public Process destroyForcibly() { destroy(); return this; } + void exitNormally() { alive = false; exited.complete(this); } + } +} diff --git a/src/test/java/com/osiris/autoplug/client/profiles/ProfileLoaderCompatibilityTest.java b/src/test/java/com/osiris/autoplug/client/profiles/ProfileLoaderCompatibilityTest.java new file mode 100644 index 00000000..3128eaad --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/profiles/ProfileLoaderCompatibilityTest.java @@ -0,0 +1,50 @@ +package com.osiris.autoplug.client.profiles; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.IOException; +import java.nio.file.*; +import static org.junit.jupiter.api.Assertions.*; + +class ProfileLoaderCompatibilityTest { + @TempDir Path temp; + @Test void rejectsPluginCollectionOnModLoaderBeforeCreatingAnyProfile() throws Exception { + ProfileStore store = new ProfileStore(temp.resolve("profiles")); + assertThrows(IOException.class, () -> store.create("Wrong", "1.20.4", "FABRIC", ProfileType.PLUGINS)); + assertTrue(store.list().isEmpty()); + } + @Test void rejectsServerModCollectionOnPluginLoaderBeforeCreatingAnyProfile() throws Exception { + ProfileStore store = new ProfileStore(temp.resolve("profiles")); + assertThrows(IOException.class, () -> store.create("Wrong", "1.20.4", "PAPER", ProfileType.MODS_SERVER)); + assertTrue(store.list().isEmpty()); + } + @Test void vanillaServerUsesServerModsTypeRatherThanPretendingToLoadPlugins() throws Exception { + ProfileStore store = new ProfileStore(temp.resolve("profiles")); + assertThrows(IOException.class, () -> store.create("Wrong", "1.20.4", "VANILLA", ProfileType.PLUGINS)); + assertEquals(ProfileType.MODS_SERVER, store.create("Vanilla", "1.20.4", "VANILLA", ProfileType.MODS_SERVER).type); + } + @Test void saveAndReadRejectContradictoryMetadataWithoutOverwritingValidProfile() throws Exception { + ProfileStore store = new ProfileStore(temp.resolve("profiles")); + Profile profile = store.create("Server", "1.20.4", "PAPER", ProfileType.PLUGINS); profile.loader = "FABRIC"; + assertThrows(IOException.class, () -> store.save(profile)); assertEquals("PAPER", store.get(profile.id).loader); + new JsonFiles().write(profile.getDirectory().resolve("profile.json"), profile); + assertThrows(IOException.class, () -> store.get(profile.id)); + } + @Test void supportedClientPluginServerModAndVanillaPairsRemainUsable() throws Exception { + ProfileStore store = new ProfileStore(temp.resolve("profiles")); + assertEquals("FABRIC", store.create("Client", "1.20.4", "FABRIC", ProfileType.MODS).loader); + assertEquals("PAPER", store.create("Plugins", "1.20.4", "PAPER", ProfileType.PLUGINS).loader); + assertEquals("FABRIC", store.create("Server mods", "1.20.4", "FABRIC", ProfileType.MODS_SERVER).loader); + assertEquals("VANILLA", store.create("Vanilla", "1.20.4", "VANILLA", ProfileType.MODS_SERVER).loader); + } + @Test void cliAndServiceRejectWrongTypesBeforeCreatingWorldOrInstallingAnything() throws Exception { + try (LauncherServices services = new LauncherServices(temp, temp.resolve("minecraft"), ignored -> { })) { + int before = services.profiles().size(); + assertThrows(IOException.class, () -> services.createProfile("Wrong", "1.20.4", "FABRIC", "PLUGINS", false)); + LauncherCommands commands = new LauncherCommands(services, ignored -> { }); + assertThrows(IOException.class, () -> commands.execute(commands.tokenize(".profiles create Wrong 1.20.4 PAPER --type mods-server"))); + assertEquals(before, services.profiles().size()); assertTrue(services.worlds().isEmpty()); + assertFalse(Files.exists(temp.resolve("autoplug")), "No legacy server initialization"); + } + } +} diff --git a/src/test/java/com/osiris/autoplug/client/profiles/ProfileStoreTest.java b/src/test/java/com/osiris/autoplug/client/profiles/ProfileStoreTest.java new file mode 100644 index 00000000..6ad0fdd2 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/profiles/ProfileStoreTest.java @@ -0,0 +1,60 @@ +package com.osiris.autoplug.client.profiles; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.nio.file.*; +import java.nio.charset.StandardCharsets; +import static org.junit.jupiter.api.Assertions.*; + +class ProfileStoreTest { + @TempDir Path temp; + @Test void cloneSharesJarsButIsolatesMutableConfiguration() throws Exception { + ProfileStore store = new ProfileStore(temp.resolve("profiles")); + Profile source = store.create("Original", "1.20.1", "FABRIC", ProfileType.MODS); + Files.write(source.getDirectory().resolve("mods/test.jar"), new byte[]{1, 2, 3}); + Files.createDirectories(source.getDirectory().resolve("config")); + Files.write(source.getDirectory().resolve("config/a.txt"), "before".getBytes(StandardCharsets.UTF_8)); + Profile target = store.cloneProfile(source.id, "1.21.1"); + assertTrue(target.migrationPending); + Path targetJar = target.getDirectory().resolve("mods/test.jar"); + Path cached = store.getCache().store(source.getDirectory().resolve("mods/test.jar")); + assertTrue(Files.isSameFile(cached, targetJar)); + Files.write(target.getDirectory().resolve("config/a.txt"), "after".getBytes(StandardCharsets.UTF_8)); + assertEquals("before", new String(Files.readAllBytes(source.getDirectory().resolve("config/a.txt")), StandardCharsets.UTF_8)); + Path replacement = temp.resolve("replacement.jar"); Files.write(replacement, new byte[]{4, 5, 6}); + store.getCache().link(store.getCache().store(replacement), targetJar); + assertArrayEquals(new byte[]{1, 2, 3}, Files.readAllBytes(cached)); + assertArrayEquals(new byte[]{1, 2, 3}, Files.readAllBytes(source.getDirectory().resolve("mods/test.jar"))); + assertNotEquals(source.id, store.get(target.id).id); + } + @Test void lockPreventsMutatingAnActiveProfileAndDeleteIsRecoverable() throws Exception { + ProfileStore store = new ProfileStore(temp.resolve("profiles")); + Profile p = store.create("Server", "1.21.1", "PAPER", ProfileType.PLUGINS); + try (ProfileLease lease = new ProfileLease(p.getDirectory())) { + assertThrows(java.io.IOException.class, () -> store.delete(p.id)); + assertThrows(java.io.IOException.class, () -> store.cloneProfile(p.id, "1.20.1")); + } + store.delete(p.id); + assertTrue(store.list().isEmpty()); + try (java.util.stream.Stream files = Files.list(temp.resolve("trash"))) { assertEquals(1, files.count()); } + } + @Test void rejectsTraversalAndMismatchedMetadata() throws Exception { + ProfileStore store = new ProfileStore(temp.resolve("profiles")); + assertThrows(java.io.IOException.class, () -> store.get("../outside")); + Profile p = store.create("Client", "1.21.1", "VANILLA", ProfileType.MODS); + assertThrows(java.io.IOException.class, () -> new ProfileCollection().resolve(p, "../../outside.jar")); + p.id = "other"; + new JsonFiles().write(store.getRoot().resolve(p.getDirectory().getFileName()).resolve("profile.json"), p); + assertThrows(java.io.IOException.class, () -> store.get(p.getDirectory().getFileName().toString())); + } + @Test void cloningNeverCopiesTransientCredentialsOrNativeRuntimeFiles() throws Exception { + ProfileStore store = new ProfileStore(temp.resolve("profiles")); Profile p = store.create("Client", "1.21.1", "VANILLA", ProfileType.MODS); + Files.createDirectories(p.getDirectory().resolve(".autoplug/natives")); + Files.write(p.getDirectory().resolve(".autoplug/launch-private.args"), "test-token".getBytes(StandardCharsets.UTF_8)); + Files.write(p.getDirectory().resolve(".autoplug/natives/test.dll"), new byte[]{1}); + Files.write(p.getDirectory().resolve("accounts.json"), "test-secret".getBytes(StandardCharsets.UTF_8)); + Profile copy = store.cloneProfile(p.id, p.gameVersion); + assertFalse(Files.exists(copy.getDirectory().resolve(".autoplug"))); + assertFalse(Files.exists(copy.getDirectory().resolve("accounts.json"))); + } +} diff --git a/src/test/java/com/osiris/autoplug/client/profiles/ProfileUpdatesTest.java b/src/test/java/com/osiris/autoplug/client/profiles/ProfileUpdatesTest.java new file mode 100644 index 00000000..085c7bcc --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/profiles/ProfileUpdatesTest.java @@ -0,0 +1,96 @@ +package com.osiris.autoplug.client.profiles; + +import com.osiris.autoplug.client.tasks.updater.search.SearchResult; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.IOException; +import java.nio.file.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.zip.*; +import static org.junit.jupiter.api.Assertions.*; + +class ProfileUpdatesTest { + @TempDir Path temp; + private void jar(Path path, String version) throws IOException { + try (ZipOutputStream out = new ZipOutputStream(Files.newOutputStream(path))) { + out.putNextEntry(new ZipEntry("fabric.mod.json")); + out.write(("{\"id\":\"example\",\"name\":\"Example\",\"version\":\"" + version + "\",\"authors\":[\"Test\"]}").getBytes(java.nio.charset.StandardCharsets.UTF_8)); + out.closeEntry(); + } + } + private Profile profile(ProfileStore store) throws Exception { + Profile p = store.create("Test", "1.20.1", "FABRIC", ProfileType.MODS); + jar(p.getDirectory().resolve("mods/example.jar"), "2.0"); return p; + } + private SearchResult release(SearchResult.Type type) { return new SearchResult(null, type, "1.0", "https://example.invalid/mod.jar", ".jar", null, null, false); } + @Test void checkNeverDownloadsAndDowngradeReplacesOnlyTheSelectedProfile() throws Exception { + ProfileStore store = new ProfileStore(temp.resolve("profiles")); Profile p = profile(store); + Profile clone = store.cloneProfile(p.id, "1.19.4"); AtomicInteger downloads = new AtomicInteger(); + ProfileUpdates updates = new ProfileUpdates(store, (pr, entry, file) -> release(SearchResult.Type.UPDATE_AVAILABLE), (r, dest) -> { downloads.incrementAndGet(); jar(dest, "1.0"); }); + byte[] original = Files.readAllBytes(p.getDirectory().resolve("mods/example.jar")); + ProfileUpdates.Plan plan = updates.check(clone.id); + assertEquals(0, downloads.get()); assertEquals(ProfileUpdates.Status.REPLACE, plan.changes.get(0).status); + updates.apply(plan, true); + assertEquals(1, downloads.get()); assertFalse(store.get(clone.id).migrationPending); + assertArrayEquals(original, Files.readAllBytes(p.getDirectory().resolve("mods/example.jar"))); + assertFalse(java.util.Arrays.equals(original, Files.readAllBytes(clone.getDirectory().resolve("mods/example.jar")))); + } + @Test void providerFailureDoesNotDisableOrChangeInstalledJars() throws Exception { + ProfileStore store = new ProfileStore(temp.resolve("profiles")); Profile p = profile(store); p.migrationPending = true; store.save(p); + ProfileUpdates updates = new ProfileUpdates(store, (pr, entry, file) -> release(SearchResult.Type.API_ERROR), (r, dest) -> fail("No download expected")); + assertThrows(IOException.class, () -> updates.update(p.id, true)); + assertTrue(Files.exists(p.getDirectory().resolve("mods/example.jar"))); + assertFalse(Files.exists(p.getDirectory().resolve("mods/example.jar.disabled"))); + assertTrue(store.get(p.id).migrationPending); + } + @Test void unverifiedMigrationNeedsConfirmationAndDisablesWithBackup() throws Exception { + ProfileStore store = new ProfileStore(temp.resolve("profiles")); Profile p = profile(store); p.migrationPending = true; store.save(p); + ProfileUpdates updates = new ProfileUpdates(store, (pr, entry, file) -> null, (r, dest) -> fail("No download expected")); + assertThrows(IOException.class, () -> updates.update(p.id, false)); + updates.update(p.id, true); + assertFalse(Files.exists(p.getDirectory().resolve("mods/example.jar"))); + assertTrue(Files.exists(p.getDirectory().resolve("mods/example.jar.disabled"))); + assertFalse(store.get(p.id).migrationPending); + } + @Test void corruptDownloadLeavesOriginalIntact() throws Exception { + ProfileStore store = new ProfileStore(temp.resolve("profiles")); Profile p = profile(store); + byte[] original = Files.readAllBytes(p.getDirectory().resolve("mods/example.jar")); + ProfileUpdates updates = new ProfileUpdates(store, (pr, entry, file) -> { SearchResult r = release(SearchResult.Type.UPDATE_AVAILABLE); r.sha1 = "0000000000000000000000000000000000000000"; return r; }, (r, dest) -> jar(dest, "bad")); + assertThrows(IOException.class, () -> updates.update(p.id, true)); + assertArrayEquals(original, Files.readAllBytes(p.getDirectory().resolve("mods/example.jar"))); + } + @Test void stalePlanCannotOverwriteNewerLocalFiles() throws Exception { + ProfileStore store = new ProfileStore(temp.resolve("profiles")); Profile p = profile(store); + ProfileUpdates updates = new ProfileUpdates(store, (pr, entry, file) -> release(SearchResult.Type.UPDATE_AVAILABLE), (r, dest) -> fail("No download expected")); + ProfileUpdates.Plan plan = updates.check(p.id); jar(p.getDirectory().resolve("mods/example.jar"), "changed"); + assertThrows(IOException.class, () -> updates.apply(plan, true)); + } + @Test void jarAddedAfterReviewCannotEscapeMigration() throws Exception { + ProfileStore store = new ProfileStore(temp.resolve("profiles")); Profile p = profile(store); p.migrationPending = true; store.save(p); + ProfileUpdates updates = new ProfileUpdates(store, (pr, entry, file) -> release(SearchResult.Type.UP_TO_DATE), (r, dest) -> fail("No download expected")); + ProfileUpdates.Plan plan = updates.check(p.id); jar(p.getDirectory().resolve("mods/unreviewed.jar"), "1.0"); + assertThrows(IOException.class, () -> updates.apply(plan, true)); + assertTrue(store.get(p.id).migrationPending); + } + @Test void providerIdentityChangedAfterReviewRequiresNewPlan() throws Exception { + ProfileStore store = new ProfileStore(temp.resolve("profiles")); Profile p = profile(store); + ProfileUpdates updates = new ProfileUpdates(store, (pr, entry, file) -> release(SearchResult.Type.UPDATE_AVAILABLE), (r, dest) -> fail("No download expected")); + ProfileUpdates.Plan plan = updates.check(p.id); + ProfileCollection collection = new ProfileCollection().scan(p); collection.entries.get(0).modrinthId = "different"; collection.save(p); + assertThrows(IOException.class, () -> updates.apply(plan, true)); + } + @Test void failedMetadataCommitRestoresBothJarAndMetadata() throws Exception { + java.util.concurrent.atomic.AtomicBoolean failSave = new java.util.concurrent.atomic.AtomicBoolean(); + ProfileStore store = new ProfileStore(temp.resolve("profiles")) { + @Override public void save(Profile p) throws IOException { if (failSave.get()) throw new IOException("Simulated disk failure"); super.save(p); } + }; + Profile p = profile(store); ProfileCollection collection = new ProfileCollection().scan(p); collection.save(p); + byte[] originalJar = Files.readAllBytes(p.getDirectory().resolve("mods/example.jar")); + byte[] originalMetadata = Files.readAllBytes(p.getDirectory().resolve("collection.json")); + ProfileUpdates updates = new ProfileUpdates(store, (pr, entry, file) -> release(SearchResult.Type.UPDATE_AVAILABLE), (r, dest) -> jar(dest, "1.0")); + ProfileUpdates.Plan plan = updates.check(p.id); failSave.set(true); + assertThrows(IOException.class, () -> updates.apply(plan, true)); + assertArrayEquals(originalJar, Files.readAllBytes(p.getDirectory().resolve("mods/example.jar"))); + assertArrayEquals(originalMetadata, Files.readAllBytes(p.getDirectory().resolve("collection.json"))); + } +} diff --git a/src/test/java/com/osiris/autoplug/client/profiles/ProviderCompatibilityTest.java b/src/test/java/com/osiris/autoplug/client/profiles/ProviderCompatibilityTest.java new file mode 100644 index 00000000..d558eead --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/profiles/ProviderCompatibilityTest.java @@ -0,0 +1,32 @@ +package com.osiris.autoplug.client.profiles; + +import com.google.gson.*; +import com.osiris.autoplug.client.tasks.updater.mods.*; +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.file.*; +import static org.junit.jupiter.api.Assertions.*; + +class ProviderCompatibilityTest { + @TempDir Path temp; + @Test void curseForgeDowngradeUsesArtifactHashNotCopiedTimestamp() throws Exception { + Path jar = temp.resolve("mod.jar"); Files.write(jar, "abc".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + Files.setLastModifiedTime(jar, java.nio.file.attribute.FileTime.fromMillis(System.currentTimeMillis() + 86400000)); + MinecraftMod mod = new MinecraftMod(jar.toString(), "Mod", "2", "Author", null, "123", null); + JsonObject release = JsonParser.parseString("{\"fileName\":\"mod-1.jar\",\"downloadUrl\":\"https://example.invalid/mod.jar\",\"fileLength\":3,\"fileDate\":\"2020-01-01T00:00:00Z\",\"hashes\":[{\"algo\":1,\"value\":\"0000000000000000000000000000000000000000\"}]}").getAsJsonObject(); + SearchResult result = new CurseForgeAPI().compatibleArtifactResult(release, mod); + assertEquals(SearchResult.Type.UPDATE_AVAILABLE, result.type); + release.getAsJsonArray("hashes").get(0).getAsJsonObject().addProperty("value", "a9993e364706816aba3e25717850c26c9cd0d89d"); + assertEquals(SearchResult.Type.UP_TO_DATE, new CurseForgeAPI().compatibleArtifactResult(release, mod).type); + } + @Test void exactModrinthSelectionRejectsWrongLoaderAndPreservesPrimaryChecksum() throws Exception { + JsonArray releases = JsonParser.parseString("[" + + "{\"version_number\":\"wrong\",\"date_published\":\"2025-01-01T00:00:00Z\",\"game_versions\":[\"1.20.1\"],\"loaders\":[\"neoforge\"],\"files\":[]}," + + "{\"version_number\":\"older-compatible\",\"date_published\":\"2024-01-01T00:00:00Z\",\"game_versions\":[\"1.20.1\"],\"loaders\":[\"fabric\"],\"files\":[{\"filename\":\"sources.jar\",\"primary\":false,\"url\":\"https://example.invalid/sources\",\"hashes\":{}},{\"filename\":\"main.jar\",\"primary\":true,\"url\":\"https://example.invalid/main\",\"size\":3,\"hashes\":{\"sha1\":\"a9993e364706816aba3e25717850c26c9cd0d89d\"}}]}]").getAsJsonArray(); + Path installed = temp.resolve("mod.jar"); Files.write(installed, "abc".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + SearchResult found = new ModrinthAPI().compatibleResult(releases, java.util.List.of("fabric"), "1.20.1", installed); + assertEquals("older-compatible", found.latestVersion); assertEquals("main.jar", found.fileName); assertEquals(SearchResult.Type.UP_TO_DATE, found.type); + assertEquals(SearchResult.Type.RESOURCE_NOT_FOUND, new ModrinthAPI().compatibleResult(releases, java.util.List.of("fabric"), "1.21.1", installed).type); + } +} diff --git a/src/test/java/com/osiris/autoplug/client/profiles/WorldActivityOrderingTest.java b/src/test/java/com/osiris/autoplug/client/profiles/WorldActivityOrderingTest.java new file mode 100644 index 00000000..8e01dd2a --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/profiles/WorldActivityOrderingTest.java @@ -0,0 +1,42 @@ +package com.osiris.autoplug.client.profiles; + +import com.osiris.autoplug.client.ui.LauncherActions.WorldInfo; +import com.osiris.autoplug.client.worlds.WorldStore; +import com.osiris.autoplug.client.worlds.VirtualWorld; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.nio.file.*; +import java.nio.file.attribute.FileTime; +import java.util.Collections; +import static org.junit.jupiter.api.Assertions.*; + +class WorldActivityOrderingTest { + @TempDir Path directory; + + @Test void newlyCreatedOrRecentlyUpdatedManagedWorldAppearsFirst() throws Exception { + Path root = directory.resolve("autoplug"); + try (LauncherServices services = new LauncherServices(root, directory.resolve("minecraft"), null)) { + WorldInfo first = services.createWorld("First", "", ""); + WorldInfo second = services.createWorld("Second", "", ""); + WorldStore store = new WorldStore(root.resolve("worlds")); + long future = System.currentTimeMillis() + 120_000; + VirtualWorld fresh = store.get(second.id); fresh.createdAt = future; store.save(fresh); + assertEquals(second.id, services.worlds().get(0).id); + Files.setLastModifiedTime(Paths.get(first.directory), FileTime.fromMillis(future + 10_000)); + assertEquals(first.id, services.worlds().get(0).id, "A newer update takes priority over creation time"); + assertFalse(store.get(first.id).eulaAccepted); assertFalse(store.get(second.id).eulaAccepted); + } + } + + @Test void localSaveLastPlayedAndLevelDataModificationAreBothConsidered() throws Exception { + Path save = Files.createDirectories(directory.resolve("save")); + long future = System.currentTimeMillis() + 120_000; + WorldInfo world = new WorldInfo("local:save", "Save", "", "", save.toString(), "", false, true, "1.21.1", + false, future, 0, true, Collections.emptyList(), false, false, true); + assertEquals(future, LauncherServices.worldActivity(world, 0)); + Path level = Files.write(save.resolve("level.dat"), new byte[]{0}); + Files.setLastModifiedTime(level, FileTime.fromMillis(future + 20_000)); + assertEquals(future + 20_000, LauncherServices.worldActivity(world, future - 10_000)); + assertArrayEquals(new byte[]{0}, Files.readAllBytes(level), "Ordering only reads metadata"); + } +} diff --git a/src/test/java/com/osiris/autoplug/client/tasks/updater/LegacyDownloadProgressTest.java b/src/test/java/com/osiris/autoplug/client/tasks/updater/LegacyDownloadProgressTest.java new file mode 100644 index 00000000..04682bc7 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/tasks/updater/LegacyDownloadProgressTest.java @@ -0,0 +1,186 @@ +package com.osiris.autoplug.client.tasks.updater; + +import com.osiris.autoplug.client.launcher.DownloadProgress; +import com.osiris.autoplug.client.tasks.updater.java.AdoptV3API; +import com.osiris.autoplug.client.tasks.updater.java.TaskJavaDownload; +import com.osiris.autoplug.client.tasks.updater.mods.TaskModDownload; +import com.osiris.autoplug.client.tasks.updater.plugins.TaskPluginDownload; +import com.osiris.autoplug.client.utils.GD; +import com.osiris.betterthread.BThreadManager; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.parallel.ResourceLock; +import org.junit.jupiter.api.parallel.Resources; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import java.io.File; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +/** Exercises the actual legacy loops, which do not inherit TaskDownload. */ +@ResourceLock(Resources.SYSTEM_PROPERTIES) +class LegacyDownloadProgressTest { + enum Kind { MOD, PLUGIN, JAVA } + private static final byte[] CONTENT = "local legacy download fixture".getBytes(StandardCharsets.UTF_8); + @TempDir Path temporary; + private String originalUserDir; + private File originalWorkingDir; + + @BeforeEach void isolateDownloadCache() { + originalUserDir = System.getProperty("user.dir"); + originalWorkingDir = GD.WORKING_DIR; + System.setProperty("user.dir", temporary.toString()); + } + + @AfterEach void restoreDownloadCache() { + System.setProperty("user.dir", originalUserDir); + GD.WORKING_DIR = originalWorkingDir; + } + + @ParameterizedTest @EnumSource(Kind.class) + void redirectedDownloadsReportSourcesBytesAndCompletion(Kind kind) throws Exception { + HttpServer server = server(false, 200, "application/octet-stream"); + List events = new CopyOnWriteArrayList<>(); + String source = url(server) + "/redirect?token=private"; + try (AutoCloseable ignored = DownloadProgress.subscribe(events::add)) { + assertArrayEquals(CONTENT, Files.readAllBytes(download(kind, source))); + } finally { server.stop(0); } + assertEquals(DownloadProgress.sourceUrl(source), events.get(0).sourceUrl); + assertTrue(events.stream().anyMatch(event -> event.sourceUrl.contains("/artifact?version=1&token=[redacted]"))); + assertTrue(events.stream().anyMatch(event -> !event.finished && event.downloadedBytes > 0 + && event.totalBytes == CONTENT.length)); + assertCompleted(events, CONTENT.length); + assertFalse(events.stream().anyMatch(event -> event.sourceUrl.contains("private"))); + } + + @ParameterizedTest @EnumSource(Kind.class) + void unknownLengthDownloadsRemainIndeterminateUntilFinished(Kind kind) throws Exception { + HttpServer server = server(true, 200, "application/octet-stream"); + List events = new CopyOnWriteArrayList<>(); + try (AutoCloseable ignored = DownloadProgress.subscribe(events::add)) { + assertArrayEquals(CONTENT, Files.readAllBytes(download(kind, url(server) + "/artifact"))); + } finally { server.stop(0); } + assertCompleted(events, -1); + } + + @ParameterizedTest @EnumSource(Kind.class) + void rejectedContentClosesProgressWithoutFalseCompletion(Kind kind) throws Exception { + HttpServer server = server(false, 200, "text/html"); + List events = new CopyOnWriteArrayList<>(); + try (AutoCloseable ignored = DownloadProgress.subscribe(events::add)) { + Exception failure = assertThrows(Exception.class, () -> download(kind, url(server) + "/artifact")); + assertTrue(failure.getMessage().contains("invalid content type")); + } finally { server.stop(0); } + assertFailed(events); + } + + @ParameterizedTest @EnumSource(Kind.class) + void failedHttpRequestsReleaseActiveCountAndRedactSource(Kind kind) throws Exception { + HttpServer server = server(false, 404, "application/octet-stream"); + List events = new CopyOnWriteArrayList<>(); + try (AutoCloseable ignored = DownloadProgress.subscribe(events::add)) { + Exception failure = assertThrows(Exception.class, () -> download(kind, url(server) + "/artifact?token=private")); + assertTrue(failure.getMessage().contains("404")); + assertTrue(failure.getMessage().contains("token=[redacted]")); + assertFalse(failure.getMessage().contains("private")); + } finally { server.stop(0); } + assertFailed(events); + } + + @Test void javaCacheCopyFailureMustNotReportSuccessfulCompletion() throws Exception { + HttpServer server = server(false, 200, "application/octet-stream"); + List events = new CopyOnWriteArrayList<>(); + AtomicBoolean blocked = new AtomicBoolean(); + AtomicReference fixtureFailure = new AtomicReference<>(); + Path output = temporary.resolve("java/runtime.zip"); + try (AutoCloseable ignored = DownloadProgress.subscribe(event -> { + events.add(event); + if (!event.finished && event.downloadedBytes > 0 && blocked.compareAndSet(false, true)) { + try { + // Simulate a destination becoming unavailable after HTTP succeeds. + Files.delete(output); + Files.createDirectory(output); + Files.write(output.resolve("occupied"), CONTENT); + } catch (Exception failure) { fixtureFailure.set(failure); } + } + })) { + assertThrows(Exception.class, () -> download(Kind.JAVA, url(server) + "/artifact")); + } finally { server.stop(0); } + assertTrue(blocked.get()); + assertNull(fixtureFailure.get()); + assertFailed(events); + } + + private Path download(Kind kind, String source) throws Exception { + BThreadManager manager = new BThreadManager(); + if (kind == Kind.MOD) { + TaskModDownload task = new TaskModDownload("fixture", manager, "fixture-mod", "1", source, + "MANUAL", temporary.resolve("unused.jar").toFile()); + task.download(); + return task.getDownloadDest().toPath(); + } + if (kind == Kind.PLUGIN) { + TaskPluginDownload task = new TaskPluginDownload("fixture", manager, "fixture-plugin", "1", source, + "MANUAL", temporary.resolve("unused.jar").toFile()); + task.download(); + return task.getDownloadDest().toPath(); + } + TaskJavaDownload task = new TaskJavaDownload("fixture", manager, source, + temporary.resolve("java/runtime.file").toFile(), AdoptV3API.OperatingSystemType.WINDOWS); + task.runAtStart(); + assertTrue(task.getNewCacheDest().getName().endsWith(".zip")); + return task.getNewCacheDest().toPath(); + } + + private static void assertCompleted(List events, long expectedTotal) { + assertFalse(events.isEmpty()); + DownloadProgress.Event last = events.get(events.size() - 1); + assertTrue(last.complete); + assertTrue(last.finished); + assertEquals(CONTENT.length, last.downloadedBytes); + assertEquals(expectedTotal, last.totalBytes); + assertEquals(0, last.activeTransfers); + assertEquals(1, events.stream().filter(event -> event.finished).count()); + assertTrue(events.stream().allMatch(event -> event.transferId == last.transferId)); + } + + private static void assertFailed(List events) { + assertFalse(events.isEmpty()); + assertFalse(events.stream().anyMatch(event -> event.complete)); + DownloadProgress.Event last = events.get(events.size() - 1); + assertTrue(last.finished); + assertEquals(0, last.activeTransfers); + } + + private static HttpServer server(boolean chunked, int status, String contentType) throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/redirect", exchange -> { + exchange.getResponseHeaders().set("Location", "/artifact?version=1&token=private"); + exchange.sendResponseHeaders(302, -1); + exchange.close(); + }); + server.createContext("/artifact", exchange -> { + exchange.getResponseHeaders().set("Content-Type", contentType); + exchange.getResponseHeaders().set("Content-Disposition", "attachment; filename=fixture.zip"); + exchange.sendResponseHeaders(status, chunked ? 0 : CONTENT.length); + try (OutputStream output = exchange.getResponseBody()) { output.write(CONTENT); } + }); + server.start(); + return server; + } + + private static String url(HttpServer server) { return "http://127.0.0.1:" + server.getAddress().getPort(); } +} diff --git a/src/test/java/com/osiris/autoplug/client/tasks/updater/mods/ModrinthArtifactIdentityTest.java b/src/test/java/com/osiris/autoplug/client/tasks/updater/mods/ModrinthArtifactIdentityTest.java new file mode 100644 index 00000000..13be1b6d --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/tasks/updater/mods/ModrinthArtifactIdentityTest.java @@ -0,0 +1,108 @@ +package com.osiris.autoplug.client.tasks.updater.mods; + +import com.osiris.autoplug.client.tasks.updater.search.SearchResult; +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.net.HttpURLConnection; +import java.net.SocketTimeoutException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +class ModrinthArtifactIdentityTest { + private static final String SHA1_ABC = "a9993e364706816aba3e25717850c26c9cd0d89d"; + @TempDir Path directory; + + @Test void resolvesTopLevelProjectFromExactBytesWithoutChangingInstalledFile() throws Exception { + Path installed = artifact(); + byte[] before = Files.readAllBytes(installed); + AtomicReference request = new AtomicReference<>(); + ModrinthAPI api = new ModrinthAPI(url -> { + FixtureConnection connection = new FixtureConnection(url, 200, + "{\"id\":\"Version1\",\"project_id\":\"AABBCCDD\",\"dependencies\":[{\"project_id\":\"Other123\"}],\"files\":[{\"hashes\":{\"sha1\":\"" + SHA1_ABC + "\"}}]}"); + request.set(connection); return connection; + }); + assertEquals("AABBCCDD", api.findProjectForArtifact(installed)); + assertEquals("https://api.modrinth.com/v2/version_file/" + SHA1_ABC + "?algorithm=sha1", request.get().getURL().toString()); + assertEquals("GET", request.get().getRequestMethod()); + assertEquals(15000, request.get().getConnectTimeout()); + assertEquals(20000, request.get().getReadTimeout()); + assertTrue(request.get().getRequestProperty("User-Agent").contains("AutoPlug-Client")); + assertTrue(request.get().disconnected); + assertArrayEquals(before, Files.readAllBytes(installed)); + } + + @Test void unknownHashReturnsNullButProviderErrorsRemainErrors() throws Exception { + Path installed = artifact(); + FixtureConnection absent = new FixtureConnection(new URL("https://example.invalid"), 404, ""); + assertNull(new ModrinthAPI(url -> absent).findProjectForArtifact(installed)); + assertTrue(absent.disconnected); + FixtureConnection failed = new FixtureConnection(new URL("https://example.invalid"), 503, "unavailable"); + IOException exception = assertThrows(IOException.class, () -> new ModrinthAPI(url -> failed).findProjectForArtifact(installed)); + assertTrue(exception.getMessage().contains("503")); + assertTrue(failed.disconnected); + assertThrows(SocketTimeoutException.class, () -> new ModrinthAPI(url -> { throw new SocketTimeoutException("Fixture timeout"); }).findProjectForArtifact(installed)); + } + + @Test void malformedSuccessfulResponsesDoNotLookLikeUnknownArtifacts() throws Exception { + Path installed = artifact(); + for (String body : List.of("not-json", "[]", "{}", "{\"project_id\":null}", "{\"project_id\":12}", "{\"project_id\":\"../escape\"}")) { + ModrinthAPI api = new ModrinthAPI(url -> new FixtureConnection(url, 200, body)); + assertThrows(IOException.class, () -> api.findProjectForArtifact(installed), body); + } + } + + @Test void absentLocalArtifactFailsBeforeAnyNetworkRequest() { + ModrinthAPI api = new ModrinthAPI(url -> { throw new AssertionError("Missing files must not trigger a lookup"); }); + assertThrows(IOException.class, () -> api.findProjectForArtifact(directory.resolve("missing.jar"))); + } + + @Test void compatibleResultsCarryIdentityForCurrentAndReplacementButNotMissingRelease() throws Exception { + Path installed = artifact(); + String response = "[{\"project_id\":\"AABBCCDD\",\"version_number\":\"1.0\",\"date_published\":\"2024-01-01T00:00:00Z\"," + + "\"game_versions\":[\"1.20.1\"],\"loaders\":[\"paper\"],\"files\":[{\"filename\":\"plugin.jar\",\"primary\":true," + + "\"url\":\"https://cdn.modrinth.com/data/AABBCCDD/plugin.jar\",\"size\":3,\"hashes\":{\"sha1\":\"" + SHA1_ABC + "\"}}]}]"; + ModrinthAPI api = new ModrinthAPI(url -> new FixtureConnection(url, 200, response)); + SearchResult current = api.searchCompatible(List.of("paper"), "AABBCCDD", "1.20.1", installed); + assertEquals(SearchResult.Type.UP_TO_DATE, current.type); + assertEquals("AABBCCDD", current.modrinthProjectId); + Files.write(installed, "different".getBytes(StandardCharsets.UTF_8)); + SearchResult replacement = api.searchCompatible(List.of("paper"), "AABBCCDD", "1.20.1", installed); + assertEquals(SearchResult.Type.UPDATE_AVAILABLE, replacement.type); + assertEquals("AABBCCDD", replacement.modrinthProjectId); + SearchResult missing = api.searchCompatible(List.of("paper"), "AABBCCDD", "1.21.1", installed); + assertEquals(SearchResult.Type.RESOURCE_NOT_FOUND, missing.type); + assertNull(missing.modrinthProjectId); + SearchResult failed = new ModrinthAPI(url -> new FixtureConnection(url, 500, "")).searchCompatible(List.of("paper"), "AABBCCDD", "1.20.1", installed); + assertEquals(SearchResult.Type.API_ERROR, failed.type); + assertNull(failed.modrinthProjectId); + } + + private Path artifact() throws IOException { + Path file = directory.resolve("a-name-unrelated-to-its-project.jar"); + Files.write(file, "abc".getBytes(StandardCharsets.UTF_8)); return file; + } + + private static final class FixtureConnection extends HttpURLConnection { + private final int status; + private final byte[] body; + boolean disconnected; + FixtureConnection(URL url, int status, String body) { + super(url); this.status = status; this.body = body.getBytes(StandardCharsets.UTF_8); + } + @Override public int getResponseCode() { return status; } + @Override public InputStream getInputStream() { return new ByteArrayInputStream(body); } + @Override public void disconnect() { disconnected = true; } + @Override public boolean usingProxy() { return false; } + @Override public void connect() { } + } +} diff --git a/src/test/java/com/osiris/autoplug/client/ui/DashboardAuthenticationLifecycleTest.java b/src/test/java/com/osiris/autoplug/client/ui/DashboardAuthenticationLifecycleTest.java new file mode 100644 index 00000000..4a4bda96 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/ui/DashboardAuthenticationLifecycleTest.java @@ -0,0 +1,122 @@ +package com.osiris.autoplug.client.ui; + +import com.formdev.flatlaf.FlatLightLaf; +import com.osiris.autoplug.client.browser.ServerBrowserService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import javax.imageio.ImageIO; +import javax.swing.*; +import java.awt.*; +import java.awt.event.WindowEvent; +import java.awt.image.BufferedImage; +import java.lang.reflect.Field; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeFalse; + +/** Agent-driven native Swing fixture interactions; no account/browser/network authorization. */ +class DashboardAuthenticationLifecycleTest { + @TempDir Path temporary; + static class Attempt { + final String name; final CountDownLatch waiting = new CountDownLatch(1), release = new CountDownLatch(1), done = new CountDownLatch(1); + Attempt(String name) { this.name = name; } + } + static class FakeActions implements LauncherActions { + long generation; String label = "Player (offline)"; + final Queue attempts = new ConcurrentLinkedQueue<>(); + @Override public synchronized long beginAccountChange() { return ++generation; } + @Override public synchronized void cancelMicrosoftSignIn() { generation++; } + @Override public synchronized SettingsInfo settings() { SettingsInfo s = new SettingsInfo(); s.microsoftClientId = "fixture-app-id"; s.account = label; return s; } + @Override public void saveSettings(SettingsInfo settings) {} + @Override public synchronized void useOfflineAccount(long attempt, String name) { if (attempt != generation) throw new CancellationException(); label = name + " (offline)"; } + @Override public void signInMicrosoft(long attempt, SettingsInfo settings, Consumer instructions) throws Exception { + Attempt fixture = attempts.remove(); + try { + instructions.accept("FAKE " + fixture.name + " - no real sign-in. example.invalid only."); fixture.waiting.countDown(); + assertTrue(fixture.release.await(10, TimeUnit.SECONDS), "fixture release timed out"); + // Send a late UI callback even after cancellation to test dashboard suppression. + instructions.accept("LATE FAKE " + fixture.name); + synchronized (this) { if (attempt != generation) throw new CancellationException(); label = fixture.name + " (Microsoft)"; } + } finally { fixture.done.countDown(); } + } + } + DashboardPanel panel; JFrame frame; javax.swing.Timer confirmation; + void open(FakeActions fake) throws Exception { + assumeFalse(GraphicsEnvironment.isHeadless(), "Native Swing peer test must be run with headless=false"); + SwingUtilities.invokeAndWait(() -> { + FlatLightLaf.setup(); panel = new DashboardPanel(fake, new ServerBrowserService(temporary.resolve("servers.json"), temporary.resolve("servers.dat")), false); + frame = new JFrame("AutoPlug - FAKE AUTH fixture only"); frame.setContentPane(panel); frame.setSize(1100, 720); frame.setVisible(true); + confirmation = new javax.swing.Timer(30, event -> { + for (Window window : Window.getWindows()) if (window.isVisible() && window instanceof JDialog && ((JDialog)window).getTitle().equals("Microsoft sign-in")) { + JOptionPane option = findOption(window); if (option != null) option.setValue(JOptionPane.OK_OPTION); + } + }); confirmation.start(); + }); + awaitUi(() -> field("clientId", JTextField.class).getText().equals("fixture-app-id")); + SwingUtilities.invokeAndWait(() -> button(panel, "Settings").doClick(0)); + } + void close() throws Exception { SwingUtilities.invokeAndWait(() -> { if (confirmation != null) confirmation.stop(); if (panel != null) panel.close(); if (frame != null) frame.dispose(); }); } + T field(String name, Class type) { + try { Field field = DashboardPanel.class.getDeclaredField(name); field.setAccessible(true); return type.cast(field.get(panel)); } + catch (Exception e) { throw new AssertionError(e); } + } + void signIn(Attempt attempt, FakeActions fake) throws Exception { + fake.attempts.add(attempt); + SwingUtilities.invokeAndWait(() -> button(panel, "Sign in with Microsoft").doClick(0)); + assertTrue(attempt.waiting.await(5, TimeUnit.SECONDS)); awaitUi(() -> { JDialog d=field("signInDialog", JDialog.class); return d!=null && d.isVisible(); }); + } + void finish(Attempt a) throws Exception { a.release.countDown(); assertTrue(a.done.await(5, TimeUnit.SECONDS)); } + void snapshot(String name) throws Exception { + String destination = System.getProperty("autoplug.auth.uiEvidence"); if (destination == null) return; + SwingUtilities.invokeAndWait(() -> { + try { + BufferedImage image = new BufferedImage(frame.getWidth(), frame.getHeight(), BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = image.createGraphics(); frame.paint(graphics); graphics.dispose(); + Path directory = Paths.get(destination); Files.createDirectories(directory); ImageIO.write(image, "png", directory.resolve(name + ".png").toFile()); + JDialog dialog = field("signInDialog", JDialog.class); + if (dialog != null && dialog.isVisible()) { + BufferedImage dialogImage = new BufferedImage(dialog.getWidth(), dialog.getHeight(), BufferedImage.TYPE_INT_RGB); + Graphics2D g = dialogImage.createGraphics(); dialog.paint(g); g.dispose(); ImageIO.write(dialogImage, "png", directory.resolve(name + "-dialog.png").toFile()); + } + } catch (Exception e) { throw new AssertionError(e); } + }); + } + @Test void repeatedSignInClickKeepsNewDialogAfterOldResponse() throws Exception { + FakeActions fake = new FakeActions(); Attempt a = new Attempt("LoginA"), b = new Attempt("LoginB"); + try { open(fake); signIn(a, fake); signIn(b, fake); snapshot("fixed-repeated-click"); finish(a); + awaitUi(() -> field("signInDialog", JDialog.class) != null && field("signInDialog", JDialog.class).isVisible()); + SwingUtilities.invokeAndWait(() -> assertTrue(text(field("signInDialog", JDialog.class)).contains("FAKE LoginB"))); + finish(b); awaitUi(() -> field("account", JLabel.class).getText().equals("LoginB (Microsoft)") && field("signInDialog", JDialog.class) == null && field("progress", JProgressBar.class).getValue() == 100); + snapshot("fixed-latest-login"); + } finally { a.release.countDown(); b.release.countDown(); close(); } + } + @Test void windowCloseSuppressesLateResponseAndNewDialog() throws Exception { + FakeActions fake = new FakeActions(); Attempt a = new Attempt("LoginA"); + try { open(fake); signIn(a, fake); snapshot("fixed-before-close"); + SwingUtilities.invokeAndWait(() -> { JDialog d=field("signInDialog", JDialog.class); d.dispatchEvent(new WindowEvent(d, WindowEvent.WINDOW_CLOSING)); }); + finish(a); awaitUi(() -> field("signInDialog", JDialog.class)==null); + SwingUtilities.invokeAndWait(() -> assertEquals("Player (offline)", field("account", JLabel.class).getText())); snapshot("fixed-after-close"); + } finally { a.release.countDown(); close(); } + } + @Test void offlineClickClosesDialogAndKeepsOfflineLabel() throws Exception { + FakeActions fake = new FakeActions(); Attempt a = new Attempt("LoginA"); + try { open(fake); signIn(a, fake); + SwingUtilities.invokeAndWait(() -> { field("offlineName", JTextField.class).setText("LocalPlayer"); button(panel, "Use offline name").doClick(0); }); + finish(a); awaitUi(() -> field("account", JLabel.class).getText().equals("LocalPlayer (offline)") && field("signInDialog", JDialog.class)==null); snapshot("fixed-after-offline"); + } finally { a.release.countDown(); close(); } + } + static void awaitUi(BooleanSupplier predicate) throws Exception { + long deadline=System.nanoTime()+TimeUnit.SECONDS.toNanos(5); + while (System.nanoTime() ready=new AtomicReference<>(); SwingUtilities.invokeAndWait(() -> ready.set(predicate.getAsBoolean())); if (ready.get()) return; Thread.sleep(20); } + fail("Native UI milestone timed out"); + } + static AbstractButton button(Container root, String title) { + for (Component child: root.getComponents()) { if (child instanceof AbstractButton && title.equals(((AbstractButton)child).getText())) return (AbstractButton)child; if (child instanceof Container) { AbstractButton found=button((Container)child,title); if(found!=null)return found; } } return null; + } + static JOptionPane findOption(Container root) { if(root instanceof JOptionPane)return (JOptionPane)root; for(Component child:root.getComponents())if(child instanceof Container){JOptionPane found=findOption((Container)child);if(found!=null)return found;}return null; } + static String text(Container root) { StringBuilder result=new StringBuilder(); for(Component child:root.getComponents()){if(child instanceof JTextArea)result.append(((JTextArea)child).getText());if(child instanceof Container)result.append(text((Container)child));}return result.toString(); } +} diff --git a/src/test/java/com/osiris/autoplug/client/ui/DashboardDisplayableTest.java b/src/test/java/com/osiris/autoplug/client/ui/DashboardDisplayableTest.java new file mode 100644 index 00000000..3089f68d --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/ui/DashboardDisplayableTest.java @@ -0,0 +1,282 @@ +package com.osiris.autoplug.client.ui; + +import com.formdev.flatlaf.FlatDarkLaf; +import com.formdev.flatlaf.FlatLightLaf; +import com.osiris.autoplug.client.browser.ServerBrowserService; +import com.osiris.autoplug.client.ui.LauncherActions.ProfileInfo; +import com.osiris.autoplug.client.ui.LauncherActions.SettingsInfo; +import com.osiris.autoplug.client.ui.LauncherActions.WorldInfo; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import javax.swing.*; +import java.awt.*; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BooleanSupplier; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeFalse; + +/** Exercises real, valid Swing peers without showing a window or contacting game services. */ +@Tag("displayable") +class DashboardDisplayableTest { + @TempDir Path directory; + + @Test void hiddenDisplayableDashboardReflowsSettingsAndWorldsInBothThemes() throws Exception { + assumeFalse(GraphicsEnvironment.isHeadless(), "Requires hidden native Swing peers; run with java.awt.headless=false"); + LookAndFeel original = UIManager.getLookAndFeel(); + RecordingEventQueue events = new RecordingEventQueue(); + AtomicReference frame = new AtomicReference<>(); + AtomicReference dashboard = new AtomicReference<>(); + onEdt(() -> Toolkit.getDefaultToolkit().getSystemEventQueue().push(events)); + try { + for (boolean dark : new boolean[]{false, true}) { + onEdt(() -> { + UIManager.setLookAndFeel(dark ? new FlatDarkLaf() : new FlatLightLaf()); + DashboardPanel panel = new DashboardPanel(fixtures(), new ServerBrowserService( + directory.resolve("missing-favorites.json"), directory.resolve("missing-servers.dat")), false); + dashboard.set(panel); + JFrame window = new JFrame("Hidden dashboard lifecycle test"); + frame.set(window); + window.setContentPane(panel); + // addNotify/pack create real peers, but setVisible(true) is never called. + window.addNotify(); + window.pack(); + window.setSize(1200, 820); + window.validate(); + assertTrue(window.isDisplayable()); + assertFalse(window.isVisible()); + assertFalse(window.isShowing()); + }); + awaitUi(() -> ((JProgressBar) required(dashboard.get(), "activity-progress")).getValue() == 100 + && findNamed(dashboard.get(), "world-local:fixture-7") != null, events); + + for (int width : new int[]{1200, 950, 1200, 950}) { + onEdt(() -> { + button(dashboard.get(), "Settings").doClick(); + frame.get().setSize(width, width == 950 ? 620 : 820); + frame.get().validate(); + }); + settle(frame.get(), events); + onEdt(() -> { + Container settings = required(dashboard.get(), "responsive-settings"); + assertValidAncestors(settings, frame.get()); + assertSettingsColumns(settings, width); + Container runtime = required(dashboard.get(), "advanced-runtime-fields"); + AbstractButton advanced = button(dashboard.get(), runtime.isVisible() ? "Advanced −" : "Advanced"); + if (!runtime.isVisible()) advanced.doClick(); + }); + settle(frame.get(), events); + onEdt(() -> { + Container runtime = required(dashboard.get(), "advanced-runtime-fields"); + List fields = textFields(runtime); + assertEquals(4, fields.size(), "All Java override fields remain available"); + JTextField java8 = fields.get(0); + if (java8.getText().isEmpty()) java8.setText("C:/fixture/runtime with spaces/bin/java"); + assertEquals("C:/fixture/runtime with spaces/bin/java", java8.getText()); + for (JTextField field : fields) { + assertTrue(field.getWidth() >= 80, "Runtime field must remain usable"); + assertTrue(field.getHeight() > 0); + } + JScrollPane scroll = (JScrollPane) SwingUtilities.getAncestorOfClass(JScrollPane.class, runtime); + assertNotNull(scroll); + scroll.doLayout(); // Calls ScrollPaneLayout -> ResponsiveSettings.getPreferredSize. + JTextField last = fields.get(fields.size() - 1); + // JTextField.scrollRectToVisible only scrolls the text horizontally. + JComponent view = (JComponent) scroll.getViewport().getView(); + view.scrollRectToVisible(SwingUtilities.convertRectangle(last.getParent(), last.getBounds(), view)); + assertInsideViewport(last, scroll); + if (width == 950) assertTrue(scroll.getViewport().getViewSize().height + > scroll.getViewport().getExtentSize().height, "Expanded narrow settings must scroll"); + assertInside(button(dashboard.get(), "Save settings"), dashboard.get()); + button(dashboard.get(), "Advanced −").doClick(); + }); + settle(frame.get(), events); + onEdt(() -> { + assertFalse(required(dashboard.get(), "advanced-runtime-fields").isVisible()); + button(dashboard.get(), "Advanced").doClick(); + }); + settle(frame.get(), events); + onEdt(() -> assertEquals("C:/fixture/runtime with spaces/bin/java", + textFields(required(dashboard.get(), "advanced-runtime-fields")).get(0).getText())); + + onEdt(() -> button(dashboard.get(), "Worlds").doClick()); + awaitUi(() -> ((JProgressBar) required(dashboard.get(), "activity-progress")).getValue() == 100, events); + settle(frame.get(), events); + onEdt(() -> verifyWorlds(dashboard.get(), frame.get())); + events.assertClean(); + } + onEdt(() -> { + dashboard.getAndSet(null).close(); + frame.getAndSet(null).dispose(); + }); + onEdt(() -> { }); + events.assertClean(); + } + } finally { + onEdt(() -> { + try { + if (dashboard.get() != null) dashboard.get().close(); + if (frame.get() != null) frame.get().dispose(); + if (original != null) UIManager.setLookAndFeel(original); + } finally { events.restore(); } + }); + } + events.assertClean(); + } + + private LauncherActions fixtures() { + return new LauncherActions() { + @Override public List profiles() { + assertFalse(SwingUtilities.isEventDispatchThread()); + return Arrays.asList(new ProfileInfo("fixture", "Fixture profile", "1.21.1", "VANILLA", "MODS", "", false)); + } + @Override public SettingsInfo settings() { + assertFalse(SwingUtilities.isEventDispatchThread()); + SettingsInfo settings = new SettingsInfo(); settings.account = "Offline fixture"; settings.defaultProfile = "fixture"; + return settings; + } + @Override public List worlds() { + assertFalse(SwingUtilities.isEventDispatchThread()); + List worlds = new ArrayList<>(); + for (int i = 0; i < 8; i++) worlds.add(new WorldInfo("local:fixture-" + i, + "Fixture world " + i + " — long metadata and Unicode 世界", "", "", + directory.resolve("a long fixture path with spaces/another long directory/original saves/world " + i).toString(), + "", false, true, "1.21.1", true, 1791072000000L, 184549376L, true, + Arrays.asList("vanilla", "file/long fixture datapack name"), true, false, true)); + return worlds; + } + }; + } + + private static void verifyWorlds(DashboardPanel panel, JFrame frame) { + Container first = required(panel, "world-local:fixture-0"); + assertValidAncestors(first, frame); + JScrollPane scroll = (JScrollPane) SwingUtilities.getAncestorOfClass(JScrollPane.class, first); + assertNotNull(scroll); + scroll.doLayout(); + Container view = (Container) scroll.getViewport().getView(); + int previousBottom = 0; + for (int i = 0; i < 8; i++) { + Container card = required(panel, "world-local:fixture-" + i); + assertTrue(card.getWidth() > 0 && card.getHeight() > 0); + assertTrue(card.getY() >= previousBottom, "World cards must not overlap"); + assertTrue(card.getX() >= 0 && card.getX() + card.getWidth() <= view.getWidth()); + assertInside(button(card, "Launch"), card); + assertInside(button(card, "Open folder"), card); + previousBottom = card.getY() + card.getHeight(); + } + assertTrue(view.getHeight() > scroll.getViewport().getExtentSize().height); + AbstractButton lastLaunch = button(required(panel, "world-local:fixture-7"), "Launch"); + lastLaunch.scrollRectToVisible(new Rectangle(0, 0, lastLaunch.getWidth(), lastLaunch.getHeight())); + assertInsideViewport(lastLaunch, scroll); + assertTrue(scroll.getViewport().getViewPosition().y > 0, "Final world must be reachable by scrolling"); + assertFalse(frame.isVisible()); + } + + private static void assertSettingsColumns(Container settings, int windowWidth) { + Component left = settings.getComponent(0), right = settings.getComponent(1); + assertTrue(left.getWidth() > 0 && right.getWidth() > 0); + if (windowWidth == 1200) { + assertEquals(left.getY(), right.getY()); + assertTrue(right.getX() >= left.getX() + left.getWidth()); + } else { + assertEquals(left.getX(), right.getX()); + assertTrue(right.getY() >= left.getY() + left.getHeight()); + } + } + + private static void assertValidAncestors(Component component, JFrame frame) { + assertTrue(component.isDisplayable(), "The test must use real native peers"); + for (Component current = component; current != null; current = current.getParent()) { + assertTrue(current.isValid(), "Expected validated ancestor: " + current.getClass().getSimpleName()); + if (current == frame) return; + } + fail("Component is not attached to the fixture frame"); + } + + private static void assertInside(Component child, Container ancestor) { + Rectangle bounds = SwingUtilities.convertRectangle(child.getParent(), child.getBounds(), ancestor); + assertTrue(bounds.width > 0 && bounds.height > 0); + assertTrue(new Rectangle(0, 0, ancestor.getWidth(), ancestor.getHeight()).contains(bounds), + "Control outside its container: " + bounds); + } + + private static void assertInsideViewport(Component child, JScrollPane scroll) { + Rectangle bounds = SwingUtilities.convertRectangle(child.getParent(), child.getBounds(), scroll.getViewport().getView()); + assertTrue(scroll.getViewport().getViewRect().contains(bounds), "Control must be reachable in the viewport: " + bounds + " within " + scroll.getViewport().getViewRect()); + } + + private static void settle(JFrame frame, RecordingEventQueue events) throws Exception { + // Separate EDT turns allow queued validation to run; this is not recursive doLayout. + for (int pass = 0; pass < 3; pass++) { + onEdt(() -> { RepaintManager.currentManager(frame).validateInvalidComponents(); frame.validate(); }); + events.assertClean(); + } + } + + private static void awaitUi(BooleanSupplier ready, RecordingEventQueue events) throws Exception { + AtomicBoolean result = new AtomicBoolean(); long end = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (!result.get() && System.nanoTime() < end) { + onEdt(() -> result.set(ready.getAsBoolean())); events.assertClean(); + if (!result.get()) Thread.sleep(20); + } + assertTrue(result.get(), "Fixture callbacks did not complete"); + } + + private static Container required(Container root, String name) { + Container found = findNamed(root, name); assertNotNull(found, "Missing component: " + name); return found; + } + private static Container findNamed(Container root, String name) { + if (name.equals(root.getName())) return root; + for (Component child : root.getComponents()) if (child instanceof Container) { + Container found = findNamed((Container) child, name); if (found != null) return found; + } + return null; + } + private static AbstractButton button(Container root, String text) { + AbstractButton found = findButton(root, text); assertNotNull(found, "Missing button: " + text); return found; + } + private static AbstractButton findButton(Container root, String text) { + for (Component child : root.getComponents()) { + if (child instanceof AbstractButton && text.equals(((AbstractButton) child).getText())) return (AbstractButton) child; + if (child instanceof Container) { AbstractButton found = findButton((Container) child, text); if (found != null) return found; } + } + return null; + } + private static List textFields(Container root) { + List fields = new ArrayList<>(); + for (Component child : root.getComponents()) { + if (child instanceof JTextField) fields.add((JTextField) child); + else if (child instanceof Container) fields.addAll(textFields((Container) child)); + } + return fields; + } + + private interface EdtAction { void run() throws Exception; } + private static void onEdt(EdtAction action) throws Exception { + AtomicReference failure = new AtomicReference<>(); + SwingUtilities.invokeAndWait(() -> { try { action.run(); } catch (Throwable error) { failure.set(error); } }); + if (failure.get() != null) throw new AssertionError("Displayable EDT action failed", failure.get()); + } + + private static final class RecordingEventQueue extends EventQueue { + private final ConcurrentLinkedQueue failures = new ConcurrentLinkedQueue<>(); + @Override protected void dispatchEvent(AWTEvent event) { + try { super.dispatchEvent(event); } catch (Throwable failure) { failures.add(failure); } + } + void assertClean() { + Throwable failure = failures.peek(); + if (failure != null) throw new AssertionError("Uncaught exception on the Swing event queue", failure); + } + void restore() { pop(); } + } +} diff --git a/src/test/java/com/osiris/autoplug/client/ui/DashboardPanelTest.java b/src/test/java/com/osiris/autoplug/client/ui/DashboardPanelTest.java new file mode 100644 index 00000000..31461663 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/ui/DashboardPanelTest.java @@ -0,0 +1,566 @@ +package com.osiris.autoplug.client.ui; + +import com.formdev.flatlaf.FlatLightLaf; +import com.formdev.flatlaf.FlatDarkLaf; +import com.osiris.autoplug.client.browser.ServerBrowserService; +import com.osiris.autoplug.client.browser.SavedServer; +import com.osiris.autoplug.client.browser.ServerStatus; +import com.osiris.autoplug.client.ui.LauncherActions.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import javax.imageio.ImageIO; +import javax.swing.*; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.nio.file.*; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import static org.junit.jupiter.api.Assertions.*; + +class DashboardPanelTest { + @TempDir Path directory; + + @Test void firstLaunchUtilitySummaryAndActionsFitCompactProfilesPage() throws Exception { + String summary = "Client utility mods install on first launch: Fabric API, Sodium, Entity Culling, ImmediatelyFast and Mod Menu, with required libraries. Exact-version availability is checked before launch."; + ProfileInfo profile = new ProfileInfo("fabric", "Default (FABRIC)", "1.21.1", "FABRIC", "MODS", directory.toString(), false, summary, true); + LauncherActions actions = new LauncherActions() { @Override public List profiles() { return Collections.singletonList(profile); } }; + AtomicReference panel = new AtomicReference<>(); + SwingUtilities.invokeAndWait(() -> { FlatLightLaf.setup(); panel.set(new DashboardPanel(actions, + new ServerBrowserService(directory.resolve("servers.json"), directory.resolve("servers.dat")), false)); }); + try { + awaitUi(() -> ((JProgressBar)findNamed(panel.get(), "activity-progress")).getValue() == 100); + SwingUtilities.invokeAndWait(() -> { + findButton(panel.get(), "Profiles").doClick(0); + ((JTable)findNamed(panel.get(), "profile-table")).setRowSelectionInterval(0, 0); + }); + awaitUi(() -> ((JTextArea)findNamed(panel.get(), "profile-details")).getText().contains("No mods added yet.")); + SwingUtilities.invokeAndWait(() -> { + panel.get().setSize(950, 620); for (int i = 0; i < 8; i++) layout(panel.get()); + JTextArea details = (JTextArea)findNamed(panel.get(), "profile-details"); + assertEquals(0, details.getCaretPosition(), "The background empty-collection note must not hide the beginning"); + assertTrue(details.getText().contains(summary)); assertTrue(details.getText().contains("Ready to launch")); + JViewport viewport = (JViewport)SwingUtilities.getAncestorOfClass(JViewport.class, details); + try { + Rectangle first = details.modelToView(0), last = details.modelToView(details.getDocument().getLength()); + assertTrue(viewport.getViewRect().contains(first), "Directory and readiness remain visible"); + assertTrue(viewport.getViewRect().contains(last), "The complete first-launch summary and empty-collection note fit"); + } catch (javax.swing.text.BadLocationException e) { throw new AssertionError(e); } + assertTrue(findNamed(panel.get(), "profile-table").getHeight() >= 100, "Profile list remains useful"); + for (String label : new String[]{"Launch client", "Add JAR", "Modrinth", "CurseForge", "AutoPlug mods"}) { + AbstractButton action = findButton(panel.get(), label); + Rectangle bounds = SwingUtilities.convertRectangle(action.getParent(), action.getBounds(), panel.get()); + assertTrue(bounds.x >= 0 && bounds.y >= 0 && bounds.width > 0 && bounds.height > 0 + && bounds.x + bounds.width <= 950 && bounds.y + bounds.height <= 620, label + " stays visible"); + } + }); + } finally { SwingUtilities.invokeAndWait(() -> panel.get().close()); } + } + + @Test void progressKeepsLiveStepThroughOtherRefreshAndReleasesSubscriptions() throws Exception { + AtomicReference> listener = new AtomicReference<>(); + AtomicReference> measured = new AtomicReference<>(); + CountDownLatch started = new CountDownLatch(1), release = new CountDownLatch(1); + AtomicInteger settingsCalls = new AtomicInteger(); + LauncherActions actions = new LauncherActions() { + @Override public void onProgress(Consumer callback) { listener.set(callback); } + @Override public void onProgressValue(Consumer callback) { measured.set(callback); } + @Override public SettingsInfo settings() { SettingsInfo result = new SettingsInfo(); result.account = "Account " + settingsCalls.incrementAndGet(); return result; } + @Override public void addArtifact(String id, String path, String project) throws Exception { started.countDown(); assertTrue(release.await(4, TimeUnit.SECONDS)); } + }; + AtomicReference panel = new AtomicReference<>(); + SwingUtilities.invokeAndWait(() -> panel.set(new DashboardPanel(actions, new ServerBrowserService(directory.resolve("servers.json"), directory.resolve("servers.dat")), false))); + try { + awaitUi(() -> ((JProgressBar) findNamed(panel.get(), "activity-progress")).getValue() == 100); + SwingUtilities.invokeAndWait(() -> panel.get().importArtifact(new ProfileInfo("test", "Test", "1.21.1", "VANILLA", "MODS", "", false), "example.jar", "")); + assertTrue(started.await(4, TimeUnit.SECONDS)); + listener.get().accept("Resolving libraries https://example.invalid/library.jar"); + awaitUi(() -> containsText(panel.get(), "Resolving libraries")); + SwingUtilities.invokeAndWait(() -> { + assertFalse(((JProgressBar) findNamed(panel.get(), "activity-progress")).isIndeterminate()); + assertTrue(((JProgressBar) findNamed(panel.get(), "activity-progress")).getValue() < 100); + assertEquals("https://example.invalid/library.jar", ((JTextField) findNamed(panel.get(), "download-source")).getText()); + findButton(panel.get(), "Reload").doClick(); + }); + awaitUi(() -> containsText(panel.get(), "Account 2")); + SwingUtilities.invokeAndWait(() -> assertTrue(containsText(panel.get(), "Resolving libraries"))); + measured.get().accept(37); + awaitUi(() -> ((JProgressBar) findNamed(panel.get(), "activity-progress")).getToolTipText().contains("Current step: 37%")); + AtomicInteger beforeRefresh = new AtomicInteger(); + SwingUtilities.invokeAndWait(() -> { + assertFalse(((JProgressBar) findNamed(panel.get(), "activity-progress")).isIndeterminate()); + beforeRefresh.set(((JProgressBar) findNamed(panel.get(), "activity-progress")).getValue()); + findButton(panel.get(), "Reload").doClick(); + }); + awaitUi(() -> containsText(panel.get(), "Account 3")); + SwingUtilities.invokeAndWait(() -> { + JProgressBar bar = (JProgressBar) findNamed(panel.get(), "activity-progress"); + assertFalse(bar.isIndeterminate()); assertTrue(bar.getValue() >= beforeRefresh.get()); assertTrue(bar.getValue() < 100); + }); + release.countDown(); awaitUi(() -> ((JProgressBar) findNamed(panel.get(), "activity-progress")).getValue() == 100); + Consumer stale = listener.get(); + SwingUtilities.invokeAndWait(() -> panel.get().close()); assertNull(listener.get()); assertNull(measured.get()); + stale.accept("Should not appear"); + SwingUtilities.invokeAndWait(() -> assertFalse(containsText(panel.get(), "Should not appear"))); + } finally { release.countDown(); SwingUtilities.invokeAndWait(() -> panel.get().close()); } + } + + @Test void navigationAndSettingsReflowWithoutDiscardingEdits() throws Exception { + AtomicReference panel = new AtomicReference<>(); + SwingUtilities.invokeAndWait(() -> panel.set(new DashboardPanel(new LauncherActions() {}, new ServerBrowserService(directory.resolve("servers.json"), directory.resolve("servers.dat")), false))); + try { + awaitUi(() -> ((JProgressBar) findNamed(panel.get(), "activity-progress")).getValue() == 100); + SwingUtilities.invokeAndWait(() -> { + for (String view : new String[]{"Server Browser", "Worlds", "Profiles", "Server Manager", "Settings"}) { + findButton(panel.get(), view).doClick(); + assertEquals(view, ((JLabel) findNamed(panel.get(), "navigation-title")).getText()); + assertTrue(findNamed(panel.get(), "page-" + view).isVisible()); + } + panel.get().setSize(1200, 820); for (int i = 0; i < 3; i++) layout(panel.get()); + Container grid = findNamed(panel.get(), "responsive-settings"); + assertEquals(grid.getComponent(0).getY(), grid.getComponent(1).getY()); + assertTrue(grid.getComponent(1).getX() > grid.getComponent(0).getX()); + assertFalse(findNamed(panel.get(), "advanced-runtime-fields").isVisible()); + findButton(panel.get(), "Advanced").doClick(); + assertTrue(findNamed(panel.get(), "advanced-runtime-fields").isVisible()); + JTextField runtime = findTextField(findNamed(panel.get(), "advanced-runtime-fields")); runtime.setText("/chosen/java"); + panel.get().setSize(950, 620); for (int i = 0; i < 3; i++) layout(panel.get()); + assertEquals(grid.getComponent(0).getX(), grid.getComponent(1).getX()); + assertTrue(grid.getComponent(1).getY() >= grid.getComponent(0).getHeight()); + assertTrue(runtime.getWidth() >= 80); assertEquals("/chosen/java", runtime.getText()); + assertNotNull(findButton(panel.get(), "Save settings")); + AbstractButton importer = findAccessibleButton(panel.get(), "Import Minecraft"); + assertNotNull(importer); assertNotNull(importer.getIcon()); assertEquals("Import Minecraft", importer.getToolTipText()); + }); + } finally { SwingUtilities.invokeAndWait(() -> panel.get().close()); } + } + + @Test void settingsMeasurementSurvivesChildLayoutInvalidation() throws Exception { + AtomicReference panel = new AtomicReference<>(); + SwingUtilities.invokeAndWait(() -> panel.set(new DashboardPanel(new LauncherActions() {}, new ServerBrowserService(directory.resolve("servers.json"), directory.resolve("servers.dat")), false))); + try { + awaitUi(() -> ((JProgressBar) findNamed(panel.get(), "activity-progress")).getValue() == 100); + SwingUtilities.invokeAndWait(() -> { + findButton(panel.get(), "Settings").doClick(); + findButton(panel.get(), "Advanced").doClick(); + Container responsive = findNamed(panel.get(), "responsive-settings"); + JScrollPane scroll = (JScrollPane) SwingUtilities.getAncestorOfClass(JScrollPane.class, responsive); + for (Container group : new Container[]{findNamed(panel.get(), "settings-account"), findNamed(panel.get(), "settings-defaults"), findNamed(panel.get(), "settings-advanced"), (Container) responsive.getComponent(1)}) { + AtomicBoolean invalidated = new AtomicBoolean(); + // Wrapped text can synchronously invalidate an ancestor while Swing measures it. + // Force that lifecycle event once, including in headless CI. + JTextArea wrapped = new JTextArea("A wrapped text measurement invalidates its containing layout.") { + @Override public Dimension getPreferredSize() { + if (getParent() != null && invalidated.compareAndSet(false, true)) { + LayoutManager manager = getParent().getLayout(); + if (manager instanceof LayoutManager2) ((LayoutManager2) manager).invalidateLayout(getParent()); + } + return super.getPreferredSize(); + } + }; + wrapped.setLineWrap(true); group.add(wrapped); + scroll.setSize(620, 350); scroll.doLayout(); + assertTrue(invalidated.get(), "Regression must actually invalidate during measurement"); + assertTrue(responsive.getPreferredSize().height > 0); + group.remove(wrapped); + } + }); + } finally { SwingUtilities.invokeAndWait(() -> panel.get().close()); } + } + + @Test void serverCardJoinKeepsAddressThroughRefreshDuringDefaultPreparation() throws Exception { + CountDownLatch preparing = new CountDownLatch(1), release = new CountDownLatch(1), launched = new CountDownLatch(1); + AtomicBoolean prepared = new AtomicBoolean(); AtomicReference> request = new AtomicReference<>(); + ProfileInfo client = new ProfileInfo("ready", "Default", "1.21.1", "VANILLA", "MODS", "", false); + LauncherActions actions = new LauncherActions() { + @Override public List profiles() { return prepared.get() ? Arrays.asList(client) : Collections.emptyList(); } + @Override public ProfileInfo ensureDefaultProfiles(String version) throws Exception { + assertFalse(SwingUtilities.isEventDispatchThread()); assertEquals("1.21.1", version); + preparing.countDown(); assertTrue(release.await(4, TimeUnit.SECONDS)); prepared.set(true); return client; + } + @Override public void launchProfile(String id, String host, int port) { + assertFalse(SwingUtilities.isEventDispatchThread()); request.set(Arrays.asList(id, host, String.valueOf(port))); launched.countDown(); + } + }; + SavedServer first = new SavedServer("Same name", "first.example.invalid:25566"), second = new SavedServer("Same name", "second.example.invalid:25567"); + Map statuses = new LinkedHashMap<>(); statuses.put(first.address, onlineStatus("First", "1.21.1")); statuses.put(second.address, onlineStatus("Second", "1.21.1")); + AtomicReference panel = new AtomicReference<>(); + SwingUtilities.invokeAndWait(() -> panel.set(new DashboardPanel(actions, new ServerBrowserService(directory.resolve("servers.json"), directory.resolve("servers.dat")), false, + choices -> ((AbstractButton)findNamed(choices, "launch-choice-ready")).doClick()))); + try { + awaitUi(() -> ((JProgressBar) findNamed(panel.get(), "activity-progress")).getValue() == 100); + SwingUtilities.invokeAndWait(() -> { + panel.get().displayServers(Arrays.asList(first, second), statuses); + findButton(findNamed(panel.get(), "server-" + first.address), "Join").doClick(); + }); + assertTrue(preparing.await(4, TimeUnit.SECONDS)); + SwingUtilities.invokeAndWait(() -> { + panel.get().displayServers(Arrays.asList(second, first), statuses); + ((AbstractButton) findNamed(panel.get(), "select-server-" + second.address)).doClick(); + }); + release.countDown(); assertTrue(launched.await(4, TimeUnit.SECONDS)); + assertEquals(Arrays.asList("ready", "first.example.invalid", "25566"), request.get()); + } finally { release.countDown(); SwingUtilities.invokeAndWait(() -> panel.get().close()); } + } + + @Test void serverCardsRejectStalePingsAndKeepCurrentVersionForKeyboardJoin() throws Exception { + CountDownLatch launched = new CountDownLatch(1); AtomicReference launchedProfile = new AtomicReference<>(); + LauncherActions actions = new LauncherActions() { + @Override public List profiles() { return Arrays.asList( + new ProfileInfo("newer", "Newer", "1.21.1", "VANILLA", "MODS", "", false), + new ProfileInfo("older", "Older", "1.20.1", "VANILLA", "MODS", "", false)); } + @Override public void launchProfile(String id, String host, int port) { launchedProfile.set(id); launched.countDown(); } + }; + SavedServer first = new SavedServer("First", "first.example.invalid"), removed = new SavedServer("Removed", "removed.example.invalid"); + AtomicReference panel = new AtomicReference<>(); + SwingUtilities.invokeAndWait(() -> panel.set(new DashboardPanel(actions, new ServerBrowserService(directory.resolve("servers.json"), directory.resolve("servers.dat")), false, + choices -> ((AbstractButton)findNamed(choices, "launch-choice-older")).doClick()))); + try { + awaitUi(() -> ((JProgressBar) findNamed(panel.get(), "activity-progress")).getValue() == 100); + SwingUtilities.invokeAndWait(() -> { + int oldGeneration = panel.get().displayServers(Arrays.asList(first, removed), Collections.emptyMap()); + assertTrue(containsText(findNamed(panel.get(), "server-" + first.address), "Checking…")); + int generation = panel.get().displayServers(Arrays.asList(first), Collections.emptyMap()); + panel.get().showPing(generation, first, onlineStatus("Current message", "1.20.1")); + panel.get().showPing(oldGeneration, first, onlineStatus("Stale message", "1.21.1")); + panel.get().showPing(oldGeneration, removed, onlineStatus("Removed result", "1.21.1")); + Container card = findNamed(panel.get(), "server-" + first.address); + assertTrue(containsText(card, "Current message")); assertTrue(containsText(card, "1.20.1")); + assertFalse(containsText(card, "Stale message")); assertNull(findNamed(panel.get(), "server-" + removed.address)); + AbstractButton select = (AbstractButton) findNamed(card, "select-server-" + first.address); + Object binding = select.getInputMap(JComponent.WHEN_FOCUSED).get(KeyStroke.getKeyStroke("ENTER")); + assertNotNull(binding); select.getActionMap().get(binding).actionPerformed(new java.awt.event.ActionEvent(select, 0, "keyboard")); + }); + assertTrue(launched.await(4, TimeUnit.SECONDS)); assertEquals("older", launchedProfile.get()); + } finally { SwingUtilities.invokeAndWait(() -> panel.get().close()); } + } + + @Test void serverCardsReflowFilterAndDisplayLiteralTextWithoutLosingControls() throws Exception { + AtomicReference panel = new AtomicReference<>(); + SwingUtilities.invokeAndWait(() -> { FlatLightLaf.setup(); panel.set(new DashboardPanel(new LauncherActions() {}, new ServerBrowserService(directory.resolve("servers.json"), directory.resolve("servers.dat")), false)); }); + try { + awaitUi(() -> ((JProgressBar) findNamed(panel.get(), "activity-progress")).getValue() == 100); + SwingUtilities.invokeAndWait(() -> { + SavedServer first = new SavedServer("Literal 雪 — A very long Minecraft favorite name", "first.example.invalid"), second = new SavedServer("Other", "second.example.invalid"); + Map statuses = new LinkedHashMap<>(); + statuses.put(first.address, onlineStatus("\nWelcome 雪", "Paper 1.21.1")); + statuses.put(second.address, new ServerStatus(false, "", "", 0, 0, 0, 0, "Connection refused")); + panel.get().displayServers(Arrays.asList(first, second), statuses); + Container firstCard = findNamed(panel.get(), "server-" + first.address), secondCard = findNamed(panel.get(), "server-" + second.address); + AbstractButton title = (AbstractButton) findNamed(firstCard, "select-server-" + first.address); + assertEquals(first.name, title.getText()); assertNull(title.getClientProperty(javax.swing.plaf.basic.BasicHTML.propertyKey)); + assertTrue(containsText(firstCard, " firstCard.getY()); + ((AbstractButton) findNamed(secondCard, "select-server-" + second.address)).doClick(); + ((JComboBox) findNamed(panel.get(), "server-sort")).setSelectedItem("Name"); + assertTrue(((AbstractButton) findNamed(secondCard, "select-server-" + second.address)).isSelected()); + panel.get().setSize(950, 620); for (int i = 0; i < 5; i++) layout(panel.get()); + assertEquals(firstCard.getX(), secondCard.getX()); assertNotEquals(firstCard.getY(), secondCard.getY()); + for (String action : new String[]{"Join", "Ping selected", "Copy address", "Edit server", "Remove"}) { + AbstractButton button = findAccessibleButton(firstCard, action); + Rectangle bounds = SwingUtilities.convertRectangle(button.getParent(), button.getBounds(), firstCard); + assertTrue(bounds.width > 0 && bounds.x >= 0 && bounds.x + bounds.width <= firstCard.getWidth(), action + " must fit within its card"); + } + JTextField search = (JTextField) findNamed(panel.get(), "server-search"); search.setText("first.example"); + assertTrue(firstCard.isVisible()); assertFalse(secondCard.isVisible()); assertTrue(title.isSelected()); + search.setText("no matching favorite"); assertFalse(firstCard.isVisible()); assertTrue(containsText(panel.get(), "No favorites match")); + search.setText(""); panel.get().displayServers(Collections.emptyList(), Collections.emptyMap()); + assertEquals(0, findNamed(panel.get(), "server-cards").getComponentCount()); assertTrue(containsText(panel.get(), "Add a server or import")); + }); + } finally { SwingUtilities.invokeAndWait(() -> panel.get().close()); } + } + + private static ServerStatus onlineStatus(String message, String version) { return new ServerStatus(true, message, version, 12, 40, 0, 38, "SUCCESS"); } + + @Test void parallelDownloadCompletionKeepsProgressVisibleUntilLastTransferEnds() throws Exception { + AtomicReference panel = new AtomicReference<>(); + SwingUtilities.invokeAndWait(() -> panel.set(new DashboardPanel(new LauncherActions() {}, new ServerBrowserService(directory.resolve("servers.json"), directory.resolve("servers.dat")), false))); + try { + awaitUi(() -> ((JProgressBar) findNamed(panel.get(), "activity-progress")).getValue() == 100); + try (com.osiris.autoplug.client.launcher.DownloadProgress.Transfer first = com.osiris.autoplug.client.launcher.DownloadProgress.begin("First asset", "https://example.invalid/first.jar"); + com.osiris.autoplug.client.launcher.DownloadProgress.Transfer second = com.osiris.autoplug.client.launcher.DownloadProgress.begin("Second asset", "https://example.invalid/second.jar")) { + first.complete(20, 20); + awaitUi(() -> containsText(panel.get(), "First asset — complete")); + SwingUtilities.invokeAndWait(() -> { + JProgressBar bar = (JProgressBar) findNamed(panel.get(), "activity-progress"); assertTrue(bar.isVisible()); assertFalse(bar.isIndeterminate()); assertTrue(bar.getValue() < 100); + }); + second.complete(30, 30); + awaitUi(() -> ((JProgressBar) findNamed(panel.get(), "activity-progress")).getValue() == 100); + SwingUtilities.invokeAndWait(() -> assertEquals("https://example.invalid/second.jar", ((JTextField) findNamed(panel.get(), "download-source")).getText())); + } + } finally { SwingUtilities.invokeAndWait(() -> panel.get().close()); } + } + + @Test void worldMetadataReportsUnknownAndBoundedSizeTruthfully() { + WorldInfo unknown = new WorldInfo("unknown", "Unknown", "", "", "", "", false, true, ""); + assertEquals("Additional save metadata unavailable", DashboardPanel.worldMetadata(unknown)); + WorldInfo bounded = new WorldInfo("known", "Known", "", "", "", "", false, true, "1.21.1", true, -1, 1536, false, Arrays.asList("vanilla", "file/pack"), true, false, true); + String detail = DashboardPanel.worldMetadata(bounded); + assertTrue(detail.contains("Last played: Unknown")); assertTrue(detail.contains("Size: At least 1.5 KiB")); + assertTrue(detail.contains("Modded")); assertTrue(detail.contains("Cheats: On")); assertTrue(detail.contains("Hardcore: Off")); assertTrue(detail.contains("Datapacks: 2")); + WorldInfo partial = new WorldInfo("partial", "Partial", "", "", "", "", false, true, "1.21.1", false, -1, 1588, false, Arrays.asList("vanilla"), false, false, true); + assertTrue(DashboardPanel.worldMetadata(partial).contains("At least 1.5 KiB")); + } + + @Test void versionSelectionDoesNotConfuseVersionPrefixes() { + assertTrue(DashboardPanel.versionMatches("Paper 1.21.1", "1.21.1")); + assertFalse(DashboardPanel.versionMatches("Paper 1.21.11", "1.21.1")); + assertFalse(DashboardPanel.versionMatches("1.21", "1.2")); + assertFalse(DashboardPanel.versionMatches(null, "1.21")); + assertEquals("1.21.1", DashboardPanel.gameVersionFromStatus("Paper 1.21.1")); + assertEquals("", DashboardPanel.gameVersionFromStatus("1.8 - 1.21.1")); + assertEquals("1.21.5-pre2", DashboardPanel.gameVersionFromStatus("Paper 1.21.5-pre2")); + assertEquals("25w34a", DashboardPanel.gameVersionFromStatus("25w34a")); + assertFalse(DashboardPanel.versionMatches("1.21.5-pre2", "1.21.5")); + } + + @Test void automaticJoinRequiresClientTypeExactVersionLoaderAndReadyWorkingProfile() { + ProfileInfo otherLoader = new ProfileInfo("wrong", "Forge", "1.21.1", "FORGE", "MODS", "", false); + ProfileInfo template = new ProfileInfo("template", "Base", "1.21.1", "FABRIC", "MODS", "", true); + ProfileInfo pending = new ProfileInfo("pending", "Pending", "1.21.1", "FABRIC", "MODS", "", false, "Review", false); + ProfileInfo server = new ProfileInfo("server", "Server", "1.21.1", "FABRIC", "MODS_SERVER", "", false); + ProfileInfo ready = new ProfileInfo("ready", "Client", "1.21.1", "FABRIC", "MODS", "", false); + List candidates = Arrays.asList(otherLoader, template, pending, server, ready); + assertSame(ready, DashboardPanel.matchingClientProfile(candidates, "1.21.1", "fabric")); + assertNull(DashboardPanel.matchingClientProfile(candidates, "1.21", "FABRIC")); + assertNull(DashboardPanel.matchingClientProfile(candidates, "", "FABRIC")); + } + + @Test void loadsServicesOffEventThreadAndBuildsAllFiveViews() throws Exception { + AtomicBoolean onEventThread = new AtomicBoolean(); CountDownLatch calls = new CountDownLatch(3); + LauncherActions actions = fixtures(onEventThread, calls); + AtomicReference panel = new AtomicReference<>(); + SwingUtilities.invokeAndWait(() -> panel.set(new DashboardPanel(actions, new ServerBrowserService(directory.resolve("servers.json"), directory.resolve("servers.dat")), false))); + try { + assertTrue(calls.await(4, TimeUnit.SECONDS)); assertFalse(onEventThread.get()); + SwingUtilities.invokeAndWait(() -> { + for (String view : new String[]{"Server Browser", "Worlds", "Profiles", "Server Manager", "Settings"}) + assertNotNull(findButton(panel.get(), view), "Missing navigation: " + view); + assertNotNull(findButton(panel.get(), "Create world")); + assertNotNull(findButton(panel.get(), "Sign in with Microsoft")); + }); + } finally { SwingUtilities.invokeAndWait(() -> panel.get().close()); } + } + + @Test void localWorldLaunchUsesRecordedVersionWithoutProfileEulaOrSharing() throws Exception { + CountDownLatch launched = new CountDownLatch(1); + AtomicReference> request = new AtomicReference<>(); AtomicBoolean onEventThread = new AtomicBoolean(); + String result = "Minecraft 1.12.2 started with automatic entry for Old cottage."; + ProfileInfo vanilla = new ProfileInfo("local-vanilla", "Vanilla", "1.12.2", "VANILLA", "MODS", "", false); + LauncherActions actions = new LauncherActions() { + @Override public List worlds() { + return Arrays.asList(new WorldInfo("local:cottage", "Old cottage", "", "", directory.toString(), "", false, true, "1.12.2")); + } + @Override public List profiles() { + return Arrays.asList(new ProfileInfo("newer-profile", "Different version", "1.21.1", "FABRIC", "MODS", "", false), vanilla); + } + @Override public ProfileInfo ensureDefaultProfiles(String version) { assertEquals("1.12.2", version); return vanilla; } + @Override public String launchLocalWorld(String id, String version) { + onEventThread.set(SwingUtilities.isEventDispatchThread()); request.set(Arrays.asList(id, version)); launched.countDown(); return result; + } + @Override public void launchWorld(String id, boolean share) { fail("A local save must not start a managed server"); } + }; + AtomicReference panel = new AtomicReference<>(); + SwingUtilities.invokeAndWait(() -> panel.set(new DashboardPanel(actions, new ServerBrowserService(directory.resolve("servers.json"), directory.resolve("servers.dat")), false, + choices -> ((AbstractButton)findNamed(choices, "launch-choice-local-vanilla")).doClick()))); + try { + awaitUi(() -> findNamed(panel.get(), "world-local:cottage") != null); + SwingUtilities.invokeAndWait(() -> { + Container card = findNamed(panel.get(), "world-local:cottage"); + assertTrue(containsText(card, "Singleplayer")); assertTrue(containsText(card, "Minecraft 1.12.2")); + assertNull(findButton(card, "Share")); assertNull(findButton(card, "Minecraft EULA")); assertNull(findButton(card, "Play locally")); + findButton(card, "Launch").doClick(); + }); + assertTrue(launched.await(4, TimeUnit.SECONDS)); assertFalse(onEventThread.get()); + assertEquals(Arrays.asList("local:cottage", "1.12.2"), request.get()); + awaitUi(() -> containsText(panel.get(), result)); + } finally { SwingUtilities.invokeAndWait(() -> panel.get().close()); } + } + + @Test void unknownLocalVersionIsVisibleAndManagedWorldKeepsItsOwnControls() throws Exception { + CountDownLatch managedLaunch = new CountDownLatch(1); + ProfileInfo client = new ProfileInfo("client", "Client", "1.21.1", "VANILLA", "MODS", "", false); + LauncherActions actions = new LauncherActions() { + @Override public List worlds() { + return Arrays.asList(new WorldInfo("local:old", "Old save", "", "", directory.toString(), "", false, true, ""), + new WorldInfo("managed", "Friends", "server", "client", directory.toString(), "", false, false, "1.21.1")); + } + @Override public List profiles() { return Arrays.asList(client); } + @Override public ProfileInfo ensureDefaultProfiles(String version) { return client; } + @Override public void launchWorld(String id, boolean share) { assertEquals("managed", id); assertFalse(share); managedLaunch.countDown(); } + @Override public String launchLocalWorld(String id, String version) { fail("Managed play must not launch a local save"); return ""; } + }; + AtomicReference panel = new AtomicReference<>(); + SwingUtilities.invokeAndWait(() -> panel.set(new DashboardPanel(actions, new ServerBrowserService(directory.resolve("servers.json"), directory.resolve("servers.dat")), false, + choices -> ((AbstractButton)findNamed(choices, "launch-choice-client")).doClick()))); + try { + awaitUi(() -> findNamed(panel.get(), "world-managed") != null); + SwingUtilities.invokeAndWait(() -> { + Container local = findNamed(panel.get(), "world-local:old"); + assertTrue(containsText(local, "version unknown")); assertNotNull(findButton(local, "Launch")); + assertFalse(containsText(local, "Server:")); assertNull(findButton(local, "Share")); + Container managed = findNamed(panel.get(), "world-managed"); + assertTrue(containsText(managed, "Managed world")); assertNotNull(findButton(managed, "Share")); + assertNotNull(findButton(managed, "Minecraft EULA")); assertNull(findButton(managed, "Launch")); + findButton(managed, "Play locally").doClick(); + }); + assertTrue(managedLaunch.await(4, TimeUnit.SECONDS)); + } finally { SwingUtilities.invokeAndWait(() -> panel.get().close()); } + } + + @Test void consoleReceivesBackgroundLogsAndReleasesListenerOnClose() throws Exception { + java.util.List> before = + new java.util.ArrayList<>(com.osiris.jlib.logger.AL.actionsOnMessageEvent); + AtomicReference panel = new AtomicReference<>(); + SwingUtilities.invokeAndWait(() -> panel.set(new ServerConsolePanel(new JPanel()))); + try { + java.util.List> added = + new java.util.ArrayList<>(com.osiris.jlib.logger.AL.actionsOnMessageEvent); added.removeAll(before); + assertEquals(1, added.size()); + added.get(0).executeOnEvent(new com.osiris.jlib.logger.Message(com.osiris.jlib.logger.Message.Type.INFO, "Background task finished")); + AtomicReference output = new AtomicReference<>(""); long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3); + while (!output.get().contains("Background task finished") && System.nanoTime() < deadline) { + Thread.sleep(30); SwingUtilities.invokeAndWait(() -> output.set(panel.get().txtConsole.getText())); + } + assertTrue(output.get().contains("Background task finished")); + SwingUtilities.invokeAndWait(() -> panel.get().close()); + assertFalse(com.osiris.jlib.logger.AL.actionsOnMessageEvent.contains(added.get(0))); + } finally { SwingUtilities.invokeAndWait(() -> panel.get().close()); } + } + + @Test void jarImportKeepsSelectedProfileAndOptionalProjectAndRunsOffEventThread() throws Exception { + CountDownLatch imported = new CountDownLatch(1); AtomicBoolean onEventThread = new AtomicBoolean(); + AtomicReference> request = new AtomicReference<>(); + LauncherActions actions = new LauncherActions() { + @Override public void addArtifact(String id, String path, String project) { + onEventThread.set(SwingUtilities.isEventDispatchThread()); request.set(Arrays.asList(id, path, project)); imported.countDown(); + } + }; + AtomicReference panel = new AtomicReference<>(); + ProfileInfo selected = new ProfileInfo("selected-profile", "My pack", "1.21.1", "FABRIC", "MODS", directory.toString(), false); + String jar = directory.resolve("a mod with spaces.jar").toString(); + SwingUtilities.invokeAndWait(() -> { + panel.set(new DashboardPanel(actions, new ServerBrowserService(directory.resolve("servers.json"), directory.resolve("servers.dat")), false)); + panel.get().importArtifact(selected, jar, "sodium"); + }); + try { + assertTrue(imported.await(4, TimeUnit.SECONDS)); assertFalse(onEventThread.get()); + assertEquals(Arrays.asList("selected-profile", jar, "sodium"), request.get()); + } finally { SwingUtilities.invokeAndWait(() -> panel.get().close()); } + } + + /** Offscreen fixture preview; does not initialize AutoPlug, sign in, or contact public servers. */ + public static void main(String[] args) throws Exception { + Path output = Paths.get(args.length == 0 ? "target/dashboard-preview" : args[0]); Files.createDirectories(output); + int width = args.length > 1 ? Integer.parseInt(args[1]) : 1200; + int height = args.length > 2 ? Integer.parseInt(args[2]) : 820; + Path data = Files.createTempDirectory("autoplug-dashboard-preview-"); + AtomicReference panel = new AtomicReference<>(); CountDownLatch calls = new CountDownLatch(3); + SwingUtilities.invokeAndWait(() -> { + if (args.length > 3 && "dark".equalsIgnoreCase(args[3])) FlatDarkLaf.setup(); else FlatLightLaf.setup(); + com.osiris.autoplug.client.utils.GD.TARGET = com.osiris.autoplug.client.Target.MINECRAFT_SERVER; + panel.set(new DashboardPanel(fixtures(new AtomicBoolean(), calls), new ServerBrowserService(data.resolve("servers.json"), data.resolve("servers.dat")), true)); + }); + calls.await(4, TimeUnit.SECONDS); + // The empty local import finishes before injecting render-only examples; no server is pinged. + awaitUi(() -> ((JProgressBar) findNamed(panel.get(), "activity-progress")).getValue() == 100); + SwingUtilities.invokeAndWait(() -> { + SavedServer online = new SavedServer("Quiet Cove", "cove.example.invalid"), unavailable = new SavedServer("Skyline Survival", "skyline.example.invalid:25566"), checking = new SavedServer("Weekend Adventure", "weekend.example.invalid"); + Map statuses = new LinkedHashMap<>(); + statuses.put(online.address, onlineStatus("A relaxed place to build, explore, and meet friends.\nNew adventures every weekend.", "Paper 1.21.1")); + statuses.put(unavailable.address, new ServerStatus(false, "", "", 0, 0, 0, 0, "Could not reach the server. Try again when it is online.")); + panel.get().displayServers(Arrays.asList(online, unavailable, checking), statuses); + }); + try { + for (String view : new String[]{"Server Browser", "Worlds", "Profiles", "Server Manager", "Settings"}) { + SwingUtilities.invokeAndWait(() -> findButton(panel.get(), view).doClick()); + Thread.sleep(50); + SwingUtilities.invokeAndWait(() -> { + try { + panel.get().setSize(width, height); for (int pass = 0; pass < 3; pass++) layout(panel.get()); + BufferedImage bitmap = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = bitmap.createGraphics(); + graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + panel.get().printAll(graphics); graphics.dispose(); + ImageIO.write(bitmap, "png", output.resolve(view.toLowerCase().replace(' ', '-') + ".png").toFile()); + } catch (Exception e) { throw new RuntimeException(e); } + }); + } + } finally { SwingUtilities.invokeAndWait(() -> panel.get().close()); } + } + + private static LauncherActions fixtures(AtomicBoolean onEventThread, CountDownLatch calls) { + return new LauncherActions() { + private void called() { if (SwingUtilities.isEventDispatchThread()) onEventThread.set(true); calls.countDown(); } + @Override public List profiles() { + called(); return Arrays.asList( + new ProfileInfo("vanilla", "Everyday Minecraft", "1.21.1", "VANILLA", "MODS", "/profiles/everyday", false), + new ProfileInfo("fabric", "Exploration pack", "1.21.1", "FABRIC", "MODS", "/profiles/exploration", false), + new ProfileInfo("paper", "Friends server", "1.21.1", "PAPER", "PLUGINS", "/profiles/friends", false), + new ProfileInfo("template", "My base template", "1.21.1", "FABRIC", "MODS", "/profiles/base", true)); + } + @Override public List worlds() { + called(); return Arrays.asList(new WorldInfo("local:cottage", "Lakeside cottage", "", "", "/.minecraft/saves/Lakeside cottage", "", false, true, "1.21.1", false, 1791072000000L, 184549376, true, Arrays.asList("vanilla"), false, false, true), + new WorldInfo("cove", "Quiet Cove", "paper", "vanilla", "/worlds/quiet-cove", "", false, false, "1.21.1", true, 1790990000000L, 597688320, true, Arrays.asList("vanilla", "file/terrain"), true, false, false), + new WorldInfo("local:archive", "An old adventure", "", "", "/.minecraft/saves/An old adventure", "", false, true, "")); + } + @Override public SettingsInfo settings() { called(); SettingsInfo settings = new SettingsInfo(); settings.account = "Offline · Alex"; settings.defaultProfile = "vanilla"; settings.java17 = "/runtimes/java-17/bin/java"; settings.java21 = "/runtimes/java-21/bin/java"; return settings; } + }; + } + private static AbstractButton findButton(Container container, String text) { + for (Component component : container.getComponents()) { + if (component instanceof AbstractButton && text.equals(((AbstractButton) component).getText())) return (AbstractButton) component; + if (component instanceof Container) { AbstractButton found = findButton((Container) component, text); if (found != null) return found; } + } + return null; + } + private static AbstractButton findAccessibleButton(Container container, String name) { + for (Component component : container.getComponents()) { + if (component instanceof AbstractButton && name.equals(component.getAccessibleContext().getAccessibleName())) return (AbstractButton) component; + if (component instanceof Container) { AbstractButton found = findAccessibleButton((Container) component, name); if (found != null) return found; } + } return null; + } + private static JTextField findTextField(Container container) { + for (Component component : container.getComponents()) { + if (component instanceof JTextField) return (JTextField) component; + if (component instanceof Container) { JTextField found = findTextField((Container) component); if (found != null) return found; } + } return null; + } + private static Container findNamed(Container container, String name) { + if (name.equals(container.getName())) return container; + for (Component child : container.getComponents()) if (child instanceof Container) { + Container found = findNamed((Container) child, name); if (found != null) return found; + } + return null; + } + private static boolean containsText(Container container, String text) { + if (container instanceof JLabel && ((JLabel) container).getText() != null && ((JLabel) container).getText().contains(text)) return true; + if (container instanceof JTextArea && ((JTextArea) container).getText().contains(text)) return true; + for (Component child : container.getComponents()) if (child instanceof Container && containsText((Container) child, text)) return true; + return false; + } + private static void awaitUi(java.util.function.BooleanSupplier condition) throws Exception { + AtomicBoolean satisfied = new AtomicBoolean(); long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(4); + while (!satisfied.get() && System.nanoTime() < deadline) { + SwingUtilities.invokeAndWait(() -> satisfied.set(condition.getAsBoolean())); + if (!satisfied.get()) Thread.sleep(20); + } + assertTrue(satisfied.get(), "Dashboard update did not arrive"); + } + private static void layout(Container container) { container.doLayout(); for (Component component : container.getComponents()) if (component instanceof Container) layout((Container) component); } +} diff --git a/src/test/java/com/osiris/autoplug/client/ui/DashboardProfileCompatibilityTest.java b/src/test/java/com/osiris/autoplug/client/ui/DashboardProfileCompatibilityTest.java new file mode 100644 index 00000000..bb60e5b0 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/ui/DashboardProfileCompatibilityTest.java @@ -0,0 +1,73 @@ +package com.osiris.autoplug.client.ui; + +import com.formdev.flatlaf.FlatLightLaf; +import com.osiris.autoplug.client.browser.ServerBrowserService; +import com.osiris.autoplug.client.profiles.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import javax.imageio.ImageIO; +import javax.swing.*; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.nio.file.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicReference; +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeFalse; + +/** Native creation dialog plus actual persisted ProfileStore; no game, gateway or identity provider. */ +class DashboardProfileCompatibilityTest { + @TempDir Path temp; + @Test void nativeCreateDialogFiltersTypeChangesAndPersistsSelectedPluginLoader() throws Exception { + assumeFalse(GraphicsEnvironment.isHeadless()); + ProfileStore store = new ProfileStore(temp.resolve("profiles")); CountDownLatch created = new CountDownLatch(1); + AtomicReference saved = new AtomicReference<>(); AtomicReference frame = new AtomicReference<>(); + AtomicReference panel = new AtomicReference<>(); + LauncherActions actions = new LauncherActions() { + @Override public ProfileInfo createProfile(String name, String version, String loader, String type, boolean template) throws Exception { + assertFalse(SwingUtilities.isEventDispatchThread()); + Profile profile = store.create(name, version, loader, ProfileType.valueOf(type)); saved.set(profile); created.countDown(); + return null; + } + }; + try { + SwingUtilities.invokeAndWait(() -> { + FlatLightLaf.setup(); DashboardPanel p = new DashboardPanel(actions, new ServerBrowserService(temp.resolve("servers.json"), temp.resolve("servers.dat")), false); + JFrame f = new JFrame("Profile fixture - no game"); f.setContentPane(p); f.setSize(950, 620); f.setVisible(true); panel.set(p); frame.set(f); + DashboardAuthenticationLifecycleTest.button(p, "Profiles").doClick(0); + }); + SwingUtilities.invokeLater(() -> DashboardAuthenticationLifecycleTest.button(panel.get(), "Create").doClick(0)); + AtomicReference dialog = new AtomicReference<>(); + DashboardAuthenticationLifecycleTest.awaitUi(() -> { + for (Window w : Window.getWindows()) if (w instanceof JDialog && w.isVisible() && "Create profile".equals(((JDialog) w).getTitle())) { dialog.set((JDialog) w); return true; } + return false; + }); + SwingUtilities.invokeAndWait(() -> { + JComboBox type = combo(dialog.get(), "MODS"), loader = combo(dialog.get(), "FABRIC"); assertNotNull(type); assertNotNull(loader); + select(type, "PLUGINS"); assertFalse(contains(loader, "FABRIC")); assertTrue(contains(loader, "PAPER")); + select(type, "MODS_SERVER"); assertFalse(contains(loader, "PAPER")); assertTrue(contains(loader, "FABRIC")); + select(type, "MODS"); assertFalse(contains(loader, "PAPER")); assertTrue(contains(loader, "VANILLA")); + select(type, "PLUGINS"); select(loader, "PAPER"); fillName(dialog.get()); + String output = System.getProperty("autoplug.derived.uiEvidence"); + if (output != null) try { + BufferedImage image = new BufferedImage(dialog.get().getWidth(), dialog.get().getHeight(), BufferedImage.TYPE_INT_RGB); + Graphics2D g = image.createGraphics(); dialog.get().paint(g); g.dispose(); + Files.createDirectories(Paths.get(output)); ImageIO.write(image, "png", Paths.get(output, "profile-plugin-dialog.png").toFile()); + } catch (Exception e) { throw new AssertionError(e); } + DashboardAuthenticationLifecycleTest.findOption(dialog.get()).setValue(JOptionPane.OK_OPTION); + }); + assertTrue(created.await(5, TimeUnit.SECONDS)); Profile actual = store.get(saved.get().id); + assertEquals(ProfileType.PLUGINS, actual.type); assertEquals("PAPER", actual.loader); assertEquals("Native fixture", actual.name); + } finally { + SwingUtilities.invokeAndWait(() -> { if (panel.get() != null) panel.get().close(); for (Window w : Window.getWindows()) if (w instanceof JDialog && (w.getOwner() == frame.get() || SwingUtilities.isDescendingFrom(w, frame.get()))) w.dispose(); if (frame.get() != null) frame.get().dispose(); }); + } + } + private static JComboBox combo(Container root, String item) { + for (Component c : root.getComponents()) { if (c instanceof JComboBox && contains((JComboBox) c, item)) return (JComboBox) c; + if (c instanceof Container) { JComboBox result = combo((Container) c, item); if (result != null) return result; } } + return null; + } + private static boolean contains(JComboBox combo, String item) { for (int i = 0; i < combo.getItemCount(); i++) if (item.equals(combo.getItemAt(i))) return true; return false; } + private static void select(JComboBox combo, String value) { combo.setSelectedItem(value); } + private static void fillName(Container root) { for (Component c : root.getComponents()) { if (c instanceof JTextField && ((JTextField)c).getText().isEmpty()) ((JTextField)c).setText("Native fixture"); else if (c instanceof Container) fillName((Container)c); } } +} diff --git a/src/test/java/com/osiris/autoplug/client/ui/DashboardServerRowsTest.java b/src/test/java/com/osiris/autoplug/client/ui/DashboardServerRowsTest.java new file mode 100644 index 00000000..64576015 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/ui/DashboardServerRowsTest.java @@ -0,0 +1,151 @@ +package com.osiris.autoplug.client.ui; + +import com.formdev.flatlaf.FlatLightLaf; +import com.osiris.autoplug.client.browser.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import javax.swing.*; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; +import static org.junit.jupiter.api.Assertions.*; + +class DashboardServerRowsTest { + @TempDir Path directory; + + @Test void repeatedPingsKeepNewestStatusAndIconAndNeverUpdateReplacedCards() throws Exception { + AtomicReference panel = new AtomicReference<>(); + SwingUtilities.invokeAndWait(() -> panel.set(new DashboardPanel(new LauncherActions() {}, + new ServerBrowserService(directory.resolve("servers.json"), directory.resolve("servers.dat")), false))); + try { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); boolean[] ready = {false}; + while (!ready[0] && System.nanoTime() < deadline) { + SwingUtilities.invokeAndWait(() -> ready[0] = ((JProgressBar)find(panel.get(), "activity-progress")).getValue() == 100); + if (!ready[0]) Thread.sleep(15); + } + assertTrue(ready[0]); + SwingUtilities.invokeAndWait(() -> { + SavedServer server = new SavedServer("Fixture", "fixture.example.invalid"); + BufferedImage oldIcon = new BufferedImage(64, 64, BufferedImage.TYPE_INT_RGB); + BufferedImage newIcon = new BufferedImage(64, 64, BufferedImage.TYPE_INT_RGB); + ServerStatus old = new ServerStatus(true, "Older response", "1.20.1", 1, 10, 763, 50, "SUCCESS", oldIcon); + ServerStatus fresh = new ServerStatus(true, "Newest response", "1.21.1", 2, 10, 767, 25, "SUCCESS", newIcon); + int generation = panel.get().displayServers(Collections.singletonList(server), Collections.singletonMap(server.address, old)); + long first = panel.get().beginServerPing(server), second = panel.get().beginServerPing(server); + JLabel icon = (JLabel)find(panel.get(), "server-icon"); + assertFalse(icon.getIcon() instanceof ImageIcon, "A pending check clears the old favicon"); + panel.get().showPing(generation, server, second, fresh); + panel.get().showPing(generation, server, first, old); + assertSame(newIcon, ((ImageIcon)icon.getIcon()).getImage()); + assertEquals("Newest response", ((JTextArea)find(panel.get(), "server-message")).getText()); + int replacement = panel.get().displayServers(Collections.singletonList(server), Collections.emptyMap()); + assertNotEquals(generation, replacement); + panel.get().showPing(generation, server, second, fresh); + assertEquals("Checking…", ((JLabel)find(panel.get(), "server-status")).getText()); + long latest = panel.get().beginServerPing(server); + panel.get().showPing(replacement, server, latest, new ServerStatus(false, "", "", 0, 0, 0, 0, "Offline")); + assertFalse(((JLabel)find(panel.get(), "server-icon")).getIcon() instanceof ImageIcon); + panel.get().close(); + panel.get().showPing(replacement, server, latest, fresh); + assertEquals("Offline · Unavailable", ((JLabel)find(panel.get(), "server-status")).getText()); + }); + } finally { SwingUtilities.invokeAndWait(() -> panel.get().close()); } + } + + @Test void rememberedCompatibleLoaderIsOfferedEvenWhenGlobalDefaultHasAnotherVersion() throws Exception { + LauncherActions.ProfileInfo global = new LauncherActions.ProfileInfo("global", "Global", "1.21.1", "VANILLA", "MODS", "", false); + LauncherActions.ProfileInfo previous = new LauncherActions.ProfileInfo("previous", "Previous", "1.20.1", "FABRIC", "MODS", "", false); + CountDownLatch launched = new CountDownLatch(1); AtomicReference selected = new AtomicReference<>(); + LauncherActions actions = new LauncherActions() { + @Override public java.util.List profiles() { return Arrays.asList(global, previous); } + @Override public SettingsInfo settings() { SettingsInfo result = new SettingsInfo(); result.defaultProfile = global.id; return result; } + @Override public String preferredProfile(String target) { assertEquals("server:remembered.example.invalid", target); return previous.id; } + @Override public void launchProfile(String id, String host, int port) { selected.set(id); launched.countDown(); } + @Override public ProfileInfo cloneProfile(String id, String name, String version, String loader) { fail("A compatible remembered profile must not require migration"); return null; } + }; + AtomicReference panel = new AtomicReference<>(); + SwingUtilities.invokeAndWait(() -> panel.set(new DashboardPanel(actions, + new ServerBrowserService(directory.resolve("servers.json"), directory.resolve("servers.dat")), false, + choices -> ((AbstractButton)find(choices, "launch-choice-previous")).doClick(0)))); + try { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); boolean[] ready = {false}; + while (!ready[0] && System.nanoTime() < deadline) { + SwingUtilities.invokeAndWait(() -> ready[0] = ((JProgressBar)find(panel.get(), "activity-progress")).getValue() == 100); + if (!ready[0]) Thread.sleep(15); + } + assertTrue(ready[0]); + SwingUtilities.invokeAndWait(() -> { + SavedServer server = new SavedServer("Remembered", "remembered.example.invalid"); + panel.get().displayServers(Collections.singletonList(server), Collections.singletonMap(server.address, + new ServerStatus(true, "Welcome", "1.20.1", 1, 20, 763, 20, "SUCCESS"))); + AbstractButton button = (AbstractButton)find(panel.get(), "select-server-" + server.address); + Object key = button.getInputMap(JComponent.WHEN_FOCUSED).get(KeyStroke.getKeyStroke("ENTER")); + button.getActionMap().get(key).actionPerformed(new java.awt.event.ActionEvent(button, 0, "join")); + }); + assertTrue(launched.await(4, TimeUnit.SECONDS)); assertEquals(previous.id, selected.get()); + } finally { SwingUtilities.invokeAndWait(() -> panel.get().close()); } + } + + @Test void rowsDefaultToMostRecentlyJoinedWithRealIconsAndDistinctStatusColors() throws Exception { + AtomicReference panel = new AtomicReference<>(); + SwingUtilities.invokeAndWait(() -> { FlatLightLaf.setup(); panel.set(new DashboardPanel(new LauncherActions() {}, + new ServerBrowserService(directory.resolve("servers.json"), directory.resolve("servers.dat")), false)); }); + try { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); boolean[] ready = {false}; + while (!ready[0] && System.nanoTime() < deadline) { + SwingUtilities.invokeAndWait(() -> ready[0] = ((JProgressBar) find(panel.get(), "activity-progress")).getValue() == 100); + if (!ready[0]) Thread.sleep(15); + } + assertTrue(ready[0]); + SwingUtilities.invokeAndWait(() -> { + SavedServer older = new SavedServer("Minecraft Server", "older.example.invalid", 1000); + SavedServer latest = new SavedServer("Recent favorite", "recent.example.invalid", 2000); + BufferedImage favicon = new BufferedImage(64, 64, BufferedImage.TYPE_INT_RGB); favicon.setRGB(1, 1, 0xFF237C42); + Map statuses = new HashMap<>(); + statuses.put(older.address, new ServerStatus(false, "", "", 0, 0, 0, 0, "Connection refused")); + statuses.put(latest.address, new ServerStatus(true, "Welcome", "1.21.1", 3, 20, 767, 40, "SUCCESS", favicon)); + panel.get().displayServers(Arrays.asList(older, latest), statuses); + assertEquals("Last joined", ((JComboBox) find(panel.get(), "server-sort")).getSelectedItem()); + Container grid = find(panel.get(), "server-cards"), newest = find(panel.get(), "server-" + latest.address), oldest = find(panel.get(), "server-" + older.address); + assertSame(newest, grid.getComponent(0)); + assertEquals("older.example.invalid", ((AbstractButton)find(oldest,"select-server-" + older.address)).getText()); + JLabel shownIcon = (JLabel)find(newest, "server-icon"); assertTrue(shownIcon.getIcon() instanceof ImageIcon); + assertSame(favicon, ((ImageIcon)shownIcon.getIcon()).getImage()); + for (int width : new int[]{1200, 950}) { + panel.get().setSize(width, width == 950 ? 620 : 820); for (int i = 0; i < 6; i++) layout(panel.get()); + assertEquals(newest.getX(), oldest.getX()); assertTrue(oldest.getY() >= newest.getY() + newest.getHeight()); + assertTrue(newest.getWidth() > 500); + JLabel online = (JLabel)find(newest, "server-status"), offline = (JLabel)find(oldest, "server-status"); + paint(online); paint(offline); assertNotEquals(online.getForeground(), offline.getForeground()); + for (Component child : buttons(newest)) { + Rectangle bounds = SwingUtilities.convertRectangle(child.getParent(), child.getBounds(), newest); + assertTrue(bounds.width > 0 && bounds.height > 0 && bounds.x >= 0 && bounds.y >= 0 + && bounds.x + bounds.width <= newest.getWidth() && bounds.y + bounds.height <= newest.getHeight(), "Controls remain within full-width card"); + } + } + ((JTextField)find(panel.get(), "server-search")).setText("older"); assertFalse(newest.isVisible()); assertTrue(oldest.isVisible()); + ((JTextField)find(panel.get(), "server-search")).setText(""); assertSame(newest, grid.getComponent(0)); + }); + } finally { SwingUtilities.invokeAndWait(() -> panel.get().close()); } + } + + private static void paint(JComponent component) { + BufferedImage image = new BufferedImage(Math.max(1, component.getWidth()), Math.max(1, component.getHeight()), BufferedImage.TYPE_INT_ARGB); + Graphics2D graphics = image.createGraphics(); try { component.paint(graphics); } finally { graphics.dispose(); } + } + private static java.util.List buttons(Container container) { + java.util.List result = new ArrayList<>(); + for (Component child : container.getComponents()) { if (child instanceof AbstractButton) result.add(child); if (child instanceof Container) result.addAll(buttons((Container)child)); } + return result; + } + private static Container find(Container container, String name) { + if (name.equals(container.getName())) return container; + for (Component child : container.getComponents()) if (child instanceof Container) { Container found = find((Container)child, name); if (found != null) return found; } + return null; + } + private static void layout(Container container) { container.doLayout(); for (Component child : container.getComponents()) if (child instanceof Container) layout((Container)child); } +} diff --git a/src/test/java/com/osiris/autoplug/client/ui/LaunchChoicePanelTest.java b/src/test/java/com/osiris/autoplug/client/ui/LaunchChoicePanelTest.java new file mode 100644 index 00000000..91dda4d3 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/ui/LaunchChoicePanelTest.java @@ -0,0 +1,90 @@ +package com.osiris.autoplug.client.ui; + +import com.osiris.autoplug.client.ui.LauncherActions.ProfileInfo; +import org.junit.jupiter.api.Test; +import javax.swing.*; +import java.awt.*; +import java.util.Arrays; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; +import static org.junit.jupiter.api.Assertions.*; + +class LaunchChoicePanelTest { + private final ProfileInfo preferred = new ProfileInfo("first", "First", "1.21.1", "VANILLA", "MODS", "", false); + private final ProfileInfo alternative = new ProfileInfo("second", "Second", "1.21.1", "FABRIC", "MODS", "", false); + + @Test void rememberedChoiceWaitsTwoSecondsAndLaunchesOnlyOnce() throws Exception { + SwingUtilities.invokeAndWait(() -> { + AtomicLong clock = new AtomicLong(); List chosen = new ArrayList<>(); + try (LaunchChoicePanel panel = new LaunchChoicePanel(Arrays.asList(preferred, alternative), preferred, true, chosen::add, clock::get)) { + AbstractButton cancel = named(panel, "abort-auto-launch"); assertTrue(cancel.isVisible()); assertTrue(cancel.getText().contains("2s")); + clock.set(1_000_000_000L); panel.tick(); assertTrue(cancel.getText().contains("1s")); assertTrue(chosen.isEmpty()); + clock.set(1_999_999_999L); panel.tick(); assertTrue(chosen.isEmpty()); + clock.set(2_000_000_000L); panel.tick(); panel.tick(); named(panel, "launch-choice-second").doClick(0); + assertEquals(Arrays.asList(preferred), chosen); + } + }); + } + + @Test void abortStopsAutomaticLaunchButAllowsOneDeliberateAlternative() throws Exception { + SwingUtilities.invokeAndWait(() -> { + AtomicLong clock = new AtomicLong(); List chosen = new ArrayList<>(); + try (LaunchChoicePanel panel = new LaunchChoicePanel(Arrays.asList(preferred, alternative), preferred, true, chosen::add, clock::get)) { + named(panel, "abort-auto-launch").doClick(0); + clock.set(9_000_000_000L); panel.tick(); assertTrue(chosen.isEmpty()); + assertFalse(named(panel, "abort-auto-launch").isVisible()); + named(panel, "launch-choice-second").doClick(0); named(panel, "launch-choice-first").doClick(0); panel.tick(); + assertEquals(Arrays.asList(alternative), chosen); + } + }); + } + + @Test void closingChooserCancelsQueuedTicksAndButtonEvents() throws Exception { + SwingUtilities.invokeAndWait(() -> { + AtomicLong clock = new AtomicLong(); List chosen = new ArrayList<>(); + LaunchChoicePanel panel = new LaunchChoicePanel(Arrays.asList(preferred, alternative), preferred, true, chosen::add, clock::get); + panel.close(); clock.set(9_000_000_000L); panel.tick(); named(panel, "launch-choice-first").doClick(0); panel.close(); + assertTrue(chosen.isEmpty(), "Disposing/closing the chooser cannot launch after it is gone"); + }); + } + + @Test void choicesAreFullWidthAtCompactAndWideSizesAndLiteralNamesStayLiteral() throws Exception { + SwingUtilities.invokeAndWait(() -> { + ProfileInfo literal = new ProfileInfo("literal", "Literal name", "1.21.1", "VANILLA", "MODS", "", false); + try (LaunchChoicePanel panel = new LaunchChoicePanel(Arrays.asList(preferred, literal), preferred, true, ignored -> {}, () -> 0L)) { + AbstractButton choice = named(panel, "launch-choice-literal"); + assertEquals(literal.toString(), choice.getText()); assertNull(choice.getClientProperty(javax.swing.plaf.basic.BasicHTML.propertyKey)); + for (int width : new int[]{800, 360}) { + panel.setSize(width, 460); for (int i = 0; i < 5; i++) layout(panel); + JViewport viewport = (JViewport)SwingUtilities.getAncestorOfClass(JViewport.class, choice); + assertNotNull(viewport); assertEquals(viewport.getExtentSize().width, choice.getWidth()); + assertTrue(choice.getHeight() >= 50); assertEquals(0, choice.getX()); + AbstractButton cancel = named(panel, "abort-auto-launch"); assertEquals(width - 32, cancel.getWidth()); assertTrue(cancel.getHeight() >= 50); + assertTrue(cancel.getBackground().getRed() > cancel.getBackground().getGreen()); + } + } + }); + } + + @Test void pendingOrTemplateProfilesCannotAutoLaunchOrBeSelected() throws Exception { + SwingUtilities.invokeAndWait(() -> { + ProfileInfo pending = new ProfileInfo("pending", "Pending", "1.21.1", "FABRIC", "MODS", "", false, "Review migration", false); + ProfileInfo template = new ProfileInfo("template", "Template", "1.21.1", "VANILLA", "MODS", "", true); + AtomicLong clock = new AtomicLong(); List chosen = new ArrayList<>(); + try (LaunchChoicePanel panel = new LaunchChoicePanel(Arrays.asList(pending, template), pending, true, chosen::add, clock::get)) { + assertFalse(named(panel, "launch-choice-pending").isEnabled()); assertFalse(named(panel, "launch-choice-template").isEnabled()); + clock.set(9_000_000_000L); panel.tick(); assertTrue(chosen.isEmpty()); assertFalse(named(panel, "abort-auto-launch").isVisible()); + } + }); + } + + private static AbstractButton named(Container container, String name) { + for (Component component : container.getComponents()) { + if (component instanceof AbstractButton && name.equals(component.getName())) return (AbstractButton)component; + if (component instanceof Container) { AbstractButton match = named((Container)component, name); if (match != null) return match; } + } + return null; + } + private static void layout(Container container) { container.doLayout(); for (Component child : container.getComponents()) if (child instanceof Container) layout((Container)child); } +} diff --git a/src/test/java/com/osiris/autoplug/client/ui/ManagedWorldPanelTest.java b/src/test/java/com/osiris/autoplug/client/ui/ManagedWorldPanelTest.java new file mode 100644 index 00000000..bbd92eeb --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/ui/ManagedWorldPanelTest.java @@ -0,0 +1,196 @@ +package com.osiris.autoplug.client.ui; + +import com.osiris.autoplug.client.ui.LauncherActions.WorldInfo; +import org.junit.jupiter.api.Test; + +import javax.swing.*; +import java.awt.Component; +import java.awt.Container; +import java.io.IOException; +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.FutureTask; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +/** Headless Swing tests: real EDT and worker queue, no frames, processes, network or Robot. */ +class ManagedWorldPanelTest { + private static final WorldInfo ALPHA = world("alpha", false), BETA = world("beta", false), LOCAL = world("local", true); + + @Test void serviceCallsStayOffEdtAndLocalWorldsNeverEnterManagedConsole() throws Exception { + Actions actions = new Actions(); ManagedWorldPanel panel = create(actions); + try { + drain(panel); + edt(() -> { + assertEquals(2, selector(panel).getItemCount()); + for (int i = 0; i < selector(panel).getItemCount(); i++) assertFalse(((WorldInfo) selector(panel).getItemAt(i)).local); + for (String label : Arrays.asList("Stop", "Restart", "Back up", "AutoPlug help", "Server status")) button(panel, label).doClick(0); + return null; + }); + drain(panel); + assertTrue(actions.calls.contains("stop:alpha")); + assertTrue(actions.calls.contains("restart:alpha")); + assertTrue(actions.calls.contains("command:alpha:.backup")); + assertTrue(actions.calls.contains("command:alpha:.help")); + assertTrue(actions.calls.contains("command:alpha:list")); + assertTrue(actions.calls.contains("log:alpha")); + assertFalse(actions.calledOnEdt.get(), "World listing, log reads and commands must run in the worker"); + } finally { close(panel); } + } + + @Test void slowRefreshPreservesASelectionMadeWhileTheListingWasInFlight() throws Exception { + Actions actions = new Actions(); ManagedWorldPanel panel = create(actions); Gate gate = new Gate(); + try { + drain(panel); actions.nextList.set(gate); + edt(() -> { panel.refresh(); return null; }); gate.awaitStarted(); + edt(() -> { selector(panel).setSelectedItem(BETA); return null; }); + gate.release.countDown(); drain(panel); + assertEquals("beta", edt(() -> ((WorldInfo) selector(panel).getSelectedItem()).id)); + assertEquals("log from beta", edt(() -> output(panel).getText())); + } finally { gate.release.countDown(); close(panel); } + } + + @Test void staleLogCannotReplaceTheNewSelectionsConsole() throws Exception { + Actions actions = new Actions(); ManagedWorldPanel panel = create(actions); Gate gate = new Gate(); + try { + drain(panel); actions.nextLog.set(gate); + edt(() -> { panel.refreshLog(); return null; }); gate.awaitStarted(); + edt(() -> { selector(panel).setSelectedItem(BETA); return null; }); + gate.release.countDown(); drain(panel); + assertNotEquals("log from alpha", edt(() -> output(panel).getText())); + edt(() -> { panel.refreshLog(); return null; }); drain(panel); + assertEquals("log from beta", edt(() -> output(panel).getText())); + assertFalse(actions.calledOnEdt.get()); + } finally { gate.release.countDown(); close(panel); } + } + + @Test void queuedCommandsRetainTheWorldCapturedWhenSendWasClicked() throws Exception { + Actions actions = new Actions(); ManagedWorldPanel panel = create(actions); Gate gate = new Gate(); + try { + drain(panel); actions.nextCommand.set(gate); + send(panel, "first world command"); gate.awaitStarted(); + edt(() -> { selector(panel).setSelectedItem(BETA); return null; }); + send(panel, "second world command"); + gate.release.countDown(); drain(panel); + assertTrue(actions.calls.contains("command:alpha:first world command")); + assertTrue(actions.calls.contains("command:beta:second world command")); + assertFalse(actions.calls.contains("command:beta:first world command")); + assertEquals("beta", edt(() -> ((WorldInfo) selector(panel).getSelectedItem()).id)); + assertEquals("", edt(() -> command(panel).getText())); + } finally { gate.release.countDown(); close(panel); } + } + + @Test void commandFailureFromPreviousSelectionCannotOverwriteCurrentStatus() throws Exception { + Actions actions = new Actions(); ManagedWorldPanel panel = create(actions); Gate gate = new Gate(); + List statuses = new CopyOnWriteArrayList<>(); + try { + drain(panel); actions.nextCommand.set(gate); actions.failCommand.set(true); + edt(() -> { state(panel).addPropertyChangeListener("text", event -> statuses.add(String.valueOf(event.getNewValue()))); return null; }); + send(panel, "failing command"); gate.awaitStarted(); + edt(() -> { selector(panel).setSelectedItem(BETA); statuses.clear(); return null; }); + gate.release.countDown(); drain(panel); + assertTrue(actions.calls.contains("command:alpha:failing command")); + assertFalse(statuses.stream().anyMatch(value -> value.contains("alpha command failed")), statuses.toString()); + assertEquals("log from beta", edt(() -> output(panel).getText())); + } finally { gate.release.countDown(); close(panel); } + } + + @Test void closeDiscardsQueuedCommandsAndLateResultsWithoutTouchingAnotherWorld() throws Exception { + Actions actions = new Actions(); ManagedWorldPanel panel = create(actions); Gate gate = new Gate(); + try { + drain(panel); actions.nextLog.set(gate); + edt(() -> { panel.refreshLog(); return null; }); gate.awaitStarted(); + send(panel, "must not execute"); + String before = edt(() -> { panel.close(); return output(panel).getText(); }); + gate.release.countDown(); + assertTrue(worker(panel).awaitTermination(3, TimeUnit.SECONDS)); + edt(() -> { panel.refresh(); panel.refreshLog(); return null; }); + edt(() -> null); + assertFalse(actions.calls.contains("command:alpha:must not execute")); + assertEquals(before, edt(() -> output(panel).getText())); + assertFalse(actions.calls.stream().anyMatch(value -> value.startsWith("command:beta:"))); + assertFalse(timer(panel).isRunning()); + } finally { gate.release.countDown(); close(panel); } + } + + private static ManagedWorldPanel create(Actions actions) throws Exception { + return edt(() -> { + ManagedWorldPanel panel = new ManagedWorldPanel(actions, world -> { }, path -> { }); + panel.refresh(); return panel; + }); + } + private static void send(ManagedWorldPanel panel, String text) throws Exception { + edt(() -> { command(panel).setText(text); button(panel, "Send").doClick(0); return null; }); + } + private static void drain(ManagedWorldPanel panel) throws Exception { + // A completion on the EDT may enqueue a refresh and then a log read. + for (int i = 0; i < 4; i++) { + worker(panel).submit(() -> { }).get(3, TimeUnit.SECONDS); + edt(() -> null); + } + } + private static void close(ManagedWorldPanel panel) throws Exception { + edt(() -> { panel.close(); return null; }); + assertTrue(worker(panel).awaitTermination(3, TimeUnit.SECONDS)); + edt(() -> null); + } + private static T edt(Callable task) throws Exception { + FutureTask future = new FutureTask<>(task); + SwingUtilities.invokeAndWait(future); + return future.get(3, TimeUnit.SECONDS); + } + private static Object field(ManagedWorldPanel panel, String name) throws Exception { + Field field = ManagedWorldPanel.class.getDeclaredField(name); field.setAccessible(true); return field.get(panel); + } + private static JComboBox selector(ManagedWorldPanel panel) throws Exception { return (JComboBox) field(panel, "worlds"); } + private static JTextArea output(ManagedWorldPanel panel) throws Exception { return (JTextArea) field(panel, "output"); } + private static JTextField command(ManagedWorldPanel panel) throws Exception { return (JTextField) field(panel, "command"); } + private static JLabel state(ManagedWorldPanel panel) throws Exception { return (JLabel) field(panel, "state"); } + private static ExecutorService worker(ManagedWorldPanel panel) throws Exception { return (ExecutorService) field(panel, "worker"); } + private static Timer timer(ManagedWorldPanel panel) throws Exception { return (Timer) field(panel, "timer"); } + private static JButton button(Container container, String label) { + for (Component child : container.getComponents()) { + if (child instanceof JButton && label.equals(((JButton) child).getText())) return (JButton) child; + if (child instanceof Container) { JButton found = findButton((Container) child, label); if (found != null) return found; } + } + throw new AssertionError("Missing button " + label); + } + private static JButton findButton(Container container, String label) { + for (Component child : container.getComponents()) { + if (child instanceof JButton && label.equals(((JButton) child).getText())) return (JButton) child; + if (child instanceof Container) { JButton found = findButton((Container) child, label); if (found != null) return found; } + } + return null; + } + private static WorldInfo world(String id, boolean local) { + return new WorldInfo(id, id, "server-" + id, "client-" + id, "world-" + id, null, true, local, "1.21.1"); + } + private static final class Gate { + final CountDownLatch started = new CountDownLatch(1), release = new CountDownLatch(1); + void awaitStarted() throws InterruptedException { assertTrue(started.await(3, TimeUnit.SECONDS), "Worker did not reach controlled operation"); } + void pause() throws InterruptedException { started.countDown(); if (!release.await(5, TimeUnit.SECONDS)) throw new InterruptedException("Fixture gate timeout"); } + } + private static final class Actions implements LauncherActions { + final List calls = new CopyOnWriteArrayList<>(); + final AtomicBoolean calledOnEdt = new AtomicBoolean(), failCommand = new AtomicBoolean(); + final AtomicReference nextList = new AtomicReference<>(), nextLog = new AtomicReference<>(), nextCommand = new AtomicReference<>(); + private void record(String call) { if (SwingUtilities.isEventDispatchThread()) calledOnEdt.set(true); calls.add(call); } + private void pause(AtomicReference next) throws InterruptedException { Gate gate = next.getAndSet(null); if (gate != null) gate.pause(); } + @Override public List worlds() throws Exception { record("worlds"); pause(nextList); return Arrays.asList(ALPHA, BETA, LOCAL); } + @Override public String serverLog(String id) throws Exception { record("log:" + id); pause(nextLog); return "log from " + id; } + @Override public void serverCommand(String id, String command) throws Exception { + record("command:" + id + ":" + command); pause(nextCommand); + if (failCommand.getAndSet(false)) throw new IOException(id + " command failed"); + } + @Override public void stopWorld(String id) { record("stop:" + id); } + @Override public void restartWorld(String id) { record("restart:" + id); } + } +} diff --git a/src/test/java/com/osiris/autoplug/client/ui/ManagedWorldStartupNativeTest.java b/src/test/java/com/osiris/autoplug/client/ui/ManagedWorldStartupNativeTest.java new file mode 100644 index 00000000..0f2d6514 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/ui/ManagedWorldStartupNativeTest.java @@ -0,0 +1,72 @@ +package com.osiris.autoplug.client.ui; + +import com.osiris.autoplug.client.profiles.*; +import com.osiris.autoplug.client.worlds.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import javax.imageio.ImageIO; +import javax.swing.*; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.nio.file.*; +import java.time.Duration; +import java.util.*; +import java.util.List; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeFalse; + +/** Native console clicks exercise the real WorldService with an interrupted, late fake installer. */ +class ManagedWorldStartupNativeTest { + @TempDir Path temp; + @Test void repeatedStopClicksCancelActualPendingServiceLaunch() throws Exception { + assumeFalse(GraphicsEnvironment.isHeadless()); + ProfileStore profiles = new ProfileStore(temp.resolve("profiles")); + Profile server = profiles.create("Server", "1.20.4", "PAPER", ProfileType.PLUGINS), client = profiles.create("Client", "1.20.4", "VANILLA", ProfileType.MODS); + WorldStore worlds = new WorldStore(temp.resolve("worlds")); VirtualWorld world = worlds.create("Delayed fixture world", server.id, client.id, true); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1), stopped = new CountDownLatch(2); AtomicInteger starts = new AtomicInteger(); + ExecutorService launchWorker = Executors.newSingleThreadExecutor(); AtomicReference> launch = new AtomicReference<>(); + AtomicReference panel = new AtomicReference<>(); AtomicReference frame = new AtomicReference<>(); + try (WorldService service = new WorldService(profiles, worlds, (p, d) -> { + entered.countDown(); while (release.getCount() != 0) try { release.await(); } catch (InterruptedException ignored) { } + return new ServerLaunch(Collections.singletonList("never-executed-fixture"), d); + }, (p, h, n) -> { throw new AssertionError("No client expected"); }, port -> { throw new AssertionError("No gateway"); }, + (h, p) -> true, request -> { starts.incrementAndGet(); throw new IOException("No actual server binary"); }, Duration.ofSeconds(5))) { + LauncherActions actions = new LauncherActions() { + @Override public List worlds() throws Exception { return Collections.singletonList(new WorldInfo(world.id, world.name, server.id, client.id, worlds.getDirectory(world.id).toString(), "", false)); } + @Override public void stopWorld(String id) { assertFalse(SwingUtilities.isEventDispatchThread()); service.stopWorld(id); stopped.countDown(); } + @Override public String serverLog(String id) { return release.getCount() == 0 ? "FAKE launch cancelled. No late process started: " + starts.get() + ". No Minecraft process or router exists." : "FAKE installer waiting. No Minecraft process or router exists."; } + }; + SwingUtilities.invokeAndWait(() -> { + ManagedWorldPanel p = new ManagedWorldPanel(actions, w -> launch.set(launchWorker.submit(() -> service.launch(w.id, false))), ignored -> {}); + JFrame f = new JFrame("World startup - FAKE fixture only"); f.setContentPane(p); f.setSize(950, 620); f.setVisible(true); panel.set(p); frame.set(f); p.select(world.id); + }); + DashboardAuthenticationLifecycleTest.awaitUi(() -> selected(panel.get())); + SwingUtilities.invokeAndWait(() -> DashboardAuthenticationLifecycleTest.button(panel.get(), "Play / start").doClick(0)); + assertTrue(entered.await(5, TimeUnit.SECONDS)); snapshot(frame.get(), "world-preparation-before-stop"); + SwingUtilities.invokeAndWait(() -> { DashboardAuthenticationLifecycleTest.button(panel.get(), "Stop").doClick(0); DashboardAuthenticationLifecycleTest.button(panel.get(), "Stop").doClick(0); }); + assertTrue(stopped.await(5, TimeUnit.SECONDS)); release.countDown(); + ExecutionException failure = assertThrows(ExecutionException.class, () -> launch.get().get(5, TimeUnit.SECONDS)); + assertTrue(failure.getCause() instanceof InterruptedException); assertEquals(0, starts.get()); assertTrue(service.activeSessions().isEmpty()); + assertFalse(Files.exists(worlds.getDirectory(world.id).resolve("eula.txt"))); + try (ProfileLease ignored = new ProfileLease(worlds.getDirectory(world.id))) { } + SwingUtilities.invokeAndWait(() -> panel.get().refresh()); + DashboardAuthenticationLifecycleTest.awaitUi(() -> outputContains(panel.get(), "FAKE launch cancelled")); + snapshot(frame.get(), "world-preparation-after-stop"); + } finally { + release.countDown(); launchWorker.shutdownNow(); assertTrue(launchWorker.awaitTermination(5, TimeUnit.SECONDS)); + SwingUtilities.invokeAndWait(() -> { if (panel.get() != null) panel.get().close(); if (frame.get() != null) frame.get().dispose(); }); + } + } + private static boolean selected(Container root) { for (Component c : root.getComponents()) { if (c instanceof JComboBox && ((JComboBox)c).getSelectedItem() instanceof LauncherActions.WorldInfo) return true; if (c instanceof Container && selected((Container)c)) return true; } return false; } + private static boolean outputContains(Container root, String text) { for (Component c : root.getComponents()) { if (c instanceof JTextArea && ((JTextArea)c).getText().contains(text)) return true; if (c instanceof Container && outputContains((Container)c, text)) return true; } return false; } + private static void snapshot(JFrame frame, String name) throws Exception { + String output = System.getProperty("autoplug.derived.uiEvidence"); if (output == null) return; + SwingUtilities.invokeAndWait(() -> { try { + BufferedImage image = new BufferedImage(frame.getWidth(), frame.getHeight(), BufferedImage.TYPE_INT_RGB); Graphics2D g = image.createGraphics(); frame.paint(g); g.dispose(); + Files.createDirectories(Paths.get(output)); ImageIO.write(image, "png", Paths.get(output, name + ".png").toFile()); + } catch (Exception e) { throw new AssertionError(e); } }); + } +} diff --git a/src/test/java/com/osiris/autoplug/client/ui/OverallProgressTest.java b/src/test/java/com/osiris/autoplug/client/ui/OverallProgressTest.java new file mode 100644 index 00000000..f284818b --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/ui/OverallProgressTest.java @@ -0,0 +1,23 @@ +package com.osiris.autoplug.client.ui; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class OverallProgressTest { + @Test void estimateMovesForwardAndSlowsWithoutPretendingToComplete() { + OverallProgress progress = new OverallProgress(); progress.start(); + int previous = progress.value(), start = previous; + for (int i = 0; i < 30; i++) { int next = progress.tick(); assertTrue(next >= previous); previous = next; } + int earlyGain = previous - start, laterStart = previous; + for (int i = 0; i < 30; i++) { int next = progress.tick(); assertTrue(next >= previous); previous = next; } + assertTrue(previous - laterStart < earlyGain); + for (int i = 0; i < 2000; i++) { int next = progress.tick(); assertTrue(next >= previous && next < 100); previous = next; } + assertTrue(previous <= 95); + } + + @Test void completionStaysCompleteUntilAnotherOperationExplicitlyStarts() { + OverallProgress progress = new OverallProgress(); progress.start(); progress.tick(); + assertEquals(100, progress.complete()); assertEquals(100, progress.value()); assertEquals(100, progress.tick()); + progress.start(); assertTrue(progress.value() > 0 && progress.value() < 100); + } +} diff --git a/src/test/java/com/osiris/autoplug/client/worlds/AutoPlugWorldBootstrapTest.java b/src/test/java/com/osiris/autoplug/client/worlds/AutoPlugWorldBootstrapTest.java new file mode 100644 index 00000000..1f09a588 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/worlds/AutoPlugWorldBootstrapTest.java @@ -0,0 +1,94 @@ +package com.osiris.autoplug.client.worlds; + +import com.osiris.autoplug.client.profiles.*; +import com.osiris.dyml.Yaml; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.zip.*; +import static org.junit.jupiter.api.Assertions.*; + +class AutoPlugWorldBootstrapTest { + @TempDir Path temporary; + + @Test void wrapsPackagedAutoPlugWithExactArgumentsAndIndependentWrapperRuntime() throws Exception { + Path source = jar(), world = Files.createDirectories(temporary.resolve("world with spaces")); + Path nativeJava = Files.write(temporary.resolve("older server java.exe"), new byte[]{1}); + List nativeCommand = Arrays.asList(nativeJava.toString(), "@libraries/forge/win_args.txt", "-Dnote=space and \"quoted\" value", "nogui"); + Profile profile = profile(); + ServerLaunch result = new AutoPlugWorldBootstrap(source, java()).wrap(profile, new ServerLaunch(nativeCommand, world)); + assertEquals(java().toString(), result.command.get(0)); assertNotEquals(nativeCommand.get(0), result.command.get(0)); + assertEquals(world.toAbsolutePath(), result.directory); assertEquals(".stop both", result.stopCommand); assertEquals(".restart", result.restartCommand); + assertTrue(result.command.contains(AutoPlugWorldBootstrap.CHILD_ARGUMENT)); + assertArrayEquals(Files.readAllBytes(source), Files.readAllBytes(world.resolve("autoplug/AutoPlug-Client.jar"))); + assertEquals(nativeCommand, AutoPlugWorldBootstrap.readCommand(world)); + assertFalse(Files.exists(world.resolve("eula.txt")), "Wrapper must not accept an EULA"); + Yaml general = read(world, "general"); + assertEquals("MINECRAFT_SERVER", general.get("general", "autoplug", "target-software").asString()); + assertTrue(general.get("general", "autoplug", "auto-stop").asBoolean()); + assertTrue(general.get("general", "server", "auto-start").asBoolean()); + assertFalse(general.get("general", "server", "auto-eula").asBoolean()); + assertFalse(general.get("general", "server", "restart-on-crash").asBoolean()); + assertFalse(general.get("general", "autoplug", "start-on-boot").asBoolean()); + assertFalse(general.get("general", "autoplug", "system-tray", "enable").asBoolean()); + Yaml updater = read(world, "updater"); + for (String name : Arrays.asList("self-updater", "java-updater", "server-updater", "mods-updater", "plugins-updater", "global-recurring-checks")) + assertFalse(updater.get("updater", name, "enable").asBoolean()); + } + + @Test void repeatLaunchKeepsUserConfigurationAndDoesNotTouchAnotherWorld() throws Exception { + Path source = jar(), first = Files.createDirectories(temporary.resolve("first")), second = Files.createDirectories(temporary.resolve("second")); + AutoPlugWorldBootstrap bootstrap = new AutoPlugWorldBootstrap(source, java()); Profile profile = profile(); + bootstrap.wrap(profile, new ServerLaunch(Arrays.asList(java().toString(), "-version"), first)); + bootstrap.wrap(profile, new ServerLaunch(Arrays.asList(java().toString(), "-version"), second)); + byte[] otherConfig = Files.readAllBytes(second.resolve("autoplug/general.yml")); + Yaml general = read(first, "general"); general.put("general", "server", "key").setValues("fixture-personal-key"); general.save(); + Yaml backup = read(first, "backup"); backup.put("backup", "enable").setValues("true"); backup.save(); + bootstrap.wrap(profile, new ServerLaunch(Arrays.asList(java().toString(), "-version"), first)); + assertEquals("fixture-personal-key", read(first, "general").get("general", "server", "key").asString()); + assertTrue(read(first, "backup").get("backup", "enable").asBoolean()); + assertArrayEquals(otherConfig, Files.readAllBytes(second.resolve("autoplug/general.yml"))); + } + + @Test void unbundledDevelopmentLaunchFailsClearlyBeforeMakingAnInstance() throws Exception { + Path world = Files.createDirectories(temporary.resolve("world")); + IOException error = assertThrows(IOException.class, () -> new AutoPlugWorldBootstrap(temporary, java()).wrap(profile(), new ServerLaunch(Arrays.asList(java().toString(), "-version"), world))); + assertTrue(error.getMessage().contains("packaged AutoPlug JAR")); + assertFalse(Files.exists(world.resolve("autoplug"))); + } + + @Test void independentRealConsoleProcessesReceiveOnlyTheirOwnCommandsAndStopGracefully() throws Exception { + Path first = Files.createDirectories(temporary.resolve("first")), second = Files.createDirectories(temporary.resolve("second")); + Process a = console(first), b = console(second); + try (ManagedServer one = new ManagedServer(a, 5000, ".stop both", ".restart"); + ManagedServer two = new ManagedServer(b, 5000, ".stop both", ".restart")) { + one.command("say first world"); two.command("say second world"); one.restart(); + assertThrows(IOException.class, () -> one.command("say invalid\nstop")); + one.close(); assertFalse(a.isAlive()); assertTrue(b.isAlive()); + two.close(); assertFalse(b.isAlive()); + assertEquals(Arrays.asList("say first world", ".restart", ".stop both"), Files.readAllLines(first.resolve("commands.txt"), StandardCharsets.UTF_8)); + assertEquals(Arrays.asList("say second world", ".stop both"), Files.readAllLines(second.resolve("commands.txt"), StandardCharsets.UTF_8)); + assertThrows(IOException.class, () -> one.command("say closed")); + } finally { if (a.isAlive()) a.destroyForcibly(); if (b.isAlive()) b.destroyForcibly(); } + } + + private Process console(Path directory) throws Exception { + Path classes = Paths.get(ManagedConsoleFixtureProcess.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + return new ProcessBuilder(java().toString(), "-cp", classes.toString(), ManagedConsoleFixtureProcess.class.getName()) + .directory(directory.toFile()).redirectErrorStream(true).redirectOutput(directory.resolve("fixture.log").toFile()).start(); + } + private Path jar() throws Exception { + Path file = temporary.resolve("AutoPlug fixture.jar"); + try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(file))) { + output.putNextEntry(new ZipEntry("com/osiris/autoplug/client/Main.class")); output.write(new byte[]{1, 2, 3}); output.closeEntry(); + } + return file; + } + private Profile profile() { Profile p = new Profile(); p.gameVersion = "1.20.4"; p.loader = "VANILLA"; p.type = ProfileType.MODS_SERVER; return p; } + private static Path java() { return Paths.get(System.getProperty("java.home"), "bin", System.getProperty("os.name").startsWith("Windows") ? "java.exe" : "java").toAbsolutePath().normalize(); } + private Yaml read(Path world, String name) throws Exception { Yaml yaml = new Yaml(world.resolve("autoplug/" + name + ".yml").toString()); yaml.load(); return yaml; } +} diff --git a/src/test/java/com/osiris/autoplug/client/worlds/LocalWorldStoreTest.java b/src/test/java/com/osiris/autoplug/client/worlds/LocalWorldStoreTest.java new file mode 100644 index 00000000..ecd73d1b --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/worlds/LocalWorldStoreTest.java @@ -0,0 +1,300 @@ +package com.osiris.autoplug.client.worlds; + +import com.osiris.autoplug.client.profiles.LauncherServices; +import com.osiris.autoplug.client.ui.LauncherActions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.Assumptions; +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.*; +import java.nio.file.*; +import java.util.*; +import java.util.zip.GZIPOutputStream; +import static org.junit.jupiter.api.Assertions.*; + +class LocalWorldStoreTest { + @TempDir Path temporary; + + @Test void discoversRawAndCompressedMetadataAndIconsWithoutChangingSaves() throws Exception { + Path minecraft = temporary.resolve("minecraft"); + Path modern = save(minecraft, "Folder with spaces", "Display name", "1.21.1", true, false, false); + save(minecraft, "Legacy", "", null, false, false, false); + ImageIO.write(new BufferedImage(64, 64, BufferedImage.TYPE_INT_RGB), "png", modern.resolve("icon.png").toFile()); + byte[] before = Files.readAllBytes(modern.resolve("level.dat")); + java.nio.file.attribute.FileTime modified = Files.getLastModifiedTime(modern.resolve("level.dat")); + List worlds = new LocalWorldStore(minecraft).list(); + assertEquals(2, worlds.size()); + LocalWorld world = worlds.get(0); + assertEquals("local:Folder with spaces", world.id); + assertEquals("Display name", world.name); + assertEquals("Folder with spaces", world.saveName()); + assertEquals("1.21.1", world.gameVersion); + assertEquals(minecraft, world.gameDirectory); + assertEquals(modern.resolve("icon.png"), world.icon); + assertEquals("Legacy", worlds.get(1).name); + assertEquals("", worlds.get(1).gameVersion); + assertArrayEquals(before, Files.readAllBytes(modern.resolve("level.dat"))); + assertEquals(modified, Files.getLastModifiedTime(modern.resolve("level.dat"))); + assertFalse(Files.exists(minecraft.resolve(".autoplug"))); + } + + @Test void missingMinecraftDirectoryIsNotCreatedByDiscovery() throws Exception { + Path absent = temporary.resolve("not-installed"); + assertTrue(new LocalWorldStore(absent).list().isEmpty()); + assertFalse(Files.exists(absent)); + } + + @Test void versionMustMatchAndUnknownRequiresAnExplicitChoice() throws Exception { + Path minecraft = temporary.resolve("minecraft"); + save(minecraft, "Modern", "Modern", "1.21.1", true, false, false); + save(minecraft, "Old", "Old", null, false, false, false); + LocalWorldStore store = new LocalWorldStore(minecraft); + LocalWorld modern = store.get("local:Modern"), old = store.get("local:Old"); + assertEquals("1.21.1", store.launchVersion(modern, null)); + assertThrows(IOException.class, () -> store.launchVersion(modern, "1.21.2")); + assertThrows(IOException.class, () -> store.launchVersion(old, "")); + assertEquals("1.7.10", store.launchVersion(old, "1.7.10")); + assertThrows(IOException.class, () -> store.launchVersion(old, "../../latest")); + // Metadata is not cached: a changed save is reflected before any subsequent launch. + save(minecraft, "Modern", "Modern", "1.21.2", true, false, false); + assertEquals("1.21.2", store.get("local:Modern").gameVersion); + } + + @Test void moddedMarkersRemainVisibleButPreventVanillaLaunch() throws Exception { + Path minecraft = temporary.resolve("minecraft"); + save(minecraft, "WasModded", "Modded", "1.21.1", true, true, false); + save(minecraft, "Forge", "Forge", "1.21.1", true, false, true); + LocalWorldStore store = new LocalWorldStore(minecraft); + assertEquals(2, store.list().size()); + for (LocalWorld world : store.list()) { + assertTrue(world.modded); + assertTrue(assertThrows(IOException.class, () -> store.launchVersion(world, "1.21.1")).getMessage().contains("modded")); + } + } + + @Test void prereleaseDisplayNamesResolveOnlyToTheSamePublishedBuild() throws Exception { + Path minecraft = temporary.resolve("minecraft"); + save(minecraft, "Prerelease", "Prerelease", "1.20 Pre-release 1", true, false, false); + save(minecraft, "Candidate", "Candidate", "1.20.2 Release Candidate 2", true, false, false); + LocalWorldStore store = new LocalWorldStore(minecraft); + LocalWorld prerelease = store.get("local:Prerelease"), candidate = store.get("local:Candidate"); + assertEquals("1.20 Pre-release 1", prerelease.gameVersion); + assertEquals("1.20-pre1", store.launchVersion(prerelease, null)); + assertEquals("1.20-pre1", store.launchVersion(prerelease, "1.20-pre1")); + assertEquals("1.20-pre1", store.launchVersion(prerelease, "1.20 pRE-rELEASe 1")); + assertEquals("1.20.2-rc2", store.launchVersion(candidate, "1.20.2-rc2")); + assertEquals("1.20.2-rc2", store.launchVersion(candidate, "1.20.2 RELEASE CANDIDATE 2")); + assertThrows(IOException.class, () -> store.launchVersion(prerelease, "1.20-pre2")); + assertThrows(IOException.class, () -> store.launchVersion(prerelease, "1.20")); + assertThrows(IOException.class, () -> store.launchVersion(candidate, "1.20.2-rc1")); + assertThrows(IOException.class, () -> store.launchVersion(candidate, "1.20.2-rc2 extra")); + } + + @Test void malformedSaveDoesNotHideValidWorldAndThumbnailIsOptional() throws Exception { + Path minecraft = temporary.resolve("minecraft"); + Path good = save(minecraft, "Valid", "Valid", "1.21.1", true, false, false); + Files.write(good.resolve("icon.png"), new byte[]{1, 2, 3}); + Path broken = Files.createDirectories(minecraft.resolve("saves/Broken")); + Files.write(broken.resolve("level.dat"), new byte[]{10, 0, 0, 10}); + List messages = new ArrayList<>(); + List worlds = new LocalWorldStore(minecraft, messages::add).list(); + assertEquals(1, worlds.size()); assertNull(worlds.get(0).icon); + assertTrue(messages.stream().anyMatch(message -> message.contains("Broken"))); + assertThrows(IOException.class, () -> new LocalWorldStore(minecraft).get("local:Broken")); + } + + @Test void rejectsTraversalAndLinkedSaveOrMetadata() throws Exception { + Path minecraft = temporary.resolve("minecraft"); + Path original = save(minecraft, "Original", "Original", "1.21.1", false, false, false); + LocalWorldStore store = new LocalWorldStore(minecraft); + for (String invalid : Arrays.asList("Original", "local:..", "local:../Original", "local:nested/Original", "local:C:world", "local:")) + assertThrows(IOException.class, () -> store.get(invalid)); + Path linked = minecraft.resolve("saves/Linked"); + try { Files.createSymbolicLink(linked, original); } + catch (IOException | UnsupportedOperationException | SecurityException e) { Assumptions.assumeTrue(false, "Host does not permit fixture symlinks"); } + assertThrows(IOException.class, () -> store.get("local:Linked")); + Path linkedMetadata = Files.createDirectories(minecraft.resolve("saves/LinkedMetadata")); + Files.createSymbolicLink(linkedMetadata.resolve("level.dat"), original.resolve("level.dat")); + assertThrows(IOException.class, () -> store.get("local:LinkedMetadata")); + assertEquals(1, store.list().size()); + } + + @Test void boundedReaderRejectsNegativeArraysDeepNestingAndCompressedExpansion() throws Exception { + Path badLength = temporary.resolve("negative.dat"); + try (DataOutputStream out = new DataOutputStream(Files.newOutputStream(badLength))) { + tag(out, 10, ""); tag(out, 7, "bytes"); out.writeInt(-1); + } + assertThrows(IOException.class, () -> new LevelDatReader().read(badLength)); + Path deep = temporary.resolve("deep.dat"); + try (DataOutputStream out = new DataOutputStream(Files.newOutputStream(deep))) { + tag(out, 10, ""); for (int i = 0; i < 40; i++) tag(out, 10, "nested"); + for (int i = 0; i < 41; i++) out.writeByte(0); + } + assertThrows(IOException.class, () -> new LevelDatReader().read(deep)); + Path expanded = temporary.resolve("expanded.dat"); + try (DataOutputStream out = new DataOutputStream(new GZIPOutputStream(Files.newOutputStream(expanded)))) { + tag(out, 10, ""); tag(out, 10, "Data"); tag(out, 7, "bytes"); out.writeInt(16 * 1024 * 1024); + byte[] block = new byte[8192]; for (int i = 0; i < 2048; i++) out.write(block); + out.writeByte(0); out.writeByte(0); + } + assertTrue(Files.size(expanded) < 100000); + assertThrows(IOException.class, () -> new LevelDatReader().read(expanded)); + } + + @Test void duplicateVersionTagsAreRejectedRatherThanSilentlyOverridingSafetyMetadata() throws Exception { + Path file = temporary.resolve("duplicate.dat"); + try (DataOutputStream out = new DataOutputStream(Files.newOutputStream(file))) { + tag(out, 10, ""); tag(out, 10, "Data"); + tag(out, 1, "WasModded"); out.writeByte(1); + tag(out, 1, "WasModded"); out.writeByte(0); + out.writeByte(0); out.writeByte(0); + } + assertThrows(IOException.class, () -> new LevelDatReader().read(file)); + } + + @Test void servicesCombineLocalAndManagedWorldsAndRejectUpgradeBeforeDownloads() throws Exception { + Path minecraft = temporary.resolve("minecraft"); + Path original = save(minecraft, "Local", "Local save", "1.21.1", true, false, false); + byte[] before = Files.readAllBytes(original.resolve("level.dat")); + try (LauncherServices services = new LauncherServices(temporary.resolve("autoplug"), minecraft, ignored -> { })) { + LauncherActions.ProfileInfo server = services.createProfile("Server", "1.21.1", "VANILLA", "MODS_SERVER", false); + LauncherActions.ProfileInfo client = services.createProfile("Client", "1.21.1", "VANILLA", "MODS", false); + LauncherActions.WorldInfo created = services.createWorld("Managed", server.id, client.id); + WorldStore store = new WorldStore(temporary.resolve("autoplug/worlds")); + VirtualWorld managed = store.get(created.id); + managed.createdAt = System.currentTimeMillis() + 60_000; store.save(managed); + List worlds = services.worlds(); + assertEquals(2, worlds.size()); + assertEquals(created.id, worlds.get(0).id, "The most recently created world leads the combined list"); + assertFalse(worlds.get(0).local); assertFalse(store.get(created.id).eulaAccepted); + LauncherActions.WorldInfo local = worlds.stream().filter(world -> world.id.equals("local:Local")).findFirst().orElseThrow(AssertionError::new); + assertTrue(local.local); assertEquals("1.21.1", local.gameVersion); + assertEquals(original.toString(), local.directory); + assertThrows(IOException.class, () -> services.launchLocalWorld(local.id, "1.21.2")); + } + assertArrayEquals(before, Files.readAllBytes(original.resolve("level.dat"))); + assertFalse(Files.exists(minecraft.resolve(".autoplug"))); + } + + @Test void invalidLocalSavesRootDoesNotHideManagedWorlds() throws Exception { + Path minecraft = Files.createDirectories(temporary.resolve("minecraft")); + byte[] original = new byte[]{1, 2, 3}; + Files.write(minecraft.resolve("saves"), original); + List messages = new ArrayList<>(); + try (LauncherServices services = new LauncherServices(temporary.resolve("autoplug"), minecraft, messages::add)) { + LauncherActions.ProfileInfo server = services.createProfile("Server", "1.21.1", "VANILLA", "MODS_SERVER", false); + LauncherActions.ProfileInfo client = services.createProfile("Client", "1.21.1", "VANILLA", "MODS", false); + LauncherActions.WorldInfo managed = services.createWorld("Managed", server.id, client.id); + List worlds = services.worlds(); + assertEquals(1, worlds.size()); + assertEquals(managed.id, worlds.get(0).id); + assertFalse(worlds.get(0).local); + assertTrue(messages.stream().anyMatch(message -> message.contains("Could not list local Minecraft saves"))); + } + assertArrayEquals(original, Files.readAllBytes(minecraft.resolve("saves"))); + } + + @Test void displaysBoundedMetadataAndExactSmallSaveSizeWithoutRewritingIt() throws Exception { + Path minecraft = temporary.resolve("minecraft"); + Path directory = Files.createDirectories(minecraft.resolve("saves/Detailed")); + try (DataOutputStream out = new DataOutputStream(Files.newOutputStream(directory.resolve("level.dat")))) { + tag(out, 10, ""); tag(out, 10, "Data"); + tag(out, 8, "LevelName"); out.writeUTF("Detailed"); + tag(out, 10, "Version"); tag(out, 8, "Name"); out.writeUTF("1.21.1"); out.writeByte(0); + tag(out, 4, "LastPlayed"); out.writeLong(1700000000123L); + tag(out, 1, "allowCommands"); out.writeByte(1); + tag(out, 1, "hardcore"); out.writeByte(1); + tag(out, 10, "DataPacks"); tag(out, 9, "Enabled"); out.writeByte(8); out.writeInt(2); + out.writeUTF("vanilla"); out.writeUTF("file/custom-pack"); out.writeByte(0); + out.writeByte(0); out.writeByte(0); + } + byte[] level = Files.readAllBytes(directory.resolve("level.dat")); + Files.write(directory.resolve("region.mca"), new byte[128]); + LocalWorld world = new LocalWorldStore(minecraft).get("local:Detailed"); + assertEquals(1700000000123L, world.lastPlayed); assertTrue(world.cheats); assertTrue(world.hardcore); + assertTrue(world.quickPlayEligible); assertFalse(world.modded); + assertEquals(Arrays.asList("vanilla", "file/custom-pack"), world.dataPacks); + assertEquals(level.length + 128, world.sizeBytes); assertTrue(world.sizeComplete); + assertThrows(UnsupportedOperationException.class, () -> world.dataPacks.add("mutate")); + assertArrayEquals(level, Files.readAllBytes(directory.resolve("level.dat"))); + assertEquals(0, LocalWorldStore.directorySize(directory, 1)[1], "Exhausted traversal is labeled a lower bound"); + try (LauncherServices service = new LauncherServices(temporary.resolve("autoplug"), minecraft, null)) { + LauncherActions.WorldInfo info = service.worlds().get(0); + assertTrue(info.metadataAvailable); assertTrue(info.cheats); assertTrue(info.hardcore); + assertEquals(world.lastPlayed, info.lastPlayed); assertEquals(world.dataPacks, info.dataPacks); + } + } + + @Test void localOwnershipIsIdempotentAndNeverDuplicatesOrModifiesTheOriginal() throws Exception { + Path minecraft = temporary.resolve("minecraft"), root = temporary.resolve("autoplug"); + Path directory = save(minecraft, "Existing", "Existing", "1.21.1", true, false, false); + byte[] original = Files.readAllBytes(directory.resolve("level.dat")); + LocalWorldStore local = new LocalWorldStore(minecraft); WorldStore store = new WorldStore(root.resolve("worlds")); + LocalWorld unowned = local.get("local:Existing"); + assertEquals(unowned.id, store.ownedLocal(unowned, false).id); + LocalWorld owned = store.registerLocal(unowned); + assertTrue(owned.id.matches("world-[a-f0-9-]{36}")); + assertEquals(owned.id, store.registerLocal(local.get("local:Existing")).id); + assertEquals(owned.id, new WorldStore(root.resolve("worlds")).registerLocal(unowned).id); + assertEquals(directory, store.getLocal(owned.id, local).directory); + assertEquals(owned.id, store.getLocal("local:Existing", local).id); + assertTrue(store.list().isEmpty(), "Local reference must not also appear as a virtual server world"); + try (java.util.stream.Stream entries = Files.list(root.resolve("worlds"))) { assertEquals(1, entries.count()); } + try (LauncherServices service = new LauncherServices(root, minecraft, null)) { + List displayed = service.worlds(); + assertEquals(1, displayed.size()); assertEquals(owned.id, displayed.get(0).id); assertTrue(displayed.get(0).local); + assertThrows(IOException.class, () -> service.launchLocalWorld(owned.id, "1.20.1")); + } + assertArrayEquals(original, Files.readAllBytes(directory.resolve("level.dat"))); + assertFalse(Files.exists(directory.resolve("world.json"))); + assertFalse(Files.exists(root.resolve("worlds").resolve(owned.id).resolve("server"))); + } + + @Test void alteredOwnershipCannotRedirectLaunchToAnotherSave() throws Exception { + Path minecraft = temporary.resolve("minecraft"); + save(minecraft, "Original", "Original", "1.21.1", false, false, false); + save(minecraft, "Other", "Other", "1.21.1", false, false, false); + LocalWorldStore local = new LocalWorldStore(minecraft); WorldStore store = new WorldStore(temporary.resolve("worlds")); + LocalWorld owned = store.registerLocal(local.get("local:Original")); + Path reference = temporary.resolve("worlds").resolve(owned.id).resolve("local-world.json"); + String json = new String(Files.readAllBytes(reference), java.nio.charset.StandardCharsets.UTF_8); + Files.write(reference, json.replace("local:Original", "local:Other").getBytes(java.nio.charset.StandardCharsets.UTF_8)); + assertThrows(IOException.class, () -> store.getLocal(owned.id, local)); + assertThrows(IOException.class, () -> store.registerLocal(local.get("local:Original"))); + } + + @Test void ownershipRecoversEmptyInterruptedEntryButNeverOverwritesUnrecognizedData() throws Exception { + Path minecraft = temporary.resolve("minecraft"); + save(minecraft, "Original", "Original", "1.21.1", false, false, false); + LocalWorld original = new LocalWorldStore(minecraft).get("local:Original"); + WorldStore store = new WorldStore(temporary.resolve("worlds")); + LocalWorld owned = store.registerLocal(original); + Path entry = temporary.resolve("worlds").resolve(owned.id), reference = entry.resolve("local-world.json"); + Files.delete(reference); // Simulate an interrupted registration leaving its new directory empty. + assertEquals(owned.id, store.registerLocal(original).id); + Files.delete(reference); + byte[] data = new byte[]{1, 2, 3}; Files.write(entry.resolve("existing-save.dat"), data); + assertThrows(IOException.class, () -> store.registerLocal(original)); + assertArrayEquals(data, Files.readAllBytes(entry.resolve("existing-save.dat"))); + assertFalse(Files.exists(reference)); + } + + private static Path save(Path minecraft, String folder, String name, String version, boolean compressed, boolean modded, boolean forge) throws IOException { + Path directory = Files.createDirectories(minecraft.resolve("saves").resolve(folder)); + OutputStream raw = Files.newOutputStream(directory.resolve("level.dat")); + try (DataOutputStream out = new DataOutputStream(compressed ? new GZIPOutputStream(raw) : raw)) { + tag(out, 10, ""); tag(out, 10, "Data"); + tag(out, 8, "LevelName"); out.writeUTF(name); + if (version != null) { tag(out, 10, "Version"); tag(out, 8, "Name"); out.writeUTF(version); out.writeByte(0); } + tag(out, 1, "WasModded"); out.writeByte(modded ? 1 : 0); + // Unknown arrays must be consumed correctly without being interpreted as metadata. + tag(out, 12, "Unrelated"); out.writeInt(2); out.writeLong(1); out.writeLong(2); + out.writeByte(0); + if (forge) { tag(out, 10, "FML"); out.writeByte(0); } + out.writeByte(0); + } + return directory; + } + private static void tag(DataOutputStream out, int type, String name) throws IOException { out.writeByte(type); out.writeUTF(name); } +} diff --git a/src/test/java/com/osiris/autoplug/client/worlds/ManagedConsoleFixtureProcess.java b/src/test/java/com/osiris/autoplug/client/worlds/ManagedConsoleFixtureProcess.java new file mode 100644 index 00000000..6948c74c --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/worlds/ManagedConsoleFixtureProcess.java @@ -0,0 +1,19 @@ +package com.osiris.autoplug.client.worlds; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.Collections; + +/** No server, network or real world data: a real pipe target for instance-routing tests. */ +public final class ManagedConsoleFixtureProcess { + public static void main(String[] args) throws Exception { + try (BufferedReader input = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8))) { + String line; + while ((line = input.readLine()) != null) { + Files.write(Paths.get("commands.txt"), Collections.singletonList(line), StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.APPEND); + if (".stop both".equals(line)) return; + } + } + } +} diff --git a/src/test/java/com/osiris/autoplug/client/worlds/ManagedWorldPackagedSmoke.java b/src/test/java/com/osiris/autoplug/client/worlds/ManagedWorldPackagedSmoke.java new file mode 100644 index 00000000..bb8a2aad --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/worlds/ManagedWorldPackagedSmoke.java @@ -0,0 +1,92 @@ +package com.osiris.autoplug.client.worlds; + +import com.osiris.autoplug.client.profiles.Profile; +import com.osiris.autoplug.client.profiles.ProfileType; +import com.osiris.autoplug.client.worlds.AutoPlugWorldBootstrap; +import com.osiris.autoplug.client.worlds.ManagedServer; +import com.osiris.autoplug.client.worlds.ServerLaunch; +import com.osiris.autoplug.client.utils.MineStat; +import com.osiris.dyml.Yaml; +import java.nio.file.*; +import java.nio.charset.StandardCharsets; +import java.net.*; +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** Offline packaged smoke helper; invoke after Maven package. Runs actual packaged AutoPlug wrappers around test-only JVM servers. */ +public class ManagedWorldPackagedSmoke { + public static void main(String[] args) throws Exception { + Path repo = Paths.get(args[0]), root = Paths.get(args[1]); Files.createDirectories(root); + Path java = Paths.get(System.getProperty("java.home"), "bin", System.getProperty("os.name").toLowerCase().contains("win") ? "java.exe" : "java"); + int firstPort = port(), secondPort = port(); while (secondPort == firstPort) secondPort = port(); + Path first = Files.createDirectories(root.resolve("first world")), second = Files.createDirectories(root.resolve("second world")); + Profile profile = new Profile(); profile.gameVersion = "1.20.4"; profile.loader = "VANILLA"; profile.type = ProfileType.MODS_SERVER; + AutoPlugWorldBootstrap bootstrap = new AutoPlugWorldBootstrap(); + ServerLaunch a = configure(bootstrap, profile, java, repo, first, firstPort); + ServerLaunch b = configure(bootstrap, profile, java, repo, second, secondPort); + Process pa = start(a), pb; + try { pb = start(b); } catch (Exception e) { pa.descendants().forEach(ProcessHandle::destroyForcibly); pa.destroyForcibly(); throw e; } + try (ManagedServer one = new ManagedServer(pa, 15000, a.stopCommand, a.restartCommand); + ManagedServer two = new ManagedServer(pb, 15000, b.stopCommand, b.restartCommand)) { + ready(pa, firstPort); ready(pb, secondPort); + long original = child(pa, first); + if (original < 0 || child(pb, second) < 0) throw new IllegalStateException("Both wrappers must own a real test server process"); + one.command("say first world"); two.command("say second world"); one.restart(); + long deadline = System.nanoTime() + 90_000_000_000L; + while (System.nanoTime() < deadline && (child(pa, first) == original || child(pa, first) < 0)) { + if (!pa.isAlive()) throw new IllegalStateException("Wrapper exited on restart"); Thread.sleep(250); + } + if (child(pa, first) == original || child(pa, first) < 0) throw new IllegalStateException("Restart did not replace native server process"); + ready(pa, firstPort); + if (!Files.exists(first.resolve("stopped.txt"))) throw new IllegalStateException("Restart did not gracefully save the fixture server"); + one.close(); + if (pa.isAlive() || !pb.isAlive()) throw new IllegalStateException("First stop crossed instance boundaries"); + ready(pb, secondPort); + two.command("stop"); // Native server exit must make the wrapper auto-exit without its lifecycle lock blocking shutdown hooks. + if (!pb.waitFor(15, TimeUnit.SECONDS)) + throw new IllegalStateException("Second wrapper did not automatically exit after raw server stop; possible shutdown-hook deadlock"); + if (pb.exitValue() != 0) throw new IllegalStateException("Second wrapper automatic exit was not clean"); + two.close(); + String secondLog = new String(Files.readAllBytes(second.resolve("wrapper.log")), StandardCharsets.UTF_8); + if (!secondLog.contains("Stopping AutoPlug too")) throw new IllegalStateException("Automatic wrapper exit path was not exercised"); + String firstCommands = new String(Files.readAllBytes(first.resolve("commands.log")), StandardCharsets.UTF_8); + String secondCommands = new String(Files.readAllBytes(second.resolve("commands.log")), StandardCharsets.UTF_8); + if (!firstCommands.contains("say first world") || firstCommands.contains("say second world") + || !secondCommands.contains("say second world") || secondCommands.contains("say first world")) + throw new IllegalStateException("Console commands crossed world boundaries"); + for (Path dir : Arrays.asList(first, second)) { + if (!"graceful".equals(new String(Files.readAllBytes(dir.resolve("stopped.txt")), StandardCharsets.UTF_8))) throw new IllegalStateException("Not gracefully stopped"); + String config = new String(Files.readAllBytes(dir.resolve("autoplug/updater.yml")), StandardCharsets.UTF_8); + if (!config.contains(AutoPlugWorldBootstrap.UPDATER_POLICY)) throw new IllegalStateException("Managed policy comment was lost"); + if (Files.exists(dir.resolve("eula.txt"))) throw new IllegalStateException("Wrapper created EULA acceptance unexpectedly"); + } + System.out.println("PASS: actual packaged wrappers started two isolated fixture servers, restarted one, preserved the other, then saved and stopped both; raw server stop automatically exited its wrapper within 15 seconds. No Minecraft world or remote service used."); + } finally { + for (Process p : Arrays.asList(pa, pb)) if (p.isAlive()) { p.descendants().forEach(ProcessHandle::destroyForcibly); p.destroyForcibly(); } + } + } + static ServerLaunch configure(AutoPlugWorldBootstrap bootstrap, Profile profile, Path java, Path repo, Path dir, int port) throws Exception { + Files.write(dir.resolve("server.properties"), ("server-ip=127.0.0.1\nserver-port=" + port + "\n").getBytes(StandardCharsets.UTF_8)); + ServerLaunch launch = bootstrap.wrap(profile, new ServerLaunch(Arrays.asList(java.toString(), "-cp", repo.resolve("target/test-classes").toString(), "com.osiris.autoplug.client.worlds.WorldFixtureProcess", "server"), dir)); + // Disable animated terminal rendering for this redirected-output fixture only. + Yaml logger = new Yaml(dir.resolve("autoplug/logger.yml").toString()); logger.load(); logger.put("logger", "tasks", "live-tasks", "enable").setValues("false"); logger.save(); + return launch; + } + static Process start(ServerLaunch launch) throws Exception { return new ProcessBuilder(launch.command).directory(launch.directory.toFile()).redirectErrorStream(true).redirectOutput(launch.directory.resolve("wrapper.log").toFile()).start(); } + static int port() throws Exception { try (ServerSocket socket = new ServerSocket(0, 8, InetAddress.getByName("127.0.0.1"))) { return socket.getLocalPort(); } } + static long child(Process process, Path directory) throws Exception { + Path file = directory.resolve("fixture.pid"); + if (!Files.exists(file)) return -1; + long pid = Long.parseLong(new String(Files.readAllBytes(file), StandardCharsets.UTF_8).trim()); + return process.descendants().anyMatch(p -> p.pid() == pid && p.isAlive()) ? pid : -1; + } + static void ready(Process process, int port) throws Exception { + long deadline = System.nanoTime() + 90_000_000_000L; + while (System.nanoTime() < deadline) { + if (!process.isAlive()) throw new IllegalStateException("AutoPlug wrapper exited before fixture readiness"); + if (new MineStat("127.0.0.1", port, 1, MineStat.Request.JSON).isServerUp()) return; + Thread.sleep(250); + } + throw new IllegalStateException("Fixture never became ready; inspect wrapper.log"); + } +} diff --git a/src/test/java/com/osiris/autoplug/client/worlds/MinecraftServerInstallerTest.java b/src/test/java/com/osiris/autoplug/client/worlds/MinecraftServerInstallerTest.java new file mode 100644 index 00000000..89570f26 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/worlds/MinecraftServerInstallerTest.java @@ -0,0 +1,61 @@ +package com.osiris.autoplug.client.worlds; + +import com.osiris.autoplug.client.profiles.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.jar.*; +import static org.junit.jupiter.api.Assertions.*; + +class MinecraftServerInstallerTest { + @TempDir Path temporary; + @Test void installsIntoIndependentWorldsCachesJarsAndPreservesMutableWorldConfig() throws Exception { + ProfileStore store = new ProfileStore(temporary.resolve("profiles")); + Profile profile = store.create("Server template", "1.20.4", "PAPER", ProfileType.PLUGINS); + profile.template = true; store.save(profile); + Path plugin = profile.getDirectory().resolve("plugins/example.jar"); + jar(plugin, "v1"); + Files.write(profile.getDirectory().resolve("server.properties"), "motd=Template".getBytes(StandardCharsets.UTF_8)); + Files.createDirectories(profile.getDirectory().resolve("world")); + Files.write(profile.getDirectory().resolve("world/level.dat"), new byte[]{9}); + AtomicInteger acquisitions = new AtomicInteger(); + Path java = Paths.get(System.getProperty("java.home"), "bin", "java"); + MinecraftServerInstaller installer = new MinecraftServerInstaller(store.getCache(), version -> java, (p, work, runtime) -> { + acquisitions.incrementAndGet(); Path result = work.resolve("fixture-server.jar"); jar(result, "server"); return result; + }); + Path first = temporary.resolve("worlds/first"), second = temporary.resolve("worlds/second"); + ServerLaunch firstLaunch = installer.prepare(profile, first); + installer.prepare(profile, second); + assertEquals(1, acquisitions.get()); + assertEquals(first.toAbsolutePath(), firstLaunch.directory); + assertEquals("server.jar", firstLaunch.command.get(4)); + assertTrue(Files.exists(first.resolve("plugins/example.jar"))); + assertFalse(Files.exists(first.resolve("world/level.dat")), "Reusable profiles must not copy saved worlds"); + Files.write(first.resolve("server.properties"), "motd=My world".getBytes(StandardCharsets.UTF_8)); + Files.delete(plugin); + jar(profile.getDirectory().resolve("plugins/replacement.jar"), "v2"); + installer.prepare(profile, first); + assertFalse(Files.exists(first.resolve("plugins/example.jar")), "Removed profile artifacts must not remain active"); + assertTrue(Files.exists(first.resolve("plugins/replacement.jar"))); + assertTrue(Files.exists(second.resolve("plugins/example.jar")), "Another world's files remain isolated"); + assertEquals("motd=My world", new String(Files.readAllBytes(first.resolve("server.properties")), StandardCharsets.UTF_8)); + assertEquals("motd=Template", new String(Files.readAllBytes(profile.getDirectory().resolve("server.properties")), StandardCharsets.UTF_8)); + } + @Test void rejectsAnErrorPageInsteadOfCachingItAsServerJar() throws Exception { + ProfileStore store = new ProfileStore(temporary.resolve("profiles")); + Profile profile = store.create("Server", "1.20.4", "PAPER", ProfileType.PLUGINS); + MinecraftServerInstaller installer = new MinecraftServerInstaller(store.getCache(), version -> temporary.resolve("java"), (p, work, runtime) -> { + Path bad = work.resolve("bad.jar"); Files.write(bad, "not a jar".getBytes(StandardCharsets.UTF_8)); return bad; + }); + assertThrows(java.io.IOException.class, () -> installer.prepare(profile, temporary.resolve("world"))); + assertFalse(Files.exists(temporary.resolve("world/server.jar"))); + } + private static void jar(Path file, String contents) throws Exception { + Files.createDirectories(file.getParent()); + try (JarOutputStream out = new JarOutputStream(Files.newOutputStream(file))) { + out.putNextEntry(new JarEntry("fixture.txt")); out.write(contents.getBytes(StandardCharsets.UTF_8)); out.closeEntry(); + } + } +} diff --git a/src/test/java/com/osiris/autoplug/client/worlds/UpnpSharingServiceTest.java b/src/test/java/com/osiris/autoplug/client/worlds/UpnpSharingServiceTest.java new file mode 100644 index 00000000..3aee62e5 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/worlds/UpnpSharingServiceTest.java @@ -0,0 +1,109 @@ +package com.osiris.autoplug.client.worlds; + +import org.junit.jupiter.api.Test; +import java.io.*; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import com.sun.net.httpserver.HttpServer; +import static org.junit.jupiter.api.Assertions.*; + +class UpnpSharingServiceTest { + @Test void explicitSharingRelaysLoopbackTrafficAndRemovesOnlyItsOwnedMapping() throws Exception { + FakeGateway gateway = new FakeGateway(); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try (ServerSocket target = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))) { + Future echo = executor.submit(() -> { + try (Socket socket = target.accept()) { + socket.setSoTimeout(3000); + int value = socket.getInputStream().read(); socket.getOutputStream().write(value + 1); socket.getOutputStream().flush(); + } catch (IOException e) { throw new UncheckedIOException(e); } + }); + SharingService.Lease lease = new UpnpSharingService(() -> gateway).open(target.getLocalPort()); + try { + assertEquals(1, gateway.additions.get()); + assertEquals("203.0.113.10:" + gateway.port, lease.address()); + try (Socket client = new Socket("127.0.0.1", gateway.port)) { + client.setSoTimeout(3000); client.getOutputStream().write(41); client.getOutputStream().flush(); + assertEquals(42, client.getInputStream().read()); + } + echo.get(5, TimeUnit.SECONDS); + } finally { lease.close(); } + lease.close(); + assertEquals(1, gateway.removals.get()); + assertThrows(IOException.class, () -> new Socket("127.0.0.1", gateway.port)); + } finally { executor.shutdownNow(); } + } + @Test void mappingFailureClosesForwardingListener() throws Exception { + FakeGateway gateway = new FakeGateway() { + @Override public void addMapping(int external, int internal, String description, int lease) throws IOException { + port = internal; throw new IOException("Fixture denies mapping"); + } + }; + assertThrows(IOException.class, () -> new UpnpSharingService(() -> gateway).open(25565)); + assertTrue(gateway.port > 0); + assertThrows(IOException.class, () -> new Socket("127.0.0.1", gateway.port)); + assertEquals(0, gateway.removals.get()); + } + @Test void detectsDoubleNatBeforeOpeningMapping() throws Exception { + FakeGateway gateway = new FakeGateway() { @Override public String externalAddress() { return "100.64.0.2"; } }; + assertThrows(IOException.class, () -> new UpnpSharingService(() -> gateway).open(25565)); + assertEquals(0, gateway.additions.get()); + } + @Test void gatewayXmlCannotReadExternalEntities() { + byte[] payload = "]>&secret;".getBytes(StandardCharsets.UTF_8); + assertThrows(Exception.class, () -> UpnpSharingService.xml(payload)); + } + @Test void soapFixtureChecksOwnershipBeforeOverwritingOrDeletingMapping() throws Exception { + HttpServer http = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + AtomicReference owner = new AtomicReference<>(); + AtomicInteger deletes = new AtomicInteger(); + http.createContext("/control", exchange -> { + String action = exchange.getRequestHeaders().getFirst("SOAPAction"); + String response; int code = 200; + if (action.contains("GetExternalIPAddress")) response = "203.0.113.10"; + else if (action.contains("GetSpecificPortMappingEntry")) { + if (owner.get() == null) { code = 500; response = "714NoSuchEntryInArray"; } + else response = "25565127.0.0.1" + owner.get() + ""; + } else if (action.contains("AddPortMapping")) { + String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + assertTrue(body.contains("3600")); + assertTrue(body.contains("127.0.0.1")); + owner.set("owned"); response = ""; + } else { deletes.incrementAndGet(); owner.set(null); response = ""; } + byte[] bytes = ("" + response + "").getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(code, bytes.length); + try (OutputStream out = exchange.getResponseBody()) { out.write(bytes); } + }); + http.start(); + try { + UpnpSharingService.Gateway gateway = new UpnpSharingService.SoapGateway(new URL("http://127.0.0.1:" + http.getAddress().getPort() + "/control"), + "urn:schemas-upnp-org:service:WANIPConnection:1", InetAddress.getByName("127.0.0.1")); + assertEquals("203.0.113.10", gateway.externalAddress()); + gateway.addMapping(25565, 25565, "owned", 3600); + gateway.addMapping(25565, 25565, "owned", 3600); + owner.set("another application"); + assertThrows(IOException.class, () -> gateway.addMapping(25565, 25565, "owned", 3600)); + gateway.removeMapping(25565, 25565, "owned"); + assertEquals(0, deletes.get()); + owner.set("owned"); gateway.removeMapping(25565, 25565, "owned"); + assertEquals(1, deletes.get()); + } finally { http.stop(0); } + } + private static class FakeGateway implements UpnpSharingService.Gateway { + int port; String ownedDescription; + final AtomicInteger additions = new AtomicInteger(), removals = new AtomicInteger(); + public InetAddress localAddress() { return InetAddress.getLoopbackAddress(); } + public String externalAddress() { return "203.0.113.10"; } + public void addMapping(int external, int internal, String description, int lease) throws IOException { + assertEquals(external, internal); assertEquals(3600, lease); + port = internal; ownedDescription = description; additions.incrementAndGet(); + } + public void removeMapping(int external, int internal, String description) { + if (ownedDescription == null) return; + assertEquals(port, internal); assertEquals(ownedDescription, description); removals.incrementAndGet(); + } + } +} diff --git a/src/test/java/com/osiris/autoplug/client/worlds/WorldFixtureProcess.java b/src/test/java/com/osiris/autoplug/client/worlds/WorldFixtureProcess.java new file mode 100644 index 00000000..6e2cc5ee --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/worlds/WorldFixtureProcess.java @@ -0,0 +1,55 @@ +package com.osiris.autoplug.client.worlds; + +import java.io.*; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.Properties; + +/** A real child JVM used only by offline lifecycle tests; all sockets bind loopback. */ +public final class WorldFixtureProcess { + public static void main(String[] args) throws Exception { + if (args[0].equals("client")) { Thread.sleep(60000); return; } + if (args[0].equals("exit")) return; + Files.write(Paths.get("fixture.pid"), Long.toString(ProcessHandle.current().pid()).getBytes(StandardCharsets.UTF_8)); + Properties properties = new Properties(); + try (InputStream input = Files.newInputStream(Paths.get("server.properties"))) { properties.load(input); } + if (!"127.0.0.1".equals(properties.getProperty("server-ip"))) throw new IllegalStateException("Fixture must be local"); + try (ServerSocket server = new ServerSocket(Integer.parseInt(properties.getProperty("server-port")), 8, + InetAddress.getByName("127.0.0.1"))) { + Thread input = new Thread(() -> { + try { + BufferedReader reader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)); + String line; + while ((line = reader.readLine()) != null) { + Files.write(Paths.get("commands.log"), (line + "\n").getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE, StandardOpenOption.APPEND); + if (line.equals("stop")) { + Files.write(Paths.get("stopped.txt"), "graceful".getBytes(StandardCharsets.UTF_8)); server.close(); return; + } + } + } catch (IOException ignored) { } + }); + input.setDaemon(true); input.start(); + while (!server.isClosed()) { + try (Socket socket = server.accept()) { + socket.setSoTimeout(2000); + DataInputStream in = new DataInputStream(socket.getInputStream()); + int length = varint(in); byte[] handshake = new byte[length]; in.readFully(handshake); + in.readUnsignedByte(); in.readUnsignedByte(); + byte[] json = "{\"version\":{\"name\":\"1.20.4\",\"protocol\":765},\"players\":{\"max\":20,\"online\":0},\"description\":{\"text\":\"Offline fixture\"}}".getBytes(StandardCharsets.UTF_8); + ByteArrayOutputStream packet = new ByteArrayOutputStream(); + packet.write(0); varint(packet, json.length); packet.write(json); + OutputStream out = socket.getOutputStream(); varint(out, packet.size()); packet.writeTo(out); out.flush(); + } catch (SocketException e) { if (!server.isClosed()) throw e; } + } + } + } + private static int varint(InputStream input) throws IOException { + int value = 0; + for (int i = 0; i < 5; i++) { int b = input.read(); if (b < 0) throw new EOFException(); value |= (b & 127) << (i * 7); if ((b & 128) == 0) return value; } + throw new IOException("Invalid varint"); + } + private static void varint(OutputStream output, int value) throws IOException { + do { int b = value & 127; value >>>= 7; output.write(value == 0 ? b : b | 128); } while (value != 0); + } +} diff --git a/src/test/java/com/osiris/autoplug/client/worlds/WorldServiceTest.java b/src/test/java/com/osiris/autoplug/client/worlds/WorldServiceTest.java new file mode 100644 index 00000000..db15b9d9 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/worlds/WorldServiceTest.java @@ -0,0 +1,194 @@ +package com.osiris.autoplug.client.worlds; + +import com.osiris.autoplug.client.profiles.*; +import com.osiris.autoplug.client.utils.MineStat; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.net.ServerSocket; +import java.net.InetAddress; +import java.nio.file.*; +import java.time.Duration; +import java.util.*; +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 WorldServiceTest { + @TempDir Path temporary; + + @Test void realLocalServerBecomesReadyThenStopsWhenClientExits() throws Exception { + Fixture fixture = fixture(true); + AtomicInteger mappings = new AtomicInteger(); + AtomicInteger unmappings = new AtomicInteger(); + SharingService share = port -> { mappings.incrementAndGet(); return new SharingService.Lease() { + public String address() { return "203.0.113.1:25565"; } + public void close() { unmappings.incrementAndGet(); } + }; }; + try (WorldService service = service(fixture, share, fixtureInstaller(), realProbe(), Duration.ofSeconds(10))) { + WorldSession session = service.launch(fixture.world.id, false); + assertTrue(session.server().isAlive()); + assertTrue(session.client().isAlive()); + assertEquals(0, mappings.get(), "Local launch must never discover or map a router"); + Properties properties = new Properties(); + try (java.io.InputStream input = Files.newInputStream(fixture.worlds.getDirectory(fixture.world.id).resolve("server.properties"))) { properties.load(input); } + assertEquals("127.0.0.1", properties.getProperty("server-ip")); + assertEquals("true", properties.getProperty("online-mode")); + assertTrue(session.share().isShared()); + assertTrue(session.share().isShared()); + assertEquals(1, mappings.get(), "A second click reuses the owned lease"); + session.client().destroy(); + assertTrue(session.client().waitFor(5, TimeUnit.SECONDS)); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while ((!service.activeSessions().isEmpty() || session.server().isAlive()) && System.nanoTime() < deadline) Thread.sleep(50); + assertFalse(session.server().isAlive()); + assertTrue(service.activeSessions().isEmpty()); + assertEquals("graceful", new String(Files.readAllBytes(fixture.worlds.getDirectory(fixture.world.id).resolve("stopped.txt")), java.nio.charset.StandardCharsets.UTF_8)); + assertEquals(1, unmappings.get()); + session.close(); + assertEquals(1, unmappings.get()); + } + } + + @Test void refusesUnacceptedEulaBeforeAnyInstallOrNetwork() throws Exception { + Fixture fixture = fixture(false); + try (WorldService service = service(fixture, port -> { throw new AssertionError("No sharing expected"); }, + (p, d) -> { throw new AssertionError("Must not install before EULA acceptance"); }, realProbe(), Duration.ofSeconds(5))) { + assertTrue(assertThrows(IllegalStateException.class, () -> service.launch(fixture.world.id, false)).getMessage().contains("EULA")); + assertFalse(Files.exists(fixture.worlds.getDirectory(fixture.world.id).resolve("eula.txt"))); + } + } + + @Test void moddedClientCanJoinVanillaWorldButNonVanillaLoadersMustMatch() throws Exception { + Fixture fixture = fixture(true); + Profile server = fixture.profiles.get(fixture.world.serverProfileId); + server.type = ProfileType.MODS_SERVER; server.loader = "FORGE"; fixture.profiles.save(server); + Profile client = fixture.profiles.get(fixture.world.clientProfileId); + client.loader = "FABRIC"; fixture.profiles.save(client); + try (WorldService service = service(fixture, port -> { throw new AssertionError("No sharing"); }, fixtureInstaller(), realProbe(), Duration.ofSeconds(10))) { + assertThrows(IllegalArgumentException.class, () -> service.launch(fixture.world.id, false)); + server.loader = "VANILLA"; fixture.profiles.save(server); + WorldSession session = service.launch(fixture.world.id, false); + assertTrue(session.server().isAlive()); + } + } + + @Test void timeoutStopsTheRealChildAndDoesNotLaunchClient() throws Exception { + Fixture fixture = fixture(true); + AtomicReference server = new AtomicReference<>(); + try (WorldService service = new WorldService(fixture.profiles, fixture.worlds, fixtureInstaller(), + (p, h, n) -> { throw new AssertionError("Client must wait for readiness"); }, + port -> { throw new AssertionError("No router actions"); }, (host, port) -> false, + launch -> { Process process = process(launch); server.set(process); return process; }, Duration.ofMillis(700))) { + assertThrows(IOException.class, () -> service.launch(fixture.world.id, false)); + assertNotNull(server.get()); + assertFalse(server.get().isAlive()); + assertTrue(service.activeSessions().isEmpty()); + } + } + + @Test void sharingFailureLeavesLocalWorldAliveAndReturnsFallback() throws Exception { + Fixture fixture = fixture(true); + try (WorldService service = service(fixture, port -> { throw new IOException("No gateway"); }, fixtureInstaller(), realProbe(), Duration.ofSeconds(10))) { + WorldSession session = service.launch(fixture.world.id, true); + assertFalse(session.getShareResult().isShared()); + assertTrue(session.getShareResult().message.contains("TCP tunnel")); + assertTrue(session.server().isAlive()); + assertTrue(session.client().isAlive()); + } + } + + @Test void offlineWorldAllowsLocalJoinButNeverSharesUnauthenticatedServer() throws Exception { + Fixture fixture = fixture(true); + try (WorldService service = service(fixture, port -> { throw new AssertionError("Offline mode must not map a router"); }, fixtureInstaller(), realProbe(), Duration.ofSeconds(10))) { + WorldSession session = service.launch(fixture.world.id, true, false); + Properties properties = new Properties(); + try (java.io.InputStream input = Files.newInputStream(fixture.worlds.getDirectory(fixture.world.id).resolve("server.properties"))) { properties.load(input); } + assertEquals("false", properties.getProperty("online-mode")); + assertEquals("127.0.0.1", properties.getProperty("server-ip")); + assertFalse(session.getShareResult().isShared()); + assertTrue(session.share().message.contains("licensed Microsoft account")); + assertTrue(session.server().isAlive()); + } + } + + @Test void clientLaunchFailureStillStopsServerAndReleasesWorldLease() throws Exception { + Fixture fixture = fixture(true); + AtomicReference server = new AtomicReference<>(); + try (WorldService service = new WorldService(fixture.profiles, fixture.worlds, fixtureInstaller(), + (p, h, n) -> { throw new IOException("Client fixture failure"); }, port -> { throw new AssertionError(); }, realProbe(), + launch -> { Process result = process(launch); server.set(result); return result; }, Duration.ofSeconds(10))) { + assertThrows(IOException.class, () -> service.launch(fixture.world.id, false)); + assertFalse(server.get().isAlive()); + try (ProfileLease ignored = new ProfileLease(fixture.worlds.getDirectory(fixture.world.id))) { assertTrue(true); } + } + } + + @Test void disabledSharingMakesNoRouterRequestAndUsesPreferredPort() throws Exception { + Fixture fixture = fixture(true); + int preferred; + try (ServerSocket free = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))) { preferred = free.getLocalPort(); } + try (WorldService service = service(fixture, port -> { throw new AssertionError("Settings prohibit router requests"); }, fixtureInstaller(), realProbe(), Duration.ofSeconds(10))) { + service.setPreferredPort(preferred); + service.setSharingEnabled(false); + WorldSession session = service.launch(fixture.world.id, true); + assertEquals(preferred, session.getPort()); + assertFalse(session.getShareResult().isShared()); + assertTrue(service.shareWorld(fixture.world.id).message.contains("disabled in Settings")); + service.setPreferredPort(preferred == 65535 ? 25565 : preferred + 1); + assertEquals(preferred, session.getPort(), "Settings do not change an active world's port"); + } + } + + @Test void busyPreferredPortFallsBackAndDisablingSettingsReleasesExistingShare() throws Exception { + Fixture fixture = fixture(true); + AtomicInteger removed = new AtomicInteger(); + SharingService sharing = port -> new SharingService.Lease() { + public String address() { return "203.0.113.10:25565"; } + public void close() { removed.incrementAndGet(); } + }; + try (ServerSocket busy = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1")); + WorldService service = service(fixture, sharing, fixtureInstaller(), realProbe(), Duration.ofSeconds(10))) { + service.setPreferredPort(busy.getLocalPort()); + WorldSession session = service.launch(fixture.world.id, true); + assertNotEquals(busy.getLocalPort(), session.getPort()); + assertTrue(session.getShareResult().isShared()); + service.setSharingEnabled(false); + assertEquals(1, removed.get()); + assertFalse(session.share().isShared()); + assertTrue(session.server().isAlive()); + } + assertEquals(1, removed.get(), "Closing the world must not delete an already-removed mapping again"); + } + + private Fixture fixture(boolean eula) throws Exception { + ProfileStore profiles = new ProfileStore(temporary.resolve("profiles")); + Profile server = profiles.create("Server", "1.20.4", "PAPER", ProfileType.PLUGINS); + Profile client = profiles.create("Client", "1.20.4", "VANILLA", ProfileType.MODS); + WorldStore worlds = new WorldStore(temporary.resolve("worlds")); + return new Fixture(profiles, worlds, worlds.create("Local fixture", server.id, client.id, eula)); + } + private WorldService service(Fixture fixture, SharingService sharing, ServerInstaller installer, WorldService.Probe probe, Duration timeout) { + return new WorldService(fixture.profiles, fixture.worlds, installer, + (profile, host, port) -> new ProcessBuilder(command("client")).start(), sharing, probe, WorldServiceTest::process, timeout); + } + private ServerInstaller fixtureInstaller() { return (profile, directory) -> new ServerLaunch(command("server"), directory); } + private WorldService.Probe realProbe() { return (host, port) -> new MineStat(host, port, 1, MineStat.Request.JSON).isServerUp(); } + private static Process process(ServerLaunch launch) throws IOException { + return new ProcessBuilder(launch.command).directory(launch.directory.toFile()).redirectErrorStream(true) + .redirectOutput(launch.directory.resolve("fixture.log").toFile()).start(); + } + private static List command(String mode) { + String java = Paths.get(System.getProperty("java.home"), "bin", System.getProperty("os.name").startsWith("Windows") ? "java.exe" : "java").toString(); + try { + String classes = Paths.get(WorldFixtureProcess.class.getProtectionDomain().getCodeSource().getLocation().toURI()).toString(); + return Arrays.asList(java, "-cp", classes, WorldFixtureProcess.class.getName(), mode); + } catch (Exception e) { throw new IllegalStateException(e); } + } + private static final class Fixture { + final ProfileStore profiles; final WorldStore worlds; final VirtualWorld world; + Fixture(ProfileStore profiles, WorldStore worlds, VirtualWorld world) { this.profiles = profiles; this.worlds = worlds; this.world = world; } + } +} diff --git a/src/test/java/com/osiris/autoplug/client/worlds/WorldStartupCancellationTest.java b/src/test/java/com/osiris/autoplug/client/worlds/WorldStartupCancellationTest.java new file mode 100644 index 00000000..cc55cde1 --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/worlds/WorldStartupCancellationTest.java @@ -0,0 +1,145 @@ +package com.osiris.autoplug.client.worlds; + +import com.osiris.autoplug.client.profiles.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.*; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Collections; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import java.util.stream.Stream; +import static org.junit.jupiter.api.Assertions.*; + +/** No game or gateway: delayed collaborators deliberately ignore interruption and return late. */ +class WorldStartupCancellationTest { + @TempDir Path temp; + + @Test void stopDuringInstallerCannotStartLateServerOrClientAndReleasesLease() throws Exception { + Fixture f = new Fixture(temp); Gate gate = new Gate(); AtomicInteger starts = new AtomicInteger(); + try (WorldService service = f.service((p, d) -> { gate.block(); return new ServerLaunch(Collections.singletonList("fixture"), d); }, + (h, p) -> true, launch -> { starts.incrementAndGet(); return new MemoryProcess(); }, + (p, h, n) -> { starts.incrementAndGet(); return new MemoryProcess(); })) { + ExecutorService worker = Executors.newSingleThreadExecutor(); Future result = worker.submit(() -> service.launch(f.world.id, false)); + try { + gate.entered(); service.stopWorld(f.world.id); gate.release(); + assertCancelled(result); assertEquals(0, starts.get(), "Stop must invalidate an installer that returns late"); + assertTrue(service.activeSessions().isEmpty()); + try (ProfileLease ignored = new ProfileLease(f.worlds.getDirectory(f.world.id))) { } + } finally { gate.release(); finish(worker, result); } + } + } + + @Test void stopDuringReadinessClosesServerBeforeLateProbeReturns() throws Exception { + Fixture f = new Fixture(temp); Gate gate = new Gate(); MemoryProcess server = new MemoryProcess(); AtomicInteger clients = new AtomicInteger(); + try (WorldService service = f.service((p, d) -> new ServerLaunch(Collections.singletonList("fixture"), d), + (h, p) -> { gate.block(); return true; }, launch -> server, + (p, h, n) -> { clients.incrementAndGet(); return new MemoryProcess(); })) { + ExecutorService worker = Executors.newSingleThreadExecutor(); Future result = worker.submit(() -> service.launch(f.world.id, false)); + try { + gate.entered(); service.stopWorld(f.world.id); + assertFalse(server.isAlive(), "Stop owns the server even before a client/session exists"); + gate.release(); assertCancelled(result); assertEquals(0, clients.get()); + } finally { gate.release(); finish(worker, result); } + } + } + + @Test void stopDuringClientPreparationDestroysLateClientAndRetainsProfileOwnershipUntilCleanup() throws Exception { + Fixture f = new Fixture(temp); Gate gate = new Gate(); MemoryProcess server = new MemoryProcess(), client = new MemoryProcess(); + try (WorldService service = f.service((p, d) -> new ServerLaunch(Collections.singletonList("fixture"), d), + (h, p) -> true, launch -> server, (p, h, n) -> { gate.block(); return client; })) { + ExecutorService worker = Executors.newSingleThreadExecutor(); Future result = worker.submit(() -> service.launch(f.world.id, false)); + try { + gate.entered(); service.stopWorld(f.world.id); + assertFalse(server.isAlive()); + assertTrue(service.isProfileInUse(f.client.id), "A cancelled but unfinished preparation still owns its profile"); + gate.release(); assertCancelled(result); assertFalse(client.isAlive()); assertTrue(service.activeSessions().isEmpty()); + assertFalse(service.isProfileInUse(f.client.id)); + } finally { gate.release(); finish(worker, result); } + } + } + + @Test void closeCancelsBlockedInstallerWithoutWaitingForItsServiceMonitor() throws Exception { + Fixture f = new Fixture(temp); Gate gate = new Gate(); AtomicInteger starts = new AtomicInteger(); + WorldService service = f.service((p, d) -> { gate.block(); return new ServerLaunch(Collections.singletonList("fixture"), d); }, + (h, p) -> true, launch -> { starts.incrementAndGet(); return new MemoryProcess(); }, (p, h, n) -> new MemoryProcess()); + ExecutorService workers = Executors.newFixedThreadPool(2); Future result = workers.submit(() -> service.launch(f.world.id, false)); + Future closing = null; + try { + gate.entered(); closing = workers.submit(service::close); closing.get(2, TimeUnit.SECONDS); + gate.release(); assertCancelled(result); assertEquals(0, starts.get()); + assertThrows(IllegalStateException.class, () -> service.launch(f.world.id, false)); + } finally { gate.release(); finish(workers, result); if (closing != null) closing.get(5, TimeUnit.SECONDS); service.close(); } + } + + @Test void processCreatedWhileStopRacesWithFactoryIsOwnedAndClosedWhenFactoryReturns() throws Exception { + Fixture f = new Fixture(temp); Gate gate = new Gate(); MemoryProcess server = new MemoryProcess(); AtomicInteger clients = new AtomicInteger(); + try (WorldService service = f.service((p, d) -> new ServerLaunch(Collections.singletonList("fixture"), d), + (h, p) -> true, launch -> { gate.block(); return server; }, (p, h, n) -> { clients.incrementAndGet(); return new MemoryProcess(); })) { + ExecutorService worker = Executors.newSingleThreadExecutor(); Future result = worker.submit(() -> service.launch(f.world.id, false)); + try { + gate.entered(); service.stopWorld(f.world.id); gate.release(); + assertCancelled(result); assertFalse(server.isAlive()); assertEquals(0, clients.get()); + } finally { gate.release(); finish(worker, result); } + } + } + + @Test void cancelledAttemptCannotRemoveRelaunchedWorldsSession() throws Exception { + Fixture f = new Fixture(temp); Gate gate = new Gate(); AtomicInteger calls = new AtomicInteger(); + try (WorldService service = f.service((p, d) -> { if (calls.getAndIncrement() == 0) gate.block(); return new ServerLaunch(Collections.singletonList("fixture"), d); }, + (h, p) -> true, launch -> new MemoryProcess(), (p, h, n) -> new MemoryProcess())) { + ExecutorService worker = Executors.newSingleThreadExecutor(); Future result = worker.submit(() -> service.launch(f.world.id, false)); + try { + gate.entered(); service.stopWorld(f.world.id); service.stopWorld(f.world.id); gate.release(); assertCancelled(result); + WorldSession replacement = service.launch(f.world.id, false); + assertTrue(replacement.client().isAlive()); assertTrue(service.activeSessions().contains(replacement)); + service.stopWorld(f.world.id); assertFalse(replacement.client().isAlive()); assertTrue(service.activeSessions().isEmpty()); + } finally { gate.release(); finish(worker, result); } + } + } + + private static void assertCancelled(Future result) throws Exception { + ExecutionException failure = assertThrows(ExecutionException.class, () -> result.get(5, TimeUnit.SECONDS)); + assertTrue(failure.getCause() instanceof InterruptedException, "Expected cancellation, got " + failure.getCause()); + } + private static void finish(ExecutorService worker, Future result) throws Exception { + try { WorldSession session = result.get(5, TimeUnit.SECONDS); session.close(); } catch (ExecutionException ignored) { } + finally { worker.shutdownNow(); assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); } + } + private static final class Gate { + private final CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + void block() { entered.countDown(); while (release.getCount() != 0) try { release.await(); } catch (InterruptedException ignored) { } } + void entered() throws Exception { assertTrue(entered.await(5, TimeUnit.SECONDS), "Preparation never reached latch"); } + void release() { release.countDown(); } + } + private static final class Fixture { + final ProfileStore profiles; final WorldStore worlds; final Profile client; final VirtualWorld world; + Fixture(Path temp) throws Exception { + profiles = new ProfileStore(temp.resolve("profiles")); + Profile server = profiles.create("Server", "1.20.4", "PAPER", ProfileType.PLUGINS); + client = profiles.create("Client", "1.20.4", "VANILLA", ProfileType.MODS); + worlds = new WorldStore(temp.resolve("worlds")); + // Synthetic fixture state only. No Minecraft server or EULA acceptance action is executed. + world = worlds.create("Fixture", server.id, client.id, true); + } + WorldService service(ServerInstaller installer, WorldService.Probe probe, WorldService.ProcessFactory processes, ClientLauncher client) { + return new WorldService(profiles, worlds, installer, client, port -> { throw new AssertionError("No gateway"); }, probe, processes, Duration.ofSeconds(5)); + } + } + private static final class MemoryProcess extends Process { + private final AtomicBoolean alive = new AtomicBoolean(true); + private final CompletableFuture exit = new CompletableFuture<>(); + public OutputStream getOutputStream() { return new ByteArrayOutputStream() { public void flush() { destroy(); } }; } + public InputStream getInputStream() { return new ByteArrayInputStream(new byte[0]); } + public InputStream getErrorStream() { return getInputStream(); } + public int waitFor() throws InterruptedException { while (alive.get()) Thread.sleep(5); return 0; } + public boolean waitFor(long timeout, TimeUnit unit) { return !alive.get(); } + public int exitValue() { if (alive.get()) throw new IllegalThreadStateException(); return 0; } + public boolean isAlive() { return alive.get(); } + public void destroy() { alive.set(false); exit.complete(this); } + public Process destroyForcibly() { destroy(); return this; } + public CompletableFuture onExit() { return exit; } + public Stream descendants() { return Stream.empty(); } + } +} diff --git a/src/test/java/com/osiris/autoplug/client/worlds/WorldStoreTest.java b/src/test/java/com/osiris/autoplug/client/worlds/WorldStoreTest.java new file mode 100644 index 00000000..4b626bea --- /dev/null +++ b/src/test/java/com/osiris/autoplug/client/worlds/WorldStoreTest.java @@ -0,0 +1,31 @@ +package com.osiris.autoplug.client.worlds; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.nio.file.*; +import static org.junit.jupiter.api.Assertions.*; + +class WorldStoreTest { + @TempDir Path temporary; + @Test void roundTripsSeparateWorldDirectoriesAndExplicitEula() throws Exception { + WorldStore store = new WorldStore(temporary); + VirtualWorld first = store.create("First", "shared-server", "client"); + VirtualWorld second = store.create("Second", "shared-server", "client"); + assertNotEquals(store.getDirectory(first.id), store.getDirectory(second.id)); + assertFalse(new WorldStore(temporary).get(first.id).eulaAccepted); + store.setEulaAccepted(first.id, true); + assertTrue(store.get(first.id).eulaAccepted); + assertFalse(store.get(second.id).eulaAccepted); + assertEquals(2, store.list().size()); + Files.write(store.getDirectory(first.id).resolve("server-icon.png"), new byte[]{1}); + assertNotNull(store.get(first.id).thumbnail); + store.delete(first.id); + assertEquals(1, store.list().size()); + assertTrue(Files.exists(temporary.resolve(".trash")), "Deletion keeps saves recoverable"); + } + @Test void rejectsPathsOutsideStore() throws Exception { + WorldStore store = new WorldStore(temporary); + assertThrows(IllegalArgumentException.class, () -> store.getDirectory("../escape")); + assertThrows(IllegalArgumentException.class, () -> store.delete("..")); + } +} diff --git a/src/test/resources/launcher/1.16.5.json b/src/test/resources/launcher/1.16.5.json new file mode 100644 index 00000000..31b3b015 --- /dev/null +++ b/src/test/resources/launcher/1.16.5.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}", "--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}"]}], "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": {"name": "windows", "version": "^10\\."}}], "value": ["-Dos.name=Windows 10", "-Dos.version=10.0"]}, {"rules": [{"action": "allow", "os": {"arch": "x86"}}], "value": "-Xss1M"}, "-Djava.library.path=${natives_directory}", "-Dminecraft.launcher.brand=${launcher_name}", "-Dminecraft.launcher.version=${launcher_version}", "-cp", "${classpath}"]}, "assetIndex": {"id": "1.16", "sha1": "f3c4aa96e12951cd2781b3e1c0e8ab82bf719cf2", "size": 295227, "totalSize": 334438503, "url": "https://piston-meta.mojang.com/v1/packages/f3c4aa96e12951cd2781b3e1c0e8ab82bf719cf2/1.16.json"}, "assets": "1.16", "complianceLevel": 1, "downloads": {"client": {"sha1": "37fd3c903861eeff3bc24b71eed48f828b5269c8", "size": 17547153, "url": "https://piston-data.mojang.com/v1/objects/37fd3c903861eeff3bc24b71eed48f828b5269c8/client.jar"}, "client_mappings": {"sha1": "374c6b789574afbdc901371207155661e0509e17", "size": 5746047, "url": "https://piston-data.mojang.com/v1/objects/374c6b789574afbdc901371207155661e0509e17/client.txt"}, "server": {"sha1": "1b557e7b033b583cd9f66746b7a9ab1ec1673ced", "size": 37962360, "url": "https://piston-data.mojang.com/v1/objects/1b557e7b033b583cd9f66746b7a9ab1ec1673ced/server.jar"}, "server_mappings": {"sha1": "41285beda6d251d190f2bf33beadd4fee187df7a", "size": 4400926, "url": "https://piston-data.mojang.com/v1/objects/41285beda6d251d190f2bf33beadd4fee187df7a/server.txt"}}, "id": "1.16.5", "javaVersion": {"component": "jre-legacy", "majorVersion": 8}, "libraries": [{"downloads": {"artifact": {"path": "com/mojang/patchy/1.3.9/patchy-1.3.9.jar", "sha1": "eb8bb7b66fa0e2152b1b40b3856e82f7619439ee", "size": 23581, "url": "https://libraries.minecraft.net/com/mojang/patchy/1.3.9/patchy-1.3.9.jar"}}, "name": "com.mojang:patchy:1.3.9"}, {"downloads": {"artifact": {"path": "oshi-project/oshi-core/1.1/oshi-core-1.1.jar", "sha1": "9ddf7b048a8d701be231c0f4f95fd986198fd2d8", "size": 30973, "url": "https://libraries.minecraft.net/oshi-project/oshi-core/1.1/oshi-core-1.1.jar"}}, "name": "oshi-project:oshi-core:1.1"}, {"downloads": {"artifact": {"path": "net/java/dev/jna/jna/4.4.0/jna-4.4.0.jar", "sha1": "cb208278274bf12ebdb56c61bd7407e6f774d65a", "size": 1091208, "url": "https://libraries.minecraft.net/net/java/dev/jna/jna/4.4.0/jna-4.4.0.jar"}}, "name": "net.java.dev.jna:jna:4.4.0"}, {"downloads": {"artifact": {"path": "net/java/dev/jna/platform/3.4.0/platform-3.4.0.jar", "sha1": "e3f70017be8100d3d6923f50b3d2ee17714e9c13", "size": 913436, "url": "https://libraries.minecraft.net/net/java/dev/jna/platform/3.4.0/platform-3.4.0.jar"}}, "name": "net.java.dev.jna:platform:3.4.0"}, {"downloads": {"artifact": {"path": "com/ibm/icu/icu4j/66.1/icu4j-66.1.jar", "sha1": "72c7519b6d91f7a1f993bd44a99fe95d67211b27", "size": 12935630, "url": "https://libraries.minecraft.net/com/ibm/icu/icu4j/66.1/icu4j-66.1.jar"}}, "name": "com.ibm.icu:icu4j:66.1"}, {"downloads": {"artifact": {"path": "com/mojang/javabridge/1.0.22/javabridge-1.0.22.jar", "sha1": "6aa6453aa99a52a5cd91749da1af6ab70e082ab3", "size": 5111, "url": "https://libraries.minecraft.net/com/mojang/javabridge/1.0.22/javabridge-1.0.22.jar"}}, "name": "com.mojang:javabridge:1.0.22"}, {"downloads": {"artifact": {"path": "net/sf/jopt-simple/jopt-simple/5.0.3/jopt-simple-5.0.3.jar", "sha1": "cdd846cfc4e0f7eefafc02c0f5dce32b9303aa2a", "size": 78175, "url": "https://libraries.minecraft.net/net/sf/jopt-simple/jopt-simple/5.0.3/jopt-simple-5.0.3.jar"}}, "name": "net.sf.jopt-simple:jopt-simple:5.0.3"}, {"downloads": {"artifact": {"path": "io/netty/netty-all/4.1.25.Final/netty-all-4.1.25.Final.jar", "sha1": "d0626cd3108294d1d58c05859add27b4ef21f83b", "size": 3823147, "url": "https://libraries.minecraft.net/io/netty/netty-all/4.1.25.Final/netty-all-4.1.25.Final.jar"}}, "name": "io.netty:netty-all:4.1.25.Final"}, {"downloads": {"artifact": {"path": "com/google/guava/guava/21.0/guava-21.0.jar", "sha1": "3a3d111be1be1b745edfa7d91678a12d7ed38709", "size": 2521113, "url": "https://libraries.minecraft.net/com/google/guava/guava/21.0/guava-21.0.jar"}}, "name": "com.google.guava:guava:21.0"}, {"downloads": {"artifact": {"path": "org/apache/commons/commons-lang3/3.5/commons-lang3-3.5.jar", "sha1": "6c6c702c89bfff3cd9e80b04d668c5e190d588c6", "size": 479881, "url": "https://libraries.minecraft.net/org/apache/commons/commons-lang3/3.5/commons-lang3-3.5.jar"}}, "name": "org.apache.commons:commons-lang3:3.5"}, {"downloads": {"artifact": {"path": "commons-io/commons-io/2.5/commons-io-2.5.jar", "sha1": "2852e6e05fbb95076fc091f6d1780f1f8fe35e0f", "size": 208700, "url": "https://libraries.minecraft.net/commons-io/commons-io/2.5/commons-io-2.5.jar"}}, "name": "commons-io:commons-io:2.5"}, {"downloads": {"artifact": {"path": "commons-codec/commons-codec/1.10/commons-codec-1.10.jar", "sha1": "4b95f4897fa13f2cd904aee711aeafc0c5295cd8", "size": 284184, "url": "https://libraries.minecraft.net/commons-codec/commons-codec/1.10/commons-codec-1.10.jar"}}, "name": "commons-codec:commons-codec:1.10"}, {"downloads": {"artifact": {"path": "net/java/jinput/jinput/2.0.5/jinput-2.0.5.jar", "sha1": "39c7796b469a600f72380316f6b1f11db6c2c7c4", "size": 208338, "url": "https://libraries.minecraft.net/net/java/jinput/jinput/2.0.5/jinput-2.0.5.jar"}}, "name": "net.java.jinput:jinput:2.0.5"}, {"downloads": {"artifact": {"path": "net/java/jutils/jutils/1.0.0/jutils-1.0.0.jar", "sha1": "e12fe1fda814bd348c1579329c86943d2cd3c6a6", "size": 7508, "url": "https://libraries.minecraft.net/net/java/jutils/jutils/1.0.0/jutils-1.0.0.jar"}}, "name": "net.java.jutils:jutils:1.0.0"}, {"downloads": {"artifact": {"path": "com/mojang/brigadier/1.0.17/brigadier-1.0.17.jar", "sha1": "c6b7dc51dd44379cc751b7504816006e9be4b1e6", "size": 77392, "url": "https://libraries.minecraft.net/com/mojang/brigadier/1.0.17/brigadier-1.0.17.jar"}}, "name": "com.mojang:brigadier:1.0.17"}, {"downloads": {"artifact": {"path": "com/mojang/datafixerupper/4.0.26/datafixerupper-4.0.26.jar", "sha1": "ebd6690f33871ccee9b6132c6480668ee2e35020", "size": 673183, "url": "https://libraries.minecraft.net/com/mojang/datafixerupper/4.0.26/datafixerupper-4.0.26.jar"}}, "name": "com.mojang:datafixerupper:4.0.26"}, {"downloads": {"artifact": {"path": "com/google/code/gson/gson/2.8.0/gson-2.8.0.jar", "sha1": "c4ba5371a29ac9b2ad6129b1d39ea38750043eff", "size": 231952, "url": "https://libraries.minecraft.net/com/google/code/gson/gson/2.8.0/gson-2.8.0.jar"}}, "name": "com.google.code.gson:gson:2.8.0"}, {"downloads": {"artifact": {"path": "com/mojang/authlib/2.1.28/authlib-2.1.28.jar", "sha1": "ad54da276bf59983d02d5ed16fc14541354c71fd", "size": 76328, "url": "https://libraries.minecraft.net/com/mojang/authlib/2.1.28/authlib-2.1.28.jar"}}, "name": "com.mojang:authlib:2.1.28"}, {"downloads": {"artifact": {"path": "org/apache/commons/commons-compress/1.8.1/commons-compress-1.8.1.jar", "sha1": "a698750c16740fd5b3871425f4cb3bbaa87f529d", "size": 365552, "url": "https://libraries.minecraft.net/org/apache/commons/commons-compress/1.8.1/commons-compress-1.8.1.jar"}}, "name": "org.apache.commons:commons-compress:1.8.1"}, {"downloads": {"artifact": {"path": "org/apache/httpcomponents/httpclient/4.3.3/httpclient-4.3.3.jar", "sha1": "18f4247ff4572a074444572cee34647c43e7c9c7", "size": 589512, "url": "https://libraries.minecraft.net/org/apache/httpcomponents/httpclient/4.3.3/httpclient-4.3.3.jar"}}, "name": "org.apache.httpcomponents:httpclient:4.3.3"}, {"downloads": {"artifact": {"path": "commons-logging/commons-logging/1.1.3/commons-logging-1.1.3.jar", "sha1": "f6f66e966c70a83ffbdb6f17a0919eaf7c8aca7f", "size": 62050, "url": "https://libraries.minecraft.net/commons-logging/commons-logging/1.1.3/commons-logging-1.1.3.jar"}}, "name": "commons-logging:commons-logging:1.1.3"}, {"downloads": {"artifact": {"path": "org/apache/httpcomponents/httpcore/4.3.2/httpcore-4.3.2.jar", "sha1": "31fbbff1ddbf98f3aa7377c94d33b0447c646b6e", "size": 282269, "url": "https://libraries.minecraft.net/org/apache/httpcomponents/httpcore/4.3.2/httpcore-4.3.2.jar"}}, "name": "org.apache.httpcomponents:httpcore:4.3.2"}, {"downloads": {"artifact": {"path": "it/unimi/dsi/fastutil/8.2.1/fastutil-8.2.1.jar", "sha1": "5ad88f325e424f8dbc2be5459e21ea5cab3864e9", "size": 18800417, "url": "https://libraries.minecraft.net/it/unimi/dsi/fastutil/8.2.1/fastutil-8.2.1.jar"}}, "name": "it.unimi.dsi:fastutil:8.2.1"}, {"downloads": {"artifact": {"path": "org/apache/logging/log4j/log4j-api/2.8.1/log4j-api-2.8.1.jar", "sha1": "e801d13612e22cad62a3f4f3fe7fdbe6334a8e72", "size": 228859, "url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-api/2.8.1/log4j-api-2.8.1.jar"}}, "name": "org.apache.logging.log4j:log4j-api:2.8.1"}, {"downloads": {"artifact": {"path": "org/apache/logging/log4j/log4j-core/2.8.1/log4j-core-2.8.1.jar", "sha1": "4ac28ff2f1ddf05dae3043a190451e8c46b73c31", "size": 1402925, "url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-core/2.8.1/log4j-core-2.8.1.jar"}}, "name": "org.apache.logging.log4j:log4j-core:2.8.1"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.2.1/lwjgl-3.2.1.jar", "sha1": "2bb514e444994c6fece99a21f76e0c90438e377f", "size": 317748, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.2.1/lwjgl-3.2.1.jar"}}, "name": "org.lwjgl:lwjgl:3.2.1", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.2.2/lwjgl-3.2.2.jar", "sha1": "8ad6294407e15780b43e84929c40e4c5e997972e", "size": 321900, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.2.2/lwjgl-3.2.2.jar"}}, "name": "org.lwjgl:lwjgl:3.2.2", "rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.2.1/lwjgl-jemalloc-3.2.1.jar", "sha1": "7a0c583fcbec32b15784f846df536e1837d83666", "size": 38616, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.2.1/lwjgl-jemalloc-3.2.1.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.2.1", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.2.2/lwjgl-jemalloc-3.2.2.jar", "sha1": "ee8e57a79300f78294576d87c4a587f8c99402e2", "size": 34848, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.2.2/lwjgl-jemalloc-3.2.2.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.2.2", "rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.2.1/lwjgl-openal-3.2.1.jar", "sha1": "dc7ff2dabb40a141ee9bf2e326d9b1b19f3278fb", "size": 80103, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.2.1/lwjgl-openal-3.2.1.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.2.1", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.2.2/lwjgl-openal-3.2.2.jar", "sha1": "2b772a102b0a11ee5f2109a5b136f4dc7c630827", "size": 80012, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.2.2/lwjgl-openal-3.2.2.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.2.2", "rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.2.1/lwjgl-opengl-3.2.1.jar", "sha1": "57008c2374c5bc434b18adfef3f3653ee450ee18", "size": 931322, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.2.1/lwjgl-opengl-3.2.1.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.2.1", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.2.2/lwjgl-opengl-3.2.2.jar", "sha1": "6ac5bb88b44c43ea195a570aab059f63da004cd8", "size": 929780, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.2.2/lwjgl-opengl-3.2.2.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.2.2", "rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.2.1/lwjgl-glfw-3.2.1.jar", "sha1": "027abb7f64894b61cad163791acd8113f0b21296", "size": 116708, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.2.1/lwjgl-glfw-3.2.1.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.2.1", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.2.2/lwjgl-glfw-3.2.2.jar", "sha1": "d3ad4df38e400b8afba1de63f84338809399df5b", "size": 108907, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.2.2/lwjgl-glfw-3.2.2.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.2.2", "rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.2.1/lwjgl-stb-3.2.1.jar", "sha1": "31f5eb5fce3791d58ec898bc5c1867d76d781ba1", "size": 105765, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.2.1/lwjgl-stb-3.2.1.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.2.1", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.2.2/lwjgl-stb-3.2.2.jar", "sha1": "3b8e6ebc5851dd3d17e37e5cadce2eff2a429f0f", "size": 104469, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.2.2/lwjgl-stb-3.2.2.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.2.2", "rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.2.1/lwjgl-tinyfd-3.2.1.jar", "sha1": "259f1dbddb921e27e01b32458d6f584eb8bba13a", "size": 7088, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.2.1/lwjgl-tinyfd-3.2.1.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.2.1", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.2.2/lwjgl-tinyfd-3.2.2.jar", "sha1": "fcbe606c8f8da6f8f9a05e2c540eb1ee8632b0e9", "size": 7092, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.2.2/lwjgl-tinyfd-3.2.2.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.2.2", "rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.2.1/lwjgl-3.2.1.jar", "sha1": "2bb514e444994c6fece99a21f76e0c90438e377f", "size": 317748, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.2.1/lwjgl-3.2.1.jar"}, "classifiers": {"natives-macos": {"path": "org/lwjgl/lwjgl/3.2.1/lwjgl-3.2.1-natives-macos.jar", "sha1": "5a4c271d150906858d475603dcb9479453c60555", "size": 39835, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.2.1/lwjgl-3.2.1-natives-macos.jar"}}}, "name": "org.lwjgl:lwjgl:3.2.1", "natives": {"osx": "natives-macos"}, "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.2.2/lwjgl-3.2.2.jar", "sha1": "8ad6294407e15780b43e84929c40e4c5e997972e", "size": 321900, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.2.2/lwjgl-3.2.2.jar"}, "classifiers": {"natives-linux": {"path": "org/lwjgl/lwjgl/3.2.2/lwjgl-3.2.2-natives-linux.jar", "sha1": "ae7976827ca2a3741f6b9a843a89bacd637af350", "size": 124776, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.2.2/lwjgl-3.2.2-natives-linux.jar"}, "natives-windows": {"path": "org/lwjgl/lwjgl/3.2.2/lwjgl-3.2.2-natives-windows.jar", "sha1": "05359f3aa50d36352815fc662ea73e1c00d22170", "size": 279593, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.2.2/lwjgl-3.2.2-natives-windows.jar"}}}, "name": "org.lwjgl:lwjgl:3.2.2", "natives": {"linux": "natives-linux", "windows": "natives-windows"}, "rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.2.1/lwjgl-jemalloc-3.2.1.jar", "sha1": "7a0c583fcbec32b15784f846df536e1837d83666", "size": 38616, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.2.1/lwjgl-jemalloc-3.2.1.jar"}, "classifiers": {"natives-macos": {"path": "org/lwjgl/lwjgl-jemalloc/3.2.1/lwjgl-jemalloc-3.2.1-natives-macos.jar", "sha1": "439ab9d0264167a949cc7bcce673704322baaf50", "size": 117001, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.2.1/lwjgl-jemalloc-3.2.1-natives-macos.jar"}}}, "name": "org.lwjgl:lwjgl-jemalloc:3.2.1", "natives": {"osx": "natives-macos"}, "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.2.2/lwjgl-jemalloc-3.2.2.jar", "sha1": "ee8e57a79300f78294576d87c4a587f8c99402e2", "size": 34848, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.2.2/lwjgl-jemalloc-3.2.2.jar"}, "classifiers": {"natives-linux": {"path": "org/lwjgl/lwjgl-jemalloc/3.2.2/lwjgl-jemalloc-3.2.2-natives-linux.jar", "sha1": "268c08a150347e04e44ba56e359d62c9b78669df", "size": 156173, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.2.2/lwjgl-jemalloc-3.2.2-natives-linux.jar"}, "natives-windows": {"path": "org/lwjgl/lwjgl-jemalloc/3.2.2/lwjgl-jemalloc-3.2.2-natives-windows.jar", "sha1": "338b25b99da3ba5f441f6492f2ce2a9c608860ed", "size": 220623, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.2.2/lwjgl-jemalloc-3.2.2-natives-windows.jar"}}}, "name": "org.lwjgl:lwjgl-jemalloc:3.2.2", "natives": {"linux": "natives-linux", "windows": "natives-windows"}, "rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.2.1/lwjgl-openal-3.2.1.jar", "sha1": "dc7ff2dabb40a141ee9bf2e326d9b1b19f3278fb", "size": 80103, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.2.1/lwjgl-openal-3.2.1.jar"}, "classifiers": {"natives-macos": {"path": "org/lwjgl/lwjgl-openal/3.2.1/lwjgl-openal-3.2.1-natives-macos.jar", "sha1": "9357ebfc82a0d6f64e17093dd963219367cd6fa2", "size": 528004, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.2.1/lwjgl-openal-3.2.1-natives-macos.jar"}}}, "name": "org.lwjgl:lwjgl-openal:3.2.1", "natives": {"osx": "natives-macos"}, "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.2.2/lwjgl-openal-3.2.2.jar", "sha1": "2b772a102b0a11ee5f2109a5b136f4dc7c630827", "size": 80012, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.2.2/lwjgl-openal-3.2.2.jar"}, "classifiers": {"natives-linux": {"path": "org/lwjgl/lwjgl-openal/3.2.2/lwjgl-openal-3.2.2-natives-linux.jar", "sha1": "0364f9f5c3947393083ab5f37a571f5603aadd0b", "size": 590997, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.2.2/lwjgl-openal-3.2.2-natives-linux.jar"}, "natives-windows": {"path": "org/lwjgl/lwjgl-openal/3.2.2/lwjgl-openal-3.2.2-natives-windows.jar", "sha1": "ec20a7d42a2438528fca87e60b1705f1e2339ddb", "size": 1310102, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.2.2/lwjgl-openal-3.2.2-natives-windows.jar"}}}, "name": "org.lwjgl:lwjgl-openal:3.2.2", "natives": {"linux": "natives-linux", "windows": "natives-windows"}, "rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.2.1/lwjgl-opengl-3.2.1.jar", "sha1": "57008c2374c5bc434b18adfef3f3653ee450ee18", "size": 931322, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.2.1/lwjgl-opengl-3.2.1.jar"}, "classifiers": {"natives-macos": {"path": "org/lwjgl/lwjgl-opengl/3.2.1/lwjgl-opengl-3.2.1-natives-macos.jar", "sha1": "dca9ad9e59a87172144d531e08ef7f6988073db0", "size": 38998, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.2.1/lwjgl-opengl-3.2.1-natives-macos.jar"}}}, "name": "org.lwjgl:lwjgl-opengl:3.2.1", "natives": {"osx": "natives-macos"}, "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.2.2/lwjgl-opengl-3.2.2.jar", "sha1": "6ac5bb88b44c43ea195a570aab059f63da004cd8", "size": 929780, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.2.2/lwjgl-opengl-3.2.2.jar"}, "classifiers": {"natives-linux": {"path": "org/lwjgl/lwjgl-opengl/3.2.2/lwjgl-opengl-3.2.2-natives-linux.jar", "sha1": "338d33387919cb3f4cdba143c2b738a71ccfda60", "size": 77392, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.2.2/lwjgl-opengl-3.2.2-natives-linux.jar"}, "natives-windows": {"path": "org/lwjgl/lwjgl-opengl/3.2.2/lwjgl-opengl-3.2.2-natives-windows.jar", "sha1": "d8dcdc91066cae2d2d8279cb4a9f9f05d9525826", "size": 170798, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.2.2/lwjgl-opengl-3.2.2-natives-windows.jar"}}}, "name": "org.lwjgl:lwjgl-opengl:3.2.2", "natives": {"linux": "natives-linux", "windows": "natives-windows"}, "rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.2.1/lwjgl-glfw-3.2.1.jar", "sha1": "027abb7f64894b61cad163791acd8113f0b21296", "size": 116708, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.2.1/lwjgl-glfw-3.2.1.jar"}, "classifiers": {"natives-macos": {"path": "org/lwjgl/lwjgl-glfw/3.2.1/lwjgl-glfw-3.2.1-natives-macos.jar", "sha1": "72fe6dab6110a5a1cd4833f11840eef7b2eadce5", "size": 64724, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.2.1/lwjgl-glfw-3.2.1-natives-macos.jar"}}}, "name": "org.lwjgl:lwjgl-glfw:3.2.1", "natives": {"osx": "natives-macos"}, "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.2.2/lwjgl-glfw-3.2.2.jar", "sha1": "d3ad4df38e400b8afba1de63f84338809399df5b", "size": 108907, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.2.2/lwjgl-glfw-3.2.2.jar"}, "classifiers": {"natives-linux": {"path": "org/lwjgl/lwjgl-glfw/3.2.2/lwjgl-glfw-3.2.2-natives-linux.jar", "sha1": "0957733f26a6661d4883da0335f7ef46d3bbbd7d", "size": 159198, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.2.2/lwjgl-glfw-3.2.2-natives-linux.jar"}, "natives-windows": {"path": "org/lwjgl/lwjgl-glfw/3.2.2/lwjgl-glfw-3.2.2-natives-windows.jar", "sha1": "dc6826d636bf796b33a49038c354210e661bfc17", "size": 266648, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.2.2/lwjgl-glfw-3.2.2-natives-windows.jar"}}}, "name": "org.lwjgl:lwjgl-glfw:3.2.2", "natives": {"linux": "natives-linux", "windows": "natives-windows"}, "rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.2.1/lwjgl-stb-3.2.1.jar", "sha1": "31f5eb5fce3791d58ec898bc5c1867d76d781ba1", "size": 105765, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.2.1/lwjgl-stb-3.2.1.jar"}, "classifiers": {"natives-macos": {"path": "org/lwjgl/lwjgl-stb/3.2.1/lwjgl-stb-3.2.1-natives-macos.jar", "sha1": "1f5615c952451c30afafba4a6e3ba4e1cd9e7f5c", "size": 192364, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.2.1/lwjgl-stb-3.2.1-natives-macos.jar"}}}, "name": "org.lwjgl:lwjgl-stb:3.2.1", "natives": {"osx": "natives-macos"}, "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.2.2/lwjgl-tinyfd-3.2.2.jar", "sha1": "fcbe606c8f8da6f8f9a05e2c540eb1ee8632b0e9", "size": 7092, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.2.2/lwjgl-tinyfd-3.2.2.jar"}, "classifiers": {"natives-linux": {"path": "org/lwjgl/lwjgl-tinyfd/3.2.2/lwjgl-tinyfd-3.2.2-natives-linux.jar", "sha1": "39e35b161c130635d9c8918ce04e887a30c5b687", "size": 38804, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.2.2/lwjgl-tinyfd-3.2.2-natives-linux.jar"}, "natives-windows": {"path": "org/lwjgl/lwjgl-tinyfd/3.2.2/lwjgl-tinyfd-3.2.2-natives-windows.jar", "sha1": "e9115958773644e863332a6a06488d26f9e1fc9f", "size": 208314, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.2.2/lwjgl-tinyfd-3.2.2-natives-windows.jar"}}}, "name": "org.lwjgl:lwjgl-tinyfd:3.2.2", "natives": {"linux": "natives-linux", "windows": "natives-windows"}, "rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.2.1/lwjgl-tinyfd-3.2.1.jar", "sha1": "259f1dbddb921e27e01b32458d6f584eb8bba13a", "size": 7088, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.2.1/lwjgl-tinyfd-3.2.1.jar"}, "classifiers": {"natives-macos": {"path": "org/lwjgl/lwjgl-tinyfd/3.2.1/lwjgl-tinyfd-3.2.1-natives-macos.jar", "sha1": "759c2fd9cc5c6ce0b5b7af77ac8200483b7fb660", "size": 41962, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.2.1/lwjgl-tinyfd-3.2.1-natives-macos.jar"}}}, "name": "org.lwjgl:lwjgl-tinyfd:3.2.1", "natives": {"osx": "natives-macos"}, "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.2.2/lwjgl-stb-3.2.2.jar", "sha1": "3b8e6ebc5851dd3d17e37e5cadce2eff2a429f0f", "size": 104469, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.2.2/lwjgl-stb-3.2.2.jar"}, "classifiers": {"natives-linux": {"path": "org/lwjgl/lwjgl-stb/3.2.2/lwjgl-stb-3.2.2-natives-linux.jar", "sha1": "172c52e586fecf43f759bc4f70a778c01f6fdcc1", "size": 203476, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.2.2/lwjgl-stb-3.2.2-natives-linux.jar"}, "natives-windows": {"path": "org/lwjgl/lwjgl-stb/3.2.2/lwjgl-stb-3.2.2-natives-windows.jar", "sha1": "811f705cbb29e8ae8d60bdf8fdd38c0c123ad3ef", "size": 465810, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.2.2/lwjgl-stb-3.2.2-natives-windows.jar"}}}, "name": "org.lwjgl:lwjgl-stb:3.2.2", "natives": {"linux": "natives-linux", "windows": "natives-windows"}, "rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "com/mojang/text2speech/1.11.3/text2speech-1.11.3.jar", "sha1": "f378f889797edd7df8d32272c06ca80a1b6b0f58", "size": 13164, "url": "https://libraries.minecraft.net/com/mojang/text2speech/1.11.3/text2speech-1.11.3.jar"}}, "name": "com.mojang:text2speech:1.11.3"}, {"downloads": {"artifact": {"path": "com/mojang/text2speech/1.11.3/text2speech-1.11.3.jar", "sha1": "f378f889797edd7df8d32272c06ca80a1b6b0f58", "size": 13164, "url": "https://libraries.minecraft.net/com/mojang/text2speech/1.11.3/text2speech-1.11.3.jar"}, "classifiers": {"natives-linux": {"path": "com/mojang/text2speech/1.11.3/text2speech-1.11.3-natives-linux.jar", "sha1": "ac641755a2a841d1fca9e660194f42523ee5cfe0", "size": 7833, "url": "https://libraries.minecraft.net/com/mojang/text2speech/1.11.3/text2speech-1.11.3-natives-linux.jar"}, "natives-windows": {"path": "com/mojang/text2speech/1.11.3/text2speech-1.11.3-natives-windows.jar", "sha1": "c0b242c0091be5acbf303263c7eeeaedd70544c7", "size": 81379, "url": "https://libraries.minecraft.net/com/mojang/text2speech/1.11.3/text2speech-1.11.3-natives-windows.jar"}}}, "extract": {"exclude": ["META-INF/"]}, "name": "com.mojang:text2speech:1.11.3", "natives": {"linux": "natives-linux", "windows": "natives-windows"}}, {"downloads": {"artifact": {"path": "ca/weblite/java-objc-bridge/1.0.0/java-objc-bridge-1.0.0.jar", "sha1": "6ef160c3133a78de015830860197602ca1c855d3", "size": 40502, "url": "https://libraries.minecraft.net/ca/weblite/java-objc-bridge/1.0.0/java-objc-bridge-1.0.0.jar"}, "classifiers": {"natives-osx": {"path": "ca/weblite/java-objc-bridge/1.0.0/java-objc-bridge-1.0.0-natives-osx.jar", "sha1": "08befab4894d55875f33c3d300f4f71e6e828f64", "size": 5629, "url": "https://libraries.minecraft.net/ca/weblite/java-objc-bridge/1.0.0/java-objc-bridge-1.0.0-natives-osx.jar"}}}, "extract": {"exclude": ["META-INF/"]}, "name": "ca.weblite:java-objc-bridge:1.0.0", "natives": {"osx": "natives-osx"}, "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "ca/weblite/java-objc-bridge/1.0.0/java-objc-bridge-1.0.0.jar", "sha1": "6ef160c3133a78de015830860197602ca1c855d3", "size": 40502, "url": "https://libraries.minecraft.net/ca/weblite/java-objc-bridge/1.0.0/java-objc-bridge-1.0.0.jar"}}, "name": "ca.weblite:java-objc-bridge:1.0.0", "rules": [{"action": "allow", "os": {"name": "osx"}}]}], "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": "2021-01-14T16:05:32+00:00", "time": "2021-01-14T16:05:32+00:00", "type": "release"} \ No newline at end of file diff --git a/src/test/resources/launcher/1.17.1.json b/src/test/resources/launcher/1.17.1.json new file mode 100644 index 00000000..e1df9cf9 --- /dev/null +++ b/src/test/resources/launcher/1.17.1.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}", "--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}"]}], "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": {"name": "windows", "version": "^10\\."}}], "value": ["-Dos.name=Windows 10", "-Dos.version=10.0"]}, {"rules": [{"action": "allow", "os": {"arch": "x86"}}], "value": "-Xss1M"}, "-Djava.library.path=${natives_directory}", "-Dminecraft.launcher.brand=${launcher_name}", "-Dminecraft.launcher.version=${launcher_version}", "-cp", "${classpath}"]}, "assetIndex": {"id": "1.17", "sha1": "f425401a00adf0112fde624ee80c66333530f8a1", "size": 346398, "totalSize": 348383264, "url": "https://piston-meta.mojang.com/v1/packages/f425401a00adf0112fde624ee80c66333530f8a1/1.17.json"}, "assets": "1.17", "complianceLevel": 1, "downloads": {"client": {"sha1": "8d9b65467c7913fcf6f5b2e729d44a1e00fde150", "size": 19546842, "url": "https://piston-data.mojang.com/v1/objects/8d9b65467c7913fcf6f5b2e729d44a1e00fde150/client.jar"}, "client_mappings": {"sha1": "e4d540e0cba05a6097e885dffdf363e621f87d3f", "size": 6437531, "url": "https://piston-data.mojang.com/v1/objects/e4d540e0cba05a6097e885dffdf363e621f87d3f/client.txt"}, "server": {"sha1": "a16d67e5807f57fc4e550299cf20226194497dc2", "size": 43626592, "url": "https://piston-data.mojang.com/v1/objects/a16d67e5807f57fc4e550299cf20226194497dc2/server.jar"}, "server_mappings": {"sha1": "f6cae1c5c1255f68ba4834b16a0da6a09621fe13", "size": 4953452, "url": "https://piston-data.mojang.com/v1/objects/f6cae1c5c1255f68ba4834b16a0da6a09621fe13/server.txt"}}, "id": "1.17.1", "javaVersion": {"component": "java-runtime-alpha", "majorVersion": 16}, "libraries": [{"downloads": {"artifact": {"path": "com/mojang/blocklist/1.0.5/blocklist-1.0.5.jar", "sha1": "9da540f21c9a8d5ed7c029e1f88d1a6dabb0d6ad", "size": 964, "url": "https://libraries.minecraft.net/com/mojang/blocklist/1.0.5/blocklist-1.0.5.jar"}}, "name": "com.mojang:blocklist:1.0.5"}, {"downloads": {"artifact": {"path": "com/mojang/patchy/2.1.6/patchy-2.1.6.jar", "sha1": "ce112c16c6275fdfff2d30208533ec906a191f71", "size": 10090, "url": "https://libraries.minecraft.net/com/mojang/patchy/2.1.6/patchy-2.1.6.jar"}}, "name": "com.mojang:patchy:2.1.6"}, {"downloads": {"artifact": {"path": "com/github/oshi/oshi-core/5.7.5/oshi-core-5.7.5.jar", "sha1": "725374463734aacde70a3b20f018aca3f922655f", "size": 835738, "url": "https://libraries.minecraft.net/com/github/oshi/oshi-core/5.7.5/oshi-core-5.7.5.jar"}}, "name": "com.github.oshi:oshi-core:5.7.5"}, {"downloads": {"artifact": {"path": "net/java/dev/jna/jna/5.8.0/jna-5.8.0.jar", "sha1": "3551d8d827e54858214107541d3aff9c615cb615", "size": 1729586, "url": "https://libraries.minecraft.net/net/java/dev/jna/jna/5.8.0/jna-5.8.0.jar"}}, "name": "net.java.dev.jna:jna:5.8.0"}, {"downloads": {"artifact": {"path": "net/java/dev/jna/jna-platform/5.8.0/jna-platform-5.8.0.jar", "sha1": "2f12f6d7f7652270d13624cef1b82d8cd9a5398e", "size": 1338206, "url": "https://libraries.minecraft.net/net/java/dev/jna/jna-platform/5.8.0/jna-platform-5.8.0.jar"}}, "name": "net.java.dev.jna:jna-platform:5.8.0"}, {"downloads": {"artifact": {"path": "org/slf4j/slf4j-api/1.8.0-beta4/slf4j-api-1.8.0-beta4.jar", "sha1": "83b0359d847ee053d745be7ec0d8e9e8a44304b4", "size": 44213, "url": "https://libraries.minecraft.net/org/slf4j/slf4j-api/1.8.0-beta4/slf4j-api-1.8.0-beta4.jar"}}, "name": "org.slf4j:slf4j-api:1.8.0-beta4"}, {"downloads": {"artifact": {"path": "org/apache/logging/log4j/log4j-slf4j18-impl/2.14.1/log4j-slf4j18-impl-2.14.1.jar", "sha1": "312b4d91b21160b9fab43600fa787f31e8cab930", "size": 20688, "url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-slf4j18-impl/2.14.1/log4j-slf4j18-impl-2.14.1.jar"}}, "name": "org.apache.logging.log4j:log4j-slf4j18-impl:2.14.1"}, {"downloads": {"artifact": {"path": "com/ibm/icu/icu4j/66.1/icu4j-66.1.jar", "sha1": "72c7519b6d91f7a1f993bd44a99fe95d67211b27", "size": 12935630, "url": "https://libraries.minecraft.net/com/ibm/icu/icu4j/66.1/icu4j-66.1.jar"}}, "name": "com.ibm.icu:icu4j:66.1"}, {"downloads": {"artifact": {"path": "com/mojang/javabridge/1.1.23/javabridge-1.1.23.jar", "sha1": "3e5084d916ba393dfd82928ec97663aeb10b2e2c", "size": 6014, "url": "https://libraries.minecraft.net/com/mojang/javabridge/1.1.23/javabridge-1.1.23.jar"}}, "name": "com.mojang:javabridge:1.1.23"}, {"downloads": {"artifact": {"path": "net/sf/jopt-simple/jopt-simple/5.0.3/jopt-simple-5.0.3.jar", "sha1": "cdd846cfc4e0f7eefafc02c0f5dce32b9303aa2a", "size": 78175, "url": "https://libraries.minecraft.net/net/sf/jopt-simple/jopt-simple/5.0.3/jopt-simple-5.0.3.jar"}}, "name": "net.sf.jopt-simple:jopt-simple:5.0.3"}, {"downloads": {"artifact": {"path": "io/netty/netty-all/4.1.25.Final/netty-all-4.1.25.Final.jar", "sha1": "d0626cd3108294d1d58c05859add27b4ef21f83b", "size": 3823147, "url": "https://libraries.minecraft.net/io/netty/netty-all/4.1.25.Final/netty-all-4.1.25.Final.jar"}}, "name": "io.netty:netty-all:4.1.25.Final"}, {"downloads": {"artifact": {"path": "com/google/guava/guava/21.0/guava-21.0.jar", "sha1": "3a3d111be1be1b745edfa7d91678a12d7ed38709", "size": 2521113, "url": "https://libraries.minecraft.net/com/google/guava/guava/21.0/guava-21.0.jar"}}, "name": "com.google.guava:guava:21.0"}, {"downloads": {"artifact": {"path": "org/apache/commons/commons-lang3/3.5/commons-lang3-3.5.jar", "sha1": "6c6c702c89bfff3cd9e80b04d668c5e190d588c6", "size": 479881, "url": "https://libraries.minecraft.net/org/apache/commons/commons-lang3/3.5/commons-lang3-3.5.jar"}}, "name": "org.apache.commons:commons-lang3:3.5"}, {"downloads": {"artifact": {"path": "commons-io/commons-io/2.5/commons-io-2.5.jar", "sha1": "2852e6e05fbb95076fc091f6d1780f1f8fe35e0f", "size": 208700, "url": "https://libraries.minecraft.net/commons-io/commons-io/2.5/commons-io-2.5.jar"}}, "name": "commons-io:commons-io:2.5"}, {"downloads": {"artifact": {"path": "commons-codec/commons-codec/1.10/commons-codec-1.10.jar", "sha1": "4b95f4897fa13f2cd904aee711aeafc0c5295cd8", "size": 284184, "url": "https://libraries.minecraft.net/commons-codec/commons-codec/1.10/commons-codec-1.10.jar"}}, "name": "commons-codec:commons-codec:1.10"}, {"downloads": {"artifact": {"path": "net/java/jinput/jinput/2.0.5/jinput-2.0.5.jar", "sha1": "39c7796b469a600f72380316f6b1f11db6c2c7c4", "size": 208338, "url": "https://libraries.minecraft.net/net/java/jinput/jinput/2.0.5/jinput-2.0.5.jar"}}, "name": "net.java.jinput:jinput:2.0.5"}, {"downloads": {"artifact": {"path": "net/java/jutils/jutils/1.0.0/jutils-1.0.0.jar", "sha1": "e12fe1fda814bd348c1579329c86943d2cd3c6a6", "size": 7508, "url": "https://libraries.minecraft.net/net/java/jutils/jutils/1.0.0/jutils-1.0.0.jar"}}, "name": "net.java.jutils:jutils:1.0.0"}, {"downloads": {"artifact": {"path": "com/mojang/brigadier/1.0.18/brigadier-1.0.18.jar", "sha1": "c1ef1234282716483c92183f49bef47b1a89bfa9", "size": 77116, "url": "https://libraries.minecraft.net/com/mojang/brigadier/1.0.18/brigadier-1.0.18.jar"}}, "name": "com.mojang:brigadier:1.0.18"}, {"downloads": {"artifact": {"path": "com/mojang/datafixerupper/4.0.26/datafixerupper-4.0.26.jar", "sha1": "ebd6690f33871ccee9b6132c6480668ee2e35020", "size": 673183, "url": "https://libraries.minecraft.net/com/mojang/datafixerupper/4.0.26/datafixerupper-4.0.26.jar"}}, "name": "com.mojang:datafixerupper:4.0.26"}, {"downloads": {"artifact": {"path": "com/google/code/gson/gson/2.8.0/gson-2.8.0.jar", "sha1": "c4ba5371a29ac9b2ad6129b1d39ea38750043eff", "size": 231952, "url": "https://libraries.minecraft.net/com/google/code/gson/gson/2.8.0/gson-2.8.0.jar"}}, "name": "com.google.code.gson:gson:2.8.0"}, {"downloads": {"artifact": {"path": "com/mojang/authlib/2.3.31/authlib-2.3.31.jar", "sha1": "bbd00ca33b052f73a6312254780fc580d2da3535", "size": 87662, "url": "https://libraries.minecraft.net/com/mojang/authlib/2.3.31/authlib-2.3.31.jar"}}, "name": "com.mojang:authlib:2.3.31"}, {"downloads": {"artifact": {"path": "org/apache/commons/commons-compress/1.8.1/commons-compress-1.8.1.jar", "sha1": "a698750c16740fd5b3871425f4cb3bbaa87f529d", "size": 365552, "url": "https://libraries.minecraft.net/org/apache/commons/commons-compress/1.8.1/commons-compress-1.8.1.jar"}}, "name": "org.apache.commons:commons-compress:1.8.1"}, {"downloads": {"artifact": {"path": "org/apache/httpcomponents/httpclient/4.3.3/httpclient-4.3.3.jar", "sha1": "18f4247ff4572a074444572cee34647c43e7c9c7", "size": 589512, "url": "https://libraries.minecraft.net/org/apache/httpcomponents/httpclient/4.3.3/httpclient-4.3.3.jar"}}, "name": "org.apache.httpcomponents:httpclient:4.3.3"}, {"downloads": {"artifact": {"path": "commons-logging/commons-logging/1.1.3/commons-logging-1.1.3.jar", "sha1": "f6f66e966c70a83ffbdb6f17a0919eaf7c8aca7f", "size": 62050, "url": "https://libraries.minecraft.net/commons-logging/commons-logging/1.1.3/commons-logging-1.1.3.jar"}}, "name": "commons-logging:commons-logging:1.1.3"}, {"downloads": {"artifact": {"path": "org/apache/httpcomponents/httpcore/4.3.2/httpcore-4.3.2.jar", "sha1": "31fbbff1ddbf98f3aa7377c94d33b0447c646b6e", "size": 282269, "url": "https://libraries.minecraft.net/org/apache/httpcomponents/httpcore/4.3.2/httpcore-4.3.2.jar"}}, "name": "org.apache.httpcomponents:httpcore:4.3.2"}, {"downloads": {"artifact": {"path": "it/unimi/dsi/fastutil/8.2.1/fastutil-8.2.1.jar", "sha1": "5ad88f325e424f8dbc2be5459e21ea5cab3864e9", "size": 18800417, "url": "https://libraries.minecraft.net/it/unimi/dsi/fastutil/8.2.1/fastutil-8.2.1.jar"}}, "name": "it.unimi.dsi:fastutil:8.2.1"}, {"downloads": {"artifact": {"path": "org/apache/logging/log4j/log4j-api/2.14.1/log4j-api-2.14.1.jar", "sha1": "cd8858fbbde69f46bce8db1152c18a43328aae78", "size": 300365, "url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-api/2.14.1/log4j-api-2.14.1.jar"}}, "name": "org.apache.logging.log4j:log4j-api:2.14.1"}, {"downloads": {"artifact": {"path": "org/apache/logging/log4j/log4j-core/2.14.1/log4j-core-2.14.1.jar", "sha1": "9141212b8507ab50a45525b545b39d224614528b", "size": 1745700, "url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-core/2.14.1/log4j-core-2.14.1.jar"}}, "name": "org.apache.logging.log4j:log4j-core:2.14.1"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.2.1/lwjgl-3.2.1.jar", "sha1": "2bb514e444994c6fece99a21f76e0c90438e377f", "size": 317748, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.2.1/lwjgl-3.2.1.jar"}}, "name": "org.lwjgl:lwjgl:3.2.1", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.2.2/lwjgl-3.2.2.jar", "sha1": "8ad6294407e15780b43e84929c40e4c5e997972e", "size": 321900, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.2.2/lwjgl-3.2.2.jar"}}, "name": "org.lwjgl:lwjgl:3.2.2", "rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.2.1/lwjgl-jemalloc-3.2.1.jar", "sha1": "7a0c583fcbec32b15784f846df536e1837d83666", "size": 38616, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.2.1/lwjgl-jemalloc-3.2.1.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.2.1", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.2.2/lwjgl-jemalloc-3.2.2.jar", "sha1": "ee8e57a79300f78294576d87c4a587f8c99402e2", "size": 34848, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.2.2/lwjgl-jemalloc-3.2.2.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.2.2", "rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.2.1/lwjgl-openal-3.2.1.jar", "sha1": "dc7ff2dabb40a141ee9bf2e326d9b1b19f3278fb", "size": 80103, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.2.1/lwjgl-openal-3.2.1.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.2.1", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.2.2/lwjgl-openal-3.2.2.jar", "sha1": "2b772a102b0a11ee5f2109a5b136f4dc7c630827", "size": 80012, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.2.2/lwjgl-openal-3.2.2.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.2.2", "rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.2.1/lwjgl-opengl-3.2.1.jar", "sha1": "57008c2374c5bc434b18adfef3f3653ee450ee18", "size": 931322, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.2.1/lwjgl-opengl-3.2.1.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.2.1", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.2.2/lwjgl-opengl-3.2.2.jar", "sha1": "6ac5bb88b44c43ea195a570aab059f63da004cd8", "size": 929780, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.2.2/lwjgl-opengl-3.2.2.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.2.2", "rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.2.1/lwjgl-glfw-3.2.1.jar", "sha1": "027abb7f64894b61cad163791acd8113f0b21296", "size": 116708, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.2.1/lwjgl-glfw-3.2.1.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.2.1", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.2.2/lwjgl-glfw-3.2.2.jar", "sha1": "d3ad4df38e400b8afba1de63f84338809399df5b", "size": 108907, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.2.2/lwjgl-glfw-3.2.2.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.2.2", "rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.2.1/lwjgl-stb-3.2.1.jar", "sha1": "31f5eb5fce3791d58ec898bc5c1867d76d781ba1", "size": 105765, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.2.1/lwjgl-stb-3.2.1.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.2.1", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.2.2/lwjgl-stb-3.2.2.jar", "sha1": "3b8e6ebc5851dd3d17e37e5cadce2eff2a429f0f", "size": 104469, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.2.2/lwjgl-stb-3.2.2.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.2.2", "rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.2.1/lwjgl-tinyfd-3.2.1.jar", "sha1": "259f1dbddb921e27e01b32458d6f584eb8bba13a", "size": 7088, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.2.1/lwjgl-tinyfd-3.2.1.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.2.1", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.2.2/lwjgl-tinyfd-3.2.2.jar", "sha1": "fcbe606c8f8da6f8f9a05e2c540eb1ee8632b0e9", "size": 7092, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.2.2/lwjgl-tinyfd-3.2.2.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.2.2", "rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.2.1/lwjgl-3.2.1.jar", "sha1": "2bb514e444994c6fece99a21f76e0c90438e377f", "size": 317748, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.2.1/lwjgl-3.2.1.jar"}, "classifiers": {"natives-macos": {"path": "org/lwjgl/lwjgl/3.2.1/lwjgl-3.2.1-natives-macos.jar", "sha1": "5a4c271d150906858d475603dcb9479453c60555", "size": 39835, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.2.1/lwjgl-3.2.1-natives-macos.jar"}}}, "name": "org.lwjgl:lwjgl:3.2.1", "natives": {"osx": "natives-macos"}, "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.2.2/lwjgl-3.2.2.jar", "sha1": "8ad6294407e15780b43e84929c40e4c5e997972e", "size": 321900, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.2.2/lwjgl-3.2.2.jar"}, "classifiers": {"natives-linux": {"path": "org/lwjgl/lwjgl/3.2.2/lwjgl-3.2.2-natives-linux.jar", "sha1": "ae7976827ca2a3741f6b9a843a89bacd637af350", "size": 124776, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.2.2/lwjgl-3.2.2-natives-linux.jar"}, "natives-windows": {"path": "org/lwjgl/lwjgl/3.2.2/lwjgl-3.2.2-natives-windows.jar", "sha1": "05359f3aa50d36352815fc662ea73e1c00d22170", "size": 279593, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.2.2/lwjgl-3.2.2-natives-windows.jar"}}}, "name": "org.lwjgl:lwjgl:3.2.2", "natives": {"linux": "natives-linux", "windows": "natives-windows"}, "rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.2.1/lwjgl-jemalloc-3.2.1.jar", "sha1": "7a0c583fcbec32b15784f846df536e1837d83666", "size": 38616, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.2.1/lwjgl-jemalloc-3.2.1.jar"}, "classifiers": {"natives-macos": {"path": "org/lwjgl/lwjgl-jemalloc/3.2.1/lwjgl-jemalloc-3.2.1-natives-macos.jar", "sha1": "439ab9d0264167a949cc7bcce673704322baaf50", "size": 117001, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.2.1/lwjgl-jemalloc-3.2.1-natives-macos.jar"}}}, "name": "org.lwjgl:lwjgl-jemalloc:3.2.1", "natives": {"osx": "natives-macos"}, "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.2.2/lwjgl-jemalloc-3.2.2.jar", "sha1": "ee8e57a79300f78294576d87c4a587f8c99402e2", "size": 34848, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.2.2/lwjgl-jemalloc-3.2.2.jar"}, "classifiers": {"natives-linux": {"path": "org/lwjgl/lwjgl-jemalloc/3.2.2/lwjgl-jemalloc-3.2.2-natives-linux.jar", "sha1": "268c08a150347e04e44ba56e359d62c9b78669df", "size": 156173, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.2.2/lwjgl-jemalloc-3.2.2-natives-linux.jar"}, "natives-windows": {"path": "org/lwjgl/lwjgl-jemalloc/3.2.2/lwjgl-jemalloc-3.2.2-natives-windows.jar", "sha1": "338b25b99da3ba5f441f6492f2ce2a9c608860ed", "size": 220623, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.2.2/lwjgl-jemalloc-3.2.2-natives-windows.jar"}}}, "name": "org.lwjgl:lwjgl-jemalloc:3.2.2", "natives": {"linux": "natives-linux", "windows": "natives-windows"}, "rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.2.1/lwjgl-openal-3.2.1.jar", "sha1": "dc7ff2dabb40a141ee9bf2e326d9b1b19f3278fb", "size": 80103, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.2.1/lwjgl-openal-3.2.1.jar"}, "classifiers": {"natives-macos": {"path": "org/lwjgl/lwjgl-openal/3.2.1/lwjgl-openal-3.2.1-natives-macos.jar", "sha1": "9357ebfc82a0d6f64e17093dd963219367cd6fa2", "size": 528004, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.2.1/lwjgl-openal-3.2.1-natives-macos.jar"}}}, "name": "org.lwjgl:lwjgl-openal:3.2.1", "natives": {"osx": "natives-macos"}, "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.2.2/lwjgl-openal-3.2.2.jar", "sha1": "2b772a102b0a11ee5f2109a5b136f4dc7c630827", "size": 80012, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.2.2/lwjgl-openal-3.2.2.jar"}, "classifiers": {"natives-linux": {"path": "org/lwjgl/lwjgl-openal/3.2.2/lwjgl-openal-3.2.2-natives-linux.jar", "sha1": "0364f9f5c3947393083ab5f37a571f5603aadd0b", "size": 590997, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.2.2/lwjgl-openal-3.2.2-natives-linux.jar"}, "natives-windows": {"path": "org/lwjgl/lwjgl-openal/3.2.2/lwjgl-openal-3.2.2-natives-windows.jar", "sha1": "ec20a7d42a2438528fca87e60b1705f1e2339ddb", "size": 1310102, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.2.2/lwjgl-openal-3.2.2-natives-windows.jar"}}}, "name": "org.lwjgl:lwjgl-openal:3.2.2", "natives": {"linux": "natives-linux", "windows": "natives-windows"}, "rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.2.1/lwjgl-opengl-3.2.1.jar", "sha1": "57008c2374c5bc434b18adfef3f3653ee450ee18", "size": 931322, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.2.1/lwjgl-opengl-3.2.1.jar"}, "classifiers": {"natives-macos": {"path": "org/lwjgl/lwjgl-opengl/3.2.1/lwjgl-opengl-3.2.1-natives-macos.jar", "sha1": "dca9ad9e59a87172144d531e08ef7f6988073db0", "size": 38998, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.2.1/lwjgl-opengl-3.2.1-natives-macos.jar"}}}, "name": "org.lwjgl:lwjgl-opengl:3.2.1", "natives": {"osx": "natives-macos"}, "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.2.2/lwjgl-opengl-3.2.2.jar", "sha1": "6ac5bb88b44c43ea195a570aab059f63da004cd8", "size": 929780, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.2.2/lwjgl-opengl-3.2.2.jar"}, "classifiers": {"natives-linux": {"path": "org/lwjgl/lwjgl-opengl/3.2.2/lwjgl-opengl-3.2.2-natives-linux.jar", "sha1": "338d33387919cb3f4cdba143c2b738a71ccfda60", "size": 77392, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.2.2/lwjgl-opengl-3.2.2-natives-linux.jar"}, "natives-windows": {"path": "org/lwjgl/lwjgl-opengl/3.2.2/lwjgl-opengl-3.2.2-natives-windows.jar", "sha1": "d8dcdc91066cae2d2d8279cb4a9f9f05d9525826", "size": 170798, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.2.2/lwjgl-opengl-3.2.2-natives-windows.jar"}}}, "name": "org.lwjgl:lwjgl-opengl:3.2.2", "natives": {"linux": "natives-linux", "windows": "natives-windows"}, "rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.2.1/lwjgl-glfw-3.2.1.jar", "sha1": "027abb7f64894b61cad163791acd8113f0b21296", "size": 116708, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.2.1/lwjgl-glfw-3.2.1.jar"}, "classifiers": {"natives-macos": {"path": "org/lwjgl/lwjgl-glfw/3.2.1/lwjgl-glfw-3.2.1-natives-macos.jar", "sha1": "72fe6dab6110a5a1cd4833f11840eef7b2eadce5", "size": 64724, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.2.1/lwjgl-glfw-3.2.1-natives-macos.jar"}}}, "name": "org.lwjgl:lwjgl-glfw:3.2.1", "natives": {"osx": "natives-macos"}, "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.2.2/lwjgl-glfw-3.2.2.jar", "sha1": "d3ad4df38e400b8afba1de63f84338809399df5b", "size": 108907, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.2.2/lwjgl-glfw-3.2.2.jar"}, "classifiers": {"natives-linux": {"path": "org/lwjgl/lwjgl-glfw/3.2.2/lwjgl-glfw-3.2.2-natives-linux.jar", "sha1": "0957733f26a6661d4883da0335f7ef46d3bbbd7d", "size": 159198, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.2.2/lwjgl-glfw-3.2.2-natives-linux.jar"}, "natives-windows": {"path": "org/lwjgl/lwjgl-glfw/3.2.2/lwjgl-glfw-3.2.2-natives-windows.jar", "sha1": "dc6826d636bf796b33a49038c354210e661bfc17", "size": 266648, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.2.2/lwjgl-glfw-3.2.2-natives-windows.jar"}}}, "name": "org.lwjgl:lwjgl-glfw:3.2.2", "natives": {"linux": "natives-linux", "windows": "natives-windows"}, "rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.2.1/lwjgl-stb-3.2.1.jar", "sha1": "31f5eb5fce3791d58ec898bc5c1867d76d781ba1", "size": 105765, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.2.1/lwjgl-stb-3.2.1.jar"}, "classifiers": {"natives-macos": {"path": "org/lwjgl/lwjgl-stb/3.2.1/lwjgl-stb-3.2.1-natives-macos.jar", "sha1": "1f5615c952451c30afafba4a6e3ba4e1cd9e7f5c", "size": 192364, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.2.1/lwjgl-stb-3.2.1-natives-macos.jar"}}}, "name": "org.lwjgl:lwjgl-stb:3.2.1", "natives": {"osx": "natives-macos"}, "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.2.2/lwjgl-tinyfd-3.2.2.jar", "sha1": "fcbe606c8f8da6f8f9a05e2c540eb1ee8632b0e9", "size": 7092, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.2.2/lwjgl-tinyfd-3.2.2.jar"}, "classifiers": {"natives-linux": {"path": "org/lwjgl/lwjgl-tinyfd/3.2.2/lwjgl-tinyfd-3.2.2-natives-linux.jar", "sha1": "39e35b161c130635d9c8918ce04e887a30c5b687", "size": 38804, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.2.2/lwjgl-tinyfd-3.2.2-natives-linux.jar"}, "natives-windows": {"path": "org/lwjgl/lwjgl-tinyfd/3.2.2/lwjgl-tinyfd-3.2.2-natives-windows.jar", "sha1": "e9115958773644e863332a6a06488d26f9e1fc9f", "size": 208314, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.2.2/lwjgl-tinyfd-3.2.2-natives-windows.jar"}}}, "name": "org.lwjgl:lwjgl-tinyfd:3.2.2", "natives": {"linux": "natives-linux", "windows": "natives-windows"}, "rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.2.1/lwjgl-tinyfd-3.2.1.jar", "sha1": "259f1dbddb921e27e01b32458d6f584eb8bba13a", "size": 7088, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.2.1/lwjgl-tinyfd-3.2.1.jar"}, "classifiers": {"natives-macos": {"path": "org/lwjgl/lwjgl-tinyfd/3.2.1/lwjgl-tinyfd-3.2.1-natives-macos.jar", "sha1": "759c2fd9cc5c6ce0b5b7af77ac8200483b7fb660", "size": 41962, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.2.1/lwjgl-tinyfd-3.2.1-natives-macos.jar"}}}, "name": "org.lwjgl:lwjgl-tinyfd:3.2.1", "natives": {"osx": "natives-macos"}, "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.2.2/lwjgl-stb-3.2.2.jar", "sha1": "3b8e6ebc5851dd3d17e37e5cadce2eff2a429f0f", "size": 104469, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.2.2/lwjgl-stb-3.2.2.jar"}, "classifiers": {"natives-linux": {"path": "org/lwjgl/lwjgl-stb/3.2.2/lwjgl-stb-3.2.2-natives-linux.jar", "sha1": "172c52e586fecf43f759bc4f70a778c01f6fdcc1", "size": 203476, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.2.2/lwjgl-stb-3.2.2-natives-linux.jar"}, "natives-windows": {"path": "org/lwjgl/lwjgl-stb/3.2.2/lwjgl-stb-3.2.2-natives-windows.jar", "sha1": "811f705cbb29e8ae8d60bdf8fdd38c0c123ad3ef", "size": 465810, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.2.2/lwjgl-stb-3.2.2-natives-windows.jar"}}}, "name": "org.lwjgl:lwjgl-stb:3.2.2", "natives": {"linux": "natives-linux", "windows": "natives-windows"}, "rules": [{"action": "allow"}, {"action": "disallow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "com/mojang/text2speech/1.11.3/text2speech-1.11.3.jar", "sha1": "f378f889797edd7df8d32272c06ca80a1b6b0f58", "size": 13164, "url": "https://libraries.minecraft.net/com/mojang/text2speech/1.11.3/text2speech-1.11.3.jar"}}, "name": "com.mojang:text2speech:1.11.3"}, {"downloads": {"artifact": {"path": "com/mojang/text2speech/1.11.3/text2speech-1.11.3.jar", "sha1": "f378f889797edd7df8d32272c06ca80a1b6b0f58", "size": 13164, "url": "https://libraries.minecraft.net/com/mojang/text2speech/1.11.3/text2speech-1.11.3.jar"}, "classifiers": {"natives-linux": {"path": "com/mojang/text2speech/1.11.3/text2speech-1.11.3-natives-linux.jar", "sha1": "ac641755a2a841d1fca9e660194f42523ee5cfe0", "size": 7833, "url": "https://libraries.minecraft.net/com/mojang/text2speech/1.11.3/text2speech-1.11.3-natives-linux.jar"}, "natives-windows": {"path": "com/mojang/text2speech/1.11.3/text2speech-1.11.3-natives-windows.jar", "sha1": "c0b242c0091be5acbf303263c7eeeaedd70544c7", "size": 81379, "url": "https://libraries.minecraft.net/com/mojang/text2speech/1.11.3/text2speech-1.11.3-natives-windows.jar"}}}, "extract": {"exclude": ["META-INF/"]}, "name": "com.mojang:text2speech:1.11.3", "natives": {"linux": "natives-linux", "windows": "natives-windows"}}, {"downloads": {"artifact": {"path": "ca/weblite/java-objc-bridge/1.0.0/java-objc-bridge-1.0.0.jar", "sha1": "6ef160c3133a78de015830860197602ca1c855d3", "size": 40502, "url": "https://libraries.minecraft.net/ca/weblite/java-objc-bridge/1.0.0/java-objc-bridge-1.0.0.jar"}, "classifiers": {"natives-osx": {"path": "ca/weblite/java-objc-bridge/1.0.0/java-objc-bridge-1.0.0-natives-osx.jar", "sha1": "08befab4894d55875f33c3d300f4f71e6e828f64", "size": 5629, "url": "https://libraries.minecraft.net/ca/weblite/java-objc-bridge/1.0.0/java-objc-bridge-1.0.0-natives-osx.jar"}}}, "extract": {"exclude": ["META-INF/"]}, "name": "ca.weblite:java-objc-bridge:1.0.0", "natives": {"osx": "natives-osx"}, "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "ca/weblite/java-objc-bridge/1.0.0/java-objc-bridge-1.0.0.jar", "sha1": "6ef160c3133a78de015830860197602ca1c855d3", "size": 40502, "url": "https://libraries.minecraft.net/ca/weblite/java-objc-bridge/1.0.0/java-objc-bridge-1.0.0.jar"}}, "name": "ca.weblite:java-objc-bridge:1.0.0", "rules": [{"action": "allow", "os": {"name": "osx"}}]}], "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": "2021-07-06T12:01:34+00:00", "time": "2021-07-06T12:01:34+00:00", "type": "release"} \ No newline at end of file diff --git a/src/test/resources/launcher/1.20.1.json b/src/test/resources/launcher/1.20.1.json new file mode 100644 index 00000000..3f47dcb4 --- /dev/null +++ b/src/test/resources/launcher/1.20.1.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": "5", "sha1": "3d1e8e9cad62f188270564f4bdfd3a79df9be65a", "size": 413067, "totalSize": 650617398, "url": "https://piston-meta.mojang.com/v1/packages/3d1e8e9cad62f188270564f4bdfd3a79df9be65a/5.json"}, "assets": "5", "complianceLevel": 1, "downloads": {"client": {"sha1": "0c3ec587af28e5a785c0b4a7b8a30f9a8f78f838", "size": 23028853, "url": "https://piston-data.mojang.com/v1/objects/0c3ec587af28e5a785c0b4a7b8a30f9a8f78f838/client.jar"}, "client_mappings": {"sha1": "6c48521eed01fe2e8ecdadbd5ae348415f3c47da", "size": 8001795, "url": "https://piston-data.mojang.com/v1/objects/6c48521eed01fe2e8ecdadbd5ae348415f3c47da/client.txt"}, "server": {"sha1": "84194a2f286ef7c14ed7ce0090dba59902951553", "size": 47791053, "url": "https://piston-data.mojang.com/v1/objects/84194a2f286ef7c14ed7ce0090dba59902951553/server.jar"}, "server_mappings": {"sha1": "0b4dba049482496c507b2387a73a913230ebbd76", "size": 6154195, "url": "https://piston-data.mojang.com/v1/objects/0b4dba049482496c507b2387a73a913230ebbd76/server.txt"}}, "id": "1.20.1", "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.2.2/oshi-core-6.2.2.jar", "sha1": "54f5efc19bca95d709d9a37d19ffcbba3d21c1a6", "size": 947865, "url": "https://libraries.minecraft.net/com/github/oshi/oshi-core/6.2.2/oshi-core-6.2.2.jar"}}, "name": "com.github.oshi:oshi-core:6.2.2"}, {"downloads": {"artifact": {"path": "com/google/code/gson/gson/2.10/gson-2.10.jar", "sha1": "dd9b193aef96e973d5a11ab13cd17430c2e4306b", "size": 286235, "url": "https://libraries.minecraft.net/com/google/code/gson/gson/2.10/gson-2.10.jar"}}, "name": "com.google.code.gson:gson:2.10"}, {"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/31.1-jre/guava-31.1-jre.jar", "sha1": "60458f877d055d0c9114d9e1a2efb737b4bc282c", "size": 2959479, "url": "https://libraries.minecraft.net/com/google/guava/guava/31.1-jre/guava-31.1-jre.jar"}}, "name": "com.google.guava:guava:31.1-jre"}, {"downloads": {"artifact": {"path": "com/ibm/icu/icu4j/71.1/icu4j-71.1.jar", "sha1": "9e7d3304c23f9ba5cb71915f7cce23231a57a445", "size": 13963762, "url": "https://libraries.minecraft.net/com/ibm/icu/icu4j/71.1/icu4j-71.1.jar"}}, "name": "com.ibm.icu:icu4j:71.1"}, {"downloads": {"artifact": {"path": "com/mojang/authlib/4.0.43/authlib-4.0.43.jar", "sha1": "2ff9d747a77570a07a60d32ac77eb6162ad2a2d9", "size": 121766, "url": "https://libraries.minecraft.net/com/mojang/authlib/4.0.43/authlib-4.0.43.jar"}}, "name": "com.mojang:authlib:4.0.43"}, {"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.1.8/brigadier-1.1.8.jar", "sha1": "5244ce82c3337bba4a196a3ce858bfaecc74404a", "size": 77121, "url": "https://libraries.minecraft.net/com/mojang/brigadier/1.1.8/brigadier-1.1.8.jar"}}, "name": "com.mojang:brigadier:1.1.8"}, {"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.15/commons-codec-1.15.jar", "sha1": "49d94806b6e3dc933dacbd8acb0fdbab8ebd1e5d", "size": 353793, "url": "https://libraries.minecraft.net/commons-codec/commons-codec/1.15/commons-codec-1.15.jar"}}, "name": "commons-codec:commons-codec:1.15"}, {"downloads": {"artifact": {"path": "commons-io/commons-io/2.11.0/commons-io-2.11.0.jar", "sha1": "a2503f302b11ebde7ebc3df41daebe0e4eea3689", "size": 327135, "url": "https://libraries.minecraft.net/commons-io/commons-io/2.11.0/commons-io-2.11.0.jar"}}, "name": "commons-io:commons-io:2.11.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.82.Final/netty-buffer-4.1.82.Final.jar", "sha1": "a544270cf1ae8b8077082f5036436a9a9971ea71", "size": 304664, "url": "https://libraries.minecraft.net/io/netty/netty-buffer/4.1.82.Final/netty-buffer-4.1.82.Final.jar"}}, "name": "io.netty:netty-buffer:4.1.82.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-codec/4.1.82.Final/netty-codec-4.1.82.Final.jar", "sha1": "b77200379acb345a9ffdece1c605e591ac3e4e0a", "size": 339155, "url": "https://libraries.minecraft.net/io/netty/netty-codec/4.1.82.Final/netty-codec-4.1.82.Final.jar"}}, "name": "io.netty:netty-codec:4.1.82.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-common/4.1.82.Final/netty-common-4.1.82.Final.jar", "sha1": "022d148e85c3f5ebdacc0ce1f5aabb1d420f73f3", "size": 653880, "url": "https://libraries.minecraft.net/io/netty/netty-common/4.1.82.Final/netty-common-4.1.82.Final.jar"}}, "name": "io.netty:netty-common:4.1.82.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-handler/4.1.82.Final/netty-handler-4.1.82.Final.jar", "sha1": "644041d1fa96a5d3130a29e8978630d716d76e38", "size": 538569, "url": "https://libraries.minecraft.net/io/netty/netty-handler/4.1.82.Final/netty-handler-4.1.82.Final.jar"}}, "name": "io.netty:netty-handler:4.1.82.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-resolver/4.1.82.Final/netty-resolver-4.1.82.Final.jar", "sha1": "38f665ae8dcd29032eea31245ba7806bed2e0fa8", "size": 37776, "url": "https://libraries.minecraft.net/io/netty/netty-resolver/4.1.82.Final/netty-resolver-4.1.82.Final.jar"}}, "name": "io.netty:netty-resolver:4.1.82.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-transport-classes-epoll/4.1.82.Final/netty-transport-classes-epoll-4.1.82.Final.jar", "sha1": "e7c7dd18deac93105797f30057c912651ea76521", "size": 142066, "url": "https://libraries.minecraft.net/io/netty/netty-transport-classes-epoll/4.1.82.Final/netty-transport-classes-epoll-4.1.82.Final.jar"}}, "name": "io.netty:netty-transport-classes-epoll:4.1.82.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-transport-native-epoll/4.1.82.Final/netty-transport-native-epoll-4.1.82.Final-linux-aarch_64.jar", "sha1": "476409d6255001ca53a55f65b01c13822f8dc93a", "size": 39489, "url": "https://libraries.minecraft.net/io/netty/netty-transport-native-epoll/4.1.82.Final/netty-transport-native-epoll-4.1.82.Final-linux-aarch_64.jar"}}, "name": "io.netty:netty-transport-native-epoll:4.1.82.Final:linux-aarch_64", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "io/netty/netty-transport-native-epoll/4.1.82.Final/netty-transport-native-epoll-4.1.82.Final-linux-x86_64.jar", "sha1": "c7350a71920f3ae9142945e25fed4846cce53374", "size": 37922, "url": "https://libraries.minecraft.net/io/netty/netty-transport-native-epoll/4.1.82.Final/netty-transport-native-epoll-4.1.82.Final-linux-x86_64.jar"}}, "name": "io.netty:netty-transport-native-epoll:4.1.82.Final:linux-x86_64", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "io/netty/netty-transport-native-unix-common/4.1.82.Final/netty-transport-native-unix-common-4.1.82.Final.jar", "sha1": "3e895b35ca1b8a0eca56cacff4c2dde5d2c6abce", "size": 43684, "url": "https://libraries.minecraft.net/io/netty/netty-transport-native-unix-common/4.1.82.Final/netty-transport-native-unix-common-4.1.82.Final.jar"}}, "name": "io.netty:netty-transport-native-unix-common:4.1.82.Final"}, {"downloads": {"artifact": {"path": "io/netty/netty-transport/4.1.82.Final/netty-transport-4.1.82.Final.jar", "sha1": "e431a218d91acb6476ccad5f5aafde50aa3945ca", "size": 485752, "url": "https://libraries.minecraft.net/io/netty/netty-transport/4.1.82.Final/netty-transport-4.1.82.Final.jar"}}, "name": "io.netty:netty-transport:4.1.82.Final"}, {"downloads": {"artifact": {"path": "it/unimi/dsi/fastutil/8.5.9/fastutil-8.5.9.jar", "sha1": "bb7ea75ecdb216654237830b3a96d87ad91f8cc5", "size": 23376043, "url": "https://libraries.minecraft.net/it/unimi/dsi/fastutil/8.5.9/fastutil-8.5.9.jar"}}, "name": "it.unimi.dsi:fastutil:8.5.9"}, {"downloads": {"artifact": {"path": "net/java/dev/jna/jna-platform/5.12.1/jna-platform-5.12.1.jar", "sha1": "097406a297c852f4a41e688a176ec675f72e8329", "size": 1356627, "url": "https://libraries.minecraft.net/net/java/dev/jna/jna-platform/5.12.1/jna-platform-5.12.1.jar"}}, "name": "net.java.dev.jna:jna-platform:5.12.1"}, {"downloads": {"artifact": {"path": "net/java/dev/jna/jna/5.12.1/jna-5.12.1.jar", "sha1": "b1e93a735caea94f503e95e6fe79bf9cdc1e985d", "size": 1866196, "url": "https://libraries.minecraft.net/net/java/dev/jna/jna/5.12.1/jna-5.12.1.jar"}}, "name": "net.java.dev.jna:jna:5.12.1"}, {"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.21/commons-compress-1.21.jar", "sha1": "4ec95b60d4e86b5c95a0e919cb172a0af98011ef", "size": 1018316, "url": "https://libraries.minecraft.net/org/apache/commons/commons-compress/1.21/commons-compress-1.21.jar"}}, "name": "org.apache.commons:commons-compress:1.21"}, {"downloads": {"artifact": {"path": "org/apache/commons/commons-lang3/3.12.0/commons-lang3-3.12.0.jar", "sha1": "c6842c86792ff03b9f1d1fe2aab8dc23aa6c6f0e", "size": 587402, "url": "https://libraries.minecraft.net/org/apache/commons/commons-lang3/3.12.0/commons-lang3-3.12.0.jar"}}, "name": "org.apache.commons:commons-lang3:3.12.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.15/httpcore-4.4.15.jar", "sha1": "7f2e0c573eaa7a74bac2e89b359e1f73d92a0a1d", "size": 328324, "url": "https://libraries.minecraft.net/org/apache/httpcomponents/httpcore/4.4.15/httpcore-4.4.15.jar"}}, "name": "org.apache.httpcomponents:httpcore:4.4.15"}, {"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.1/lwjgl-glfw-3.3.1.jar", "sha1": "cbac1b8d30cb4795149c1ef540f912671a8616d0", "size": 128801, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.1/lwjgl-glfw-3.3.1.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.1"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.1/lwjgl-glfw-3.3.1-natives-linux.jar", "sha1": "81716978214ecbda15050ca394b06ef61501a49e", "size": 119817, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.1/lwjgl-glfw-3.3.1-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.1:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.1/lwjgl-glfw-3.3.1-natives-macos.jar", "sha1": "9ec4ce1fc8c85fdef03ef4ff2aace6f5775fb280", "size": 131655, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.1/lwjgl-glfw-3.3.1-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.1:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.1/lwjgl-glfw-3.3.1-natives-macos-arm64.jar", "sha1": "cac0d3f712a3da7641fa174735a5f315de7ffe0a", "size": 129077, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.1/lwjgl-glfw-3.3.1-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.1:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.1/lwjgl-glfw-3.3.1-natives-windows.jar", "sha1": "ed892f945cf7e79c8756796f32d00fa4ceaf573b", "size": 145512, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.1/lwjgl-glfw-3.3.1-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.1:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.1/lwjgl-glfw-3.3.1-natives-windows-arm64.jar", "sha1": "beda65ee503443e60aa196d58ed31f8d001dc22a", "size": 123808, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.1/lwjgl-glfw-3.3.1-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.1:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.1/lwjgl-glfw-3.3.1-natives-windows-x86.jar", "sha1": "b997e3391d6ce8f05487e7335d95c606043884a1", "size": 139251, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.1/lwjgl-glfw-3.3.1-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.1:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.1/lwjgl-jemalloc-3.3.1.jar", "sha1": "a817bcf213db49f710603677457567c37d53e103", "size": 36601, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.1/lwjgl-jemalloc-3.3.1.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.1"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.1/lwjgl-jemalloc-3.3.1-natives-linux.jar", "sha1": "33dbb017b6ed6b25f993ad9d56741a49e7937718", "size": 166524, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.1/lwjgl-jemalloc-3.3.1-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.1:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.1/lwjgl-jemalloc-3.3.1-natives-macos.jar", "sha1": "56424dc8db3cfb8e7b594aa6d59a4f4387b7f544", "size": 117480, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.1/lwjgl-jemalloc-3.3.1-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.1:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.1/lwjgl-jemalloc-3.3.1-natives-macos-arm64.jar", "sha1": "e577b87d8ad2ade361aaea2fcf226c660b15dee8", "size": 103475, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.1/lwjgl-jemalloc-3.3.1-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.1:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.1/lwjgl-jemalloc-3.3.1-natives-windows.jar", "sha1": "948a89b76a16aa324b046ae9308891216ffce5f9", "size": 135585, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.1/lwjgl-jemalloc-3.3.1-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.1:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.1/lwjgl-jemalloc-3.3.1-natives-windows-arm64.jar", "sha1": "cae85c4edb219c88b6a0c26a87955ad98dc9519d", "size": 114250, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.1/lwjgl-jemalloc-3.3.1-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.1:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.1/lwjgl-jemalloc-3.3.1-natives-windows-x86.jar", "sha1": "fb476c8ec110e1c137ad3ce8a7f7bfe6b11c6324", "size": 110405, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.1/lwjgl-jemalloc-3.3.1-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.1:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.1/lwjgl-openal-3.3.1.jar", "sha1": "2623a6b8ae1dfcd880738656a9f0243d2e6840bd", "size": 88237, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.1/lwjgl-openal-3.3.1.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.1"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.1/lwjgl-openal-3.3.1-natives-linux.jar", "sha1": "f906b6439f6daa66001182ea7727e3467a93681b", "size": 476825, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.1/lwjgl-openal-3.3.1-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.1:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.1/lwjgl-openal-3.3.1-natives-macos.jar", "sha1": "3a57b8911835fb58b5e558d0ca0d28157e263d45", "size": 397196, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.1/lwjgl-openal-3.3.1-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.1:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.1/lwjgl-openal-3.3.1-natives-macos-arm64.jar", "sha1": "23d55e7490b57495320f6c9e1936d78fd72c4ef8", "size": 346125, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.1/lwjgl-openal-3.3.1-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.1:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.1/lwjgl-openal-3.3.1-natives-windows.jar", "sha1": "30a474d0e57193d7bc128849a3ab66bc9316fdb1", "size": 576872, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.1/lwjgl-openal-3.3.1-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.1:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.1/lwjgl-openal-3.3.1-natives-windows-arm64.jar", "sha1": "40d65f1a7368a2aa47336f9cb69f5a190cf9975a", "size": 505234, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.1/lwjgl-openal-3.3.1-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.1:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.1/lwjgl-openal-3.3.1-natives-windows-x86.jar", "sha1": "888349f7b1be6fbae58bf8edfb9ef12def04c4e3", "size": 520313, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.1/lwjgl-openal-3.3.1-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.1:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.1/lwjgl-opengl-3.3.1.jar", "sha1": "831a5533a21a5f4f81bbc51bb13e9899319b5411", "size": 921563, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.1/lwjgl-opengl-3.3.1.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.1"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.1/lwjgl-opengl-3.3.1-natives-linux.jar", "sha1": "ab9ab6fde3743e3550fa5d46d785ecb45b047d99", "size": 79125, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.1/lwjgl-opengl-3.3.1-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.1:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.1/lwjgl-opengl-3.3.1-natives-macos.jar", "sha1": "a0d12697ea019a7362eff26475b0531340e876a6", "size": 40709, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.1/lwjgl-opengl-3.3.1-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.1:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.1/lwjgl-opengl-3.3.1-natives-macos-arm64.jar", "sha1": "eafe34b871d966292e8db0f1f3d6b8b110d4e91d", "size": 41665, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.1/lwjgl-opengl-3.3.1-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.1:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.1/lwjgl-opengl-3.3.1-natives-windows.jar", "sha1": "c1807e9bd571402787d7e37e3029776ae2513bb8", "size": 100205, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.1/lwjgl-opengl-3.3.1-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.1:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.1/lwjgl-opengl-3.3.1-natives-windows-arm64.jar", "sha1": "527d78f1e9056aff3ed02ce93019c73c5e8f1721", "size": 82445, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.1/lwjgl-opengl-3.3.1-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.1:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.1/lwjgl-opengl-3.3.1-natives-windows-x86.jar", "sha1": "deef3eb9b178ff2ff3ce893cc72ae741c3a17974", "size": 87463, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.1/lwjgl-opengl-3.3.1-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.1:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.1/lwjgl-stb-3.3.1.jar", "sha1": "b119297cf8ed01f247abe8685857f8e7fcf5980f", "size": 112380, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.1/lwjgl-stb-3.3.1.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.1"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.1/lwjgl-stb-3.3.1-natives-linux.jar", "sha1": "3ee7aec8686e52867677110415566a5342a80230", "size": 227237, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.1/lwjgl-stb-3.3.1-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.1:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.1/lwjgl-stb-3.3.1-natives-macos.jar", "sha1": "def8879b8d38a47a4cc1d48b1f9a7b772e51258e", "size": 203582, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.1/lwjgl-stb-3.3.1-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.1:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.1/lwjgl-stb-3.3.1-natives-macos-arm64.jar", "sha1": "fcf073ed911752abdca5f0b00a53cfdf17ff8e8b", "size": 178408, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.1/lwjgl-stb-3.3.1-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.1:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.1/lwjgl-stb-3.3.1-natives-windows.jar", "sha1": "86315914ac119efdb02dc9e8e978ade84f1702af", "size": 256301, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.1/lwjgl-stb-3.3.1-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.1:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.1/lwjgl-stb-3.3.1-natives-windows-arm64.jar", "sha1": "fde63cdd2605c00636721a6c8b961e41d1f6b247", "size": 216848, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.1/lwjgl-stb-3.3.1-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.1:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.1/lwjgl-stb-3.3.1-natives-windows-x86.jar", "sha1": "a8d41f419eecb430b7c91ea2ce2c5c451cae2091", "size": 225147, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.1/lwjgl-stb-3.3.1-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.1:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.1/lwjgl-tinyfd-3.3.1.jar", "sha1": "0ff1914111ef2e3e0110ef2dabc8d8cdaad82347", "size": 6767, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.1/lwjgl-tinyfd-3.3.1.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.1"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.1/lwjgl-tinyfd-3.3.1-natives-linux.jar", "sha1": "a35110b9471bea8cde826ab297550ee8c22f5035", "size": 45389, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.1/lwjgl-tinyfd-3.3.1-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.1:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.1/lwjgl-tinyfd-3.3.1-natives-macos.jar", "sha1": "78641a0fa5e9afa714adfdd152c357930c97a1ce", "size": 44821, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.1/lwjgl-tinyfd-3.3.1-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.1:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.1/lwjgl-tinyfd-3.3.1-natives-macos-arm64.jar", "sha1": "972ecc17bad3571e81162153077b4d47b7b9eaa9", "size": 41380, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.1/lwjgl-tinyfd-3.3.1-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.1:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.1/lwjgl-tinyfd-3.3.1-natives-windows.jar", "sha1": "a5d830475ec0958d9fdba1559efa99aef211e6ff", "size": 127930, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.1/lwjgl-tinyfd-3.3.1-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.1:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.1/lwjgl-tinyfd-3.3.1-natives-windows-arm64.jar", "sha1": "83a5e780df610829ff3a737822b4f931cffecd91", "size": 109139, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.1/lwjgl-tinyfd-3.3.1-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.1:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.1/lwjgl-tinyfd-3.3.1-natives-windows-x86.jar", "sha1": "842eedd876fae354abc308c98a263f6bbc9e8a4d", "size": 110042, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.1/lwjgl-tinyfd-3.3.1-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.1:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1.jar", "sha1": "ae58664f88e18a9bb2c77b063833ca7aaec484cb", "size": 724243, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1.jar"}}, "name": "org.lwjgl:lwjgl:3.3.1"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-linux.jar", "sha1": "1de885aba434f934201b99f2f1afb142036ac189", "size": 110704, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl:3.3.1:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-macos.jar", "sha1": "fc6bb723dec2cd031557dccb2a95f0ab80acb9db", "size": 55706, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl:3.3.1:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-macos-arm64.jar", "sha1": "71d0d5e469c9c95351eb949064497e3391616ac9", "size": 42693, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl:3.3.1:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-windows.jar", "sha1": "0036c37f16ab611b3aa11f3bcf80b1d509b4ce6b", "size": 159361, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl:3.3.1:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-windows-arm64.jar", "sha1": "0f46cadcf95675908fd3a550d63d9d709cb68998", "size": 130064, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl:3.3.1:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-windows-x86.jar", "sha1": "3b14f4beae9dd39791ec9e12190a9380cd8a3ce6", "size": 134695, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl:3.3.1:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/slf4j/slf4j-api/2.0.1/slf4j-api-2.0.1.jar", "sha1": "f48d81adce2abf5ad3cfe463df517952749e03bc", "size": 61388, "url": "https://libraries.minecraft.net/org/slf4j/slf4j-api/2.0.1/slf4j-api-2.0.1.jar"}}, "name": "org.slf4j:slf4j-api:2.0.1"}], "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-06-12T13:25:51+00:00", "time": "2023-06-12T13:25:51+00:00", "type": "release"} \ No newline at end of file diff --git a/src/test/resources/launcher/1.21.1.json b/src/test/resources/launcher/1.21.1.json new file mode 100644 index 00000000..867a8056 --- /dev/null +++ b/src/test/resources/launcher/1.21.1.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": "17", "sha1": "9b16298b1dc0697878cec88bb2d96168f5239e4f", "size": 449557, "totalSize": 825003775, "url": "https://piston-meta.mojang.com/v1/packages/9b16298b1dc0697878cec88bb2d96168f5239e4f/17.json"}, "assets": "17", "complianceLevel": 1, "downloads": {"client": {"sha1": "30c73b1c5da787909b2f73340419fdf13b9def88", "size": 26836906, "url": "https://piston-data.mojang.com/v1/objects/30c73b1c5da787909b2f73340419fdf13b9def88/client.jar"}, "client_mappings": {"sha1": "2244b6f072256667bcd9a73df124d6c58de77992", "size": 9598610, "url": "https://piston-data.mojang.com/v1/objects/2244b6f072256667bcd9a73df124d6c58de77992/client.txt"}, "server": {"sha1": "59353fb40c36d304f2035d51e7d6e6baa98dc05c", "size": 51627615, "url": "https://piston-data.mojang.com/v1/objects/59353fb40c36d304f2035d51e7d6e6baa98dc05c/server.jar"}, "server_mappings": {"sha1": "03f8985492bda0afc0898465341eb0acef35f570", "size": 7456063, "url": "https://piston-data.mojang.com/v1/objects/03f8985492bda0afc0898465341eb0acef35f570/server.txt"}}, "id": "1.21.1", "javaVersion": {"component": "java-runtime-delta", "majorVersion": 21}, "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.10/oshi-core-6.4.10.jar", "sha1": "b1d8ab82d11d92fd639b56d639f8f46f739dd5fa", "size": 979212, "url": "https://libraries.minecraft.net/com/github/oshi/oshi-core/6.4.10/oshi-core-6.4.10.jar"}}, "name": "com.github.oshi:oshi-core:6.4.10"}, {"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.54/authlib-6.0.54.jar", "sha1": "de8bc95660e1b2fe8793fd427a7a10dcec5b3ea7", "size": 115242, "url": "https://libraries.minecraft.net/com/mojang/authlib/6.0.54/authlib-6.0.54.jar"}}, "name": "com.mojang:authlib:6.0.54"}, {"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.3.10/brigadier-1.3.10.jar", "sha1": "d15b53a14cf20fdcaa98f731af5dda654452c010", "size": 80082, "url": "https://libraries.minecraft.net/com/mojang/brigadier/1.3.10/brigadier-1.3.10.jar"}}, "name": "com.mojang:brigadier:1.3.10"}, {"downloads": {"artifact": {"path": "com/mojang/datafixerupper/8.0.16/datafixerupper-8.0.16.jar", "sha1": "67d4de6d7f95d89bcf5862995fb854ebaec02a34", "size": 724313, "url": "https://libraries.minecraft.net/com/mojang/datafixerupper/8.0.16/datafixerupper-8.0.16.jar"}}, "name": "com.mojang:datafixerupper:8.0.16"}, {"downloads": {"artifact": {"path": "com/mojang/logging/1.2.7/logging-1.2.7.jar", "sha1": "24cb95ffb0e3433fd6e844c04e68009e504ca1c0", "size": 15343, "url": "https://libraries.minecraft.net/com/mojang/logging/1.2.7/logging-1.2.7.jar"}}, "name": "com.mojang:logging:1.2.7"}, {"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.15.1/commons-io-2.15.1.jar", "sha1": "f11560da189ab563a5c8e351941415430e9304ea", "size": 501218, "url": "https://libraries.minecraft.net/commons-io/commons-io/2.15.1/commons-io-2.15.1.jar"}}, "name": "commons-io:commons-io:2.15.1"}, {"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.14.0/jna-platform-5.14.0.jar", "sha1": "28934d48aed814f11e4c584da55c49fa7032b31b", "size": 1369287, "url": "https://libraries.minecraft.net/net/java/dev/jna/jna-platform/5.14.0/jna-platform-5.14.0.jar"}}, "name": "net.java.dev.jna:jna-platform:5.14.0"}, {"downloads": {"artifact": {"path": "net/java/dev/jna/jna/5.14.0/jna-5.14.0.jar", "sha1": "67bf3eaea4f0718cb376a181a629e5f88fa1c9dd", "size": 1878533, "url": "https://libraries.minecraft.net/net/java/dev/jna/jna/5.14.0/jna-5.14.0.jar"}}, "name": "net.java.dev.jna:jna:5.14.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.26.0/commons-compress-1.26.0.jar", "sha1": "659feffdd12280201c8aacb8f7be94f9a883c824", "size": 1078328, "url": "https://libraries.minecraft.net/org/apache/commons/commons-compress/1.26.0/commons-compress-1.26.0.jar"}}, "name": "org.apache.commons:commons-compress:1.26.0"}, {"downloads": {"artifact": {"path": "org/apache/commons/commons-lang3/3.14.0/commons-lang3-3.14.0.jar", "sha1": "1ed471194b02f2c6cb734a0cd6f6f107c673afae", "size": 657952, "url": "https://libraries.minecraft.net/org/apache/commons/commons-lang3/3.14.0/commons-lang3-3.14.0.jar"}}, "name": "org.apache.commons:commons-lang3:3.14.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.22.1/log4j-api-2.22.1.jar", "sha1": "bea6fede6328fabafd7e68363161a7ea6605abd1", "size": 335001, "url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-api/2.22.1/log4j-api-2.22.1.jar"}}, "name": "org.apache.logging.log4j:log4j-api:2.22.1"}, {"downloads": {"artifact": {"path": "org/apache/logging/log4j/log4j-core/2.22.1/log4j-core-2.22.1.jar", "sha1": "7183a25510a02ad00cc6a95d3b3d2a7d3c5a8dc4", "size": 1900022, "url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-core/2.22.1/log4j-core-2.22.1.jar"}}, "name": "org.apache.logging.log4j:log4j-core:2.22.1"}, {"downloads": {"artifact": {"path": "org/apache/logging/log4j/log4j-slf4j2-impl/2.22.1/log4j-slf4j2-impl-2.22.1.jar", "sha1": "d7e6693c2606cb7e7335047d7bb96dec52db5665", "size": 27364, "url": "https://libraries.minecraft.net/org/apache/logging/log4j/log4j-slf4j2-impl/2.22.1/log4j-slf4j2-impl-2.22.1.jar"}}, "name": "org.apache.logging.log4j:log4j-slf4j2-impl:2.22.1"}, {"downloads": {"artifact": {"path": "org/jcraft/jorbis/0.0.17/jorbis-0.0.17.jar", "sha1": "8872d22b293e8f5d7d56ff92be966e6dc28ebdc6", "size": 99701, "url": "https://libraries.minecraft.net/org/jcraft/jorbis/0.0.17/jorbis-0.0.17.jar"}}, "name": "org.jcraft:jorbis:0.0.17"}, {"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-freetype/3.3.3/lwjgl-freetype-3.3.3.jar", "sha1": "a0db6c84a8becc8ca05f9dbfa985edc348a824c7", "size": 450896, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3.jar"}}, "name": "org.lwjgl:lwjgl-freetype:3.3.3"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-linux.jar", "sha1": "149070a5480900347071b7074779531f25a6e3dc", "size": 1245129, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-freetype:3.3.3:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-macos-arm64.jar", "sha1": "b0a8c9baa9d1f54ac61e1ab9640c7659e7fa700c", "size": 1040981, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-freetype:3.3.3:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-macos-patch.jar", "sha1": "806d869f37ce0df388a24e17aaaf5ca0894d851b", "size": 1071983, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-macos-patch.jar"}}, "name": "org.lwjgl:lwjgl-freetype:3.3.3:natives-macos-patch", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-windows.jar", "sha1": "81091b006dbb43fab04c8c638e9ac87c51b4096d", "size": 1035586, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-freetype:3.3.3:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-windows-arm64.jar", "sha1": "82028265a0a2ff33523ca75137ada7dc176e5210", "size": 886068, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-freetype:3.3.3:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-windows-x86.jar", "sha1": "15a8c1de7f51d07a92eae7ce1222557073a0c0c3", "size": 877480, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-freetype/3.3.3/lwjgl-freetype-3.3.3-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-freetype:3.3.3:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3.jar", "sha1": "efa1eb78c5ccd840e9f329717109b5e892d72f8e", "size": 135546, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.3"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-linux.jar", "sha1": "a03684c5e4b1b1dbbe0d29dbbdc27b985b6840f2", "size": 118478, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.3:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-macos.jar", "sha1": "a1bf400f6bc64e6195596cb1430dafda46090751", "size": 140884, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.3:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-macos-arm64.jar", "sha1": "ee8cc78d0a4a5b3b4600fade6d927c9fc320c858", "size": 138288, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.3:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-windows.jar", "sha1": "e449e28b4891fc423c54c85fbc5bb0b9efece67a", "size": 166368, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.3:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-windows-arm64.jar", "sha1": "f27018dc74f6289574502b46cce55d52817554e2", "size": 141970, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.3:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-windows-x86.jar", "sha1": "32334f3fd5270a59bad9939a93115acb6de36dcf", "size": 157123, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-glfw:3.3.3:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3.jar", "sha1": "b543467b7ff3c6920539a88ee602d34098628be5", "size": 43896, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.3"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-linux.jar", "sha1": "4f86728bf449b1dd61251c4e0ac01df1389cb51e", "size": 206779, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.3:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-macos.jar", "sha1": "2906637657a57579847238c9c72d2c4bde7083f8", "size": 153131, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.3:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-macos-arm64.jar", "sha1": "e9412c3ff8cb3a3bad1d3f52909ad74d8a5bdad1", "size": 141418, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.3:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-windows.jar", "sha1": "426222fc027602a5f21b9c0fe79cde6a4c7a011f", "size": 180344, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.3:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-windows-arm64.jar", "sha1": "ba1f3fed0ee4be0217eaa41c5bbfb4b9b1383c33", "size": 154415, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.3:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-windows-x86.jar", "sha1": "f6063b6e0f23be483c5c88d84ce51b39dc69126c", "size": 148612, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-jemalloc:3.3.3:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3.jar", "sha1": "daada81ceb5fc0c291fbfdd4433cb8d9423577f2", "size": 110586, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.3"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-linux.jar", "sha1": "3037360cc4595079bea240af250b6d1a527e0905", "size": 573224, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.3:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-macos.jar", "sha1": "8df8338bfa77f2ebabef4e58964bd04d24805cbf", "size": 519824, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.3:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-macos-arm64.jar", "sha1": "0c78b078de2fb52f45aa55d04db889a560f3544f", "size": 471012, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.3:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-windows.jar", "sha1": "cf83862ae95d98496b26915024c7e666d8ab1c8f", "size": 698720, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.3:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-windows-arm64.jar", "sha1": "8e0615235116b9e4160dfe87bec90f5f6378bf72", "size": 630410, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.3:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-windows-x86.jar", "sha1": "87b8d5050e3adb46bb58fe1cb2669a4a48fce10d", "size": 638424, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-openal:3.3.3:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3.jar", "sha1": "02f6b0147078396a58979125a4c947664e98293a", "size": 929192, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.3"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-linux.jar", "sha1": "62c70a4b00ca5391882b0f4b787c1588d24f1c86", "size": 80463, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.3:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-macos.jar", "sha1": "1bd45997551ae8a28469f3a2b678f4b7289e12c0", "size": 41484, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.3:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-macos-arm64.jar", "sha1": "d213ddef27637b1af87961ffa94d6b27036becc8", "size": 42487, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.3:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-windows.jar", "sha1": "e6c1eec8be8a71951b830a4d69efc01c6531900c", "size": 101535, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.3:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-windows-arm64.jar", "sha1": "65e956d3735a1abdc82eff4baec1b61174697d4b", "size": 83095, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.3:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-windows-x86.jar", "sha1": "0d32d833dcaa2f355a886eaf21f0408b5f03241d", "size": 88612, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-opengl:3.3.3:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3.jar", "sha1": "25dd6161988d7e65f71d5065c99902402ee32746", "size": 120283, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.3"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-linux.jar", "sha1": "fd1271ccd9d85eff2fa31f3fd543e02ccfaf5041", "size": 231820, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.3:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-macos.jar", "sha1": "472792c98fb2c1557c060cb9da5fca6a9773621f", "size": 216456, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.3:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-macos-arm64.jar", "sha1": "51c6955571fbcdb7bb538c6aa589b953b584c6af", "size": 183628, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.3:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-windows.jar", "sha1": "1d9facdf6541de114b0f963be33505b7679c78cb", "size": 261297, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.3:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-windows-arm64.jar", "sha1": "a584ab44de569708871f0a79561f4d8c37487f2c", "size": 219511, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.3:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-windows-x86.jar", "sha1": "b5c874687b9aac1a936501d4ed2c49567fd1b575", "size": 227800, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-stb:3.3.3:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3.jar", "sha1": "82d755ca94b102e9ca77283b9e2dc46d1b15fbe5", "size": 13400, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.3"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-linux.jar", "sha1": "d8d58daa0c3e5fd906fee96f5fddbcbc07cc308b", "size": 44192, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.3:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-macos.jar", "sha1": "6598081e346a03038a8be68eb2de614a1c2eac68", "size": 45865, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.3:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-macos-arm64.jar", "sha1": "406feedb977372085a61eb0fee358183f4f4c67a", "size": 42498, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.3:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-windows.jar", "sha1": "a6697981b0449a5087c1d546fc08b4f73e8f98c9", "size": 130253, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.3:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-windows-arm64.jar", "sha1": "a88c494f3006eb91a7433b12a3a55a9a6c20788b", "size": 110867, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.3:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-windows-x86.jar", "sha1": "c336c84ee88cccb495c6ffa112395509e7378e8a", "size": 111797, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl-tinyfd/3.3.3/lwjgl-tinyfd-3.3.3-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl-tinyfd:3.3.3:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3.jar", "sha1": "29589b5f87ed335a6c7e7ee6a5775f81f97ecb84", "size": 785029, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3.jar"}}, "name": "org.lwjgl:lwjgl:3.3.3"}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-linux.jar", "sha1": "1713758e3660ba66e1e954396fd18126038b33c0", "size": 114627, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-linux.jar"}}, "name": "org.lwjgl:lwjgl:3.3.3:natives-linux", "rules": [{"action": "allow", "os": {"name": "linux"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-macos.jar", "sha1": "33a6efa288390490ce6eb6c3df47ac21ecf648cf", "size": 60543, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-macos.jar"}}, "name": "org.lwjgl:lwjgl:3.3.3:natives-macos", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-macos-arm64.jar", "sha1": "226246e75f6bd8d4e1895bdce8638ef87808d114", "size": 48620, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-macos-arm64.jar"}}, "name": "org.lwjgl:lwjgl:3.3.3:natives-macos-arm64", "rules": [{"action": "allow", "os": {"name": "osx"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-windows.jar", "sha1": "a5ed18a2b82fc91b81f40d717cb1f64c9dcb0540", "size": 165442, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-windows.jar"}}, "name": "org.lwjgl:lwjgl:3.3.3:natives-windows", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-windows-arm64.jar", "sha1": "e9aca8c5479b520a2a7f0d542a118140e812c5e8", "size": 133378, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-windows-arm64.jar"}}, "name": "org.lwjgl:lwjgl:3.3.3:natives-windows-arm64", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-windows-x86.jar", "sha1": "9e670718e050aeaeea0c2d5b907cffb142f2e58f", "size": 139653, "url": "https://libraries.minecraft.net/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-windows-x86.jar"}}, "name": "org.lwjgl:lwjgl:3.3.3:natives-windows-x86", "rules": [{"action": "allow", "os": {"name": "windows"}}]}, {"downloads": {"artifact": {"path": "org/lz4/lz4-java/1.8.0/lz4-java-1.8.0.jar", "sha1": "4b986a99445e49ea5fbf5d149c4b63f6ed6c6780", "size": 682804, "url": "https://libraries.minecraft.net/org/lz4/lz4-java/1.8.0/lz4-java-1.8.0.jar"}}, "name": "org.lz4:lz4-java:1.8.0"}, {"downloads": {"artifact": {"path": "org/slf4j/slf4j-api/2.0.9/slf4j-api-2.0.9.jar", "sha1": "7cf2726fdcfbc8610f9a71fb3ed639871f315340", "size": 64579, "url": "https://libraries.minecraft.net/org/slf4j/slf4j-api/2.0.9/slf4j-api-2.0.9.jar"}}, "name": "org.slf4j:slf4j-api:2.0.9"}], "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": "2024-08-08T12:24:45+00:00", "time": "2024-08-08T12:24:45+00:00", "type": "release"} \ No newline at end of file diff --git a/src/test/resources/launcher/fabric-1.21.1.json b/src/test/resources/launcher/fabric-1.21.1.json new file mode 100644 index 00000000..2b698f13 --- /dev/null +++ b/src/test/resources/launcher/fabric-1.21.1.json @@ -0,0 +1 @@ +{"id":"fabric-loader-0.16.10-1.21.1","inheritsFrom":"1.21.1","releaseTime":"2026-10-03T16:33:18+0000","time":"2026-10-03T16:33:18+0000","type":"release","mainClass":"net.fabricmc.loader.impl.launch.knot.KnotClient","arguments":{"game":[],"jvm":["-DFabricMcEmu= net.minecraft.client.main.Main "]},"libraries":[{"name":"org.ow2.asm:asm:9.7.1","url":"https://maven.fabricmc.net/","md5":"e2cdd32d198ad31427d298eee9d39d8d","sha1":"f0ed132a49244b042cd0e15702ab9f2ce3cc8436","sha256":"8cadd43ac5eb6d09de05faecca38b917a040bb9139c7edeb4cc81c740b713281","sha512":"4767b01603dad5c79cc1e2b5f3722f72b1059d928f184f446ba11badeb1b381b3a3a9a801cc43d25d396df950b09d19597c73173c411b1da890de808b94f1f50","size":126093},{"name":"org.ow2.asm:asm-analysis:9.7.1","url":"https://maven.fabricmc.net/","md5":"4cd9e7178dfb85371bba3077e40cd514","sha1":"f97a3b319f0ed6a8cd944dc79060d3912a28985f","sha256":"85b29371884ba31bb76edf22323c2c24e172c3267a67152eba3d1ccc2e041ef2","sha512":"a8bd265c81d9bb4371cafd3f5d18f96ad79aec65031457d518c54599144d199d9feddf13b8dc822b2598b8b504a88edbd81d1f2c52991a70a6b343d8f5bb6fe5","size":35126},{"name":"org.ow2.asm:asm-commons:9.7.1","url":"https://maven.fabricmc.net/","md5":"8344aea3c8b7d707e9d35a62710e77c9","sha1":"406c6a2225cfe1819f102a161e54cc16a5c24f75","sha256":"9a579b54d292ad9be171d4313fd4739c635592c2b5ac3a459bbd1049cddec6a0","sha512":"81daf5765e387e6aeec5d45c4b9e4e1b471fb4f350931e5a214845c7c657a2142768f6902765e49c0ce2c595962e5d008883cba2e4a40c4bdce8f2e92518d2db","size":73459},{"name":"org.ow2.asm:asm-tree:9.7.1","url":"https://maven.fabricmc.net/","md5":"e85029f613b6469989cc7cf53fe06b74","sha1":"3a53139787663b139de76b627fca0084ab60d32c","sha256":"9929881f59eb6b840e86d54570c77b59ce721d104e6dfd7a40978991c2d3b41f","sha512":"e55008c392fdd35e95d3404766b12dd4b46e13d5c362fcd0ab42a65751a82737eaf0ebc857691d1916190d34407adfde4437615d69c278785416fd911e00978d","size":51939},{"name":"org.ow2.asm:asm-util:9.7.1","url":"https://maven.fabricmc.net/","md5":"36d373a9cd6cad9b35db9f984c6b4bbb","sha1":"9e23359b598ec6b74b23e53110dd5c577adf2243","sha256":"f885be71b5c90556f5f1ad1c4f9276b29b96057c497d46666fe4ddbec3cb43c6","sha512":"522d793d15a2c5ea6504a50222cf0750f1eab7b881cf289675042539b1aba8b3868197b1bebe729de728dd10020eb028ae16252dcd5d84fdcbf7f925832bc269","size":94519},{"name":"net.fabricmc:sponge-mixin:0.15.4+mixin.0.8.7","url":"https://maven.fabricmc.net/","md5":"991ca8be2c408fe19eb64014d8a18243","sha1":"6a12aacc794f1078458433116e9ed42c1cc98096","sha256":"4631f67da980ae2ac9e6e285a7df4680645e2f37a6a14acc775f97982162c2a2","sha512":"d4f6caaee7cb841cc9bc36ae5eb725bdb3dd28a5af8f63f75747a17156d708263b37f5a87eca863b2c3d91732c0b128318e9de455d1cd964e1ae8ba0a318fbf1","size":1494834},{"name":"net.fabricmc:intermediary:1.21.1","url":"https://maven.fabricmc.net/"},{"name":"net.fabricmc:fabric-loader:0.16.10","url":"https://maven.fabricmc.net/"}]} \ No newline at end of file diff --git a/src/test/resources/launcher/legacy-adapter-1.12.2-evidence.txt b/src/test/resources/launcher/legacy-adapter-1.12.2-evidence.txt new file mode 100644 index 00000000..9219785b --- /dev/null +++ b/src/test/resources/launcher/legacy-adapter-1.12.2-evidence.txt @@ -0,0 +1,44 @@ +# Narrow adapter evidence for Minecraft 1.12.2; retrieved 2026-10-04. +# Official client: https://piston-data.mojang.com/v1/objects/0f275bc1547d01fa5f56ba34bdc87d981ee12daf/client.jar +# Client SHA-1 verified: 0f275bc1547d01fa5f56ba34bdc87d981ee12daf +# SRG source: https://maven.minecraftforge.net/de/oceanlabs/mcp/mcp/1.12.2/mcp-1.12.2-srg.zip +# Names source: https://maven.minecraftforge.net/de/oceanlabs/mcp/mcp_stable/39-1.12/mcp_stable-39-1.12.zip +# Unmodified source lines below. Method descriptors and native call arguments independently checked with javap. +# GuiListWorldSelectionEntry.loadWorld invokes launchIntegratedServer(summary.getFileName(), summary.getDisplayName(), null) after canLoadWorld. +# Minecraft is NOT Executor; addScheduledTask(Runnable) wraps a Callable and queues it for its original Minecraft thread. +# AnvilSaveConverter extends SaveFormatOld; canLoadWorld is inherited and requires public getMethod or superclass lookup. +# No getOverlay exists in this version; a GuiMainMenu screen is the readiness gate. +CL: tr net/minecraft/util/IThreadListener +CL: amx net/minecraft/world/WorldSettings +CL: bez net/minecraft/world/chunk/storage/AnvilSaveConverter +CL: bfc net/minecraft/world/storage/SaveFormatOld +CL: bfg net/minecraft/world/storage/ISaveFormat +CL: bfh net/minecraft/world/storage/WorldSummary +CL: bib net/minecraft/client/Minecraft +CL: blr net/minecraft/client/gui/GuiMainMenu +CL: bol net/minecraft/client/gui/GuiListWorldSelectionEntry +FD: bib/M net/minecraft/client/Minecraft/field_130070_K +FD: bib/m net/minecraft/client/Minecraft/field_71462_r +MD: bez/b ()Ljava/util/List; net/minecraft/world/chunk/storage/AnvilSaveConverter/func_75799_b ()Ljava/util/List; +MD: bfc/b ()Ljava/util/List; net/minecraft/world/storage/SaveFormatOld/func_75799_b ()Ljava/util/List; +MD: bfc/f (Ljava/lang/String;)Z net/minecraft/world/storage/SaveFormatOld/func_90033_f (Ljava/lang/String;)Z +MD: bfg/b ()Ljava/util/List; net/minecraft/world/storage/ISaveFormat/func_75799_b ()Ljava/util/List; +MD: bfg/f (Ljava/lang/String;)Z net/minecraft/world/storage/ISaveFormat/func_90033_f (Ljava/lang/String;)Z +MD: bfh/a ()Ljava/lang/String; net/minecraft/world/storage/WorldSummary/func_75786_a ()Ljava/lang/String; +MD: bfh/b ()Ljava/lang/String; net/minecraft/world/storage/WorldSummary/func_75788_b ()Ljava/lang/String; +MD: bib/a (Ljava/lang/Runnable;)Lcom/google/common/util/concurrent/ListenableFuture; net/minecraft/client/Minecraft/func_152344_a (Ljava/lang/Runnable;)Lcom/google/common/util/concurrent/ListenableFuture; +MD: bib/a (Ljava/lang/String;Ljava/lang/String;Lamx;)V net/minecraft/client/Minecraft/func_71371_a (Ljava/lang/String;Ljava/lang/String;Lnet/minecraft/world/WorldSettings;)V +MD: bib/g ()Lbfg; net/minecraft/client/Minecraft/func_71359_d ()Lnet/minecraft/world/storage/ISaveFormat; +MD: bib/z ()Lbib; net/minecraft/client/Minecraft/func_71410_x ()Lnet/minecraft/client/Minecraft; +MD: bol/e ()V net/minecraft/client/gui/GuiListWorldSelectionEntry/func_186777_e ()V +# Human-readable names from MCP stable 39: +func_152344_a,addScheduledTask,2, +func_186777_e,loadWorld,0, +func_71359_d,getSaveLoader,0,Returns the save loader that is currently being used +func_71371_a,launchIntegratedServer,0,"Arguments: World foldername, World ingame name, WorldSettings" +func_71410_x,getMinecraft,0,Return the singleton Minecraft instance for the game +func_75786_a,getFileName,0,return the file name +func_75788_b,getDisplayName,0,return the display name of the save +func_75799_b,getSaveList,0, +func_90033_f,canLoadWorld,0,Return whether the given world can be loaded. +field_71462_r,currentScreen,0,The GuiScreen that's being displayed at the moment. diff --git a/src/test/resources/launcher/legacy-adapter-1.13.2-evidence.txt b/src/test/resources/launcher/legacy-adapter-1.13.2-evidence.txt new file mode 100644 index 00000000..f203c243 --- /dev/null +++ b/src/test/resources/launcher/legacy-adapter-1.13.2-evidence.txt @@ -0,0 +1,31 @@ +# Narrow adapter evidence for Minecraft 1.13.2; retrieved 2026-10-04. +# Official client: https://piston-data.mojang.com/v1/objects/30bfe37a8db404db11c7edf02cb5165817afb4d9/client.jar +# Client SHA-1 verified: 30bfe37a8db404db11c7edf02cb5165817afb4d9 +# TSRG source: https://maven.minecraftforge.net/de/oceanlabs/mcp/mcp_config/1.13.2/mcp_config-1.13.2.zip +# Archive SHA-256: 6cce7b121f7e30c8bec08c5c5ed10e887fc5720a08fd6d698ea1f37ab127df8d +# Unmodified config/joined.tsrg excerpts below; signatures and behavior independently checked with javap. +# Native selection cnc.i() checks cce.f(summary.a()) then calls cft.a(summary.a(), summary.b(), null). +# Runnable scheduler cft.a(Runnable) wraps Callable and queues off-thread work in aG; it is not Executor. +# SaveFormatOld owns inherited canLoadWorld; public getMethod or superclass lookup is necessary. +# No overlay API; original GuiMainMenu is the readiness gate. +ayd net/minecraft/world/WorldSettings +cbw net/minecraft/world/chunk/storage/AnvilSaveConverter + b ()Ljava/util/List; func_75799_b +cca net/minecraft/world/storage/SaveFormatOld + b ()Ljava/util/List; func_75799_b + f (Ljava/lang/String;)Z func_90033_f +cce net/minecraft/world/storage/ISaveFormat + b ()Ljava/util/List; func_75799_b + f (Ljava/lang/String;)Z func_90033_f +ccf net/minecraft/world/storage/WorldSummary + a ()Ljava/lang/String; func_75786_a + b ()Ljava/lang/String; func_75788_b +cft net/minecraft/client/Minecraft + m field_71462_r + a (Ljava/lang/Runnable;)Lcom/google/common/util/concurrent/ListenableFuture; func_152344_a + a (Ljava/lang/String;Ljava/lang/String;Layd;)V func_71371_a + g ()Lcce; func_71359_d + s ()Lcft; func_71410_x +cki net/minecraft/client/gui/GuiMainMenu +cnc net/minecraft/client/gui/GuiListWorldSelectionEntry + i ()V func_186777_e diff --git a/src/test/resources/launcher/legacy-adapter-1.7.10-evidence.txt b/src/test/resources/launcher/legacy-adapter-1.7.10-evidence.txt new file mode 100644 index 00000000..1e8b33cf --- /dev/null +++ b/src/test/resources/launcher/legacy-adapter-1.7.10-evidence.txt @@ -0,0 +1,33 @@ +# Narrow adapter evidence for Minecraft 1.7.10; retrieved 2026-10-04. +# Official client: https://launcher.mojang.com/v1/objects/e80d9b3bf5085002218d4be59e668bac718abbc6/client.jar +# Client SHA-1 verified: e80d9b3bf5085002218d4be59e668bac718abbc6 +# SRG source: https://maven.minecraftforge.net/de/oceanlabs/mcp/mcp/1.7.10/mcp-1.7.10-srg.zip +# SRG archive SHA-256: d49df5c445dcf00ada532ab3b0e4283bf4b05ac13f210647658dfd3cec8b34c2 +# Unmodified joined.srg lines below; signatures and behavior independently checked with javap. +# Native selection bdx.e(int) checks canLoadWorld and calls launchIntegratedServer(folder, displayName, null). +# Its display-name helper reads the summary name; only null/empty names get localized World N fallback. +# For ordinary nonempty save names, the summary name is passed unchanged. +# Minecraft Runnable scheduler wraps Callable and queues off-thread work in aB; it is not Executor. +# SaveFormatOld owns inherited canLoadWorld; public getMethod or superclass lookup is necessary. +# No overlay API; original GuiMainMenu is the readiness gate. +CL: ahj net/minecraft/world/WorldSettings +CL: ayn net/minecraft/world/chunk/storage/AnvilSaveConverter +CL: ayr net/minecraft/world/storage/SaveFormatOld +CL: aze net/minecraft/world/storage/ISaveFormat +CL: azf net/minecraft/world/storage/SaveFormatComparator +CL: bao net/minecraft/client/Minecraft +CL: bdx net/minecraft/client/gui/GuiSelectWorld +CL: bee net/minecraft/client/gui/GuiMainMenu +FD: bao/n net/minecraft/client/Minecraft/field_71462_r +MD: ayn/b ()Ljava/util/List; net/minecraft/world/chunk/storage/AnvilSaveConverter/func_75799_b ()Ljava/util/List; +MD: ayr/b ()Ljava/util/List; net/minecraft/world/storage/SaveFormatOld/func_75799_b ()Ljava/util/List; +MD: ayr/f (Ljava/lang/String;)Z net/minecraft/world/storage/SaveFormatOld/func_90033_f (Ljava/lang/String;)Z +MD: aze/b ()Ljava/util/List; net/minecraft/world/storage/ISaveFormat/func_75799_b ()Ljava/util/List; +MD: aze/f (Ljava/lang/String;)Z net/minecraft/world/storage/ISaveFormat/func_90033_f (Ljava/lang/String;)Z +MD: azf/a ()Ljava/lang/String; net/minecraft/world/storage/SaveFormatComparator/func_75786_a ()Ljava/lang/String; +MD: azf/b ()Ljava/lang/String; net/minecraft/world/storage/SaveFormatComparator/func_75788_b ()Ljava/lang/String; +MD: bao/B ()Lbao; net/minecraft/client/Minecraft/func_71410_x ()Lnet/minecraft/client/Minecraft; +MD: bao/a (Ljava/lang/Runnable;)Lcom/google/common/util/concurrent/ListenableFuture; net/minecraft/client/Minecraft/func_152344_a (Ljava/lang/Runnable;)Lcom/google/common/util/concurrent/ListenableFuture; +MD: bao/a (Ljava/lang/String;Ljava/lang/String;Lahj;)V net/minecraft/client/Minecraft/func_71371_a (Ljava/lang/String;Ljava/lang/String;Lnet/minecraft/world/WorldSettings;)V +MD: bao/d ()Laze; net/minecraft/client/Minecraft/func_71359_d ()Lnet/minecraft/world/storage/ISaveFormat; +MD: bdx/e (I)V net/minecraft/client/gui/GuiSelectWorld/func_146615_e (I)V diff --git a/src/test/resources/launcher/legacy-adapter-1.8.9-evidence.txt b/src/test/resources/launcher/legacy-adapter-1.8.9-evidence.txt new file mode 100644 index 00000000..9fe634b7 --- /dev/null +++ b/src/test/resources/launcher/legacy-adapter-1.8.9-evidence.txt @@ -0,0 +1,36 @@ +# Narrow adapter evidence for Minecraft 1.8.9; retrieved 2026-10-04. +# Official client: https://launcher.mojang.com/v1/objects/3870888a6c3d349d3771a3e9d16c9bf5e076b908/client.jar +# Client SHA-1 verified: 3870888a6c3d349d3771a3e9d16c9bf5e076b908 +# SRG source: https://maven.minecraftforge.net/de/oceanlabs/mcp/mcp/1.8.9/mcp-1.8.9-srg.zip +# SRG archive SHA-256: ea6fdd37e68569a378fd149b0ab7cfede1de7fa3e794f4ed81f9018366fbdc51 +# Unmodified joined.srg lines below; signatures and behavior independently checked with javap. +# Native selection axv.i(int) checks canLoadWorld and calls launchIntegratedServer(folder, displayName, null). +# Its display-name helper reads the summary name; only null/empty names get localized World N fallback. +# For ordinary nonempty save names, the summary name is passed unchanged. +# Minecraft Runnable scheduler wraps Callable and queues off-thread work in aM; it is not Executor. +# SaveFormatOld owns inherited canLoadWorld; public getMethod or superclass lookup is necessary. +# No overlay API; original GuiMainMenu is the readiness gate. +CL: adp net/minecraft/world/WorldSettings +CL: atk net/minecraft/world/chunk/storage/AnvilSaveConverter +CL: atn net/minecraft/world/storage/SaveFormatOld +CL: atr net/minecraft/world/storage/ISaveFormat +CL: ats net/minecraft/world/storage/SaveFormatComparator +CL: ave net/minecraft/client/Minecraft +CL: axv net/minecraft/client/gui/GuiSelectWorld +CL: aya net/minecraft/client/gui/GuiMainMenu +FD: ave/m net/minecraft/client/Minecraft/field_71462_r +MD: atk/b ()Ljava/util/List; net/minecraft/world/chunk/storage/AnvilSaveConverter/func_75799_b ()Ljava/util/List; +MD: atn/b ()Ljava/util/List; net/minecraft/world/storage/SaveFormatOld/func_75799_b ()Ljava/util/List; +MD: atn/f (Ljava/lang/String;)Z net/minecraft/world/storage/SaveFormatOld/func_90033_f (Ljava/lang/String;)Z +MD: atr/b ()Ljava/util/List; net/minecraft/world/storage/ISaveFormat/func_75799_b ()Ljava/util/List; +MD: atr/f (Ljava/lang/String;)Z net/minecraft/world/storage/ISaveFormat/func_90033_f (Ljava/lang/String;)Z +MD: ats/a ()Ljava/lang/String; net/minecraft/world/storage/SaveFormatComparator/func_75786_a ()Ljava/lang/String; +MD: ats/b ()Ljava/lang/String; net/minecraft/world/storage/SaveFormatComparator/func_75788_b ()Ljava/lang/String; +MD: ave/A ()Lave; net/minecraft/client/Minecraft/func_71410_x ()Lnet/minecraft/client/Minecraft; +MD: ave/a (Ljava/lang/Runnable;)Lcom/google/common/util/concurrent/ListenableFuture; net/minecraft/client/Minecraft/func_152344_a (Ljava/lang/Runnable;)Lcom/google/common/util/concurrent/ListenableFuture; +MD: ave/a (Ljava/lang/String;Ljava/lang/String;Ladp;)V net/minecraft/client/Minecraft/func_71371_a (Ljava/lang/String;Ljava/lang/String;Lnet/minecraft/world/WorldSettings;)V +MD: ave/f ()Latr; net/minecraft/client/Minecraft/func_71359_d ()Lnet/minecraft/world/storage/ISaveFormat; +MD: axv/i (I)V net/minecraft/client/gui/GuiSelectWorld/func_146615_e (I)V +MD: le/a (Ljava/lang/Runnable;)Lcom/google/common/util/concurrent/ListenableFuture; net/minecraft/world/WorldServer/func_152344_a (Ljava/lang/Runnable;)Lcom/google/common/util/concurrent/ListenableFuture; +MD: net/minecraft/server/MinecraftServer/a (Ljava/lang/Runnable;)Lcom/google/common/util/concurrent/ListenableFuture; net/minecraft/server/MinecraftServer/func_152344_a (Ljava/lang/Runnable;)Lcom/google/common/util/concurrent/ListenableFuture; +MD: od/a (Ljava/lang/Runnable;)Lcom/google/common/util/concurrent/ListenableFuture; net/minecraft/util/IThreadListener/func_152344_a (Ljava/lang/Runnable;)Lcom/google/common/util/concurrent/ListenableFuture; diff --git a/src/test/resources/launcher/legacy-mappings-1.14.4.txt b/src/test/resources/launcher/legacy-mappings-1.14.4.txt new file mode 100644 index 00000000..53e2ecd1 --- /dev/null +++ b/src/test/resources/launcher/legacy-mappings-1.14.4.txt @@ -0,0 +1,29 @@ +# Selected lines from the unmodified Mojang client mappings. +# Minecraft 1.14.4; retrieved 2026-10-04. +# Source: https://piston-data.mojang.com/v1/objects/6073e4ba6949217eb708c4512be2ccc1850a603f/client.txt +# Source SHA-1: 6073e4ba6949217eb708c4512be2ccc1850a603f +# Native WorldListEntry.loadWorld bytecode calls selectLevel(getLevelId(), getLevelName(), null), after levelExists. +# Verified against original client JAR SHA-1 8c325a0c5bd674dd747d6ebaa4c791fd363ad8a9 using javap; no world was opened. +net.minecraft.client.Minecraft -> cyc: + net.minecraft.client.gui.screens.Screen screen -> n + 803:803:net.minecraft.world.level.storage.LevelStorageSource getLevelSource() -> h + 1577:1646:void selectLevel(java.lang.String,java.lang.String,net.minecraft.world.level.LevelSettings) -> a + 1930:1930:net.minecraft.client.Minecraft getInstance() -> u + 2408:2408:net.minecraft.client.gui.screens.Overlay getOverlay() -> at +net.minecraft.client.main.Main -> net.minecraft.client.main.Main: + 42:156:void main(java.lang.String[]) -> main +net.minecraft.client.gui.screens.TitleScreen -> dcp: +net.minecraft.client.gui.screens.Screen -> dcl: +net.minecraft.client.gui.screens.Overlay -> dcg: +net.minecraft.world.level.LevelSettings -> bhv: +net.minecraft.world.level.storage.LevelStorageSource -> coq: + 75:101:java.util.List getLevelList() -> b + 109:109:net.minecraft.world.level.storage.LevelStorage selectLevel(java.lang.String,net.minecraft.server.MinecraftServer) -> a + 113:113:net.minecraft.world.level.storage.LevelStorage selectLevel(java.nio.file.Path,com.mojang.datafixers.DataFixer,java.lang.String,net.minecraft.server.MinecraftServer) -> a + 247:247:boolean levelExists(java.lang.String) -> e +net.minecraft.world.level.storage.LevelSummary -> cor: + 44:44:java.lang.String getLevelId() -> a + 48:48:java.lang.String getLevelName() -> b +net.minecraft.util.thread.BlockableEventLoop -> agq: + 91:96:void execute(java.lang.Runnable) -> execute +net.minecraft.util.thread.ReentrantBlockableEventLoop -> agu: diff --git a/src/test/resources/launcher/legacy-mappings-1.15.2.txt b/src/test/resources/launcher/legacy-mappings-1.15.2.txt new file mode 100644 index 00000000..852d7b85 --- /dev/null +++ b/src/test/resources/launcher/legacy-mappings-1.15.2.txt @@ -0,0 +1,29 @@ +# Selected lines from the unmodified Mojang client mappings. +# Minecraft 1.15.2; retrieved 2026-10-04. +# Source: https://piston-data.mojang.com/v1/objects/1fbf9f0bc9c326af859b3ccf71c2a8f5edc47ef8/client.txt +# Source SHA-1: 1fbf9f0bc9c326af859b3ccf71c2a8f5edc47ef8 +# Native WorldListEntry.loadWorld bytecode calls selectLevel(getLevelId(), getLevelName(), null), after levelExists. +# Verified against original client JAR SHA-1 e3f78cd16f9eb9a52307ed96ebec64241cc5b32d using javap; no world was opened. +net.minecraft.client.Minecraft -> dbn: + net.minecraft.client.gui.screens.Screen screen -> x + 832:832:net.minecraft.world.level.storage.LevelStorageSource getLevelSource() -> j + 1613:1682:void selectLevel(java.lang.String,java.lang.String,net.minecraft.world.level.LevelSettings) -> a + 1970:1970:net.minecraft.client.Minecraft getInstance() -> x + 2426:2426:net.minecraft.client.gui.screens.Overlay getOverlay() -> at +net.minecraft.client.main.Main -> net.minecraft.client.main.Main: + 45:220:void main(java.lang.String[]) -> main +net.minecraft.client.gui.screens.TitleScreen -> dgf: +net.minecraft.client.gui.screens.Screen -> dgb: +net.minecraft.client.gui.screens.Overlay -> dfw: +net.minecraft.world.level.LevelSettings -> bka: +net.minecraft.world.level.storage.LevelStorageSource -> crp: + 75:101:java.util.List getLevelList() -> b + 109:109:net.minecraft.world.level.storage.LevelStorage selectLevel(java.lang.String,net.minecraft.server.MinecraftServer) -> a + 113:113:net.minecraft.world.level.storage.LevelStorage selectLevel(java.nio.file.Path,com.mojang.datafixers.DataFixer,java.lang.String,net.minecraft.server.MinecraftServer) -> a + 247:247:boolean levelExists(java.lang.String) -> e +net.minecraft.world.level.storage.LevelSummary -> crq: + 44:44:java.lang.String getLevelId() -> a + 48:48:java.lang.String getLevelName() -> b +net.minecraft.util.thread.BlockableEventLoop -> ais: + 91:96:void execute(java.lang.Runnable) -> execute +net.minecraft.util.thread.ReentrantBlockableEventLoop -> aiw: diff --git a/src/test/resources/launcher/legacy-mappings-1.16.5.txt b/src/test/resources/launcher/legacy-mappings-1.16.5.txt new file mode 100644 index 00000000..5cc4525d --- /dev/null +++ b/src/test/resources/launcher/legacy-mappings-1.16.5.txt @@ -0,0 +1,17 @@ +# Selected lines from the unmodified Mojang client mappings. +# Minecraft 1.16.5; retrieved 2026-10-04. +# Source: https://piston-data.mojang.com/v1/objects/374c6b789574afbdc901371207155661e0509e17/client.txt +# Source SHA-1: 374c6b789574afbdc901371207155661e0509e17 +net.minecraft.client.Minecraft -> djz: + net.minecraft.client.gui.screens.Screen screen -> y + 1748:1749:void loadLevel(java.lang.String) -> a + 2295:2295:net.minecraft.client.Minecraft getInstance() -> C + 2752:2752:net.minecraft.client.gui.screens.Overlay getOverlay() -> aA +net.minecraft.client.main.Main -> net.minecraft.client.main.Main: + 46:231:void main(java.lang.String[]) -> main +net.minecraft.client.gui.screens.TitleScreen -> doy: +net.minecraft.client.gui.screens.Screen -> dot: +net.minecraft.client.gui.screens.Overlay -> don: +net.minecraft.util.thread.BlockableEventLoop -> aob: + 91:96:void execute(java.lang.Runnable) -> execute +net.minecraft.util.thread.ReentrantBlockableEventLoop -> aof: diff --git a/src/test/resources/launcher/legacy-mappings-1.16.txt b/src/test/resources/launcher/legacy-mappings-1.16.txt new file mode 100644 index 00000000..7c742b5e --- /dev/null +++ b/src/test/resources/launcher/legacy-mappings-1.16.txt @@ -0,0 +1,17 @@ +# Selected lines from the unmodified Mojang client mappings. +# Minecraft 1.16; retrieved 2026-10-04. +# Source: https://piston-data.mojang.com/v1/objects/9539840a7dbea682b305858fe138bd03ee7823cf/client.txt +# Source SHA-1: 9539840a7dbea682b305858fe138bd03ee7823cf +net.minecraft.client.Minecraft -> dlx: + net.minecraft.client.gui.screens.Screen screen -> y + 1698:1699:void loadLevel(java.lang.String) -> a + 2230:2230:net.minecraft.client.Minecraft getInstance() -> B + 2687:2687:net.minecraft.client.gui.screens.Overlay getOverlay() -> az +net.minecraft.client.main.Main -> net.minecraft.client.main.Main: + 46:231:void main(java.lang.String[]) -> main +net.minecraft.client.gui.screens.TitleScreen -> dqw: +net.minecraft.client.gui.screens.Screen -> dqs: +net.minecraft.client.gui.screens.Overlay -> dql: +net.minecraft.util.thread.BlockableEventLoop -> amn: + 91:96:void execute(java.lang.Runnable) -> execute +net.minecraft.util.thread.ReentrantBlockableEventLoop -> amr: diff --git a/src/test/resources/launcher/legacy-mappings-1.17.1.txt b/src/test/resources/launcher/legacy-mappings-1.17.1.txt new file mode 100644 index 00000000..0c9db0e4 --- /dev/null +++ b/src/test/resources/launcher/legacy-mappings-1.17.1.txt @@ -0,0 +1,17 @@ +# Selected lines from the unmodified Mojang client mappings. +# Minecraft 1.17.1; retrieved 2026-10-04. +# Source: https://piston-data.mojang.com/v1/objects/e4d540e0cba05a6097e885dffdf363e621f87d3f/client.txt +# Source SHA-1: e4d540e0cba05a6097e885dffdf363e621f87d3f +net.minecraft.client.Minecraft -> dvp: + net.minecraft.client.gui.screens.Screen screen -> y + 1939:1940:void loadLevel(java.lang.String) -> a + 2467:2467:net.minecraft.client.Minecraft getInstance() -> C + 2932:2932:net.minecraft.client.gui.screens.Overlay getOverlay() -> aA +net.minecraft.client.main.Main -> net.minecraft.client.main.Main: + 47:235:void main(java.lang.String[]) -> main +net.minecraft.client.gui.screens.TitleScreen -> eav: +net.minecraft.client.gui.screens.Screen -> eaq: +net.minecraft.client.gui.screens.Overlay -> eak: +net.minecraft.util.thread.BlockableEventLoop -> aqv: + 98:103:void execute(java.lang.Runnable) -> execute +net.minecraft.util.thread.ReentrantBlockableEventLoop -> aqz: diff --git a/src/test/resources/launcher/legacy-mappings-1.18.2.txt b/src/test/resources/launcher/legacy-mappings-1.18.2.txt new file mode 100644 index 00000000..f2e9d433 --- /dev/null +++ b/src/test/resources/launcher/legacy-mappings-1.18.2.txt @@ -0,0 +1,17 @@ +# Selected lines from the unmodified Mojang client mappings. +# Minecraft 1.18.2; retrieved 2026-10-04. +# Source: https://piston-data.mojang.com/v1/objects/a661c6a55a0600bd391bdbbd6827654c05b2109c/client.txt +# Source SHA-1: a661c6a55a0600bd391bdbbd6827654c05b2109c +net.minecraft.client.Minecraft -> dyr: + net.minecraft.client.gui.screens.Screen screen -> y + 1937:1938:void loadLevel(java.lang.String) -> a + 2435:2435:net.minecraft.client.Minecraft getInstance() -> D + 2826:2826:net.minecraft.client.gui.screens.Overlay getOverlay() -> az +net.minecraft.client.main.Main -> net.minecraft.client.main.Main: + 51:254:void main(java.lang.String[]) -> main +net.minecraft.client.gui.screens.TitleScreen -> eeb: +net.minecraft.client.gui.screens.Screen -> edw: +net.minecraft.client.gui.screens.Overlay -> edq: +net.minecraft.util.thread.BlockableEventLoop -> auz: + 100:105:void execute(java.lang.Runnable) -> execute +net.minecraft.util.thread.ReentrantBlockableEventLoop -> avd: diff --git a/src/test/resources/launcher/legacy-mappings-1.19.2.txt b/src/test/resources/launcher/legacy-mappings-1.19.2.txt new file mode 100644 index 00000000..ef6a8dad --- /dev/null +++ b/src/test/resources/launcher/legacy-mappings-1.19.2.txt @@ -0,0 +1,19 @@ +# Selected lines from the unmodified Mojang client mappings. +# Minecraft 1.19.2; retrieved 2026-10-04. +# Source: https://piston-data.mojang.com/v1/objects/8e8c9be5dc27802caba47053d4fdea328f7f89bd/client.txt +# Source SHA-1: 8e8c9be5dc27802caba47053d4fdea328f7f89bd +net.minecraft.client.Minecraft -> efu: + net.minecraft.client.gui.screens.Screen screen -> z + 2029:2029:net.minecraft.client.gui.screens.worldselection.WorldOpenFlows createWorldOpenFlows() -> u + 2400:2400:net.minecraft.client.Minecraft getInstance() -> I + 2804:2804:net.minecraft.client.gui.screens.Overlay getOverlay() -> aD +net.minecraft.client.main.Main -> net.minecraft.client.main.Main: + 51:52:void main(java.lang.String[]) -> main +net.minecraft.client.gui.screens.TitleScreen -> elr: +net.minecraft.client.gui.screens.Screen -> elm: +net.minecraft.client.gui.screens.Overlay -> elg: +net.minecraft.client.gui.screens.worldselection.WorldOpenFlows -> ept: + 57:58:void loadLevel(net.minecraft.client.gui.screens.Screen,java.lang.String) -> a +net.minecraft.util.thread.BlockableEventLoop -> ayz: + 100:105:void execute(java.lang.Runnable) -> execute +net.minecraft.util.thread.ReentrantBlockableEventLoop -> azd: diff --git a/src/test/resources/launcher/legacy-mappings-1.19.3.txt b/src/test/resources/launcher/legacy-mappings-1.19.3.txt new file mode 100644 index 00000000..976b839c --- /dev/null +++ b/src/test/resources/launcher/legacy-mappings-1.19.3.txt @@ -0,0 +1,19 @@ +# Selected lines from the unmodified Mojang client mappings. +# Minecraft 1.19.3; retrieved 2026-10-04. +# Source: https://piston-data.mojang.com/v1/objects/42366909cc612e76208d34bf1356f05a88e08a1d/client.txt +# Source SHA-1: 42366909cc612e76208d34bf1356f05a88e08a1d +net.minecraft.client.Minecraft -> ejf: + net.minecraft.client.gui.screens.Screen screen -> z + 2063:2063:net.minecraft.client.gui.screens.worldselection.WorldOpenFlows createWorldOpenFlows() -> w + 2449:2449:net.minecraft.client.Minecraft getInstance() -> N + 2850:2850:net.minecraft.client.gui.screens.Overlay getOverlay() -> aK +net.minecraft.client.main.Main -> net.minecraft.client.main.Main: + 51:52:void main(java.lang.String[]) -> main +net.minecraft.client.gui.screens.TitleScreen -> epg: +net.minecraft.client.gui.screens.Screen -> epb: +net.minecraft.client.gui.screens.Overlay -> eov: +net.minecraft.client.gui.screens.worldselection.WorldOpenFlows -> etq: + 61:62:void loadLevel(net.minecraft.client.gui.screens.Screen,java.lang.String) -> a +net.minecraft.util.thread.BlockableEventLoop -> bbc: + 99:104:void execute(java.lang.Runnable) -> execute +net.minecraft.util.thread.ReentrantBlockableEventLoop -> bbg: diff --git a/src/test/resources/launcher/legacy-mappings-1.19.4.txt b/src/test/resources/launcher/legacy-mappings-1.19.4.txt new file mode 100644 index 00000000..5231b5e6 --- /dev/null +++ b/src/test/resources/launcher/legacy-mappings-1.19.4.txt @@ -0,0 +1,19 @@ +# Selected lines from the unmodified Mojang client mappings. +# Minecraft 1.19.4; retrieved 2026-10-04. +# Source: https://piston-data.mojang.com/v1/objects/f14771b764f943c154d3a6fcb47694477e328148/client.txt +# Source SHA-1: f14771b764f943c154d3a6fcb47694477e328148 +net.minecraft.client.Minecraft -> emh: + net.minecraft.client.gui.screens.Screen screen -> z + 2080:2080:net.minecraft.client.gui.screens.worldselection.WorldOpenFlows createWorldOpenFlows() -> w + 2465:2465:net.minecraft.client.Minecraft getInstance() -> N + 2862:2862:net.minecraft.client.gui.screens.Overlay getOverlay() -> aJ +net.minecraft.client.main.Main -> net.minecraft.client.main.Main: + 51:255:void main(java.lang.String[]) -> main +net.minecraft.client.gui.screens.TitleScreen -> eti: +net.minecraft.client.gui.screens.Screen -> etd: +net.minecraft.client.gui.screens.Overlay -> esx: +net.minecraft.client.gui.screens.worldselection.WorldOpenFlows -> exx: + 62:63:void loadLevel(net.minecraft.client.gui.screens.Screen,java.lang.String) -> a +net.minecraft.util.thread.BlockableEventLoop -> bco: + 99:104:void execute(java.lang.Runnable) -> execute +net.minecraft.util.thread.ReentrantBlockableEventLoop -> bcs: diff --git a/src/test/resources/launcher/legacy-mappings-1.19.txt b/src/test/resources/launcher/legacy-mappings-1.19.txt new file mode 100644 index 00000000..152b2125 --- /dev/null +++ b/src/test/resources/launcher/legacy-mappings-1.19.txt @@ -0,0 +1,19 @@ +# Selected lines from the unmodified Mojang client mappings. +# Minecraft 1.19; retrieved 2026-10-04. +# Source: https://piston-data.mojang.com/v1/objects/150346d1c0b4acec0b4eb7f58b86e3ea1aa730f3/client.txt +# Source SHA-1: 150346d1c0b4acec0b4eb7f58b86e3ea1aa730f3 +net.minecraft.client.Minecraft -> eev: + net.minecraft.client.gui.screens.Screen screen -> y + 1991:1991:net.minecraft.client.gui.screens.worldselection.WorldOpenFlows createWorldOpenFlows() -> u + 2347:2347:net.minecraft.client.Minecraft getInstance() -> G + 2737:2737:net.minecraft.client.gui.screens.Overlay getOverlay() -> aB +net.minecraft.client.main.Main -> net.minecraft.client.main.Main: + 51:255:void main(java.lang.String[]) -> main +net.minecraft.client.gui.screens.TitleScreen -> eko: +net.minecraft.client.gui.screens.Screen -> ekj: +net.minecraft.client.gui.screens.Overlay -> ekd: +net.minecraft.client.gui.screens.worldselection.WorldOpenFlows -> eok: + 57:58:void loadLevel(net.minecraft.client.gui.screens.Screen,java.lang.String) -> a +net.minecraft.util.thread.BlockableEventLoop -> ayc: + 100:105:void execute(java.lang.Runnable) -> execute +net.minecraft.util.thread.ReentrantBlockableEventLoop -> ayg: diff --git a/src/test/resources/launcher/legacy-mappings-22w11a.txt b/src/test/resources/launcher/legacy-mappings-22w11a.txt new file mode 100644 index 00000000..4867db9f --- /dev/null +++ b/src/test/resources/launcher/legacy-mappings-22w11a.txt @@ -0,0 +1,19 @@ +# Selected lines from the unmodified Mojang client mappings. +# Minecraft 22w11a; retrieved 2026-10-04. +# Source: https://piston-data.mojang.com/v1/objects/dbb05f0a348f2c76df6916b6aedcf691fb8c11f0/client.txt +# Source SHA-1: dbb05f0a348f2c76df6916b6aedcf691fb8c11f0 +net.minecraft.client.Minecraft -> eam: + net.minecraft.client.gui.screens.Screen screen -> y + 1970:1970:net.minecraft.client.gui.screens.worldselection.WorldOpenFlows createWorldOpenFlows() -> t + 2334:2334:net.minecraft.client.Minecraft getInstance() -> F + 2724:2724:net.minecraft.client.gui.screens.Overlay getOverlay() -> aB +net.minecraft.client.main.Main -> net.minecraft.client.main.Main: + 51:254:void main(java.lang.String[]) -> main +net.minecraft.client.gui.screens.TitleScreen -> egd: +net.minecraft.client.gui.screens.Screen -> efy: +net.minecraft.client.gui.screens.Overlay -> efs: +net.minecraft.client.gui.screens.worldselection.WorldOpenFlows -> ejy: + 55:56:void loadLevel(java.lang.String) -> a +net.minecraft.util.thread.BlockableEventLoop -> avp: + 100:105:void execute(java.lang.Runnable) -> execute +net.minecraft.util.thread.ReentrantBlockableEventLoop -> avt: diff --git a/src/test/resources/launcher/legacy-mappings-22w13a.txt b/src/test/resources/launcher/legacy-mappings-22w13a.txt new file mode 100644 index 00000000..3f26da50 --- /dev/null +++ b/src/test/resources/launcher/legacy-mappings-22w13a.txt @@ -0,0 +1,19 @@ +# Selected lines from the unmodified Mojang client mappings. +# Minecraft 22w13a; retrieved 2026-10-04. +# Source: https://piston-data.mojang.com/v1/objects/6c24f90a6b2653803072e82d26814aec59b79eb1/client.txt +# Source SHA-1: 6c24f90a6b2653803072e82d26814aec59b79eb1 +net.minecraft.client.Minecraft -> ebq: + net.minecraft.client.gui.screens.Screen screen -> y + 1970:1970:net.minecraft.client.gui.screens.worldselection.WorldOpenFlows createWorldOpenFlows() -> t + 2334:2334:net.minecraft.client.Minecraft getInstance() -> F + 2724:2724:net.minecraft.client.gui.screens.Overlay getOverlay() -> aB +net.minecraft.client.main.Main -> net.minecraft.client.main.Main: + 51:254:void main(java.lang.String[]) -> main +net.minecraft.client.gui.screens.TitleScreen -> ehf: +net.minecraft.client.gui.screens.Screen -> eha: +net.minecraft.client.gui.screens.Overlay -> egu: +net.minecraft.client.gui.screens.worldselection.WorldOpenFlows -> ela: + 56:57:void loadLevel(net.minecraft.client.gui.screens.Screen,java.lang.String) -> a +net.minecraft.util.thread.BlockableEventLoop -> avz: + 100:105:void execute(java.lang.Runnable) -> execute +net.minecraft.util.thread.ReentrantBlockableEventLoop -> awd: diff --git a/src/test/resources/launcher/neoforge-1.21.1.json b/src/test/resources/launcher/neoforge-1.21.1.json new file mode 100644 index 00000000..385dc6b4 --- /dev/null +++ b/src/test/resources/launcher/neoforge-1.21.1.json @@ -0,0 +1 @@ +{"id":"neoforge-21.1.172","time":"2025-05-12T21:39:20.541475284","releaseTime":"2025-05-12T21:39:20.541475284","type":"release","mainClass":"cpw.mods.bootstraplauncher.BootstrapLauncher","inheritsFrom":"1.21.1","arguments":{"game":["--fml.neoForgeVersion","21.1.172","--fml.fmlVersion","4.0.39","--fml.mcVersion","1.21.1","--fml.neoFormVersion","20240808.144430","--launchTarget","forgeclient"],"jvm":["-Djava.net.preferIPv6Addresses=system","-DignoreList=client-extra,${version_name}.jar","-DlibraryDirectory=${library_directory}","-p","${library_directory}/cpw/mods/bootstraplauncher/2.0.2/bootstraplauncher-2.0.2.jar${classpath_separator}${library_directory}/cpw/mods/securejarhandler/3.0.8/securejarhandler-3.0.8.jar${classpath_separator}${library_directory}/org/ow2/asm/asm-commons/9.7/asm-commons-9.7.jar${classpath_separator}${library_directory}/org/ow2/asm/asm-util/9.7/asm-util-9.7.jar${classpath_separator}${library_directory}/org/ow2/asm/asm-analysis/9.7/asm-analysis-9.7.jar${classpath_separator}${library_directory}/org/ow2/asm/asm-tree/9.7/asm-tree-9.7.jar${classpath_separator}${library_directory}/org/ow2/asm/asm/9.7/asm-9.7.jar${classpath_separator}${library_directory}/net/neoforged/JarJarFileSystems/0.4.1/JarJarFileSystems-0.4.1.jar","--add-modules","ALL-MODULE-PATH","--add-opens","java.base/java.util.jar=cpw.mods.securejarhandler","--add-opens","java.base/java.lang.invoke=cpw.mods.securejarhandler","--add-exports","java.base/sun.security.util=cpw.mods.securejarhandler","--add-exports","jdk.naming.dns/com.sun.jndi.dns=java.naming"]},"libraries":[{"name":"net.neoforged.fancymodloader:earlydisplay:4.0.39","downloads":{"artifact":{"sha1":"4388fd457faa6f04f8858d92ead5f33a354693a0","size":261720,"url":"https://maven.neoforged.net/releases/net/neoforged/fancymodloader/earlydisplay/4.0.39/earlydisplay-4.0.39.jar","path":"net/neoforged/fancymodloader/earlydisplay/4.0.39/earlydisplay-4.0.39.jar"}}},{"name":"net.neoforged.fancymodloader:loader:4.0.39","downloads":{"artifact":{"sha1":"3721e43a7254e2861a63ab4a4ce716e4150d2640","size":504648,"url":"https://maven.neoforged.net/releases/net/neoforged/fancymodloader/loader/4.0.39/loader-4.0.39.jar","path":"net/neoforged/fancymodloader/loader/4.0.39/loader-4.0.39.jar"}}},{"name":"net.neoforged.accesstransformers:at-modlauncher:10.0.1","downloads":{"artifact":{"sha1":"5aba50202aceead086bc09fbc2751c9f05cb4890","size":2347,"url":"https://maven.neoforged.net/releases/net/neoforged/accesstransformers/at-modlauncher/10.0.1/at-modlauncher-10.0.1.jar","path":"net/neoforged/accesstransformers/at-modlauncher/10.0.1/at-modlauncher-10.0.1.jar"}}},{"name":"net.neoforged:accesstransformers:10.0.1","downloads":{"artifact":{"sha1":"fd83b5725f76eae9115e9355fa1c456a6a441400","size":67234,"url":"https://maven.neoforged.net/releases/net/neoforged/accesstransformers/10.0.1/accesstransformers-10.0.1.jar","path":"net/neoforged/accesstransformers/10.0.1/accesstransformers-10.0.1.jar"}}},{"name":"net.neoforged:bus:8.0.2","downloads":{"artifact":{"sha1":"163c9ef7c4bcca6d850c34e95261b606af91fe06","size":29341,"url":"https://maven.neoforged.net/releases/net/neoforged/bus/8.0.2/bus-8.0.2.jar","path":"net/neoforged/bus/8.0.2/bus-8.0.2.jar"}}},{"name":"net.neoforged:coremods:7.0.3","downloads":{"artifact":{"sha1":"09147e6f638b4272b3bd5fc8f92ad37802512c6c","size":22659,"url":"https://maven.neoforged.net/releases/net/neoforged/coremods/7.0.3/coremods-7.0.3.jar","path":"net/neoforged/coremods/7.0.3/coremods-7.0.3.jar"}}},{"name":"cpw.mods:modlauncher:11.0.4","downloads":{"artifact":{"sha1":"6619d4812a3c092310d521ebc4c9503727563df7","size":116200,"url":"https://maven.neoforged.net/releases/cpw/mods/modlauncher/11.0.4/modlauncher-11.0.4.jar","path":"cpw/mods/modlauncher/11.0.4/modlauncher-11.0.4.jar"}}},{"name":"net.neoforged:mergetool:2.0.0:api","downloads":{"artifact":{"sha1":"52fe1949be64e3303aabaaa21e315f551db9c9f4","size":2550,"url":"https://maven.neoforged.net/releases/net/neoforged/mergetool/2.0.0/mergetool-2.0.0-api.jar","path":"net/neoforged/mergetool/2.0.0/mergetool-2.0.0-api.jar"}}},{"name":"com.electronwill.night-config:toml:3.8.2","downloads":{"artifact":{"sha1":"c128842f759692f613ea6c3ef0ef052273bbb38c","size":39516,"url":"https://maven.neoforged.net/releases/com/electronwill/night-config/toml/3.8.2/toml-3.8.2.jar","path":"com/electronwill/night-config/toml/3.8.2/toml-3.8.2.jar"}}},{"name":"com.electronwill.night-config:core:3.8.2","downloads":{"artifact":{"sha1":"94eee571e1524129804d08c75f6416a3be380ad4","size":380967,"url":"https://maven.neoforged.net/releases/com/electronwill/night-config/core/3.8.2/core-3.8.2.jar","path":"com/electronwill/night-config/core/3.8.2/core-3.8.2.jar"}}},{"name":"net.neoforged:JarJarSelector:0.4.1","downloads":{"artifact":{"sha1":"fb3cc7a58af22ad2880adb98af6d518128c47dae","size":17168,"url":"https://maven.neoforged.net/releases/net/neoforged/JarJarSelector/0.4.1/JarJarSelector-0.4.1.jar","path":"net/neoforged/JarJarSelector/0.4.1/JarJarSelector-0.4.1.jar"}}},{"name":"net.neoforged:JarJarMetadata:0.4.1","downloads":{"artifact":{"sha1":"f8da03683dc81694556dc3e177c5e3bb77ae6fcb","size":15648,"url":"https://maven.neoforged.net/releases/net/neoforged/JarJarMetadata/0.4.1/JarJarMetadata-0.4.1.jar","path":"net/neoforged/JarJarMetadata/0.4.1/JarJarMetadata-0.4.1.jar"}}},{"name":"org.apache.maven:maven-artifact:3.8.5","downloads":{"artifact":{"sha1":"4433f50c07debefaed0553bd0068f4f48d449313","size":58077,"url":"https://maven.neoforged.net/releases/org/apache/maven/maven-artifact/3.8.5/maven-artifact-3.8.5.jar","path":"org/apache/maven/maven-artifact/3.8.5/maven-artifact-3.8.5.jar"}}},{"name":"net.jodah:typetools:0.6.3","downloads":{"artifact":{"sha1":"a01aaa6ddaea9ec07ec4f209487b7a46a526283a","size":18281,"url":"https://maven.neoforged.net/releases/net/jodah/typetools/0.6.3/typetools-0.6.3.jar","path":"net/jodah/typetools/0.6.3/typetools-0.6.3.jar"}}},{"name":"net.minecrell:terminalconsoleappender:1.3.0","downloads":{"artifact":{"sha1":"b562e9bb61235c9520e26282cdee71f8f802d1fc","size":16949,"url":"https://maven.neoforged.net/releases/net/minecrell/terminalconsoleappender/1.3.0/terminalconsoleappender-1.3.0.jar","path":"net/minecrell/terminalconsoleappender/1.3.0/terminalconsoleappender-1.3.0.jar"}}},{"name":"net.fabricmc:sponge-mixin:0.15.2+mixin.0.8.7","downloads":{"artifact":{"sha1":"2af2f021d8e02a0220dc27a7a72b4666d66d44ca","size":1494751,"url":"https://maven.neoforged.net/releases/net/fabricmc/sponge-mixin/0.15.2+mixin.0.8.7/sponge-mixin-0.15.2+mixin.0.8.7.jar","path":"net/fabricmc/sponge-mixin/0.15.2+mixin.0.8.7/sponge-mixin-0.15.2+mixin.0.8.7.jar"}}},{"name":"org.openjdk.nashorn:nashorn-core:15.4","downloads":{"artifact":{"sha1":"f67f5ffaa5f5130cf6fb9b133da00c7df3b532a5","size":2167292,"url":"https://maven.neoforged.net/releases/org/openjdk/nashorn/nashorn-core/15.4/nashorn-core-15.4.jar","path":"org/openjdk/nashorn/nashorn-core/15.4/nashorn-core-15.4.jar"}}},{"name":"org.apache.commons:commons-lang3:3.14.0","downloads":{"artifact":{"sha1":"1ed471194b02f2c6cb734a0cd6f6f107c673afae","size":657952,"url":"https://libraries.minecraft.net/org/apache/commons/commons-lang3/3.14.0/commons-lang3-3.14.0.jar","path":"org/apache/commons/commons-lang3/3.14.0/commons-lang3-3.14.0.jar"}}},{"name":"cpw.mods:bootstraplauncher:2.0.2","downloads":{"artifact":{"sha1":"1a2d076cbc33b0520cbacd591224427b2a20047d","size":11116,"url":"https://maven.neoforged.net/releases/cpw/mods/bootstraplauncher/2.0.2/bootstraplauncher-2.0.2.jar","path":"cpw/mods/bootstraplauncher/2.0.2/bootstraplauncher-2.0.2.jar"}}},{"name":"cpw.mods:securejarhandler:3.0.8","downloads":{"artifact":{"sha1":"c0ef95cecd8699a0449053ac7d9c160748d902cd","size":103765,"url":"https://maven.neoforged.net/releases/cpw/mods/securejarhandler/3.0.8/securejarhandler-3.0.8.jar","path":"cpw/mods/securejarhandler/3.0.8/securejarhandler-3.0.8.jar"}}},{"name":"org.ow2.asm:asm-commons:9.7","downloads":{"artifact":{"sha1":"e86dda4696d3c185fcc95d8d311904e7ce38a53f","size":73426,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm-commons/9.7/asm-commons-9.7.jar","path":"org/ow2/asm/asm-commons/9.7/asm-commons-9.7.jar"}}},{"name":"org.ow2.asm:asm-util:9.7","downloads":{"artifact":{"sha1":"c0655519f24d92af2202cb681cd7c1569df6ead6","size":93784,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm-util/9.7/asm-util-9.7.jar","path":"org/ow2/asm/asm-util/9.7/asm-util-9.7.jar"}}},{"name":"org.ow2.asm:asm-analysis:9.7","downloads":{"artifact":{"sha1":"e4a258b7eb96107106c0599f0061cfc1832fe07a","size":34776,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm-analysis/9.7/asm-analysis-9.7.jar","path":"org/ow2/asm/asm-analysis/9.7/asm-analysis-9.7.jar"}}},{"name":"org.ow2.asm:asm-tree:9.7","downloads":{"artifact":{"sha1":"e446a17b175bfb733b87c5c2560ccb4e57d69f1a","size":51934,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm-tree/9.7/asm-tree-9.7.jar","path":"org/ow2/asm/asm-tree/9.7/asm-tree-9.7.jar"}}},{"name":"org.ow2.asm:asm:9.7","downloads":{"artifact":{"sha1":"073d7b3086e14beb604ced229c302feff6449723","size":125428,"url":"https://maven.neoforged.net/releases/org/ow2/asm/asm/9.7/asm-9.7.jar","path":"org/ow2/asm/asm/9.7/asm-9.7.jar"}}},{"name":"net.neoforged:JarJarFileSystems:0.4.1","downloads":{"artifact":{"sha1":"78f59f89defcd032ed788b151ca6a0d40ace796a","size":31807,"url":"https://maven.neoforged.net/releases/net/neoforged/JarJarFileSystems/0.4.1/JarJarFileSystems-0.4.1.jar","path":"net/neoforged/JarJarFileSystems/0.4.1/JarJarFileSystems-0.4.1.jar"}}},{"name":"net.sf.jopt-simple:jopt-simple:5.0.4","downloads":{"artifact":{"sha1":"4fdac2fbe92dfad86aa6e9301736f6b4342a3f5c","size":78146,"url":"https://libraries.minecraft.net/net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar","path":"net/sf/jopt-simple/jopt-simple/5.0.4/jopt-simple-5.0.4.jar"}}},{"name":"org.slf4j:slf4j-api:2.0.9","downloads":{"artifact":{"sha1":"7cf2726fdcfbc8610f9a71fb3ed639871f315340","size":64579,"url":"https://libraries.minecraft.net/org/slf4j/slf4j-api/2.0.9/slf4j-api-2.0.9.jar","path":"org/slf4j/slf4j-api/2.0.9/slf4j-api-2.0.9.jar"}}},{"name":"org.antlr:antlr4-runtime:4.13.1","downloads":{"artifact":{"sha1":"17125bae1d965624e265ef49552f6465a2bfa307","size":326305,"url":"https://maven.neoforged.net/releases/org/antlr/antlr4-runtime/4.13.1/antlr4-runtime-4.13.1.jar","path":"org/antlr/antlr4-runtime/4.13.1/antlr4-runtime-4.13.1.jar"}}},{"name":"com.mojang:logging:1.2.7","downloads":{"artifact":{"sha1":"24cb95ffb0e3433fd6e844c04e68009e504ca1c0","size":15343,"url":"https://libraries.minecraft.net/com/mojang/logging/1.2.7/logging-1.2.7.jar","path":"com/mojang/logging/1.2.7/logging-1.2.7.jar"}}},{"name":"org.apache.logging.log4j:log4j-slf4j2-impl:2.22.1","downloads":{"artifact":{"sha1":"d7e6693c2606cb7e7335047d7bb96dec52db5665","size":27364,"url":"https://libraries.minecraft.net/org/apache/logging/log4j/log4j-slf4j2-impl/2.22.1/log4j-slf4j2-impl-2.22.1.jar","path":"org/apache/logging/log4j/log4j-slf4j2-impl/2.22.1/log4j-slf4j2-impl-2.22.1.jar"}}},{"name":"org.apache.logging.log4j:log4j-core:2.22.1","downloads":{"artifact":{"sha1":"7183a25510a02ad00cc6a95d3b3d2a7d3c5a8dc4","size":1900022,"url":"https://libraries.minecraft.net/org/apache/logging/log4j/log4j-core/2.22.1/log4j-core-2.22.1.jar","path":"org/apache/logging/log4j/log4j-core/2.22.1/log4j-core-2.22.1.jar"}}},{"name":"org.apache.logging.log4j:log4j-api:2.22.1","downloads":{"artifact":{"sha1":"bea6fede6328fabafd7e68363161a7ea6605abd1","size":335001,"url":"https://libraries.minecraft.net/org/apache/logging/log4j/log4j-api/2.22.1/log4j-api-2.22.1.jar","path":"org/apache/logging/log4j/log4j-api/2.22.1/log4j-api-2.22.1.jar"}}},{"name":"org.jline:jline-reader:3.20.0","downloads":{"artifact":{"sha1":"8f15415b022a25b473e8e16c28ae913186ffb9c4","size":171052,"url":"https://maven.neoforged.net/releases/org/jline/jline-reader/3.20.0/jline-reader-3.20.0.jar","path":"org/jline/jline-reader/3.20.0/jline-reader-3.20.0.jar"}}},{"name":"org.jline:jline-terminal:3.20.0","downloads":{"artifact":{"sha1":"d0ddcc708ddf527a3454c941b7b9225cc83a15ff","size":228481,"url":"https://maven.neoforged.net/releases/org/jline/jline-terminal/3.20.0/jline-terminal-3.20.0.jar","path":"org/jline/jline-terminal/3.20.0/jline-terminal-3.20.0.jar"}}},{"name":"commons-io:commons-io:2.15.1","downloads":{"artifact":{"sha1":"f11560da189ab563a5c8e351941415430e9304ea","size":501218,"url":"https://libraries.minecraft.net/commons-io/commons-io/2.15.1/commons-io-2.15.1.jar","path":"commons-io/commons-io/2.15.1/commons-io-2.15.1.jar"}}},{"name":"net.minecraftforge:srgutils:0.4.15","downloads":{"artifact":{"sha1":"ca408b131759478f164e010fae0d73997e125fb5","size":69393,"url":"https://maven.neoforged.net/releases/net/minecraftforge/srgutils/0.4.15/srgutils-0.4.15.jar","path":"net/minecraftforge/srgutils/0.4.15/srgutils-0.4.15.jar"}}},{"name":"com.google.guava:guava:32.1.2-jre","downloads":{"artifact":{"sha1":"5e64ec7e056456bef3a4bc4c6fdaef71e8ab6318","size":3041591,"url":"https://libraries.minecraft.net/com/google/guava/guava/32.1.2-jre/guava-32.1.2-jre.jar","path":"com/google/guava/guava/32.1.2-jre/guava-32.1.2-jre.jar"}}},{"name":"com.google.guava:failureaccess:1.0.1","downloads":{"artifact":{"sha1":"1dcf1de382a0bf95a3d8b0849546c88bac1292c9","size":4617,"url":"https://libraries.minecraft.net/com/google/guava/failureaccess/1.0.1/failureaccess-1.0.1.jar","path":"com/google/guava/failureaccess/1.0.1/failureaccess-1.0.1.jar"}}},{"name":"com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava","downloads":{"artifact":{"sha1":"b421526c5f297295adef1c886e5246c39d4ac629","size":2199,"url":"https://libraries.minecraft.net/com/google/guava/listenablefuture/9999.0-empty-to-avoid-conflict-with-guava/listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar","path":"com/google/guava/listenablefuture/9999.0-empty-to-avoid-conflict-with-guava/listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar"}}},{"name":"com.google.code.findbugs:jsr305:3.0.2","downloads":{"artifact":{"sha1":"25ea2e8b0c338a877313bd4672d3fe056ea78f0d","size":19936,"url":"https://libraries.minecraft.net/com/google/code/findbugs/jsr305/3.0.2/jsr305-3.0.2.jar","path":"com/google/code/findbugs/jsr305/3.0.2/jsr305-3.0.2.jar"}}},{"name":"org.checkerframework:checker-qual:3.33.0","downloads":{"artifact":{"sha1":"de2b60b62da487644fc11f734e73c8b0b431238f","size":223979,"url":"https://libraries.minecraft.net/org/checkerframework/checker-qual/3.33.0/checker-qual-3.33.0.jar","path":"org/checkerframework/checker-qual/3.33.0/checker-qual-3.33.0.jar"}}},{"name":"com.google.errorprone:error_prone_annotations:2.18.0","downloads":{"artifact":{"sha1":"89b684257096f548fa39a7df9fdaa409d4d4df91","size":16017,"url":"https://libraries.minecraft.net/com/google/errorprone/error_prone_annotations/2.18.0/error_prone_annotations-2.18.0.jar","path":"com/google/errorprone/error_prone_annotations/2.18.0/error_prone_annotations-2.18.0.jar"}}},{"name":"com.google.j2objc:j2objc-annotations:2.8","downloads":{"artifact":{"sha1":"c85270e307e7b822f1086b93689124b89768e273","size":9301,"url":"https://libraries.minecraft.net/com/google/j2objc/j2objc-annotations/2.8/j2objc-annotations-2.8.jar","path":"com/google/j2objc/j2objc-annotations/2.8/j2objc-annotations-2.8.jar"}}},{"name":"com.google.code.gson:gson:2.10.1","downloads":{"artifact":{"sha1":"b3add478d4382b78ea20b1671390a858002feb6c","size":283367,"url":"https://libraries.minecraft.net/com/google/code/gson/gson/2.10.1/gson-2.10.1.jar","path":"com/google/code/gson/gson/2.10.1/gson-2.10.1.jar"}}},{"name":"org.codehaus.plexus:plexus-utils:3.3.0","downloads":{"artifact":{"sha1":"cf43b5391de623b36fe066a21127baef82c64022","size":263253,"url":"https://maven.neoforged.net/releases/org/codehaus/plexus/plexus-utils/3.3.0/plexus-utils-3.3.0.jar","path":"org/codehaus/plexus/plexus-utils/3.3.0/plexus-utils-3.3.0.jar"}}},{"name":"com.machinezoo.noexception:noexception:1.7.1","downloads":{"artifact":{"sha1":"b65330c98e38a1f915fa54a6e5eca496505e3f0a","size":250861,"url":"https://maven.neoforged.net/releases/com/machinezoo/noexception/noexception/1.7.1/noexception-1.7.1.jar","path":"com/machinezoo/noexception/noexception/1.7.1/noexception-1.7.1.jar"}}}]} \ No newline at end of file diff --git a/src/test/resources/launcher/quilt-1.20.1.json b/src/test/resources/launcher/quilt-1.20.1.json new file mode 100644 index 00000000..df09248f --- /dev/null +++ b/src/test/resources/launcher/quilt-1.20.1.json @@ -0,0 +1,69 @@ +{ + "id": "quilt-loader-0.26.4-1.20.1", + "inheritsFrom": "1.20.1", + "type": "release", + "mainClass": "org.quiltmc.loader.impl.launch.knot.KnotClient", + "arguments": { + "game": [] + }, + "libraries": [ + { + "name": "net.fabricmc:tiny-mappings-parser:0.3.0+build.17", + "url": "https://maven.fabricmc.net/" + }, + { + "name": "net.fabricmc:sponge-mixin:0.15.3+mixin.0.8.7", + "url": "https://maven.fabricmc.net/" + }, + { + "name": "net.fabricmc:tiny-remapper:0.10.4", + "url": "https://maven.fabricmc.net/" + }, + { + "name": "net.fabricmc:access-widener:2.1.0", + "url": "https://maven.fabricmc.net/" + }, + { + "name": "org.quiltmc:quilt-json5:1.0.4+final", + "url": "https://maven.quiltmc.org/repository/release/" + }, + { + "name": "org.ow2.asm:asm:9.6", + "url": "https://maven.fabricmc.net/" + }, + { + "name": "org.ow2.asm:asm-analysis:9.6", + "url": "https://maven.fabricmc.net/" + }, + { + "name": "org.ow2.asm:asm-commons:9.6", + "url": "https://maven.fabricmc.net/" + }, + { + "name": "org.ow2.asm:asm-tree:9.6", + "url": "https://maven.fabricmc.net/" + }, + { + "name": "org.ow2.asm:asm-util:9.6", + "url": "https://maven.fabricmc.net/" + }, + { + "name": "org.quiltmc:quilt-config:1.3.1", + "url": "https://maven.quiltmc.org/repository/release/" + }, + { + "name": "org.quiltmc:quilt-loader:0.26.4", + "url": "https://maven.quiltmc.org/repository/release/" + }, + { + "name": "org.quiltmc:hashed:1.20.1", + "url": "https://maven.quiltmc.org/repository/release/" + }, + { + "name": "net.fabricmc:intermediary:1.20.1", + "url": "https://maven.fabricmc.net/" + } + ], + "releaseTime": "2023-06-12T13:25:51+00:00", + "time": "2026-09-15T06:42:41+00:00" +} \ No newline at end of file