diff --git a/stage0/modlauncher9/build.gradle b/stage0/modlauncher9/build.gradle index e981db8..11b2cbd 100644 --- a/stage0/modlauncher9/build.gradle +++ b/stage0/modlauncher9/build.gradle @@ -8,4 +8,7 @@ dependencies { compileOnly("cpw.mods:securejarhandler:0.9.50") compileOnly("net.sf.jopt-simple:jopt-simple:5.0.4") compileOnly("org.jetbrains:annotations:21.0.1") -} + // as of forge 37.1 for MC 1.17.1, mixin is shipped with forge + // https://github.com/MinecraftForge/MinecraftForge/commit/c8d3ad1d1c977513b75e6aa3dd81c6ed9463bcae + compileOnly("org.spongepowered:mixin:0.8.5") +} \ No newline at end of file diff --git a/stage0/modlauncher9/src/main/java/gg/essential/loader/stage0/EssentialStage0MixinPlugin.java b/stage0/modlauncher9/src/main/java/gg/essential/loader/stage0/EssentialStage0MixinPlugin.java new file mode 100644 index 0000000..d7550c6 --- /dev/null +++ b/stage0/modlauncher9/src/main/java/gg/essential/loader/stage0/EssentialStage0MixinPlugin.java @@ -0,0 +1,76 @@ +package gg.essential.loader.stage0; + +import cpw.mods.modlauncher.Launcher; +import cpw.mods.modlauncher.api.IEnvironment; +import org.objectweb.asm.tree.ClassNode; +import org.spongepowered.asm.mixin.extensibility.IMixinConfigPlugin; +import org.spongepowered.asm.mixin.extensibility.IMixinInfo; + +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.Set; + +import static gg.essential.loader.stage0.EssentialLoader.STAGE1_PKG; + +@SuppressWarnings("unused") +public class EssentialStage0MixinPlugin implements IMixinConfigPlugin { + private static final String STAGE1_CLS = STAGE1_PKG + "EssentialMixinPluginLoader"; + + public EssentialStage0MixinPlugin() throws Exception { + loadStage1(this); + } + + @Override + public void onLoad(String mixinPackage) { + } + + private static void loadStage1(Object stage0) throws Exception { + Path gameDir = Launcher.INSTANCE.environment() + .getProperty(IEnvironment.Keys.GAMEDIR.get()) + .orElseGet(() -> Paths.get(".")); + + final EssentialLoader loader = new EssentialLoader("modlauncher"); // using same variant as the transformation service + final Path stage1File = loader.loadStage1File(gameDir); + final URL stage1Url = stage1File.toUri().toURL(); + + // Create a class loader with which to load stage1 + URLClassLoader classLoader = new URLClassLoader(new URL[]{ stage1Url }, stage0.getClass().getClassLoader()); + + Class.forName(STAGE1_CLS, true, classLoader) + .getConstructor() + .newInstance(); + } + + @Override + public String getRefMapperConfig() { + return null; + } + + @Override + public boolean shouldApplyMixin(String targetClassName, String mixinClassName) { + return false; + } + + @Override + public void acceptTargets(Set myTargets, Set otherTargets) { + + } + + @Override + public List getMixins() { + return null; + } + + @Override + public void preApply(String targetClassName, ClassNode targetClass, String mixinClassName, IMixinInfo mixinInfo) { + + } + + @Override + public void postApply(String targetClassName, ClassNode targetClass, String mixinClassName, IMixinInfo mixinInfo) { + + } +} diff --git a/stage1/modlauncher/src/main/java/gg/essential/loader/stage1/EssentialTransformationServiceBase.java b/stage1/modlauncher/src/main/java/gg/essential/loader/stage1/EssentialTransformationServiceBase.java index 17936ba..15ae0b3 100644 --- a/stage1/modlauncher/src/main/java/gg/essential/loader/stage1/EssentialTransformationServiceBase.java +++ b/stage1/modlauncher/src/main/java/gg/essential/loader/stage1/EssentialTransformationServiceBase.java @@ -18,7 +18,7 @@ import java.util.stream.Collectors; public abstract class EssentialTransformationServiceBase extends DelegatingTransformationServiceBase { - private static final String KEY_LOADED = "gg.essential.loader.stage1.loaded"; + protected static final String KEY_LOADED = "gg.essential.loader.stage1.loaded"; public EssentialTransformationServiceBase( final ITransformationService stage0, diff --git a/stage1/modlauncher9/src/main/java/gg/essential/loader/stage1/EssentialMixinPluginLoader.java b/stage1/modlauncher9/src/main/java/gg/essential/loader/stage1/EssentialMixinPluginLoader.java new file mode 100644 index 0000000..5d46141 --- /dev/null +++ b/stage1/modlauncher9/src/main/java/gg/essential/loader/stage1/EssentialMixinPluginLoader.java @@ -0,0 +1,38 @@ +package gg.essential.loader.stage1; + +import cpw.mods.modlauncher.Launcher; +import cpw.mods.modlauncher.api.IEnvironment; +import cpw.mods.modlauncher.api.ITransformationService; +import cpw.mods.modlauncher.api.TypesafeMap; + +import java.nio.file.Path; +import java.nio.file.Paths; + +import static gg.essential.loader.stage1.EssentialTransformationService.MC_VERSION; +import static gg.essential.loader.stage1.EssentialTransformationServiceBase.KEY_LOADED; + +@SuppressWarnings("unused") +public class EssentialMixinPluginLoader { + + public EssentialMixinPluginLoader() throws Exception { + // Check if stage 2 has already been loaded by a transformation service + final TypesafeMap blackboard = Launcher.INSTANCE.blackboard(); + final TypesafeMap.Key LOADED = + TypesafeMap.Key.getOrCreate(blackboard, KEY_LOADED, ITransformationService.class); + if (blackboard.get(LOADED).isPresent()) { + return; + } + + final Path gameDir = Launcher.INSTANCE.environment() + .getProperty(IEnvironment.Keys.GAMEDIR.get()) + .orElse(Paths.get(".")); + + // variant and game version to match transformation service path + EssentialLoader loader = EssentialLoader.getInstance("modlauncher9", "forge_" + MC_VERSION); + loader.load(gameDir); + + loader.getStage2().getClass() + .getMethod("loadFromMixin", Path.class) + .invoke(loader.getStage2(), gameDir); + } +} diff --git a/stage1/modlauncher9/src/main/java/gg/essential/loader/stage1/EssentialTransformationService.java b/stage1/modlauncher9/src/main/java/gg/essential/loader/stage1/EssentialTransformationService.java index 9fa54d5..19e6544 100644 --- a/stage1/modlauncher9/src/main/java/gg/essential/loader/stage1/EssentialTransformationService.java +++ b/stage1/modlauncher9/src/main/java/gg/essential/loader/stage1/EssentialTransformationService.java @@ -23,7 +23,7 @@ public class EssentialTransformationService extends EssentialTransformationServi // the first one we support (stage2 will be the same for all MC versions on the same stage1 anyway) and once we are // in stage2, we can re-query at a later point as needed (but we always retain the ability to run an up-to-date // stage2 as early as possible). - private static final String MC_VERSION = "1.17.1"; + protected static final String MC_VERSION = "1.17.1"; public EssentialTransformationService(final ITransformationService stage0) throws Exception { super(stage0, FallbackTransformationService::new, "modlauncher9", MC_VERSION); diff --git a/stage2/modlauncher9/src/main/java/gg/essential/loader/stage2/DedicatedJarLoader.java b/stage2/modlauncher9/src/main/java/gg/essential/loader/stage2/DedicatedJarLoader.java new file mode 100644 index 0000000..26eb113 --- /dev/null +++ b/stage2/modlauncher9/src/main/java/gg/essential/loader/stage2/DedicatedJarLoader.java @@ -0,0 +1,78 @@ +package gg.essential.loader.stage2; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URL; +import java.net.URLConnection; +import java.nio.file.Files; +import java.nio.file.Path; + +import static java.nio.file.StandardCopyOption.ATOMIC_MOVE; + +public class DedicatedJarLoader { + private static final Logger LOGGER = LogManager.getLogger(DedicatedJarLoader.class); + private static final String BASE_URL = System.getProperty( + "essential.download.url", + System.getenv().getOrDefault("ESSENTIAL_DOWNLOAD_URL", "https://api.essential.gg/mods") + ); + private static final String VERSION_URL = BASE_URL + "/v1/essential:essential-pinned/versions/%s/platforms/%s"; + private static final String DOWNLOAD_URL = VERSION_URL + "/download"; + + protected static void downloadDedicatedJar(LoaderUI ui, Path modsDir, String gameVersion) throws IOException { + final String apiVersion = gameVersion.replace('.', '-'); + final String essentialVersion = getEssentialVersionMeta(apiVersion).get("version").getAsString(); + + final JsonObject meta = getEssentialDownloadMeta(essentialVersion, apiVersion); + final URL url = new URL(meta.get("url").getAsString()); + final URLConnection connection = url.openConnection(); + + ui.setDownloadSize(connection.getContentLength()); + + final Path target = modsDir.resolve(String.format("Essential %s (%s).jar", essentialVersion, gameVersion)); + final Path tempFile = Files.createFile(modsDir.resolve("Essential Jar Download.jar.tmp")); + + try ( + final InputStream in = connection.getInputStream(); + final OutputStream out = Files.newOutputStream(tempFile); + ) { + final byte[] buffer = new byte[1024]; + int totalRead = 0; + int read; + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + totalRead += read; + ui.setDownloaded(totalRead); + } + } finally { + try { + Files.move(tempFile, target, ATOMIC_MOVE); + } finally { + Files.deleteIfExists(tempFile); + } + } + } + + private static JsonObject getEssentialMeta(URL url) throws IOException { + String response; + try (final InputStream in = url.openStream()) { + response = new String(in.readAllBytes()); + } + JsonElement json = new JsonParser().parse(response); + return json.getAsJsonObject(); + } + + private static JsonObject getEssentialVersionMeta(String gameVersion) throws IOException { + return getEssentialMeta(new URL(String.format(VERSION_URL, "stable", gameVersion))); + } + + private static JsonObject getEssentialDownloadMeta(String essentialVersion, String gameVersion) throws IOException { + return getEssentialMeta(new URL(String.format(DOWNLOAD_URL, essentialVersion, gameVersion))); + } +} diff --git a/stage2/modlauncher9/src/main/java/gg/essential/loader/stage2/EssentialLoader.java b/stage2/modlauncher9/src/main/java/gg/essential/loader/stage2/EssentialLoader.java index 4be0bb8..f600126 100644 --- a/stage2/modlauncher9/src/main/java/gg/essential/loader/stage2/EssentialLoader.java +++ b/stage2/modlauncher9/src/main/java/gg/essential/loader/stage2/EssentialLoader.java @@ -1,8 +1,20 @@ package gg.essential.loader.stage2; import cpw.mods.modlauncher.api.ITransformationService; +import gg.essential.loader.stage2.components.ForkedRestartUI; +import gg.essential.loader.stage2.components.RestartUI; +import gg.essential.loader.stage2.jvm.ForkedJvmLoaderSwingUI; +import net.minecraftforge.fml.loading.FMLLoader; +import org.apache.logging.log4j.LogManager; +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; /** * The initial entrypoint for stage2. With ModLauncher, we cannot yet know the MC version at this point, so this is a @@ -26,4 +38,32 @@ public ITransformationService getTransformationService() { public void load() { // delayed until ModLauncher exposes the MC version } + + @SuppressWarnings("unused") // called via reflection from stage1 + public void loadFromMixin(Path gameDir) throws Exception { + final Path modsDir = gameDir.resolve("mods"); + LoaderUI ui = LoaderUI.all( + new LoaderLoggingUI().updatesEveryMillis(1000), + new ForkedJvmLoaderSwingUI().updatesEveryMillis(1000 / 60) + ); + ui.start(); + try { + DedicatedJarLoader.downloadDedicatedJar(ui, modsDir, "forge_" + FMLLoader.versionInfo().mcVersion()); + } finally { + ui.complete(); + } + List modNameMarkers = Collections.list(this.getClass().getClassLoader().getResources("META-INF/essential-loader-mod-name.txt")); + List modNames = new ArrayList<>(); + for (URL url : modNameMarkers) { + String modName = Files.readString(Paths.get(url.toURI())); + modNames.add(modName); + } + if (modNames.isEmpty()) { + modNames = List.of("Unknown"); + } + ForkedRestartUI restartUI = new ForkedRestartUI(modNames); + restartUI.show(); + restartUI.waitForClose(); + System.exit(0); + } } diff --git a/stage2/modlauncher9/src/main/java/gg/essential/loader/stage2/components/ForkedRestartUI.java b/stage2/modlauncher9/src/main/java/gg/essential/loader/stage2/components/ForkedRestartUI.java new file mode 100644 index 0000000..13bf3d6 --- /dev/null +++ b/stage2/modlauncher9/src/main/java/gg/essential/loader/stage2/components/ForkedRestartUI.java @@ -0,0 +1,71 @@ +package gg.essential.loader.stage2.components; + +import gg.essential.loader.stage2.jvm.ForkedJvm; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +public class ForkedRestartUI { + private final Logger LOGGER = LogManager.getLogger(); + private final List mods; + private ForkedJvm jvm; + + public ForkedRestartUI(List mods) { + this.mods = mods; + } + + public void show() { + try { + this.jvm = new ForkedJvm(getClass()); + + DataOutputStream out = new DataOutputStream(this.jvm.process.getOutputStream()); + for (String name : this.mods) { + out.writeBoolean(true); // signal more entries + out.writeUTF(name); + } + out.writeBoolean(false); // signal end of list + out.flush(); + } catch (IOException e) { + LOGGER.warn("Failed to fork JVM for RestartUI:", e); + } + } + + public void waitForClose() { + if (this.jvm == null) return; + + try { + this.jvm.process.getInputStream().read(); + } catch (IOException e) { + LOGGER.warn("Failed to wait for RestartUI to close:", e); + } finally { + this.jvm.close(); + this.jvm = null; + } + } + + public static void main(String[] args) throws IOException { + DataInputStream in = new DataInputStream(System.in); + + List mods = new ArrayList<>(); + while(in.readBoolean()) { + mods.add(in.readUTF()); + } + + try { + RestartUI ui = new RestartUI(mods); + ui.show(); + + ui.waitForClose(); + } catch (Throwable t) { + t.printStackTrace(); + } + + System.out.flush(); + System.out.close(); + } +} diff --git a/stage2/modlauncher9/src/main/java/gg/essential/loader/stage2/components/RestartUI.java b/stage2/modlauncher9/src/main/java/gg/essential/loader/stage2/components/RestartUI.java new file mode 100644 index 0000000..85b8771 --- /dev/null +++ b/stage2/modlauncher9/src/main/java/gg/essential/loader/stage2/components/RestartUI.java @@ -0,0 +1,95 @@ +package gg.essential.loader.stage2.components; + +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import java.awt.*; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import static gg.essential.loader.stage2.components.ButtonShadowBorder.X_SHADOW; +import static gg.essential.loader.stage2.components.ButtonShadowBorder.Y_SHADOW; + +public class RestartUI implements EssentialStyle { + private final CompletableFuture closedFuture = new CompletableFuture<>(); + + private final JFrame frame = makeFrame(it -> closedFuture.complete(null)); + + private final List mods; + + public RestartUI(List mods) { + this.mods = mods; + } + + public void show() { + final List htmlLabels = new ArrayList<>(); + + final JPanel content = makeContent(frame); + content.setBorder(new EmptyBorder(0, 60 - X_SHADOW, 0, 60 - X_SHADOW)); + content.add(Box.createHorizontalStrut(CONTENT_WIDTH)); + + htmlLabels.add(makeTitle(content, html(centered("Please restart your game to automatically install Essential.")))); + + final JLabel explanation = new JLabel(html(centered("The following mods require
Essential Mod's API:")), SwingConstants.CENTER); + explanation.setMaximumSize(new Dimension(CONTENT_WIDTH, Integer.MAX_VALUE)); + explanation.setForeground(COLOR_FOREGROUND); + explanation.setAlignmentX(Container.CENTER_ALIGNMENT); + if (Fonts.medium != null) { + explanation.setFont(Fonts.medium.deriveFont(16F)); + } + content.add(explanation); + htmlLabels.add(explanation); + + content.add(Box.createVerticalStrut(19)); + + final JPanel modList = new JPanel(); + modList.setMaximumSize(new Dimension(CONTENT_WIDTH, Integer.MAX_VALUE)); + modList.setBackground(COLOR_BACKGROUND); + modList.setLayout(new BoxLayout(modList, BoxLayout.Y_AXIS)); + content.add(modList); + + for (String modName : mods) { + final JLabel text = new JLabel(html(centered(modName)), SwingConstants.CENTER); + text.setForeground(COLOR_HIGHLIGHT); + text.setAlignmentX(Container.CENTER_ALIGNMENT); + if (Fonts.mediumItalic != null) { + text.setFont(Fonts.mediumItalic.deriveFont(16F)); + } + modList.add(text); + htmlLabels.add(text); + } + + content.add(Box.createVerticalStrut(32 - Y_SHADOW)); + + final JPanel buttons = new JPanel(); + buttons.setOpaque(false); + buttons.setLayout(new BoxLayout(buttons, BoxLayout.X_AXIS)); + buttons.add(makeButton("Quit Game", COLOR_PRIMARY_BUTTON, COLOR_BUTTON_HOVER, () -> closedFuture.complete(null))); + content.add(buttons); + + content.add(Box.createVerticalStrut(32 - Y_SHADOW)); + + frame.pack(); + + htmlLabels.forEach(this::fixJLabelHeight); + frame.pack(); + + frame.setLocationRelativeTo(null); + frame.setVisible(true); + } + + public void waitForClose() { + closedFuture.join(); + close(); + } + + public void close() { + frame.dispose(); + } + + public static void main(String[] args) { + RestartUI ui = new RestartUI(List.of("Skytils")); + ui.show(); + ui.waitForClose(); + } +}