diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7e2d1f7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,34 @@
+# Eclipse stuff
+/.classpath
+/.project
+/.settings
+
+# netbeans
+/nbproject
+
+# we use maven!
+/build.xml
+
+# maven
+/target
+
+# vim
+.*.sw[a-p]
+
+# various other potential build files
+/build
+/bin
+/dist
+/manifest.mf
+
+# Mac filesystem dust
+/.DS_Store
+
+# intellij
+*.iml
+*.ipr
+*.iws
+.idea/
+
+/dependency-reduced-pom.xml
+/*.pack
\ No newline at end of file
diff --git a/LICENSE.txt b/LICENSE.txt
new file mode 100644
index 0000000..c2f5a43
--- /dev/null
+++ b/LICENSE.txt
@@ -0,0 +1,259 @@
+The following license applies files of the project, other than graphics files
+and non-XML resources included within src/main/resources:
+
+ GNU LESSER GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+
+ This version of the GNU Lesser General Public License incorporates
+the terms and conditions of version 3 of the GNU General Public
+License, supplemented by the additional permissions listed below.
+
+ 0. Additional Definitions.
+
+ As used herein, "this License" refers to version 3 of the GNU Lesser
+General Public License, and the "GNU GPL" refers to version 3 of the GNU
+General Public License.
+
+ "The Library" refers to a covered work governed by this License,
+other than an Application or a Combined Work as defined below.
+
+ An "Application" is any work that makes use of an interface provided
+by the Library, but which is not otherwise based on the Library.
+Defining a subclass of a class defined by the Library is deemed a mode
+of using an interface provided by the Library.
+
+ A "Combined Work" is a work produced by combining or linking an
+Application with the Library. The particular version of the Library
+with which the Combined Work was made is also called the "Linked
+Version".
+
+ The "Minimal Corresponding Source" for a Combined Work means the
+Corresponding Source for the Combined Work, excluding any source code
+for portions of the Combined Work that, considered in isolation, are
+based on the Application, and not on the Linked Version.
+
+ The "Corresponding Application Code" for a Combined Work means the
+object code and/or source code for the Application, including any data
+and utility programs needed for reproducing the Combined Work from the
+Application, but excluding the System Libraries of the Combined Work.
+
+ 1. Exception to Section 3 of the GNU GPL.
+
+ You may convey a covered work under sections 3 and 4 of this License
+without being bound by section 3 of the GNU GPL.
+
+ 2. Conveying Modified Versions.
+
+ If you modify a copy of the Library, and, in your modifications, a
+facility refers to a function or data to be supplied by an Application
+that uses the facility (other than as an argument passed when the
+facility is invoked), then you may convey a copy of the modified
+version:
+
+ a) under this License, provided that you make a good faith effort to
+ ensure that, in the event an Application does not supply the
+ function or data, the facility still operates, and performs
+ whatever part of its purpose remains meaningful, or
+
+ b) under the GNU GPL, with none of the additional permissions of
+ this License applicable to that copy.
+
+ 3. Object Code Incorporating Material from Library Header Files.
+
+ The object code form of an Application may incorporate material from
+a header file that is part of the Library. You may convey such object
+code under terms of your choice, provided that, if the incorporated
+material is not limited to numerical parameters, data structure
+layouts and accessors, or small macros, inline functions and templates
+(ten or fewer lines in length), you do both of the following:
+
+ a) Give prominent notice with each copy of the object code that the
+ Library is used in it and that the Library and its use are
+ covered by this License.
+
+ b) Accompany the object code with a copy of the GNU GPL and this license
+ document.
+
+ 4. Combined Works.
+
+ You may convey a Combined Work under terms of your choice that,
+taken together, effectively do not restrict modification of the
+portions of the Library contained in the Combined Work and reverse
+engineering for debugging such modifications, if you also do each of
+the following:
+
+ a) Give prominent notice with each copy of the Combined Work that
+ the Library is used in it and that the Library and its use are
+ covered by this License.
+
+ b) Accompany the Combined Work with a copy of the GNU GPL and this license
+ document.
+
+ c) For a Combined Work that displays copyright notices during
+ execution, include the copyright notice for the Library among
+ these notices, as well as a reference directing the user to the
+ copies of the GNU GPL and this license document.
+
+ d) Do one of the following:
+
+ 0) Convey the Minimal Corresponding Source under the terms of this
+ License, and the Corresponding Application Code in a form
+ suitable for, and under terms that permit, the user to
+ recombine or relink the Application with a modified version of
+ the Linked Version to produce a modified Combined Work, in the
+ manner specified by section 6 of the GNU GPL for conveying
+ Corresponding Source.
+
+ 1) Use a suitable shared library mechanism for linking with the
+ Library. A suitable mechanism is one that (a) uses at run time
+ a copy of the Library already present on the user's computer
+ system, and (b) will operate properly with a modified version
+ of the Library that is interface-compatible with the Linked
+ Version.
+
+ e) Provide Installation Information, but only if you would otherwise
+ be required to provide such information under section 6 of the
+ GNU GPL, and only to the extent that such information is
+ necessary to install and execute a modified version of the
+ Combined Work produced by recombining or relinking the
+ Application with a modified version of the Linked Version. (If
+ you use option 4d0, the Installation Information must accompany
+ the Minimal Corresponding Source and Corresponding Application
+ Code. If you use option 4d1, you must provide the Installation
+ Information in the manner specified by section 6 of the GNU GPL
+ for conveying Corresponding Source.)
+
+ 5. Combined Libraries.
+
+ You may place library facilities that are a work based on the
+Library side by side in a single library together with other library
+facilities that are not Applications and are not covered by this
+License, and convey such a combined library under terms of your
+choice, if you do both of the following:
+
+ a) Accompany the combined library with a copy of the same work based
+ on the Library, uncombined with any other library facilities,
+ conveyed under the terms of this License.
+
+ b) Give prominent notice with the combined library that part of it
+ is a work based on the Library, and explaining where to find the
+ accompanying uncombined form of the same work.
+
+ 6. Revised Versions of the GNU Lesser General Public License.
+
+ The Free Software Foundation may publish revised and/or new versions
+of the GNU Lesser General Public License from time to time. Such new
+versions will be similar in spirit to the present version, but may
+differ in detail to address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Library as you received it specifies that a certain numbered version
+of the GNU Lesser General Public License "or any later version"
+applies to it, you have the option of following the terms and
+conditions either of that published version or of any later version
+published by the Free Software Foundation. If the Library as you
+received it does not specify a version number of the GNU Lesser
+General Public License, you may choose any version of the GNU Lesser
+General Public License ever published by the Free Software Foundation.
+
+ If the Library as you received it specifies that a proxy can decide
+whether future versions of the GNU Lesser General Public License shall
+apply, that proxy's public statement of acceptance of any version is
+permanent authorization for you to choose that version for the
+Library.
+
+
+JNBT License
+------------
+
+Copyright (c) 2010 Graham Edgecombe
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+ * Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer.
+
+ * Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+
+ * Neither the name of the JNBT team nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE.
+
+
+JChronic License
+----------------
+
+The MIT License
+
+Copyright (c) 2009 Mike Schrag, Sam Tingleff
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+-------------------------------------------------------------------------------
+
+The following license also applies to contributions by third parties:
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+* Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+
+* The names of its contributors may not be used to endorse or promote
+ products derived from this software without specific prior
+ written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
+IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
+PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
+TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1981b2c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,11 @@
+SKCraft Launcher
+================
+
+License
+-------
+
+The launcher is licensed under the GNU General Public License, version 3.
+
+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).
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..9cacad6
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,120 @@
+
+
+
+ 4.0.0
+
+ com.skcraft
+ launcher
+ 4.0.0-SNAPSHOT
+
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ 2.3.0
+
+
+ commons-lang
+ commons-lang
+ 2.6
+
+
+ commons-io
+ commons-io
+ 1.2
+
+
+ org.projectlombok
+ lombok
+ 1.12.2
+
+
+ com.google.guava
+ guava
+ 15.0
+
+
+ com.beust
+ jcommander
+ 1.32
+
+
+
+
+ SKCraftLauncher
+
+
+
+ src/main/resources
+ true
+
+ **/*.properties
+
+
+
+ src/main/resources
+ false
+
+ **/*.properties
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 2.3.2
+
+ 1.6
+ 1.6
+
+
+
+ org.apache.maven.plugins
+ maven-jar-plugin
+ 2.3.1
+
+
+ false
+
+ com.skcraft.launcher.Launcher
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-shade-plugin
+ 2.0
+
+
+ package
+
+ shade
+
+
+
+
+ org.projectlombok:lombok
+
+
+
+
+
+
+ false
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/main/java/com/skcraft/concurrency/ObservableFuture.java b/src/main/java/com/skcraft/concurrency/ObservableFuture.java
new file mode 100644
index 0000000..c4fee92
--- /dev/null
+++ b/src/main/java/com/skcraft/concurrency/ObservableFuture.java
@@ -0,0 +1,78 @@
+/*
+ * 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 double getProgress() {
+ return observable.getProgress();
+ }
+
+ @Override
+ public String toString() {
+ return observable.toString();
+ }
+
+}
diff --git a/src/main/java/com/skcraft/concurrency/ProgressObservable.java b/src/main/java/com/skcraft/concurrency/ProgressObservable.java
new file mode 100644
index 0000000..590a56a
--- /dev/null
+++ b/src/main/java/com/skcraft/concurrency/ProgressObservable.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.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();
+
+}
diff --git a/src/main/java/com/skcraft/launcher/AssetsRoot.java b/src/main/java/com/skcraft/launcher/AssetsRoot.java
new file mode 100644
index 0000000..5bd2da9
--- /dev/null
+++ b/src/main/java/com/skcraft/launcher/AssetsRoot.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;
+
+import com.google.common.io.Files;
+import com.skcraft.launcher.model.minecraft.Asset;
+import com.skcraft.launcher.model.minecraft.AssetsIndex;
+import com.skcraft.launcher.persistence.Persistence;
+import lombok.Getter;
+import lombok.extern.java.Log;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Map;
+import java.util.logging.Level;
+
+@Log
+public class AssetsRoot {
+
+ @Getter
+ private final File dir;
+
+ public AssetsRoot(File dir) {
+ this.dir = dir;
+ }
+
+ public File getIndexPath(String indexId) {
+ return new File(dir, "indexes/" + indexId + ".json");
+ }
+
+ public File getObjectPath(Asset asset) {
+ String hash = asset.getHash();
+ return new File(dir, "objects/" + hash.substring(0, 2) + "/" + hash);
+ }
+
+ public File buildAssetTree(String indexId) throws IOException {
+ log.info("Building asset virtual tree for '" + indexId + "'");
+
+ AssetsIndex index = Persistence.read(getIndexPath(indexId), AssetsIndex.class);
+ File treeDir = new File(dir, "virtual/" + indexId);
+ treeDir.mkdirs();
+
+ for (Map.Entry entry : index.getObjects().entrySet()) {
+ File objectPath = getObjectPath(entry.getValue());
+ File virtualPath = new File(treeDir, entry.getKey());
+ virtualPath.getParentFile().mkdirs();
+ if (!virtualPath.exists()) {
+ log.log(Level.INFO, "Copying {0} to {1}...", new Object[] {
+ objectPath.getAbsolutePath(), virtualPath.getAbsolutePath()});
+ Files.copy(objectPath, virtualPath);
+ }
+ }
+
+ return treeDir;
+ }
+
+}
diff --git a/src/main/java/com/skcraft/launcher/Configuration.java b/src/main/java/com/skcraft/launcher/Configuration.java
new file mode 100644
index 0000000..0d08d66
--- /dev/null
+++ b/src/main/java/com/skcraft/launcher/Configuration.java
@@ -0,0 +1,41 @@
+/*
+ * 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;
+
+@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/src/main/java/com/skcraft/launcher/Instance.java b/src/main/java/com/skcraft/launcher/Instance.java
new file mode 100644
index 0000000..6fc5af3
--- /dev/null
+++ b/src/main/java/com/skcraft/launcher/Instance.java
@@ -0,0 +1,100 @@
+/*
+ * 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 lombok.Data;
+
+import java.io.File;
+import java.net.URL;
+import java.util.Date;
+
+@Data
+public class Instance implements Comparable {
+
+ private String title;
+ private String name;
+ private String version;
+ private boolean updatePending;
+ private boolean installed;
+ private Date lastAccessed;
+
+ @JsonIgnore private File dir;
+ @JsonIgnore private URL manifestURL;
+ @JsonIgnore private int priority;
+ @JsonIgnore private boolean selected;
+ @JsonIgnore private boolean local;
+
+ public String getTitle() {
+ return title != null ? title : name;
+ }
+
+ @JsonIgnore
+ public File getContentDir() {
+ return new File(dir, "minecraft");
+ }
+
+ @JsonIgnore
+ public File getManifestPath() {
+ return new File(dir, "manifest.json");
+ }
+
+ @JsonIgnore
+ public File getVersionManifestPath() {
+ return new File(dir, "version.json");
+ }
+
+ @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/src/main/java/com/skcraft/launcher/InstanceList.java b/src/main/java/com/skcraft/launcher/InstanceList.java
new file mode 100644
index 0000000..b6326a4
--- /dev/null
+++ b/src/main/java/com/skcraft/launcher/InstanceList.java
@@ -0,0 +1,172 @@
+/*
+ * 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.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._;
+
+@Log
+public class InstanceList {
+
+ private static int PARSER_VERSION = 1;
+ private final Launcher launcher;
+ @Getter private final List instances = new ArrayList();
+
+ public InstanceList(@NonNull Launcher launcher) {
+ this.launcher = launcher;
+ }
+
+ public synchronized Instance get(int index) {
+ return instances.get(index);
+ }
+
+ public synchronized int size() {
+ return instances.size();
+ }
+
+ public Enumerator createEnumerator() {
+ return new Enumerator();
+ }
+
+ public synchronized List getSelected() {
+ List selected = new ArrayList();
+ for (Instance instance : instances) {
+ if (instance.isSelected()) {
+ selected.add(instance);
+ }
+ }
+
+ return selected;
+ }
+
+ public synchronized void sort() {
+ Collections.sort(instances);
+ }
+
+ public final class Enumerator implements Callable, ProgressObservable {
+ private Enumerator() {
+ }
+
+ @Override
+ public InstanceList call() throws Exception {
+ log.info("Enumerating instance list...");
+
+ 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());
+ }
+ }
+
+ try {
+ URL packagesURL = launcher.getPackagesURL();
+
+ PackageList packages = HttpRequest
+ .get(packagesURL)
+ .execute()
+ .expectResponseCode(200)
+ .returnContent()
+ .asJson(PackageList.class);
+
+ if (packages.getMinimumVersion() > PARSER_VERSION) {
+ throw new LauncherException("Update required", _("errors.updateRequiredError"));
+ }
+
+ for (ManifestInfo manifest : packages.getPackages()) {
+ boolean foundLocal = false;
+
+ for (Instance instance : local) {
+ if (instance.getName().equals(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;
+ }
+ }
+}
diff --git a/src/main/java/com/skcraft/launcher/Launcher.java b/src/main/java/com/skcraft/launcher/Launcher.java
new file mode 100644
index 0000000..2b1d303
--- /dev/null
+++ b/src/main/java/com/skcraft/launcher/Launcher.java
@@ -0,0 +1,320 @@
+/*
+ * 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 {
+
+ @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");
+ 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);
+ }
+ }
+
+ /**
+ * 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/src/main/java/com/skcraft/launcher/LauncherArguments.java b/src/main/java/com/skcraft/launcher/LauncherArguments.java
new file mode 100644
index 0000000..967850c
--- /dev/null
+++ b/src/main/java/com/skcraft/launcher/LauncherArguments.java
@@ -0,0 +1,26 @@
+/*
+ * 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;
+
+@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/src/main/java/com/skcraft/launcher/LauncherException.java b/src/main/java/com/skcraft/launcher/LauncherException.java
new file mode 100644
index 0000000..dea9d41
--- /dev/null
+++ b/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/src/main/java/com/skcraft/launcher/LauncherUtils.java b/src/main/java/com/skcraft/launcher/LauncherUtils.java
new file mode 100644
index 0000000..3a18751
--- /dev/null
+++ b/src/main/java/com/skcraft/launcher/LauncherUtils.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;
+
+import com.google.common.io.Closer;
+
+import java.io.*;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.Properties;
+import java.util.regex.Pattern;
+
+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) throws IOException {
+ Closer closer = Closer.create();
+ Properties prop = new Properties();
+ try {
+ InputStream in = closer.register(clazz.getResourceAsStream(name));
+ 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) 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);
+ }
+
+ file.delete();
+ } else {
+ if (!file.exists()) {
+ throw new FileNotFoundException("Does not exist: " + file);
+ }
+
+ file.delete();
+ }
+ }
+
+}
diff --git a/src/main/java/com/skcraft/launcher/auth/Account.java b/src/main/java/com/skcraft/launcher/auth/Account.java
new file mode 100644
index 0000000..e99bade
--- /dev/null
+++ b/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/src/main/java/com/skcraft/launcher/auth/AccountList.java b/src/main/java/com/skcraft/launcher/auth/AccountList.java
new file mode 100644
index 0000000..ddf9cb0
--- /dev/null
+++ b/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/src/main/java/com/skcraft/launcher/auth/AuthenticationException.java b/src/main/java/com/skcraft/launcher/auth/AuthenticationException.java
new file mode 100644
index 0000000..4dc3f4f
--- /dev/null
+++ b/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/src/main/java/com/skcraft/launcher/auth/LoginService.java b/src/main/java/com/skcraft/launcher/auth/LoginService.java
new file mode 100644
index 0000000..60419ed
--- /dev/null
+++ b/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/src/main/java/com/skcraft/launcher/auth/OfflineSession.java b/src/main/java/com/skcraft/launcher/auth/OfflineSession.java
new file mode 100644
index 0000000..04223df
--- /dev/null
+++ b/src/main/java/com/skcraft/launcher/auth/OfflineSession.java
@@ -0,0 +1,65 @@
+/*
+ * 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;
+ }
+
+}
diff --git a/src/main/java/com/skcraft/launcher/auth/Session.java b/src/main/java/com/skcraft/launcher/auth/Session.java
new file mode 100644
index 0000000..5aa5adf
--- /dev/null
+++ b/src/main/java/com/skcraft/launcher/auth/Session.java
@@ -0,0 +1,67 @@
+/*
+ * 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();
+
+}
diff --git a/src/main/java/com/skcraft/launcher/auth/UserType.java b/src/main/java/com/skcraft/launcher/auth/UserType.java
new file mode 100644
index 0000000..7dfeff6
--- /dev/null
+++ b/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/src/main/java/com/skcraft/launcher/auth/YggdrasilLoginService.java b/src/main/java/com/skcraft/launcher/auth/YggdrasilLoginService.java
new file mode 100644
index 0000000..ca97a47
--- /dev/null
+++ b/src/main/java/com/skcraft/launcher/auth/YggdrasilLoginService.java
@@ -0,0 +1,123 @@
+/*
+ * 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;
+ }
+ }
+
+}
diff --git a/src/main/java/com/skcraft/launcher/builder/ClientFileCollector.java b/src/main/java/com/skcraft/launcher/builder/ClientFileCollector.java
new file mode 100644
index 0000000..f2efdd2
--- /dev/null
+++ b/src/main/java/com/skcraft/launcher/builder/ClientFileCollector.java
@@ -0,0 +1,68 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.launcher.builder;
+
+import com.google.common.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 java.io.File;
+import java.io.IOException;
+
+/**
+ * Walks a path and adds hashed path versions to the given
+ * {@link com.skcraft.launcher.model.modpack.Manifest}.
+ */
+@Log
+public class ClientFileCollector extends DirectoryWalker {
+
+ private final Manifest manifest;
+ private final File destDir;
+ private HashFunction hf = Hashing.sha1();
+
+ /**
+ * Create a new collector.
+ *
+ * @param manifest the manifest
+ * @param destDir the destination directory to copy the hashed objects
+ */
+ public ClientFileCollector(@NonNull Manifest manifest, @NonNull File destDir) {
+ this.manifest = manifest;
+ this.destDir = destDir;
+ }
+
+ @Override
+ public DirectoryBehavior getBehavior(@NonNull String name) {
+ if (name.equals("_SERVER")) {
+ return DirectoryBehavior.SKIP;
+ } else if (name.equals("_CLIENT")) {
+ return DirectoryBehavior.IGNORE;
+ } else {
+ return DirectoryBehavior.CONTINUE;
+ }
+ }
+
+ @Override
+ protected void onFile(File file, String relPath) throws IOException {
+ FileInstall task = 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);
+ task.setHash(hash);
+ task.setLocation(hashedPath);
+ task.setTo(relPath);
+ destPath.getParentFile().mkdirs();
+ ClientFileCollector.log.info(String.format("Adding %s from %s...", relPath, file.getAbsolutePath()));
+ Files.copy(file, destPath);
+ manifest.getTasks().add(task);
+ }
+
+}
diff --git a/src/main/java/com/skcraft/launcher/builder/DirectoryWalker.java b/src/main/java/com/skcraft/launcher/builder/DirectoryWalker.java
new file mode 100644
index 0000000..cf8ea95
--- /dev/null
+++ b/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
+ */
+ public 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/src/main/java/com/skcraft/launcher/builder/PackageBuilder.java b/src/main/java/com/skcraft/launcher/builder/PackageBuilder.java
new file mode 100644
index 0000000..e80aa31
--- /dev/null
+++ b/src/main/java/com/skcraft/launcher/builder/PackageBuilder.java
@@ -0,0 +1,105 @@
+/*
+ * SK's Minecraft Launcher
+ * Copyright (C) 2010-2014 Albert Pham and contributors
+ * Please see LICENSE.txt for license information.
+ */
+
+package com.skcraft.launcher.builder;
+
+import com.beust.jcommander.JCommander;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.skcraft.launcher.model.minecraft.VersionManifest;
+import com.skcraft.launcher.model.modpack.Manifest;
+import com.skcraft.launcher.util.SimpleLogFormatter;
+import lombok.NonNull;
+import lombok.extern.java.Log;
+
+import java.io.File;
+import java.io.IOException;
+
+/**
+ * Builds packages for the launcher.
+ */
+@Log
+public class PackageBuilder {
+
+ private final ObjectMapper mapper;
+ private final Manifest manifest;
+
+ /**
+ * Create a new package builder.
+ *
+ * @param mapper the mapper
+ * @param manifest the manifest
+ */
+ public PackageBuilder(@NonNull ObjectMapper mapper, @NonNull Manifest manifest) {
+ this.mapper = mapper;
+ this.manifest = manifest;
+ }
+
+ /**
+ * Add the files in the given directory.
+ *
+ * @param dir the directory
+ * @param destDir the directory to copy the files to
+ * @throws IOException thrown on I/O error
+ */
+ private void addFiles(File dir, File destDir) throws IOException {
+ ClientFileCollector collector = new ClientFileCollector(this.manifest, destDir);
+ collector.walk(dir);
+ }
+
+ /**
+ * Write the manifest to a file.
+ *
+ * @param path the path
+ * @throws IOException thrown on I/O error
+ */
+ public void writeManifest(@NonNull File path) throws IOException {
+ path.getParentFile().mkdirs();
+ mapper.writeValue(path, manifest);
+ }
+
+ private static PackageOptions parseArgs(String[] args) {
+ PackageOptions options = new PackageOptions();
+ new JCommander(options, args);
+ return options;
+ }
+
+ /**
+ * Build a package given the arguments.
+ *
+ * @param args arguments
+ * @throws IOException thrown on I/O error
+ */
+ public static void main(String[] args) throws IOException {
+ // May throw error here
+ PackageOptions options = parseArgs(args);
+
+ SimpleLogFormatter.configureGlobalLogger();
+ ObjectMapper mapper = new ObjectMapper();
+ mapper.setSerializationInclusion(JsonInclude.Include.NON_DEFAULT);
+
+ Manifest manifest = new Manifest();
+ manifest.setName(options.getName());
+ manifest.setTitle(options.getTitle());
+ manifest.setVersion(options.getVersion());
+ manifest.setGameVersion(options.getGameVersion());
+ manifest.setLibrariesLocation(options.getLibrariesLocation());
+ manifest.setObjectsLocation(options.getObjectsLocation());
+
+ File path = options.getVersionManifestPath();
+ if (path != null) {
+ manifest.setVersionManifest(mapper.readValue(path, VersionManifest.class));
+ }
+
+ PackageBuilder builder = new PackageBuilder(mapper, manifest);
+ log.info("Adding files...");
+ builder.addFiles(options.getFilesDir(), options.getObjectsDir());
+ builder.writeManifest(options.getManifestPath());
+ log.info("Wrote manifest to " + options.getManifestPath().getAbsolutePath());
+ log.info("Done.");
+ }
+
+}
diff --git a/src/main/java/com/skcraft/launcher/builder/PackageOptions.java b/src/main/java/com/skcraft/launcher/builder/PackageOptions.java
new file mode 100644
index 0000000..f724c06
--- /dev/null
+++ b/src/main/java/com/skcraft/launcher/builder/PackageOptions.java
@@ -0,0 +1,47 @@
+/*
+ * 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 PackageOptions {
+
+ @Parameter(names = "--name", required = true)
+ private String name;
+
+ @Parameter(names = "--title", required = true)
+ private String title;
+
+ @Parameter(names = "--version", required = true)
+ private String version;
+
+ @Parameter(names = "--mc-version", required = true)
+ private String gameVersion;
+
+ @Parameter(names = "--manifest-path", required = true)
+ private File manifestPath;
+
+ @Parameter(names = "--objects-dest", required = true)
+ private File objectsDir;
+
+ @Parameter(names = "--files", required = true)
+ private File filesDir;
+
+ @Parameter(names = "--version-file")
+ private File versionManifestPath;
+
+ @Parameter(names = "--libs-url")
+ private String librariesLocation;
+
+ @Parameter(names = "--objects-url")
+ private String objectsLocation;
+
+}
diff --git a/src/main/java/com/skcraft/launcher/dialog/ConfigurationDialog.java b/src/main/java/com/skcraft/launcher/dialog/ConfigurationDialog.java
new file mode 100644
index 0000000..ae7fffa
--- /dev/null
+++ b/src/main/java/com/skcraft/launcher/dialog/ConfigurationDialog.java
@@ -0,0 +1,146 @@
+/*
+ * 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"));
+
+ /**
+ * 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.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();
+ }
+ });
+ }
+
+ /**
+ * Save the configuration and close the dialog.
+ */
+ public void save() {
+ mapper.copyFromSwing();
+ Persistence.commitAndForget(config);
+ dispose();
+ }
+}
diff --git a/src/main/java/com/skcraft/launcher/dialog/ConsoleFrame.java b/src/main/java/com/skcraft/launcher/dialog/ConsoleFrame.java
new file mode 100644
index 0000000..ff7df48
--- /dev/null
+++ b/src/main/java/com/skcraft/launcher/dialog/ConsoleFrame.java
@@ -0,0 +1,125 @@
+/*
+ * 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 {
+
+ @Getter private final Image trayRunningIcon;
+ @Getter private final Image trayClosedIcon;
+
+ @Getter private final MessageLog messageLog;
+ @Getter private LinedBoxPanel buttonsPanel;
+
+ /**
+ * 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();
+ }
+ });
+ }
+
+ /**
+ * Attempt to perform window close.
+ */
+ protected void performClose() {
+ messageLog.detachGlobalHandler();
+ 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());
+ }
+ });
+ }
+
+}
diff --git a/src/main/java/com/skcraft/launcher/dialog/LauncherFrame.java b/src/main/java/com/skcraft/launcher/dialog/LauncherFrame.java
new file mode 100644
index 0000000..eb14c6e
--- /dev/null
+++ b/src/main/java/com/skcraft/launcher/dialog/LauncherFrame.java
@@ -0,0 +1,505 @@
+/*
+ * 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.InstanceLauncher;
+import com.skcraft.launcher.launch.LaunchProcessHandler;
+import com.skcraft.launcher.persistence.Persistence;
+import com.skcraft.launcher.selfupdate.LauncherUpdateChecker;
+import com.skcraft.launcher.selfupdate.LauncherUpdater;
+import com.skcraft.launcher.swing.*;
+import com.skcraft.launcher.update.InstanceDeleter;
+import com.skcraft.launcher.update.InstanceResetter;
+import com.skcraft.launcher.update.InstanceUpdater;
+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();
+ }
+ });
+
+ 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() {
+ ListenableFuture future = launcher.getExecutor().submit(new LauncherUpdateChecker(launcher));
+
+ Futures.addCallback(future, new FutureCallback() {
+ @Override
+ public void onSuccess(URL result) {
+ requestUpdate(result);
+ }
+
+ @Override
+ public void onFailure(Throwable t) {
+
+ }
+ }, SwingExecutor.INSTANCE);
+ }
+
+ private void selfUpdate() {
+ URL url = updateUrl;
+ if (url != null) {
+ LauncherUpdater downloader = new LauncherUpdater(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
+ InstanceDeleter resetter = new InstanceDeleter(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
+ InstanceResetter resetter = new InstanceResetter(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
+ InstanceUpdater updater = new InstanceUpdater(launcher, instance);
+ 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
+ InstanceLauncher task = new InstanceLauncher(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);
+ }
+ }
+ }, sameThreadExecutor());
+ }
+
+}
diff --git a/src/main/java/com/skcraft/launcher/dialog/LoginDialog.java b/src/main/java/com/skcraft/launcher/dialog/LoginDialog.java
new file mode 100644
index 0000000..2b0ef0d
--- /dev/null
+++ b/src/main/java/com/skcraft/launcher/dialog/LoginDialog.java
@@ -0,0 +1,352 @@
+/*
+ * 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.idPassword")), 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;
+ }
+ }
+
+}
diff --git a/src/main/java/com/skcraft/launcher/dialog/ProcessConsoleFrame.java b/src/main/java/com/skcraft/launcher/dialog/ProcessConsoleFrame.java
new file mode 100644
index 0000000..7f7ab6d
--- /dev/null
+++ b/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/src/main/java/com/skcraft/launcher/dialog/ProgressDialog.java b/src/main/java/com/skcraft/launcher/dialog/ProgressDialog.java
new file mode 100644
index 0000000..da2a951
--- /dev/null
+++ b/src/main/java/com/skcraft/launcher/dialog/ProgressDialog.java
@@ -0,0 +1,184 @@
+/*
+ * 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.util.Timer;
+import java.util.TimerTask;
+
+import static com.skcraft.launcher.util.SharedLocale._;
+
+@Log
+public class ProgressDialog extends JDialog {
+
+ 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 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);
+ 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("Details...");
+ setMinimumSize(new Dimension(400, 100));
+ pack();
+ }
+
+ private void setDetailsSize() {
+ detailsButton.setText("Less...");
+ setSize(400, 350);
+ }
+
+ private void initComponents() {
+ buttonsPanel.addElement(detailsButton);
+ 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());
+
+ progressBar.setIndeterminate(true);
+ progressBar.setPreferredSize(new Dimension(0, 16));
+
+ 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();
+ }
+ });
+ }
+
+ private boolean confirmCancel() {
+ return SwingHelper.confirmDialog(this, "Are you sure that you wish to cancel?", "Cancel");
+ }
+
+ 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);
+ }
+ };
+
+ final Timer timer = new Timer();
+ timer.scheduleAtFixedRate(new UpdateProgress(dialog, future), 400, 400);
+
+ Futures.addCallback(future, new FutureCallback