diff --git a/.gitignore b/.gitignore
index 64e3509..b213f95 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,35 +1,153 @@
-# Eclipse stuff
-/.classpath
-/.project
-/.settings
+### Eclipse ###
+*.pydevproject
+.metadata
+.gradle
+bin/
+tmp/
+*.tmp
+*.bak
+*.swp
+*~.nib
+local.properties
+.settings/
+.loadpath
-# netbeans
-/nbproject
+# Eclipse Core
+.project
-# we use maven!
-/build.xml
+# External tool builders
+.externalToolBuilders/
-# maven
-/target
+# Locally stored "Eclipse launch configurations"
+*.launch
-# vim
-.*.sw[a-p]
+# CDT-specific
+.cproject
-# various other potential build files
-/build
-/bin
-/dist
-/manifest.mf
+# JDT-specific (Eclipse Java Development Tools)
+.classpath
-# Mac filesystem dust
-/.DS_Store
+# PDT-specific
+.buildpath
-# intellij
+# sbteclipse plugin
+.target
+
+# TeXlipse plugin
+.texlipse
+
+
+### NetBeans ###
+nbproject/private/
+build/
+nbbuild/
+dist/
+nbdist/
+nbactions.xml
+nb-configuration.xml
+.nb-gradle/
+
+
+### Intellij ###
+# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm
+
*.iml
+
+## Directory-based project format:
+.idea/
+# if you remove the above rule, at least ignore the following:
+
+# User-specific stuff:
+# .idea/workspace.xml
+# .idea/tasks.xml
+# .idea/dictionaries
+
+# Sensitive or high-churn files:
+# .idea/dataSources.ids
+# .idea/dataSources.xml
+# .idea/sqlDataSources.xml
+# .idea/dynamic.xml
+# .idea/uiDesigner.xml
+
+# Gradle:
+# .idea/gradle.xml
+# .idea/libraries
+
+# Mongo Explorer plugin:
+# .idea/mongoSettings.xml
+
+## File-based project format:
*.ipr
*.iws
-.idea/
-/dependency-reduced-pom.xml
-/*.pack
-/local_launcher.properties
\ No newline at end of file
+## Plugin-specific files:
+
+# IntelliJ
+out/
+
+# mpeltonen/sbt-idea plugin
+.idea_modules/
+
+# JIRA plugin
+atlassian-ide-plugin.xml
+
+# Crashlytics plugin (for Android Studio and IntelliJ)
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+
+
+### Java ###
+*.class
+
+# Mobile Tools for Java (J2ME)
+.mtj.tmp/
+
+# Package Files #
+*.jar
+*.war
+*.ear
+
+# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
+hs_err_pid*
+
+
+### Maven ###
+target/
+pom.xml.tag
+pom.xml.releaseBackup
+pom.xml.versionsBackup
+pom.xml.next
+release.properties
+
+
+### Gradle ###
+.gradle
+build/
+
+# Ignore Gradle GUI config
+gradle-app.setting
+
+# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
+!gradle-wrapper.jar
+
+
+### Windows ###
+# Windows image file caches
+Thumbs.db
+ehthumbs.db
+
+# Folder config file
+Desktop.ini
+
+# Recycle Bin used on file shares
+$RECYCLE.BIN/
+
+# Windows Installer files
+*.cab
+*.msi
+*.msm
+*.msp
+
+# Windows shortcuts
+*.lnk
diff --git a/README.md b/README.md
index 77e258d..46f7f5f 100644
--- a/README.md
+++ b/README.md
@@ -4,73 +4,56 @@
This project provides an open-source Minecraft launcher platform for downloading,
installing, and updating modpacks.
-Introduction
-------------
+The launcher has its roots in MC Alpha as a launcher for sk89q's server.
-This launcher is maintained by sk89q, who writes WorldEdit, WorldGuard, and so on. It has
-been primarily developed for his server, but you can use it for your own modpack or
-server.
+**Note:** "SKMCLauncher" is the *older* version of this launcher. This project is called "SKCraft Launcher."
-* One of Minecraft's oldest launchers -- since Minecraft Alpha
+## Introduction
+
* Requires almost no configuration files to make a modpack
* Add a new mod by dropping in the .jar (and its configuration)
* Remove a mod by deleting its .jar (and configuration).
* Builds **server** modpacks with no extra configuration
* Advanced download system: incremental, file removal detection, optional feature/mod selection, etc.
* Very easy for users to use and install modpacks
-* Pretty well-documented with easy-to-understand, well-organized code*
* Open source!
-*Except for the Launcher frame class. That one is pretty bad.
+## Usage
-### Previous Versions
-
-This repository only contains code for the launcher versions 4.x and newer.
-
-You can find [the 3.x version on GitHub](https://github.com/sk89q/skmclauncher).
-
-Documentation
--------------
-
-First off, be aware that the launcher in this directory has been branded for sk89q's
-server, so you will have to replace that with your own. There's only a few places that
-you need to do that, and it's all documented on our documentation page.
-
-**You can fork the project on GitHub** and make modifications.
+1. Download the code.
+2. See if you can compile it (see instructions below).
+3. Read the documentation to (1) learn how to change the launcher to use your own website and (2) create modpacks in the right format for the launcher.
* [Documentation](http://wiki.sk89q.com/wiki/Launcher)
+* [Forum to ask for help](http://forum.enginehub.org/forums/launcher.25/)
-Note that documentation may be lacking in some places. If you run into problems,
-**do not hesitate to ask**.
+You can also [contact sk89q](http://www.sk89q.com/contact/).
-If you want to contact me about some sort of partnership, want to make the launcher
-the official launcher for something, please email me (see
-[my website](http://www.sk89q.com/contact/)). While you do not have to do this, we
-can make future decisions with awareness of what the needs of other users may be.
+## Compiling
-Compiling
----------
+First, make sure to install the Java Development Kit (JDK).
-The launcher can be compiled using [Maven](http://maven.apache.org/).
+In your command prompt or terminal, run:
- mvn clean package
+ ./gradlew clean build
-If you wish to import the project into an IDE, you must add support for
-Project Lombok.
+If you are on Windows:
-Contributing
-------------
+ gradlew clean build
-Pull requests can be submitted on GitHub, but we will accept them
-at our discretion. Please note that your code must follow
-Oracle's Java Code Conventions.
+Once compiled, look for the "-all" .jar files in the following folders:
-Contributions by third parties must be dual licensed under the two licenses
-described within LICENSE.txt (GNU General Public License, version 3, and the
-3-clause BSD license).
+* `launcher/build/libs/` - The main launcher
+* `launcher-builder/build/libs/` - Command line app to build modpacks
+If you wish to import the project into an IDE, you must add support for Project Lombok (IntelliJ IDEA users: also enable annotation processing in compiler settings).
-License
--------
+## Contributing
+
+Pull requests can be submitted on GitHub, but we will accept them at our discretion. Please note that your code must follow Oracle's Java Code Conventions.
+
+Contributions by third parties must be dual licensed under the two licenses described within LICENSE.txt (GNU General Public License, version 3, and the 3-clause BSD license).
+
+## License
The launcher is licensed under the GNU General Public License, version 3.
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..ce16d93
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,41 @@
+println """
+*******************************************
+ You are building SKCraft Launcher!
+
+ Output files will be in [subproject]/build/libs
+*******************************************
+"""
+
+buildscript {
+ repositories {
+ mavenCentral()
+ jcenter()
+ }
+
+ dependencies {
+ classpath 'com.github.jengelman.gradle.plugins:shadow:1.2.0'
+ }
+}
+
+subprojects {
+ apply plugin: 'java'
+ apply plugin: 'maven'
+
+ group = 'com.skcraft'
+ version = '4.2.3-SNAPSHOT'
+
+ sourceCompatibility = 1.6
+ targetCompatibility = 1.6
+
+ repositories {
+ mavenCentral()
+ maven { url "http://repo.maven.apache.org/maven2" }
+ }
+
+ if (JavaVersion.current().isJava8Compatible()) {
+ // Java 8 turns on doclint which we fail
+ tasks.withType(Javadoc) {
+ options.addStringOption('Xdoclint:none', '-quiet')
+ }
+ }
+}
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..3d0dee6
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.jar
Binary files differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..b9c1aac
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Wed Feb 18 18:57:36 PST 2015
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-2.1-bin.zip
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..91a7e26
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,164 @@
+#!/usr/bin/env bash
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS=""
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn ( ) {
+ echo "$*"
+}
+
+die ( ) {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+esac
+
+# For Cygwin, ensure paths are in UNIX format before anything is touched.
+if $cygwin ; then
+ [ -n "$JAVA_HOME" ] && JAVA_HOME=`cygpath --unix "$JAVA_HOME"`
+fi
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >&-
+APP_HOME="`pwd -P`"
+cd "$SAVED" >&-
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin, switch paths to Windows format before running java
+if $cygwin ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=$((i+1))
+ done
+ case $i in
+ (0) set -- ;;
+ (1) set -- "$args0" ;;
+ (2) set -- "$args0" "$args1" ;;
+ (3) set -- "$args0" "$args1" "$args2" ;;
+ (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
+function splitJvmOpts() {
+ JVM_OPTS=("$@")
+}
+eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
+JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
+
+exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..8a0b282
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,90 @@
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto init
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto init
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:init
+@rem Get command-line arguments, handling Windowz variants
+
+if not "%OS%" == "Windows_NT" goto win9xME_args
+if "%@eval[2+2]" == "4" goto 4NT_args
+
+:win9xME_args
+@rem Slurp the command line arguments.
+set CMD_LINE_ARGS=
+set _SKIP=2
+
+:win9xME_args_slurp
+if "x%~1" == "x" goto execute
+
+set CMD_LINE_ARGS=%*
+goto execute
+
+:4NT_args
+@rem Get arguments from the 4NT Shell from JP Software
+set CMD_LINE_ARGS=%$
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/launcher-builder/build.gradle b/launcher-builder/build.gradle
new file mode 100644
index 0000000..828447c
--- /dev/null
+++ b/launcher-builder/build.gradle
@@ -0,0 +1,21 @@
+apply plugin: 'com.github.johnrengelman.shadow'
+
+jar {
+ manifest {
+ attributes("Main-Class": "com.skcraft.launcher.builder.PackageBuilder")
+ }
+}
+
+dependencies {
+ compile project(':launcher')
+ compile 'org.tukaani:xz:1.0'
+ compile 'org.apache.commons:commons-compress:1.9'
+}
+
+shadowJar {
+ dependencies {
+ exclude(dependency('org.projectlombok:lombok'))
+ }
+}
+
+build.dependsOn(shadowJar)
\ No newline at end of file
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..658d619
--- /dev/null
+++ b/launcher-builder/src/main/java/com/skcraft/launcher/builder/BuilderConfig.java
@@ -0,0 +1,49 @@
+/*
+ * 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.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;
+ private List features;
+ private FnPatternList userFiles;
+
+ 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..793312e
--- /dev/null
+++ b/launcher-builder/src/main/java/com/skcraft/launcher/builder/BuilderOptions.java
@@ -0,0 +1,109 @@
+/*
+ * 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 {
+
+ // 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, "modpack.json");
+ }
+
+ if (versionManifestPath == null) {
+ requireInputPath("--version");
+ versionManifestPath = new File(inputPath, "version.json");
+ }
+
+ if (filesDir == null) {
+ requireInputPath("--files");
+ filesDir = new File(inputPath, "src");
+ }
+
+ if (loadersDir == null) {
+ requireInputPath("--loaders");
+ loadersDir = new File(inputPath, "loaders");
+ }
+
+ 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..8f2e722
--- /dev/null
+++ b/launcher-builder/src/main/java/com/skcraft/launcher/builder/BuilderUtils.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.beust.jcommander.internal.Lists;
+import org.apache.commons.compress.compressors.CompressorStreamFactory;
+
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.List;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+
+public final class BuilderUtils {
+
+ 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 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();
+ }
+ }
+
+}
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..de8862a
--- /dev/null
+++ b/launcher-builder/src/main/java/com/skcraft/launcher/builder/ClientFileCollector.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 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;
+import java.nio.charset.Charset;
+
+/**
+ * Walks a path and adds hashed path versions to the given
+ * {@link com.skcraft.launcher.model.modpack.Manifest}.
+ */
+@Log
+public class ClientFileCollector extends DirectoryWalker {
+
+ public static final String URL_FILE_SUFFIX = ".url.txt";
+
+ 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(URL_FILE_SUFFIX)) {
+ return;
+ }
+
+ // url.txt override file
+ File urlFile = new File(file.getAbsoluteFile().getParentFile(), file.getName() + URL_FILE_SUFFIX);
+ String to;
+ if (urlFile.exists()) {
+ to = Files.readFirstLine(urlFile, Charset.defaultCharset());
+ } else {
+ to = FilenameUtils.separatorsToUnix(FilenameUtils.normalize(relPath));
+ }
+
+ FileInstall entry = new FileInstall();
+ String hash = Files.hash(file, hf).toString();
+ String hashedPath = hash.substring(0, 2) + "/" + hash.substring(2, 4) + "/" + hash;
+ File destPath = new File(destDir, hashedPath);
+ entry.setHash(hash);
+ entry.setLocation(hashedPath);
+ 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()));
+ 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..01cbf93
--- /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;
+
+ 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/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..dd9c3b0
--- /dev/null
+++ b/launcher-builder/src/main/java/com/skcraft/launcher/builder/FnPatternList.java
@@ -0,0 +1,43 @@
+/*
+ * 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 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;
+ private List exclude;
+ @Getter @Setter @JsonIgnore
+ private EnumSet flags = DEFAULT_FLAGS;
+
+ 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..303c63d
--- /dev/null
+++ b/launcher-builder/src/main/java/com/skcraft/launcher/builder/PackageBuilder.java
@@ -0,0 +1,407 @@
+/*
+ * 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.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.minecraft.Library;
+import com.skcraft.launcher.model.minecraft.VersionManifest;
+import com.skcraft.launcher.model.modpack.Manifest;
+import com.skcraft.launcher.util.Environment;
+import com.skcraft.launcher.util.HttpRequest;
+import com.skcraft.launcher.util.SimpleLogFormatter;
+import lombok.Getter;
+import lombok.NonNull;
+import lombok.extern.java.Log;
+
+import java.io.*;
+import java.net.URL;
+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.Matcher;
+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;
+ private List loaderLibraries = Lists.newArrayList();
+ private List mavenRepos;
+
+ /**
+ * 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 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 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();
+
+ try {
+ ZipEntry profileEntry = BuilderUtils.getZipEntry(jarFile, "install_profile.json");
+
+ if (profileEntry != null) {
+ InputStream stream = jarFile.getInputStream(profileEntry);
+
+ // Read file
+ String data = CharStreams.toString(closer.register(new InputStreamReader(stream)));
+ data = data.replaceAll(",\\s*\\}", "}"); // Fix issues with trailing commas
+
+ InstallProfile profile = mapper.readValue(data, InstallProfile.class);
+ VersionManifest version = manifest.getVersionManifest();
+
+ // Copy tweak class arguments
+ String args = profile.getVersionInfo().getMinecraftArguments();
+ if (args != null) {
+ String existingArgs = Strings.nullToEmpty(version.getMinecraftArguments());
+
+ Matcher m = TWEAK_CLASS_ARG.matcher(args);
+ while (m.find()) {
+ version.setMinecraftArguments(existingArgs + " " + m.group());
+ log.info("Adding " + m.group() + " to launch arguments");
+ }
+ }
+
+ // Add libraries
+ List libraries = profile.getVersionInfo().getLibraries();
+ if (libraries != null) {
+ for (Library library : libraries) {
+ if (!version.getLibraries().contains(library)) {
+ loaderLibraries.add(library);
+ }
+ }
+ }
+
+ // Copy main class
+ String mainClass = profile.getVersionInfo().getMainClass();
+ if (mainClass != null) {
+ version.setMainClass(mainClass);
+ log.info("Using " + mainClass + " as the main class");
+ }
+
+ // Extract the library
+ String filePath = profile.getInstallData().getFilePath();
+ String libraryPath = profile.getInstallData().getPath();
+
+ if (filePath != null && libraryPath != null) {
+ ZipEntry libraryEntry = BuilderUtils.getZipEntry(jarFile, filePath);
+
+ if (libraryEntry != null) {
+ Library library = new Library();
+ library.setName(libraryPath);
+ File extractPath = new File(librariesDir, library.getPath(Environment.getInstance()));
+ Files.createParentDirs(extractPath);
+ ByteStreams.copy(closer.register(jarFile.getInputStream(libraryEntry)), Files.newOutputStreamSupplier(extractPath));
+ } else {
+ log.warning("Could not find the file '" + filePath + "' in " + file.getAbsolutePath() + ", which means that this mod loader will not work correctly");
+ }
+ }
+ } else {
+ log.warning("The file at " + file.getAbsolutePath() + " did not appear to have an " +
+ "install_profile.json file inside -- is it actually an installer for a mod loader?");
+ }
+ } 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) {
+ File outputPath = new File(librariesDir, library.getPath(env));
+
+ if (!outputPath.exists()) {
+ Files.createParentDirs(outputPath);
+ boolean found = false;
+
+ // Gather a list of repositories to download from
+ List sources = Lists.newArrayList();
+ if (library.getBaseUrl() != null) {
+ sources.add(library.getBaseUrl());
+ }
+ sources.addAll(mavenRepos);
+
+ // Try each repository
+ for (String baseUrl : sources) {
+ String pathname = library.getPath(env);
+
+ // Some repositories compress their files
+ List compressors = BuilderUtils.getCompressors(baseUrl);
+ for (Compressor compressor : Lists.reverse(compressors)) {
+ pathname = compressor.transformPathname(pathname);
+ }
+
+ URL url = new URL(baseUrl + pathname);
+ File tempFile = File.createTempFile("launcherlib", null);
+
+ 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());
+ continue;
+ }
+
+ // Decompress (if needed) and write to file
+ Closer closer = Closer.create();
+ InputStream inputStream = closer.register(new FileInputStream(tempFile));
+ inputStream = closer.register(new BufferedInputStream(inputStream));
+ for (Compressor compressor : compressors) {
+ inputStream = closer.register(compressor.createInputStream(inputStream));
+ }
+ ByteStreams.copy(inputStream, closer.register(new FileOutputStream(outputPath)));
+
+ tempFile.delete();
+
+ found = true;
+ break;
+ }
+
+ if (!found) {
+ log.warning("!! Failed to download the library " + library.getName() + " -- this means your copy of the libraries will lack this file");
+ }
+ }
+ }
+ }
+
+ 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(String.format(
+ properties.getProperty("versionManifestUrl"),
+ manifest.getGameVersion()));
+
+ log.info("Fetching version manifest from " + url + "...");
+
+ manifest.setVersionManifest(HttpRequest
+ .get(url)
+ .execute()
+ .expectResponseCode(200)
+ .returnContent()
+ .asJson(VersionManifest.class));
+ }
+ }
+
+ 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 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_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());
+
+ // 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();
+ private final List features = new ArrayList();
+ @Getter @Setter
+ private FnPatternList userFiles;
+
+ public PropertiesApplicator(Manifest manifest) {
+ this.manifest = manifest;
+ }
+
+ public void apply(ManifestEntry entry) {
+ if (entry instanceof FileInstall) {
+ apply((FileInstall) entry);
+ }
+ }
+
+ private void apply(FileInstall entry) {
+ String path = entry.getTargetPath();
+ entry.setWhen(fromFeature(path));
+ entry.setUserFile(isUserFile(path));
+ }
+
+ public boolean isUserFile(String path) {
+ if (userFiles != null) {
+ return userFiles.matches(path);
+ } else {
+ return false;
+ }
+ }
+
+ public Condition fromFeature(String path) {
+ List found = new ArrayList();
+ for (FeaturePattern pattern : features) {
+ if (pattern.matches(path)) {
+ used.add(pattern.getFeature());
+ found.add(pattern.getFeature());
+ }
+ }
+
+ if (!found.isEmpty()) {
+ return new RequireAny(found);
+ } else {
+ return null;
+ }
+ }
+
+ public void register(FeaturePattern component) {
+ features.add(component);
+ }
+
+ public List getFeaturesInUse() {
+ return new ArrayList(used);
+ }
+
+}
diff --git a/launcher-builder/src/main/java/com/skcraft/launcher/builder/ServerCopyExport.java b/launcher-builder/src/main/java/com/skcraft/launcher/builder/ServerCopyExport.java
new file mode 100644
index 0000000..fa3da44
--- /dev/null
+++ b/launcher-builder/src/main/java/com/skcraft/launcher/builder/ServerCopyExport.java
@@ -0,0 +1,61 @@
+/*
+ * 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.google.common.io.Files;
+import com.skcraft.launcher.util.SimpleLogFormatter;
+import lombok.NonNull;
+import lombok.extern.java.Log;
+
+import java.io.File;
+import java.io.IOException;
+
+@Log
+public class ServerCopyExport extends DirectoryWalker {
+
+ private final File destDir;
+
+ public ServerCopyExport(@NonNull File destDir) {
+ this.destDir = destDir;
+ }
+
+ @Override
+ protected DirectoryBehavior getBehavior(String name) {
+ if (name.startsWith(".")) {
+ return DirectoryBehavior.SKIP;
+ } else if (name.equals("_SERVER")) {
+ return DirectoryBehavior.IGNORE;
+ } else if (name.equals("_CLIENT")) {
+ return DirectoryBehavior.SKIP;
+ } else {
+ return DirectoryBehavior.CONTINUE;
+ }
+ }
+
+ @Override
+ protected void onFile(File file, String relPath) throws IOException {
+ File dest = new File(destDir, relPath);
+
+ log.info("Copying " + file.getAbsolutePath() + " to " + dest.getAbsolutePath());
+ dest.getParentFile().mkdirs();
+ Files.copy(file, dest);
+ }
+
+ public static void main(String[] args) throws IOException {
+ SimpleLogFormatter.configureGlobalLogger();
+
+ ServerExportOptions options = new ServerExportOptions();
+ new JCommander(options, args);
+
+ log.info("From: " + options.getSourceDir().getAbsolutePath());
+ log.info("To: " + options.getDestDir().getAbsolutePath());
+ ServerCopyExport task = new ServerCopyExport(options.getDestDir());
+ task.walk(options.getSourceDir());
+ }
+
+}
diff --git a/launcher-builder/src/main/java/com/skcraft/launcher/builder/ServerExportOptions.java b/launcher-builder/src/main/java/com/skcraft/launcher/builder/ServerExportOptions.java
new file mode 100644
index 0000000..2e243f4
--- /dev/null
+++ b/launcher-builder/src/main/java/com/skcraft/launcher/builder/ServerExportOptions.java
@@ -0,0 +1,22 @@
+/*
+ * 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 lombok.Data;
+
+import java.io.File;
+
+@Data
+public class ServerExportOptions {
+
+ @Parameter(names = "--source", required = true)
+ private File sourceDir;
+ @Parameter(names = "--dest", required = true)
+ private File destDir;
+
+}
diff --git a/launcher/build.gradle b/launcher/build.gradle
new file mode 100644
index 0000000..7385044
--- /dev/null
+++ b/launcher/build.gradle
@@ -0,0 +1,34 @@
+apply plugin: 'com.github.johnrengelman.shadow'
+
+jar {
+ manifest {
+ attributes("Main-Class": "com.skcraft.launcher.Launcher")
+ }
+}
+
+dependencies {
+ compile 'org.projectlombok:lombok:1.12.2'
+ compile 'com.fasterxml.jackson.core:jackson-databind:2.3.0'
+ compile 'commons-lang:commons-lang:2.6'
+ compile 'commons-io:commons-io:1.2'
+ compile 'com.google.guava:guava:15.0'
+ compile 'com.beust:jcommander:1.32'
+ compile 'org.tukaani:xz:1.0'
+ compile 'org.apache.commons:commons-compress:1.9'
+}
+
+processResources {
+ filesMatching('**/*.properties') {
+ filter {
+ it.replace('${project.version}', project.version)
+ }
+ }
+}
+
+shadowJar {
+ dependencies {
+ exclude(dependency('org.projectlombok:lombok'))
+ }
+}
+
+build.dependsOn(shadowJar)
\ No newline at end of file
diff --git a/launcher/src/main/java/com/skcraft/concurrency/DefaultProgress.java b/launcher/src/main/java/com/skcraft/concurrency/DefaultProgress.java
new file mode 100644
index 0000000..fd8a681
--- /dev/null
+++ b/launcher/src/main/java/com/skcraft/concurrency/DefaultProgress.java
@@ -0,0 +1,28 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.concurrency;
+
+import lombok.Data;
+
+/**
+ * A simple default implementation of {@link com.skcraft.concurrency.ProgressObservable}
+ * with settable properties.
+ */
+@Data
+public class DefaultProgress implements ProgressObservable {
+
+ private String status;
+ private double progress = -1;
+
+ public DefaultProgress() {
+ }
+
+ public DefaultProgress(double progress, String status) {
+ this.progress = progress;
+ this.status = status;
+ }
+}
diff --git a/launcher/src/main/java/com/skcraft/concurrency/ObservableFuture.java b/launcher/src/main/java/com/skcraft/concurrency/ObservableFuture.java
new file mode 100644
index 0000000..1458d17
--- /dev/null
+++ b/launcher/src/main/java/com/skcraft/concurrency/ObservableFuture.java
@@ -0,0 +1,83 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.concurrency;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import lombok.NonNull;
+
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * A pair of ProgressObservable and ListenableFuture.
+ *
+ * @param the result type
+ */
+public class ObservableFuture implements ListenableFuture, ProgressObservable {
+
+ private final ListenableFuture future;
+ private final ProgressObservable observable;
+
+ /**
+ * Construct a new ObservableFuture.
+ *
+ * @param future the delegate future
+ * @param observable the observable
+ */
+ public ObservableFuture(@NonNull ListenableFuture future, @NonNull ProgressObservable observable) {
+ this.future = future;
+ this.observable = observable;
+ }
+
+ @Override
+ public boolean cancel(boolean mayInterruptIfRunning) {
+ return future.cancel(mayInterruptIfRunning);
+ }
+
+ @Override
+ public boolean isCancelled() {
+ return future.isCancelled();
+ }
+
+ @Override
+ public void addListener(Runnable listener, Executor executor) {
+ future.addListener(listener, executor);
+ }
+
+ @Override
+ public boolean isDone() {
+ return future.isDone();
+ }
+
+ @Override
+ public V get() throws InterruptedException, ExecutionException {
+ return future.get();
+ }
+
+ @Override
+ public V get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException {
+ return future.get(timeout, unit);
+ }
+
+ @Override
+ public String toString() {
+ return observable.toString();
+ }
+
+ @Override
+ public double getProgress() {
+ return observable.getProgress();
+ }
+
+ @Override
+ public String getStatus() {
+ return observable.getStatus();
+ }
+
+}
diff --git a/launcher/src/main/java/com/skcraft/concurrency/ProgressFilter.java b/launcher/src/main/java/com/skcraft/concurrency/ProgressFilter.java
new file mode 100644
index 0000000..d9cf69e
--- /dev/null
+++ b/launcher/src/main/java/com/skcraft/concurrency/ProgressFilter.java
@@ -0,0 +1,35 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.concurrency;
+
+public class ProgressFilter implements ProgressObservable {
+
+ private final ProgressObservable delegate;
+ private final double offset;
+ private final double portion;
+
+ public ProgressFilter(ProgressObservable delegate, double offset, double portion) {
+ this.delegate = delegate;
+ this.offset = offset;
+ this.portion = portion;
+ }
+
+ @Override
+ public double getProgress() {
+ return offset + portion * Math.max(0, delegate.getProgress());
+ }
+
+ @Override
+ public String getStatus() {
+ return delegate.getStatus();
+ }
+
+ public static ProgressObservable between(ProgressObservable delegate, double from, double to) {
+ return new ProgressFilter(delegate, from, to - from);
+ }
+
+}
diff --git a/launcher/src/main/java/com/skcraft/concurrency/ProgressObservable.java b/launcher/src/main/java/com/skcraft/concurrency/ProgressObservable.java
new file mode 100644
index 0000000..2179e6a
--- /dev/null
+++ b/launcher/src/main/java/com/skcraft/concurrency/ProgressObservable.java
@@ -0,0 +1,30 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.concurrency;
+
+/**
+ * Implementations of this interface can provide information on the progress
+ * of a task.
+ */
+public interface ProgressObservable {
+
+ /**
+ * Get the progress value as a number between 0 and 1 (inclusive), or -1
+ * if progress information is unavailable.
+ *
+ * @return the progress value
+ */
+ double getProgress();
+
+ /**
+ * Get the current status text.
+ *
+ * @return the status text, or null if unavailable
+ */
+ String getStatus();
+
+}
diff --git a/launcher/src/main/java/com/skcraft/launcher/AssetsRoot.java b/launcher/src/main/java/com/skcraft/launcher/AssetsRoot.java
new file mode 100644
index 0000000..11b94a1
--- /dev/null
+++ b/launcher/src/main/java/com/skcraft/launcher/AssetsRoot.java
@@ -0,0 +1,144 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.launcher;
+
+import com.google.common.io.Files;
+import com.skcraft.concurrency.ProgressObservable;
+import com.skcraft.launcher.model.minecraft.Asset;
+import com.skcraft.launcher.model.minecraft.AssetsIndex;
+import com.skcraft.launcher.model.minecraft.VersionManifest;
+import com.skcraft.launcher.persistence.Persistence;
+import lombok.Getter;
+import lombok.NonNull;
+import lombok.extern.java.Log;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Map;
+import java.util.logging.Level;
+
+import static com.skcraft.launcher.util.SharedLocale._;
+
+/**
+ * Represents a directory that stores assets for Minecraft. The class has
+ * various methods that abstract operations involving the assets (such
+ * as getting the path to a certain object).
+ */
+@Log
+public class AssetsRoot {
+
+ @Getter
+ private final File dir;
+
+ /**
+ * Create a new instance.
+ *
+ * @param dir the directory to the assets folder
+ */
+ public AssetsRoot(@NonNull File dir) {
+ this.dir = dir;
+ }
+
+ /**
+ * Get the path to the index .json file for a version manfiest.
+ *
+ * @param versionManifest the version manifest
+ * @return the file, which may not exist
+ */
+ public File getIndexPath(VersionManifest versionManifest) {
+ return new File(dir, "indexes/" + versionManifest.getAssetsIndex() + ".json");
+ }
+
+ /**
+ * Get the local path for a given asset.
+ *
+ * @param asset the asset
+ * @return the file, which may not exist
+ */
+ public File getObjectPath(Asset asset) {
+ String hash = asset.getHash();
+ return new File(dir, "objects/" + hash.substring(0, 2) + "/" + hash);
+ }
+
+ /**
+ * Create an instance of the assets tree builder, which copies the indexed
+ * assets (identified by hashes) into a directory where the assets
+ * have been renamed and moved to their real names and locations
+ * (i.e. sounds/whatever.ogg).
+ *
+ * @param versionManifest the version manifest
+ * @return the builder
+ * @throws LauncherException
+ */
+ public AssetsTreeBuilder createAssetsBuilder(@NonNull VersionManifest versionManifest) throws LauncherException {
+ String indexId = versionManifest.getAssetsIndex();
+ File path = getIndexPath(versionManifest);
+ AssetsIndex index = Persistence.read(path, AssetsIndex.class, true);
+ if (index == null || index.getObjects() == null) {
+ throw new LauncherException("Missing index at " + path, _("assets.missingIndex", path.getAbsolutePath()));
+ }
+ File treeDir = new File(dir, "virtual/" + indexId);
+ treeDir.mkdirs();
+ return new AssetsTreeBuilder(index, treeDir);
+ }
+
+ public class AssetsTreeBuilder implements ProgressObservable {
+ private final AssetsIndex index;
+ private final File destDir;
+ private final int count;
+ private int processed = 0;
+
+ public AssetsTreeBuilder(AssetsIndex index, File destDir) {
+ this.index = index;
+ this.destDir = destDir;
+ count = index.getObjects().size();
+ }
+
+ public File build() throws IOException, LauncherException {
+ AssetsRoot.log.info("Building asset virtual tree at '" + destDir.getAbsolutePath() + "'...");
+
+ for (Map.Entry entry : index.getObjects().entrySet()) {
+ File objectPath = getObjectPath(entry.getValue());
+ File virtualPath = new File(destDir, entry.getKey());
+ virtualPath.getParentFile().mkdirs();
+ if (!virtualPath.exists()) {
+ log.log(Level.INFO, "Copying {0} to {1}...", new Object[] {
+ objectPath.getAbsolutePath(), virtualPath.getAbsolutePath()});
+
+ if (!objectPath.exists()) {
+ String message = _("assets.missingObject", objectPath.getAbsolutePath());
+ throw new LauncherException("Missing object " + objectPath.getAbsolutePath(), message);
+ }
+
+ Files.copy(objectPath, virtualPath);
+ }
+ processed++;
+ }
+
+ return destDir;
+ }
+
+ @Override
+ public double getProgress() {
+ if (count == 0) {
+ return -1;
+ } else {
+ return processed / (double) count;
+ }
+ }
+
+ @Override
+ public String getStatus() {
+ if (count == 0) {
+ return _("assets.expanding1", count, count - processed);
+ } else {
+ return _("assets.expandingN", count, count - processed);
+ }
+ }
+ }
+
+}
diff --git a/launcher/src/main/java/com/skcraft/launcher/Configuration.java b/launcher/src/main/java/com/skcraft/launcher/Configuration.java
new file mode 100644
index 0000000..8471e68
--- /dev/null
+++ b/launcher/src/main/java/com/skcraft/launcher/Configuration.java
@@ -0,0 +1,49 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.launcher;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import lombok.Data;
+
+/**
+ * The configuration for the launcher.
+ *
+ * Default values are stored as field values. Note that if a default
+ * value is changed after the launcher has been deployed, it may not take effect
+ * for users who have already used the launcher because the old default
+ * values would have been written to disk.
+ */
+@Data
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class Configuration {
+
+ private boolean offlineEnabled = false;
+ private String jvmPath;
+ private String jvmArgs;
+ private int minMemory = 1024;
+ private int maxMemory = 1024;
+ private int permGen = 128;
+ private int windowWidth = 854;
+ private int widowHeight = 480;
+ private boolean proxyEnabled = false;
+ private String proxyHost = "localhost";
+ private int proxyPort = 8080;
+ private String proxyUsername;
+ private String proxyPassword;
+ private String gameKey;
+
+ @Override
+ public boolean equals(Object o) {
+ return super.equals(o);
+ }
+
+ @Override
+ public int hashCode() {
+ return super.hashCode();
+ }
+
+}
diff --git a/launcher/src/main/java/com/skcraft/launcher/Instance.java b/launcher/src/main/java/com/skcraft/launcher/Instance.java
new file mode 100644
index 0000000..9fa4a02
--- /dev/null
+++ b/launcher/src/main/java/com/skcraft/launcher/Instance.java
@@ -0,0 +1,152 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.launcher;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.skcraft.launcher.launch.JavaProcessBuilder;
+import com.skcraft.launcher.model.modpack.LaunchModifier;
+import lombok.Data;
+
+import java.io.File;
+import java.net.URL;
+import java.util.Date;
+
+/**
+ * An instance is a profile that represents one particular installation
+ * of the game, with separate files and so on.
+ */
+@Data
+public class Instance implements Comparable {
+
+ private String title;
+ private String name;
+ private String version;
+ private boolean updatePending;
+ private boolean installed;
+ private Date lastAccessed;
+ @JsonProperty("launch")
+ private LaunchModifier launchModifier;
+
+ @JsonIgnore private File dir;
+ @JsonIgnore private URL manifestURL;
+ @JsonIgnore private int priority;
+ @JsonIgnore private boolean selected;
+ @JsonIgnore private boolean local;
+
+ /**
+ * Get the tile of the instance, which might be the same as the
+ * instance name if no title is set.
+ *
+ * @return a title
+ */
+ public String getTitle() {
+ return title != null ? title : name;
+ }
+
+ /**
+ * Update the given process builder with launch settings that are
+ * specific to this instance.
+ *
+ * @param builder the process builder
+ */
+ public void modify(JavaProcessBuilder builder) {
+ if (launchModifier != null) {
+ launchModifier.modify(builder);
+ }
+ }
+
+ /**
+ * Get the file for the directory where Minecraft's game files are
+ * stored, including user files (screenshots, etc.).
+ *
+ * @return the content directory, which may not exist
+ */
+ @JsonIgnore
+ public File getContentDir() {
+ File dir = new File(this.dir, "minecraft");
+ if (!dir.exists()) {
+ dir.mkdirs();
+ }
+ return dir;
+ }
+
+ /**
+ * Get the file for the package manifest.
+ *
+ * @return the manifest path, which may not exist
+ */
+ @JsonIgnore
+ public File getManifestPath() {
+ return new File(dir, "manifest.json");
+ }
+
+ /**
+ * Get the file for the Minecraft version manfiest file.
+ *
+ * @return the version path, which may not exist
+ */
+ @JsonIgnore
+ public File getVersionPath() {
+ return new File(dir, "version.json");
+ }
+
+ /**
+ * Get the file for the custom JAR file.
+ *
+ * @return the JAR file, which may not exist
+ */
+ @JsonIgnore
+ public File getCustomJarPath() {
+ return new File(getContentDir(), "custom_jar.jar");
+ }
+
+ @Override
+ public String toString() {
+ return name;
+ }
+
+ @Override
+ public int hashCode() {
+ return super.hashCode();
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ return super.equals(other);
+ }
+
+ @Override
+ public int compareTo(Instance o) {
+ if (isLocal() && !o.isLocal()) {
+ return -1;
+ } else if (!isLocal() && o.isLocal()) {
+ return 1;
+ } else if (isLocal() && o.isLocal()) {
+ Date otherDate = o.getLastAccessed();
+
+ if (otherDate == null && lastAccessed == null) {
+ return 0;
+ } else if (otherDate == null) {
+ return -1;
+ } else if (lastAccessed == null) {
+ return 1;
+ } else {
+ return -lastAccessed.compareTo(otherDate);
+ }
+ } else {
+ if (priority > o.priority) {
+ return -1;
+ } else if (priority < o.priority) {
+ return 1;
+ } else {
+ return 0;
+ }
+ }
+ }
+
+}
diff --git a/launcher/src/main/java/com/skcraft/launcher/InstanceList.java b/launcher/src/main/java/com/skcraft/launcher/InstanceList.java
new file mode 100644
index 0000000..69d53df
--- /dev/null
+++ b/launcher/src/main/java/com/skcraft/launcher/InstanceList.java
@@ -0,0 +1,215 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.launcher;
+
+import com.skcraft.concurrency.DefaultProgress;
+import com.skcraft.concurrency.ProgressObservable;
+import com.skcraft.launcher.model.modpack.ManifestInfo;
+import com.skcraft.launcher.model.modpack.PackageList;
+import com.skcraft.launcher.persistence.Persistence;
+import com.skcraft.launcher.util.HttpRequest;
+import lombok.Getter;
+import lombok.NonNull;
+import lombok.extern.java.Log;
+import org.apache.commons.io.filefilter.DirectoryFileFilter;
+
+import java.io.File;
+import java.io.FileFilter;
+import java.io.IOException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.Callable;
+
+import static com.skcraft.launcher.LauncherUtils.concat;
+import static com.skcraft.launcher.util.SharedLocale._;
+
+/**
+ * Stores the list of instances.
+ */
+@Log
+public class InstanceList {
+
+ private final Launcher launcher;
+ @Getter private final List instances = new ArrayList();
+
+ /**
+ * Create a new instance list.
+ *
+ * @param launcher the launcher
+ */
+ public InstanceList(@NonNull Launcher launcher) {
+ this.launcher = launcher;
+ }
+
+ /**
+ * Get the instance at a particular index.
+ *
+ * @param index the index
+ * @return the instance
+ */
+ public synchronized Instance get(int index) {
+ return instances.get(index);
+ }
+
+ /**
+ * Get the number of instances.
+ *
+ * @return the number of instances
+ */
+ public synchronized int size() {
+ return instances.size();
+ }
+
+ /**
+ * Create a worker that loads the list of instances from disk and from
+ * the remote list of packages.
+ *
+ * @return the worker
+ */
+ public Enumerator createEnumerator() {
+ return new Enumerator();
+ }
+
+ /**
+ * Get a list of selected instances.
+ *
+ * @return a list of instances
+ */
+ public synchronized List getSelected() {
+ List selected = new ArrayList();
+ for (Instance instance : instances) {
+ if (instance.isSelected()) {
+ selected.add(instance);
+ }
+ }
+
+ return selected;
+ }
+
+ /**
+ * Sort the list of instances.
+ */
+ public synchronized void sort() {
+ Collections.sort(instances);
+ }
+
+ public final class Enumerator implements Callable, ProgressObservable {
+ private ProgressObservable progress = new DefaultProgress(-1, null);
+
+ private Enumerator() {
+ }
+
+ @Override
+ public InstanceList call() throws Exception {
+ log.info("Enumerating instance list...");
+ progress = new DefaultProgress(0, _("instanceLoader.loadingLocal"));
+
+ List local = new ArrayList();
+ List remote = new ArrayList();
+
+ File[] dirs = launcher.getInstancesDir().listFiles((FileFilter) DirectoryFileFilter.INSTANCE);
+ if (dirs != null) {
+ for (File dir : dirs) {
+ File file = new File(dir, "instance.json");
+ Instance instance = Persistence.load(file, Instance.class);
+ instance.setDir(dir);
+ instance.setName(dir.getName());
+ instance.setSelected(true);
+ instance.setLocal(true);
+ local.add(instance);
+
+ log.info(instance.getName() + " local instance found at " + dir.getAbsolutePath());
+ }
+ }
+
+ progress = new DefaultProgress(0.3, _("instanceLoader.checkingRemote"));
+
+ try {
+ URL packagesURL = launcher.getPackagesURL();
+
+ PackageList packages = HttpRequest
+ .get(packagesURL)
+ .execute()
+ .expectResponseCode(200)
+ .returnContent()
+ .asJson(PackageList.class);
+
+ if (packages.getMinimumVersion() > Launcher.PROTOCOL_VERSION) {
+ throw new LauncherException("Update required", _("errors.updateRequiredError"));
+ }
+
+ for (ManifestInfo manifest : packages.getPackages()) {
+ boolean foundLocal = false;
+
+ for (Instance instance : local) {
+ if (instance.getName().equalsIgnoreCase(manifest.getName())) {
+ foundLocal = true;
+
+ instance.setTitle(manifest.getTitle());
+ instance.setPriority(manifest.getPriority());
+ URL url = concat(packagesURL, manifest.getLocation());
+ instance.setManifestURL(url);
+
+ log.info("(" + instance.getName() + ").setManifestURL(" + url + ")");
+
+ // Check if an update is required
+ if (instance.getVersion() == null || !instance.getVersion().equals(manifest.getVersion())) {
+ instance.setUpdatePending(true);
+ instance.setVersion(manifest.getVersion());
+ Persistence.commitAndForget(instance);
+ log.info(instance.getName() + " requires an update to " + manifest.getVersion());
+ }
+ }
+ }
+
+ if (!foundLocal) {
+ File dir = new File(launcher.getInstancesDir(), manifest.getName());
+ File file = new File(dir, "instance.json");
+ Instance instance = Persistence.load(file, Instance.class);
+ instance.setDir(dir);
+ instance.setTitle(manifest.getTitle());
+ instance.setName(manifest.getName());
+ instance.setVersion(manifest.getVersion());
+ instance.setPriority(manifest.getPriority());
+ instance.setSelected(false);
+ instance.setManifestURL(concat(packagesURL, manifest.getLocation()));
+ instance.setUpdatePending(true);
+ instance.setLocal(false);
+ remote.add(instance);
+
+ log.info("Available remote instance: '" + instance.getName() +
+ "' at version " + instance.getVersion());
+ }
+ }
+ } catch (IOException e) {
+ throw new IOException("The list of modpacks could not be downloaded.", e);
+ } finally {
+ synchronized (InstanceList.this) {
+ instances.clear();
+ instances.addAll(local);
+ instances.addAll(remote);
+
+ log.info(instances.size() + " instance(s) enumerated.");
+ }
+ }
+
+ return InstanceList.this;
+ }
+
+ @Override
+ public double getProgress() {
+ return -1;
+ }
+
+ @Override
+ public String getStatus() {
+ return progress.getStatus();
+ }
+ }
+}
diff --git a/launcher/src/main/java/com/skcraft/launcher/Launcher.java b/launcher/src/main/java/com/skcraft/launcher/Launcher.java
new file mode 100644
index 0000000..3a3750e
--- /dev/null
+++ b/launcher/src/main/java/com/skcraft/launcher/Launcher.java
@@ -0,0 +1,366 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.launcher;
+
+import com.beust.jcommander.JCommander;
+import com.beust.jcommander.ParameterException;
+import com.google.common.base.Strings;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.skcraft.launcher.auth.AccountList;
+import com.skcraft.launcher.auth.LoginService;
+import com.skcraft.launcher.auth.YggdrasilLoginService;
+import com.skcraft.launcher.dialog.LauncherFrame;
+import com.skcraft.launcher.model.minecraft.VersionManifest;
+import com.skcraft.launcher.persistence.Persistence;
+import com.skcraft.launcher.swing.SwingHelper;
+import com.skcraft.launcher.util.HttpRequest;
+import com.skcraft.launcher.util.SharedLocale;
+import com.skcraft.launcher.util.SimpleLogFormatter;
+import lombok.Getter;
+import lombok.NonNull;
+import lombok.extern.java.Log;
+import org.apache.commons.io.FileUtils;
+
+import javax.swing.*;
+import java.io.File;
+import java.io.FileFilter;
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.net.URL;
+import java.net.URLEncoder;
+import java.util.Locale;
+import java.util.Properties;
+import java.util.concurrent.Executors;
+import java.util.logging.Level;
+
+/**
+ * The main entry point for the launcher.
+ */
+@Log
+public final class Launcher {
+
+ public static final int PROTOCOL_VERSION = 2;
+
+ @Getter
+ private final ListeningExecutorService executor = MoreExecutors.listeningDecorator(Executors.newCachedThreadPool());
+ @Getter private final File baseDir;
+ @Getter private final Properties properties;
+ @Getter private final InstanceList instances;
+ @Getter private final Configuration config;
+ @Getter private final AccountList accounts;
+ @Getter private final AssetsRoot assets;
+
+ /**
+ * Create a new launcher instance with the given base directory.
+ *
+ * @param baseDir the base directory
+ * @throws java.io.IOException on load error
+ */
+ public Launcher(@NonNull File baseDir) throws IOException {
+ SharedLocale.loadBundle("com.skcraft.launcher.lang.Launcher", Locale.getDefault());
+
+ this.baseDir = baseDir;
+ this.properties = LauncherUtils.loadProperties(Launcher.class,
+ "launcher.properties", "com.skcraft.launcher.propertiesFile");
+ this.instances = new InstanceList(this);
+ this.assets = new AssetsRoot(new File(baseDir, "assets"));
+ this.config = Persistence.load(new File(baseDir, "config.json"), Configuration.class);
+ this.accounts = Persistence.load(new File(baseDir, "accounts.dat"), AccountList.class);
+
+ if (accounts.getSize() > 0) {
+ accounts.setSelectedItem(accounts.getElementAt(0));
+ }
+
+ executor.submit(new Runnable() {
+ @Override
+ public void run() {
+ cleanupExtractDir();
+ }
+ });
+ }
+
+ /**
+ * Get the launcher version.
+ *
+ * @return the launcher version
+ */
+ public String getVersion() {
+ String version = getProperties().getProperty("version");
+ if (version.equals("${project.version}")) {
+ return "1.0.0-SNAPSHOT";
+ }
+ return version;
+ }
+
+ /**
+ * Get a login service.
+ *
+ * @return a login service
+ */
+ public LoginService getLoginService() {
+ return new YggdrasilLoginService(HttpRequest.url(getProperties().getProperty("yggdrasilAuthUrl")));
+ }
+
+ /**
+ * Get the directory containing the instances.
+ *
+ * @return the instances dir
+ */
+ public File getInstancesDir() {
+ return new File(getBaseDir(), "instances");
+ }
+
+ /**
+ * Get the directory to store temporary files.
+ *
+ * @return the temporary directory
+ */
+ public File getTemporaryDir() {
+ return new File(getBaseDir(), "temp");
+ }
+
+ /**
+ * Get the directory to store temporary install files.
+ *
+ * @return the temporary install directory
+ */
+ public File getInstallerDir() {
+ return new File(getTemporaryDir(), "install");
+ }
+
+ /**
+ * Get the directory to store temporarily extracted files.
+ *
+ * @return the directory
+ */
+ private File getExtractDir() {
+ return new File(getTemporaryDir(), "extract");
+ }
+
+ /**
+ * Delete old extracted files.
+ */
+ public void cleanupExtractDir() {
+ log.info("Cleaning up temporary extracted files directory...");
+
+ final long now = System.currentTimeMillis();
+
+ File[] dirs = getExtractDir().listFiles(new FileFilter() {
+ @Override
+ public boolean accept(File pathname) {
+ try {
+ long time = Long.parseLong(pathname.getName());
+ return (now - time) > (1000 * 60 * 60);
+ } catch (NumberFormatException e) {
+ return false;
+ }
+ }
+ });
+
+ if (dirs != null) {
+ for (File dir : dirs) {
+ log.info("Removing " + dir.getAbsolutePath() + "...");
+ try {
+ FileUtils.deleteDirectory(dir);
+ } catch (IOException e) {
+ log.log(Level.WARNING, "Failed to delete " + dir.getAbsolutePath(), e);
+ }
+ }
+ }
+ }
+
+ /**
+ * Create a new temporary directory to extract files to.
+ *
+ * @return the directory path
+ */
+ public File createExtractDir() {
+ File dir = new File(getExtractDir(), String.valueOf(System.currentTimeMillis()));
+ dir.mkdirs();
+ log.info("Created temporary directory " + dir.getAbsolutePath());
+ return dir;
+ }
+
+ /**
+ * Get the directory to store the launcher binaries.
+ *
+ * @return the libraries directory
+ */
+ public File getLauncherBinariesDir() {
+ return new File(getBaseDir(), "launcher");
+ }
+
+ /**
+ * Get the directory to store common data files.
+ *
+ * @return the common data directory
+ */
+ public File getCommonDataDir() {
+ return getBaseDir();
+ }
+
+ /**
+ * Get the directory to store libraries.
+ *
+ * @return the libraries directory
+ */
+ public File getLibrariesDir() {
+ return new File(getCommonDataDir(), "libraries");
+ }
+
+ /**
+ * Get the directory to store versions.
+ *
+ * @return the versions directory
+ */
+ public File getVersionsDir() {
+ return new File(getCommonDataDir(), "versions");
+ }
+
+ /**
+ * Get the directory to store a version.
+ *
+ * @param version the version
+ * @return the directory
+ */
+ public File getVersionDir(String version) {
+ return new File(getVersionsDir(), version);
+ }
+
+ /**
+ * Get the path to the JAR for the given version manifest.
+ *
+ * @param versionManifest the version manifest
+ * @return the path
+ */
+ public File getJarPath(VersionManifest versionManifest) {
+ return new File(getVersionDir(versionManifest.getId()), versionManifest.getId() + ".jar");
+ }
+
+ /**
+ * Get the news URL.
+ *
+ * @return the news URL
+ */
+ public URL getNewsURL() {
+ try {
+ return HttpRequest.url(
+ String.format(getProperties().getProperty("newsUrl"),
+ URLEncoder.encode(getVersion(), "UTF-8")));
+ } catch (UnsupportedEncodingException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * Get the packages URL.
+ *
+ * @return the packages URL
+ */
+ public URL getPackagesURL() {
+ try {
+ String key = Strings.nullToEmpty(getConfig().getGameKey());
+ return HttpRequest.url(
+ String.format(getProperties().getProperty("packageListUrl"),
+ URLEncoder.encode(key, "UTF-8")));
+ } catch (UnsupportedEncodingException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * Convenient method to fetch a property.
+ *
+ * @param key the key
+ * @return the property
+ */
+ public String prop(String key) {
+ return getProperties().getProperty(key);
+ }
+
+ /**
+ * Convenient method to fetch a property.
+ *
+ * @param key the key
+ * @param args formatting arguments
+ * @return the property
+ */
+ public String prop(String key, String... args) {
+ return String.format(getProperties().getProperty(key), (Object[]) args);
+ }
+
+ /**
+ * Convenient method to fetch a property.
+ *
+ * @param key the key
+ * @return the property
+ */
+ public URL propUrl(String key) {
+ return HttpRequest.url(prop(key));
+ }
+
+ /**
+ * Convenient method to fetch a property.
+ *
+ * @param key the key
+ * @param args formatting arguments
+ * @return the property
+ */
+ public URL propUrl(String key, String... args) {
+ return HttpRequest.url(prop(key, args));
+ }
+
+ /**
+ * Bootstrap.
+ *
+ * @param args args
+ */
+ public static void main(String[] args) {
+ SimpleLogFormatter.configureGlobalLogger();
+
+ LauncherArguments options = new LauncherArguments();
+ try {
+ new JCommander(options, args);
+ } catch (ParameterException e) {
+ System.err.print(e.getMessage());
+ System.exit(1);
+ return;
+ }
+
+ Integer bsVersion = options.getBootstrapVersion();
+ log.info(bsVersion != null ? "Bootstrap version " + bsVersion + " detected" : "Not bootstrapped");
+
+ File dir = options.getDir();
+ if (dir != null) {
+ log.info("Using given base directory " + dir.getAbsolutePath());
+ } else {
+ dir = new File(".");
+ log.info("Using current directory " + dir.getAbsolutePath());
+ }
+
+ final File baseDir = dir;
+
+ SwingUtilities.invokeLater(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
+ UIManager.getDefaults().put("SplitPane.border", BorderFactory.createEmptyBorder());
+ Launcher launcher = new Launcher(baseDir);
+ new LauncherFrame(launcher).setVisible(true);
+ } catch (Throwable t) {
+ log.log(Level.WARNING, "Load failure", t);
+ SwingHelper.showErrorDialog(null, "Uh oh! The updater couldn't be opened because a " +
+ "problem was encountered.", "Launcher error", t);
+ }
+ }
+ });
+
+ }
+
+}
diff --git a/launcher/src/main/java/com/skcraft/launcher/LauncherArguments.java b/launcher/src/main/java/com/skcraft/launcher/LauncherArguments.java
new file mode 100644
index 0000000..2b340e0
--- /dev/null
+++ b/launcher/src/main/java/com/skcraft/launcher/LauncherArguments.java
@@ -0,0 +1,29 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.launcher;
+
+import com.beust.jcommander.Parameter;
+import lombok.Data;
+
+import java.io.File;
+
+/**
+ * The command line arguments that the launcher accepts.
+ */
+@Data
+public class LauncherArguments {
+
+ @Parameter(names = "--dir")
+ private File dir;
+
+ @Parameter(names = "--bootstrap-version")
+ private Integer bootstrapVersion;
+
+ @Parameter(names = "--portable")
+ private boolean portable;
+
+}
diff --git a/launcher/src/main/java/com/skcraft/launcher/LauncherException.java b/launcher/src/main/java/com/skcraft/launcher/LauncherException.java
new file mode 100644
index 0000000..dea9d41
--- /dev/null
+++ b/launcher/src/main/java/com/skcraft/launcher/LauncherException.java
@@ -0,0 +1,30 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.launcher;
+
+/**
+ * A human-readable error wrapper.
+ */
+public class LauncherException extends Exception {
+
+ private final String localizedMessage;
+
+ public LauncherException(String message, String localizedMessage) {
+ super(message);
+ this.localizedMessage = localizedMessage;
+ }
+
+ public LauncherException(Throwable cause, String localizedMessage) {
+ super(cause.getMessage(), cause);
+ this.localizedMessage = localizedMessage;
+ }
+
+ @Override
+ public String getLocalizedMessage() {
+ return localizedMessage;
+ }
+}
diff --git a/launcher/src/main/java/com/skcraft/launcher/LauncherUtils.java b/launcher/src/main/java/com/skcraft/launcher/LauncherUtils.java
new file mode 100644
index 0000000..0cf6e99
--- /dev/null
+++ b/launcher/src/main/java/com/skcraft/launcher/LauncherUtils.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;
+
+import com.google.common.io.Closer;
+import lombok.extern.java.Log;
+
+import java.io.*;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.List;
+import java.util.Properties;
+import java.util.regex.Pattern;
+
+@Log
+public final class LauncherUtils {
+
+ private static final Pattern absoluteUrlPattern = Pattern.compile("^[A-Za-z0-9\\-]+://.*$");
+
+ private LauncherUtils() {
+ }
+
+ public static String getStackTrace(Throwable t) {
+ Writer result = new StringWriter();
+ PrintWriter printWriter = new PrintWriter(result);
+ t.printStackTrace(printWriter);
+ return result.toString();
+ }
+
+ public static void checkInterrupted() throws InterruptedException {
+ if (Thread.interrupted()) {
+ throw new InterruptedException();
+ }
+ }
+
+ public static Properties loadProperties(Class> clazz, String name, String extraProperty) throws IOException {
+ Closer closer = Closer.create();
+ Properties prop = new Properties();
+ try {
+ InputStream in = closer.register(clazz.getResourceAsStream(name));
+ prop.load(in);
+ String extraPath = System.getProperty(extraProperty);
+ if (extraPath != null) {
+ log.info("Loading extra properties for " +
+ clazz.getCanonicalName() + ":" + name + " from " + extraPath + "...");
+ in = closer.register(new BufferedInputStream(closer.register(new FileInputStream(extraPath))));
+ prop.load(in);
+ }
+ } finally {
+ closer.close();
+ }
+
+ return prop;
+ }
+
+ public static URL concat(URL baseUrl, String url) throws MalformedURLException {
+ if (absoluteUrlPattern.matcher(url).matches()) {
+ return new URL(url);
+ }
+
+ int lastSlash = baseUrl.toExternalForm().lastIndexOf("/");
+ if (lastSlash == -1) {
+ return new URL(url);
+ }
+
+ int firstSlash = url.indexOf("/");
+ if (firstSlash == 0) {
+ boolean portSet = (baseUrl.getDefaultPort() == baseUrl.getPort() ||
+ baseUrl.getPort() == -1);
+ String port = portSet ? "" : ":" + baseUrl.getPort();
+ return new URL(baseUrl.getProtocol() + "://" + baseUrl.getHost()
+ + port + url);
+ } else {
+ return new URL(baseUrl.toExternalForm().substring(0, lastSlash + 1) + url);
+ }
+ }
+
+
+
+ public static void interruptibleDelete(File file, List failures) throws IOException, InterruptedException {
+ checkInterrupted();
+
+ if (file.isDirectory()) {
+ File[] files = file.listFiles();
+
+ if (files == null) {
+ throw new IOException("Failed to list contents of " + file.getAbsolutePath());
+ }
+
+ for (File f : files) {
+ interruptibleDelete(f, failures);
+ }
+
+ if (!file.delete()) {
+ log.warning("Failed to delete " + file.getAbsolutePath());
+ failures.add(file);
+ }
+ } else {
+ if (!file.exists()) {
+ throw new FileNotFoundException("Does not exist: " + file);
+ }
+
+ if (!file.delete()) {
+ log.warning("Failed to delete " + file.getAbsolutePath());
+ failures.add(file);
+ }
+ }
+ }
+
+}
diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/Account.java b/launcher/src/main/java/com/skcraft/launcher/auth/Account.java
new file mode 100644
index 0000000..e99bade
--- /dev/null
+++ b/launcher/src/main/java/com/skcraft/launcher/auth/Account.java
@@ -0,0 +1,91 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.launcher.auth;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.google.common.base.Strings;
+import lombok.Data;
+import lombok.NonNull;
+
+import java.util.Date;
+
+/**
+ * A user account that can be stored and loaded.
+ */
+@Data
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class Account implements Comparable {
+
+ private String id;
+ private String password;
+ private Date lastUsed;
+
+ /**
+ * Create a new account.
+ */
+ public Account() {
+ }
+
+ /**
+ * Create a new account with the given ID.
+ *
+ * @param id the ID
+ */
+ public Account(String id) {
+ setId(id);
+ }
+
+ /**
+ * Set the account's stored password, that may be stored to disk.
+ *
+ * @param password the password
+ */
+ public void setPassword(String password) {
+ if (password != null && password.isEmpty()) {
+ password = null;
+ }
+ this.password = Strings.emptyToNull(password);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+
+ Account account = (Account) o;
+
+ if (!id.equalsIgnoreCase(account.id)) return false;
+
+ return true;
+ }
+
+ @Override
+ public int hashCode() {
+ return id.toLowerCase().hashCode();
+ }
+
+ @Override
+ public int compareTo(@NonNull Account o) {
+ Date otherDate = o.getLastUsed();
+
+ if (otherDate == null && lastUsed == null) {
+ return 0;
+ } else if (otherDate == null) {
+ return -1;
+ } else if (lastUsed == null) {
+ return 1;
+ } else {
+ return -lastUsed.compareTo(otherDate);
+ }
+ }
+
+ @Override
+ public String toString() {
+ return getId();
+ }
+
+}
diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/AccountList.java b/launcher/src/main/java/com/skcraft/launcher/auth/AccountList.java
new file mode 100644
index 0000000..ddf9cb0
--- /dev/null
+++ b/launcher/src/main/java/com/skcraft/launcher/auth/AccountList.java
@@ -0,0 +1,134 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.launcher.auth;
+
+import com.fasterxml.jackson.annotation.JsonAutoDetect;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.skcraft.launcher.persistence.Scrambled;
+import lombok.Getter;
+import lombok.NonNull;
+
+import javax.swing.*;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+
+/**
+ * A list of accounts that can be stored to disk.
+ */
+@Scrambled("ACCOUNT_LIST")
+@JsonIgnoreProperties(ignoreUnknown = true)
+@JsonAutoDetect(
+ getterVisibility = JsonAutoDetect.Visibility.NONE,
+ setterVisibility = JsonAutoDetect.Visibility.NONE,
+ fieldVisibility = JsonAutoDetect.Visibility.NONE)
+public class AccountList extends AbstractListModel implements ComboBoxModel {
+
+ @JsonProperty
+ @Getter
+ private List accounts = new ArrayList();
+ private transient Account selected;
+
+ /**
+ * Add a new account.
+ *
+ * If there is already an existing account with the same ID, then the
+ * new account will not be added.
+ *
+ * @param account the account to add
+ */
+ public synchronized void add(@NonNull Account account) {
+ if (!accounts.contains(account)) {
+ accounts.add(account);
+ Collections.sort(accounts);
+ fireContentsChanged(this, 0, accounts.size());
+ }
+ }
+
+ /**
+ * Remove an account.
+ *
+ * @param account the account
+ */
+ public synchronized void remove(@NonNull Account account) {
+ Iterator it = accounts.iterator();
+ while (it.hasNext()) {
+ Account other = it.next();
+ if (other.equals(account)) {
+ it.remove();
+ fireContentsChanged(this, 0, accounts.size() + 1);
+ break;
+ }
+ }
+ }
+
+ /**
+ * Set the list of accounts.
+ *
+ * @param accounts the list of accounts
+ */
+ public synchronized void setAccounts(@NonNull List accounts) {
+ this.accounts = accounts;
+ Collections.sort(accounts);
+ }
+
+ @Override
+ @JsonIgnore
+ public synchronized int getSize() {
+ return accounts.size();
+ }
+
+ @Override
+ public synchronized Account getElementAt(int index) {
+ try {
+ return accounts.get(index);
+ } catch (IndexOutOfBoundsException e) {
+ return null;
+ }
+ }
+
+ @Override
+ public void setSelectedItem(Object item) {
+ if (item == null) {
+ selected = null;
+ return;
+ }
+
+ if (item instanceof Account) {
+ this.selected = (Account) item;
+ } else {
+ String id = String.valueOf(item).trim();
+ Account account = new Account(id);
+ for (Account test : accounts) {
+ if (test.equals(account)) {
+ account = test;
+ break;
+ }
+ }
+ selected = account;
+ }
+
+ if (selected.getId() == null || selected.getId().isEmpty()) {
+ selected = null;
+ }
+ }
+
+ @Override
+ @JsonIgnore
+ public Account getSelectedItem() {
+ return selected;
+ }
+
+ public synchronized void forgetPasswords() {
+ for (Account account : accounts) {
+ account.setPassword(null);
+ }
+ }
+}
diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/AuthenticationException.java b/launcher/src/main/java/com/skcraft/launcher/auth/AuthenticationException.java
new file mode 100644
index 0000000..4dc3f4f
--- /dev/null
+++ b/launcher/src/main/java/com/skcraft/launcher/auth/AuthenticationException.java
@@ -0,0 +1,23 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.launcher.auth;
+
+import com.skcraft.launcher.LauncherException;
+
+/**
+ * Thrown on authentication error.
+ */
+public class AuthenticationException extends LauncherException {
+
+ public AuthenticationException(String message, String localizedMessage) {
+ super(message, localizedMessage);
+ }
+
+ public AuthenticationException(Throwable cause, String localizedMessage) {
+ super(cause, localizedMessage);
+ }
+}
diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/LoginService.java b/launcher/src/main/java/com/skcraft/launcher/auth/LoginService.java
new file mode 100644
index 0000000..60419ed
--- /dev/null
+++ b/launcher/src/main/java/com/skcraft/launcher/auth/LoginService.java
@@ -0,0 +1,31 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.launcher.auth;
+
+import java.io.IOException;
+import java.util.List;
+
+/**
+ * A service for creating authenticated sessions.
+ */
+public interface LoginService {
+
+ /**
+ * Attempt to login with the given details.
+ *
+ * @param agent the game to authenticate for, such as "Minecraft"
+ * @param id the login ID
+ * @param password the password
+ * @return a list of authenticated sessions, which corresponds to identities
+ * @throws IOException thrown on I/O error
+ * @throws InterruptedException thrown if interrupted
+ * @throws AuthenticationException thrown on an authentication error
+ */
+ List extends Session> login(String agent, String id, String password)
+ throws IOException, InterruptedException, AuthenticationException;
+
+}
diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/OfflineSession.java b/launcher/src/main/java/com/skcraft/launcher/auth/OfflineSession.java
new file mode 100644
index 0000000..0431b2c
--- /dev/null
+++ b/launcher/src/main/java/com/skcraft/launcher/auth/OfflineSession.java
@@ -0,0 +1,70 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.launcher.auth;
+
+import lombok.Getter;
+import lombok.NonNull;
+
+import java.util.Collections;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * An offline session.
+ */
+public class OfflineSession implements Session {
+
+ private static Map dummyProperties = Collections.emptyMap();
+
+ @Getter
+ private final String name;
+
+ /**
+ * Create a new offline session using the given player name.
+ *
+ * @param name the player name
+ */
+ public OfflineSession(@NonNull String name) {
+ this.name = name;
+ }
+
+ @Override
+ public String getUuid() {
+ return (new UUID(0, 0)).toString();
+ }
+
+ @Override
+ public String getClientToken() {
+ return "0";
+ }
+
+ @Override
+ public String getAccessToken() {
+ return "0";
+ }
+
+ @Override
+ public Map getUserProperties() {
+ return dummyProperties;
+ }
+
+ @Override
+ public String getSessionToken() {
+ return "-";
+ }
+
+ @Override
+ public UserType getUserType() {
+ return UserType.LEGACY;
+ }
+
+ @Override
+ public boolean isOnline() {
+ return false;
+ }
+
+}
diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/Session.java b/launcher/src/main/java/com/skcraft/launcher/auth/Session.java
new file mode 100644
index 0000000..95b3e58
--- /dev/null
+++ b/launcher/src/main/java/com/skcraft/launcher/auth/Session.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.auth;
+
+import java.util.Map;
+
+/**
+ * Represents an authenticated (or virtual) session.
+ */
+public interface Session {
+
+ /**
+ * Get the user's UUID.
+ *
+ * @return the user's UUID
+ */
+ String getUuid();
+
+ /**
+ * Get the user's game username.
+ *
+ * @return the username
+ */
+ String getName();
+
+ /**
+ * Get the client token.
+ *
+ * @return client token
+ */
+ String getClientToken();
+
+ /**
+ * Get the access token.
+ *
+ * @return the access token
+ */
+ String getAccessToken();
+
+ /**
+ * Get a map of user properties.
+ *
+ * @return the map of user properties
+ */
+ Map getUserProperties();
+
+ /**
+ * Get the session token string, which is in the form of
+ * token:accessToken:uuid
for authenticated players, and
+ * simply -
for offline players.
+ *
+ * @return the session token
+ */
+ String getSessionToken();
+
+ /**
+ * Get the user type.
+ *
+ * @return the user type
+ */
+ UserType getUserType();
+
+ /**
+ * Return true if the user is in an online session.
+ *
+ * @return true if online
+ */
+ boolean isOnline();
+
+}
diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/UserType.java b/launcher/src/main/java/com/skcraft/launcher/auth/UserType.java
new file mode 100644
index 0000000..7dfeff6
--- /dev/null
+++ b/launcher/src/main/java/com/skcraft/launcher/auth/UserType.java
@@ -0,0 +1,33 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.launcher.auth;
+
+/**
+ * Represents the type of user for the account.
+ */
+public enum UserType {
+
+ /**
+ * Legacy accounts login with an account username.
+ */
+ LEGACY,
+ /**
+ * Mojang accounts login with an email address.
+ */
+ MOJANG;
+
+ /**
+ * Return a lowercase version of the enum type.
+ *
+ * @return the lowercase name
+ */
+ public String getName() {
+ return name().toLowerCase();
+ }
+
+
+}
diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/YggdrasilLoginService.java b/launcher/src/main/java/com/skcraft/launcher/auth/YggdrasilLoginService.java
new file mode 100644
index 0000000..b686953
--- /dev/null
+++ b/launcher/src/main/java/com/skcraft/launcher/auth/YggdrasilLoginService.java
@@ -0,0 +1,128 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.launcher.auth;
+
+import com.fasterxml.jackson.annotation.*;
+import com.skcraft.launcher.util.HttpRequest;
+import lombok.Data;
+import lombok.NonNull;
+import lombok.ToString;
+
+import java.io.IOException;
+import java.net.URL;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Creates authenticated sessions using the Mojang Yggdrasil login protocol.
+ */
+public class YggdrasilLoginService implements LoginService {
+
+ private final URL authUrl;
+
+ /**
+ * Create a new login service with the given authentication URL.
+ *
+ * @param authUrl the authentication URL
+ */
+ public YggdrasilLoginService(@NonNull URL authUrl) {
+ this.authUrl = authUrl;
+ }
+
+ @Override
+ public List extends Session> login(String agent, String id, String password)
+ throws IOException, InterruptedException, AuthenticationException {
+ Object payload = new AuthenticatePayload(new Agent(agent), id, password);
+
+ HttpRequest request = HttpRequest
+ .post(authUrl)
+ .bodyJson(payload)
+ .execute();
+
+ if (request.getResponseCode() != 200) {
+ ErrorResponse error = request.returnContent().asJson(ErrorResponse.class);
+ throw new AuthenticationException(error.getErrorMessage(), error.getErrorMessage());
+ } else {
+ AuthenticateResponse response = request.returnContent().asJson(AuthenticateResponse.class);
+ return response.getAvailableProfiles();
+ }
+ }
+
+ @Data
+ private static class Agent {
+ private final String name;
+ private final int version = 1;
+ }
+
+ @Data
+ private static class AuthenticatePayload {
+ private final Agent agent;
+ private final String username;
+ private final String password;
+ }
+
+ @Data
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ private static class AuthenticateResponse {
+ private String accessToken;
+ private String clientToken;
+ @JsonManagedReference private List availableProfiles;
+ private Profile selectedProfile;
+ }
+
+ @Data
+ private static class ErrorResponse {
+ private String error;
+ private String errorMessage;
+ private String cause;
+ }
+
+ /**
+ * Return in the list of available profiles.
+ */
+ @Data
+ @ToString(exclude = "response")
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ private static class Profile implements Session {
+ @JsonProperty("id") private String uuid;
+ private String name;
+ private boolean legacy;
+ @JsonIgnore private final Map userProperties = Collections.emptyMap();
+ @JsonBackReference private AuthenticateResponse response;
+
+ @Override
+ @JsonIgnore
+ public String getSessionToken() {
+ return String.format("token:%s:%s", getAccessToken(), getUuid());
+ }
+
+ @Override
+ @JsonIgnore
+ public String getClientToken() {
+ return response.getClientToken();
+ }
+
+ @Override
+ @JsonIgnore
+ public String getAccessToken() {
+ return response.getAccessToken();
+ }
+
+ @Override
+ @JsonIgnore
+ public UserType getUserType() {
+ return legacy ? UserType.LEGACY : UserType.MOJANG;
+ }
+
+ @Override
+ public boolean isOnline() {
+ return true;
+ }
+ }
+
+}
diff --git a/launcher/src/main/java/com/skcraft/launcher/dialog/ConfigurationDialog.java b/launcher/src/main/java/com/skcraft/launcher/dialog/ConfigurationDialog.java
new file mode 100644
index 0000000..a08b05e
--- /dev/null
+++ b/launcher/src/main/java/com/skcraft/launcher/dialog/ConfigurationDialog.java
@@ -0,0 +1,155 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.launcher.dialog;
+
+import com.skcraft.launcher.Configuration;
+import com.skcraft.launcher.Launcher;
+import com.skcraft.launcher.swing.*;
+import com.skcraft.launcher.persistence.Persistence;
+import lombok.NonNull;
+
+import javax.swing.*;
+import java.awt.*;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+
+import static com.skcraft.launcher.util.SharedLocale._;
+
+/**
+ * A dialog to modify configuration options.
+ */
+public class ConfigurationDialog extends JDialog {
+
+ private final Configuration config;
+ private final ObjectSwingMapper mapper;
+
+ private final JPanel tabContainer = new JPanel(new BorderLayout());
+ private final JTabbedPane tabbedPane = new JTabbedPane();
+ private final FormPanel javaSettingsPanel = new FormPanel();
+ private final JTextField jvmPathText = new JTextField();
+ private final JTextField jvmArgsText = new JTextField();
+ private final JSpinner minMemorySpinner = new JSpinner();
+ private final JSpinner maxMemorySpinner = new JSpinner();
+ private final JSpinner permGenSpinner = new JSpinner();
+ private final FormPanel gameSettingsPanel = new FormPanel();
+ private final JSpinner widthSpinner = new JSpinner();
+ private final JSpinner heightSpinner = new JSpinner();
+ private final FormPanel proxySettingsPanel = new FormPanel();
+ private final JCheckBox useProxyCheck = new JCheckBox(_("options.useProxyCheck"));
+ private final JTextField proxyHostText = new JTextField();
+ private final JSpinner proxyPortText = new JSpinner();
+ private final JTextField proxyUsernameText = new JTextField();
+ private final JPasswordField proxyPasswordText = new JPasswordField();
+ private final FormPanel advancedPanel = new FormPanel();
+ private final JTextField gameKeyText = new JTextField();
+ private final LinedBoxPanel buttonsPanel = new LinedBoxPanel(true);
+ private final JButton okButton = new JButton(_("button.ok"));
+ private final JButton cancelButton = new JButton(_("button.cancel"));
+ private final JButton logButton = new JButton(_("options.launcherConsole"));
+
+ /**
+ * Create a new configuration dialog.
+ *
+ * @param owner the window owner
+ * @param launcher the launcher
+ */
+ public ConfigurationDialog(Window owner, @NonNull Launcher launcher) {
+ super(owner, ModalityType.DOCUMENT_MODAL);
+
+ this.config = launcher.getConfig();
+ mapper = new ObjectSwingMapper(config);
+
+ setTitle(_("options.title"));
+ initComponents();
+ setDefaultCloseOperation(DISPOSE_ON_CLOSE);
+ setSize(new Dimension(400, 500));
+ setResizable(false);
+ setLocationRelativeTo(owner);
+
+ mapper.map(jvmPathText, "jvmPath");
+ mapper.map(jvmArgsText, "jvmArgs");
+ mapper.map(minMemorySpinner, "minMemory");
+ mapper.map(maxMemorySpinner, "maxMemory");
+ mapper.map(permGenSpinner, "permGen");
+ mapper.map(widthSpinner, "windowWidth");
+ mapper.map(heightSpinner, "widowHeight");
+ mapper.map(useProxyCheck, "proxyEnabled");
+ mapper.map(proxyHostText, "proxyHost");
+ mapper.map(proxyPortText, "proxyPort");
+ mapper.map(proxyUsernameText, "proxyUsername");
+ mapper.map(proxyPasswordText, "proxyPassword");
+ mapper.map(gameKeyText, "gameKey");
+
+ mapper.copyFromObject();
+ }
+
+ private void initComponents() {
+ javaSettingsPanel.addRow(new JLabel(_("options.jvmPath")), jvmPathText);
+ javaSettingsPanel.addRow(new JLabel(_("options.jvmArguments")), jvmArgsText);
+ javaSettingsPanel.addRow(Box.createVerticalStrut(15));
+ javaSettingsPanel.addRow(new JLabel(_("options.64BitJavaWarning")));
+ javaSettingsPanel.addRow(new JLabel(_("options.minMemory")), minMemorySpinner);
+ javaSettingsPanel.addRow(new JLabel(_("options.maxMemory")), maxMemorySpinner);
+ javaSettingsPanel.addRow(new JLabel(_("options.permGen")), permGenSpinner);
+ SwingHelper.removeOpaqueness(javaSettingsPanel);
+ tabbedPane.addTab(_("options.javaTab"), SwingHelper.alignTabbedPane(javaSettingsPanel));
+
+ gameSettingsPanel.addRow(new JLabel(_("options.windowWidth")), widthSpinner);
+ gameSettingsPanel.addRow(new JLabel(_("options.windowHeight")), heightSpinner);
+ SwingHelper.removeOpaqueness(gameSettingsPanel);
+ tabbedPane.addTab(_("options.minecraftTab"), SwingHelper.alignTabbedPane(gameSettingsPanel));
+
+ proxySettingsPanel.addRow(useProxyCheck);
+ proxySettingsPanel.addRow(new JLabel(_("options.proxyHost")), proxyHostText);
+ proxySettingsPanel.addRow(new JLabel(_("options.proxyPort")), proxyPortText);
+ proxySettingsPanel.addRow(new JLabel(_("options.proxyUsername")), proxyUsernameText);
+ proxySettingsPanel.addRow(new JLabel(_("options.proxyPassword")), proxyPasswordText);
+ SwingHelper.removeOpaqueness(proxySettingsPanel);
+ tabbedPane.addTab(_("options.proxyTab"), SwingHelper.alignTabbedPane(proxySettingsPanel));
+
+ advancedPanel.addRow(new JLabel(_("options.gameKey")), gameKeyText);
+ SwingHelper.removeOpaqueness(advancedPanel);
+ tabbedPane.addTab(_("options.advancedTab"), SwingHelper.alignTabbedPane(advancedPanel));
+
+ buttonsPanel.addElement(logButton);
+ buttonsPanel.addGlue();
+ buttonsPanel.addElement(okButton);
+ buttonsPanel.addElement(cancelButton);
+
+ tabContainer.add(tabbedPane, BorderLayout.CENTER);
+ tabContainer.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
+ add(tabContainer, BorderLayout.CENTER);
+ add(buttonsPanel, BorderLayout.SOUTH);
+
+ SwingHelper.equalWidth(okButton, cancelButton);
+
+ cancelButton.addActionListener(ActionListeners.dispose(this));
+
+ okButton.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ save();
+ }
+ });
+
+ logButton.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ ConsoleFrame.showMessages();
+ }
+ });
+ }
+
+ /**
+ * Save the configuration and close the dialog.
+ */
+ public void save() {
+ mapper.copyFromSwing();
+ Persistence.commitAndForget(config);
+ dispose();
+ }
+}
diff --git a/launcher/src/main/java/com/skcraft/launcher/dialog/ConsoleFrame.java b/launcher/src/main/java/com/skcraft/launcher/dialog/ConsoleFrame.java
new file mode 100644
index 0000000..eb3125c
--- /dev/null
+++ b/launcher/src/main/java/com/skcraft/launcher/dialog/ConsoleFrame.java
@@ -0,0 +1,156 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.launcher.dialog;
+
+import com.skcraft.launcher.Launcher;
+import com.skcraft.launcher.swing.LinedBoxPanel;
+import com.skcraft.launcher.swing.MessageLog;
+import com.skcraft.launcher.swing.SwingHelper;
+import com.skcraft.launcher.util.PastebinPoster;
+import lombok.Getter;
+import lombok.NonNull;
+
+import javax.swing.*;
+import java.awt.*;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.awt.event.WindowAdapter;
+import java.awt.event.WindowEvent;
+
+import static com.skcraft.launcher.util.SharedLocale._;
+
+/**
+ * A frame capable of showing messages.
+ */
+public class ConsoleFrame extends JFrame {
+
+ private static ConsoleFrame globalFrame;
+
+ @Getter private final Image trayRunningIcon;
+ @Getter private final Image trayClosedIcon;
+
+ @Getter private final MessageLog messageLog;
+ @Getter private LinedBoxPanel buttonsPanel;
+
+ private boolean registeredGlobalLog = false;
+
+ /**
+ * Construct the frame.
+ *
+ * @param numLines number of lines to show at a time
+ * @param colorEnabled true to enable a colored console
+ */
+ public ConsoleFrame(int numLines, boolean colorEnabled) {
+ this(_("console.title"), numLines, colorEnabled);
+ }
+
+ /**
+ * Construct the frame.
+ *
+ * @param title the title of the window
+ * @param numLines number of lines to show at a time
+ * @param colorEnabled true to enable a colored console
+ */
+ public ConsoleFrame(@NonNull String title, int numLines, boolean colorEnabled) {
+ messageLog = new MessageLog(numLines, colorEnabled);
+ trayRunningIcon = SwingHelper.readIconImage(Launcher.class, "tray_ok.png");
+ trayClosedIcon = SwingHelper.readIconImage(Launcher.class, "tray_closed.png");
+
+ setTitle(title);
+ setIconImage(trayRunningIcon);
+
+ setSize(new Dimension(650, 400));
+ initComponents();
+
+ setDefaultCloseOperation(WindowConstants.DO_NOTHING_ON_CLOSE);
+ addWindowListener(new WindowAdapter() {
+ @Override
+ public void windowClosing(WindowEvent event) {
+ performClose();
+ }
+ });
+ }
+
+ /**
+ * Add components to the frame.
+ */
+ private void initComponents() {
+ JButton pastebinButton = new JButton(_("console.uploadLog"));
+ buttonsPanel = new LinedBoxPanel(true);
+
+ buttonsPanel.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
+ buttonsPanel.addElement(pastebinButton);
+
+ add(buttonsPanel, BorderLayout.NORTH);
+ add(messageLog, BorderLayout.CENTER);
+
+ pastebinButton.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ pastebinLog();
+ }
+ });
+ }
+
+ /**
+ * Register the global logger if it hasn't been registered.
+ */
+ private void registerLoggerHandler() {
+ if (!registeredGlobalLog) {
+ getMessageLog().registerLoggerHandler();
+ registeredGlobalLog = true;
+ }
+ }
+
+ /**
+ * Attempt to perform window close.
+ */
+ protected void performClose() {
+ messageLog.detachGlobalHandler();
+ messageLog.clear();
+ registeredGlobalLog = false;
+ dispose();
+ }
+
+ /**
+ * Send the contents of the message log to a pastebin.
+ */
+ private void pastebinLog() {
+ String text = messageLog.getPastableText();
+ // Not really bytes!
+ messageLog.log(_("console.pasteUploading", text.length()), messageLog.asHighlighted());
+
+ PastebinPoster.paste(text, new PastebinPoster.PasteCallback() {
+ @Override
+ public void handleSuccess(String url) {
+ messageLog.log(_("console.pasteUploaded", url), messageLog.asHighlighted());
+ SwingHelper.openURL(url, messageLog);
+ }
+
+ @Override
+ public void handleError(String err) {
+ messageLog.log(_("console.pasteFailed", err), messageLog.asError());
+ }
+ });
+ }
+
+ public static void showMessages() {
+ ConsoleFrame frame = globalFrame;
+ if (frame == null) {
+ frame = new ConsoleFrame(10000, false);
+ globalFrame = frame;
+ frame.setTitle(_("console.launcherConsoleTitle"));
+ frame.registerLoggerHandler();
+ frame.setVisible(true);
+ } else {
+ frame.setVisible(true);
+ frame.registerLoggerHandler();
+ frame.requestFocus();
+ }
+ }
+
+}
diff --git a/launcher/src/main/java/com/skcraft/launcher/dialog/FeatureSelectionDialog.java b/launcher/src/main/java/com/skcraft/launcher/dialog/FeatureSelectionDialog.java
new file mode 100644
index 0000000..57b0150
--- /dev/null
+++ b/launcher/src/main/java/com/skcraft/launcher/dialog/FeatureSelectionDialog.java
@@ -0,0 +1,97 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.launcher.dialog;
+
+import com.skcraft.launcher.model.modpack.Feature;
+import com.skcraft.launcher.swing.*;
+import lombok.NonNull;
+
+import javax.swing.*;
+import javax.swing.event.ListSelectionEvent;
+import javax.swing.event.ListSelectionListener;
+import java.awt.*;
+import java.util.List;
+
+import static com.skcraft.launcher.util.SharedLocale._;
+import static javax.swing.BorderFactory.createEmptyBorder;
+
+public class FeatureSelectionDialog extends JDialog {
+
+ private final List features;
+ private final JPanel container = new JPanel(new BorderLayout());
+ private final JTextArea descText = new JTextArea(_("features.selectForInfo"));
+ private final JScrollPane descScroll = new JScrollPane(descText);
+ private final CheckboxTable componentsTable = new CheckboxTable();
+ private final JScrollPane componentsScroll = new JScrollPane(componentsTable);
+ private final JSplitPane splitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, componentsScroll, descScroll);
+ private final LinedBoxPanel buttonsPanel = new LinedBoxPanel(true);
+ private final JButton installButton = new JButton(_("features.install"));
+
+ public FeatureSelectionDialog(Window owner, @NonNull List features) {
+ super(owner, ModalityType.DOCUMENT_MODAL);
+
+ this.features = features;
+
+ setTitle(_("features.title"));
+ initComponents();
+ setDefaultCloseOperation(DISPOSE_ON_CLOSE);
+ setSize(new Dimension(500, 400));
+ setResizable(false);
+ setLocationRelativeTo(owner);
+ }
+
+ private void initComponents() {
+ componentsTable.setModel(new FeatureTableModel(features));
+
+ descScroll.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS);
+
+ descText.setFont(new JLabel().getFont());
+ descText.setEditable(false);
+ descText.setWrapStyleWord(true);
+ descText.setLineWrap(true);
+ SwingHelper.removeOpaqueness(descText);
+ descText.setComponentPopupMenu(TextFieldPopupMenu.INSTANCE);
+
+ splitPane.setDividerLocation(300);
+ splitPane.setDividerSize(6);
+ SwingHelper.flattenJSplitPane(splitPane);
+
+ container.setBorder(createEmptyBorder(12, 12, 12, 12));
+ container.add(splitPane, BorderLayout.CENTER);
+
+ buttonsPanel.addGlue();
+ buttonsPanel.addElement(installButton);
+
+ JLabel descLabel = new JLabel(_("features.intro"));
+ descLabel.setBorder(createEmptyBorder(12, 12, 4, 12));
+
+ SwingHelper.equalWidth(installButton, new JButton(_("button.cancel")));
+
+ add(descLabel, BorderLayout.NORTH);
+ add(container, BorderLayout.CENTER);
+ add(buttonsPanel, BorderLayout.SOUTH);
+
+ componentsTable.getSelectionModel().addListSelectionListener(new ListSelectionListener() {
+ public void valueChanged(ListSelectionEvent e) {
+ updateDescription();
+ }
+ });
+
+ installButton.addActionListener(ActionListeners.dispose(this));
+ }
+
+ private void updateDescription() {
+ Feature feature = features.get(componentsTable.getSelectedRow());
+
+ if (feature != null) {
+ descText.setText(feature.getDescription());
+ } else {
+ descText.setText(_("features.selectForInfo"));
+ }
+ }
+
+}
diff --git a/launcher/src/main/java/com/skcraft/launcher/dialog/LauncherFrame.java b/launcher/src/main/java/com/skcraft/launcher/dialog/LauncherFrame.java
new file mode 100644
index 0000000..f40b40c
--- /dev/null
+++ b/launcher/src/main/java/com/skcraft/launcher/dialog/LauncherFrame.java
@@ -0,0 +1,513 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.launcher.dialog;
+
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.skcraft.concurrency.ObservableFuture;
+import com.skcraft.launcher.Instance;
+import com.skcraft.launcher.InstanceList;
+import com.skcraft.launcher.Launcher;
+import com.skcraft.launcher.auth.Session;
+import com.skcraft.launcher.launch.Runner;
+import com.skcraft.launcher.launch.LaunchProcessHandler;
+import com.skcraft.launcher.persistence.Persistence;
+import com.skcraft.launcher.selfupdate.UpdateChecker;
+import com.skcraft.launcher.selfupdate.SelfUpdater;
+import com.skcraft.launcher.swing.*;
+import com.skcraft.launcher.update.HardResetter;
+import com.skcraft.launcher.update.Remover;
+import com.skcraft.launcher.update.Updater;
+import com.skcraft.launcher.util.SwingExecutor;
+import lombok.NonNull;
+import lombok.extern.java.Log;
+import org.apache.commons.io.FileUtils;
+
+import javax.swing.*;
+import javax.swing.event.TableModelEvent;
+import javax.swing.event.TableModelListener;
+import java.awt.*;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.awt.event.MouseEvent;
+import java.io.File;
+import java.io.IOException;
+import java.net.URL;
+import java.util.Date;
+import java.util.logging.Level;
+
+import static com.google.common.util.concurrent.MoreExecutors.sameThreadExecutor;
+import static com.skcraft.launcher.util.SharedLocale._;
+
+/**
+ * The main launcher frame.
+ */
+@Log
+public class LauncherFrame extends JFrame {
+
+ private final Launcher launcher;
+
+ private final HeaderPanel header = new HeaderPanel();
+ private final InstanceTable instancesTable = new InstanceTable();
+ private final InstanceTableModel instancesModel;
+ private final JScrollPane instanceScroll = new JScrollPane(instancesTable);
+ private WebpagePanel webView;
+ private JSplitPane splitPane;
+ private final JPanel container = new JPanel();
+ private final LinedBoxPanel buttonsPanel = new LinedBoxPanel(true).fullyPadded();
+ private final JButton launchButton = new JButton(_("launcher.launch"));
+ private final JButton refreshButton = new JButton(_("launcher.checkForUpdates"));
+ private final JButton optionsButton = new JButton(_("launcher.options"));
+ private final JButton selfUpdateButton = new JButton(_("launcher.updateLauncher"));
+ private final JCheckBox updateCheck = new JCheckBox(_("launcher.downloadUpdates"));
+ private URL updateUrl;
+
+ /**
+ * Create a new frame.
+ *
+ * @param launcher the launcher
+ */
+ public LauncherFrame(@NonNull Launcher launcher) {
+ super(_("launcher.title", launcher.getVersion()));
+
+ this.launcher = launcher;
+ instancesModel = new InstanceTableModel(launcher.getInstances());
+
+ setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
+ setSize(700, 450);
+ setMinimumSize(new Dimension(400, 300));
+ initComponents();
+ setLocationRelativeTo(null);
+
+ SwingHelper.setIconImage(this, Launcher.class, "icon.png");
+
+ loadInstances();
+ checkLauncherUpdate();
+ }
+
+ private void initComponents() {
+ webView = WebpagePanel.forURL(launcher.getNewsURL(), false);
+ splitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, instanceScroll, webView);
+ selfUpdateButton.setVisible(false);
+
+ updateCheck.setSelected(true);
+ instancesTable.setModel(instancesModel);
+ launchButton.setFont(launchButton.getFont().deriveFont(Font.BOLD));
+ splitPane.setDividerLocation(200);
+ splitPane.setDividerSize(4);
+ SwingHelper.flattenJSplitPane(splitPane);
+ buttonsPanel.addElement(refreshButton);
+ buttonsPanel.addElement(updateCheck);
+ buttonsPanel.addGlue();
+ buttonsPanel.addElement(selfUpdateButton);
+ buttonsPanel.addElement(optionsButton);
+ buttonsPanel.addElement(launchButton);
+ container.setLayout(new BorderLayout());
+ container.setBorder(BorderFactory.createEmptyBorder(10, 10, 0, 10));
+ container.add(splitPane, BorderLayout.CENTER);
+ add(buttonsPanel, BorderLayout.SOUTH);
+ add(container, BorderLayout.CENTER);
+
+ instancesModel.addTableModelListener(new TableModelListener() {
+ @Override
+ public void tableChanged(TableModelEvent e) {
+ if (instancesTable.getRowCount() > 0) {
+ instancesTable.setRowSelectionInterval(0, 0);
+ }
+ }
+ });
+
+ instancesTable.addMouseListener(new DoubleClickToButtonAdapter(launchButton));
+
+ refreshButton.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ loadInstances();
+ checkLauncherUpdate();
+ }
+ });
+
+ selfUpdateButton.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ selfUpdate();
+ }
+ });
+
+ optionsButton.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ showOptions();
+ }
+ });
+
+ launchButton.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ launch();
+ }
+ });
+
+ instancesTable.addMouseListener(new PopupMouseAdapter() {
+ @Override
+ protected void showPopup(MouseEvent e) {
+ int index = instancesTable.rowAtPoint(e.getPoint());
+ Instance selected = null;
+ if (index >= 0) {
+ instancesTable.setRowSelectionInterval(index, index);
+ selected = launcher.getInstances().get(index);
+ }
+ popupInstanceMenu(e.getComponent(), e.getX(), e.getY(), selected);
+ }
+ });
+ }
+
+ private void checkLauncherUpdate() {
+ if (SelfUpdater.updatedAlready) {
+ return;
+ }
+
+ ListenableFuture future = launcher.getExecutor().submit(new UpdateChecker(launcher));
+
+ Futures.addCallback(future, new FutureCallback() {
+ @Override
+ public void onSuccess(URL result) {
+ if (result != null) {
+ requestUpdate(result);
+ }
+ }
+
+ @Override
+ public void onFailure(Throwable t) {
+
+ }
+ }, SwingExecutor.INSTANCE);
+ }
+
+ private void selfUpdate() {
+ URL url = updateUrl;
+ if (url != null) {
+ SelfUpdater downloader = new SelfUpdater(launcher, url);
+ ObservableFuture future = new ObservableFuture(
+ launcher.getExecutor().submit(downloader), downloader);
+
+ Futures.addCallback(future, new FutureCallback() {
+ @Override
+ public void onSuccess(File result) {
+ selfUpdateButton.setVisible(false);
+ SwingHelper.showMessageDialog(
+ LauncherFrame.this,
+ _("launcher.selfUpdateComplete"),
+ _("launcher.selfUpdateCompleteTitle"),
+ null,
+ JOptionPane.INFORMATION_MESSAGE);
+ }
+
+ @Override
+ public void onFailure(Throwable t) {
+ }
+ }, SwingExecutor.INSTANCE);
+
+ ProgressDialog.showProgress(this, future, _("launcher.selfUpdatingTitle"), _("launcher.selfUpdatingStatus"));
+ SwingHelper.addErrorDialogCallback(this, future);
+ } else {
+ selfUpdateButton.setVisible(false);
+ }
+ }
+
+ private void requestUpdate(URL url) {
+ this.updateUrl = url;
+ selfUpdateButton.setVisible(true);
+ }
+
+ /**
+ * Popup the menu for the instances.
+ *
+ * @param component the component
+ * @param x mouse X
+ * @param y mouse Y
+ * @param selected the selected instance, possibly null
+ */
+ private void popupInstanceMenu(Component component, int x, int y, final Instance selected) {
+ JPopupMenu popup = new JPopupMenu();
+ JMenuItem menuItem;
+
+ if (selected != null) {
+ menuItem = new JMenuItem(!selected.isLocal() ? "Install" : "Launch");
+ menuItem.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ launch();
+ }
+ });
+ popup.add(menuItem);
+
+ if (selected.isLocal()) {
+ popup.addSeparator();
+
+ menuItem = new JMenuItem(_("instance.openFolder"));
+ menuItem.addActionListener(ActionListeners.browseDir(
+ LauncherFrame.this, selected.getContentDir(), true));
+ popup.add(menuItem);
+
+ menuItem = new JMenuItem(_("instance.openSaves"));
+ menuItem.addActionListener(ActionListeners.browseDir(
+ LauncherFrame.this, new File(selected.getContentDir(), "saves"), true));
+ popup.add(menuItem);
+
+ menuItem = new JMenuItem(_("instance.openResourcePacks"));
+ menuItem.addActionListener(ActionListeners.browseDir(
+ LauncherFrame.this, new File(selected.getContentDir(), "resourcepacks"), true));
+ popup.add(menuItem);
+
+ menuItem = new JMenuItem(_("instance.openScreenshots"));
+ menuItem.addActionListener(ActionListeners.browseDir(
+ LauncherFrame.this, new File(selected.getContentDir(), "screenshots"), true));
+ popup.add(menuItem);
+
+ menuItem = new JMenuItem(_("instance.copyAsPath"));
+ menuItem.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ File dir = selected.getContentDir();
+ dir.mkdirs();
+ SwingHelper.setClipboard(dir.getAbsolutePath());
+ }
+ });
+ popup.add(menuItem);
+
+ popup.addSeparator();
+
+ if (!selected.isUpdatePending()) {
+ menuItem = new JMenuItem(_("instance.forceUpdate"));
+ menuItem.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ selected.setUpdatePending(true);
+ launch();
+ instancesModel.update();
+ }
+ });
+ popup.add(menuItem);
+ }
+
+ menuItem = new JMenuItem(_("instance.hardForceUpdate"));
+ menuItem.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ confirmHardUpdate(selected);
+ }
+ });
+ popup.add(menuItem);
+
+ menuItem = new JMenuItem(_("instance.deleteFiles"));
+ menuItem.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ confirmDelete(selected);
+ }
+ });
+ popup.add(menuItem);
+ }
+
+ popup.addSeparator();
+ }
+
+ menuItem = new JMenuItem(_("launcher.refreshList"));
+ menuItem.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ loadInstances();
+ }
+ });
+ popup.add(menuItem);
+
+ popup.show(component, x, y);
+
+ }
+
+ private void confirmDelete(Instance instance) {
+ if (!SwingHelper.confirmDialog(this,
+ _("instance.confirmDelete", instance.getTitle()), _("confirmTitle"))) {
+ return;
+ }
+
+ // Execute the deleter
+ Remover resetter = new Remover(instance);
+ ObservableFuture future = new ObservableFuture(
+ launcher.getExecutor().submit(resetter), resetter);
+
+ // Show progress
+ ProgressDialog.showProgress(
+ this, future, _("instance.deletingTitle"), _("instance.deletingStatus", instance.getTitle()));
+ SwingHelper.addErrorDialogCallback(this, future);
+
+ // Update the list of instances after updating
+ future.addListener(new Runnable() {
+ @Override
+ public void run() {
+ loadInstances();
+ }
+ }, SwingExecutor.INSTANCE);
+ }
+
+ private void confirmHardUpdate(Instance instance) {
+ if (!SwingHelper.confirmDialog(this, _("instance.confirmHardUpdate"), _("confirmTitle"))) {
+ return;
+ }
+
+ // Execute the resetter
+ HardResetter resetter = new HardResetter(instance);
+ ObservableFuture future = new ObservableFuture(
+ launcher.getExecutor().submit(resetter), resetter);
+
+ // Show progress
+ ProgressDialog.showProgress( this, future, _("instance.resettingTitle"),
+ _("instance.resettingStatus", instance.getTitle()));
+ SwingHelper.addErrorDialogCallback(this, future);
+
+ // Update the list of instances after updating
+ future.addListener(new Runnable() {
+ @Override
+ public void run() {
+ launch();
+ instancesModel.update();
+ }
+ }, SwingExecutor.INSTANCE);
+ }
+
+ private void loadInstances() {
+ InstanceList.Enumerator loader = launcher.getInstances().createEnumerator();
+ ObservableFuture future = new ObservableFuture(
+ launcher.getExecutor().submit(loader), loader);
+
+ future.addListener(new Runnable() {
+ @Override
+ public void run() {
+ instancesModel.update();
+ if (instancesTable.getRowCount() > 0) {
+ instancesTable.setRowSelectionInterval(0, 0);
+ }
+ requestFocus();
+ }
+ }, SwingExecutor.INSTANCE);
+
+ ProgressDialog.showProgress(this, future, _("launcher.checkingTitle"), _("launcher.checkingStatus"));
+ SwingHelper.addErrorDialogCallback(this, future);
+ }
+
+ private void showOptions() {
+ ConfigurationDialog configDialog = new ConfigurationDialog(this, launcher);
+ configDialog.setVisible(true);
+ }
+
+ private void launch() {
+ try {
+ final Instance instance = launcher.getInstances().get(instancesTable.getSelectedRow());
+ boolean update = updateCheck.isSelected() && instance.isUpdatePending();
+
+ // Store last access date
+ Date now = new Date();
+ instance.setLastAccessed(now);
+ Persistence.commitAndForget(instance);
+
+ // Perform login
+ final Session session = LoginDialog.showLoginRequest(this, launcher);
+ if (session == null) {
+ return;
+ }
+
+ // If we have to update, we have to update
+ if (!instance.isInstalled()) {
+ update = true;
+ }
+
+ if (update) {
+ // Execute the updater
+ Updater updater = new Updater(launcher, instance);
+ updater.setOnline(session.isOnline());
+ ObservableFuture future = new ObservableFuture(
+ launcher.getExecutor().submit(updater), updater);
+
+ // Show progress
+ ProgressDialog.showProgress(
+ this, future, _("launcher.updatingTitle"), _("launcher.updatingStatus", instance.getTitle()));
+ SwingHelper.addErrorDialogCallback(this, future);
+
+ // Update the list of instances after updating
+ future.addListener(new Runnable() {
+ @Override
+ public void run() {
+ instancesModel.update();
+ }
+ }, SwingExecutor.INSTANCE);
+
+ // On success, launch also
+ Futures.addCallback(future, new FutureCallback() {
+ @Override
+ public void onSuccess(Instance result) {
+ launch(instance, session);
+ }
+
+ @Override
+ public void onFailure(Throwable t) {
+ }
+ }, SwingExecutor.INSTANCE);
+ } else {
+ launch(instance, session);
+ }
+ } catch (ArrayIndexOutOfBoundsException e) {
+ SwingHelper.showErrorDialog(this, _("launcher.noInstanceError"), _("launcher.noInstanceTitle"));
+ }
+ }
+
+ private void launch(Instance instance, Session session) {
+ final File extractDir = launcher.createExtractDir();
+
+ // Get the process
+ Runner task = new Runner(launcher, instance, session, extractDir);
+ ObservableFuture processFuture = new ObservableFuture(
+ launcher.getExecutor().submit(task), task);
+
+ // Show process for the process retrieval
+ ProgressDialog.showProgress(
+ this, processFuture, _("launcher.launchingTItle"), _("launcher.launchingStatus", instance.getTitle()));
+
+ // If the process is started, get rid of this window
+ Futures.addCallback(processFuture, new FutureCallback() {
+ @Override
+ public void onSuccess(Process result) {
+ dispose();
+ }
+
+ @Override
+ public void onFailure(Throwable t) {
+ }
+ });
+
+ // Watch the created process
+ ListenableFuture> future = Futures.transform(
+ processFuture, new LaunchProcessHandler(launcher), launcher.getExecutor());
+ SwingHelper.addErrorDialogCallback(null, future);
+
+ // Clean up at the very end
+ future.addListener(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ log.info("Process ended; cleaning up " + extractDir.getAbsolutePath());
+ FileUtils.deleteDirectory(extractDir);
+ } catch (IOException e) {
+ log.log(Level.WARNING, "Failed to clean up " + extractDir.getAbsolutePath(), e);
+ }
+ instancesModel.update();
+ }
+ }, sameThreadExecutor());
+ }
+
+}
diff --git a/launcher/src/main/java/com/skcraft/launcher/dialog/LoginDialog.java b/launcher/src/main/java/com/skcraft/launcher/dialog/LoginDialog.java
new file mode 100644
index 0000000..1d206fa
--- /dev/null
+++ b/launcher/src/main/java/com/skcraft/launcher/dialog/LoginDialog.java
@@ -0,0 +1,357 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.launcher.dialog;
+
+import com.google.common.base.Strings;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.skcraft.concurrency.ObservableFuture;
+import com.skcraft.concurrency.ProgressObservable;
+import com.skcraft.launcher.Configuration;
+import com.skcraft.launcher.Launcher;
+import com.skcraft.launcher.auth.*;
+import com.skcraft.launcher.swing.*;
+import com.skcraft.launcher.persistence.Persistence;
+import com.skcraft.launcher.util.SwingExecutor;
+import lombok.Getter;
+import lombok.NonNull;
+
+import javax.swing.*;
+import java.awt.*;
+import java.awt.event.*;
+import java.io.IOException;
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.Callable;
+
+import static com.skcraft.launcher.util.SharedLocale._;
+
+/**
+ * The login dialog.
+ */
+public class LoginDialog extends JDialog {
+
+ private final Launcher launcher;
+ @Getter private final AccountList accounts;
+ @Getter private Session session;
+
+ private final JComboBox idCombo = new JComboBox();
+ private final JPasswordField passwordText = new JPasswordField();
+ private final JCheckBox rememberIdCheck = new JCheckBox(_("login.rememberId"));
+ private final JCheckBox rememberPassCheck = new JCheckBox(_("login.rememberPassword"));
+ private final JButton loginButton = new JButton(_("login.login"));
+ private final LinkButton recoverButton = new LinkButton(_("login.recoverAccount"));
+ private final JButton offlineButton = new JButton(_("login.playOffline"));
+ private final JButton cancelButton = new JButton(_("button.cancel"));
+ private final FormPanel formPanel = new FormPanel();
+ private final LinedBoxPanel buttonsPanel = new LinedBoxPanel(true);
+
+ /**
+ * Create a new login dialog.
+ *
+ * @param owner the owner
+ * @param launcher the launcher
+ */
+ public LoginDialog(Window owner, @NonNull Launcher launcher) {
+ super(owner, ModalityType.DOCUMENT_MODAL);
+
+ this.launcher = launcher;
+ this.accounts = launcher.getAccounts();
+
+ setTitle(_("login.title"));
+ initComponents();
+ setDefaultCloseOperation(DISPOSE_ON_CLOSE);
+ setMinimumSize(new Dimension(420, 0));
+ setResizable(false);
+ pack();
+ setLocationRelativeTo(owner);
+
+ setDefaultCloseOperation(WindowConstants.DO_NOTHING_ON_CLOSE);
+ addWindowListener(new WindowAdapter() {
+ @Override
+ public void windowClosing(WindowEvent event) {
+ removeListeners();
+ dispose();
+ }
+ });
+ }
+
+ private void removeListeners() {
+ idCombo.setModel(new DefaultComboBoxModel());
+ }
+
+ private void initComponents() {
+ idCombo.setModel(getAccounts());
+ updateSelection();
+
+ rememberIdCheck.setBorder(BorderFactory.createEmptyBorder());
+ rememberPassCheck.setBorder(BorderFactory.createEmptyBorder());
+ idCombo.setEditable(true);
+ idCombo.getEditor().selectAll();
+
+ loginButton.setFont(loginButton.getFont().deriveFont(Font.BOLD));
+
+ formPanel.addRow(new JLabel(_("login.idEmail")), idCombo);
+ formPanel.addRow(new JLabel(_("login.password")), passwordText);
+ formPanel.addRow(new JLabel(), rememberIdCheck);
+ formPanel.addRow(new JLabel(), rememberPassCheck);
+ buttonsPanel.setBorder(BorderFactory.createEmptyBorder(26, 13, 13, 13));
+
+ if (launcher.getConfig().isOfflineEnabled()) {
+ buttonsPanel.addElement(offlineButton);
+ buttonsPanel.addElement(Box.createHorizontalStrut(2));
+ }
+ buttonsPanel.addElement(recoverButton);
+ buttonsPanel.addGlue();
+ buttonsPanel.addElement(loginButton);
+ buttonsPanel.addElement(cancelButton);
+
+ add(formPanel, BorderLayout.CENTER);
+ add(buttonsPanel, BorderLayout.SOUTH);
+
+ getRootPane().setDefaultButton(loginButton);
+
+ passwordText.setComponentPopupMenu(TextFieldPopupMenu.INSTANCE);
+
+ idCombo.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ updateSelection();
+ }
+ });
+
+ idCombo.getEditor().getEditorComponent().addMouseListener(new PopupMouseAdapter() {
+ @Override
+ protected void showPopup(MouseEvent e) {
+ popupManageMenu(e.getComponent(), e.getX(), e.getY());
+ }
+ });
+
+ recoverButton.addActionListener(
+ ActionListeners.openURL(recoverButton, launcher.getProperties().getProperty("resetPasswordUrl")));
+
+ loginButton.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ prepareLogin();
+ }
+ });
+
+ offlineButton.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ setResult(new OfflineSession(launcher.getProperties().getProperty("offlinePlayerName")));
+ removeListeners();
+ dispose();
+ }
+ });
+
+ cancelButton.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ removeListeners();
+ dispose();
+ }
+ });
+
+ rememberPassCheck.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ if (rememberPassCheck.isSelected()) {
+ rememberIdCheck.setSelected(true);
+ }
+ }
+ });
+
+ rememberIdCheck.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ if (!rememberIdCheck.isSelected()) {
+ rememberPassCheck.setSelected(false);
+ }
+ }
+ });
+ }
+
+ private void popupManageMenu(Component component, int x, int y) {
+ Object selected = idCombo.getSelectedItem();
+ JPopupMenu popup = new JPopupMenu();
+ JMenuItem menuItem;
+
+ if (selected != null && selected instanceof Account) {
+ final Account account = (Account) selected;
+
+ menuItem = new JMenuItem(_("login.forgetUser"));
+ menuItem.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ accounts.remove(account);
+ Persistence.commitAndForget(accounts);
+ }
+ });
+ popup.add(menuItem);
+
+ if (!Strings.isNullOrEmpty(account.getPassword())) {
+ menuItem = new JMenuItem(_("login.forgetPassword"));
+ menuItem.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ account.setPassword(null);
+ Persistence.commitAndForget(accounts);
+ }
+ });
+ popup.add(menuItem);
+ }
+ }
+
+ menuItem = new JMenuItem(_("login.forgetAllPasswords"));
+ menuItem.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ if (SwingHelper.confirmDialog(LoginDialog.this,
+ _("login.confirmForgetAllPasswords"),
+ _("login.forgetAllPasswordsTitle"))) {
+ accounts.forgetPasswords();
+ Persistence.commitAndForget(accounts);
+ }
+ }
+ });
+ popup.add(menuItem);
+
+ popup.show(component, x, y);
+ }
+
+ private void updateSelection() {
+ Object selected = idCombo.getSelectedItem();
+
+ if (selected != null && selected instanceof Account) {
+ Account account = (Account) selected;
+ String password = account.getPassword();
+
+ rememberIdCheck.setSelected(true);
+ if (!Strings.isNullOrEmpty(password)) {
+ rememberPassCheck.setSelected(true);
+ passwordText.setText(password);
+ } else {
+ rememberPassCheck.setSelected(false);
+ }
+ } else {
+ passwordText.setText("");
+ rememberIdCheck.setSelected(true);
+ rememberPassCheck.setSelected(false);
+ }
+ }
+
+ @SuppressWarnings("deprecation")
+ private void prepareLogin() {
+ Object selected = idCombo.getSelectedItem();
+
+ if (selected != null && selected instanceof Account) {
+ Account account = (Account) selected;
+ String password = passwordText.getText();
+
+ if (password == null || password.isEmpty()) {
+ SwingHelper.showErrorDialog(this, _("login.noPasswordError"), _("login.noPasswordTitle"));
+ } else {
+ if (rememberPassCheck.isSelected()) {
+ account.setPassword(password);
+ } else {
+ account.setPassword(null);
+ }
+
+ if (rememberIdCheck.isSelected()) {
+ accounts.add(account);
+ } else {
+ accounts.remove(account);
+ }
+
+ account.setLastUsed(new Date());
+
+ Persistence.commitAndForget(accounts);
+
+ attemptLogin(account, password);
+ }
+ } else {
+ SwingHelper.showErrorDialog(this, _("login.noLoginError"), _("login.noLoginTitle"));
+ }
+ }
+
+ private void attemptLogin(Account account, String password) {
+ LoginCallable callable = new LoginCallable(account, password);
+ ObservableFuture future = new ObservableFuture(
+ launcher.getExecutor().submit(callable), callable);
+
+ Futures.addCallback(future, new FutureCallback() {
+ @Override
+ public void onSuccess(Session result) {
+ setResult(result);
+ }
+
+ @Override
+ public void onFailure(Throwable t) {
+ }
+ }, SwingExecutor.INSTANCE);
+
+ ProgressDialog.showProgress(this, future, _("login.loggingInTitle"), _("login.loggingInStatus"));
+ SwingHelper.addErrorDialogCallback(this, future);
+ }
+
+ private void setResult(Session session) {
+ this.session = session;
+ removeListeners();
+ dispose();
+ }
+
+ public static Session showLoginRequest(Window owner, Launcher launcher) {
+ LoginDialog dialog = new LoginDialog(owner, launcher);
+ dialog.setVisible(true);
+ return dialog.getSession();
+ }
+
+ private class LoginCallable implements Callable,ProgressObservable {
+ private final Account account;
+ private final String password;
+
+ private LoginCallable(Account account, String password) {
+ this.account = account;
+ this.password = password;
+ }
+
+ @Override
+ public Session call() throws AuthenticationException, IOException, InterruptedException {
+ LoginService service = launcher.getLoginService();
+ List extends Session> identities = service.login(launcher.getProperties().getProperty("agentName"), account.getId(), password);
+
+ // The list of identities (profiles in Mojang terms) corresponds to whether the account
+ // owns the game, so we need to check that
+ if (identities.size() > 0) {
+ // Set offline enabled flag to true
+ Configuration config = launcher.getConfig();
+ if (!config.isOfflineEnabled()) {
+ config.setOfflineEnabled(true);
+ Persistence.commitAndForget(config);
+ }
+
+ Persistence.commitAndForget(getAccounts());
+ return identities.get(0);
+ } else {
+ throw new AuthenticationException("Minecraft not owned", _("login.minecraftNotOwnedError"));
+ }
+ }
+
+ @Override
+ public double getProgress() {
+ return -1;
+ }
+
+ @Override
+ public String getStatus() {
+ return _("login.loggingInStatus");
+ }
+ }
+
+}
diff --git a/launcher/src/main/java/com/skcraft/launcher/dialog/ProcessConsoleFrame.java b/launcher/src/main/java/com/skcraft/launcher/dialog/ProcessConsoleFrame.java
new file mode 100644
index 0000000..7f7ab6d
--- /dev/null
+++ b/launcher/src/main/java/com/skcraft/launcher/dialog/ProcessConsoleFrame.java
@@ -0,0 +1,230 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.launcher.dialog;
+
+import com.skcraft.launcher.swing.LinedBoxPanel;
+import com.skcraft.launcher.swing.SwingHelper;
+import lombok.Getter;
+import lombok.Setter;
+
+import javax.swing.*;
+import java.awt.*;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.io.PrintWriter;
+
+import static com.skcraft.launcher.util.SharedLocale._;
+
+/**
+ * A version of the console window that can manage a process.
+ */
+public class ProcessConsoleFrame extends ConsoleFrame {
+
+ private JButton killButton;
+ private JButton minimizeButton;
+ private TrayIcon trayIcon;
+
+ @Getter private Process process;
+ @Getter @Setter private boolean killOnClose;
+
+ private PrintWriter processOut;
+
+ /**
+ * Create a new instance of the frame.
+ *
+ * @param numLines the number of log lines
+ * @param colorEnabled whether color is enabled in the log
+ */
+ public ProcessConsoleFrame(int numLines, boolean colorEnabled) {
+ super(_("console.title"), numLines, colorEnabled);
+ processOut = new PrintWriter(
+ getMessageLog().getOutputStream(new Color(0, 0, 255)), true);
+ initComponents();
+ updateComponents();
+ }
+
+ /**
+ * Track the given process.
+ *
+ * @param process the process
+ */
+ public synchronized void setProcess(Process process) {
+ try {
+ Process lastProcess = this.process;
+ if (lastProcess != null) {
+ processOut.println(_("console.processEndCode", lastProcess.exitValue()));
+ }
+ } catch (IllegalThreadStateException e) {
+ }
+
+ if (process != null) {
+ processOut.println(_("console.attachedToProcess"));
+ }
+
+ this.process = process;
+
+ SwingUtilities.invokeLater(new Runnable() {
+ public void run() {
+ updateComponents();
+ }
+ });
+ }
+
+ private synchronized boolean hasProcess() {
+ return process != null;
+ }
+
+ @Override
+ protected void performClose() {
+ if (hasProcess()) {
+ if (killOnClose) {
+ performKill();
+ }
+ }
+
+ if (trayIcon != null) {
+ SystemTray.getSystemTray().remove(trayIcon);
+ }
+
+ super.performClose();
+ }
+
+ private void performKill() {
+ if (!confirmKill()) {
+ return;
+ }
+
+ synchronized (this) {
+ if (hasProcess()) {
+ process.destroy();
+ setProcess(null);
+ }
+ }
+
+ updateComponents();
+ }
+
+ protected void initComponents() {
+ killButton = new JButton(_("console.forceClose"));
+ minimizeButton = new JButton(); // Text set later
+
+ LinedBoxPanel buttonsPanel = getButtonsPanel();
+ buttonsPanel.addGlue();
+ buttonsPanel.addElement(killButton);
+ buttonsPanel.addElement(minimizeButton);
+
+ killButton.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ performKill();
+ }
+ });
+
+ minimizeButton.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ contextualClose();
+ }
+ });
+
+ if (!setupTrayIcon()) {
+ minimizeButton.setEnabled(true);
+ }
+ }
+
+ private boolean setupTrayIcon() {
+ if (!SystemTray.isSupported()) {
+ return false;
+ }
+
+ trayIcon = new TrayIcon(getTrayRunningIcon());
+ trayIcon.setImageAutoSize(true);
+ trayIcon.setToolTip(_("console.trayTooltip"));
+
+ trayIcon.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ reshow();
+ }
+ });
+
+ PopupMenu popup = new PopupMenu();
+ MenuItem item;
+
+ popup.add(item = new MenuItem(_("console.trayTitle")));
+ item.setEnabled(false);
+
+ popup.add(item = new MenuItem(_("console.tray.showWindow")));
+ item.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ reshow();
+ }
+ });
+
+ popup.add(item = new MenuItem(_("console.tray.forceClose")));
+ item.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ performKill();
+ }
+ });
+
+ trayIcon.setPopupMenu(popup);
+
+ try {
+ SystemTray tray = SystemTray.getSystemTray();
+ tray.add(trayIcon);
+ return true;
+ } catch (AWTException e) {
+ }
+
+ return false;
+ }
+
+ private synchronized void updateComponents() {
+ Image icon = hasProcess() ? getTrayRunningIcon() : getTrayClosedIcon();
+
+ killButton.setEnabled(hasProcess());
+
+ if (!hasProcess() || trayIcon == null) {
+ minimizeButton.setText(_("console.closeWindow"));
+ } else {
+ minimizeButton.setText(_("console.hideWindow"));
+ }
+
+ if (trayIcon != null) {
+ trayIcon.setImage(icon);
+ }
+
+ setIconImage(icon);
+ }
+
+ private synchronized void contextualClose() {
+ if (!hasProcess() || trayIcon == null) {
+ performClose();
+ } else {
+ minimize();
+ }
+
+ updateComponents();
+ }
+
+ private boolean confirmKill() {
+ return SwingHelper.confirmDialog(this, _("console.confirmKill"), _("console.confirmKillTitle"));
+ }
+
+ private void minimize() {
+ setVisible(false);
+ }
+
+ private void reshow() {
+ setVisible(true);
+ requestFocus();
+ }
+
+}
diff --git a/launcher/src/main/java/com/skcraft/launcher/dialog/ProgressDialog.java b/launcher/src/main/java/com/skcraft/launcher/dialog/ProgressDialog.java
new file mode 100644
index 0000000..4cb0320
--- /dev/null
+++ b/launcher/src/main/java/com/skcraft/launcher/dialog/ProgressDialog.java
@@ -0,0 +1,244 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.launcher.dialog;
+
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.skcraft.concurrency.ObservableFuture;
+import com.skcraft.concurrency.ProgressObservable;
+import com.skcraft.launcher.swing.LinedBoxPanel;
+import com.skcraft.launcher.swing.SwingHelper;
+import com.skcraft.launcher.util.SwingExecutor;
+import lombok.extern.java.Log;
+
+import javax.swing.*;
+import java.awt.*;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.awt.event.WindowAdapter;
+import java.awt.event.WindowEvent;
+import java.lang.ref.WeakReference;
+import java.util.Timer;
+import java.util.TimerTask;
+
+import static com.skcraft.launcher.util.SharedLocale._;
+
+@Log
+public class ProgressDialog extends JDialog {
+
+ private static WeakReference lastDialogRef;
+
+ private final String defaultTitle;
+ private final String defaultMessage;
+ private final JLabel label = new JLabel();
+ private final JPanel progressPanel = new JPanel(new BorderLayout(0, 5));
+ private final JPanel textAreaPanel = new JPanel(new BorderLayout());
+ private final JProgressBar progressBar = new JProgressBar();
+ private final LinedBoxPanel buttonsPanel = new LinedBoxPanel(true);
+ private final JTextArea logText = new JTextArea();
+ private final JScrollPane logScroll = new JScrollPane(logText);
+ private final JButton detailsButton = new JButton();
+ private final JButton logButton = new JButton(_("progress.viewLog"));
+ private final JButton cancelButton = new JButton(_("button.cancel"));
+
+ public ProgressDialog(Window owner, String title, String message) {
+ super(owner, title, ModalityType.DOCUMENT_MODAL);
+
+ setResizable(false);
+ initComponents();
+ label.setText(message);
+ defaultTitle = title;
+ defaultMessage = message;
+ setCompactSize();
+ setLocationRelativeTo(owner);
+
+ setDefaultCloseOperation(WindowConstants.DO_NOTHING_ON_CLOSE);
+ addWindowListener(new WindowAdapter() {
+ @Override
+ public void windowClosing(WindowEvent event) {
+ if (confirmCancel()) {
+ cancel();
+ dispose();
+ }
+ }
+ });
+ }
+
+ private void setCompactSize() {
+ detailsButton.setText(_("progress.details"));
+ logButton.setVisible(false);
+ setMinimumSize(new Dimension(400, 100));
+ pack();
+ }
+
+ private void setDetailsSize() {
+ detailsButton.setText(_("progress.less"));
+ logButton.setVisible(true);
+ setSize(400, 350);
+ }
+
+ private void initComponents() {
+ progressBar.setMaximum(1000);
+ progressBar.setMinimum(0);
+ progressBar.setIndeterminate(true);
+ progressBar.setPreferredSize(new Dimension(0, 18));
+
+ buttonsPanel.addElement(detailsButton);
+ buttonsPanel.addElement(logButton);
+ buttonsPanel.addGlue();
+ buttonsPanel.addElement(cancelButton);
+ buttonsPanel.setBorder(BorderFactory.createEmptyBorder(30, 13, 13, 13));
+
+ logScroll.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS);
+ logText.setBackground(getBackground());
+ logText.setEditable(false);
+ logText.setLineWrap(true);
+ logText.setWrapStyleWord(false);
+ logText.setFont(new JLabel().getFont());
+
+ progressPanel.add(label, BorderLayout.NORTH);
+ progressPanel.setBorder(BorderFactory.createEmptyBorder(13, 13, 0, 13));
+ progressPanel.add(progressBar, BorderLayout.CENTER);
+ textAreaPanel.setBorder(BorderFactory.createEmptyBorder(10, 13, 0, 13));
+ textAreaPanel.add(logScroll, BorderLayout.CENTER);
+
+ add(progressPanel, BorderLayout.NORTH);
+ add(textAreaPanel, BorderLayout.CENTER);
+ add(buttonsPanel, BorderLayout.SOUTH);
+
+ textAreaPanel.setVisible(false);
+ cancelButton.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ if (confirmCancel()) {
+ cancel();
+ dispose();
+ }
+ }
+ });
+
+ detailsButton.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ toggleDetails();
+ }
+ });
+
+ logButton.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ ConsoleFrame.showMessages();
+ }
+ });
+ }
+
+ private boolean confirmCancel() {
+ return SwingHelper.confirmDialog(this, _("progress.confirmCancel"), _("progress.confirmCancelTitle"));
+ }
+
+ protected void cancel() {
+ }
+
+ private void toggleDetails() {
+ if (textAreaPanel.isVisible()) {
+ textAreaPanel.setVisible(false);
+ setCompactSize();
+ } else {
+ textAreaPanel.setVisible(true);
+ setDetailsSize();
+ }
+ setLocationRelativeTo(getOwner());
+ }
+
+ public static void showProgress(final Window owner, final ObservableFuture> future, String title, String message) {
+ final ProgressDialog dialog = new ProgressDialog(owner, title, message) {
+ @Override
+ protected void cancel() {
+ future.cancel(true);
+ }
+ };
+
+ lastDialogRef = new WeakReference(dialog);
+
+ final Timer timer = new Timer();
+ timer.scheduleAtFixedRate(new UpdateProgress(dialog, future), 400, 400);
+
+ Futures.addCallback(future, new FutureCallback