The dialog will be shown from the Event Dispatch Thread, regardless of the
+ * thread it is called from. In either case, the method will block until the
+ * user has closed the dialog (or dialog creation fails for whatever reason).
+ *
+ * @param parentComponent the frame from which the dialog is displayed, otherwise
+ * null to use the default frame
+ * @param message the message to display
+ * @param title the title string for the dialog
+ * @param messageType see {@link javax.swing.JOptionPane#showMessageDialog(java.awt.Component, Object, String, int)}
+ * for available message types
+ */
+ public static void showMessageDialog(final Component parentComponent,
+ @NonNull final String message,
+ @NonNull final String title,
+ final String detailsText,
+ final int messageType) {
+
+ if (SwingUtilities.isEventDispatchThread()) {
+ // To force the label to wrap, convert the message to broken HTML
+ String htmlMessage = "" + htmlEscape(message);
+
+ JPanel panel = new JPanel(new BorderLayout(0, detailsText != null ? 20 : 0));
+
+ // Add the main message
+ panel.add(new JLabel(htmlMessage), BorderLayout.NORTH);
+
+ // Add the extra details
+ if (detailsText != null) {
+ JTextArea textArea = new JTextArea(tr("errorDialog.reportError") + "\n\n" + detailsText);
+ JLabel tempLabel = new JLabel();
+ textArea.setFont(tempLabel.getFont());
+ textArea.setBackground(tempLabel.getBackground());
+ textArea.setTabSize(2);
+ textArea.setEditable(false);;
+
+ JScrollPane scrollPane = new JScrollPane(textArea);
+ scrollPane.setPreferredSize(new Dimension(350, 120));
+ panel.add(scrollPane, BorderLayout.CENTER);
+ }
+
+ JOptionPane.showMessageDialog(
+ parentComponent, panel, title, messageType);
+ } else {
+ // Call method again from the Event Dispatch Thread
+ try {
+ SwingUtilities.invokeAndWait(new Runnable() {
+ @Override
+ public void run() {
+ showMessageDialog(
+ parentComponent, message, title,
+ detailsText, messageType);
+ }
+ });
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } catch (InvocationTargetException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ }
+
+ /**
+ * Asks the user a binary yes or no question.
+ *
+ * @param parentComponent the component
+ * @param message the message to display
+ * @param title the title string for the dialog
+ * @return whether 'yes' was selected
+ */
+ public static boolean confirmDialog(final Component parentComponent,
+ @NonNull final String message,
+ @NonNull final String title) {
+ if (SwingUtilities.isEventDispatchThread()) {
+ return JOptionPane.showConfirmDialog(
+ parentComponent, message, title, JOptionPane.YES_NO_OPTION) ==
+ JOptionPane.YES_OPTION;
+ } else {
+ // Use an AtomicBoolean to pass the result back from the
+ // Event Dispatcher Thread
+ final AtomicBoolean yesSelected = new AtomicBoolean();
+
+ try {
+ SwingUtilities.invokeAndWait(new Runnable() {
+ @Override
+ public void run() {
+ yesSelected.set(confirmDialog(parentComponent, title, message));
+ }
+ });
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } catch (InvocationTargetException e) {
+ throw new RuntimeException(e);
+ }
+
+ return yesSelected.get();
+ }
+ }
+
+ public static BufferedImage readIconImage(Class> clazz, String path) {
+ InputStream in = null;
+ try {
+ in = clazz.getResourceAsStream(path);
+ if (in != null) {
+ return ImageIO.read(in);
+ }
+ } catch (IOException e) {
+ } finally {
+ closeQuietly(in);
+ }
+ return null;
+ }
+
+ public static void setIconImage(Window frame, Class> clazz, String path) {
+ BufferedImage image = readIconImage(clazz, path);
+ if (image != null) {
+ frame.setIconImage(image);
+ }
+ }
+
+ /**
+ * Focus a component.
+ *
+ *
The focus call happens in {@link javax.swing.SwingUtilities#invokeLater(Runnable)}.
+ *
+ * @param component the component
+ */
+ public static void focusLater(@NonNull final Component component) {
+ SwingUtilities.invokeLater(new Runnable() {
+ @Override
+ public void run() {
+ if (component instanceof JTextComponent) {
+ ((JTextComponent) component).selectAll();
+ }
+ component.requestFocusInWindow();
+ }
+ });
+ }
+
+}
diff --git a/launcher-bootstrap/src/main/resources/com/skcraft/launcher/bootstrap.properties b/launcher-bootstrap/src/main/resources/com/skcraft/launcher/bootstrap.properties
new file mode 100644
index 0000000..678f616
--- /dev/null
+++ b/launcher-bootstrap/src/main/resources/com/skcraft/launcher/bootstrap.properties
@@ -0,0 +1,11 @@
+#
+# SK's Minecraft Launcher
+# Copyright (C) 2010-2014 Albert Pham
and contributors
+# Please see LICENSE.txt for license information.
+#
+
+homeFolderWindows=Example Launcher
+homeFolderLinux=example_launcher
+homeFolder=.examplelauncher
+launcherClass=com.skcraft.launcher.Launcher
+latestUrl=http://update.skcraft.com/quark/launcher/latest.json
diff --git a/launcher-bootstrap/src/main/resources/com/skcraft/launcher/bootstrapper_icon.png b/launcher-bootstrap/src/main/resources/com/skcraft/launcher/bootstrapper_icon.png
new file mode 100644
index 0000000..8c74095
Binary files /dev/null and b/launcher-bootstrap/src/main/resources/com/skcraft/launcher/bootstrapper_icon.png differ
diff --git a/launcher-bootstrap/src/main/resources/com/skcraft/launcher/lang/Bootstrap.properties b/launcher-bootstrap/src/main/resources/com/skcraft/launcher/lang/Bootstrap.properties
new file mode 100644
index 0000000..9b9f6a2
--- /dev/null
+++ b/launcher-bootstrap/src/main/resources/com/skcraft/launcher/lang/Bootstrap.properties
@@ -0,0 +1,21 @@
+#
+# SK's Minecraft Launcher
+# Copyright (C) 2010-2014 Albert Pham and contributors
+# Please see LICENSE.txt for license information.
+#
+
+button.cancal=Cancel
+
+errorTitle=Error
+errors.failedDownloadError=The SKCraft launcher could not be downloaded.
+errors.bootstrapError=An error occurred while trying to bootstrap the launcher\!
+
+errorDialog.reportError=To report this error, provide\:
+
+downloader.title=Downloading SKCraft Launcher...
+downloader.pleaseWait=Please wait...
+downloader.status=Downloading latest SKCraft Launcher...
+downloader.progressStatus=Downloading latest SKCraft Launcher (%.2f%%)...
+
+progress.confirmCancel=Are you sure that you wish to cancel?
+progress.confirmCancelTitle=Cancel
\ No newline at end of file
diff --git a/launcher-builder/build.gradle b/launcher-builder/build.gradle
new file mode 100644
index 0000000..2248957
--- /dev/null
+++ b/launcher-builder/build.gradle
@@ -0,0 +1,28 @@
+plugins {
+ id 'application'
+ id 'java-library'
+ id "com.github.johnrengelman.shadow"
+ id 'io.freefair.lombok'
+}
+
+application {
+ mainClassName = "com.skcraft.launcher.builder.PackageBuilder"
+}
+
+dependencies {
+ api project(':launcher')
+ implementation 'org.apache.commons:commons-compress:1.21'
+}
+
+shadowJar {
+ dependsOn ':launcher:shadowJar'
+ archiveClassifier.set("")
+}
+
+// Work around gradle shadow bug
+// see https://github.com/johnrengelman/shadow/issues/713
+startScripts.dependsOn(':launcher:shadowJar')
+distTar.dependsOn(':launcher:shadowJar')
+distZip.dependsOn(':launcher:shadowJar')
+
+build.dependsOn(shadowJar)
diff --git a/launcher-builder/lombok.config b/launcher-builder/lombok.config
new file mode 100644
index 0000000..6aa51d7
--- /dev/null
+++ b/launcher-builder/lombok.config
@@ -0,0 +1,2 @@
+# This file is generated by the 'io.freefair.lombok' Gradle plugin
+config.stopBubbling = true
diff --git a/launcher-builder/src/main/java/com/skcraft/launcher/builder/BuilderConfig.java b/launcher-builder/src/main/java/com/skcraft/launcher/builder/BuilderConfig.java
new file mode 100644
index 0000000..5101417
--- /dev/null
+++ b/launcher-builder/src/main/java/com/skcraft/launcher/builder/BuilderConfig.java
@@ -0,0 +1,62 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.launcher.builder;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.google.common.collect.Lists;
+import com.skcraft.launcher.model.modpack.LaunchModifier;
+import com.skcraft.launcher.model.modpack.Manifest;
+import lombok.Data;
+
+import java.util.List;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Strings.emptyToNull;
+
+@Data
+public class BuilderConfig {
+
+ private String name;
+ private String title;
+ private String gameVersion;
+ @JsonProperty("launch")
+ private LaunchModifier launchModifier = new LaunchModifier();
+ private List features = Lists.newArrayList();
+ private FnPatternList userFiles = new FnPatternList();
+
+ public void setLaunchModifier(LaunchModifier launchModifier) {
+ this.launchModifier = launchModifier != null ? launchModifier : new LaunchModifier();
+ }
+
+ public void setFeatures(List features) {
+ this.features = features != null ? features : Lists.newArrayList();
+ }
+
+ public void setUserFiles(FnPatternList userFiles) {
+ this.userFiles = userFiles != null ? userFiles : new FnPatternList();
+ }
+
+ public void update(Manifest manifest) {
+ manifest.updateName(getName());
+ manifest.updateTitle(getTitle());
+ manifest.updateGameVersion(getGameVersion());
+ manifest.setLaunchModifier(getLaunchModifier());
+ }
+
+ public void registerProperties(PropertiesApplicator applicator) {
+ if (features != null) {
+ for (FeaturePattern feature : features) {
+ checkNotNull(emptyToNull(feature.getFeature().getName()),
+ "Empty feature name found");
+ applicator.register(feature);
+ }
+ }
+
+ applicator.setUserFiles(userFiles);
+ }
+
+}
diff --git a/launcher-builder/src/main/java/com/skcraft/launcher/builder/BuilderOptions.java b/launcher-builder/src/main/java/com/skcraft/launcher/builder/BuilderOptions.java
new file mode 100644
index 0000000..8a62b86
--- /dev/null
+++ b/launcher-builder/src/main/java/com/skcraft/launcher/builder/BuilderOptions.java
@@ -0,0 +1,114 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.launcher.builder;
+
+import com.beust.jcommander.Parameter;
+import com.beust.jcommander.ParameterException;
+import lombok.Data;
+
+import java.io.File;
+
+@Data
+public class BuilderOptions {
+
+ public static final String DEFAULT_CONFIG_FILENAME = "modpack.json";
+ public static final String DEFAULT_VERSION_FILENAME = "version.json";
+ public static final String DEFAULT_SRC_DIRNAME = "src";
+ public static final String DEFAULT_LOADERS_DIRNAME = "loaders";
+
+ // Configuration
+
+ // Override config
+ @Parameter(names = "--name")
+ private String name;
+ @Parameter(names = "--title")
+ private String title;
+ @Parameter(names = "--mc-version")
+ private String gameVersion;
+
+ // Required
+ @Parameter(names = "--version", required = true)
+ private String version;
+ @Parameter(names = "--manifest-dest", required = true)
+ private File manifestPath;
+
+ // Overall paths
+ @Parameter(names = {"--input", "-i"})
+ private File inputPath;
+ @Parameter(names = {"--output", "-o"})
+ private File outputPath;
+
+ // Input paths
+ @Parameter(names = "--config")
+ private File configPath;
+ @Parameter(names = "--version-file")
+ private File versionManifestPath;
+ @Parameter(names = "--files")
+ private File filesDir;
+ @Parameter(names = "--loaders")
+ private File loadersDir;
+
+ // Output paths
+ @Parameter(names = "--objects-dest")
+ private File objectsDir;
+ @Parameter(names = "--libraries-dest")
+ private File librariesDir;
+
+ @Parameter(names = "--libs-url")
+ private String librariesLocation = "libraries";
+ @Parameter(names = "--objects-url")
+ private String objectsLocation = "objects";
+
+ // Misc
+ @Parameter(names = "--pretty-print")
+ private boolean prettyPrinting;
+
+ public void choosePaths() throws ParameterException {
+ if (configPath == null) {
+ requireInputPath("--config");
+ configPath = new File(inputPath, DEFAULT_CONFIG_FILENAME);
+ }
+
+ if (versionManifestPath == null) {
+ requireInputPath("--version");
+ versionManifestPath = new File(inputPath, DEFAULT_VERSION_FILENAME);
+ }
+
+ if (filesDir == null) {
+ requireInputPath("--files");
+ filesDir = new File(inputPath, DEFAULT_SRC_DIRNAME);
+ }
+
+ if (loadersDir == null) {
+ requireInputPath("--loaders");
+ loadersDir = new File(inputPath, DEFAULT_LOADERS_DIRNAME);
+ }
+
+ if (objectsDir == null) {
+ requireOutputPath("--objects-dest");
+ objectsDir = new File(outputPath, objectsLocation);
+ }
+
+ if (librariesDir == null) {
+ requireOutputPath("--libs-dest");
+ librariesDir = new File(outputPath, librariesLocation);
+ }
+ }
+
+ private void requireOutputPath(String name) throws ParameterException {
+ if (outputPath == null) {
+ throw new ParameterException("Because " + name + " was not specified, --output needs to be specified as the output directory and then " + name + " will be default to a pre-set path within the output directory");
+ }
+ }
+
+ private void requireInputPath(String name) throws ParameterException {
+ if (inputPath == null) {
+ throw new ParameterException("Because " + name + " was not specified, --input needs to be specified as the project directory and then " + name + " will be default to a pre-set path within the project directory");
+ }
+ }
+
+}
diff --git a/launcher-builder/src/main/java/com/skcraft/launcher/builder/BuilderUtils.java b/launcher-builder/src/main/java/com/skcraft/launcher/builder/BuilderUtils.java
new file mode 100644
index 0000000..c291ee0
--- /dev/null
+++ b/launcher-builder/src/main/java/com/skcraft/launcher/builder/BuilderUtils.java
@@ -0,0 +1,68 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.launcher.builder;
+
+import com.google.common.collect.Lists;
+import com.google.common.io.CharStreams;
+import org.apache.commons.compress.compressors.CompressorStreamFactory;
+
+import java.io.IOException;
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.*;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+
+public final class BuilderUtils {
+
+ private static final DateFormat VERSION_DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd-HH-mm-ss");
+
+ private BuilderUtils() {
+ }
+
+ public static String normalizePath(String path) {
+ return path.replaceAll("^[/\\\\]*", "").replaceAll("[/\\\\]+", "/");
+ }
+
+ public static ZipEntry getZipEntry(ZipFile jarFile, String path) {
+ Enumeration extends ZipEntry> entries = jarFile.entries();
+ String expected = normalizePath(path);
+
+ while (entries.hasMoreElements()) {
+ ZipEntry entry = entries.nextElement();
+ String test = normalizePath(entry.getName());
+ if (expected.equals(test)) {
+ return entry;
+ }
+ }
+
+ return null;
+ }
+
+ public static String readStringFromStream(Readable r) throws IOException {
+ String data = CharStreams.toString(r);
+ data = data.replaceAll(",\\s*\\}", "}"); // Fix issues with trailing commas
+
+ return data;
+ }
+
+ public static List getCompressors(String repoUrl) {
+ if (repoUrl.matches("^https?://files.minecraftforge.net/maven/?")) {
+ return Lists.newArrayList(
+ new Compressor("xz", CompressorStreamFactory.XZ),
+ new Compressor("pack", CompressorStreamFactory.PACK200));
+ } else {
+ return Collections.emptyList();
+ }
+ }
+
+ public static String generateVersionFromDate() {
+ Date today = Calendar.getInstance().getTime();
+ return VERSION_DATE_FORMAT.format(today);
+ }
+
+}
diff --git a/launcher-builder/src/main/java/com/skcraft/launcher/builder/ClientFileCollector.java b/launcher-builder/src/main/java/com/skcraft/launcher/builder/ClientFileCollector.java
new file mode 100644
index 0000000..79c9dd8
--- /dev/null
+++ b/launcher-builder/src/main/java/com/skcraft/launcher/builder/ClientFileCollector.java
@@ -0,0 +1,105 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.launcher.builder;
+
+import com.google.common.hash.HashFunction;
+import com.google.common.hash.Hashing;
+import com.google.common.io.Files;
+import com.skcraft.launcher.model.modpack.FileInstall;
+import com.skcraft.launcher.model.modpack.Manifest;
+import lombok.NonNull;
+import lombok.extern.java.Log;
+import org.apache.commons.io.FilenameUtils;
+
+import java.io.File;
+import java.io.IOException;
+
+/**
+ * Walks a path and adds hashed path versions to the given
+ * {@link com.skcraft.launcher.model.modpack.Manifest}.
+ */
+@Log
+public class ClientFileCollector extends DirectoryWalker {
+
+ private final Manifest manifest;
+ private final PropertiesApplicator applicator;
+ private final File destDir;
+ private HashFunction hf = Hashing.sha1();
+
+ /**
+ * Create a new collector.
+ *
+ * @param manifest the manifest
+ * @param applicator applies properties to manifest entries
+ * @param destDir the destination directory to copy the hashed objects
+ */
+ public ClientFileCollector(@NonNull Manifest manifest, @NonNull PropertiesApplicator applicator,
+ @NonNull File destDir) {
+ this.manifest = manifest;
+ this.applicator = applicator;
+ this.destDir = destDir;
+ }
+
+ @Override
+ protected DirectoryBehavior getBehavior(@NonNull String name) {
+ return getDirectoryBehavior(name);
+ }
+
+ @Override
+ protected void onFile(File file, String relPath) throws IOException {
+ if (file.getName().endsWith(FileInfoScanner.FILE_SUFFIX)
+ || file.getName().endsWith(FileUrlScanner.URL_FILE_SUFFIX)) {
+ return;
+ }
+
+ FileInstall entry = new FileInstall();
+ String hash = Files.hash(file, hf).toString();
+ String to = FilenameUtils.separatorsToUnix(FilenameUtils.normalize(relPath));
+
+ // url.txt override file
+ File urlFile = new File(file.getAbsoluteFile().getParentFile(),
+ file.getName() + FileUrlScanner.URL_FILE_SUFFIX);
+ String location;
+ boolean copy = true;
+ if (urlFile.exists() && FileUrlScanner.isEnabled()) {
+ FileUrlRedirect redirect = FileUrlRedirect.fromFile(urlFile);
+
+ location = redirect.getUrl().toString();
+ copy = false;
+ } else {
+ location = hash.substring(0, 2) + "/" + hash.substring(2, 4) + "/" + hash;
+ }
+
+ File destPath = new File(destDir, location);
+ entry.setHash(hash);
+ entry.setLocation(location);
+ entry.setTo(to);
+ entry.setSize(file.length());
+ applicator.apply(entry);
+ destPath.getParentFile().mkdirs();
+ ClientFileCollector.log.info(String.format("Adding %s from %s...", relPath, file.getAbsolutePath()));
+ if (copy) {
+ Files.copy(file, destPath);
+ }
+ manifest.getTasks().add(entry);
+ }
+
+ public static DirectoryBehavior getDirectoryBehavior(@NonNull String name) {
+ if (name.startsWith(".")) {
+ return DirectoryBehavior.SKIP;
+ } else if (name.equals("_OPTIONAL")) {
+ return DirectoryBehavior.IGNORE;
+ } else if (name.equals("_SERVER")) {
+ return DirectoryBehavior.SKIP;
+ } else if (name.equals("_CLIENT")) {
+ return DirectoryBehavior.IGNORE;
+ } else {
+ return DirectoryBehavior.CONTINUE;
+ }
+ }
+
+}
diff --git a/launcher-builder/src/main/java/com/skcraft/launcher/builder/Compressor.java b/launcher-builder/src/main/java/com/skcraft/launcher/builder/Compressor.java
new file mode 100644
index 0000000..40958b1
--- /dev/null
+++ b/launcher-builder/src/main/java/com/skcraft/launcher/builder/Compressor.java
@@ -0,0 +1,48 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.launcher.builder;
+
+import org.apache.commons.compress.compressors.CompressorException;
+import org.apache.commons.compress.compressors.CompressorStreamFactory;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+public class Compressor {
+
+ private static final CompressorStreamFactory factory = new CompressorStreamFactory();
+
+ private final String extension;
+ private final String format;
+
+ public Compressor(String extension, String format) {
+ this.extension = extension;
+ this.format = format;
+ }
+
+ public String transformPathname(String filename) {
+ return filename + "." + extension;
+ }
+
+ public InputStream createInputStream(InputStream inputStream) throws IOException {
+ try {
+ return factory.createCompressorInputStream(format, inputStream);
+ } catch (CompressorException e) {
+ throw new IOException("Failed to create decompressor", e);
+ }
+ }
+
+ public OutputStream createOutputStream(OutputStream outputStream) throws IOException {
+ try {
+ return factory.createCompressorOutputStream(format, outputStream);
+ } catch (CompressorException e) {
+ throw new IOException("Failed to create compressor", e);
+ }
+ }
+
+}
diff --git a/launcher-builder/src/main/java/com/skcraft/launcher/builder/DirectoryWalker.java b/launcher-builder/src/main/java/com/skcraft/launcher/builder/DirectoryWalker.java
new file mode 100644
index 0000000..f92726c
--- /dev/null
+++ b/launcher-builder/src/main/java/com/skcraft/launcher/builder/DirectoryWalker.java
@@ -0,0 +1,99 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.launcher.builder;
+
+import lombok.NonNull;
+
+import java.io.File;
+import java.io.IOException;
+
+/**
+ * Abstract class to recursively walk a directory, keep track of a relative
+ * path (which may be modified by dropping certain directory entries),
+ * and call {@link #onFile(java.io.File, String)} with each file.
+ */
+public abstract class DirectoryWalker {
+
+ public enum DirectoryBehavior {
+ /**
+ * Continue and add the given directory to the relative path.
+ */
+ CONTINUE,
+ /**
+ * Continue but don't add the given directory to the relative path.
+ */
+ IGNORE,
+ /**
+ * Don't walk this directory.
+ */
+ SKIP
+ }
+
+ /**
+ * Walk the given directory.
+ *
+ * @param dir the directory
+ * @throws IOException thrown on I/O error
+ */
+ public final void walk(@NonNull File dir) throws IOException {
+ walk(dir, "");
+ }
+
+ /**
+ * Recursively walk the given directory and keep track of the relative path.
+ *
+ * @param dir the directory
+ * @param basePath the base path
+ * @throws IOException
+ */
+ private void walk(@NonNull File dir, @NonNull String basePath) throws IOException {
+ if (!dir.isDirectory()) {
+ throw new IllegalArgumentException(dir.getAbsolutePath() + " is not a directory");
+ }
+
+ File[] files = dir.listFiles();
+ if (files != null) {
+ for (File file : files) {
+ if (file.isDirectory()) {
+ String newPath = basePath;
+
+ switch (getBehavior(file.getName())) {
+ case CONTINUE:
+ newPath += file.getName() + "/";
+ case IGNORE:
+ walk(file, newPath);
+ break;
+ case SKIP: break;
+ }
+ } else {
+ onFile(file, basePath + file.getName());
+ }
+ }
+ }
+ }
+
+ /**
+ * Return the behavior for the given directory name.
+ *
+ * @param name the directory name
+ * @return the behavor
+ */
+ protected DirectoryBehavior getBehavior(String name) {
+ return DirectoryBehavior.CONTINUE;
+ }
+
+ /**
+ * Callback on each file.
+ *
+ * @param file the file
+ * @param relPath the relative path
+ * @throws IOException thrown on I/O error
+ */
+ protected abstract void onFile(File file, String relPath) throws IOException;
+
+
+}
diff --git a/launcher-builder/src/main/java/com/skcraft/launcher/builder/FeaturePattern.java b/launcher-builder/src/main/java/com/skcraft/launcher/builder/FeaturePattern.java
new file mode 100644
index 0000000..13a2e71
--- /dev/null
+++ b/launcher-builder/src/main/java/com/skcraft/launcher/builder/FeaturePattern.java
@@ -0,0 +1,24 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.launcher.builder;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.skcraft.launcher.model.modpack.Feature;
+import lombok.Data;
+
+@Data
+public class FeaturePattern {
+
+ @JsonProperty("properties")
+ private Feature feature;
+ @JsonProperty("files")
+ private FnPatternList filePatterns = new FnPatternList();
+
+ public boolean matches(String path) {
+ return filePatterns != null && filePatterns.matches(path);
+ }
+}
diff --git a/launcher-builder/src/main/java/com/skcraft/launcher/builder/FileInfo.java b/launcher-builder/src/main/java/com/skcraft/launcher/builder/FileInfo.java
new file mode 100644
index 0000000..bea808a
--- /dev/null
+++ b/launcher-builder/src/main/java/com/skcraft/launcher/builder/FileInfo.java
@@ -0,0 +1,17 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.launcher.builder;
+
+import com.skcraft.launcher.model.modpack.Feature;
+import lombok.Data;
+
+@Data
+public class FileInfo {
+
+ private Feature feature;
+
+}
diff --git a/launcher-builder/src/main/java/com/skcraft/launcher/builder/FileInfoScanner.java b/launcher-builder/src/main/java/com/skcraft/launcher/builder/FileInfoScanner.java
new file mode 100644
index 0000000..bd1c1b7
--- /dev/null
+++ b/launcher-builder/src/main/java/com/skcraft/launcher/builder/FileInfoScanner.java
@@ -0,0 +1,75 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.launcher.builder;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.skcraft.launcher.model.modpack.Feature;
+import lombok.Getter;
+import lombok.extern.java.Log;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.List;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Strings.emptyToNull;
+import static com.skcraft.launcher.builder.ClientFileCollector.getDirectoryBehavior;
+import static org.apache.commons.io.FilenameUtils.*;
+
+@Log
+public class FileInfoScanner extends DirectoryWalker {
+
+ private static final EnumSet MATCH_FLAGS = EnumSet.of(
+ FnMatch.Flag.CASEFOLD, FnMatch.Flag.PERIOD, FnMatch.Flag.PATHNAME);
+ public static final String FILE_SUFFIX = ".info.json";
+
+ private final ObjectMapper mapper;
+ @Getter
+ private final List patterns = new ArrayList();
+
+ public FileInfoScanner(ObjectMapper mapper) {
+ this.mapper = mapper;
+ }
+
+ @Override
+ protected DirectoryBehavior getBehavior(String name) {
+ return getDirectoryBehavior(name);
+ }
+
+ @Override
+ protected void onFile(File file, String relPath) throws IOException {
+ if (file.getName().endsWith(FILE_SUFFIX)) {
+ String fnPattern =
+ separatorsToUnix(getPath(relPath)) +
+ getBaseName(getBaseName(file.getName())) + "*";
+
+ FileInfo info = mapper.readValue(file, FileInfo.class);
+ Feature feature = info.getFeature();
+
+ if (feature != null) {
+ checkNotNull(emptyToNull(feature.getName()),
+ "Empty component name found in " + file.getAbsolutePath());
+
+ List patterns = new ArrayList();
+ patterns.add(fnPattern);
+ FnPatternList patternList = new FnPatternList();
+ patternList.setInclude(patterns);
+ patternList.setFlags(MATCH_FLAGS);
+ FeaturePattern fp = new FeaturePattern();
+ fp.setFeature(feature);
+ fp.setFilePatterns(patternList);
+ getPatterns().add(fp);
+
+ FileInfoScanner.log.info("Found .info.json file at " + file.getAbsolutePath() +
+ ", with pattern " + fnPattern + ", and component " + feature);
+ }
+ }
+ }
+
+}
diff --git a/launcher-builder/src/main/java/com/skcraft/launcher/builder/FileUrlRedirect.java b/launcher-builder/src/main/java/com/skcraft/launcher/builder/FileUrlRedirect.java
new file mode 100644
index 0000000..6115f56
--- /dev/null
+++ b/launcher-builder/src/main/java/com/skcraft/launcher/builder/FileUrlRedirect.java
@@ -0,0 +1,43 @@
+package com.skcraft.launcher.builder;
+
+import com.google.common.io.Files;
+import lombok.Data;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.URL;
+import java.nio.charset.Charset;
+import java.util.List;
+
+import static com.skcraft.launcher.util.HttpRequest.url;
+
+@Data
+public class FileUrlRedirect {
+ private URL url;
+ private String hash;
+
+ public void readFromFile(File file) throws IOException {
+ List lines = Files.readLines(file, Charset.defaultCharset());
+ this.url = url(lines.get(0));
+
+ if (lines.size() > 1) {
+ String hash = lines.get(1);
+
+ if (!hash.isEmpty()) {
+ this.hash = hash;
+ }
+ }
+ }
+
+ public void writeToFile(File file) throws IOException {
+ String entry = url.toString() + '\n' + hash;
+
+ Files.write(entry, file, Charset.defaultCharset());
+ }
+
+ public static FileUrlRedirect fromFile(File file) throws IOException {
+ FileUrlRedirect entry = new FileUrlRedirect();
+ entry.readFromFile(file);
+ return entry;
+ }
+}
diff --git a/launcher-builder/src/main/java/com/skcraft/launcher/builder/FileUrlScanner.java b/launcher-builder/src/main/java/com/skcraft/launcher/builder/FileUrlScanner.java
new file mode 100644
index 0000000..b89f3f8
--- /dev/null
+++ b/launcher-builder/src/main/java/com/skcraft/launcher/builder/FileUrlScanner.java
@@ -0,0 +1,58 @@
+package com.skcraft.launcher.builder;
+
+import com.google.common.io.Files;
+import com.skcraft.launcher.util.FileUtils;
+import com.skcraft.launcher.util.HttpRequest;
+import lombok.extern.java.Log;
+
+import java.io.File;
+import java.io.IOException;
+
+@Log
+public class FileUrlScanner extends DirectoryWalker {
+ public static final String URL_FILE_SUFFIX = ".url.txt";
+
+ public static boolean isEnabled() {
+ return !System.getProperty("com.skcraft.builder.ignoreURLOverrides", "false")
+ .equalsIgnoreCase("true");
+ }
+
+ @Override
+ protected void onFile(File file, String relPath) throws IOException {
+ if (!file.getName().endsWith(URL_FILE_SUFFIX)) return;
+
+ log.info("Found URL file " + file.getName());
+
+ File targetFile = new File(file.getAbsoluteFile().getParentFile(),
+ file.getName().replace(URL_FILE_SUFFIX, ""));
+ FileUrlRedirect info = FileUrlRedirect.fromFile(file);
+
+ if (targetFile.exists()) {
+ String localHash = FileUtils.getShaHash(targetFile);
+ if (info.getHash() == null) {
+ // Disabled for now, let's not touch source files
+// info.setHash(localHash);
+// info.writeToFile(file);
+ return;
+ }
+
+ // If everything matches, skip this file
+ if (info.getHash().equals(localHash)) return;
+ }
+
+ File tempFile = File.createTempFile("launcherlib", null);
+
+ try {
+ log.info("Downloading file " + targetFile.getName() + " from " + info.getUrl() + "...");
+ HttpRequest.get(info.getUrl())
+ .execute()
+ .expectResponseCode(200)
+ .saveContent(tempFile);
+ } catch (InterruptedException e) {
+ throw new IOException(e);
+ }
+
+ Files.move(tempFile, targetFile);
+ log.info("Updated " + targetFile.getName() + " from " + file.getName());
+ }
+}
diff --git a/launcher-builder/src/main/java/com/skcraft/launcher/builder/FnMatch.java b/launcher-builder/src/main/java/com/skcraft/launcher/builder/FnMatch.java
new file mode 100644
index 0000000..9a65859
--- /dev/null
+++ b/launcher-builder/src/main/java/com/skcraft/launcher/builder/FnMatch.java
@@ -0,0 +1,246 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+/* $OpenBSD: fnmatch.c,v 1.13 2006/03/31 05:34:14 deraadt Exp $ */
+
+package com.skcraft.launcher.builder;
+
+import java.util.EnumSet;
+
+/*
+ * Function fnmatch() as specified in POSIX 1003.2-1992, section B.6.
+ * Compares a filename or pathname to a pattern.
+ */
+public class FnMatch {
+
+ public static enum Flag {
+
+ /** Disable backslash escaping. */
+ NOESCAPE,
+ /** Slash must be matched by slash. */
+ PATHNAME,
+ /** Period must be matched by period. */
+ PERIOD,
+ /** Ignore / after Imatch. */
+ LEADING_DIR,
+ /** Case insensitive search. */
+ CASEFOLD
+ }
+ private static final int RANGE_ERROR = -1;
+ private static final int RANGE_NOMATCH = 0;
+
+ public static boolean fnmatch(String pattern, String string, EnumSet flags) {
+ return match(pattern, 0, string, 0, flags);
+ }
+
+ public static boolean fnmatch(String pattern, String string, int stringPos, Flag flag) {
+ return match(pattern, 0, string, stringPos, EnumSet.of(flag));
+ }
+
+ public static boolean fnmatch(String pattern, String string, int stringPos) {
+ return match(pattern, 0, string, stringPos, EnumSet.noneOf(Flag.class));
+ }
+
+ public static boolean fnmatch(String pattern, String string) {
+ return fnmatch(pattern, string, 0);
+ }
+
+ private static boolean match(String pattern, int patternPos,
+ String string, int stringPos, EnumSet flags) {
+ char c;
+
+ while (true) {
+ if (patternPos >= pattern.length()) {
+ if (flags.contains(Flag.LEADING_DIR) && string.charAt(stringPos) == '/') {
+ return true;
+ }
+ return stringPos == string.length();
+ }
+ c = pattern.charAt(patternPos++);
+ switch (c) {
+ case '?':
+ if (stringPos >= string.length()) {
+ return false;
+ }
+ if (string.charAt(stringPos) == '/' && flags.contains(Flag.PATHNAME)) {
+ return false;
+ }
+ if (hasLeadingPeriod(string, stringPos, flags)) {
+ return false;
+ }
+ ++stringPos;
+ continue;
+ case '*':
+ /* Collapse multiple stars. */
+ while (patternPos < pattern.length() &&
+ (c = pattern.charAt(patternPos)) == '*') {
+ patternPos++;
+ }
+
+ if (hasLeadingPeriod(string, stringPos, flags)) {
+ return false;
+ }
+
+ /* Optimize for pattern with * at end or before /. */
+ if (patternPos == pattern.length()) {
+ if (flags.contains(Flag.PATHNAME)) {
+ return flags.contains(Flag.LEADING_DIR) ||
+ string.indexOf('/', stringPos) == -1;
+ }
+ return true;
+ } else if (c == '/' && flags.contains(Flag.PATHNAME)) {
+ stringPos = string.indexOf('/', stringPos);
+ if (stringPos == -1) {
+ return false;
+ }
+ continue;
+ }
+
+ /* General case, use recursion. */
+ while (stringPos < string.length()) {
+ if (flags.contains(Flag.PERIOD)) {
+ flags = EnumSet.copyOf(flags);
+ flags.remove(Flag.PERIOD);
+ }
+ if (match(pattern, patternPos, string, stringPos, flags)) {
+ return true;
+ }
+ if (string.charAt(stringPos) == '/' && flags.contains(Flag.PATHNAME)) {
+ break;
+ }
+ ++stringPos;
+ }
+ return false;
+
+ case '[':
+ if (stringPos >= string.length()) {
+ return false;
+ }
+ if (string.charAt(stringPos) == '/' && flags.contains(Flag.PATHNAME)) {
+ return false;
+ }
+ if (hasLeadingPeriod(string, stringPos, flags)) {
+ return false;
+ }
+
+ int result = matchRange(pattern, patternPos, string.charAt(stringPos), flags);
+ if (result == RANGE_ERROR) /* not a good range, treat as normal text */ {
+ break;
+ }
+
+ if (result == RANGE_NOMATCH) {
+ return false;
+ }
+
+ patternPos = result;
+ ++stringPos;
+ continue;
+
+ case '\\':
+ if (!flags.contains(Flag.NOESCAPE)) {
+ if (patternPos >= pattern.length()) {
+ c = '\\';
+ } else {
+ c = pattern.charAt(patternPos++);
+ }
+ }
+ break;
+ }
+
+ if (stringPos >= string.length()) {
+ return false;
+ }
+ if (c != string.charAt(stringPos) &&
+ !(flags.contains(Flag.CASEFOLD) &&
+ Character.toLowerCase(c) == Character.toLowerCase(string.charAt(stringPos)))) {
+ return false;
+ }
+ ++stringPos;
+ }
+ /* NOTREACHED */
+ }
+
+ private static boolean hasLeadingPeriod(String string, int stringPos, EnumSet flags) {
+ if (stringPos > string.length() - 1)
+ return false;
+ return (stringPos == 0
+ || (flags.contains(Flag.PATHNAME) && string.charAt(stringPos - 1) == '/'))
+ && string.charAt(stringPos) == '.' && flags.contains(Flag.PERIOD);
+ }
+
+ private static int matchRange(String pattern, int patternPos, char test, EnumSet flags) {
+ boolean negate, ok;
+ char c, c2;
+
+ if (patternPos >= pattern.length()) {
+ return RANGE_ERROR;
+ }
+
+ /*
+ * A bracket expression starting with an unquoted circumflex
+ * character produces unspecified results (IEEE 1003.2-1992,
+ * 3.13.2). This implementation treats it like '!', for
+ * consistency with the regular expression syntax.
+ * J.T. Conklin (conklin@ngai.kaleida.com)
+ */
+ c = pattern.charAt(patternPos);
+ negate = c == '!' || c == '^';
+ if (negate) {
+ ++patternPos;
+ }
+
+ if (flags.contains(Flag.CASEFOLD)) {
+ test = Character.toLowerCase(test);
+ }
+
+ /*
+ * A right bracket shall lose its special meaning and represent
+ * itself in a bracket expression if it occurs first in the list.
+ * -- POSIX.2 2.8.3.2
+ */
+ ok = false;
+ while (true) {
+ if (patternPos >= pattern.length()) {
+ return RANGE_ERROR;
+ }
+
+ c = pattern.charAt(patternPos++);
+ if (c == ']') {
+ break;
+ }
+
+ if (c == '\\' && !flags.contains(Flag.NOESCAPE)) {
+ c = pattern.charAt(patternPos++);
+ }
+ if (c == '/' && flags.contains(Flag.PATHNAME)) {
+ return RANGE_NOMATCH;
+ }
+ if (flags.contains(Flag.CASEFOLD)) {
+ c = Character.toLowerCase(c);
+ }
+ if (pattern.charAt(patternPos) == '-' &&
+ patternPos + 1 < pattern.length() &&
+ (c2 = pattern.charAt(patternPos + 1)) != ']') {
+ patternPos += 2;
+ if (c2 == '\\' && !flags.contains(Flag.NOESCAPE)) {
+ if (patternPos >= pattern.length()) {
+ return RANGE_ERROR;
+ }
+ c = pattern.charAt(patternPos++);
+ }
+ if (flags.contains(Flag.CASEFOLD)) {
+ c2 = Character.toLowerCase(c2);
+ }
+ if (c <= test && test <= c2) {
+ ok = true;
+ }
+ } else if (c == test) {
+ ok = true;
+ }
+ }
+
+ return ok == negate ? RANGE_NOMATCH : patternPos;
+ }
+}
\ No newline at end of file
diff --git a/launcher-builder/src/main/java/com/skcraft/launcher/builder/FnPatternList.java b/launcher-builder/src/main/java/com/skcraft/launcher/builder/FnPatternList.java
new file mode 100644
index 0000000..b800f8f
--- /dev/null
+++ b/launcher-builder/src/main/java/com/skcraft/launcher/builder/FnPatternList.java
@@ -0,0 +1,52 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.launcher.builder;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.google.common.collect.Lists;
+import lombok.Data;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.List;
+
+@Data
+public class FnPatternList {
+
+ private static final EnumSet DEFAULT_FLAGS = EnumSet.of(
+ FnMatch.Flag.CASEFOLD, FnMatch.Flag.PERIOD);
+
+ private List include = Lists.newArrayList();
+ private List exclude = Lists.newArrayList();
+ @Getter @Setter @JsonIgnore
+ private EnumSet flags = DEFAULT_FLAGS;
+
+ public void setInclude(List include) {
+ this.include = include != null ? include : Lists.newArrayList();
+ }
+
+ public void setExclude(List exclude) {
+ this.exclude = exclude != null ? exclude : Lists.newArrayList();
+ }
+
+ public boolean matches(String path) {
+ return include != null && matches(path, include) && (exclude == null || !matches(path, exclude));
+ }
+
+ public boolean matches(String path, Collection patterns) {
+ for (String pattern : patterns) {
+ if (FnMatch.fnmatch(pattern, path, flags)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+}
diff --git a/launcher-builder/src/main/java/com/skcraft/launcher/builder/JarFileFilter.java b/launcher-builder/src/main/java/com/skcraft/launcher/builder/JarFileFilter.java
new file mode 100644
index 0000000..31ca410
--- /dev/null
+++ b/launcher-builder/src/main/java/com/skcraft/launcher/builder/JarFileFilter.java
@@ -0,0 +1,19 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.launcher.builder;
+
+import java.io.File;
+import java.io.FileFilter;
+
+public class JarFileFilter implements FileFilter {
+
+ @Override
+ public boolean accept(File pathname) {
+ return pathname.getName().toLowerCase().endsWith(".jar");
+ }
+
+}
diff --git a/launcher-builder/src/main/java/com/skcraft/launcher/builder/PackageBuilder.java b/launcher-builder/src/main/java/com/skcraft/launcher/builder/PackageBuilder.java
new file mode 100644
index 0000000..9981a17
--- /dev/null
+++ b/launcher-builder/src/main/java/com/skcraft/launcher/builder/PackageBuilder.java
@@ -0,0 +1,451 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham 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.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.common.io.ByteStreams;
+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.builder.loaders.*;
+import com.skcraft.launcher.model.loader.BasicInstallProfile;
+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.Manifest;
+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.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 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 loaderLibraries = Lists.newArrayList();
+ private List installerLibraries = Lists.newArrayList();
+ private List mavenRepos;
+ private List 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>() {
+ });
+ } 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 infoScanner = new FileInfoScanner(mapper);
+ infoScanner.walk(dir);
+ for (FeaturePattern pattern : infoScanner.getPatterns()) {
+ applicator.register(pattern);
+ }
+
+ logSection("Scanning for .url.txt files...");
+ FileUrlScanner urlScanner = new FileUrlScanner();
+ urlScanner.walk(dir);
+ }
+
+ 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 collected = new LinkedHashSet();
+
+ 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 loaderLibraries, File file, File librariesDir) throws IOException {
+ log.info("Installing " + file.getName() + "...");
+
+ JarFile jarFile = new JarFile(file);
+ Closer closer = Closer.create();
+ ILoaderProcessor processor = null;
+
+ try {
+ ZipEntry profileEntry = BuilderUtils.getZipEntry(jarFile, "install_profile.json");
+
+ if (profileEntry != null) {
+ InputStream stream = jarFile.getInputStream(profileEntry);
+ InputStreamReader reader = closer.register(new InputStreamReader(stream));
+
+ BasicInstallProfile basicProfile = mapper.readValue(BuilderUtils.readStringFromStream(reader),
+ BasicInstallProfile.class);
+
+ if (basicProfile.isLegacy()) {
+ processor = new OldForgeLoaderProcessor();
+ } else {
+ processor = new ModernForgeLoaderProcessor();
+ }
+ } else if (BuilderUtils.getZipEntry(jarFile, "fabric-installer.json") != null) {
+ processor = new FabricLoaderProcessor(FabricLoaderProcessor.Variant.FABRIC);
+ } else if (BuilderUtils.getZipEntry(jarFile, "quilt_installer.json") != null) {
+ processor = new FabricLoaderProcessor(FabricLoaderProcessor.Variant.QUILT);
+ }
+ } finally {
+ closer.close();
+ jarFile.close();
+ }
+
+ if (processor != null) {
+ LoaderResult result = processor.process(file, manifest, mapper, baseDir);
+
+ if (result == null) {
+ log.warning("Loader " + file.getName() + " failed to process.");
+ return;
+ }
+
+ loaderLibraries.addAll(result.getLoaderLibraries());
+ installerLibraries.addAll(result.getProcessorLibraries());
+ jarMavens.addAll(result.getJarMavens());
+ } else {
+ log.warning("Loader " + file.getName() + " was skipped due to missing metadata. " +
+ "Is it really a loader JAR?");
+ }
+ }
+
+ public void downloadLibraries(File librariesDir) throws IOException, InterruptedException {
+ logSection("Downloading libraries...");
+
+ for (Library library : Iterables.concat(loaderLibraries, installerLibraries)) {
+ library.ensureDownloadsExist();
+
+ for (Library.Artifact artifact : library.getDownloads().getAllArtifacts()) {
+ File outputPath = new File(librariesDir, artifact.getPath());
+
+ if (!outputPath.exists()) {
+ Files.createParentDirs(outputPath);
+ boolean found = false;
+ boolean urlEmpty = artifact.getUrl().isEmpty();
+
+ // If URL doesn't end with a /, it might be the direct file
+ if (!urlEmpty && !artifact.getUrl().endsWith("/")) {
+ 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;
+ }
+ }
+
+ // Assume artifact URL is a maven repository URL and try that
+ if (!found && !urlEmpty) {
+ URL url = LauncherUtils.concat(url(artifact.getUrl()), artifact.getPath());
+ found = tryDownloadLibrary(library, artifact, url.toString(), outputPath);
+ }
+
+ // 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);
+
+ if (url.getPath().isEmpty() || url.getPath().equals("/")) {
+ // empty path, this is probably the first "is this a full URL" try.
+ return false;
+ }
+
+ // Some repositories compress their files
+ List compressors = BuilderUtils.getCompressors(baseUrl);
+ for (Compressor compressor : Lists.reverse(compressors)) {
+ url = new URL(compressor.transformPathname(url.toString()));
+ }
+
+ File tempFile = File.createTempFile("launcherlib", null);
+
+ try {
+ log.info("Downloading library " + library.getName() + " from " + url + "...");
+ HttpRequest.get(url).execute().expectResponseCode(200)
+ .expectContentType("application/java-archive", "application/octet-stream", "application/zip")
+ .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.emptyList());
+ return true;
+ }
+
+ private void writeLibraryToFile(File outputPath, File inputFile, List 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).parse(args);
+ options.choosePaths();
+ return options;
+ }
+
+ private V read(File path, Class 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_ABSENT);
+
+ 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 + " ---");
+ }
+
+}
diff --git a/launcher-builder/src/main/java/com/skcraft/launcher/builder/PropertiesApplicator.java b/launcher-builder/src/main/java/com/skcraft/launcher/builder/PropertiesApplicator.java
new file mode 100644
index 0000000..586a04b
--- /dev/null
+++ b/launcher-builder/src/main/java/com/skcraft/launcher/builder/PropertiesApplicator.java
@@ -0,0 +1,74 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.launcher.builder;
+
+import com.skcraft.launcher.model.modpack.*;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+public class PropertiesApplicator {
+
+ private final Manifest manifest;
+ private final Set used = new HashSet