Support automatic installation of Forge 1.13.

This commit is contained in:
huanghongxun 2019-02-10 10:49:48 +08:00
parent 426ea607e8
commit e8f5088049
8 changed files with 578 additions and 10 deletions

View File

@ -0,0 +1,183 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2019 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
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<Library> libraries;
private final List<Processor> processors;
private final Map<String, Datum> data;
public ForgeNewInstallProfile(int spec, String minecraft, String json, List<Library> libraries, List<Processor> processors, Map<String, Datum> 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<Library> getLibraries() {
return libraries == null ? Collections.emptyList() : libraries;
}
/**
* Tasks to be executed to setup modded environment.
*/
public List<Processor> getProcessors() {
if (processors == null) return Collections.emptyList();
return processors.stream().filter(p -> p.isSide("client")).collect(Collectors.toList());
}
/**
* Data for processors.
*/
public Map<String, String> 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<String> sides;
private final Artifact jar;
private final List<Artifact> classpath;
private final List<String> args;
private final Map<String, String> outputs;
public Processor(List<String> sides, Artifact jar, List<Artifact> classpath, List<String> args, Map<String, String> 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<Artifact> 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<String> 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<String, String> 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;
}
}
}

View File

@ -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<Version> {
private final DefaultDependencyManager dependencyManager;
private final DefaultGameRepository gameRepository;
private final Version version;
private final Path installer;
private final List<Task> dependents = new LinkedList<>();
private final List<Task> 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 <E extends Exception> String parseLiteral(String literal, Map<String, String> var, ExceptionalFunction<String, String, E> 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<Task> getDependents() {
return dependents;
}
@Override
public List<Task> 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<ForgeNewInstallProfile.Processor> processors = profile.getProcessors();
Map<String, String> data = profile.getData();
updateProgress(0, processors.size());
for (Map.Entry<String, String> 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<String, String> outputs = new HashMap<>();
boolean miss = false;
for (Map.Entry<String, String> 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<String> command = new ArrayList<>();
command.add(JavaVersion.fromCurrentEnvironment().getBinary().toString());
command.add("-cp");
List<String> 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<String> 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<String, String> 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());
}
}

View File

@ -36,6 +36,7 @@ public final class GameLibrariesTask extends Task {
private final AbstractDependencyManager dependencyManager;
private final Version version;
private final List<Library> libraries;
private final List<Task> dependencies = new LinkedList<>();
/**
@ -45,8 +46,20 @@ public final class GameLibrariesTask extends Task {
* @param version the <b>resolved</b> 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 <b>resolved</b> version
*/
public GameLibrariesTask(AbstractDependencyManager dependencyManager, Version version, List<Library> 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));

View File

@ -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<Artifact>, JsonSerializer<Artifact> {
@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;
}
}
}

View File

@ -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

View File

@ -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);
}

View File

@ -23,4 +23,8 @@ package org.jackhuang.hmcl.util.function;
*/
public interface ExceptionalFunction<T, R, E extends Exception> {
R apply(T t) throws E;
static <T, E extends RuntimeException> ExceptionalFunction<T, T, E> identity() {
return t -> t;
}
}

View File

@ -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();