/* * SK's Minecraft Launcher * Copyright (C) 2010-2014 Albert Pham <http://www.sk89q.com> and contributors * Please see LICENSE.txt for license information. */ package com.skcraft.launcher.builder; import com.beust.jcommander.JCommander; import com.beust.jcommander.ParameterException; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectWriter; import com.google.common.base.Joiner; import com.google.common.base.Strings; import com.google.common.collect.Lists; import com.google.common.io.ByteStreams; import com.google.common.io.CharStreams; import com.google.common.io.Closer; import com.google.common.io.Files; import com.skcraft.launcher.Launcher; import com.skcraft.launcher.LauncherUtils; import com.skcraft.launcher.model.loader.InstallProfile; import com.skcraft.launcher.model.loader.LoaderManifest; import com.skcraft.launcher.model.loader.SidedData; import com.skcraft.launcher.model.loader.VersionInfo; import com.skcraft.launcher.model.minecraft.Library; import com.skcraft.launcher.model.minecraft.ReleaseList; import com.skcraft.launcher.model.minecraft.Version; import com.skcraft.launcher.model.minecraft.VersionManifest; import com.skcraft.launcher.model.modpack.DownloadableFile; import com.skcraft.launcher.model.modpack.Manifest; import com.skcraft.launcher.util.Environment; import com.skcraft.launcher.util.FileUtils; import com.skcraft.launcher.util.HttpRequest; import com.skcraft.launcher.util.SimpleLogFormatter; import lombok.Getter; import lombok.NonNull; import lombok.Setter; import lombok.extern.java.Log; import java.io.*; import java.net.URL; import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; import java.util.Properties; import java.util.jar.JarFile; import java.util.logging.Level; import java.util.regex.Pattern; import java.util.zip.ZipEntry; import static com.google.common.base.Preconditions.checkNotNull; import static com.google.common.base.Strings.emptyToNull; import static com.skcraft.launcher.util.HttpRequest.url; /** * Builds packages for the launcher. */ @Log public class PackageBuilder { private static final Pattern TWEAK_CLASS_ARG = Pattern.compile("--tweakClass\\s+([^\\s]+)"); private final Properties properties; private final ObjectMapper mapper; private ObjectWriter writer; private final Manifest manifest; private final PropertiesApplicator applicator; @Getter private boolean prettyPrint = false; @Getter @Setter private File baseDir; private List<Library> loaderLibraries = Lists.newArrayList(); private List<String> mavenRepos; private List<URL> jarMavens = Lists.newArrayList(); /** * Create a new package builder. * * @param mapper the mapper * @param manifest the manifest */ public PackageBuilder(@NonNull ObjectMapper mapper, @NonNull Manifest manifest) throws IOException { this.properties = LauncherUtils.loadProperties(Launcher.class, "launcher.properties", "com.skcraft.launcher.propertiesFile"); this.mapper = mapper; this.manifest = manifest; this.applicator = new PropertiesApplicator(manifest); setPrettyPrint(false); // Set writer Closer closer = Closer.create(); try { mavenRepos = mapper.readValue(closer.register(Launcher.class.getResourceAsStream("maven_repos.json")), new TypeReference<List<String>>() { }); } finally { closer.close(); } } public void setPrettyPrint(boolean prettyPrint) { if (prettyPrint) { writer = mapper.writerWithDefaultPrettyPrinter(); } else { writer = mapper.writer(); } this.prettyPrint = prettyPrint; } public void scan(File dir) throws IOException { logSection("Scanning for .info.json files..."); FileInfoScanner scanner = new FileInfoScanner(mapper); scanner.walk(dir); for (FeaturePattern pattern : scanner.getPatterns()) { applicator.register(pattern); } } public void addFiles(File dir, File destDir) throws IOException { logSection("Adding files to modpack..."); ClientFileCollector collector = new ClientFileCollector(this.manifest, applicator, destDir); collector.walk(dir); } public void addLoaders(File dir, File librariesDir) { logSection("Checking for mod loaders to install..."); LinkedHashSet<Library> collected = new LinkedHashSet<Library>(); File[] files = dir.listFiles(new JarFileFilter()); if (files != null) { for (File file : files) { try { processLoader(collected, file, librariesDir); } catch (IOException e) { log.log(Level.WARNING, "Failed to add the loader at " + file.getAbsolutePath(), e); } } } this.loaderLibraries.addAll(collected); VersionManifest version = manifest.getVersionManifest(); collected.addAll(version.getLibraries()); version.setLibraries(collected); } private void processLoader(LinkedHashSet<Library> loaderLibraries, File file, File librariesDir) throws IOException { log.info("Installing " + file.getName() + "..."); JarFile jarFile = new JarFile(file); Closer closer = Closer.create(); try { ZipEntry manifestEntry = BuilderUtils.getZipEntry(jarFile, "version.json"); String loaderName = file.getName(); if (manifestEntry != null) { InputStream stream = jarFile.getInputStream(manifestEntry); // Read file String data = CharStreams.toString(closer.register(new InputStreamReader(stream))); data = data.replaceAll(",\\s*\\}", "}"); // Fix issues with trailing commas VersionInfo info = mapper.readValue(data, VersionInfo.class); VersionManifest version = manifest.getVersionManifest(); if (version.getId() != null) { loaderName = version.getId(); } // Copy tweak class arguments List<String> gameArguments = info.getMinecraftArguments().getGameArguments(); if (gameArguments != null) { String args = Joiner.on(' ').join(gameArguments); String existingArgs = Strings.nullToEmpty(version.getMinecraftArguments()); version.setMinecraftArguments(Joiner.on(' ').join(existingArgs, args)); } // Add libraries List<Library> libraries = info.getLibraries(); if (libraries != null) { for (Library library : libraries) { loaderLibraries.add(library); log.info("Adding loader library " + library.getName()); } } // Copy main class String mainClass = info.getMainClass(); if (mainClass != null) { version.setMainClass(mainClass); log.info("Using " + mainClass + " as the main class"); } } else { log.warning("The file at " + file.getAbsolutePath() + " did not appear to have an " + "version.json file inside -- is it actually an installer for a mod loader?"); } ZipEntry profileEntry = BuilderUtils.getZipEntry(jarFile, "install_profile.json"); if (profileEntry != null) { InputStream stream = jarFile.getInputStream(profileEntry); String data = CharStreams.toString(closer.register(new InputStreamReader(stream))); data = data.replace(",\\s*\\}", "}"); InstallProfile profile = mapper.readValue(data, InstallProfile.class); // Import the libraries for the installer loaderLibraries.addAll(profile.getLibraries()); // Extract the data files List<DownloadableFile> extraFiles = Lists.newArrayList(); ZipEntry clientBinpatch = BuilderUtils.getZipEntry(jarFile, "data/client.lzma"); if (clientBinpatch != null) { DownloadableFile entry = FileUtils.saveStreamToObjectsDir( closer.register(jarFile.getInputStream(clientBinpatch)), new File(baseDir, manifest.getObjectsLocation())); entry.setName("client.lzma"); extraFiles.add(entry); profile.getData().get("BINPATCH").setClient("&" + entry.getName() + "&"); } ZipEntry serverBinpatch = BuilderUtils.getZipEntry(jarFile, "data/server.lzma"); if (serverBinpatch != null) { DownloadableFile entry = FileUtils.saveStreamToObjectsDir( closer.register(jarFile.getInputStream(serverBinpatch)), new File(baseDir, manifest.getObjectsLocation())); entry.setName("server.lzma"); extraFiles.add(entry); profile.getData().get("BINPATCH").setServer("&" + entry.getName() + "&"); } // Add extra sided data profile.getData().put("SIDE", SidedData.create("client", "server")); // Add loader manifest to the map manifest.getLoaders().put(loaderName, new LoaderManifest(profile.getData(), extraFiles)); // Add processors manifest.getTasks().addAll(profile.toProcessorEntries(loaderName)); } ZipEntry mavenEntry = BuilderUtils.getZipEntry(jarFile, "maven/"); if (mavenEntry != null) { URL jarUrl = new URL("jar:file:" + file.getAbsolutePath() + "!/"); jarMavens.add(new URL(jarUrl, "/maven/")); } } finally { closer.close(); jarFile.close(); } } public void downloadLibraries(File librariesDir) throws IOException, InterruptedException { logSection("Downloading libraries..."); // TODO: Download libraries for different environments -- As of writing, this is not an issue Environment env = Environment.getInstance(); for (Library library : loaderLibraries) { Library.Artifact artifact = library.getArtifact(env); File outputPath = new File(librariesDir, artifact.getPath()); if (!outputPath.exists()) { Files.createParentDirs(outputPath); boolean found = false; if (!artifact.getUrl().isEmpty()) { found = tryDownloadLibrary(library, artifact, artifact.getUrl(), outputPath); } // Look inside the loader JARs if (!found) { for (URL base : jarMavens) { found = tryFetchLibrary(library, new URL(base, artifact.getPath()), outputPath); if (found) break; } } // Try each repository if not found yet if (!found) { for (String baseUrl : mavenRepos) { found = tryDownloadLibrary(library, artifact, baseUrl + artifact.getPath(), outputPath); if (found) break; } } if (!found) { log.warning("!! Failed to download the library " + library.getName() + " -- this means your copy of the libraries will lack this file"); } } } } private boolean tryDownloadLibrary(Library library, Library.Artifact artifact, String baseUrl, File outputPath) throws IOException, InterruptedException { URL url = new URL(baseUrl); File tempFile = File.createTempFile("launcherlib", null); // Some repositories compress their files List<Compressor> compressors = BuilderUtils.getCompressors(baseUrl); for (Compressor compressor : Lists.reverse(compressors)) { url = new URL(url, compressor.transformPathname(artifact.getPath())); } try { log.info("Downloading library " + library.getName() + " from " + url + "..."); HttpRequest.get(url).execute().expectResponseCode(200).saveContent(tempFile); } catch (IOException e) { log.info("Could not get file from " + url + ": " + e.getMessage()); return false; } writeLibraryToFile(outputPath, tempFile, compressors); return true; } private boolean tryFetchLibrary(Library library, URL url, File outputPath) throws IOException { File tempFile = File.createTempFile("launcherlib", null); Closer closer = Closer.create(); try { log.info("Reading library " + library.getName() + " from " + url.toString()); InputStream stream = closer.register(url.openStream()); stream = closer.register(new BufferedInputStream(stream)); ByteStreams.copy(stream, closer.register(new FileOutputStream(tempFile))); } catch (IOException e) { log.info("Could not get file from " + url + ": " + e.getMessage()); return false; } finally { closer.close(); } writeLibraryToFile(outputPath, tempFile, Collections.<Compressor>emptyList()); return true; } private void writeLibraryToFile(File outputPath, File inputFile, List<Compressor> compressors) throws IOException { // Decompress (if needed) and write to file Closer closer = Closer.create(); InputStream inputStream = closer.register(new FileInputStream(inputFile)); inputStream = closer.register(new BufferedInputStream(inputStream)); for (Compressor compressor : compressors) { inputStream = closer.register(compressor.createInputStream(inputStream)); } ByteStreams.copy(inputStream, closer.register(new FileOutputStream(outputPath))); inputFile.delete(); closer.close(); } public void validateManifest() { checkNotNull(emptyToNull(manifest.getName()), "Package name is not defined"); checkNotNull(emptyToNull(manifest.getGameVersion()), "Game version is not defined"); } public void readConfig(File path) throws IOException { if (path != null) { BuilderConfig config = read(path, BuilderConfig.class); config.update(manifest); config.registerProperties(applicator); } } public void readVersionManifest(File path) throws IOException, InterruptedException { logSection("Reading version manifest..."); if (path.exists()) { VersionManifest versionManifest = read(path, VersionManifest.class); manifest.setVersionManifest(versionManifest); log.info("Loaded version manifest from " + path.getAbsolutePath()); } else { URL url = url(properties.getProperty("versionManifestUrl")); log.info("Fetching version manifest from " + url + "..."); ReleaseList releases = HttpRequest.get(url) .execute() .expectResponseCode(200) .returnContent() .asJson(ReleaseList.class); Version version = releases.find(manifest.getGameVersion()); VersionManifest versionManifest = HttpRequest.get(url(version.getUrl())) .execute() .expectResponseCode(200) .returnContent() .asJson(VersionManifest.class); manifest.setVersionManifest(versionManifest); } } public void writeManifest(@NonNull File path) throws IOException { logSection("Writing manifest..."); manifest.setFeatures(applicator.getFeaturesInUse()); VersionManifest versionManifest = manifest.getVersionManifest(); if (versionManifest != null) { versionManifest.setId(manifest.getGameVersion()); } validateManifest(); path.getAbsoluteFile().getParentFile().mkdirs(); writer.writeValue(path, manifest); log.info("Wrote manifest to " + path.getAbsolutePath()); } private static BuilderOptions parseArgs(String[] args) { BuilderOptions options = new BuilderOptions(); new JCommander(options, args); options.choosePaths(); return options; } private <V> V read(File path, Class<V> clazz) throws IOException { try { if (path == null) { return clazz.newInstance(); } else { return mapper.readValue(path, clazz); } } catch (InstantiationException e) { throw new IOException("Failed to create " + clazz.getCanonicalName(), e); } catch (IllegalAccessException e) { throw new IOException("Failed to create " + clazz.getCanonicalName(), e); } } /** * Build a package given the arguments. * * @param args arguments * @throws IOException thrown on I/O error * @throws InterruptedException on interruption */ public static void main(String[] args) throws IOException, InterruptedException { BuilderOptions options; try { options = parseArgs(args); } catch (ParameterException e) { new JCommander().usage(); System.err.println("error: " + e.getMessage()); System.exit(1); return; } // Initialize SimpleLogFormatter.configureGlobalLogger(); ObjectMapper mapper = new ObjectMapper(); mapper.setSerializationInclusion(JsonInclude.Include.NON_DEFAULT); Manifest manifest = new Manifest(); manifest.setMinimumVersion(Manifest.MIN_PROTOCOL_VERSION); PackageBuilder builder = new PackageBuilder(mapper, manifest); builder.setPrettyPrint(options.isPrettyPrinting()); // From config builder.readConfig(options.getConfigPath()); builder.readVersionManifest(options.getVersionManifestPath()); builder.setBaseDir(options.getOutputPath()); // From options manifest.updateName(options.getName()); manifest.updateTitle(options.getTitle()); manifest.updateGameVersion(options.getGameVersion()); manifest.setVersion(options.getVersion()); manifest.setLibrariesLocation(options.getLibrariesLocation()); manifest.setObjectsLocation(options.getObjectsLocation()); builder.scan(options.getFilesDir()); builder.addFiles(options.getFilesDir(), options.getObjectsDir()); builder.addLoaders(options.getLoadersDir(), options.getLibrariesDir()); builder.downloadLibraries(options.getLibrariesDir()); builder.writeManifest(options.getManifestPath()); logSection("Done"); log.info("Now upload the contents of " + options.getOutputPath() + " to your web server or CDN!"); } private static void logSection(String name) { log.info(""); log.info("--- " + name + " ---"); } }