diff --git a/launcher-bootstrap/build.gradle b/launcher-bootstrap/build.gradle new file mode 100644 index 0000000..57e250a --- /dev/null +++ b/launcher-bootstrap/build.gradle @@ -0,0 +1,28 @@ +apply plugin: 'com.github.johnrengelman.shadow' + +jar { + manifest { + attributes("Main-Class": "com.skcraft.launcher.Bootstrap") + } +} + +dependencies { + compile 'org.projectlombok:lombok:1.12.2' + compile 'com.googlecode.json-simple:json-simple:1.1.1' +} + +processResources { + filesMatching('**/*.properties') { + filter { + it.replace('${project.version}', project.version) + } + } +} + +shadowJar { + dependencies { + exclude(dependency('org.projectlombok:lombok')) + } +} + +build.dependsOn(shadowJar) \ No newline at end of file diff --git a/launcher-bootstrap/src/main/java/com/skcraft/launcher/Bootstrap.java b/launcher-bootstrap/src/main/java/com/skcraft/launcher/Bootstrap.java new file mode 100644 index 0000000..7667238 --- /dev/null +++ b/launcher-bootstrap/src/main/java/com/skcraft/launcher/Bootstrap.java @@ -0,0 +1,207 @@ +/* + * 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.launcher.bootstrap.*; +import lombok.Getter; +import lombok.extern.java.Log; + +import javax.swing.*; +import javax.swing.filechooser.FileSystemView; +import java.io.File; +import java.io.FileFilter; +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.*; +import java.util.logging.Level; + +import static com.skcraft.launcher.bootstrap.SharedLocale._; + +@Log +public class Bootstrap { + + private static final int BOOTSTRAP_VERSION = 1; + + @Getter private final File baseDir; + @Getter private final boolean portable; + @Getter private final File binariesDir; + @Getter private final Properties properties; + private final String[] originalArgs; + + public static void main(String[] args) throws Throwable { + SimpleLogFormatter.configureGlobalLogger(); + SharedLocale.loadBundle("com.skcraft.launcher.lang.Bootstrap", Locale.getDefault()); + + boolean portable = isPortableMode(); + + Bootstrap bootstrap = new Bootstrap(portable, args); + try { + bootstrap.cleanup(); + bootstrap.launch(); + } catch (Throwable t) { + Bootstrap.log.log(Level.WARNING, "Error", t); + Bootstrap.setSwingLookAndFeel(); + SwingHelper.showErrorDialog(null, _("errors.bootstrapError"), _("errorTitle"), t); + } + } + + public Bootstrap(boolean portable, String[] args) throws IOException { + this.properties = BootstrapUtils.loadProperties(Bootstrap.class, "bootstrap.properties"); + + File baseDir = portable ? new File(".") : getUserLauncherDir(); + + this.baseDir = baseDir; + this.portable = portable; + this.binariesDir = new File(baseDir, "launcher"); + this.originalArgs = args; + + binariesDir.mkdirs(); + } + + public void cleanup() { + File[] files = binariesDir.listFiles(new FileFilter() { + @Override + public boolean accept(File pathname) { + return pathname.getName().endsWith(".tmp"); + } + }); + + if (files != null) { + for (File file : files) { + file.delete(); + } + } + } + + public void launch() throws Throwable { + File[] files = binariesDir.listFiles(new LauncherBinary.Filter()); + List binaries = new ArrayList(); + + if (files != null) { + for (File file : files) { + Bootstrap.log.info("Found " + file.getAbsolutePath() + "..."); + binaries.add(new LauncherBinary(file)); + } + } + + if (!binaries.isEmpty()) { + launchExisting(binaries, true); + } else { + launchInitial(); + } + } + + public void launchInitial() throws Exception { + Bootstrap.log.info("Downloading the launcher..."); + Thread thread = new Thread(new Downloader(this)); + thread.start(); + } + + public void launchExisting(List binaries, boolean redownload) throws Exception { + Collections.sort(binaries); + LauncherBinary working = null; + Class clazz = null; + + for (LauncherBinary binary : binaries) { + File testFile = binary.getPath(); + try { + testFile = binary.getExecutableJar(); + Bootstrap.log.info("Trying " + testFile.getAbsolutePath() + "..."); + clazz = load(testFile); + Bootstrap.log.info("Launcher loaded successfully."); + working = binary; + break; + } catch (Throwable t) { + Bootstrap.log.log(Level.WARNING, "Failed to load " + testFile.getAbsoluteFile(), t); + } + } + + if (working != null) { + for (LauncherBinary binary : binaries) { + if (working != binary) { + log.info("Removing " + binary.getPath() + "..."); + binary.remove(); + } + } + + execute(clazz); + } else { + if (redownload) { + launchInitial(); + } else { + throw new IOException("Failed to find launchable .jar"); + } + } + } + + public void execute(Class clazz) throws InvocationTargetException, IllegalAccessException, NoSuchMethodException { + Method method = clazz.getDeclaredMethod("main", String[].class); + String[] launcherArgs; + + if (portable) { + launcherArgs = new String[] { + "--portable", + "--dir", + baseDir.getAbsolutePath(), + "--bootstrap-version", + String.valueOf(BOOTSTRAP_VERSION) }; + } else { + launcherArgs = new String[] { + "--dir", + baseDir.getAbsolutePath(), + "--bootstrap-version", + String.valueOf(BOOTSTRAP_VERSION) }; + } + + String[] args = new String[originalArgs.length + launcherArgs.length]; + System.arraycopy(launcherArgs, 0, args, 0, launcherArgs.length); + System.arraycopy(originalArgs, 0, args, launcherArgs.length, originalArgs.length); + + log.info("Launching with arguments " + Arrays.toString(args)); + + method.invoke(null, new Object[] { args }); + } + + public Class load(File jarFile) throws MalformedURLException, ClassNotFoundException { + URL[] urls = new URL[] { jarFile.toURI().toURL() }; + URLClassLoader child = new URLClassLoader(urls, this.getClass().getClassLoader()); + Class clazz = Class.forName(getProperties().getProperty("launcherClass"), true, child); + return clazz; + } + + public static void setSwingLookAndFeel() { + try { + UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); + } catch (Throwable e) { + } + } + + private static File getFileChooseDefaultDir() { + JFileChooser chooser = new JFileChooser(); + FileSystemView fsv = chooser.getFileSystemView(); + return fsv.getDefaultDirectory(); + } + + private File getUserLauncherDir() { + String osName = System.getProperty("os.name").toLowerCase(); + if (osName.contains("win")) { + return new File(getFileChooseDefaultDir(), getProperties().getProperty("homeFolderWindows")); + } else { + return new File(System.getProperty("user.home"), getProperties().getProperty("homeFolder")); + } + } + + private static boolean isPortableMode() { + return new File("portable.txt").exists(); + } + + +} diff --git a/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/BootstrapUtils.java b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/BootstrapUtils.java new file mode 100644 index 0000000..c103e96 --- /dev/null +++ b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/BootstrapUtils.java @@ -0,0 +1,49 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.bootstrap; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; +import java.util.regex.Pattern; + +public final class BootstrapUtils { + + private static final Pattern absoluteUrlPattern = Pattern.compile("^[A-Za-z0-9\\-]+://.*$"); + + private BootstrapUtils() { + } + + public static void checkInterrupted() throws InterruptedException { + if (Thread.interrupted()) { + throw new InterruptedException(); + } + } + + public static void closeQuietly(Closeable closeable) { + try { + if (closeable != null) { + closeable.close(); + } + } catch (IOException e) { + } + } + + public static Properties loadProperties(Class clazz, String name) throws IOException { + Properties prop = new Properties(); + InputStream in = null; + try { + in = clazz.getResourceAsStream(name); + prop.load(in); + } finally { + closeQuietly(in); + } + return prop; + } + +} diff --git a/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/DownloadFrame.java b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/DownloadFrame.java new file mode 100644 index 0000000..a612d95 --- /dev/null +++ b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/DownloadFrame.java @@ -0,0 +1,149 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.bootstrap; + +import com.skcraft.launcher.Bootstrap; +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.bootstrap.SharedLocale._; + +@Log +public class DownloadFrame extends JFrame { + + private Downloader downloader; + private Timer timer; + + 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 JButton cancelButton = new JButton(_("button.cancal")); + + public DownloadFrame(ProgressObservable observable) { + super(_("downloader.title")); + setResizable(false); + initComponents(); + label.setText(_("downloader.pleaseWait")); + setMinimumSize(new Dimension(400, 100)); + pack(); + setLocationRelativeTo(null); + SwingHelper.setIconImage(this, Bootstrap.class, "bootstrapper_icon.png"); + + setDefaultCloseOperation(WindowConstants.DO_NOTHING_ON_CLOSE); + addWindowListener(new WindowAdapter() { + @Override + public void windowClosing(WindowEvent event) { + if (confirmCancel()) { + cancel(); + dispose(); + } + } + }); + } + + private void initComponents() { + buttonsPanel.addGlue(); + buttonsPanel.addElement(cancelButton); + buttonsPanel.setBorder(BorderFactory.createEmptyBorder(30, 13, 13, 13));; + + progressBar.setIndeterminate(true); + progressBar.setMinimum(0); + progressBar.setMaximum(1000); + 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)); + + 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(); + } + } + }); + } + + private boolean confirmCancel() { + return SwingHelper.confirmDialog(this, _("progress.confirmCancel"), _("progress.confirmCancelTitle")); + } + + private void cancel() { + Downloader downloader = this.downloader; + if (downloader != null) { + downloader.cancel(); + } else { + System.exit(0); + } + } + + public synchronized void setDownloader(Downloader downloader) { + this.downloader = downloader; + + if (downloader == null) { + if (timer != null) { + timer.cancel(); + timer = null; + } + } else { + if (timer == null) { + timer = new Timer(); + timer.scheduleAtFixedRate(new UpdateProgress( downloader), 500, 500); + } + } + } + + private class UpdateProgress extends TimerTask { + private final Downloader downloader; + + public UpdateProgress(Downloader downloader) { + this.downloader = downloader; + } + + @Override + public void run() { + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + Downloader downloader = DownloadFrame.this.downloader; + if (downloader != null) { + label.setText(downloader.getStatus()); + double progress = downloader.getProgress(); + if (progress < 0) { + progressBar.setIndeterminate(true); + } else { + progressBar.setIndeterminate(false); + progressBar.setValue((int) (1000 * progress)); + } + } else { + label.setText(_("downloader.pleaseWait")); + progressBar.setIndeterminate(true); + } + } + }); + } + } + +} diff --git a/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/Downloader.java b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/Downloader.java new file mode 100644 index 0000000..ad3698a --- /dev/null +++ b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/Downloader.java @@ -0,0 +1,143 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.bootstrap; + +import com.skcraft.launcher.Bootstrap; +import lombok.extern.java.Log; +import org.json.simple.JSONObject; +import org.json.simple.JSONValue; + +import javax.swing.*; +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Level; + +import static com.skcraft.launcher.bootstrap.BootstrapUtils.checkInterrupted; +import static com.skcraft.launcher.bootstrap.SharedLocale._; + +@Log +public class Downloader implements Runnable, ProgressObservable { + + private final Bootstrap bootstrap; + private DownloadFrame dialog; + private HttpRequest httpRequest; + private Thread thread; + + public Downloader(Bootstrap bootstrap) { + this.bootstrap = bootstrap; + } + + @Override + public void run() { + this.thread = Thread.currentThread(); + + try { + execute(); + } catch (InterruptedException e) { + log.log(Level.WARNING, "Interrupted"); + System.exit(0); + } catch (Throwable t) { + log.log(Level.WARNING, "Failed to download launcher", t); + SwingHelper.showErrorDialog(null, _("errors.failedDownloadError"), _("errorTitle"), t); + System.exit(0); + } + } + + private void execute() throws Exception { + SwingUtilities.invokeAndWait(new Runnable() { + @Override + public void run() { + Bootstrap.setSwingLookAndFeel(); + dialog = new DownloadFrame(Downloader.this); + dialog.setVisible(true); + dialog.setDownloader(Downloader.this); + } + }); + + File finalFile = new File(bootstrap.getBinariesDir(), System.currentTimeMillis() + ".jar.pack"); + File tempFile = new File(finalFile.getParentFile(), finalFile.getName() + ".tmp"); + URL updateUrl = HttpRequest.url(bootstrap.getProperties().getProperty("latestUrl")); + + log.info("Reading update URL " + updateUrl + "..."); + + try { + String data = HttpRequest + .get(updateUrl) + .execute() + .expectResponseCode(200) + .returnContent() + .asString("UTF-8"); + + Object object = JSONValue.parse(data); + URL url; + + if (object instanceof JSONObject) { + String rawUrl = String.valueOf(((JSONObject) object).get("url")); + if (rawUrl != null) { + url = HttpRequest.url(rawUrl.trim()); + } else { + log.warning("Did not get valid update document - got:\n\n" + data); + throw new IOException("Update URL did not return a valid result"); + } + } else { + log.warning("Did not get valid update document - got:\n\n" + data); + throw new IOException("Update URL did not return a valid result"); + } + + checkInterrupted(); + + log.info("Downloading " + url + " to " + tempFile.getAbsolutePath()); + + httpRequest = HttpRequest.get(url); + httpRequest + .execute() + .expectResponseCode(200) + .saveContent(tempFile); + + finalFile.delete(); + tempFile.renameTo(finalFile); + } finally { + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + dialog.setDownloader(null); + dialog.dispose(); + } + }); + } + + LauncherBinary binary = new LauncherBinary(finalFile); + List binaries = new ArrayList(); + binaries.add(binary); + bootstrap.launchExisting(binaries, false); + } + + public void cancel() { + thread.interrupt(); + } + + public String getStatus() { + HttpRequest httpRequest = this.httpRequest; + if (httpRequest != null) { + double progress = httpRequest.getProgress(); + if (progress >= 0) { + return String.format(_("downloader.progressStatus"), progress * 100); + } + } + + return _("downloader.status"); + } + + @Override + public double getProgress() { + HttpRequest httpRequest = this.httpRequest; + return httpRequest != null ? httpRequest.getProgress() : -1; + } +} diff --git a/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/HttpRequest.java b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/HttpRequest.java new file mode 100644 index 0000000..09b84e4 --- /dev/null +++ b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/HttpRequest.java @@ -0,0 +1,492 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.bootstrap; + +import lombok.Getter; +import lombok.extern.java.Log; + +import javax.xml.bind.JAXBContext; +import javax.xml.bind.JAXBException; +import javax.xml.bind.Unmarshaller; +import java.io.*; +import java.net.*; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static com.skcraft.launcher.bootstrap.BootstrapUtils.checkInterrupted; +import static com.skcraft.launcher.bootstrap.BootstrapUtils.closeQuietly; + +/** + * A simple fluent interface for performing HTTP requests that uses + * {@link java.net.HttpURLConnection} or {@link javax.net.ssl.HttpsURLConnection}. + */ +@Log +public class HttpRequest implements Closeable, ProgressObservable { + + private static final int READ_TIMEOUT = 1000 * 60 * 10; + private static final int READ_BUFFER_SIZE = 1024 * 8; + + private final Map headers = new HashMap(); + private final String method; + @Getter + private final URL url; + private String contentType; + private byte[] body; + private HttpURLConnection conn; + private InputStream inputStream; + + private long contentLength = -1; + private long readBytes = 0; + + /** + * Create a new HTTP request. + * + * @param method the method + * @param url the URL + */ + private HttpRequest(String method, URL url) { + this.method = method; + this.url = url; + } + + /** + * Submit form data. + * + * @param form the form + * @return this object + */ + public HttpRequest bodyForm(Form form) { + contentType = "application/x-www-form-urlencoded"; + body = form.toString().getBytes(); + return this; + } + + /** + * Add a header. + * + * @param key the header key + * @param value the header value + * @return this object + */ + public HttpRequest header(String key, String value) { + headers.put(key, value); + return this; + } + + /** + * Execute the request. + *

+ * After execution, {@link #close()} should be called. + * + * @return this object + * @throws java.io.IOException on I/O error + */ + public HttpRequest execute() throws IOException { + boolean successful = false; + + try { + if (conn != null) { + throw new IllegalArgumentException("Connection already executed"); + } + + conn = (HttpURLConnection) reformat(url).openConnection(); + + if (body != null) { + conn.setRequestProperty("Content-Type", contentType); + conn.setRequestProperty("Content-Length", Integer.toString(body.length)); + conn.setDoInput(true); + } + + for (Map.Entry entry : headers.entrySet()) { + conn.setRequestProperty(entry.getKey(), entry.getValue()); + } + + conn.setRequestMethod(method); + conn.setUseCaches(false); + conn.setDoOutput(true); + conn.setReadTimeout(READ_TIMEOUT); + + conn.connect(); + + if (body != null) { + DataOutputStream out = new DataOutputStream(conn.getOutputStream()); + out.write(body); + out.flush(); + out.close(); + } + + inputStream = conn.getResponseCode() == HttpURLConnection.HTTP_OK ? + conn.getInputStream() : conn.getErrorStream(); + + successful = true; + } finally { + if (!successful) { + close(); + } + } + + return this; + } + + /** + * Require that the response code is one of the given response codes. + * + * @param codes a list of codes + * @return this object + * @throws java.io.IOException if there is an I/O error or the response code is not expected + */ + public HttpRequest expectResponseCode(int... codes) throws IOException { + int responseCode = getResponseCode(); + + for (int code : codes) { + if (code == responseCode) { + return this; + } + } + + close(); + throw new IOException("Did not get expected response code, got " + responseCode + " for " + url); + } + + /** + * Get the response code. + * + * @return the response code + * @throws java.io.IOException on I/O error + */ + public int getResponseCode() throws IOException { + if (conn == null) { + throw new IllegalArgumentException("No connection has been made"); + } + + return conn.getResponseCode(); + } + + /** + * Get the input stream. + * + * @return the input stream + */ + public InputStream getInputStream() { + return inputStream; + } + + /** + * Buffer the returned response. + * + * @return the buffered response + * @throws java.io.IOException on I/O error + * @throws InterruptedException on interruption + */ + public BufferedResponse returnContent() throws IOException, InterruptedException { + if (inputStream == null) { + throw new IllegalArgumentException("No input stream available"); + } + + try { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + int b = 0; + while ((b = inputStream.read()) != -1) { + checkInterrupted(); + bos.write(b); + } + return new BufferedResponse(bos.toByteArray()); + } finally { + close(); + } + } + + /** + * Save the result to a file. + * + * @param file the file + * @return this object + * @throws java.io.IOException on I/O error + * @throws InterruptedException on interruption + */ + public HttpRequest saveContent(File file) throws IOException, InterruptedException { + FileOutputStream fos = null; + BufferedOutputStream bos = null; + + try { + fos = new FileOutputStream(file); + bos = new BufferedOutputStream(fos); + + saveContent(bos); + } finally { + closeQuietly(bos); + closeQuietly(fos); + } + + return this; + } + + /** + * Save the result to an output stream. + * + * @param out the output stream + * @return this object + * @throws java.io.IOException on I/O error + * @throws InterruptedException on interruption + */ + public HttpRequest saveContent(OutputStream out) throws IOException, InterruptedException { + BufferedInputStream bis; + + try { + String field = conn.getHeaderField("Content-Length"); + if (field != null) { + long len = Long.parseLong(field); + if (len >= 0) { // Let's just not deal with really big numbers + contentLength = len; + } + } + } catch (NumberFormatException e) { + } + + try { + bis = new BufferedInputStream(inputStream); + + byte[] data = new byte[READ_BUFFER_SIZE]; + int len = 0; + while ((len = bis.read(data, 0, READ_BUFFER_SIZE)) >= 0) { + out.write(data, 0, len); + readBytes += len; + checkInterrupted(); + } + } finally { + close(); + } + + return this; + } + + @Override + public double getProgress() { + if (contentLength >= 0) { + return readBytes / (double) contentLength; + } else { + return -1; + } + } + + @Override + public void close() throws IOException { + if (conn != null) conn.disconnect(); + } + + /** + * Perform a GET request. + * + * @param url the URL + * @return a new request object + */ + public static HttpRequest get(URL url) { + return request("GET", url); + } + + /** + * Perform a POST request. + * + * @param url the URL + * @return a new request object + */ + public static HttpRequest post(URL url) { + return request("POST", url); + } + + /** + * Perform a request. + * + * @param method the method + * @param url the URL + * @return a new request object + */ + public static HttpRequest request(String method, URL url) { + return new HttpRequest(method, url); + } + + /** + * Create a new {@link java.net.URL} and throw a {@link RuntimeException} if the URL + * is not valid. + * + * @param url the url + * @return a URL object + * @throws RuntimeException if the URL is invalid + */ + public static URL url(String url) { + try { + return new URL(url); + } catch (MalformedURLException e) { + throw new RuntimeException(e); + } + } + + /** + * URL may contain spaces and other nasties that will cause a failure. + * + * @param existing the existing URL to transform + * @return the new URL, or old one if there was a failure + */ + private static URL reformat(URL existing) { + try { + URL url = new URL(existing.toString()); + URI uri = new URI( + url.getProtocol(), url.getUserInfo(), url.getHost(), url.getPort(), + url.getPath(), url.getQuery(), url.getRef()); + url = uri.toURL(); + return url; + } catch (MalformedURLException e) { + return existing; + } catch (URISyntaxException e) { + return existing; + } + } + + /** + * Used with {@link #bodyForm(Form)}. + */ + public final static class Form { + public final List elements = new ArrayList(); + + private Form() { + } + + /** + * Add a key/value to the form. + * + * @param key the key + * @param value the value + * @return this object + */ + public Form add(String key, String value) { + try { + elements.add(URLEncoder.encode(key, "UTF-8") + + "=" + URLEncoder.encode(value, "UTF-8")); + return this; + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + boolean first = true; + for (String element : elements) { + if (first) { + first = false; + } else { + builder.append("&"); + } + builder.append(element); + } + return builder.toString(); + } + + /** + * Create a new form. + * + * @return a new form + */ + public static Form form() { + return new Form(); + } + } + + /** + * Used to buffer the response in memory. + */ + public class BufferedResponse { + private final byte[] data; + + private BufferedResponse(byte[] data) { + this.data = data; + } + + /** + * Return the result as bytes. + * + * @return the data + */ + public byte[] asBytes() { + return data; + } + + /** + * Return the result as a string. + * + * @param encoding the encoding + * @return the string + * @throws java.io.IOException on I/O error + */ + public String asString(String encoding) throws IOException { + return new String(data, encoding); + } + + /** + * Return the result as an instance of the given class that has been + * deserialized from a XML payload. + * + * @return the object + * @throws java.io.IOException on I/O error + */ + @SuppressWarnings("unchecked") + public T asXml(Class cls) throws IOException { + try { + JAXBContext context = JAXBContext.newInstance(cls); + Unmarshaller um = context.createUnmarshaller(); + return (T) um.unmarshal(new ByteArrayInputStream(data)); + } catch (JAXBException e) { + throw new IOException(e); + } + } + + /** + * Save the result to a file. + * + * @param file the file + * @return this object + * @throws java.io.IOException on I/O error + * @throws InterruptedException on interruption + */ + public BufferedResponse saveContent(File file) throws IOException, InterruptedException { + FileOutputStream fos = null; + BufferedOutputStream bos = null; + + file.getParentFile().mkdirs(); + + try { + fos = new FileOutputStream(file); + bos = new BufferedOutputStream(fos); + + saveContent(bos); + } finally { + closeQuietly(bos); + closeQuietly(fos); + } + + return this; + } + + /** + * Save the result to an output stream. + * + * @param out the output stream + * @return this object + * @throws java.io.IOException on I/O error + * @throws InterruptedException on interruption + */ + public BufferedResponse saveContent(OutputStream out) throws IOException, InterruptedException { + out.write(data); + + return this; + } + } + +} diff --git a/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/LauncherBinary.java b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/LauncherBinary.java new file mode 100644 index 0000000..753ab2d --- /dev/null +++ b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/LauncherBinary.java @@ -0,0 +1,108 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.bootstrap; + +import lombok.Getter; +import lombok.extern.java.Log; + +import java.io.*; +import java.util.jar.JarOutputStream; +import java.util.jar.Pack200; +import java.util.logging.Level; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static com.skcraft.launcher.bootstrap.BootstrapUtils.closeQuietly; + +@Log +public class LauncherBinary implements Comparable { + + public static final Pattern PATTERN = Pattern.compile("^([0-9]+)\\.jar(\\.pack)?$"); + @Getter + private final File path; + private final long time; + private final boolean packed; + + public LauncherBinary(File path) { + this.path = path; + String name = path.getName(); + Matcher m = PATTERN.matcher(name); + if (!m.matches()) { + throw new IllegalArgumentException("Invalid filename: " + path); + } + time = Long.parseLong(m.group(1)); + packed = m.group(2) != null; + } + + public File getExecutableJar() throws IOException { + if (packed) { + log.log(Level.INFO, "Need to unpack " + path.getAbsolutePath()); + + String packName = path.getName(); + File outputPath = new File(path.getParentFile(), packName.substring(0, packName.length() - 5)); + + if (outputPath.exists()) { + return outputPath; + } + + FileInputStream fis = null; + BufferedInputStream bis = null; + FileOutputStream fos = null; + BufferedOutputStream bos = null; + JarOutputStream jos = null; + + try { + fis = new FileInputStream(path); + bis = new BufferedInputStream(fis); + fos = new FileOutputStream(outputPath); + bos = new BufferedOutputStream(fos); + jos = new JarOutputStream(bos); + Pack200.newUnpacker().unpack(bis, jos); + } finally { + closeQuietly(jos); + closeQuietly(bos); + closeQuietly(fos); + closeQuietly(bis); + closeQuietly(fis); + } + + path.delete(); + + return outputPath; + } else { + return path; + } + } + + @Override + public int compareTo(LauncherBinary o) { + if (time > o.time) { + return -1; + } else if (time < o.time) { + return 1; + } else { + if (packed && !o.packed) { + return 1; + } else if (!packed && o.packed) { + return -1; + } else { + return 0; + } + } + } + + public void remove() { + path.delete(); + } + + public static class Filter implements FileFilter { + @Override + public boolean accept(File file) { + return file.isFile() && LauncherBinary.PATTERN.matcher(file.getName()).matches(); + } + } +} diff --git a/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/LinedBoxPanel.java b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/LinedBoxPanel.java new file mode 100644 index 0000000..76d9e3f --- /dev/null +++ b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/LinedBoxPanel.java @@ -0,0 +1,52 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.bootstrap; + +import lombok.Getter; +import lombok.Setter; + +import javax.swing.*; +import java.awt.*; + +public class LinedBoxPanel extends JPanel { + + @Getter + private final boolean horizontal; + @Getter @Setter + private int spacing = 6; + private boolean needsSpacer = false; + + public LinedBoxPanel(boolean horizontal) { + this.horizontal = horizontal; + setLayout(new BoxLayout(this, + horizontal ? BoxLayout.X_AXIS : BoxLayout.Y_AXIS)); + setBorder(BorderFactory.createEmptyBorder(0, 10, 10, 10)); + } + + public LinedBoxPanel fullyPadded() { + setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); + return this; + } + + public void addElement(Component component) { + if (needsSpacer) { + add(horizontal ? + Box.createHorizontalStrut(spacing) : + Box.createVerticalStrut(spacing)); + } + add(component); + needsSpacer = true; + } + + public void addGlue() { + add(horizontal ? + Box.createHorizontalGlue() : + Box.createVerticalGlue()); + needsSpacer = false; + } + +} diff --git a/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/ProgressObservable.java b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/ProgressObservable.java new file mode 100644 index 0000000..bf13a5c --- /dev/null +++ b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/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.launcher.bootstrap; + +/** + * 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/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/SharedLocale.java b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/SharedLocale.java new file mode 100644 index 0000000..8e87c10 --- /dev/null +++ b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/SharedLocale.java @@ -0,0 +1,106 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.bootstrap; + +import lombok.NonNull; +import lombok.extern.java.Log; + +import java.text.MessageFormat; +import java.util.Locale; +import java.util.MissingResourceException; +import java.util.ResourceBundle; +import java.util.logging.Level; + +/** + * Handles loading a shared message {@link ResourceBundle}. + */ +@Log +public class SharedLocale { + + private static Locale locale = Locale.getDefault(); + private static ResourceBundle bundle; + + /** + * Get the current locale. + * + * @return the current locale + */ + public static Locale getLocale() { + return locale; + } + + /** + * Get the current resource bundle. + * + * @return the current resource bundle, or null if not available + */ + public static ResourceBundle getBundle() { + return bundle; + } + + /** + * Translate a string. + * + *

If the string is not available, then ${key} will be returned.

+ * + * @param key the key + * @return the translated string + */ + public static String _(String key) { + if (bundle != null) { + try { + return bundle.getString(key); + } catch (MissingResourceException e) { + log.log(Level.WARNING, "Failed to find message", e); + } + } + + return "${" + key + "}"; + } + + /** + * Format a translated string. + * + *

If the string is not available, then ${key}:args will be returned.

+ * + * @param key the key + * @param args arguments + * @return a translated string + */ + public static String _(String key, Object... args) { + if (bundle != null) { + try { + MessageFormat formatter = new MessageFormat(_(key)); + formatter.setLocale(getLocale()); + return formatter.format(args); + } catch (MissingResourceException e) { + log.log(Level.WARNING, "Failed to find message", e); + } + } + + return "${" + key + "}:" + args; + } + + /** + * Load a shared resource bundle. + * + * @param baseName the bundle name + * @param locale the locale + * @return true if loaded successfully + */ + public static boolean loadBundle(@NonNull String baseName, @NonNull Locale locale) { + try { + SharedLocale.locale = locale; + bundle = ResourceBundle.getBundle(baseName, locale, + SharedLocale.class.getClassLoader()); + return true; + } catch (MissingResourceException e) { + log.log(Level.SEVERE, "Failed to load resource bundle", e); + return false; + } + } +} diff --git a/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/SimpleLogFormatter.java b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/SimpleLogFormatter.java new file mode 100644 index 0000000..fbfa990 --- /dev/null +++ b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/SimpleLogFormatter.java @@ -0,0 +1,63 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.bootstrap; + +import lombok.extern.java.Log; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.logging.*; + +@Log +public final class SimpleLogFormatter extends Formatter { + + private static final String LINE_SEPARATOR = System.getProperty("line.separator"); + + @Override + public String format(LogRecord record) { + StringBuilder sb = new StringBuilder(); + + sb.append("[") + .append(record.getLevel().getLocalizedName().toLowerCase()) + .append("] ") + .append(formatMessage(record)) + .append(LINE_SEPARATOR); + + if (record.getThrown() != null) { + try { + StringWriter sw = new StringWriter(); + PrintWriter pw = new PrintWriter(sw); + record.getThrown().printStackTrace(pw); + pw.close(); + sb.append(sw.toString()); + } catch (Exception e) { + } + } + + return sb.toString(); + } + + public static void configureGlobalLogger() { + Logger globalLogger = Logger.getLogger(""); + + // Set formatter + for (Handler handler : globalLogger.getHandlers()) { + handler.setFormatter(new SimpleLogFormatter()); + } + + // Set level + String logLevel = System.getProperty( + SimpleLogFormatter.class.getCanonicalName() + ".logLevel", "INFO"); + try { + Level level = Level.parse(logLevel); + globalLogger.setLevel(level); + } catch (IllegalArgumentException e) { + log.log(Level.WARNING, "Invalid log level of " + logLevel, e); + } + } + +} \ No newline at end of file diff --git a/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/SwingHelper.java b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/SwingHelper.java new file mode 100644 index 0000000..f69a270 --- /dev/null +++ b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/SwingHelper.java @@ -0,0 +1,227 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.bootstrap; + +import lombok.NonNull; +import lombok.extern.java.Log; + +import javax.imageio.ImageIO; +import javax.swing.*; +import javax.swing.text.JTextComponent; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.io.InputStream; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.lang.reflect.InvocationTargetException; +import java.util.concurrent.atomic.AtomicBoolean; + +import static com.skcraft.launcher.bootstrap.BootstrapUtils.closeQuietly; +import static com.skcraft.launcher.bootstrap.SharedLocale._; + +/** + * Swing utility methods. + */ +@Log +public final class SwingHelper { + + private SwingHelper() { + } + + public static String htmlEscape(String str) { + return str.replace(">", ">") + .replace("<", "<") + .replace("&", "&"); + } + + /** + * Shows an popup error dialog, with potential extra details shown either immediately + * or available on the dialog. + * + * @param parentComponent the frame from which the dialog is displayed, otherwise + * null to use the default frame + * @param message the message to display + * @param title the title string for the dialog + * @see #showMessageDialog(java.awt.Component, String, String, String, int) for details + */ + public static void showErrorDialog(Component parentComponent, @NonNull String message, + @NonNull String title) { + showErrorDialog(parentComponent, message, title, null); + } + + /** + * Shows an popup error dialog, with potential extra details shown either immediately + * or available on the dialog. + * + * @param parentComponent the frame from which the dialog is displayed, otherwise + * null to use the default frame + * @param message the message to display + * @param title the title string for the dialog + * @param throwable the exception, or null if there is no exception to show + * @see #showMessageDialog(java.awt.Component, String, String, String, int) for details + */ + public static void showErrorDialog(Component parentComponent, @NonNull String message, + @NonNull String title, Throwable throwable) { + String detailsText = null; + + // Get a string version of the exception and use that for + // the extra details text + if (throwable != null) { + StringWriter sw = new StringWriter(); + throwable.printStackTrace(new PrintWriter(sw)); + detailsText = sw.toString(); + } + + showMessageDialog(parentComponent, + message, title, + detailsText, JOptionPane.ERROR_MESSAGE); + } + + /** + * Show a message dialog using + * {@link javax.swing.JOptionPane#showMessageDialog(java.awt.Component, Object, String, int)}. + * + *

The dialog will be shown from the Event Dispatch Thread, regardless of the + * thread it is called from. In either case, the method will block until the + * user has closed the dialog (or dialog creation fails for whatever reason).

+ * + * @param parentComponent the frame from which the dialog is displayed, otherwise + * null to use the default frame + * @param message the message to display + * @param title the title string for the dialog + * @param messageType see {@link javax.swing.JOptionPane#showMessageDialog(java.awt.Component, Object, String, int)} + * for available message types + */ + public static void showMessageDialog(final Component parentComponent, + @NonNull final String message, + @NonNull final String title, + final String detailsText, + final int messageType) { + + if (SwingUtilities.isEventDispatchThread()) { + // To force the label to wrap, convert the message to broken HTML + String htmlMessage = "
" + htmlEscape(message); + + JPanel panel = new JPanel(new BorderLayout(0, detailsText != null ? 20 : 0)); + + // Add the main message + panel.add(new JLabel(htmlMessage), BorderLayout.NORTH); + + // Add the extra details + if (detailsText != null) { + JTextArea textArea = new JTextArea(_("errorDialog.reportError") + "\n\n" + detailsText); + JLabel tempLabel = new JLabel(); + textArea.setFont(tempLabel.getFont()); + textArea.setBackground(tempLabel.getBackground()); + textArea.setTabSize(2); + textArea.setEditable(false);; + + JScrollPane scrollPane = new JScrollPane(textArea); + scrollPane.setPreferredSize(new Dimension(350, 120)); + panel.add(scrollPane, BorderLayout.CENTER); + } + + JOptionPane.showMessageDialog( + parentComponent, panel, title, messageType); + } else { + // Call method again from the Event Dispatch Thread + try { + SwingUtilities.invokeAndWait(new Runnable() { + @Override + public void run() { + showMessageDialog( + parentComponent, message, title, + detailsText, messageType); + } + }); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (InvocationTargetException e) { + throw new RuntimeException(e); + } + } + } + + /** + * Asks the user a binary yes or no question. + * + * @param parentComponent the component + * @param message the message to display + * @param title the title string for the dialog + * @return whether 'yes' was selected + */ + public static boolean confirmDialog(final Component parentComponent, + @NonNull final String message, + @NonNull final String title) { + if (SwingUtilities.isEventDispatchThread()) { + return JOptionPane.showConfirmDialog( + parentComponent, message, title, JOptionPane.YES_NO_OPTION) == + JOptionPane.YES_OPTION; + } else { + // Use an AtomicBoolean to pass the result back from the + // Event Dispatcher Thread + final AtomicBoolean yesSelected = new AtomicBoolean(); + + try { + SwingUtilities.invokeAndWait(new Runnable() { + @Override + public void run() { + yesSelected.set(confirmDialog(parentComponent, title, message)); + } + }); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (InvocationTargetException e) { + throw new RuntimeException(e); + } + + return yesSelected.get(); + } + } + + public static BufferedImage readIconImage(Class clazz, String path) { + InputStream in = null; + try { + in = clazz.getResourceAsStream(path); + if (in != null) { + return ImageIO.read(in); + } + } catch (IOException e) { + } finally { + closeQuietly(in); + } + return null; + } + + public static void setIconImage(Window frame, Class clazz, String path) { + BufferedImage image = readIconImage(clazz, path); + if (image != null) { + frame.setIconImage(image); + } + } + + /** + * Focus a component. + * + *

The focus call happens in {@link javax.swing.SwingUtilities#invokeLater(Runnable)}.

+ * + * @param component the component + */ + public static void focusLater(@NonNull final Component component) { + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + if (component instanceof JTextComponent) { + ((JTextComponent) component).selectAll(); + } + component.requestFocusInWindow(); + } + }); + } + +} diff --git a/launcher-bootstrap/src/main/resources/com/skcraft/launcher/bootstrap.properties b/launcher-bootstrap/src/main/resources/com/skcraft/launcher/bootstrap.properties new file mode 100644 index 0000000..8609369 --- /dev/null +++ b/launcher-bootstrap/src/main/resources/com/skcraft/launcher/bootstrap.properties @@ -0,0 +1,10 @@ +# +# SK's Minecraft Launcher +# Copyright (C) 2010-2014 Albert Pham and contributors +# Please see LICENSE.txt for license information. +# + +homeFolderWindows=Example Launcher +homeFolder=.examplelauncher +launcherClass=com.skcraft.launcher.Launcher +latestUrl=http://update.skcraft.com/quark/launcher/latest.json \ No newline at end of file diff --git a/launcher-bootstrap/src/main/resources/com/skcraft/launcher/bootstrapper_icon.png b/launcher-bootstrap/src/main/resources/com/skcraft/launcher/bootstrapper_icon.png new file mode 100644 index 0000000..8c74095 --- /dev/null +++ b/launcher-bootstrap/src/main/resources/com/skcraft/launcher/bootstrapper_icon.png Binary files differ diff --git a/launcher-bootstrap/src/main/resources/com/skcraft/launcher/lang/Bootstrap.properties b/launcher-bootstrap/src/main/resources/com/skcraft/launcher/lang/Bootstrap.properties new file mode 100644 index 0000000..9b9f6a2 --- /dev/null +++ b/launcher-bootstrap/src/main/resources/com/skcraft/launcher/lang/Bootstrap.properties @@ -0,0 +1,21 @@ +# +# SK's Minecraft Launcher +# Copyright (C) 2010-2014 Albert Pham and contributors +# Please see LICENSE.txt for license information. +# + +button.cancal=Cancel + +errorTitle=Error +errors.failedDownloadError=The SKCraft launcher could not be downloaded. +errors.bootstrapError=An error occurred while trying to bootstrap the launcher\! + +errorDialog.reportError=To report this error, provide\: + +downloader.title=Downloading SKCraft Launcher... +downloader.pleaseWait=Please wait... +downloader.status=Downloading latest SKCraft Launcher... +downloader.progressStatus=Downloading latest SKCraft Launcher (%.2f%%)... + +progress.confirmCancel=Are you sure that you wish to cancel? +progress.confirmCancelTitle=Cancel \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 308838d..8f515d2 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,3 +1,3 @@ rootProject.name = 'launcher-parent' -include 'launcher', 'launcher-fancy', 'launcher-builder' \ No newline at end of file +include 'launcher', 'launcher-fancy', 'launcher-builder', 'launcher-bootstrap' \ No newline at end of file