Skip to content

Commit

Permalink
Merge pull request #140 from 3arthqu4ke/1.9.4
Browse files Browse the repository at this point in the history
1.9.4
  • Loading branch information
3arthqu4ke authored Apr 6, 2024
2 parents 2da678f + d6bd626 commit 79b4be1
Show file tree
Hide file tree
Showing 30 changed files with 394 additions and 62 deletions.
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
[![Docker Image Size](https://badgen.net/docker/size/3arthqu4ke/headlessmc?icon=docker&label=image%20size)](https://hub.docker.com/r/3arthqu4ke/headlessmc/)
![Github last-commit](https://img.shields.io/github/last-commit/3arthqu4ke/HeadlessMc)

> :warning: HeadlessMc will not allow you to play without having bought Minecraft! Accounts will always be validated. Offline accounts can only be used to run the game headlessly in CI/CD pipelines.
> NOT AN OFFICIAL MINECRAFT PRODUCT. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT.
>
> HeadlessMc will not allow you to play without having bought Minecraft!
> Accounts will always be validated.
> Offline accounts can only be used to run the game headlessly in CI/CD pipelines.
HeadlessMc allows you to launch Minecraft from the command line. It is also able to instrument the game: before
launch the bytecode of the games libraries can be modified. HeadlessMc aims to use this feature to
Expand Down Expand Up @@ -41,6 +45,9 @@ the most important commands:
| forge | Installs Minecraft Forge. | \<version/id\> \<--uid\> |
| fabric | Installs Fabric. | \<version/id\> |

To launch the game in headless mode type use the launch command with the `-lwjgl` flag:
`launch <version> -lwjgl`

Arguments passed to commands have to be separated using spaces. If you want to pass an Argument which contains spaces
you need to escape it using quotation marks, like this:
`"argument with spaces"`. Quotation marks and backslashes can be escaped by using a backslash.
Expand Down
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ allprojects {
}

dependencies {
compileOnly 'org.jetbrains:annotations:24.1.0'

compileOnly 'org.projectlombok:lombok:1.18.30'
annotationProcessor 'org.projectlombok:lombok:1.18.30'

Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1 +1 @@
project_version=1.9.3
project_version=1.9.4
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import me.earth.headlessmc.api.command.Command;
import me.earth.headlessmc.api.command.CommandContext;
import me.earth.headlessmc.api.command.CommandException;
import me.earth.headlessmc.config.HmcProperties;
import org.jetbrains.annotations.NotNull;

import java.util.*;

Expand Down Expand Up @@ -39,6 +41,9 @@ protected void executeCommand(Command cmd, String... args) {
cmd.execute(args);
} catch (CommandException commandException) {
log.log(commandException.getMessage());
if (log.getConfig().get(HmcProperties.EXIT_ON_FAILED_COMMAND, false)) {
System.exit(-1);
}
}
}

Expand All @@ -65,10 +70,14 @@ protected void fail(String... args) {
Arrays.toString(args), command.getName()));
}
}

if (log.getConfig().get(HmcProperties.EXIT_ON_FAILED_COMMAND, false)) {
System.exit(-1);
}
}

@Override
public Iterator<Command> iterator() {
public @NotNull Iterator<Command> iterator() {
return commands.iterator();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,9 @@ public interface HmcProperties {
*/
Property<String> LOGLEVEL = string("hmc.loglevel");

/**
* Quits on a failed command. For more strictness in CI/CD pipelines.
*/
Property<Boolean> EXIT_ON_FAILED_COMMAND = bool("hmc.exit.on.failed.command");

}
Original file line number Diff line number Diff line change
Expand Up @@ -47,17 +47,22 @@ public String build() {
? new ArrayList<>(Collections.singletonList("-"))
: this.elements.stream()
.map(e.function)
.map(str -> str == null ? "null" : str)
.collect(Collectors.toList());
entries.add(0, e.name);
entries.add(0, String.valueOf(e.name));
// let's hope the Terminal uses a fixed-width font
columnWidths.add(entries.stream()
.map(String::length)
.max(Integer::compareTo)
.get());
return entries;
}).collect(Collectors.toList());
return build(columns, columnWidths);
}

