/* * Copyright (c) 2019-2022 GeyserMC. http://geysermc.org * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. * * @author GeyserMC * @link https://github.com/GeyserMC/Geyser */ package org.geysermc.geyser.util; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.JsonNode; import lombok.Getter; import org.geysermc.geyser.GeyserImpl; import org.geysermc.geyser.network.GameProtocol; import org.geysermc.geyser.text.GeyserLocale; import java.io.*; import java.nio.file.Files; import java.nio.file.Path; import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.zip.ZipFile; /** * Implementation note: try to design processes to fail softly if the client jar can't be downloaded, * either if Mojang is down or internet access to Mojang is spotty. */ public final class AssetUtils { private static final String CLIENT_JAR_HASH_FILE = "client_jar.hash"; private static final Map ASSET_MAP = new HashMap<>(); private static VersionDownload CLIENT_JAR_INFO; private static final Queue CLIENT_JAR_TASKS = new ArrayDeque<>(); /** * Download the client jar even if the hash is correct */ private static boolean FORCE_DOWNLOAD_JAR = false; public static Asset getAsset(String name) { return ASSET_MAP.get(name); } public static boolean isAssetKnown(String name) { return ASSET_MAP.containsKey(name); } /** * Add task to be ran after the client jar is downloaded or found to be cached. * * @param required if set to true, the client jar will always be downloaded, even if a pre-existing hash is matched. * This means an asset or texture is missing. */ public static void addTask(boolean required, ClientJarTask task) { CLIENT_JAR_TASKS.add(task); FORCE_DOWNLOAD_JAR |= required; } /** * Fetch the latest versions asset cache from Mojang so we can grab the locale files later */ public static CompletableFuture generateAssetCache() { return CompletableFuture.supplyAsync(() -> { try { // Get the version manifest from Mojang VersionManifest versionManifest = GeyserImpl.JSON_MAPPER.readValue( WebUtils.getBody("https://launchermeta.mojang.com/mc/game/version_manifest.json"), VersionManifest.class); // Get the url for the latest version of the games manifest String latestInfoURL = ""; for (Version version : versionManifest.getVersions()) { if (version.getId().equals(GameProtocol.getJavaCodec().getMinecraftVersion())) { latestInfoURL = version.getUrl(); break; } } // Make sure we definitely got a version if (latestInfoURL.isEmpty()) { throw new Exception(GeyserLocale.getLocaleStringLog("geyser.locale.fail.latest_version")); } // Get the individual version manifest VersionInfo versionInfo = GeyserImpl.JSON_MAPPER.readValue(WebUtils.getBody(latestInfoURL), VersionInfo.class); // Get the client jar for use when downloading the en_us locale GeyserImpl.getInstance().getLogger().debug(GeyserImpl.JSON_MAPPER.writeValueAsString(versionInfo.getDownloads())); CLIENT_JAR_INFO = versionInfo.getDownloads().get("client"); GeyserImpl.getInstance().getLogger().debug(GeyserImpl.JSON_MAPPER.writeValueAsString(CLIENT_JAR_INFO)); // Get the assets list JsonNode assets = GeyserImpl.JSON_MAPPER.readTree(WebUtils.getBody(versionInfo.getAssetIndex().getUrl())).get("objects"); // Put each asset into an array for use later Iterator> assetIterator = assets.fields(); while (assetIterator.hasNext()) { Map.Entry entry = assetIterator.next(); if (!entry.getKey().startsWith("minecraft/lang/")) { // No need to cache non-language assets as we don't use them continue; } Asset asset = GeyserImpl.JSON_MAPPER.treeToValue(entry.getValue(), Asset.class); ASSET_MAP.put(entry.getKey(), asset); } } catch (Exception e) { GeyserImpl.getInstance().getLogger().error(GeyserLocale.getLocaleStringLog("geyser.locale.fail.asset_cache", (!e.getMessage().isEmpty() ? e.getMessage() : e.getStackTrace()))); } return null; }); } public static void downloadAndRunClientJarTasks() { if (CLIENT_JAR_INFO == null) { // Likely failed to download GeyserImpl.getInstance().getLogger().debug("Skipping en_US hash check as client jar is null."); return; } if (!FORCE_DOWNLOAD_JAR) { // Don't bother checking the hash if we need to download new files anyway. String curHash = null; try { File hashFile = GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("cache").resolve(CLIENT_JAR_HASH_FILE).toFile(); if (hashFile.exists()) { try (BufferedReader br = new BufferedReader(new FileReader(hashFile))) { curHash = br.readLine().trim(); } } } catch (IOException ignored) { } String targetHash = CLIENT_JAR_INFO.getSha1(); if (targetHash.equals(curHash)) { // Just run all tasks - no new download required ClientJarTask task; while ((task = CLIENT_JAR_TASKS.poll()) != null) { task.whenDone.run(); } return; } } try { // Let the user know we are downloading the JAR GeyserImpl.getInstance().getLogger().info(GeyserLocale.getLocaleStringLog("geyser.locale.download.en_us")); GeyserImpl.getInstance().getLogger().debug("Download URL: " + CLIENT_JAR_INFO.getUrl()); Path tmpFilePath = GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("tmp_locale.jar"); WebUtils.downloadFile(CLIENT_JAR_INFO.getUrl(), tmpFilePath.toString()); // Load in the JAR as a zip and extract the files try (ZipFile localeJar = new ZipFile(tmpFilePath.toString())) { ClientJarTask task; while ((task = CLIENT_JAR_TASKS.poll()) != null) { try (InputStream fileStream = localeJar.getInputStream(localeJar.getEntry(task.asset))) { task.ifNewDownload.accept(fileStream); task.whenDone.run(); } } } // Store the latest jar hash Path cache = GeyserImpl.getInstance().getBootstrap().getConfigFolder().resolve("cache"); Files.createDirectories(cache); FileUtils.writeFile(cache.resolve(CLIENT_JAR_HASH_FILE).toString(), CLIENT_JAR_INFO.getSha1().toCharArray()); // Delete the nolonger needed client/server jar Files.delete(tmpFilePath); GeyserImpl.getInstance().getLogger().info(GeyserLocale.getLocaleStringLog("geyser.locale.download.en_us.done")); } catch (Exception e) { GeyserImpl.getInstance().getLogger().error(GeyserLocale.getLocaleStringLog("geyser.locale.fail.en_us"), e); } } public static void saveFile(Path location, InputStream fileStream) throws IOException { try (OutputStream outStream = Files.newOutputStream(location)) { // Write the file to the locale dir byte[] buf = new byte[fileStream.available()]; int length; while ((length = fileStream.read(buf)) != -1) { outStream.write(buf, 0, length); } // Flush all changes to disk and cleanup outStream.flush(); } } /** * A process that requires we download the client jar. * Designed to accommodate Geyser updates that require more assets from the jar. */ public record ClientJarTask(String asset, InputStreamConsumer ifNewDownload, Runnable whenDone) { } @FunctionalInterface public interface InputStreamConsumer { void accept(InputStream stream) throws IOException; } /* Classes that map to JSON files served by Mojang */ @JsonIgnoreProperties(ignoreUnknown = true) @Getter static class VersionManifest { @JsonProperty("latest") private LatestVersion latestVersion; @JsonProperty("versions") private List versions; } @JsonIgnoreProperties(ignoreUnknown = true) @Getter static class LatestVersion { @JsonProperty("release") private String release; @JsonProperty("snapshot") private String snapshot; } @JsonIgnoreProperties(ignoreUnknown = true) @Getter static class Version { @JsonProperty("id") private String id; @JsonProperty("type") private String type; @JsonProperty("url") private String url; @JsonProperty("time") private String time; @JsonProperty("releaseTime") private String releaseTime; } @JsonIgnoreProperties(ignoreUnknown = true) @Getter static class VersionInfo { @JsonProperty("id") private String id; @JsonProperty("type") private String type; @JsonProperty("time") private String time; @JsonProperty("releaseTime") private String releaseTime; @JsonProperty("assetIndex") private AssetIndex assetIndex; @JsonProperty("downloads") private Map downloads; } @JsonIgnoreProperties(ignoreUnknown = true) @Getter static class VersionDownload { @JsonProperty("sha1") private String sha1; @JsonProperty("size") private int size; @JsonProperty("url") private String url; } @JsonIgnoreProperties(ignoreUnknown = true) @Getter static class AssetIndex { @JsonProperty("id") private String id; @JsonProperty("sha1") private String sha1; @JsonProperty("size") private int size; @JsonProperty("totalSize") private int totalSize; @JsonProperty("url") private String url; } @JsonIgnoreProperties(ignoreUnknown = true) @Getter public static class Asset { @JsonProperty("hash") private String hash; @JsonProperty("size") private int size; } private AssetUtils() { } }