diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallProfile.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallProfile.java new file mode 100644 index 000000000..3cd83c53a --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallProfile.java @@ -0,0 +1,183 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2019 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.download.forge; + +import org.jackhuang.hmcl.game.Artifact; +import org.jackhuang.hmcl.game.Library; +import org.jackhuang.hmcl.util.Immutable; +import org.jackhuang.hmcl.util.function.ExceptionalFunction; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Immutable +public class ForgeNewInstallProfile { + + private final int spec; + private final String minecraft; + private final String json; + private final List libraries; + private final List processors; + private final Map data; + + public ForgeNewInstallProfile(int spec, String minecraft, String json, List libraries, List processors, Map data) { + this.spec = spec; + this.minecraft = minecraft; + this.json = json; + this.libraries = libraries; + this.processors = processors; + this.data = data; + } + + /** + * Specification for install_profile.json. + */ + public int getSpec() { + return spec; + } + + /** + * Vanilla game version that this installer supports. + */ + public String getMinecraft() { + return minecraft; + } + + /** + * Version json to be installed. + * @return path of the version json relative to the installer JAR file. + */ + public String getJson() { + return json; + } + + /** + * Libraries that processors depend on. + * @return the required dependencies. + */ + public List getLibraries() { + return libraries == null ? Collections.emptyList() : libraries; + } + + /** + * Tasks to be executed to setup modded environment. + */ + public List getProcessors() { + if (processors == null) return Collections.emptyList(); + return processors.stream().filter(p -> p.isSide("client")).collect(Collectors.toList()); + } + + /** + * Data for processors. + */ + public Map getData() { + if (data == null) + return new HashMap<>(); + + return data.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().getClient())); + } + + public static class Processor { + private final List sides; + private final Artifact jar; + private final List classpath; + private final List args; + private final Map outputs; + + public Processor(List sides, Artifact jar, List classpath, List args, Map outputs) { + this.sides = sides; + this.jar = jar; + this.classpath = classpath; + this.args = args; + this.outputs = outputs; + } + + /** + * Check which side this processor should be run on. We only support client install currently. + * @param side can be one of "client", "server", "extract". + * @return true if the processor can run on the side. + */ + public boolean isSide(String side) { + return sides == null || sides.contains(side); + } + + /** + * The executable jar of this processor task. Will be executed in installation process. + * @return the artifact path of executable jar. + */ + public Artifact getJar() { + return jar; + } + + /** + * The dependencies of this processor task. + * @return the artifact path of dependencies. + */ + public List getClasspath() { + return classpath == null ? Collections.emptyList() : classpath; + } + + /** + * Arguments to pass to the processor jar. + * Each item can be in one of the following formats: + * [artifact]: An artifact path, used for locating files. + * {entry}: Get corresponding value of the entry in {@link ForgeNewInstallProfile#getData()} + * {MINECRAFT_JAR}: path of the Minecraft jar. + * {SIDE}: values other than "client" will be ignored. + * @return arguments to pass to the processor jar. + * @see ForgeNewInstallTask#parseLiteral(String, Map, ExceptionalFunction) + */ + public List getArgs() { + return args == null ? Collections.emptyList() : args; + } + + /** + * File-checksum pairs, used for verifying the output file is correct. + * Arguments to pass to the processor jar. + * Keys can be in one of [artifact] or {entry}. Should be file path. + * Values can be in one of {entry} or 'literal'. Should be SHA-1 checksum. + * @return files output from this processor. + * @see ForgeNewInstallTask#parseLiteral(String, Map, ExceptionalFunction) + */ + public Map getOutputs() { + return outputs == null ? Collections.emptyMap() : outputs; + } + } + + public static class Datum { + private final String client; + + public Datum(String client) { + this.client = client; + } + + /** + * Can be in the following formats: + * [value]: An artifact path. + * 'value': A string literal. + * value: A file in the installer package, to be extracted to a temp folder, and then have the absolute path in replacements. + * @return Value to use for the client install + */ + public String getClient() { + return client; + } + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java new file mode 100644 index 000000000..2e9f51f92 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java @@ -0,0 +1,249 @@ +package org.jackhuang.hmcl.download.forge; + +import org.jackhuang.hmcl.download.DefaultDependencyManager; +import org.jackhuang.hmcl.download.game.GameLibrariesTask; +import org.jackhuang.hmcl.game.*; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.task.TaskResult; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.function.ExceptionalFunction; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.io.ChecksumMismatchException; +import org.jackhuang.hmcl.util.io.CompressingUtils; +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.platform.CommandBuilder; +import org.jackhuang.hmcl.util.platform.JavaVersion; +import org.jackhuang.hmcl.util.platform.OperatingSystem; + +import java.io.BufferedReader; +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; +import java.util.jar.Attributes; +import java.util.jar.JarFile; +import java.util.stream.Collectors; + +import static org.jackhuang.hmcl.util.DigestUtils.digest; +import static org.jackhuang.hmcl.util.Hex.encodeHex; +import static org.jackhuang.hmcl.util.Logging.LOG; + +public class ForgeNewInstallTask extends TaskResult { + + private final DefaultDependencyManager dependencyManager; + private final DefaultGameRepository gameRepository; + private final Version version; + private final Path installer; + private final List dependents = new LinkedList<>(); + private final List dependencies = new LinkedList<>(); + + private ForgeNewInstallProfile profile; + private Version forgeVersion; + + public ForgeNewInstallTask(DefaultDependencyManager dependencyManager, Version version, Path installer) { + this.dependencyManager = dependencyManager; + this.gameRepository = dependencyManager.getGameRepository(); + this.version = version; + this.installer = installer; + } + + private String parseLiteral(String literal, Map var, ExceptionalFunction plainConverter) throws E { + if (StringUtils.isSurrounded(literal, "{", "}")) + return var.get(StringUtils.removeSurrounding(literal, "{", "}")); + else if (StringUtils.isSurrounded(literal, "'", "'")) + return StringUtils.removeSurrounding(literal, "'"); + else if (StringUtils.isSurrounded(literal, "[", "]")) + return gameRepository.getArtifactFile(version, new Artifact(StringUtils.removeSurrounding(literal, "[", "]"))).toString(); + else + return plainConverter.apply(literal); + } + + @Override + public Collection getDependents() { + return dependents; + } + + @Override + public List getDependencies() { + return dependencies; + } + + @Override + public String getId() { + return "version"; + } + + @Override + public boolean doPreExecute() { + return true; + } + + @Override + public void preExecute() throws Exception { + try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(installer)) { + profile = JsonUtils.fromNonNullJson(FileUtils.readText(fs.getPath("install_profile.json")), ForgeNewInstallProfile.class); + forgeVersion = JsonUtils.fromNonNullJson(FileUtils.readText(fs.getPath(profile.getJson())), Version.class); + + for (Library library : profile.getLibraries()) { + Path file = fs.getPath("maven").resolve(library.getPath()); + if (Files.exists(file)) { + Path dest = gameRepository.getLibraryFile(version, library).toPath(); + FileUtils.copyFile(file, dest); + } + } + } + + dependents.add(new GameLibrariesTask(dependencyManager, version, profile.getLibraries())); + } + + @Override + public void execute() throws Exception { + Path temp = Files.createTempDirectory("forge_installer"); + int finished = 0; + try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(installer)) { + List processors = profile.getProcessors(); + Map data = profile.getData(); + + updateProgress(0, processors.size()); + + for (Map.Entry entry : data.entrySet()) { + String key = entry.getKey(); + String value = entry.getValue(); + + data.put(key, parseLiteral(value, + Collections.emptyMap(), + str -> { + Path dest = temp.resolve(str); + FileUtils.copyFile(fs.getPath(str), dest); + return dest.toString(); + })); + } + + data.put("SIDE", "client"); + data.put("MINECRAFT_JAR", gameRepository.getVersionJar(version).getAbsolutePath()); + + for (ForgeNewInstallProfile.Processor processor : processors) { + Map outputs = new HashMap<>(); + boolean miss = false; + + for (Map.Entry entry : processor.getOutputs().entrySet()) { + String key = entry.getKey(); + String value = entry.getValue(); + + key = parseLiteral(key, data, ExceptionalFunction.identity()); + value = parseLiteral(value, data, ExceptionalFunction.identity()); + + if (key == null || value == null) { + throw new Exception("Invalid forge installation configuration"); + } + + outputs.put(key, value); + + Path artifact = Paths.get(key); + if (Files.exists(artifact)) { + String code; + try (InputStream stream = Files.newInputStream(artifact)) { + code = encodeHex(digest("SHA-1", stream)); + } + + if (!Objects.equals(code, value)) { + Files.delete(artifact); + LOG.info("Found existing file is not valid: " + artifact); + + miss = true; + } + } else { + miss = true; + } + } + + if (!processor.getOutputs().isEmpty() && !miss) { + continue; + } + + Path jar = gameRepository.getArtifactFile(version, processor.getJar()); + if (!Files.isRegularFile(jar)) + throw new FileNotFoundException("Game processor file not found, should be downloaded in preprocess"); + + String mainClass; + try (JarFile jarFile = new JarFile(jar.toFile())) { + mainClass = jarFile.getManifest().getMainAttributes().getValue(Attributes.Name.MAIN_CLASS); + } + + if (StringUtils.isBlank(mainClass)) + throw new Exception("Game processor jar does not have main class " + jar); + + List command = new ArrayList<>(); + command.add(JavaVersion.fromCurrentEnvironment().getBinary().toString()); + command.add("-cp"); + + List classpath = new ArrayList<>(processor.getClasspath().size() + 1); + for (Artifact artifact : processor.getClasspath()) { + Path file = gameRepository.getArtifactFile(version, artifact); + if (!Files.isRegularFile(file)) + throw new Exception("Game processor dependency missing"); + classpath.add(file.toString()); + } + classpath.add(jar.toString()); + command.add(String.join(OperatingSystem.PATH_SEPARATOR, classpath)); + + command.add(mainClass); + + List args = processor.getArgs().stream().map(arg -> { + String parsed = parseLiteral(arg, data, ExceptionalFunction.identity()); + if (parsed == null) + throw new IllegalStateException("Invalid forge installation configuration"); + return parsed; + }).collect(Collectors.toList()); + + command.addAll(args); + + LOG.info("Executing external processor " + processor.getJar().toString() + ", command line: " + new CommandBuilder().addAll(command).toString()); + Process process = new ProcessBuilder(command).start(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + for (String line; (line = reader.readLine()) != null;) { + System.out.println(line); + } + } + int exitCode = process.waitFor(); + if (exitCode != 0) + throw new IllegalStateException("Game processor exited abnormally"); + + for (Map.Entry entry : outputs.entrySet()) { + Path artifact = Paths.get(entry.getKey()); + if (!Files.isRegularFile(artifact)) + throw new FileNotFoundException("File missing: " + artifact); + + String code; + try (InputStream stream = Files.newInputStream(artifact)) { + code = encodeHex(digest("SHA-1", stream)); + } + + if (!Objects.equals(code, entry.getValue())) { + Files.delete(artifact); + throw new ChecksumMismatchException("SHA-1", entry.getValue(), code); + } + } + + updateProgress(++finished, processors.size()); + } + } + + // resolve the version + SimpleVersionProvider provider = new SimpleVersionProvider(); + provider.addVersion(version); + + setResult(forgeVersion + .setInheritsFrom(version.getId()) + .resolve(provider).setJar(null) + .setId(version.getId()).setLogging(Collections.emptyMap())); + + dependencies.add(dependencyManager.checkLibraryCompletionAsync(forgeVersion)); + + FileUtils.deleteDirectory(temp.toFile()); + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameLibrariesTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameLibrariesTask.java index 58649803b..35519cec7 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameLibrariesTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameLibrariesTask.java @@ -36,6 +36,7 @@ public final class GameLibrariesTask extends Task { private final AbstractDependencyManager dependencyManager; private final Version version; + private final List libraries; private final List dependencies = new LinkedList<>(); /** @@ -45,8 +46,20 @@ public final class GameLibrariesTask extends Task { * @param version the resolved version */ public GameLibrariesTask(AbstractDependencyManager dependencyManager, Version version) { + this(dependencyManager, version, version.getLibraries()); + } + + /** + * Constructor. + * + * @param dependencyManager the dependency manager that can provides {@link org.jackhuang.hmcl.game.GameRepository} + * @param version the resolved version + */ + public GameLibrariesTask(AbstractDependencyManager dependencyManager, Version version, List libraries) { this.dependencyManager = dependencyManager; this.version = version; + this.libraries = libraries; + setSignificance(TaskSignificance.MODERATE); } @@ -57,7 +70,7 @@ public final class GameLibrariesTask extends Task { @Override public void execute() { - version.getLibraries().stream().filter(Library::appliesToCurrentEnvironment).forEach(library -> { + libraries.stream().filter(Library::appliesToCurrentEnvironment).forEach(library -> { File file = dependencyManager.getGameRepository().getLibraryFile(version, library); if (!file.exists()) dependencies.add(new LibraryDownloadTask(dependencyManager, file, library)); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/Artifact.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/Artifact.java new file mode 100644 index 000000000..34419f2b2 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/Artifact.java @@ -0,0 +1,97 @@ +package org.jackhuang.hmcl.game; + +import com.google.gson.*; +import com.google.gson.annotations.JsonAdapter; +import org.jackhuang.hmcl.util.Immutable; + +import java.lang.reflect.Type; +import java.nio.file.Path; + +@Immutable +@JsonAdapter(Artifact.Serializer.class) +public final class Artifact { + + private final String group; + private final String name; + private final String version; + private final String classifier; + private final String extension; + + private final String descriptor; + private final String fileName; + private final String path; + + public Artifact(String descriptor) { + this.descriptor = descriptor; + + String[] arr = descriptor.split(":", 4); + if (arr.length != 3 && arr.length != 4) + throw new IllegalArgumentException("Artifact name is malformed"); + + String ext = null; + int last = arr.length - 1; + String[] splitted = arr[last].split("@"); + if (splitted.length == 2) { + arr[last] = splitted[0]; + ext = splitted[1]; + } else if (splitted.length > 2) { + throw new IllegalArgumentException("Artifact name is malformed"); + } + + this.group = arr[0].replace("\\", "/"); + this.name = arr[1]; + this.version = arr[2]; + this.classifier = arr.length >= 4 ? arr[3] : null; + this.extension = ext == null ? "jar" : ext; + + String fileName = this.name + "-" + this.version; + if (classifier != null) fileName += "-" + this.classifier; + this.fileName = fileName + "." + this.extension; + this.path = String.format("%s/%s/%s/%s", this.group.replace(".", "/"), this.name, this.version, this.fileName); + } + + public String getGroup() { + return group; + } + + public String getName() { + return name; + } + + public String getVersion() { + return version; + } + + public String getClassifier() { + return classifier; + } + + public String getExtension() { + return extension; + } + + public String getFileName() { + return fileName; + } + + public Path getPath(Path root) { + return root.resolve(path); + } + + @Override + public String toString() { + return descriptor; + } + + public static class Serializer implements JsonDeserializer, JsonSerializer { + @Override + public JsonElement serialize(Artifact src, Type typeOfSrc, JsonSerializationContext context) { + return src == null ? JsonNull.INSTANCE : new JsonPrimitive(src.toString()); + } + + @Override + public Artifact deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { + return json.isJsonPrimitive() ? new Artifact(json.getAsJsonPrimitive().getAsString()) : null; + } + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java index 42069173d..c42edb4b9 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -20,19 +20,19 @@ package org.jackhuang.hmcl.game; import com.google.gson.JsonParseException; import org.jackhuang.hmcl.event.*; import org.jackhuang.hmcl.mod.ModManager; -import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.util.ToStringBuilder; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.FileUtils; -import static org.jackhuang.hmcl.util.Logging.LOG; - import java.io.File; import java.io.IOException; +import java.nio.file.Path; import java.util.*; import java.util.logging.Level; import java.util.stream.Stream; +import static org.jackhuang.hmcl.util.Logging.LOG; + /** * An implementation of classic Minecraft game repository. * @@ -82,6 +82,10 @@ public class DefaultGameRepository implements GameRepository { return new File(getBaseDirectory(), "libraries/" + lib.getPath()); } + public Path getArtifactFile(Version version, Artifact artifact) { + return artifact.getPath(getBaseDirectory().toPath().resolve("libraries")); + } + @Override public File getRunDirectory(String id) { return getBaseDirectory(); @@ -265,10 +269,8 @@ public class DefaultGameRepository implements GameRepository { if (EventBus.EVENT_BUS.fireEvent(new RefreshingVersionsEvent(this)) == Event.Result.DENY) return; - Schedulers.newThread().schedule(() -> { - refreshVersionsImpl(); - EventBus.EVENT_BUS.fireEvent(new RefreshedVersionsEvent(this)); - }); + refreshVersionsImpl(); + EventBus.EVENT_BUS.fireEvent(new RefreshedVersionsEvent(this)); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/StringUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/StringUtils.java index aaf748381..354950a0f 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/StringUtils.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/StringUtils.java @@ -17,6 +17,8 @@ */ package org.jackhuang.hmcl.util; +import org.jackhuang.hmcl.util.platform.OperatingSystem; + import java.io.ByteArrayOutputStream; import java.io.PrintStream; import java.util.Collection; @@ -24,8 +26,6 @@ import java.util.LinkedList; import java.util.List; import java.util.StringTokenizer; -import org.jackhuang.hmcl.util.platform.OperatingSystem; - /** * * @author huangyuhui @@ -128,6 +128,10 @@ public final class StringUtils { return index == -1 ? missingDelimiterValue : str.substring(index + delimiter.length()); } + public static boolean isSurrounded(String str, String prefix, String suffix) { + return str.startsWith(prefix) && str.endsWith(suffix); + } + public static String removeSurrounding(String str, String delimiter) { return removeSurrounding(str, delimiter, delimiter); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/function/ExceptionalFunction.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/function/ExceptionalFunction.java index 082e9f823..59a1aa107 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/function/ExceptionalFunction.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/function/ExceptionalFunction.java @@ -23,4 +23,8 @@ package org.jackhuang.hmcl.util.function; */ public interface ExceptionalFunction { R apply(T t) throws E; + + static ExceptionalFunction identity() { + return t -> t; + } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java index 2b6b2856d..52fe65a8f 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java @@ -275,6 +275,22 @@ public final class FileUtils { Files.copy(srcFile.toPath(), destFile.toPath(), StandardCopyOption.COPY_ATTRIBUTES, StandardCopyOption.REPLACE_EXISTING); } + public static void copyFile(Path srcFile, Path destFile) + throws IOException { + Objects.requireNonNull(srcFile, "Source must not be null"); + Objects.requireNonNull(destFile, "Destination must not be null"); + if (!Files.exists(srcFile)) + throw new FileNotFoundException("Source '" + srcFile + "' does not exist"); + if (Files.isDirectory(srcFile)) + throw new IOException("Source '" + srcFile + "' exists but is a directory"); + Path parentFile = destFile.getParent(); + Files.createDirectories(parentFile); + if (Files.exists(destFile) && !Files.isWritable(destFile)) + throw new IOException("Destination '" + destFile + "' exists but is read-only"); + + Files.copy(srcFile, destFile, StandardCopyOption.COPY_ATTRIBUTES, StandardCopyOption.REPLACE_EXISTING); + } + public static void moveFile(File srcFile, File destFile) throws IOException { copyFile(srcFile, destFile); srcFile.delete();