private String build(List<List<String>> columns, List<Integer> columnWidths) {
StringBuilder builder = new StringBuilder();
for (int i = 0; columns.size() > 0 && i < columns.get(0).size(); i++) {
for (int i = 0; !columns.isEmpty() && i < columns.get(0).size(); i++) {
for (int j = 0; j < columns.size(); j++) {
String entry = columns.get(j).get(i);
int width = columnWidths.get(j);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
@Getter
@RequiredArgsConstructor
public class Launcher implements HeadlessMc {
public static final String VERSION = "1.9.3";
public static final String VERSION = "1.9.4";

@Delegate
private final HeadlessMc headlessMc;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,13 @@ public interface LauncherProperties extends HmcProperties {
Property<Boolean> OFFLINE = bool("hmc.offline");
Property<Boolean> RE_THROW_LAUNCH_EXCEPTIONS = bool("hmc.rethrow.launch.exceptions");

// TODO: also check hashes for the libraries?
Property<Long> ASSETS_DELAY = number("hmc.assets.delay");
Property<Long> ASSETS_RETRIES = number("hmc.assets.retries");
Property<Boolean> ASSETS_PARALLEL = bool("hmc.assets.parallel");
Property<Boolean> DUMMY_ASSETS = bool("hmc.assets.dummy");
Property<Boolean> ASSETS_CHECK_HASH = bool("hmc.assets.check.hash");
Property<Boolean> ASSETS_CHECK_FILE_HASH = bool("hmc.assets.check.file.hash");
Property<Boolean> ASSETS_BACKOFF = bool("hmc.assets.backoff");

}
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ private void runHeadlessMc(String... args) throws IOException {
val accounts = new AccountManager(accountStore, validator, new OfflineChecker(configs));

val launcher = new Launcher(hmc, versions, mcFiles, files,
new ProcessFactory(mcFiles, os), configs,
new ProcessFactory(mcFiles, configs, os), configs,
javas, accounts, validator);
LauncherApi.setLauncher(launcher);
deleteOldFiles(launcher);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import java.io.IOException;
import java.nio.file.Paths;

// TODO: move to Paths?!?!?!?!?!?!
// TODO: Why were we using Files in the first place?
@CustomLog
public class FileManager {
private final String base;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,78 +1,202 @@
package me.earth.headlessmc.launcher.launch;

import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import lombok.CustomLog;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.val;
import me.earth.headlessmc.api.config.HasConfig;
import me.earth.headlessmc.launcher.LauncherProperties;
import me.earth.headlessmc.launcher.files.FileManager;
import me.earth.headlessmc.launcher.util.IOUtil;
import me.earth.headlessmc.launcher.util.JsonUtil;

import java.io.File;
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Stream;

// TODO: support legacy assets!!!
// TODO: whats the map_to_resources thing?
// TODO: can we download in parallel?
// TODO: this is awful Spaghetti
@CustomLog
@RequiredArgsConstructor
class AssetsDownloader {
private static final String URL =
"https://resources.download.minecraft.net/";
private static final String URL = "https://resources.download.minecraft.net/";
private final DummyAssets dummyAssets = new DummyAssets();
private final FileManager files;
private final HasConfig config;
private final String url;
private final String id;

public void download() throws IOException {
// TODO: this could probably be done in a better way
val index = new File(files.getDir("assets") + File.separator
+ "indexes" + File.separator + id + ".json");
if (!index.exists()) {
val index = files.getDir("assets").toPath().resolve("indexes").resolve(id + ".json");
if (!Files.exists(index)) {
log.info("Downloading assets from " + url);
IOUtil.download(url, index.getAbsolutePath());
IOUtil.download(url, index.toAbsolutePath().toString());
}

val objects = JsonUtil.getObject(JsonUtil.fromFile(index), "objects");
val objects = JsonUtil.getObject(JsonUtil.fromFile(index.toFile()), "objects");
if (objects == null || !objects.isJsonObject()) {
throw new IOException("Couldn't read contents of "
+ index.getAbsolutePath());
throw new IOException("Couldn't read contents of " + index.toAbsolutePath());
}

for (Map.Entry<String, JsonElement> entry : objects.getAsJsonObject()
.entrySet()) {
log.debug("Checking " + entry.getKey() + "...");
// TODO: we trust a lot in this always having the right format!
downloadAsset(entry.getKey(), entry.getValue()
.getAsJsonObject()
.get("hash").getAsString());
AtomicReference<IOException> failed = new AtomicReference<>();
AtomicInteger count = new AtomicInteger();
int total = objects.getAsJsonObject().size();
boolean parallel = config.getConfig().get(LauncherProperties.ASSETS_PARALLEL, true);
Stream<Map.Entry<String, JsonElement>> stream =
parallel ? objects.getAsJsonObject().entrySet().parallelStream()
: objects.getAsJsonObject().entrySet().stream();
long time = System.currentTimeMillis();
//noinspection ResultOfMethodCallIgnored
stream.anyMatch(entry -> {
downloadAsset(entry, total, count, failed);
return failed.get() != null; // end stream early if an asset failed completely
});

time = System.currentTimeMillis() - time;
log.info("Downloading assets took " + time + "ms, parallel: " + parallel);
if (failed.get() != null) {
throw failed.get();
}
}

private void downloadAsset(Map.Entry<String, JsonElement> entry, int total, AtomicInteger count, AtomicReference<IOException> failed) {
int downloaded = count.incrementAndGet();
String percentage = String.format("%d", (downloaded * 100 / total)) + "%";
String progress = percentage + " (" + downloaded + "/" + total + ")";
log.debug(progress + " Checking " + entry.getKey());

JsonObject jo = entry.getValue().getAsJsonObject();
int tries = Math.max(1, config.getConfig().get(LauncherProperties.ASSETS_RETRIES, 3L).intValue());
IOException exception = null;
for (int i = 0; i < tries; i++) {
try {
long wait = config.getConfig().get(LauncherProperties.ASSETS_DELAY, 0L);
if (config.getConfig().get(LauncherProperties.ASSETS_BACKOFF, true)) {
wait *= (i + 1); // increase wait time
}

if (wait > 0L) {
Thread.sleep(wait);
}

downloadAsset(progress,
entry.getKey(),
jo.get("hash").getAsString(),
jo.get("size") == null ? -1 : jo.get("size").getAsLong(),
jo.get("map_to_resources") != null && jo.get("map_to_resources").getAsBoolean());
return; // downloaded successfully, return
} catch (IOException e) {
e.printStackTrace();
log.warning(progress + " Failed to download asset " + entry.getKey() + ", retrying... (" + e.getMessage() + ")");
exception = e;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
exception = new IOException("Thread interrupted");
}
}

log.error("Failed to download asset " + entry.getKey() + " after " + tries + " tries!");
if (exception != null) {
failed.set(exception);
}
}

private void downloadAsset(String name, String hash) throws IOException {
private void downloadAsset(String progress, String name, String hash, long size, boolean mapToResources) throws IOException {
val firstTwo = hash.substring(0, 2);
val to = files.getDir("assets") + File.separator + "objects"
+ File.separator + firstTwo + File.separator + hash;

val file = new File(to);
if (!file.exists()) {
val from = URL + firstTwo + "/" + hash;
log.info("Downloading: " + name + " from " + from + " to " + to);
IOUtil.download(from, to);
val to = files.getDir("assets").toPath().resolve("objects").resolve(firstTwo).resolve(hash);
Path file = getAssetsFile(name, to, hash, size);
if (!Files.exists(file)) {
byte[] bytes = null;
if (config.getConfig().get(LauncherProperties.DUMMY_ASSETS, false)) {
log.debug("Using dummy asset for " + name);
bytes = dummyAssets.getResource(name);
}

if (bytes == null) {
val from = URL + firstTwo + "/" + hash;
log.info(progress + " Downloading: " + name + " from " + from + " to " + to);
// TODO: user-agent? GZIPInputStream?
bytes = IOUtil.downloadBytes(from);
if (config.getConfig().get(LauncherProperties.ASSETS_CHECK_HASH, true)
&& !checkIntegrity(size, hash, bytes)) {
throw new IOException("Failed integrity check on " + name + " from " + from);
}
}

Files.createDirectories(to.getParent());
try (OutputStream os = Files.newOutputStream(to)) {
os.write(bytes);
}
}

if ("pre-1.6".equals(id)) {
val legacy = new File(files.getDir("assets") + File.separator
+ "virtual" + File.separator + "legacy"
+ File.separator + name);
// TODO: old versions have the map_to_resource thing, copy to resources
val legacy = files.getDir("assets").toPath().resolve("virtual").resolve("legacy").resolve(name);
log.info("Legacy version, copying to " + legacy);
if (!legacy.exists()) {
Files.copy(file.toPath(), legacy.toPath(),
StandardCopyOption.REPLACE_EXISTING);
if (!Files.exists(legacy)) {
Files.copy(file, legacy, StandardCopyOption.REPLACE_EXISTING);
}
}

if (mapToResources) {
val resources = files.getDir("resources").toPath().resolve(name);
log.debug("Mapping " + name + " to resources " + resources);
if (!Files.exists(resources)) {
Files.copy(file, resources, StandardCopyOption.REPLACE_EXISTING);
}
}
}

private Path getAssetsFile(String name, Path file, String hash, long size) throws IOException {
if (Files.exists(file) && config.getConfig().get(LauncherProperties.ASSETS_CHECK_FILE_HASH, false)) {
try (FileInputStream fis = new FileInputStream(file.toFile())) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
IOUtil.copy(fis, baos);
fis.close(); // < very important or we can't delete the file
if (!checkIntegrity(size, hash, baos.toByteArray())) {
log.warning("File " + file + " (" + name + ") failed integrity check, deleting...");
Files.delete(file);
}
}
}

return file;
}

@SneakyThrows
public boolean checkIntegrity(long size, String hash, byte[] bytes) {
if (size >= 0L && size != bytes.length) {
return false;
}

String byteHash = sha1(bytes);
return hash.equalsIgnoreCase(byteHash);
}

public String sha1(byte[] bytes) throws NoSuchAlgorithmException {
MessageDigest sha1 = MessageDigest.getInstance("SHA-1");
sha1.digest(bytes);
byte[] hashBytes = sha1.digest(bytes);
StringBuilder hashBuilder = new StringBuilder(hashBytes.length * 2);
for (byte b : hashBytes) {
hashBuilder.append(String.format("%02x", b));
}

return hashBuilder.toString();
}

}
Loading

0 comments on commit 79b4be1

Please sign in to comment.