JAL-3315 getdown_1_8_6 patch file. Needs careful applying. JAL-3315_getdown_1_8_6_update
authorBen Soares <bsoares@dundee.ac.uk>
Tue, 18 Jun 2019 13:55:30 +0000 (14:55 +0100)
committerBen Soares <bsoares@dundee.ac.uk>
Tue, 18 Jun 2019 13:55:30 +0000 (14:55 +0100)
getdown/src/getdown-diff-jv--1_8_6.patch [new file with mode: 0644]

diff --git a/getdown/src/getdown-diff-jv--1_8_6.patch b/getdown/src/getdown-diff-jv--1_8_6.patch
new file mode 100644 (file)
index 0000000..7203f88
--- /dev/null
@@ -0,0 +1,7060 @@
+diff --git a/getdown/src/getdown/CHANGELOG.md b/getdown/src/getdown/CHANGELOG.md
+index 098651eb1..ec7a923e1 100644
+--- a/getdown/src/getdown/CHANGELOG.md
++++ b/getdown/src/getdown/CHANGELOG.md
+@@ -1,16 +1,73 @@
+ # Getdown Releases
+-## 1.8.3 - Unreleased
++## 1.8.7 - Unreleased
++
++* Reinstated env var support in `appbase` property.
++
++* Fixed issue with `myIpAddress()` in PAC proxy support.
++
++## 1.8.6 - June 4, 2019
++
++* Fixed issues with PAC proxy support: added `myIpAddress()`, fixed `dnsResolve()`, fixed crash
++  when detecting PAC proxy.
++
++* Reverted env var support in `appbase` property. It's causing problems that need to be
++  investigated.
++
++## 1.8.5 - May 29, 2019
++
++* Fixed issues with proxy information not getting properly passed through to app.
++  Via [#216](//github.com/threerings/getdown/pull/216).
++
++* `appbase` and `latest` properties in `getdown.txt` now process env var subtitutions.
++
++* Added support for [Proxy Auto-config](https://en.wikipedia.org/wiki/Proxy_auto-config) via PAC
++  files.
++
++* Proxy handling can now recover from credentials going out of date. It will detect the error and
++  ask for updated credentials.
++
++* Added `try_no_proxy` system property. This instructs Getdown to always first try to run without a
++  proxy, regardless of whether it has been configured to use a proxy in the past. And if it can run
++  without a proxy, it does so for that session, but retains the proxy config for future sessions in
++  which the proxy may again be needed.
++
++* Added `revalidate_policy` config to control when Getdown revalidates resources (by hashing them
++  and comparing that hash to the values in `digest.txt`). The default, `after_update`, only
++  validates resources after the app is updated. A new mode, `always`, validates resources prior to
++  every application launch.
++
++## 1.8.4 - May 14, 2019
++
++* Added `verify_timeout` config to allow customization of the default (60 second) timeout during
++  the resource verification process. Apparently in some pathological situations, this is needed.
++  Woe betide the users who have to stare at an unmoving progress bar for more than 60 seconds.
++  Via [#198](//github.com/threerings/getdown/pull/198)
++  and [901682d](//github.com/threerings/getdown/commit/901682d).
++
++* Added `java_local_dir` config to allow custom location for Java if `java_location` is specified.
++  Via [#206](//github.com/threerings/getdown/pull/206).
++
++* `messages_XX.properties` files are now all maintained in UTF-8 encoding and then converted to
++  escaped ISO-8859-1 during the build process.
++
++* Resources and unpacked resources now support `.zip` files as well as `.jar` files.
++  Via [#210](//github.com/threerings/getdown/pull/210).
++
++* Fixed issue when path to JVM contained spaces. Via [#214](//github.com/threerings/getdown/pull/214).
++
++## 1.8.3 - Apr 10, 2019
+ * Added support for `nresource` resources which must be jar files that contain native libraries.
+   Prior to launching the application, these resources will be unpacked and their contents added to
+   the `java.library.path` system property.
+ * When the app is updated to require a new version of the JVM, that JVM will be downloaded and used
+-  immediately during that app invocation (instead of one invocation later). Via PR#169.
++  immediately during that app invocation (instead of one invocation later).
++  Via [#169](//github.com/threerings/getdown/pull/169).
+-* When a custom JVM is installed, old JVM files will be deleted prior to unpacking the new JVM. Via
+-  PR#170.
++* When a custom JVM is installed, old JVM files will be deleted prior to unpacking the new JVM.
++  Via [#170](//github.com/threerings/getdown/pull/170).
+ * Number of concurrent downloads now defaults to num-cores minus one. Though downloads are I/O
+   bound rather than CPU bound, this still turns out to be a decent default.
+@@ -23,6 +80,14 @@
+   credentials supplied by the user. Otherwise they will be requested every time Getdown runs, which
+   is not a viable user experience.
++* The Getdown window can be now closed by pressing the `ESC` key.
++  Via [#191](//github.com/threerings/getdown/pull/191).
++
++* If no `appdir` is specified via the command line or system property, the current working
++  directory will be used as the `appdir`. Via [8d59367](//github.com/threerings/getdown/commit/8d59367)
++
++* A basic Russian translation has been added. Thanks [@sergiorussia](//github.com/sergiorussia)!
++
+ ## 1.8.2 - Nov 27, 2018
+ * Fixed a data corruption bug introduced at last minute into 1.8.1 release. Oops.
+diff --git a/getdown/src/getdown/ant/.project-MOVED b/getdown/src/getdown/ant/.project-MOVED
+deleted file mode 100644
+index 097cb89db..000000000
+--- a/getdown/src/getdown/ant/.project-MOVED
++++ /dev/null
+@@ -1,23 +0,0 @@
+-<?xml version="1.0" encoding="UTF-8"?>
+-<projectDescription>
+-      <name>getdown-ant</name>
+-      <comment></comment>
+-      <projects>
+-      </projects>
+-      <buildSpec>
+-              <buildCommand>
+-                      <name>org.eclipse.jdt.core.javabuilder</name>
+-                      <arguments>
+-                      </arguments>
+-              </buildCommand>
+-              <buildCommand>
+-                      <name>org.eclipse.m2e.core.maven2Builder</name>
+-                      <arguments>
+-                      </arguments>
+-              </buildCommand>
+-      </buildSpec>
+-      <natures>
+-              <nature>org.eclipse.jdt.core.javanature</nature>
+-              <nature>org.eclipse.m2e.core.maven2Nature</nature>
+-      </natures>
+-</projectDescription>
+diff --git a/getdown/src/getdown/ant/.settings/org.eclipse.core.resources.prefs b/getdown/src/getdown/ant/.settings/org.eclipse.core.resources.prefs
+deleted file mode 100644
+index e9441bb12..000000000
+--- a/getdown/src/getdown/ant/.settings/org.eclipse.core.resources.prefs
++++ /dev/null
+@@ -1,3 +0,0 @@
+-eclipse.preferences.version=1
+-encoding//src/main/java=UTF-8
+-encoding/<project>=UTF-8
+diff --git a/getdown/src/getdown/ant/.settings/org.eclipse.jdt.core.prefs b/getdown/src/getdown/ant/.settings/org.eclipse.jdt.core.prefs
+deleted file mode 100644
+index 54e56721d..000000000
+--- a/getdown/src/getdown/ant/.settings/org.eclipse.jdt.core.prefs
++++ /dev/null
+@@ -1,6 +0,0 @@
+-eclipse.preferences.version=1
+-org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.7
+-org.eclipse.jdt.core.compiler.compliance=1.7
+-org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning
+-org.eclipse.jdt.core.compiler.release=disabled
+-org.eclipse.jdt.core.compiler.source=1.7
+diff --git a/getdown/src/getdown/ant/.settings/org.eclipse.m2e.core.prefs b/getdown/src/getdown/ant/.settings/org.eclipse.m2e.core.prefs
+deleted file mode 100644
+index f897a7f1c..000000000
+--- a/getdown/src/getdown/ant/.settings/org.eclipse.m2e.core.prefs
++++ /dev/null
+@@ -1,4 +0,0 @@
+-activeProfiles=
+-eclipse.preferences.version=1
+-resolveWorkspaceProjects=true
+-version=1
+diff --git a/getdown/src/getdown/ant/pom.xml b/getdown/src/getdown/ant/pom.xml
+index f8231aa2e..a72d95d87 100644
+--- a/getdown/src/getdown/ant/pom.xml
++++ b/getdown/src/getdown/ant/pom.xml
+@@ -4,7 +4,7 @@
+   <parent>
+     <groupId>com.threerings.getdown</groupId>
+     <artifactId>getdown</artifactId>
+-    <version>1.8.3-SNAPSHOT</version>
++    <version>1.8.7-SNAPSHOT</version>
+   </parent>
+   <artifactId>getdown-ant</artifactId>
+diff --git a/getdown/src/getdown/ant/src/main/java/com/threerings/getdown/tools/DigesterTask.java b/getdown/src/getdown/ant/src/main/java/com/threerings/getdown/tools/DigesterTask.java
+index 48cc8d426..76212ae89 100644
+--- a/getdown/src/getdown/ant/src/main/java/com/threerings/getdown/tools/DigesterTask.java
++++ b/getdown/src/getdown/ant/src/main/java/com/threerings/getdown/tools/DigesterTask.java
+@@ -7,16 +7,13 @@ package com.threerings.getdown.tools;
+ import java.io.File;
+ import java.io.IOException;
+-
+ import java.security.GeneralSecurityException;
+ import org.apache.tools.ant.BuildException;
+ import org.apache.tools.ant.Task;
+-import com.threerings.getdown.data.Digest;
+-
+ /**
+- * An ant task used to create a <code>digest.txt</code> for a Getdown
++ * An ant task used to create a {@code digest.txt} for a Getdown
+  * application deployment.
+  */
+ public class DigesterTask extends Task
+diff --git a/getdown/src/getdown/core/.project-MOVED b/getdown/src/getdown/core/.project-MOVED
+deleted file mode 100644
+index 177252f5f..000000000
+--- a/getdown/src/getdown/core/.project-MOVED
++++ /dev/null
+@@ -1,23 +0,0 @@
+-<?xml version="1.0" encoding="UTF-8"?>
+-<projectDescription>
+-      <name>getdown-core</name>
+-      <comment></comment>
+-      <projects>
+-      </projects>
+-      <buildSpec>
+-              <buildCommand>
+-                      <name>org.eclipse.jdt.core.javabuilder</name>
+-                      <arguments>
+-                      </arguments>
+-              </buildCommand>
+-              <buildCommand>
+-                      <name>org.eclipse.m2e.core.maven2Builder</name>
+-                      <arguments>
+-                      </arguments>
+-              </buildCommand>
+-      </buildSpec>
+-      <natures>
+-              <nature>org.eclipse.jdt.core.javanature</nature>
+-              <nature>org.eclipse.m2e.core.maven2Nature</nature>
+-      </natures>
+-</projectDescription>
+diff --git a/getdown/src/getdown/core/.settings/org.eclipse.core.resources.prefs b/getdown/src/getdown/core/.settings/org.eclipse.core.resources.prefs
+deleted file mode 100644
+index 0a9bbb889..000000000
+--- a/getdown/src/getdown/core/.settings/org.eclipse.core.resources.prefs
++++ /dev/null
+@@ -1,6 +0,0 @@
+-eclipse.preferences.version=1
+-encoding//src/it/java=UTF-8
+-encoding//src/main/java=UTF-8
+-encoding//src/test/java=UTF-8
+-encoding//src/test/resources=UTF-8
+-encoding/<project>=UTF-8
+diff --git a/getdown/src/getdown/core/.settings/org.eclipse.jdt.core.prefs b/getdown/src/getdown/core/.settings/org.eclipse.jdt.core.prefs
+deleted file mode 100644
+index 54e56721d..000000000
+--- a/getdown/src/getdown/core/.settings/org.eclipse.jdt.core.prefs
++++ /dev/null
+@@ -1,6 +0,0 @@
+-eclipse.preferences.version=1
+-org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.7
+-org.eclipse.jdt.core.compiler.compliance=1.7
+-org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning
+-org.eclipse.jdt.core.compiler.release=disabled
+-org.eclipse.jdt.core.compiler.source=1.7
+diff --git a/getdown/src/getdown/core/.settings/org.eclipse.m2e.core.prefs b/getdown/src/getdown/core/.settings/org.eclipse.m2e.core.prefs
+deleted file mode 100644
+index f897a7f1c..000000000
+--- a/getdown/src/getdown/core/.settings/org.eclipse.m2e.core.prefs
++++ /dev/null
+@@ -1,4 +0,0 @@
+-activeProfiles=
+-eclipse.preferences.version=1
+-resolveWorkspaceProjects=true
+-version=1
+diff --git a/getdown/src/getdown/core/pom.xml b/getdown/src/getdown/core/pom.xml
+index efec8b6ce..5e0f852ac 100644
+--- a/getdown/src/getdown/core/pom.xml
++++ b/getdown/src/getdown/core/pom.xml
+@@ -4,7 +4,7 @@
+   <parent>
+     <groupId>com.threerings.getdown</groupId>
+     <artifactId>getdown</artifactId>
+-    <version>1.8.3-SNAPSHOT</version>
++    <version>1.8.7-SNAPSHOT</version>
+   </parent>
+   <artifactId>getdown-core</artifactId>
+@@ -34,7 +34,7 @@
+        Wildcards can be used (*.mycompany.com) and multiple values can be
+        separated by commas (app1.foo.com,app2.bar.com,app3.baz.com). -->
+   <properties>
+-    <getdown.host.whitelist>jalview.org,*.jalview.org</getdown.host.whitelist>
++    <getdown.host.whitelist />
+   </properties>
+   <build>
+diff --git a/getdown/src/getdown/core/src/it/java/com/threerings/getdown/tests/DigesterIT.java b/getdown/src/getdown/core/src/it/java/com/threerings/getdown/tests/DigesterIT.java
+index 52b4b5ee3..d2ddaf272 100644
+--- a/getdown/src/getdown/core/src/it/java/com/threerings/getdown/tests/DigesterIT.java
++++ b/getdown/src/getdown/core/src/it/java/com/threerings/getdown/tests/DigesterIT.java
+@@ -5,16 +5,16 @@
+ package com.threerings.getdown.tests;
+-import java.io.File;
+ import java.nio.charset.StandardCharsets;
+-import java.nio.file.*;
++import java.nio.file.Files;
++import java.nio.file.Path;
++import java.nio.file.Paths;
+ import java.util.Arrays;
+ import java.util.List;
+-import org.junit.*;
+-import static org.junit.Assert.*;
+-
+ import com.threerings.getdown.tools.Digester;
++import org.junit.Test;
++import static org.junit.Assert.assertEquals;
+ public class DigesterIT {
+@@ -32,23 +32,23 @@ public class DigesterIT {
+         Files.delete(digest2);
+         assertEquals(Arrays.asList(
+-            "getdown.txt = 779c74fb4b251e18faf6e240a0667964",
++            "getdown.txt = 9c9b2494929c99d44ae51034d59e1a1b",
+             "testapp.jar = 404dafa55e78b25ec0e3a936357b1883",
+             "funny%test dir/some=file.txt = d8e8fca2dc0f896fd7cb4cb0031ba249",
+             "crazyhashfile#txt = f29d23fd5ab1781bd8d0760b3a516f16",
+             "foo.jar = 46ca4cc9079d9d019bb30cd21ebbc1ec",
+             "script.sh = f66e8ea25598e67e99c47d9b0b2a2cdf",
+-            "digest.txt = f5561d85e4d80cc85883963897e58ff6"
++            "digest.txt = 11f9ba349cf9edacac4d72a3158447e5"
+         ), digestLines);
+         assertEquals(Arrays.asList(
+-            "getdown.txt = 4f0c657895c3c3a35fa55bf5951c64fa9b0694f8fc685af3f1d8635c639e066b",
++            "getdown.txt = 1efecfae2a189002a6658f17d162b1922c7bde978944949276dc038a0df2461f",
+             "testapp.jar = c9cb1906afbf48f8654b416c3f831046bd3752a76137e5bf0a9af2f790bf48e0",
+             "funny%test dir/some=file.txt = f2ca1bb6c7e907d06dafe4687e579fce76b37e4e93b7605022da52e6ccc26fd2",
+             "crazyhashfile#txt = 6816889f922de38f145db215a28ad7c5e1badf7354b5cdab225a27486789fa3b",
+             "foo.jar = ea188b872e0496debcbe00aaadccccb12a8aa9b025bb62c130cd3d9b8540b062",
+             "script.sh = cca1c5c7628d9bf7533f655a9cfa6573d64afb8375f81960d1d832dc5135c988",
+-            "digest2.txt = 70b442c9f56660561921da3368e1a206f05c379182fab3062750b7ddcf303407"
++            "digest2.txt = 41eacdabda8909bdbbf61e4f980867f4003c16a12f6770e6fc619b6af100e05b"
+         ), digest2Lines);
+     }
+ }
+diff --git a/getdown/src/getdown/core/src/it/resources/testapp/getdown.txt b/getdown/src/getdown/core/src/it/resources/testapp/getdown.txt
+index 3e0e5381a..ab0e47383 100644
+--- a/getdown/src/getdown/core/src/it/resources/testapp/getdown.txt
++++ b/getdown/src/getdown/core/src/it/resources/testapp/getdown.txt
+@@ -13,6 +13,18 @@ apparg = %APPDIR%
+ # test the %env% mechanism
+ jvmarg = -Dusername=\%ENV.USER%
++# test various java_*** configs, they are not interesting for digester
++java_local_dir = jre
++java_max_version = 1089999
++java_min_version = [windows] 1080111
++java_min_version = [!windows] 1080192
++java_exact_version_required = [linux] true
++java_location = [linux-amd64] /files/java/java_linux_64.zip
++java_location = [linux-i386] /files/java/java_linux_32.zip
++java_location = [mac] /files/java/java_mac_64.zip
++java_location = [windows-amd64] /files/java/java_windows_64.zip
++java_location = [windows-x86] /files/java/java_windows_32.zip
++
+ strict_comments = true
+ resource = funny%test dir/some=file.txt
+ resource = crazyhashfile#txt
+diff --git a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/Log.java b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/Log.java
+index 13b99564a..da98c9031 100644
+--- a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/Log.java
++++ b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/Log.java
+@@ -15,7 +15,7 @@ import java.util.logging.*;
+ /**
+  * A placeholder class that contains a reference to the log object used by the Getdown code.
+  */
+-public class Log
++public final class Log
+ {
+     public static class Shim {
+         /**
+@@ -69,6 +69,13 @@ public class Log
+     /** We dispatch our log messages through this logging shim. */
+     public static final Shim log = new Shim();
++    /**
++     * Formats a message with key/value pairs. The pairs will be appended to the message as a
++     * comma separated list of {@code key=value} in square brackets.
++     * @param message the main log message.
++     * @param args the key/value pairs. Any trailing key with no value will be ignored.
++     * @return the formatted message, i.e. {@code Some log message [key=value, key=value]}.
++     */
+     public static String format (Object message, Object... args) {
+         if (args.length < 2) return String.valueOf(message);
+         StringBuilder buf = new StringBuilder(String.valueOf(message));
+@@ -76,13 +83,13 @@ public class Log
+             buf.append(' ');
+         }
+         buf.append('[');
+-        for (int ii = 0; ii < args.length; ii += 2) {
++        for (int ii = 0, ll = args.length/2; ii < ll; ii += 1) {
+             if (ii > 0) {
+                 buf.append(',').append(' ');
+             }
+-            buf.append(args[ii]).append('=');
++            buf.append(args[2*ii]).append('=');
+             try {
+-                buf.append(args[ii+1]);
++                buf.append(args[2*ii+1]);
+             } catch (Throwable t) {
+                 buf.append("<toString() failure: ").append(t).append(">");
+             }
+@@ -136,6 +143,5 @@ public class Log
+         protected FieldPosition _fpos = new FieldPosition(SimpleDateFormat.DATE_FIELD);
+     }
+-    protected static final String DATE_FORMAT = "{0,date} {0,time}";
+     protected static final Level[] LEVELS = {Level.FINE, Level.INFO, Level.WARNING, Level.SEVERE};
+ }
+diff --git a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/cache/GarbageCollector.java b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/cache/GarbageCollector.java
+index 67ea64575..7e01e87c5 100644
+--- a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/cache/GarbageCollector.java
++++ b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/cache/GarbageCollector.java
+@@ -6,6 +6,8 @@
+ package com.threerings.getdown.cache;
+ import java.io.File;
++
++import com.threerings.getdown.data.Resource;
+ import com.threerings.getdown.util.FileUtil;
+ /**
+@@ -55,9 +57,9 @@ public class GarbageCollector
+         if (subdirs != null) {
+             for (File dir : subdirs) {
+                 if (dir.isDirectory()) {
+-                    // Get all the native jars in the directory (there should only be one)
++                    // Get all the native jars or zips in the directory (there should only be one)
+                     for (File file : dir.listFiles()) {
+-                        if (!file.getName().endsWith(".jar")) {
++                        if (!Resource.isJar(file) && !Resource.isZip(file)) {
+                             continue;
+                         }
+                         File cachedFile = getCachedFile(file);
+@@ -94,6 +96,6 @@ public class GarbageCollector
+     private static File getCachedFile (File file)
+     {
+         return !isLastAccessedFile(file) ? file : new File(
+-            file.getParentFile(), file.getName().substring(0, file.getName().lastIndexOf(".")));
++            file.getParentFile(), file.getName().substring(0, file.getName().lastIndexOf('.')));
+     }
+ }
+diff --git a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/cache/ResourceCache.java b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/cache/ResourceCache.java
+index 0210e9a86..41f0c5f6d 100644
+--- a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/cache/ResourceCache.java
++++ b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/cache/ResourceCache.java
+@@ -69,7 +69,7 @@ public class ResourceCache
+     private String getFileSuffix (File fileToCache) {
+         String fileName = fileToCache.getName();
+-        int index = fileName.lastIndexOf(".");
++        int index = fileName.lastIndexOf('.');
+         return index > -1 ? fileName.substring(index) : "";
+     }
+diff --git a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/data/Application.java b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/data/Application.java
+index 0de5c8ac8..a93122553 100644
+--- a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/data/Application.java
++++ b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/data/Application.java
+@@ -22,24 +22,17 @@ import java.util.*;
+ import java.util.concurrent.*;
+ import java.util.regex.Matcher;
+ import java.util.regex.Pattern;
+-import java.util.zip.GZIPInputStream;
+-import com.sun.management.OperatingSystemMXBean;
+-import java.lang.management.ManagementFactory;
+-
+-import jalview.bin.MemorySetting;
++import com.threerings.getdown.net.Connector;
+ import com.threerings.getdown.util.*;
+ // avoid ambiguity with java.util.Base64 which we can't use as it's 1.8+
+ import com.threerings.getdown.util.Base64;
+-import com.threerings.getdown.data.EnvConfig;
+-import com.threerings.getdown.data.EnvConfig.Note;
+-
+ import static com.threerings.getdown.Log.log;
+ import static java.nio.charset.StandardCharsets.UTF_8;
+ /**
+- * Parses and provide access to the information contained in the <code>getdown.txt</code>
++ * Parses and provide access to the information contained in the {@code getdown.txt}
+  * configuration file.
+  */
+ public class Application
+@@ -218,10 +211,10 @@ public class Application
+      * Used by {@link #verifyMetadata} to communicate status in circumstances where it needs to
+      * take network actions.
+      */
+-    public static interface StatusDisplay
++    public interface StatusDisplay
+     {
+         /** Requests that the specified status message be displayed. */
+-        public void updateStatus (String message);
++        void updateStatus (String message);
+     }
+     /**
+@@ -239,19 +232,56 @@ public class Application
+         }
+     }
+-    /** The proxy that should be used to do HTTP downloads. This must be configured prior to using
+-      * the application instance. Yes this is a public mutable field, no I'm not going to create a
++    /**
++     * Reads the {@code getdown.txt} config file into a {@code Config} object and returns it.
++     */
++    public static Config readConfig (EnvConfig envc, boolean checkPlatform) throws IOException {
++        Config config = null;
++        File cfgfile = new File(envc.appDir, CONFIG_FILE);
++        Config.ParseOpts opts = Config.createOpts(checkPlatform);
++        try {
++            // if we have a configuration file, read the data from it
++            if (cfgfile.exists()) {
++                config = Config.parseConfig(cfgfile, opts);
++            }
++            // otherwise, try reading data from our backup config file; thanks to funny windows
++            // bullshit, we have to do this backup file fiddling in case we got screwed while
++            // updating getdown.txt during normal operation
++            else if ((cfgfile = new File(envc.appDir, Application.CONFIG_FILE + "_old")).exists()) {
++                config = Config.parseConfig(cfgfile, opts);
++            }
++            // otherwise, issue a warning that we found no getdown file
++            else {
++                log.info("Found no getdown.txt file", "appdir", envc.appDir);
++            }
++        } catch (Exception e) {
++            log.warning("Failure reading config file", "file", config, e);
++        }
++
++        // if we failed to read our config file, check for an appbase specified via a system
++        // property; we can use that to bootstrap ourselves back into operation
++        if (config == null) {
++            log.info("Using 'appbase' from bootstrap config", "appbase", envc.appBase);
++            Map<String, Object> cdata = new HashMap<>();
++            cdata.put("appbase", envc.appBase);
++            config = new Config(cdata);
++        }
++
++        return config;
++    }
++
++    /** A helper that is used to do HTTP downloads. This must be configured prior to using the
++      * application instance. Yes this is a public mutable field, no I'm not going to create a
+       * getter and setter just to pretend like that's not the case. */
+-    public Proxy proxy = Proxy.NO_PROXY;
++    public Connector conn = Connector.DEFAULT;
+     /**
+-     * Creates an application instance which records the location of the <code>getdown.txt</code>
++     * Creates an application instance which records the location of the {@code getdown.txt}
+      * configuration file from the supplied application directory.
+      *
+      */
+     public Application (EnvConfig envc) {
+         _envc = envc;
+-        _config = getLocalPath(envc.appDir, CONFIG_FILE);
+     }
+     /**
+@@ -375,8 +405,7 @@ public class Application
+      */
+     public List<Resource> getActiveCodeResources ()
+     {
+-        ArrayList<Resource> codes = new ArrayList<>();
+-        codes.addAll(getCodeResources());
++        List<Resource> codes = new ArrayList<>(getCodeResources());
+         for (AuxGroup aux : getAuxGroups()) {
+             if (isAuxGroupActive(aux.name)) {
+                 codes.addAll(aux.codes);
+@@ -404,8 +433,7 @@ public class Application
+      */
+     public List<Resource> getActiveResources ()
+     {
+-        ArrayList<Resource> rsrcs = new ArrayList<>();
+-        rsrcs.addAll(getResources());
++        List<Resource> rsrcs = new ArrayList<>(getResources());
+         for (AuxGroup aux : getAuxGroups()) {
+             if (isAuxGroupActive(aux.name)) {
+                 rsrcs.addAll(aux.rsrcs);
+@@ -442,7 +470,15 @@ public class Application
+     }
+     /**
+-     * Returns a resource for a zip file containing a Java VM that can be downloaded to use in
++     * @return directory into which a local VM installation should be unpacked.
++     */
++    public File getJavaLocalDir ()
++    {
++        return _javaLocalDir;
++    }
++
++    /**
++     * @return a resource for a zip file containing a Java VM that can be downloaded to use in
+      * place of the installed VM (in the case where the VM that launched Getdown does not meet the
+      * application's version requirements) or null if no VM is available for this platform.
+      */
+@@ -452,21 +488,16 @@ public class Application
+             return null;
+         }
+-        String extension = (_javaLocation.endsWith(".tgz"))?".tgz":".jar";
+-        String vmfile = LaunchUtil.LOCAL_JAVA_DIR + extension;
+-              log.info("vmfile is '"+vmfile+"'");
+-              System.out.println("vmfile is '"+vmfile+"'");
++        // take extension from java location
++        String vmfileExt = _javaLocation.substring(_javaLocation.lastIndexOf('.'));
++        String vmfile = _javaLocalDir.getName() + vmfileExt;
+         try {
+             URL remote = new URL(createVAppBase(_targetVersion), encodePath(_javaLocation));
+-            log.info("Attempting to fetch jvm at "+remote.toString());
+-            System.out.println("Attempting to fetch jvm at "+remote.toString());
+             return new Resource(vmfile, remote, getLocalPath(vmfile),
+                                 EnumSet.of(Resource.Attr.UNPACK, Resource.Attr.CLEAN));
+         } catch (Exception e) {
+             log.warning("Failed to create VM resource", "vmfile", vmfile, "appbase", _appbase,
+                 "tvers", _targetVersion, "javaloc", _javaLocation, "error", e);
+-            System.out.println("Failed to create VM resource: vmfile="+vmfile+", appbase="+_appbase+
+-                ", tvers="+_targetVersion+", javaloc="+_javaLocation+", error="+e);
+             return null;
+         }
+     }
+@@ -547,88 +578,52 @@ public class Application
+      * @exception IOException thrown if there is an error reading the file or an error encountered
+      * during its parsing.
+      */
+-    public Config init (boolean checkPlatform)
+-        throws IOException
++    public Config init (boolean checkPlatform) throws IOException
+     {
+-        Config config = null;
+-        File cfgfile = _config;
+-        Config.ParseOpts opts = Config.createOpts(checkPlatform);
+-        try {
+-            // if we have a configuration file, read the data from it
+-            if (cfgfile.exists()) {
+-                config = Config.parseConfig(_config, opts);
+-            }
+-            // otherwise, try reading data from our backup config file; thanks to funny windows
+-            // bullshit, we have to do this backup file fiddling in case we got screwed while
+-            // updating getdown.txt during normal operation
+-            else if ((cfgfile = getLocalPath(CONFIG_FILE + "_old")).exists()) {
+-                config = Config.parseConfig(cfgfile, opts);
+-            }
+-            // otherwise, issue a warning that we found no getdown file
+-            else {
+-                log.info("Found no getdown.txt file", "appdir", getAppDir());
+-            }
+-        } catch (Exception e) {
+-            log.warning("Failure reading config file", "file", _config, e);
+-        }
+-        
+-        // see if there's an override config from locator file
+-        Config locatorConfig = createLocatorConfig(opts);
+-        
+-        // merge the locator file config into config (or replace config with)
+-        if (locatorConfig != null) {
+-          if (config == null || locatorConfig.getBoolean(LOCATOR_FILE_EXTENSION+"_replace")) {
+-            config = locatorConfig;
+-          } else {
+-            config.mergeConfig(locatorConfig, locatorConfig.getBoolean(LOCATOR_FILE_EXTENSION+"_merge"));
+-          }
+-        }
++        Config config = readConfig(_envc, checkPlatform);
++        initBase(config);
++        initJava(config);
++        initTracking(config);
++        initResources(config);
++        initArgs(config);
++        return config;
++    }
+-        // if we failed to read our config file, check for an appbase specified via a system
+-        // property; we can use that to bootstrap ourselves back into operation
+-        if (config == null) {
+-            String appbase = _envc.appBase;
+-            log.info("Using 'appbase' from bootstrap config", "appbase", appbase);
+-            Map<String, Object> cdata = new HashMap<>();
+-            cdata.put("appbase", appbase);
+-            config = new Config(cdata);
+-        }
++    /**
++     * Reads the basic config info from {@code config} into this instance. This includes things
++     * like the appbase and version.
++     */
++    public void initBase (Config config) throws IOException {
++        // first extract our version information
++        _version = config.getLong("version", -1L);
+-        // first determine our application base, this way if anything goes wrong later in the
++        // determine our application base, this way if anything goes wrong later in the
+         // process, our caller can use the appbase to download a new configuration file
+         _appbase = config.getString("appbase");
+-        
+-        // see if locatorConfig override
+-        if (locatorConfig != null && !StringUtil.isBlank(locatorConfig.getString("appbase"))) {
+-          _appbase = locatorConfig.getString("appbase");
+-        }
+-        
+         if (_appbase == null) {
+             throw new RuntimeException("m.missing_appbase");
+         }
+-        // check if we're overriding the domain in the appbase
+-        _appbase = SysProps.overrideAppbase(_appbase);
++        // check if we're overriding the domain in the appbase, and sub envvars
++        _appbase = resolveEnvVars(SysProps.overrideAppbase(_appbase));
+         // make sure there's a trailing slash
+         if (!_appbase.endsWith("/")) {
+-            _appbase = _appbase + "/";
++            _appbase += "/";
+         }
+-        // extract our version information
+-        _version = config.getLong("version", -1L);
+-
+         // if we are a versioned deployment, create a versioned appbase
+         try {
+             _vappbase = createVAppBase(_version);
+         } catch (MalformedURLException mue) {
+             String err = MessageUtil.tcompose("m.invalid_appbase", _appbase);
+-            throw (IOException) new IOException(err).initCause(mue);
++            throw new IOException(err, mue);
+         }
+         // check for a latest config URL
+         String latest = config.getString("latest");
+         if (latest != null) {
++            latest = processArg(latest);
+             if (latest.startsWith(_appbase)) {
+                 latest = _appbase + latest.substring(_appbase.length());
+             } else {
+@@ -641,20 +636,25 @@ public class Application
+             }
+         }
+-        String appPrefix = _envc.appId == null ? "" : (_envc.appId + ".");
+-
+-        // determine our application class name (use app-specific class _if_ one is provided)
+-        _class = config.getString("class");
+-        if (appPrefix.length() > 0) {
+-            _class = config.getString(appPrefix + "class", _class);
+-        }
+-        if (_class == null) {
+-            throw new IOException("m.missing_class");
+-        }
+-
+-        // determine whether we want strict comments
++        // read some miscellaneous configurations
+         _strictComments = config.getBoolean("strict_comments");
++        _allowOffline = config.getBoolean("allow_offline");
++        _revalidatePolicy = config.getEnum(
++            "revalidate_policy", RevalidatePolicy.class, RevalidatePolicy.AFTER_UPDATE);
++        int tpSize = SysProps.threadPoolSize();
++        _maxConcDownloads = Math.max(1, config.getInt("max_concurrent_downloads", tpSize));
++        _verifyTimeout = config.getInt("verify_timeout", 60);
++
++        // whether to cache code resources and launch from cache
++        _useCodeCache = config.getBoolean("use_code_cache");
++        _codeCacheRetentionDays = config.getInt("code_cache_retention_days", 7);
++    }
++    /**
++     * Reads the JVM requirements from {@code config} into this instance. This includes things like
++     * the min and max java version, location of a locally installed JRE, etc.
++     */
++    public void initJava (Config config) {
+         // check to see if we're using a custom java.version property and regex
+         _javaVersionProp = config.getString("java_version_prop", _javaVersionProp);
+         _javaVersionRegex = config.getString("java_version_regex", _javaVersionRegex);
+@@ -669,14 +669,16 @@ public class Application
+         // check to see if we require a particular JVM version and have a supplied JVM
+         _javaExactVersionRequired = config.getBoolean("java_exact_version_required");
+-        // this is a little weird, but when we're run from the digester, we see a String[] which
+-        // contains java locations for all platforms which we can't grok, but the digester doesn't
+-        // need to know about that; when we're run in a real application there will be only one!
+-        Object javaloc = config.getRaw("java_location");
+-        if (javaloc instanceof String) {
+-            _javaLocation = (String)javaloc;
+-        }
++        _javaLocation = config.getString("java_location");
++
++        // used only in conjunction with java_location
++        _javaLocalDir = getLocalPath(config.getString("java_local_dir", LaunchUtil.LOCAL_JAVA_DIR));
++    }
++    /**
++     * Reads the install tracking info from {@code config} into this instance.
++     */
++    public void initTracking (Config config) {
+         // determine whether we have any tracking configuration
+         _trackingURL = config.getString("tracking_url");
+@@ -701,14 +703,16 @@ public class Application
+         // Some app may need to generate google analytics code
+         _trackingGAHash = config.getString("tracking_ga_hash");
++    }
++    /**
++     * Reads the app resource info from {@code config} into this instance.
++     */
++    public void initResources (Config config) throws IOException {
+         // clear our arrays as we may be reinitializing
+         _codes.clear();
+         _resources.clear();
+         _auxgroups.clear();
+-        _jvmargs.clear();
+-        _appargs.clear();
+-        _txtJvmArgs.clear();
+         // parse our code resources
+         if (config.getMultiValue("code") == null &&
+@@ -727,10 +731,10 @@ public class Application
+         // parse our auxiliary resource groups
+         for (String auxgroup : config.getList("auxgroups")) {
+-            ArrayList<Resource> codes = new ArrayList<>();
++            List<Resource> codes = new ArrayList<>();
+             parseResources(config, auxgroup + ".code", Resource.NORMAL, codes);
+             parseResources(config, auxgroup + ".ucode", Resource.UNPACK, codes);
+-            ArrayList<Resource> rsrcs = new ArrayList<>();
++            List<Resource> rsrcs = new ArrayList<>();
+             parseResources(config, auxgroup + ".resource", Resource.NORMAL, rsrcs);
+             parseResources(config, auxgroup + ".xresource", Resource.EXEC, rsrcs);
+             parseResources(config, auxgroup + ".uresource", Resource.UNPACK, rsrcs);
+@@ -738,87 +742,48 @@ public class Application
+             parseResources(config, auxgroup + ".nresource", Resource.NATIVE, rsrcs);
+             _auxgroups.put(auxgroup, new AuxGroup(auxgroup, codes, rsrcs));
+         }
++    }
+-        // transfer our JVM arguments (we include both "global" args and app_id-prefixed args)
+-        String[] jvmargs = config.getMultiValue("jvmarg");
+-        addAll(jvmargs, _jvmargs);
++    /**
++     * Reads the command line arg info from {@code config} into this instance.
++     */
++    public void initArgs (Config config) throws IOException {
++        _jvmargs.clear();
++        _appargs.clear();
++        _txtJvmArgs.clear();
++
++        String appPrefix = _envc.appId == null ? "" : (_envc.appId + ".");
++
++        // determine our application class name (use app-specific class _if_ one is provided)
++        _class = config.getString("class");
+         if (appPrefix.length() > 0) {
+-            jvmargs = config.getMultiValue(appPrefix + "jvmarg");
+-            addAll(jvmargs, _jvmargs);
++            _class = config.getString(appPrefix + "class", _class);
++        }
++        if (_class == null) {
++            throw new IOException("m.missing_class");
+         }
+-        // see if a percentage of physical memory option exists
+-        int jvmmempc = config.getInt("jvmmempc", -1);
+-        // app_id prefixed setting overrides
++        // transfer our JVM arguments (we include both "global" args and app_id-prefixed args)
++        addAll(config.getMultiValue("jvmarg"), _jvmargs);
+         if (appPrefix.length() > 0) {
+-            jvmmempc = config.getInt(appPrefix + "jvmmempc", jvmmempc);
+-        }
+-        if (0 <= jvmmempc && jvmmempc <= 100) {
+-          
+-          long maxMemLong = -1;
+-
+-          try
+-          {
+-            maxMemLong = MemorySetting.memPercent(jvmmempc);
+-          } catch (Exception e)
+-          {
+-            e.printStackTrace();
+-          } catch (Throwable t)
+-          {
+-            t.printStackTrace();
+-          }
+-
+-          if (maxMemLong > 0)
+-          {
+-            
+-            String[] maxMemHeapArg = new String[]{"-Xmx"+Long.toString(maxMemLong)};
+-            // remove other max heap size arg
+-            ARG: for (int i = 0; i < _jvmargs.size(); i++) {
+-              if (_jvmargs.get(i) instanceof java.lang.String && _jvmargs.get(i).startsWith("-Xmx")) {
+-                _jvmargs.remove(i);
+-              }
+-            }
+-            addAll(maxMemHeapArg, _jvmargs);
+-            
+-          }
+-
+-        } else if (jvmmempc != -1) {
+-          System.out.println("'jvmmempc' value must be in range 0 to 100 (read as '"+Integer.toString(jvmmempc)+"')");
++            addAll(config.getMultiValue(appPrefix + "jvmarg"), _jvmargs);
+         }
+         // get the set of optimum JVM arguments
+         _optimumJvmArgs = config.getMultiValue("optimum_jvmarg");
+         // transfer our application arguments
+-        String[] appargs = config.getMultiValue(appPrefix + "apparg");
+-        addAll(appargs, _appargs);
++        addAll(config.getMultiValue(appPrefix + "apparg"), _appargs);
+         // add the launch specific application arguments
+         _appargs.addAll(_envc.appArgs);
+-        
++
+         // look for custom arguments
+         fillAssignmentListFromPairs("extra.txt", _txtJvmArgs);
+-        // determine whether we want to allow offline operation (defaults to false)
+-        _allowOffline = config.getBoolean("allow_offline");
+-
+-        // look for a debug.txt file which causes us to run in java.exe on Windows so that we can
+-        // obtain a thread dump of the running JVM
+-        _windebug = getLocalPath("debug.txt").exists();
+-
+-        // whether to cache code resources and launch from cache
+-        _useCodeCache = config.getBoolean("use_code_cache");
+-        _codeCacheRetentionDays = config.getInt("code_cache_retention_days", 7);
+-
+-        // maximum simultaneous downloads
+-        _maxConcDownloads = Math.max(1, config.getInt("max_concurrent_downloads",
+-                                                      SysProps.threadPoolSize()));
+-
+         // extract some info used to configure our child process on macOS
+         _dockName = config.getString("ui.name");
+         _dockIconPath = config.getString("ui.mac_dock_icon", "../desktop.icns");
+-
+-        return config;
+     }
+     /**
+@@ -847,8 +812,7 @@ public class Application
+      * Returns a URL from which the specified path can be fetched. Our application base URL is
+      * properly versioned and combined with the supplied path.
+      */
+-    public URL getRemoteURL (String path)
+-        throws MalformedURLException
++    public URL getRemoteURL (String path) throws MalformedURLException
+     {
+         return new URL(_vappbase, encodePath(path));
+     }
+@@ -858,7 +822,7 @@ public class Application
+      */
+     public File getLocalPath (String path)
+     {
+-        return getLocalPath(getAppDir(), path);
++        return new File(getAppDir(), path);
+     }
+     /**
+@@ -881,8 +845,7 @@ public class Application
+             // if we have an unpacked VM, check the 'release' file for its version
+             Resource vmjar = getJavaVMResource();
+             if (vmjar != null && vmjar.isMarkedValid()) {
+-                File vmdir = new File(getAppDir(), LaunchUtil.LOCAL_JAVA_DIR);
+-                File relfile = new File(vmdir, "release");
++                File relfile = new File(_javaLocalDir, "release");
+                 if (!relfile.exists()) {
+                     log.warning("Unpacked JVM missing 'release' file. Assuming valid version.");
+                     return true;
+@@ -939,7 +902,7 @@ public class Application
+     }
+     /**
+-     * Attempts to redownload the <code>getdown.txt</code> file based on information parsed from a
++     * Attempts to redownload the {@code getdown.txt} file based on information parsed from a
+      * previous call to {@link #init}.
+      */
+     public void attemptRecovery (StatusDisplay status)
+@@ -950,7 +913,7 @@ public class Application
+     }
+     /**
+-     * Downloads and replaces the <code>getdown.txt</code> and <code>digest.txt</code> files with
++     * Downloads and replaces the {@code getdown.txt} and {@code digest.txt} files with
+      * those for the target version of our application.
+      */
+     public void updateMetadata ()
+@@ -961,7 +924,7 @@ public class Application
+             _vappbase = createVAppBase(_targetVersion);
+         } catch (MalformedURLException mue) {
+             String err = MessageUtil.tcompose("m.invalid_appbase", _appbase);
+-            throw (IOException) new IOException(err).initCause(mue);
++            throw new IOException(err, mue);
+         }
+         try {
+@@ -1000,7 +963,7 @@ public class Application
+         ArrayList<String> args = new ArrayList<>();
+         // reconstruct the path to the JVM
+-        args.add(LaunchUtil.getJVMPath(getAppDir(), _windebug || optimum));
++        args.add(LaunchUtil.getJVMBinaryPath(_javaLocalDir, SysProps.debug() || optimum));
+         // check whether we're using -jar mode or -classpath mode
+         boolean dashJarMode = MANIFEST_CLASS.equals(_class);
+@@ -1018,14 +981,8 @@ public class Application
+             args.add("-Xdock:name=" + _dockName);
+         }
+-        // pass along our proxy settings
+-        String proxyHost;
+-        if ((proxyHost = System.getProperty("http.proxyHost")) != null) {
+-            args.add("-Dhttp.proxyHost=" + proxyHost);
+-            args.add("-Dhttp.proxyPort=" + System.getProperty("http.proxyPort"));
+-            args.add("-Dhttps.proxyHost=" + proxyHost);
+-            args.add("-Dhttps.proxyPort=" + System.getProperty("http.proxyPort"));
+-        }
++        // forward our proxy settings
++        conn.addProxyArgs(args);
+         // add the marker indicating the app is running in getdown
+         args.add("-D" + Properties.GETDOWN + "=true");
+@@ -1071,32 +1028,11 @@ public class Application
+             args.add(_class);
+         }
+-        // almost finally check the startup file arguments
+-        for (File f : _startupFiles) {
+-          _appargs.add(f.getAbsolutePath());
+-          break; // Only add one file to open
+-        }
+-        
+-        // check if one arg with recognised extension
+-        if ( _appargs.size() == 1 && _appargs.get(0) != null ) {
+-          String filename = _appargs.get(0);
+-          String ext = null;
+-          int j = filename.lastIndexOf('.');
+-          if (j > -1) {
+-            ext = filename.substring(j+1);
+-          }
+-          if (LOCATOR_FILE_EXTENSION.equals(ext.toLowerCase())) {
+-            // this file extension should have been dealt with in Getdown class
+-          } else {
+-            _appargs.add(0, "-open");
+-          }
+-        }
+-
+         // finally add the application arguments
+         for (String string : _appargs) {
+             args.add(processArg(string));
+         }
+-        
++
+         String[] envp = createEnvironment();
+         String[] sargs = args.toArray(new String[args.size()]);
+         log.info("Running " + StringUtil.join(sargs, "\n  "));
+@@ -1156,7 +1092,7 @@ public class Application
+         for (String jvmarg : _jvmargs) {
+             if (jvmarg.startsWith("-D")) {
+                 jvmarg = processArg(jvmarg.substring(2));
+-                int eqidx = jvmarg.indexOf("=");
++                int eqidx = jvmarg.indexOf('=');
+                 if (eqidx == -1) {
+                     log.warning("Bogus system property: '" + jvmarg + "'?");
+                 } else {
+@@ -1194,32 +1130,36 @@ public class Application
+         }
+     }
+-    /** Replaces the application directory and version in any argument. */
++    /** Replaces the application directory, version and env vars in any argument. */
+     protected String processArg (String arg)
+     {
+         arg = arg.replace("%APPDIR%", getAppDir().getAbsolutePath());
+         arg = arg.replace("%VERSION%", String.valueOf(_version));
++        arg = resolveEnvVars(arg);
++        return arg;
++    }
+-        // if this argument contains %ENV.FOO% replace those with the associated values looked up
+-        // from the environment
+-        if (arg.contains(ENV_VAR_PREFIX)) {
++    /** Resolves env var substitutions in {@code text}. */
++    protected String resolveEnvVars (String text) {
++        // if the text contains %ENV.FOO% replace it with FOO looked up in the environment
++        if (text.contains(ENV_VAR_PREFIX)) {
+             StringBuffer sb = new StringBuffer();
+-            Matcher matcher = ENV_VAR_PATTERN.matcher(arg);
++            Matcher matcher = ENV_VAR_PATTERN.matcher(text);
+             while (matcher.find()) {
+                 String varName = matcher.group(1), varValue = System.getenv(varName);
+                 String repValue = varValue == null ? "MISSING:"+varName : varValue;
+                 matcher.appendReplacement(sb, Matcher.quoteReplacement(repValue));
+             }
+             matcher.appendTail(sb);
+-            arg = sb.toString();
++            return sb.toString();
++        } else {
++            return text;
+         }
+-
+-        return arg;
+     }
+     /**
+-     * Loads the <code>digest.txt</code> file and verifies the contents of both that file and the
+-     * <code>getdown.text</code> file. Then it loads the <code>version.txt</code> and decides
++     * Loads the {@code digest.txt} file and verifies the contents of both that file and the
++     * {@code getdown.text} file. Then it loads the {@code version.txt} and decides
+      * whether or not the application needs to be updated or whether we can proceed to verification
+      * and execution.
+      *
+@@ -1306,11 +1246,11 @@ public class Application
+             }
+             if (_latest != null) {
+-                try (InputStream in = ConnectionUtil.open(proxy, _latest, 0, 0).getInputStream();
+-                     InputStreamReader reader = new InputStreamReader(in, UTF_8);
+-                     BufferedReader bin = new BufferedReader(reader)) {
+-                    for (String[] pair : Config.parsePairs(bin, Config.createOpts(false))) {
+-                        if (pair[0].equals("version")) {
++                try {
++                    List<String[]> vdata = Config.parsePairs(
++                        new StringReader(conn.fetch(_latest)), Config.createOpts(false));
++                    for (String[] pair : vdata) {
++                        if ("version".equals(pair[0])) {
+                             _targetVersion = Math.max(Long.parseLong(pair[1]), _targetVersion);
+                             if (fileVersion != -1 && _targetVersion > fileVersion) {
+                                 // replace the file with the newest version
+@@ -1404,7 +1344,10 @@ public class Application
+         while (completed[0] < rsrcs.size()) {
+             // we should be getting progress completion updates WAY more often than one every
+             // minute, so if things freeze up for 60 seconds, abandon ship
+-            Runnable action = actions.poll(60, TimeUnit.SECONDS);
++            Runnable action = actions.poll(_verifyTimeout, TimeUnit.SECONDS);
++            if (action == null) {
++                throw new IllegalStateException("m.verify_timeout");
++            }
+             action.run();
+         }
+@@ -1415,14 +1358,14 @@ public class Application
+         unpacked.addAll(unpackedAsync);
+         long complete = System.currentTimeMillis();
+-        log.info("Verified resources", "count", rsrcs.size(), "size", (totalSize/1024) + "k",
+-                 "duration", (complete-start) + "ms");
++        log.info("Verified resources", "count", rsrcs.size(), "alreadyValid", alreadyValid[0],
++                 "size", (totalSize/1024) + "k", "duration", (complete-start) + "ms");
+     }
+     private void verifyResource (Resource rsrc, ProgressObserver obs, int[] alreadyValid,
+                                  Set<Resource> unpacked,
+                                  Set<Resource> toInstall, Set<Resource> toDownload) {
+-        if (rsrc.isMarkedValid()) {
++        if (_revalidatePolicy != RevalidatePolicy.ALWAYS && rsrc.isMarkedValid()) {
+             if (alreadyValid != null) {
+                 alreadyValid[0]++;
+             }
+@@ -1513,7 +1456,7 @@ public class Application
+     protected URL createVAppBase (long version)
+         throws MalformedURLException
+     {
+-        String url = version < 0 ? _appbase : _appbase.replace("%VERSION%", "" + version);
++        String url = version < 0 ? _appbase : _appbase.replace("%VERSION%", String.valueOf(version));
+         return HostWhitelist.verify(new URL(url));
+     }
+@@ -1530,8 +1473,7 @@ public class Application
+     /**
+      * Downloads a new copy of CONFIG_FILE.
+      */
+-    protected void downloadConfigFile ()
+-        throws IOException
++    protected void downloadConfigFile () throws IOException
+     {
+         downloadControlFile(CONFIG_FILE, 0);
+     }
+@@ -1673,8 +1615,7 @@ public class Application
+      * Download a path to a temporary file, returning a {@link File} instance with the path
+      * contents.
+      */
+-    protected File downloadFile (String path)
+-        throws IOException
++    protected File downloadFile (String path) throws IOException
+     {
+         File target = getLocalPath(path + "_new");
+@@ -1684,30 +1625,11 @@ public class Application
+         } catch (Exception e) {
+             log.warning("Requested to download invalid control file",
+                 "appbase", _vappbase, "path", path, "error", e);
+-            throw (IOException) new IOException("Invalid path '" + path + "'.").initCause(e);
++            throw new IOException("Invalid path '" + path + "'.", e);
+         }
+         log.info("Attempting to refetch '" + path + "' from '" + targetURL + "'.");
+-
+-        // stream the URL into our temporary file
+-        URLConnection uconn = ConnectionUtil.open(proxy, targetURL, 0, 0);
+-        // we have to tell Java not to use caches here, otherwise it will cache any request for
+-        // same URL for the lifetime of this JVM (based on the URL string, not the URL object);
+-        // if the getdown.txt file, for example, changes in the meanwhile, we would never hear
+-        // about it; turning off caches is not a performance concern, because when Getdown asks
+-        // to download a file, it expects it to come over the wire, not from a cache
+-        uconn.setUseCaches(false);
+-        uconn.setRequestProperty("Accept-Encoding", "gzip");
+-        try (InputStream fin = uconn.getInputStream()) {
+-            String encoding = uconn.getContentEncoding();
+-            boolean gzip = "gzip".equalsIgnoreCase(encoding);
+-            try (InputStream fin2 = (gzip ? new GZIPInputStream(fin) : fin)) {
+-                try (FileOutputStream fout = new FileOutputStream(target)) {
+-                    StreamUtil.copy(fin2, fout);
+-                }
+-            }
+-        }
+-
++        conn.download(targetURL, target); // stream the URL into our temporary file
+         return target;
+     }
+@@ -1721,9 +1643,7 @@ public class Application
+     /** Helper function to add all values in {@code values} (if non-null) to {@code target}. */
+     protected static void addAll (String[] values, List<String> target) {
+         if (values != null) {
+-            for (String value : values) {
+-                target.add(value);
+-            }
++            Collections.addAll(target, values);
+         }
+     }
+@@ -1805,96 +1725,7 @@ public class Application
+         }
+     }
+-    protected File getLocalPath (File appdir, String path)
+-    {
+-        return new File(appdir, path);
+-    }
+-
+-    public static void setStartupFilesFromParameterString(String p) {
+-      // multiple files *might* be passed in as space separated quoted filenames
+-      String q = "\"";
+-      if (!StringUtil.isBlank(p)) {
+-        String[] filenames;
+-        // split quoted params or treat as single string array
+-        if (p.startsWith(q) && p.endsWith(q)) {
+-          // this fails if, e.g.
+-          // p=q("stupidfilename\" " "otherfilename")
+-          // let's hope no-one ever ends a filename with '" '
+-          filenames = p.substring(q.length(),p.length()-q.length()).split(q+" "+q);
+-        } else {
+-          // single unquoted filename
+-          filenames = new String[]{p};
+-        }
+-
+-        // check for locator file.  Only allow one locator file to be double clicked (if multiple files opened, ignore locator files)
+-        String locatorFilename = filenames.length >= 1 ? filenames[0] : null;
+-        if (
+-                !StringUtil.isBlank(locatorFilename)
+-                && locatorFilename.toLowerCase().endsWith("."+Application.LOCATOR_FILE_EXTENSION)
+-                ) {
+-          setLocatorFile(locatorFilename);
+-          // remove the locator filename from the filenames array
+-          String[] otherFilenames = new String[filenames.length - 1];
+-          System.arraycopy(filenames, 1, otherFilenames, 0, otherFilenames.length);
+-          filenames = otherFilenames;
+-        }
+-
+-        for (int i = 0; i < filenames.length; i++) {
+-          String filename = filenames[i];
+-          // skip any other locator files in a multiple file list
+-          if (! filename.toLowerCase().endsWith("."+Application.LOCATOR_FILE_EXTENSION)) {
+-            addStartupFile(filename);
+-          }
+-        }
+-      }
+-    }
+-    
+-    public static void setLocatorFile(String filename) {
+-      _locatorFile = new File(filename);
+-    }
+-    
+-    public static void addStartupFile(String filename) {
+-      _startupFiles.add(new File(filename));
+-    }
+-    
+-    private Config createLocatorConfig(Config.ParseOpts opts) {
+-      if (_locatorFile == null) {
+-        return null;
+-      }
+-      
+-      Config locatorConfig = null;
+-      
+-      try {
+-        Config tmpConfig = null;
+-        if (_locatorFile.exists()) {
+-          tmpConfig = Config.parseConfig(_locatorFile,  opts);
+-        } else {
+-          log.warning("Given locator file does not exist", "file", _locatorFile);
+-        }
+-        
+-        // appbase is sanitised in HostWhitelist
+-        Map<String, Object> tmpData = new HashMap<>();
+-        for (Map.Entry<String, Object> entry : tmpConfig.getData().entrySet()) {
+-          String key = entry.getKey();
+-          Object value = entry.getValue();
+-          String mkey = key.indexOf('.') > -1 ? key.substring(key.indexOf('.') + 1) : key;
+-          if (Config.allowedReplaceKeys.contains(mkey) || Config.allowedMergeKeys.contains(mkey)) {
+-            tmpData.put(key, value);
+-          }
+-        }
+-        locatorConfig = new Config(tmpData);
+-        
+-      } catch (Exception e) {
+-        log.warning("Failure reading locator file",  "file", _locatorFile, e);
+-      }
+-      
+-      log.info("Returning locatorConfig", locatorConfig);
+-      
+-      return locatorConfig;
+-    }
+-    
+     protected final EnvConfig _envc;
+-    protected File _config;
+     protected Digest _digest;
+     protected long _version = -1;
+@@ -1906,7 +1737,6 @@ public class Application
+     protected String _dockName;
+     protected String _dockIconPath;
+     protected boolean _strictComments;
+-    protected boolean _windebug;
+     protected boolean _allowOffline;
+     protected int _maxConcDownloads;
+@@ -1924,10 +1754,14 @@ public class Application
+     protected long _javaMinVersion, _javaMaxVersion;
+     protected boolean _javaExactVersionRequired;
+     protected String _javaLocation;
++    protected File _javaLocalDir;
+     protected List<Resource> _codes = new ArrayList<>();
+     protected List<Resource> _resources = new ArrayList<>();
++    protected int _verifyTimeout = 60;
++
++    protected RevalidatePolicy _revalidatePolicy = RevalidatePolicy.AFTER_UPDATE;
+     protected boolean _useCodeCache;
+     protected int _codeCacheRetentionDays;
+@@ -1941,9 +1775,6 @@ public class Application
+     protected List<String> _txtJvmArgs = new ArrayList<>();
+-    /** If a warning has been issued about not being able to set modtimes. */
+-    protected boolean _warnedAboutSetLastModified;
+-
+     /** Locks gettingdown.lock in the app dir. Held the entire time updating is going on.*/
+     protected FileLock _lock;
+@@ -1956,8 +1787,6 @@ public class Application
+     protected static final String ENV_VAR_PREFIX = "%ENV.";
+     protected static final Pattern ENV_VAR_PATTERN = Pattern.compile("%ENV\\.(.*?)%");
+- 
+-    protected static File _locatorFile;
+-    protected static List<File> _startupFiles = new ArrayList<>();
+-    public static final String LOCATOR_FILE_EXTENSION = "jvl";
++
++    protected static enum RevalidatePolicy { ALWAYS, AFTER_UPDATE }
+ }
+diff --git a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/data/Digest.java b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/data/Digest.java
+index bc8d14052..e310a52a2 100644
+--- a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/data/Digest.java
++++ b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/data/Digest.java
+@@ -21,7 +21,7 @@ import static com.threerings.getdown.Log.log;
+ import static java.nio.charset.StandardCharsets.UTF_8;
+ /**
+- * Manages the <code>digest.txt</code> file and the computing and processing of digests for an
++ * Manages the {@code digest.txt} file and the computing and processing of digests for an
+  * application.
+  */
+ public class Digest
+@@ -72,8 +72,7 @@ public class Digest
+                         digests.put(rsrc, rsrc.computeDigest(fversion, md, null));
+                         completed.add(rsrc);
+                     } catch (Throwable t) {
+-                        completed.add(new IOException("Error computing digest for: " + rsrc).
+-                                      initCause(t));
++                        completed.add(new IOException("Error computing digest for: " + rsrc, t));
+                     }
+                 }
+             });
+@@ -88,7 +87,7 @@ public class Digest
+                 if (done instanceof IOException) {
+                     throw (IOException)done;
+                 } else if (done instanceof Resource) {
+-                    pending.remove((Resource)done);
++                    pending.remove(done);
+                 } else {
+                     throw new AssertionError("What is this? " + done);
+                 }
+diff --git a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/data/EnvConfig.java b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/data/EnvConfig.java
+index 57b8d8493..a14b02c63 100644
+--- a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/data/EnvConfig.java
++++ b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/data/EnvConfig.java
+@@ -7,22 +7,19 @@ package com.threerings.getdown.data;
+ import java.io.File;
+ import java.io.FileInputStream;
+-import java.net.MalformedURLException;
+-import java.net.URL;
+ import java.security.cert.Certificate;
+ import java.security.cert.CertificateFactory;
+ import java.security.cert.X509Certificate;
+ import java.util.*;
+ import com.threerings.getdown.util.StringUtil;
+-import com.threerings.getdown.data.Application;
+ /** Configuration that comes from our "environment" (command line args, sys props, etc.). */
+ public final class EnvConfig {
+     /** Used to report problems or feedback by {@link #create}. */
+     public static final class Note {
+-        public static enum Level { INFO, WARN, ERROR };
++        public enum Level { INFO, WARN, ERROR }
+         public static Note info (String msg) { return new Note(Level.INFO, msg); }
+         public static Note warn (String msg) { return new Note(Level.WARN, msg); }
+         public static Note error (String msg) { return new Note(Level.ERROR, msg); }
+@@ -141,23 +138,11 @@ public final class EnvConfig {
+                                     appIdProv + "'"));
+             }
+         }
+-        
+-        int skipArgs = 2;
+-        // Look for locator file, pass to Application and remove from appArgs
+-        String argvLocatorFilename = argv.length > 2 ? argv[2] : null;
+-        if (
+-                !StringUtil.isBlank(argvLocatorFilename)
+-                && argvLocatorFilename.toLowerCase().endsWith("."+Application.LOCATOR_FILE_EXTENSION)
+-                ) {
+-          notes.add(Note.info("locatorFilename in args: '"+argv[2]+"'"));
+-          Application.setLocatorFile(argvLocatorFilename);
+-          
+-          skipArgs++;
+-        }
+-        // ensure that we were able to find an app dir
++        // if no appdir was provided, default to the current working directory
+         if (appDir == null) {
+-            return null; // caller will report problem to user
++            appDir = System.getProperty("user.dir");
++            appDirProv = "default (cwd)";
+         }
+         notes.add(Note.info("Using appdir from " + appDirProv + ": " + appDir));
+@@ -187,9 +172,9 @@ public final class EnvConfig {
+             return null;
+         }
+-        // pass along anything after the first two (or three) args as extra app args
+-        List<String> appArgs = argv.length > skipArgs ?
+-            Arrays.asList(argv).subList(skipArgs, argv.length) :
++        // pass along anything after the first two args as extra app args
++        List<String> appArgs = argv.length > 2 ?
++            Arrays.asList(argv).subList(2, argv.length) :
+             Collections.<String>emptyList();
+         // load X.509 certificate if it exists
+diff --git a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/data/PathBuilder.java b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/data/PathBuilder.java
+index b0a1dc920..57e9275be 100644
+--- a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/data/PathBuilder.java
++++ b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/data/PathBuilder.java
+@@ -10,7 +10,7 @@ import java.io.IOException;
+ import java.util.LinkedHashSet;
+ import java.util.List;
+ import java.util.concurrent.TimeUnit;
+-import java.util.jar.JarFile;
++import java.util.zip.ZipFile;
+ import com.threerings.getdown.cache.GarbageCollector;
+ import com.threerings.getdown.cache.ResourceCache;
+@@ -112,7 +112,7 @@ public class PathBuilder
+             if (!unpackedIndicator.exists()) {
+                 try {
+-                    FileUtil.unpackJar(new JarFile(cachedFile), cachedParent, false);
++                    FileUtil.unpackJar(new ZipFile(cachedFile), cachedParent, false);
+                     unpackedIndicator.createNewFile();
+                 } catch (IOException ioe) {
+                     log.warning("Failed to unpack native jar",
+diff --git a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/data/Resource.java b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/data/Resource.java
+index adc2d4f21..d1ccba3ba 100644
+--- a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/data/Resource.java
++++ b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/data/Resource.java
+@@ -7,25 +7,14 @@ package com.threerings.getdown.data;
+ import java.io.*;
+ import java.net.URL;
+-import java.nio.file.Files;
+-import java.nio.file.Paths;
+ import java.security.MessageDigest;
+-import java.util.Collections;
+-import java.util.Comparator;
+-import java.util.EnumSet;
+-import java.util.List;
+-import java.util.Locale;
+-import java.util.jar.JarEntry;
+-import java.util.jar.JarFile;
+-
+-import org.apache.commons.compress.archivers.ArchiveInputStream;
+-import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
+-import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream;
++import java.util.*;
++import java.util.zip.ZipEntry;
++import java.util.zip.ZipFile;
+ import com.threerings.getdown.util.FileUtil;
+ import com.threerings.getdown.util.ProgressObserver;
+ import com.threerings.getdown.util.StringUtil;
+-
+ import static com.threerings.getdown.Log.log;
+ /**
+@@ -34,7 +23,7 @@ import static com.threerings.getdown.Log.log;
+ public class Resource implements Comparable<Resource>
+ {
+     /** Defines special attributes for resources. */
+-    public static enum Attr {
++    public enum Attr {
+         /** Indicates that the resource should be unpacked. */
+         UNPACK,
+         /** If present, when unpacking a resource, any directories created by the newly
+@@ -46,7 +35,7 @@ public class Resource implements Comparable<Resource>
+         PRELOAD,
+         /** Indicates that the resource is a jar containing native libs. */
+         NATIVE
+-    };
++    }
+     public static final EnumSet<Attr> NORMAL  = EnumSet.noneOf(Attr.class);
+     public static final EnumSet<Attr> UNPACK  = EnumSet.of(Attr.UNPACK);
+@@ -66,29 +55,30 @@ public class Resource implements Comparable<Resource>
+         byte[] buffer = new byte[DIGEST_BUFFER_SIZE];
+         int read;
+-        boolean isJar = isJar(target.getPath());
+-        boolean isPacked200Jar = isPacked200Jar(target.getPath());
++        boolean isZip = isJar(target) || isZip(target); // jar is a zip too
++        boolean isPacked200Jar = isPacked200Jar(target);
+         // if this is a jar, we need to compute the digest in a "timestamp and file order" agnostic
+         // manner to properly correlate jardiff patched jars with their unpatched originals
+-        if (isJar || isPacked200Jar){
++        if (isPacked200Jar || isZip){
+             File tmpJarFile = null;
+-            JarFile jar = null;
++            ZipFile zip = null;
+             try {
+-                // if this is a compressed jar file, uncompress it to compute the jar file digest
++                // if this is a compressed zip file, uncompress it to compute the zip file digest
+                 if (isPacked200Jar){
+                     tmpJarFile = new File(target.getPath() + ".tmp");
++                    tmpJarFile.deleteOnExit();
+                     FileUtil.unpackPacked200Jar(target, tmpJarFile);
+-                    jar = new JarFile(tmpJarFile);
++                    zip = new ZipFile(tmpJarFile);
+                 } else{
+-                    jar = new JarFile(target);
++                    zip = new ZipFile(target);
+                 }
+-                List<JarEntry> entries = Collections.list(jar.entries());
++                List<? extends ZipEntry> entries = Collections.list(zip.entries());
+                 Collections.sort(entries, ENTRY_COMP);
+                 int eidx = 0;
+-                for (JarEntry entry : entries) {
++                for (ZipEntry entry : entries) {
+                     // old versions of the digest code skipped metadata
+                     if (version < 2) {
+                         if (entry.getName().startsWith("META-INF")) {
+@@ -97,7 +87,7 @@ public class Resource implements Comparable<Resource>
+                         }
+                     }
+-                    try (InputStream in = jar.getInputStream(entry)) {
++                    try (InputStream in = zip.getInputStream(entry)) {
+                         while ((read = in.read(buffer)) != -1) {
+                             md.update(buffer, 0, read);
+                         }
+@@ -107,11 +97,11 @@ public class Resource implements Comparable<Resource>
+                 }
+             } finally {
+-                if (jar != null) {
++                if (zip != null) {
+                     try {
+-                        jar.close();
++                        zip.close();
+                     } catch (IOException ioe) {
+-                        log.warning("Error closing jar", "path", target, "jar", jar, "error", ioe);
++                        log.warning("Error closing", "path", target, "zip", zip, "error", ioe);
+                     }
+                 }
+                 if (tmpJarFile != null) {
+@@ -132,6 +122,34 @@ public class Resource implements Comparable<Resource>
+         return StringUtil.hexlate(md.digest());
+     }
++    /**
++     * Returns whether {@code file} is a {@code zip} file.
++     */
++    public static boolean isZip (File file)
++    {
++        String path = file.getName();
++        return path.endsWith(".zip") || path.endsWith(".zip_new");
++    }
++
++    /**
++     * Returns whether {@code file} is a {@code jar} file.
++     */
++    public static boolean isJar (File file)
++    {
++        String path = file.getName();
++        return path.endsWith(".jar") || path.endsWith(".jar_new");
++    }
++
++    /**
++     * Returns whether {@code file} is a {@code jar.pack} file.
++     */
++    public static boolean isPacked200Jar (File file)
++    {
++        String path = file.getName();
++        return path.endsWith(".jar.pack") || path.endsWith(".jar.pack_new") ||
++            path.endsWith(".jar.pack.gz") || path.endsWith(".jar.pack.gz_new");
++    }
++
+     /**
+      * Creates a resource with the supplied remote URL and local path.
+      */
+@@ -141,17 +159,13 @@ public class Resource implements Comparable<Resource>
+         _remote = remote;
+         _local = local;
+         _localNew = new File(local.toString() + "_new");
+-        String lpath = _local.getPath();
+-        _marker = new File(lpath + "v");
++        _marker = new File(_local.getPath() + "v");
+         _attrs = attrs;
+-        _isTgz = isTgz(lpath);
+-        _isJar = isJar(lpath);
+-        _isPacked200Jar = isPacked200Jar(lpath);
++        _isZip = isJar(local) || isZip(local);
++        _isPacked200Jar = isPacked200Jar(local);
+         boolean unpack = attrs.contains(Attr.UNPACK);
+-        if (unpack && _isJar) {
+-            _unpacked = _local.getParentFile();
+-        } else if(unpack && _isTgz) {
++        if (unpack && _isZip) {
+             _unpacked = _local.getParentFile();
+         } else if(unpack && _isPacked200Jar) {
+             String dotJar = ".jar", lname = _local.getName();
+@@ -307,20 +321,13 @@ public class Resource implements Comparable<Resource>
+     public void unpack () throws IOException
+     {
+         // sanity check
+-        if (!_isJar && !_isPacked200Jar && !_isTgz) {
+-            throw new IOException("Requested to unpack non-jar/tgz file '" + _local + "'.");
++        if (!_isZip && !_isPacked200Jar) {
++            throw new IOException("Requested to unpack non-jar file '" + _local + "'.");
+         }
+-        if (_isJar) {
+-            try (JarFile jar = new JarFile(_local)) {
++        if (_isZip) {
++            try (ZipFile jar = new ZipFile(_local)) {
+                 FileUtil.unpackJar(jar, _unpacked, _attrs.contains(Attr.CLEAN));
+             }
+-        } else if (_isTgz) {
+-            try (InputStream fi = Files.newInputStream(_local.toPath());
+-                         InputStream bi = new BufferedInputStream(fi);
+-                         InputStream gzi = new GzipCompressorInputStream(bi);
+-                         TarArchiveInputStream tgz = new TarArchiveInputStream(gzi)) {
+-                    FileUtil.unpackTgz(tgz, _unpacked, _attrs.contains(Attr.CLEAN));
+-            }
+         } else {
+             FileUtil.unpackPacked200Jar(_local, _unpacked);
+         }
+@@ -382,31 +389,15 @@ public class Resource implements Comparable<Resource>
+         }
+     }
+-    protected static boolean isJar (String path)
+-    {
+-        return path.endsWith(".jar") || path.endsWith(".jar_new");
+-    }
+-    
+-    protected static boolean isTgz (String path)
+-    {
+-        return path.endsWith(".tgz") || path.endsWith(".tgz_new");
+-    }
+-
+-    protected static boolean isPacked200Jar (String path)
+-    {
+-        return path.endsWith(".jar.pack") || path.endsWith(".jar.pack_new") ||
+-            path.endsWith(".jar.pack.gz")|| path.endsWith(".jar.pack.gz_new");
+-    }
+-
+     protected String _path;
+     protected URL _remote;
+     protected File _local, _localNew, _marker, _unpacked;
+     protected EnumSet<Attr> _attrs;
+-    protected boolean _isJar, _isPacked200Jar, _isTgz;
++    protected boolean _isZip, _isPacked200Jar;
+     /** Used to sort the entries in a jar file. */
+-    protected static final Comparator<JarEntry> ENTRY_COMP = new Comparator<JarEntry>() {
+-        @Override public int compare (JarEntry e1, JarEntry e2) {
++    protected static final Comparator<ZipEntry> ENTRY_COMP = new Comparator<ZipEntry>() {
++        @Override public int compare (ZipEntry e1, ZipEntry e2) {
+             return e1.getName().compareTo(e2.getName());
+         }
+     };
+diff --git a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/data/SysProps.java b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/data/SysProps.java
+index 0d96ecb71..b36d40021 100644
+--- a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/data/SysProps.java
++++ b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/data/SysProps.java
+@@ -16,7 +16,7 @@ import com.threerings.getdown.util.VersionUtil;
+  * accessor so that it's easy to see all of the secret system property arguments that Getdown makes
+  * use of.
+  */
+-public class SysProps
++public final class SysProps
+ {
+     /** Configures the appdir (in lieu of passing it in argv). Usage: {@code -Dappdir=foo}. */
+     public static String appDir () {
+@@ -40,6 +40,14 @@ public class SysProps
+         return System.getProperty("no_log_redir") != null;
+     }
++    /** Used to debug Getdown's launching of an app. When set, it disables redirection of stdout
++      * and stderr into a log file, and on Windows it uses {@code java.exe} to launch the app so
++      * that its console output can be observed.
++      * Usage: {@code -Ddebug}. */
++    public static boolean debug () {
++        return System.getProperty("debug") != null;
++    }
++
+     /** Overrides the domain on {@code appbase}. Usage: {@code -Dappbase_domain=foo}. */
+     public static String appbaseDomain () {
+         return System.getProperty("appbase_domain");
+@@ -101,6 +109,16 @@ public class SysProps
+         return Boolean.getBoolean("direct");
+     }
++    /** If true, Getdown will always try to connect without proxy settings even it a proxy is set
++      * in {@code proxy.txt}. If direct access is possible it will not clear {@code proxy.txt}, it
++      * will preserve the settings. This is to support cases where a user uses a workstation in two
++      * different networks, one with proxy the other one without. They should not be asked for
++      * proxy settings again each time they switch back to the proxy network.
++      * Usage: {@code -Dtry_no_proxy}. */
++    public static boolean tryNoProxyFirst () {
++        return Boolean.getBoolean("try_no_proxy");
++    }
++
+     /** Specifies the connection timeout (in seconds) to use when downloading control files from
+       * the server. This is chiefly useful when you are running in versionless mode and want Getdown
+       * to more quickly timeout its startup update check if the server with which it is
+diff --git a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/net/Downloader.java b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/net/Downloader.java
+index 6033e2f6e..2298d6099 100644
+--- a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/net/Downloader.java
++++ b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/net/Downloader.java
+@@ -6,7 +6,13 @@
+ package com.threerings.getdown.net;
+ import java.io.File;
++import java.io.FileOutputStream;
+ import java.io.IOException;
++import java.io.InputStream;
++import java.net.HttpURLConnection;
++import java.net.URLConnection;
++import java.nio.channels.Channels;
++import java.nio.channels.ReadableByteChannel;
+ import java.util.Collection;
+ import java.util.HashMap;
+@@ -26,8 +32,13 @@ import static com.threerings.getdown.Log.log;
+  * implementors must take care to only execute thread-safe code or simply pass a message to the AWT
+  * thread, for example.
+  */
+-public abstract class Downloader
++public class Downloader
+ {
++    public Downloader (Connector conn)
++    {
++        _conn = conn;
++    }
++
+     /**
+      * Start the downloading process.
+      * @param resources the resources to download.
+@@ -129,10 +140,31 @@ public abstract class Downloader
+      */
+     protected void downloadFailed (Resource rsrc, Exception cause) {}
++    /**
++     * Called when a to-be-downloaded resource returns a 404 not found.
++     */
++    protected void resourceMissing (Resource rsrc) {}
++
+     /**
+      * Performs the protocol-specific portion of checking download size.
+      */
+-    protected abstract long checkSize (Resource rsrc) throws IOException;
++    protected long checkSize (Resource rsrc) throws IOException {
++        URLConnection conn = _conn.open(rsrc.getRemote(), 0, 0);
++        try {
++            // if we're accessing our data via HTTP, we only need a HEAD request
++            if (conn instanceof HttpURLConnection) {
++                ((HttpURLConnection)conn).setRequestMethod("HEAD");
++            }
++            // if we get a satisfactory response code, return a size; ignore errors as we'll report
++            // those when we actually attempt to download the resource
++            int code = _conn.checkConnectStatus(conn);
++            return code == HttpURLConnection.HTTP_OK ? conn.getContentLength() : 0;
++
++        } finally {
++            // let it be known that we're done with this connection
++            conn.getInputStream().close();
++        }
++    }
+     /**
+      * Periodically called by the protocol-specific downloaders to update their progress. This
+@@ -203,13 +235,68 @@ public abstract class Downloader
+      * protocol-specific code. This method should periodically check whether {@code _state} is set
+      * to aborted and abort any in-progress download if so.
+      */
+-    protected abstract void download (Resource rsrc) throws IOException;
++    protected void download (Resource rsrc) throws IOException {
++        URLConnection conn = _conn.open(rsrc.getRemote(), 0, 0);
++        // make sure we got a satisfactory response code
++        int code = _conn.checkConnectStatus(conn);
++        if (code == HttpURLConnection.HTTP_NOT_FOUND) {
++            resourceMissing(rsrc);
++        } else if (code != HttpURLConnection.HTTP_OK) {
++            throw new IOException(
++                "Resource returned HTTP error " + rsrc.getRemote() + " [code=" + code + "]");
++        }
++
++        // TODO: make FileChannel download impl (below) robust and allow apps to opt-into it via a
++        // system property
++        if (true) {
++            // download the resource from the specified URL
++            long actualSize = conn.getContentLength();
++            log.info("Downloading resource", "url", rsrc.getRemote(), "size", actualSize);
++            long currentSize = 0L;
++            byte[] buffer = new byte[4*4096];
++            try (InputStream in = conn.getInputStream();
++                 FileOutputStream out = new FileOutputStream(rsrc.getLocalNew())) {
++
++                // TODO: look to see if we have a download info file
++                // containing info on potentially partially downloaded data;
++                // if so, use a "Range: bytes=HAVE-" header.
++
++                // read in the file data
++                int read;
++                while ((read = in.read(buffer)) != -1) {
++                    // abort the download if the downloader is aborted
++                    if (_state == State.ABORTED) {
++                        break;
++                    }
++                    // write it out to our local copy
++                    out.write(buffer, 0, read);
++                    // note that we've downloaded some data
++                    currentSize += read;
++                    reportProgress(rsrc, currentSize, actualSize);
++                }
++            }
++
++        } else {
++            log.info("Downloading resource", "url", rsrc.getRemote(), "size", "unknown");
++            File localNew = rsrc.getLocalNew();
++            try (ReadableByteChannel rbc = Channels.newChannel(conn.getInputStream());
++                 FileOutputStream fos = new FileOutputStream(localNew)) {
++                // TODO: more work is needed here, transferFrom can fail to transfer the entire
++                // file, in which case it's not clear what we're supposed to do.. call it again?
++                // will it repeatedly fail?
++                fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE);
++                reportProgress(rsrc, localNew.length(), localNew.length());
++            }
++        }
++    }
++
++    protected final Connector _conn;
+     /** The reported sizes of our resources. */
+-    protected Map<Resource, Long> _sizes = new HashMap<>();
++    protected final Map<Resource, Long> _sizes = new HashMap<>();
+     /** The bytes downloaded for each resource. */
+-    protected Map<Resource, Long> _downloaded = new HashMap<>();
++    protected final Map<Resource, Long> _downloaded = new HashMap<>();
+     /** The time at which the file transfer began. */
+     protected long _start;
+diff --git a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/net/HTTPDownloader.java b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/net/HTTPDownloader.java
+deleted file mode 100644
+index a7a3287a9..000000000
+--- a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/net/HTTPDownloader.java
++++ /dev/null
+@@ -1,115 +0,0 @@
+-//
+-// Getdown - application installer, patcher and launcher
+-// Copyright (C) 2004-2018 Getdown authors
+-// https://github.com/threerings/getdown/blob/master/LICENSE
+-
+-package com.threerings.getdown.net;
+-
+-import java.io.File;
+-import java.io.FileOutputStream;
+-import java.io.IOException;
+-import java.io.InputStream;
+-import java.net.HttpURLConnection;
+-import java.net.Proxy;
+-import java.net.URL;
+-import java.net.URLConnection;
+-import java.nio.channels.Channels;
+-import java.nio.channels.ReadableByteChannel;
+-
+-import com.threerings.getdown.data.Resource;
+-import com.threerings.getdown.util.ConnectionUtil;
+-
+-import static com.threerings.getdown.Log.log;
+-
+-/**
+- * Implements downloading files over HTTP
+- */
+-public class HTTPDownloader extends Downloader
+-{
+-    public HTTPDownloader (Proxy proxy)
+-    {
+-        _proxy = proxy;
+-    }
+-
+-    @Override protected long checkSize (Resource rsrc) throws IOException
+-    {
+-        URLConnection conn = ConnectionUtil.open(_proxy, rsrc.getRemote(), 0, 0);
+-        try {
+-            // if we're accessing our data via HTTP, we only need a HEAD request
+-            if (conn instanceof HttpURLConnection) {
+-                HttpURLConnection hcon = (HttpURLConnection)conn;
+-                hcon.setRequestMethod("HEAD");
+-                hcon.connect();
+-                // make sure we got a satisfactory response code
+-                if (hcon.getResponseCode() != HttpURLConnection.HTTP_OK) {
+-                    throw new IOException("Unable to check up-to-date for " +
+-                                          rsrc.getRemote() + ": " + hcon.getResponseCode());
+-                }
+-            }
+-            return conn.getContentLength();
+-
+-        } finally {
+-            // let it be known that we're done with this connection
+-            conn.getInputStream().close();
+-        }
+-    }
+-
+-    @Override protected void download (Resource rsrc) throws IOException
+-    {
+-        // TODO: make FileChannel download impl (below) robust and allow apps to opt-into it via a
+-        // system property
+-        if (true) {
+-            // download the resource from the specified URL
+-            URLConnection conn = ConnectionUtil.open(_proxy, rsrc.getRemote(), 0, 0);
+-            conn.connect();
+-
+-            // make sure we got a satisfactory response code
+-            if (conn instanceof HttpURLConnection) {
+-                HttpURLConnection hcon = (HttpURLConnection)conn;
+-                if (hcon.getResponseCode() != HttpURLConnection.HTTP_OK) {
+-                    throw new IOException("Unable to download resource " + rsrc.getRemote() + ": " +
+-                                          hcon.getResponseCode());
+-                }
+-            }
+-            long actualSize = conn.getContentLength();
+-            log.info("Downloading resource", "url", rsrc.getRemote(), "size", actualSize);
+-            long currentSize = 0L;
+-            byte[] buffer = new byte[4*4096];
+-            try (InputStream in = conn.getInputStream();
+-                 FileOutputStream out = new FileOutputStream(rsrc.getLocalNew())) {
+-
+-                // TODO: look to see if we have a download info file
+-                // containing info on potentially partially downloaded data;
+-                // if so, use a "Range: bytes=HAVE-" header.
+-
+-                // read in the file data
+-                int read;
+-                while ((read = in.read(buffer)) != -1) {
+-                    // abort the download if the downloader is aborted
+-                    if (_state == State.ABORTED) {
+-                        break;
+-                    }
+-                    // write it out to our local copy
+-                    out.write(buffer, 0, read);
+-                    // note that we've downloaded some data
+-                    currentSize += read;
+-                    reportProgress(rsrc, currentSize, actualSize);
+-                }
+-            }
+-
+-        } else {
+-            log.info("Downloading resource", "url", rsrc.getRemote(), "size", "unknown");
+-            File localNew = rsrc.getLocalNew();
+-            try (ReadableByteChannel rbc = Channels.newChannel(rsrc.getRemote().openStream());
+-                 FileOutputStream fos = new FileOutputStream(localNew)) {
+-                // TODO: more work is needed here, transferFrom can fail to transfer the entire
+-                // file, in which case it's not clear what we're supposed to do.. call it again?
+-                // will it repeatedly fail?
+-                fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE);
+-                reportProgress(rsrc, localNew.length(), localNew.length());
+-            }
+-        }
+-    }
+-
+-    protected final Proxy _proxy;
+-}
+diff --git a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/spi/ProxyAuth.java b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/spi/ProxyAuth.java
+index 22446ec0a..8c6d84160 100644
+--- a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/spi/ProxyAuth.java
++++ b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/spi/ProxyAuth.java
+@@ -11,7 +11,7 @@ package com.threerings.getdown.spi;
+ public interface ProxyAuth
+ {
+     /** Credentials for a proxy server. */
+-    public static class Credentials {
++    class Credentials {
+         public final String username;
+         public final String password;
+         public Credentials (String username, String password) {
+@@ -23,10 +23,10 @@ public interface ProxyAuth
+     /**
+      * Loads the credentials for the app installed in {@code appDir}.
+      */
+-    public Credentials loadCredentials (String appDir);
++    Credentials loadCredentials (String appDir);
+     /**
+      * Encrypts and saves the credentials for the app installed in {@code appDir}.
+      */
+-    public void saveCredentials (String appDir, String username, String password);
++    void saveCredentials (String appDir, String username, String password);
+ }
+diff --git a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/tools/Differ.java b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/tools/Differ.java
+index c2e740b6e..4f8e50ebd 100644
+--- a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/tools/Differ.java
++++ b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/tools/Differ.java
+@@ -11,15 +11,14 @@ import java.io.FileInputStream;
+ import java.io.FileOutputStream;
+ import java.io.IOException;
+ import java.io.InputStream;
+-
++import java.io.OutputStream;
++import java.security.MessageDigest;
+ import java.util.ArrayList;
+ import java.util.Enumeration;
+-import java.util.jar.JarEntry;
+-import java.util.jar.JarFile;
+-import java.util.jar.JarOutputStream;
++import java.util.List;
+ import java.util.zip.ZipEntry;
+-
+-import java.security.MessageDigest;
++import java.util.zip.ZipFile;
++import java.util.zip.ZipOutputStream;
+ import com.threerings.getdown.data.Application;
+ import com.threerings.getdown.data.Digest;
+@@ -39,8 +38,8 @@ public class Differ
+     /**
+      * Creates a single patch file that contains the differences between
+      * the two specified application directories. The patch file will be
+-     * created in the <code>nvdir</code> directory with name
+-     * <code>patchV.dat</code> where V is the old application version.
++     * created in the {@code nvdir} directory with name
++     * {@code patchV.dat} where V is the old application version.
+      */
+     public void createDiff (File nvdir, File ovdir, boolean verbose)
+         throws IOException
+@@ -61,13 +60,13 @@ public class Differ
+         Application oapp = new Application(new EnvConfig(ovdir));
+         oapp.init(false);
+-        ArrayList<Resource> orsrcs = new ArrayList<>();
++        List<Resource> orsrcs = new ArrayList<>();
+         orsrcs.addAll(oapp.getCodeResources());
+         orsrcs.addAll(oapp.getResources());
+         Application napp = new Application(new EnvConfig(nvdir));
+         napp.init(false);
+-        ArrayList<Resource> nrsrcs = new ArrayList<>();
++        List<Resource> nrsrcs = new ArrayList<>();
+         nrsrcs.addAll(napp.getCodeResources());
+         nrsrcs.addAll(napp.getResources());
+@@ -91,15 +90,15 @@ public class Differ
+         }
+     }
+-    protected void createPatch (File patch, ArrayList<Resource> orsrcs,
+-                                ArrayList<Resource> nrsrcs, boolean verbose)
++    protected void createPatch (File patch, List<Resource> orsrcs,
++                                List<Resource> nrsrcs, boolean verbose)
+         throws IOException
+     {
+         int version = Digest.VERSION;
+         MessageDigest md = Digest.getMessageDigest(version);
+         try (FileOutputStream fos = new FileOutputStream(patch);
+              BufferedOutputStream buffered = new BufferedOutputStream(fos);
+-             JarOutputStream jout = new JarOutputStream(buffered)) {
++             ZipOutputStream jout = new ZipOutputStream(buffered)) {
+             // for each file in the new application, it either already exists
+             // in the old application, or it is new
+@@ -172,13 +171,13 @@ public class Differ
+         throws IOException
+     {
+         File temp = File.createTempFile("differ", "jar");
+-        try (JarFile jar = new JarFile(target);
++        try (ZipFile jar = new ZipFile(target);
+              FileOutputStream tempFos = new FileOutputStream(temp);
+              BufferedOutputStream tempBos = new BufferedOutputStream(tempFos);
+-             JarOutputStream jout = new JarOutputStream(tempBos)) {
++             ZipOutputStream jout = new ZipOutputStream(tempBos)) {
+             byte[] buffer = new byte[4096];
+-            for (Enumeration< JarEntry > iter = jar.entries(); iter.hasMoreElements();) {
+-                JarEntry entry = iter.nextElement();
++            for (Enumeration<? extends ZipEntry> iter = jar.entries(); iter.hasMoreElements();) {
++                ZipEntry entry = iter.nextElement();
+                 entry.setCompressedSize(-1);
+                 jout.putNextEntry(entry);
+                 try (InputStream in = jar.getInputStream(entry)) {
+@@ -193,8 +192,7 @@ public class Differ
+         return temp;
+     }
+-    protected void jarDiff (File ofile, File nfile, JarOutputStream jout)
+-        throws IOException
++    protected void jarDiff (File ofile, File nfile, ZipOutputStream jout) throws IOException
+     {
+         JarDiff.createPatch(ofile.getPath(), nfile.getPath(), jout, false);
+     }
+@@ -209,7 +207,7 @@ public class Differ
+         Differ differ = new Differ();
+         boolean verbose = false;
+         int aidx = 0;
+-        if (args[0].equals("-verbose")) {
++        if ("-verbose".equals(args[0])) {
+             verbose = true;
+             aidx++;
+         }
+@@ -222,11 +220,10 @@ public class Differ
+         }
+     }
+-    protected static void pipe (File file, JarOutputStream jout)
+-        throws IOException
++    protected static void pipe (File file, OutputStream out) throws IOException
+     {
+         try (FileInputStream fin = new FileInputStream(file)) {
+-            StreamUtil.copy(fin, jout);
++            StreamUtil.copy(fin, out);
+         }
+     }
+ }
+diff --git a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/tools/Digester.java b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/tools/Digester.java
+index b04a6539b..ae61b4333 100644
+--- a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/tools/Digester.java
++++ b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/tools/Digester.java
+@@ -23,6 +23,7 @@ import com.threerings.getdown.data.Digest;
+ import com.threerings.getdown.data.EnvConfig;
+ import com.threerings.getdown.data.Resource;
+ import com.threerings.getdown.util.Base64;
++import com.threerings.getdown.util.Config;
+ import static java.nio.charset.StandardCharsets.UTF_8;
+@@ -74,8 +75,11 @@ public class Digester
+         System.out.println("Generating digest file '" + target + "'...");
+         // create our application and instruct it to parse its business
+-        Application app = new Application(new EnvConfig(appdir));
+-        app.init(false);
++        EnvConfig envc = new EnvConfig(appdir);
++        Application app = new Application(envc);
++        Config config = Application.readConfig(envc, false);
++        app.initBase(config);
++        app.initResources(config);
+         List<Resource> rsrcs = new ArrayList<>();
+         rsrcs.add(app.getConfigResource());
+@@ -86,6 +90,9 @@ public class Digester
+             rsrcs.addAll(ag.rsrcs);
+         }
++        // reinit app just to verify that getdown.txt has valid format
++        app.init(true);
++
+         // now generate the digest file
+         Digest.createDigest(version, rsrcs, target);
+     }
+diff --git a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/tools/JarDiff.java b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/tools/JarDiff.java
+index 1cea0eacd..f0db8ac03 100644
+--- a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/tools/JarDiff.java
++++ b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/tools/JarDiff.java
+@@ -41,15 +41,24 @@
+ package com.threerings.getdown.tools;
+-import java.io.*;
++import java.io.Closeable;
++import java.io.File;
++import java.io.IOException;
++import java.io.InputStream;
++import java.io.OutputStream;
++import java.io.StringWriter;
++import java.io.Writer;
+ import java.util.*;
+-import java.util.jar.*;
++import java.util.zip.ZipEntry;
++import java.util.zip.ZipFile;
++import java.util.zip.ZipOutputStream;
+ import static java.nio.charset.StandardCharsets.UTF_8;
+ /**
+- * JarDiff is able to create a jar file containing the delta between two jar files (old and new).
+- * The delta jar file can then be applied to the old jar file to reconstruct the new jar file.
++ * JarDiff is able to create a zip file containing the delta between two jar or zip files (old
++ * and new). The delta file can then be applied to the old archive file to reconstruct the new
++ * archive file.
+  *
+  * <p> Refer to the JNLP spec for details on how this is done.
+  *
+@@ -58,39 +67,37 @@ import static java.nio.charset.StandardCharsets.UTF_8;
+ public class JarDiff implements JarDiffCodes
+ {
+     private static final int DEFAULT_READ_SIZE = 2048;
+-    private static byte[] newBytes = new byte[DEFAULT_READ_SIZE];
+-    private static byte[] oldBytes = new byte[DEFAULT_READ_SIZE];
++    private static final byte[] newBytes = new byte[DEFAULT_READ_SIZE];
++    private static final byte[] oldBytes = new byte[DEFAULT_READ_SIZE];
+     // The JARDiff.java is the stand-alone jardiff.jar tool. Thus, we do not depend on Globals.java
+     // and other stuff here. Instead, we use an explicit _debug flag.
+     private static boolean _debug;
+     /**
+-     * Creates a patch from the two passed in files, writing the result to <code>os</code>.
++     * Creates a patch from the two passed in files, writing the result to {@code os}.
+      */
+     public static void createPatch (String oldPath, String newPath,
+                                     OutputStream os, boolean minimal) throws IOException
+     {
+-        try (JarFile2 oldJar = new JarFile2(oldPath);
+-             JarFile2 newJar = new JarFile2(newPath)) {
++        try (ZipFile2 oldArchive = new ZipFile2(oldPath);
++             ZipFile2 newArchive = new ZipFile2(newPath)) {
+-            HashMap<String,String> moved = new HashMap<>();
+-            HashSet<String> implicit = new HashSet<>();
+-            HashSet<String> moveSrc = new HashSet<>();
+-            HashSet<String> newEntries = new HashSet<>();
++            Map<String,String> moved = new HashMap<>();
++            Set<String> implicit = new HashSet<>();
++            Set<String> moveSrc = new HashSet<>();
++            Set<String> newEntries = new HashSet<>();
+             // FIRST PASS
+-            // Go through the entries in new jar and
+-            // determine which files are candidates for implicit moves
+-            // ( files that has the same filename and same content in old.jar
+-            // and new.jar )
+-            // and for files that cannot be implicitly moved, we will either
+-            // find out whether it is moved or new (modified)
+-            for (JarEntry newEntry : newJar) {
++            // Go through the entries in new archive and determine which files are candidates for
++            // implicit moves (files that have the same filename and same content in old and new)
++            // and for files that cannot be implicitly moved, we will either find out whether it is
++            // moved or new (modified)
++            for (ZipEntry newEntry : newArchive) {
+                 String newname = newEntry.getName();
+                 // Return best match of contents, will return a name match if possible
+-                String oldname = oldJar.getBestMatch(newJar, newEntry);
++                String oldname = oldArchive.getBestMatch(newArchive, newEntry);
+                 if (oldname == null) {
+                     // New or modified entry
+                     if (_debug) {
+@@ -101,7 +108,7 @@ public class JarDiff implements JarDiffCodes
+                     // Content already exist - need to do a move
+                     // Should do implicit move? Yes, if names are the same, and
+-                    // no move command already exist from oldJar
++                    // no move command already exist from oldArchive
+                     if (oldname.equals(newname) && !moveSrc.contains(oldname)) {
+                         if (_debug) {
+                             System.out.println(newname + " added to implicit set!");
+@@ -117,12 +124,9 @@ public class JarDiff implements JarDiffCodes
+                         // JarDiffPatcher also.
+                         if (!minimal && (implicit.contains(oldname) ||
+                                          moveSrc.contains(oldname) )) {
+-
+                             // generate non-minimal jardiff
+                             // for backward compatibility
+-
+                             if (_debug) {
+-
+                                 System.out.println("NEW: "+ newname);
+                             }
+                             newEntries.add(newname);
+@@ -153,8 +157,8 @@ public class JarDiff implements JarDiffCodes
+             // SECOND PASS: <deleted files> = <oldjarnames> - <implicitmoves> -
+             // <source of move commands> - <new or modified entries>
+-            ArrayList<String> deleted = new ArrayList<>();
+-            for (JarEntry oldEntry : oldJar) {
++            List<String> deleted = new ArrayList<>();
++            for (ZipEntry oldEntry : oldArchive) {
+                 String oldName = oldEntry.getName();
+                 if (!implicit.contains(oldName) && !moveSrc.contains(oldName)
+                     && !newEntries.contains(oldName)) {
+@@ -180,7 +184,7 @@ public class JarDiff implements JarDiffCodes
+                 }
+             }
+-            JarOutputStream jos = new JarOutputStream(os);
++            ZipOutputStream jos = new ZipOutputStream(os);
+             // Write out all the MOVEs and REMOVEs
+             createIndex(jos, deleted, moved);
+@@ -190,7 +194,7 @@ public class JarDiff implements JarDiffCodes
+                 if (_debug) {
+                     System.out.println("New File: " + newName);
+                 }
+-                writeEntry(jos, newJar.getEntryByName(newName), newJar);
++                writeEntry(jos, newArchive.getEntryByName(newName), newArchive);
+             }
+             jos.finish();
+@@ -199,11 +203,11 @@ public class JarDiff implements JarDiffCodes
+     }
+     /**
+-     * Writes the index file out to <code>jos</code>.
+-     * <code>oldEntries</code> gives the names of the files that were removed,
+-     * <code>movedMap</code> maps from the new name to the old name.
++     * Writes the index file out to {@code jos}.
++     * {@code oldEntries} gives the names of the files that were removed,
++     * {@code movedMap} maps from the new name to the old name.
+      */
+-    private static void createIndex (JarOutputStream jos, List<String> oldEntries,
++    private static void createIndex (ZipOutputStream jos, List<String> oldEntries,
+                                      Map<String,String> movedMap)
+         throws IOException
+     {
+@@ -220,17 +224,17 @@ public class JarDiff implements JarDiffCodes
+         }
+         // And those that have moved
+-        for (String newName : movedMap.keySet()) {
+-            String oldName = movedMap.get(newName);
++        for (Map.Entry<String, String> entry : movedMap.entrySet()) {
++            String oldName = entry.getValue();
+             writer.write(MOVE_COMMAND);
+             writer.write(" ");
+             writeEscapedString(writer, oldName);
+             writer.write(" ");
+-            writeEscapedString(writer, newName);
++            writeEscapedString(writer, entry.getKey());
+             writer.write("\r\n");
+         }
+-        jos.putNextEntry(new JarEntry(INDEX_NAME));
++        jos.putNextEntry(new ZipEntry(INDEX_NAME));
+         byte[] bytes = writer.toString().getBytes(UTF_8);
+         jos.write(bytes, 0, bytes.length);
+     }
+@@ -264,10 +268,10 @@ public class JarDiff implements JarDiffCodes
+         return writer;
+     }
+-    private static void writeEntry (JarOutputStream jos, JarEntry entry, JarFile2 file)
++    private static void writeEntry (ZipOutputStream jos, ZipEntry entry, ZipFile2 file)
+         throws IOException
+     {
+-        try (InputStream data = file.getJarFile().getInputStream(entry)) {
++        try (InputStream data = file.getArchive().getInputStream(entry)) {
+             jos.putNextEntry(entry);
+             int size = data.read(newBytes);
+             while (size != -1) {
+@@ -278,31 +282,31 @@ public class JarDiff implements JarDiffCodes
+     }
+     /**
+-     * JarFile2 wraps a JarFile providing some convenience methods.
++     * ZipFile2 wraps a ZipFile providing some convenience methods.
+      */
+-    private static class JarFile2 implements Iterable<JarEntry>, Closeable
++    private static class ZipFile2 implements Iterable<ZipEntry>, Closeable
+     {
+-        private JarFile _jar;
+-        private List<JarEntry> _entries;
+-        private HashMap<String,JarEntry> _nameToEntryMap;
+-        private HashMap<Long,LinkedList<JarEntry>> _crcToEntryMap;
++        private final ZipFile _archive;
++        private List<ZipEntry> _entries;
++        private HashMap<String,ZipEntry> _nameToEntryMap;
++        private HashMap<Long,LinkedList<ZipEntry>> _crcToEntryMap;
+-        public JarFile2 (String path) throws IOException {
+-            _jar = new JarFile(new File(path));
++        public ZipFile2 (String path) throws IOException {
++            _archive = new ZipFile(new File(path));
+             index();
+         }
+-        public JarFile getJarFile () {
+-            return _jar;
++        public ZipFile getArchive () {
++            return _archive;
+         }
+-        // from interface Iterable<JarEntry>
++        // from interface Iterable<ZipEntry>
+         @Override
+-        public Iterator<JarEntry> iterator () {
++        public Iterator<ZipEntry> iterator () {
+             return _entries.iterator();
+         }
+-        public JarEntry getEntryByName (String name) {
++        public ZipEntry getEntryByName (String name) {
+             return _nameToEntryMap.get(name);
+         }
+@@ -350,7 +354,7 @@ public class JarDiff implements JarDiffCodes
+             return retVal;
+         }
+-        public String getBestMatch (JarFile2 file, JarEntry entry) throws IOException {
++        public String getBestMatch (ZipFile2 file, ZipEntry entry) throws IOException {
+             // check for same name and same content, return name if found
+             if (contains(file, entry)) {
+                 return (entry.getName());
+@@ -360,11 +364,10 @@ public class JarDiff implements JarDiffCodes
+             return (hasSameContent(file,entry));
+         }
+-        public boolean contains (JarFile2 f, JarEntry e) throws IOException {
+-
+-            JarEntry thisEntry = getEntryByName(e.getName());
++        public boolean contains (ZipFile2 f, ZipEntry e) throws IOException {
++            ZipEntry thisEntry = getEntryByName(e.getName());
+-            // Look up name in 'this' Jar2File - if not exist return false
++            // Look up name in 'this' ZipFile2 - if not exist return false
+             if (thisEntry == null)
+                 return false;
+@@ -373,26 +376,26 @@ public class JarDiff implements JarDiffCodes
+                 return false;
+             // Check contents - if no match - return false
+-            try (InputStream oldIS = getJarFile().getInputStream(thisEntry);
+-                 InputStream newIS = f.getJarFile().getInputStream(e)) {
++            try (InputStream oldIS = getArchive().getInputStream(thisEntry);
++                 InputStream newIS = f.getArchive().getInputStream(e)) {
+                 return !differs(oldIS, newIS);
+             }
+         }
+-        public String hasSameContent (JarFile2 file, JarEntry entry) throws IOException {
++        public String hasSameContent (ZipFile2 file, ZipEntry entry) throws IOException {
+             String thisName = null;
+-            Long crcL = Long.valueOf(entry.getCrc());
+-            // check if this jar contains files with the passed in entry's crc
++            Long crcL = entry.getCrc();
++            // check if this archive contains files with the passed in entry's crc
+             if (_crcToEntryMap.containsKey(crcL)) {
+                 // get the Linked List with files with the crc
+-                LinkedList<JarEntry> ll = _crcToEntryMap.get(crcL);
++                LinkedList<ZipEntry> ll = _crcToEntryMap.get(crcL);
+                 // go through the list and check for content match
+-                ListIterator<JarEntry> li = ll.listIterator(0);
++                ListIterator<ZipEntry> li = ll.listIterator(0);
+                 while (li.hasNext()) {
+-                    JarEntry thisEntry = li.next();
++                    ZipEntry thisEntry = li.next();
+                     // check for content match
+-                    try (InputStream oldIS = getJarFile().getInputStream(thisEntry);
+-                         InputStream newIS = file.getJarFile().getInputStream(entry)) {
++                    try (InputStream oldIS = getArchive().getInputStream(thisEntry);
++                         InputStream newIS = file.getArchive().getInputStream(entry)) {
+                         if (!differs(oldIS, newIS)) {
+                             thisName = thisEntry.getName();
+                             return thisName;
+@@ -404,19 +407,19 @@ public class JarDiff implements JarDiffCodes
+         }
+         private void index () throws IOException {
+-            Enumeration<JarEntry> entries = _jar.entries();
++            Enumeration<? extends ZipEntry> entries = _archive.entries();
+             _nameToEntryMap = new HashMap<>();
+             _crcToEntryMap = new HashMap<>();
+             _entries = new ArrayList<>();
+             if (_debug) {
+-                System.out.println("indexing: " + _jar.getName());
++                System.out.println("indexing: " + _archive.getName());
+             }
+             if (entries != null) {
+                 while (entries.hasMoreElements()) {
+-                    JarEntry entry = entries.nextElement();
++                    ZipEntry entry = entries.nextElement();
+                     long crc = entry.getCrc();
+-                    Long crcL = Long.valueOf(crc);
++                    Long crcL = crc;
+                     if (_debug) {
+                         System.out.println("\t" + entry.getName() + " CRC " + crc);
+                     }
+@@ -427,13 +430,13 @@ public class JarDiff implements JarDiffCodes
+                     // generate the CRC to entries map
+                     if (_crcToEntryMap.containsKey(crcL)) {
+                         // key exist, add the entry to the correcponding linked list
+-                        LinkedList<JarEntry> ll = _crcToEntryMap.get(crcL);
++                        LinkedList<ZipEntry> ll = _crcToEntryMap.get(crcL);
+                         ll.add(entry);
+                         _crcToEntryMap.put(crcL, ll);
+                     } else {
+                         // create a new entry in the hashmap for the new key
+-                        LinkedList<JarEntry> ll = new LinkedList<JarEntry>();
++                        LinkedList<ZipEntry> ll = new LinkedList<>();
+                         ll.add(entry);
+                         _crcToEntryMap.put(crcL, ll);
+                     }
+@@ -443,7 +446,7 @@ public class JarDiff implements JarDiffCodes
+         @Override
+         public void close() throws IOException {
+-            _jar.close();
++            _archive.close();
+         }
+     }
+ }
+diff --git a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/tools/JarDiffPatcher.java b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/tools/JarDiffPatcher.java
+index b5a0a1763..e55034bca 100644
+--- a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/tools/JarDiffPatcher.java
++++ b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/tools/JarDiffPatcher.java
+@@ -16,27 +16,25 @@ import java.util.ArrayList;
+ import java.util.Enumeration;
+ import java.util.HashMap;
+ import java.util.HashSet;
+-import java.util.Iterator;
+ import java.util.List;
+ import java.util.Map;
+ import java.util.Set;
+-
+-import java.util.jar.JarEntry;
+-import java.util.jar.JarFile;
+ import java.util.jar.JarOutputStream;
++import java.util.zip.ZipEntry;
++import java.util.zip.ZipFile;
++import java.util.zip.ZipOutputStream;
+ import com.threerings.getdown.util.ProgressObserver;
+-
+ import static java.nio.charset.StandardCharsets.UTF_8;
+ /**
+- * Applies a jardiff patch to a jar file.
++ * Applies a jardiff patch to a jar/zip file.
+  */
+ public class JarDiffPatcher implements JarDiffCodes
+ {
+     /**
+-     * Patches the specified jar file using the supplied patch file and writing
+-     * the new jar file to the supplied target.
++     * Patches the specified jar file using the supplied patch file and writing the new jar file to
++     * the supplied target.
+      *
+      * @param jarPath the path to the original jar file.
+      * @param diffPath the path to the jardiff patch file.
+@@ -49,11 +47,9 @@ public class JarDiffPatcher implements JarDiffCodes
+         throws IOException
+     {
+         File oldFile = new File(jarPath), diffFile = new File(diffPath);
+-
+-        try (JarFile oldJar = new JarFile(oldFile);
+-             JarFile jarDiff = new JarFile(diffFile);
+-             JarOutputStream jos = new JarOutputStream(new FileOutputStream(target))) {
+-
++        try (ZipFile oldJar = new ZipFile(oldFile);
++             ZipFile jarDiff = new ZipFile(diffFile);
++             ZipOutputStream jos = makeOutputStream(oldFile, target)) {
+             Set<String> ignoreSet = new HashSet<>();
+             Map<String, String> renameMap = new HashMap<>();
+             determineNameMapping(jarDiff, ignoreSet, renameMap);
+@@ -63,7 +59,7 @@ public class JarDiffPatcher implements JarDiffCodes
+             // Files to implicit move
+             Set<String> oldjarNames  = new HashSet<>();
+-            Enumeration<JarEntry> oldEntries = oldJar.entries();
++            Enumeration<? extends ZipEntry> oldEntries = oldJar.entries();
+             if (oldEntries != null) {
+                 while  (oldEntries.hasMoreElements()) {
+                     oldjarNames.add(oldEntries.nextElement().getName());
+@@ -83,10 +79,10 @@ public class JarDiffPatcher implements JarDiffCodes
+             size -= ignoreSet.size();
+             // Add content from JARDiff
+-            Enumeration<JarEntry> entries = jarDiff.entries();
++            Enumeration<? extends ZipEntry> entries = jarDiff.entries();
+             if (entries != null) {
+                 while (entries.hasMoreElements()) {
+-                    JarEntry entry = entries.nextElement();
++                    ZipEntry entry = entries.nextElement();
+                     if (!INDEX_NAME.equals(entry.getName())) {
+                         updateObserver(observer, currentEntry, size);
+                         currentEntry++;
+@@ -114,15 +110,15 @@ public class JarDiffPatcher implements JarDiffCodes
+                 // Apply move <oldName> <newName> command
+                 String oldName = renameMap.get(newName);
+-                // Get source JarEntry
+-                JarEntry oldEntry = oldJar.getJarEntry(oldName);
++                // Get source ZipEntry
++                ZipEntry oldEntry = oldJar.getEntry(oldName);
+                 if (oldEntry == null) {
+                     String moveCmd = MOVE_COMMAND + oldName + " " + newName;
+                     throw new IOException("error.badmove: " + moveCmd);
+                 }
+-                // Create dest JarEntry
+-                JarEntry newEntry = new JarEntry(newName);
++                // Create dest ZipEntry
++                ZipEntry newEntry = new ZipEntry(newName);
+                 newEntry.setTime(oldEntry.getTime());
+                 newEntry.setSize(oldEntry.getSize());
+                 newEntry.setCompressedSize(oldEntry.getCompressedSize());
+@@ -149,19 +145,15 @@ public class JarDiffPatcher implements JarDiffCodes
+             }
+             // implicit move
+-            Iterator<String> iEntries = oldjarNames.iterator();
+-            if (iEntries != null) {
+-                while (iEntries.hasNext()) {
+-                    String name = iEntries.next();
+-                    JarEntry entry = oldJar.getJarEntry(name);
+-                    if (entry == null) {
+-                        // names originally retrieved from the JAR, so this should never happen
+-                        throw new AssertionError("JAR entry not found: " + name);
+-                    }
+-                    updateObserver(observer, currentEntry, size);
+-                    currentEntry++;
+-                    writeEntry(jos, entry, oldJar);
++            for (String name : oldjarNames) {
++                ZipEntry entry = oldJar.getEntry(name);
++                if (entry == null) {
++                    // names originally retrieved from the archive, so this should never happen
++                    throw new AssertionError("Archive entry not found: " + name);
+                 }
++                updateObserver(observer, currentEntry, size);
++                currentEntry++;
++                writeEntry(jos, entry, oldJar);
+             }
+             updateObserver(observer, currentEntry, size);
+         }
+@@ -175,7 +167,7 @@ public class JarDiffPatcher implements JarDiffCodes
+     }
+     protected void determineNameMapping (
+-        JarFile jarDiff, Set<String> ignoreSet, Map<String, String> renameMap)
++        ZipFile jarDiff, Set<String> ignoreSet, Map<String, String> renameMap)
+         throws IOException
+     {
+         InputStream is = jarDiff.getInputStream(jarDiff.getEntry(INDEX_NAME));
+@@ -223,7 +215,7 @@ public class JarDiffPatcher implements JarDiffCodes
+     {
+         int index = 0;
+         int length = path.length();
+-        ArrayList<String> sub = new ArrayList<>();
++        List<String> sub = new ArrayList<>();
+         while (index < length) {
+             while (index < length && Character.isWhitespace
+@@ -231,9 +223,8 @@ public class JarDiffPatcher implements JarDiffCodes
+                 index++;
+             }
+             if (index < length) {
+-                int start = index;
+-                int last = start;
+-                String subString = null;
++                int last = index;
++                StringBuilder subString = null;
+                 while (index < length) {
+                     char aChar = path.charAt(index);
+@@ -241,9 +232,9 @@ public class JarDiffPatcher implements JarDiffCodes
+                         path.charAt(index + 1) == ' ') {
+                         if (subString == null) {
+-                            subString = path.substring(last, index);
++                            subString = new StringBuilder(path.substring(last, index));
+                         } else {
+-                            subString += path.substring(last, index);
++                            subString.append(path, last, index);
+                         }
+                         last = ++index;
+                     } else if (Character.isWhitespace(aChar)) {
+@@ -253,18 +244,20 @@ public class JarDiffPatcher implements JarDiffCodes
+                 }
+                 if (last != index) {
+                     if (subString == null) {
+-                        subString = path.substring(last, index);
++                        subString = new StringBuilder(path.substring(last, index));
+                     } else {
+-                        subString += path.substring(last, index);
++                        subString.append(path, last, index);
+                     }
+                 }
+-                sub.add(subString);
++                if (subString != null) {
++                    sub.add(subString.toString());
++                }
+             }
+         }
+         return sub;
+     }
+-    protected void writeEntry (JarOutputStream jos, JarEntry entry, JarFile file)
++    protected void writeEntry (ZipOutputStream jos, ZipEntry entry, ZipFile file)
+         throws IOException
+     {
+         try (InputStream data = file.getInputStream(entry)) {
+@@ -272,10 +265,10 @@ public class JarDiffPatcher implements JarDiffCodes
+         }
+     }
+-    protected void writeEntry (JarOutputStream jos, JarEntry entry, InputStream data)
++    protected void writeEntry (ZipOutputStream jos, ZipEntry entry, InputStream data)
+         throws IOException
+     {
+-        jos.putNextEntry(new JarEntry(entry.getName()));
++        jos.putNextEntry(new ZipEntry(entry.getName()));
+         // Read the entry
+         int size = data.read(newBytes);
+@@ -285,6 +278,15 @@ public class JarDiffPatcher implements JarDiffCodes
+         }
+     }
++    protected static ZipOutputStream makeOutputStream (File source, File target)
++        throws IOException
++    {
++        FileOutputStream out = new FileOutputStream(target);
++        if (source.getName().endsWith(".jar")) return new JarOutputStream(out);
++        else if (source.getName().endsWith(".zip")) return new ZipOutputStream(out);
++        else throw new AssertionError("Unsupported source file '" + source + "'. Not a .jar or .zip?");
++    }
++
+     protected static final int DEFAULT_READ_SIZE = 2048;
+     protected static byte[] newBytes = new byte[DEFAULT_READ_SIZE];
+diff --git a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/tools/Patcher.java b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/tools/Patcher.java
+index 4ead59bb3..5c8baaaab 100644
+--- a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/tools/Patcher.java
++++ b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/tools/Patcher.java
+@@ -9,16 +9,13 @@ import java.io.File;
+ import java.io.FileOutputStream;
+ import java.io.IOException;
+ import java.io.InputStream;
+-
+ import java.util.Enumeration;
+-import java.util.jar.JarEntry;
+-import java.util.jar.JarFile;
+ import java.util.zip.ZipEntry;
++import java.util.zip.ZipFile;
+ import com.threerings.getdown.util.FileUtil;
+ import com.threerings.getdown.util.ProgressObserver;
+ import com.threerings.getdown.util.StreamUtil;
+-
+ import static com.threerings.getdown.Log.log;
+ /**
+@@ -55,10 +52,10 @@ public class Patcher
+         _obs = obs;
+         _plength = patch.length();
+-        try (JarFile file = new JarFile(patch)) {
+-            Enumeration<JarEntry> entries = file.entries(); // old skool!
++        try (ZipFile file = new ZipFile(patch)) {
++            Enumeration<? extends ZipEntry> entries = file.entries();
+             while (entries.hasMoreElements()) {
+-                JarEntry entry = entries.nextElement();
++                ZipEntry entry = entries.nextElement();
+                 String path = entry.getName();
+                 long elength = entry.getCompressedSize();
+@@ -96,7 +93,7 @@ public class Patcher
+         return path.substring(0, path.length() - suffix.length());
+     }
+-    protected void createFile (JarFile file, ZipEntry entry, File target)
++    protected void createFile (ZipFile file, ZipEntry entry, File target)
+     {
+         // create our copy buffer if necessary
+         if (_buffer == null) {
+@@ -124,8 +121,7 @@ public class Patcher
+         }
+     }
+-    protected void patchFile (JarFile file, ZipEntry entry,
+-                              File appdir, String path)
++    protected void patchFile (ZipFile file, ZipEntry entry, File appdir, String path)
+     {
+         File target = new File(appdir, path);
+         File patch = new File(appdir, entry.getName());
+diff --git a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/Base64.java b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/Base64.java
+index 2a5db79ec..233f1e739 100644
+--- a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/Base64.java
++++ b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/Base64.java
+@@ -24,7 +24,7 @@ import static java.nio.charset.StandardCharsets.US_ASCII;
+  * href="http://www.ietf.org/rfc/rfc2045.txt">2045</a> and <a
+  * href="http://www.ietf.org/rfc/rfc3548.txt">3548</a>.
+  */
+-public class Base64 {
++public final class Base64 {
+     /**
+      * Default values for encoder/decoder flags.
+      */
+@@ -68,7 +68,7 @@ public class Base64 {
+     //  shared code
+     //  --------------------------------------------------------
+-    /* package */ static abstract class Coder {
++    /* package */ abstract static class Coder {
+         public byte[] output;
+         public int op;
+@@ -178,7 +178,7 @@ public class Base64 {
+          * Lookup table for turning bytes into their position in the
+          * Base64 alphabet.
+          */
+-        private static final int DECODE[] = {
++        private static final int[] DECODE = {
+             -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+             -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+             -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, -1, 63,
+@@ -201,7 +201,7 @@ public class Base64 {
+          * Decode lookup table for the "web safe" variant (RFC 3548
+          * sec. 4) where - and _ replace + and /.
+          */
+-        private static final int DECODE_WEBSAFE[] = {
++        private static final int[] DECODE_WEBSAFE = {
+             -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+             -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+             -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1,
+@@ -236,7 +236,7 @@ public class Base64 {
+         private int state;   // state number (0 to 6)
+         private int value;
+-        final private int[] alphabet;
++        private final int[] alphabet;
+         public Decoder(int flags, byte[] output) {
+             this.output = output;
+@@ -541,7 +541,7 @@ public class Base64 {
+          * Lookup table for turning Base64 alphabet positions (6 bits)
+          * into output bytes.
+          */
+-        private static final byte ENCODE[] = {
++        private static final byte[] ENCODE = {
+             'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P',
+             'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f',
+             'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v',
+@@ -552,21 +552,21 @@ public class Base64 {
+          * Lookup table for turning Base64 alphabet positions (6 bits)
+          * into output bytes.
+          */
+-        private static final byte ENCODE_WEBSAFE[] = {
++        private static final byte[] ENCODE_WEBSAFE = {
+             'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P',
+             'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f',
+             'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v',
+             'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '-', '_',
+         };
+-        final private byte[] tail;
++        private final byte[] tail;
+         /* package */ int tailLen;
+         private int count;
+-        final public boolean do_padding;
+-        final public boolean do_newline;
+-        final public boolean do_cr;
+-        final private byte[] alphabet;
++        public final boolean do_padding;
++        public final boolean do_newline;
++        public final boolean do_cr;
++        private final byte[] alphabet;
+         public Encoder(int flags, byte[] output) {
+             this.output = output;
+@@ -618,7 +618,7 @@ public class Base64 {
+                             ((input[p++] & 0xff) << 8) |
+                             (input[p++] & 0xff);
+                         tailLen = 0;
+-                    };
++                    }
+                     break;
+                 case 2:
+diff --git a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/Color.java b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/Color.java
+index 047cead76..416f77e58 100644
+--- a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/Color.java
++++ b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/Color.java
+@@ -8,11 +8,11 @@ package com.threerings.getdown.util;
+ /**
+  * Utilities for handling ARGB colors.
+  */
+-public class Color
++public final class Color
+ {
+-    public final static int CLEAR = 0x00000000;
+-    public final static int WHITE = 0xFFFFFFFF;
+-    public final static int BLACK = 0xFF000000;
++    public static final int CLEAR = 0x00000000;
++    public static final int WHITE = 0xFFFFFFFF;
++    public static final int BLACK = 0xFF000000;
+     public static float brightness (int argb) {
+         // TODO: we're ignoring alpha here...
+diff --git a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/Config.java b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/Config.java
+index 6ad2b4fd9..75357e882 100644
+--- a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/Config.java
++++ b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/Config.java
+@@ -14,7 +14,6 @@ import java.net.MalformedURLException;
+ import java.net.URL;
+ import java.nio.charset.StandardCharsets;
+ import java.util.ArrayList;
+-import java.util.Arrays;
+ import java.util.HashMap;
+ import java.util.List;
+ import java.util.Locale;
+@@ -58,33 +57,17 @@ public class Config
+     }
+     /**
+-     * Parses a configuration file containing key/value pairs. The file must be in the UTF-8
+-     * encoding.
+-     *
++     * Parses configuration text containing key/value pairs.
+      * @param opts options that influence the parsing. See {@link #createOpts}.
+-     *
+-     * @return a list of <code>String[]</code> instances containing the key/value pairs in the
++     * @return a list of {@code String[]} instances containing the key/value pairs in the
+      * order they were parsed from the file.
+      */
+-    public static List<String[]> parsePairs (File source, ParseOpts opts)
+-        throws IOException
+-    {
+-        // annoyingly FileReader does not allow encoding to be specified (uses platform default)
+-        try (FileInputStream fis = new FileInputStream(source);
+-             InputStreamReader input = new InputStreamReader(fis, StandardCharsets.UTF_8)) {
+-            return parsePairs(input, opts);
+-        }
+-    }
+-
+-    /**
+-     * See {@link #parsePairs(File,ParseOpts)}.
+-     */
+     public static List<String[]> parsePairs (Reader source, ParseOpts opts) throws IOException
+     {
+         List<String[]> pairs = new ArrayList<>();
+         for (String line : FileUtil.readLines(source)) {
+             // nix comments
+-            int cidx = line.indexOf("#");
++            int cidx = line.indexOf('#');
+             if (opts.strictComments ? cidx == 0 : cidx != -1) {
+                 line = line.substring(0, cidx);
+             }
+@@ -98,7 +81,7 @@ public class Config
+             // parse our key/value pair
+             String[] pair = new String[2];
+             // if we're biasing toward key, put all the extra = in the key rather than the value
+-            int eidx = opts.biasToKey ? line.lastIndexOf("=") : line.indexOf("=");
++            int eidx = opts.biasToKey ? line.lastIndexOf('=') : line.indexOf('=');
+             if (eidx != -1) {
+                 pair[0] = line.substring(0, eidx).trim();
+                 pair[1] = line.substring(eidx+1).trim();
+@@ -109,7 +92,7 @@ public class Config
+             // if the pair has an os qualifier, we need to process it
+             if (pair[1].startsWith("[")) {
+-                int qidx = pair[1].indexOf("]");
++                int qidx = pair[1].indexOf(']');
+                 if (qidx == -1) {
+                     log.warning("Bogus platform specifier", "key", pair[0], "value", pair[1]);
+                     continue; // omit the pair entirely
+@@ -132,6 +115,21 @@ public class Config
+         return pairs;
+     }
++    /**
++     * Parses configuration file containing key/value pairs.
++     * @param source the file containing the config text. Must be in the UTF-8 encoding.
++     * @param opts options that influence the parsing. See {@link #createOpts}.
++     */
++    public static List<String[]> parsePairs (File source, ParseOpts opts)
++        throws IOException
++    {
++        // annoyingly FileReader does not allow encoding to be specified (uses platform default)
++        try (FileInputStream fis = new FileInputStream(source);
++             InputStreamReader input = new InputStreamReader(fis, StandardCharsets.UTF_8)) {
++            return parsePairs(input, opts);
++        }
++    }
++
+     /**
+      * Takes a comma-separated String of four integers and returns a rectangle using those ints as
+      * the its x, y, width, and height.
+@@ -167,13 +165,10 @@ public class Config
+     }
+     /**
+-     * Parses a configuration file containing key/value pairs. The file must be in the UTF-8
+-     * encoding.
+-     *
+-     * @return a map from keys to values, where a value will be an array of strings if more than
+-     * one key/value pair in the config file was associated with the same key.
++     * Parses the data for a config instance from the supplied {@code source} reader.
++     * @return a map that can be used to create a {@link #Config}.
+      */
+-    public static Config parseConfig (File source, ParseOpts opts)
++    public static Map<String, Object> parseData (Reader source, ParseOpts opts)
+         throws IOException
+     {
+         Map<String, Object> data = new HashMap<>();
+@@ -196,15 +191,34 @@ public class Config
+             }
+         }
+-        // special magic for the getdown.txt config: if the parsed data contains 'strict_comments =
+-        // true' then we reparse the file with strict comments (i.e. # is only assumed to start a
+-        // comment in column 0)
+-        if (!opts.strictComments && Boolean.parseBoolean((String)data.get("strict_comments"))) {
+-            opts.strictComments = true;
+-            return parseConfig(source, opts);
+-        }
++        return data;
++    }
++
++    /**
++     * Parses a configuration file containing key/value pairs. The file must be in the UTF-8
++     * encoding.
++     *
++     * @return a map from keys to values, where a value will be an array of strings if more than
++     * one key/value pair in the config file was associated with the same key.
++     */
++    public static Config parseConfig (File source, ParseOpts opts)
++        throws IOException
++    {
++        // annoyingly FileReader does not allow encoding to be specified (uses platform default)
++        try (FileInputStream fis = new FileInputStream(source);
++             InputStreamReader input = new InputStreamReader(fis, StandardCharsets.UTF_8)) {
++            Map<String, Object> data = parseData(input, opts);
++
++            // special magic for the getdown.txt config: if the parsed data contains
++            // 'strict_comments = true' then we reparse the file with strict comments (i.e. # is
++            // only assumed to start a comment in column 0)
++            if (!opts.strictComments && Boolean.parseBoolean((String)data.get("strict_comments"))) {
++                opts.strictComments = true;
++                return parseConfig(source, opts);
++            }
+-        return new Config(data);
++            return new Config(data);
++        }
+     }
+     public Config (Map<String,  Object> data) {
+@@ -248,6 +262,20 @@ public class Config
+         return Boolean.parseBoolean(getString(name));
+     }
++    /**
++     * Returns the specified config value as an enum value. The string value of the config is
++     * converted to all upper case and then turned into an enum via {@link Enum#valueOf}.
++     */
++    public <T extends Enum<T>> T getEnum (String name, Class<T> eclass, T defval) {
++        String value = getString(name, defval.toString());
++        try {
++            return Enum.valueOf(eclass, value.toUpperCase());
++        } catch (Exception e) {
++            log.warning("Invalid value for '" + name + "' config: '" + value + "'.");
++            return defval;
++        }
++    }
++
+     /**
+      * Massages a single string into an array and leaves existing array values as is. Simplifies
+      * access to parameters that are expected to be arrays.
+@@ -255,9 +283,6 @@ public class Config
+     public String[] getMultiValue (String name)
+     {
+         Object value = _data.get(name);
+-        if (value == null) {
+-          return new String[] {};
+-        }
+         if (value instanceof String) {
+             return new String[] { (String)value };
+         } else {
+@@ -355,7 +380,7 @@ public class Config
+     protected static boolean checkQualifiers (String quals, String osname, String osarch)
+     {
+         if (quals.startsWith("!")) {
+-            if (quals.indexOf(",") != -1) { // sanity check
++            if (quals.contains(",")) { // sanity check
+                 log.warning("Multiple qualifiers cannot be used when one of the qualifiers " +
+                             "is negative", "quals", quals);
+                 return false;
+@@ -375,103 +400,8 @@ public class Config
+     {
+         String[] bits = qual.trim().toLowerCase(Locale.ROOT).split("-");
+         String os = bits[0], arch = (bits.length > 1) ? bits[1] : "";
+-        return (osname.indexOf(os) != -1) && (osarch.indexOf(arch) != -1);
+-    }
+-    
+-    public void mergeConfig(Config newValues, boolean merge) {
+-      
+-      for (Map.Entry<String, Object> entry : newValues.getData().entrySet()) {
+-        
+-        String key = entry.getKey();
+-        Object nvalue = entry.getValue();
+-
+-        String mkey = key.indexOf('.') > -1 ? key.substring(key.indexOf('.') + 1) : key;
+-        if (merge && allowedMergeKeys.contains(mkey)) {
+-          
+-          // merge multi values
+-          
+-          Object value = _data.get(key);
+-          
+-          if (value == null) {
+-            _data.put(key, nvalue);
+-          } else if (value instanceof String) {
+-            if (nvalue instanceof String) {
+-              
+-              // value is String, nvalue is String
+-              _data.put(key, new String[] { (String)value, (String)nvalue });
+-              
+-            } else if (nvalue instanceof String[]) {
+-              
+-              // value is String, nvalue is String[]
+-              String[] nvalues = (String[])nvalue;
+-              String[] newvalues = new String[nvalues.length+1];
+-              newvalues[0] = (String)value;
+-              System.arraycopy(nvalues, 0, newvalues, 1, nvalues.length);
+-              _data.put(key, newvalues);
+-              
+-            }
+-          } else if (value instanceof String[]) {
+-            if (nvalue instanceof String) {
+-              
+-              // value is String[], nvalue is String
+-              String[] values = (String[])value;
+-              String[] newvalues = new String[values.length+1];
+-              System.arraycopy(values, 0, newvalues, 0, values.length);
+-              newvalues[values.length] = (String)nvalue;
+-              _data.put(key, newvalues);
+-              
+-            } else if (nvalue instanceof String[]) {
+-              
+-              // value is String[], nvalue is String[]
+-              String[] values = (String[])value;
+-              String[] nvalues = (String[])nvalue;
+-              String[] newvalues = new String[values.length + nvalues.length];
+-              System.arraycopy(values, 0, newvalues, 0, values.length);
+-              System.arraycopy(nvalues, 0, newvalues, values.length, newvalues.length);
+-              _data.put(key, newvalues);
+-              
+-            }
+-          }
+-          
+-        } else if (allowedReplaceKeys.contains(mkey)){
+-          
+-          // replace value
+-          _data.put(key, nvalue);
+-          
+-        } else {
+-          log.warning("Not merging key '"+key+"' into config");
+-        }
+-
+-      }
+-      
+-    }
+-    
+-    public String toString() {
+-      StringBuilder sb = new StringBuilder();
+-      for (Map.Entry<String, Object> entry : getData().entrySet()) {
+-        String key = entry.getKey();
+-        Object val = entry.getValue();
+-        sb.append(key);
+-        sb.append("=");
+-        if (val instanceof String) {
+-          sb.append((String)val);
+-        } else if (val instanceof String[]) {
+-          sb.append(Arrays.toString((String[])val));
+-        } else {
+-          sb.append("Value not String or String[]");
+-        }
+-        sb.append("\n");
+-      }
+-      return sb.toString();
+-    }
+-    
+-    public Map<String, Object> getData() {
+-      return _data;
++        return (osname.contains(os)) && (osarch.contains(arch));
+     }
+     private final Map<String, Object> _data;
+- 
+-    public static final List<String> allowedReplaceKeys = Arrays.asList("appbase","apparg","jvmarg"); // these are the ones we might use
+-    public static final List<String> allowedMergeKeys = Arrays.asList("apparg","jvmarg"); // these are the ones we might use
+-    //private final List<String> allowedMergeKeys = Arrays.asList("apparg","jvmarg","resource","code","java_location"); // (not exhaustive list here)
+ }
+diff --git a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/ConnectionUtil.java b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/ConnectionUtil.java
+deleted file mode 100644
+index 21b056932..000000000
+--- a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/ConnectionUtil.java
++++ /dev/null
+@@ -1,73 +0,0 @@
+-//
+-// Getdown - application installer, patcher and launcher
+-// Copyright (C) 2004-2018 Getdown authors
+-// https://github.com/threerings/getdown/blob/master/LICENSE
+-
+-package com.threerings.getdown.util;
+-
+-import java.io.IOException;
+-import java.net.HttpURLConnection;
+-import java.net.Proxy;
+-import java.net.URL;
+-import java.net.URLConnection;
+-import java.net.URLDecoder;
+-
+-import com.threerings.getdown.data.SysProps;
+-
+-import static java.nio.charset.StandardCharsets.UTF_8;
+-
+-public class ConnectionUtil
+-{
+-    /**
+-     * Opens a connection to a URL, setting the authentication header if user info is present.
+-     * @param proxy the proxy via which to perform HTTP connections.
+-     * @param url the URL to which to open a connection.
+-     * @param connectTimeout if {@code > 0} then a timeout, in seconds, to use when opening the
+-     * connection. If {@code 0} is supplied, the connection timeout specified via system properties
+-     * will be used instead.
+-     * @param readTimeout if {@code > 0} then a timeout, in seconds, to use while reading data from
+-     * the connection. If {@code 0} is supplied, the read timeout specified via system properties
+-     * will be used instead.
+-     */
+-    public static URLConnection open (Proxy proxy, URL url, int connectTimeout, int readTimeout)
+-        throws IOException
+-    {
+-        URLConnection conn = url.openConnection(proxy);
+-
+-        // configure a connect timeout, if requested
+-        int ctimeout = connectTimeout > 0 ? connectTimeout : SysProps.connectTimeout();
+-        if (ctimeout > 0) {
+-            conn.setConnectTimeout(ctimeout * 1000);
+-        }
+-
+-        // configure a read timeout, if requested
+-        int rtimeout = readTimeout > 0 ? readTimeout : SysProps.readTimeout();
+-        if (rtimeout > 0) {
+-            conn.setReadTimeout(rtimeout * 1000);
+-        }
+-
+-        // If URL has a username:password@ before hostname, use HTTP basic auth
+-        String userInfo = url.getUserInfo();
+-        if (userInfo != null) {
+-            // Remove any percent-encoding in the username/password
+-            userInfo = URLDecoder.decode(userInfo, "UTF-8");
+-            // Now base64 encode the auth info and make it a single line
+-            String encoded = Base64.encodeToString(userInfo.getBytes(UTF_8), Base64.DEFAULT).
+-                replaceAll("\\n","").replaceAll("\\r", "");
+-            conn.setRequestProperty("Authorization", "Basic " + encoded);
+-        }
+-
+-        return conn;
+-    }
+-
+-    /**
+-     * Opens a connection to a http or https URL, setting the authentication header if user info is
+-     * present. Throws a class cast exception if the connection returned is not the right type. See
+-     * {@link #open} for parameter documentation.
+-     */
+-    public static HttpURLConnection openHttp (
+-        Proxy proxy, URL url, int connectTimeout, int readTimeout) throws IOException
+-    {
+-        return (HttpURLConnection)open(proxy, url, connectTimeout, readTimeout);
+-    }
+-}
+diff --git a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/FileUtil.java b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/FileUtil.java
+index 67d033086..d7de78bbd 100644
+--- a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/FileUtil.java
++++ b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/FileUtil.java
+@@ -6,25 +6,17 @@
+ package com.threerings.getdown.util;
+ import java.io.*;
+-import java.nio.file.Files;
+-import java.nio.file.Paths;
+ import java.util.*;
+ import java.util.jar.*;
+-import java.util.zip.GZIPInputStream;
++import java.util.zip.*;
+-import org.apache.commons.compress.archivers.ArchiveEntry;
+-import org.apache.commons.compress.archivers.ArchiveInputStream;
+-import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
+-import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
+-
+-import com.threerings.getdown.util.StreamUtil;
+ import com.threerings.getdown.Log;
+ import static com.threerings.getdown.Log.log;
+ /**
+  * File related utilities.
+  */
+-public class FileUtil
++public final class FileUtil
+ {
+     /**
+      * Gets the specified source file to the specified destination file by hook or crook. Windows
+@@ -122,13 +114,13 @@ public class FileUtil
+      * @param cleanExistingDirs if true, all files in all directories contained in {@code jar} will
+      * be deleted prior to unpacking the jar.
+      */
+-    public static void unpackJar (JarFile jar, File target, boolean cleanExistingDirs)
++    public static void unpackJar (ZipFile jar, File target, boolean cleanExistingDirs)
+         throws IOException
+     {
+         if (cleanExistingDirs) {
+-            Enumeration<?> entries = jar.entries();
++            Enumeration<? extends ZipEntry> entries = jar.entries();
+             while (entries.hasMoreElements()) {
+-                JarEntry entry = (JarEntry)entries.nextElement();
++                ZipEntry entry = entries.nextElement();
+                 if (entry.isDirectory()) {
+                     File efile = new File(target, entry.getName());
+                     if (efile.exists()) {
+@@ -141,9 +133,9 @@ public class FileUtil
+             }
+         }
+-        Enumeration<?> entries = jar.entries();
++        Enumeration<? extends ZipEntry> entries = jar.entries();
+         while (entries.hasMoreElements()) {
+-            JarEntry entry = (JarEntry)entries.nextElement();
++            ZipEntry entry = entries.nextElement();
+             File efile = new File(target, entry.getName());
+             // if we're unpacking a normal jar file, it will have special path
+@@ -164,7 +156,7 @@ public class FileUtil
+             }
+             try (BufferedOutputStream fout = new BufferedOutputStream(new FileOutputStream(efile));
+-                InputStream jin = jar.getInputStream(entry)) {
++                 InputStream jin = jar.getInputStream(entry)) {
+                 StreamUtil.copy(jin, fout);
+             } catch (Exception e) {
+                 throw new IOException(
+@@ -174,82 +166,13 @@ public class FileUtil
+     }
+     /**
+-     * Unpacks the specified tgz file into the specified target directory.
+-     * @param cleanExistingDirs if true, all files in all directories contained in {@code tgz} will
+-     * be deleted prior to unpacking the tgz.
+-     */
+-    public static void unpackTgz (TarArchiveInputStream tgz, File target, boolean cleanExistingDirs)
+-        throws IOException
+-    {
+-              TarArchiveEntry entry;
+-              while ((entry = tgz.getNextTarEntry()) != null) {
+-            // sanitize the entry name
+-                      String entryName = entry.getName();
+-                      if (entryName.startsWith(File.separator))
+-                      {
+-                              entryName = entryName.substring(File.separator.length());
+-                      }
+-            File efile = new File(target, entryName);
+-
+-            // if we're unpacking a normal tgz file, it will have special path
+-            // entries that allow us to create our directories first
+-            if (entry.isDirectory()) {
+-              
+-                              if (cleanExistingDirs) {
+-                    if (efile.exists()) {
+-                        for (File f : efile.listFiles()) {
+-                            if (!f.isDirectory())
+-                            f.delete();
+-                        }
+-                    }
+-                              }
+-                              
+-                if (!efile.exists() && !efile.mkdir()) {
+-                    log.warning("Failed to create tgz entry path", "tgz", tgz, "entry", entry);
+-                }
+-                continue;
+-            }
+-
+-            // but some do not, so we want to ensure that our directories exist
+-            // prior to getting down and funky
+-            File parent = new File(efile.getParent());
+-            if (!parent.exists() && !parent.mkdirs()) {
+-                log.warning("Failed to create tgz entry parent", "tgz", tgz, "parent", parent);
+-                continue;
+-            }
+-
+-            if (entry.isLink())
+-            {
+-              System.out.println("Creating hard link "+efile.getName()+" -> "+entry.getLinkName());
+-              Files.createLink(efile.toPath(), Paths.get(entry.getLinkName()));
+-              continue;
+-            }
+-
+-            if (entry.isSymbolicLink())
+-            {
+-              System.out.println("Creating symbolic link "+efile.getName()+" -> "+entry.getLinkName());
+-              Files.createSymbolicLink(efile.toPath(), Paths.get(entry.getLinkName()));
+-              continue;
+-            }
+-            
+-            try (BufferedOutputStream fout = new BufferedOutputStream(new FileOutputStream(efile));
+-                InputStream tin = tgz;) {
+-                StreamUtil.copy(tin, fout);
+-            } catch (Exception e) {
+-                throw new IOException(
+-                    Log.format("Failure unpacking", "tgz", tgz, "entry", efile), e);
+-            }
+-        }
+-    }
+-
+-    /**
+-     * Unpacks a pack200 packed jar file from {@code packedJar} into {@code target}. If {@code
+-     * packedJar} has a {@code .gz} extension, it will be gunzipped first.
++     * Unpacks a pack200 packed jar file from {@code packedJar} into {@code target}.
++     * If {@code packedJar} has a {@code .gz} extension, it will be gunzipped first.
+      */
+     public static void unpackPacked200Jar (File packedJar, File target) throws IOException
+     {
+         try (InputStream packJarIn = new FileInputStream(packedJar);
+-             JarOutputStream jarOut = new JarOutputStream(new FileOutputStream(target))) {
++            JarOutputStream jarOut = new JarOutputStream(new FileOutputStream(target))) {
+             boolean gz = (packedJar.getName().endsWith(".gz") ||
+                           packedJar.getName().endsWith(".gz_new"));
+             try (InputStream packJarIn2 = (gz ? new GZIPInputStream(packJarIn) : packJarIn)) {
+diff --git a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/HostWhitelist.java b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/HostWhitelist.java
+index f2f7ef39f..c05992abd 100644
+--- a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/HostWhitelist.java
++++ b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/HostWhitelist.java
+@@ -25,8 +25,6 @@ public final class HostWhitelist
+      */
+     public static URL verify (URL url) throws MalformedURLException
+     {
+-        
+-      
+         return verify(Build.hostWhitelist(), url);
+     }
+@@ -43,12 +41,6 @@ public final class HostWhitelist
+         }
+         String urlHost = url.getHost();
+-        String protocol = url.getProtocol();
+-        
+-        if (ALLOW_LOCATOR_FILE_PROTOCOL && protocol.equals("file") && urlHost.equals("")) {
+-          return url;
+-        }
+-        
+         for (String host : hosts) {
+             String regex = host.replace(".", "\\.").replace("*", ".*");
+             if (urlHost.matches(regex)) {
+@@ -59,5 +51,4 @@ public final class HostWhitelist
+         throw new MalformedURLException(
+             "The host for the specified URL (" + url + ") is not in the host whitelist: " + hosts);
+     }
+-    private static boolean ALLOW_LOCATOR_FILE_PROTOCOL = true;
+ }
+diff --git a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/LaunchUtil.java b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/LaunchUtil.java
+index f2cd5736e..cc51794ef 100644
+--- a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/LaunchUtil.java
++++ b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/LaunchUtil.java
+@@ -17,21 +17,23 @@ import static com.threerings.getdown.Log.log;
+  * Useful routines for launching Java applications from within other Java
+  * applications.
+  */
+-public class LaunchUtil
++public final class LaunchUtil
+ {
+-    /** The directory into which a local VM installation should be unpacked. */
+-    public static final String LOCAL_JAVA_DIR = "jre";
++    /** The default directory into which a local VM installation should be unpacked. */
++    public static final String LOCAL_JAVA_DIR = "java_vm";
+     /**
+-     * Writes a <code>version.txt</code> file into the specified application directory and
++     * Writes a {@code version.txt} file into the specified application directory and
+      * attempts to relaunch Getdown in that directory which will cause it to upgrade to the newly
+      * specified version and relaunch the application.
+      *
+      * @param appdir the directory in which the application is installed.
+      * @param getdownJarName the name of the getdown jar file in the application directory. This is
+-     * probably <code>getdown-pro.jar</code> or <code>getdown-retro-pro.jar</code> if you are using
++     * probably {@code getdown-pro.jar} or {@code getdown-retro-pro.jar} if you are using
+      * the results of the standard build.
+      * @param newVersion the new version to which Getdown will update when it is executed.
++     * @param javaLocalDir the name of the directory (inside {@code appdir}) that contains a
++     * locally installed JRE. Defaults to {@link #LOCAL_JAVA_DIR} if null is passed.
+      *
+      * @return true if the relaunch succeeded, false if we were unable to relaunch due to being on
+      * Windows 9x where we cannot launch subprocesses without waiting around for them to exit,
+@@ -39,13 +41,13 @@ public class LaunchUtil
+      * after making this call as it will be upgraded and restarted. If false is returned, the
+      * application should tell the user that they must restart the application manually.
+      *
+-     * @exception IOException thrown if we were unable to create the <code>version.txt</code> file
++     * @exception IOException thrown if we were unable to create the {@code version.txt} file
+      * in the supplied application directory. If the version.txt file cannot be created, restarting
+      * Getdown will not cause the application to be upgraded, so the application will have to
+      * resort to telling the user that it is in a bad way.
+      */
+     public static boolean updateVersionAndRelaunch (
+-            File appdir, String getdownJarName, String newVersion)
++            File appdir, String getdownJarName, String newVersion, String javaLocalDir)
+         throws IOException
+     {
+         // create the file that instructs Getdown to upgrade
+@@ -61,9 +63,9 @@ public class LaunchUtil
+         }
+         // do the deed
+-        String[] args = new String[] {
+-            getJVMPath(appdir), "-jar", pro.toString(), appdir.getPath()
+-        };
++        String javaDir = StringUtil.isBlank(javaLocalDir) ? LOCAL_JAVA_DIR : javaLocalDir;
++        String javaBin = getJVMBinaryPath(new File(appdir, javaDir), false);
++        String[] args = { javaBin, "-jar", pro.toString(), appdir.getPath() };
+         log.info("Running " + StringUtil.join(args, "\n  "));
+         try {
+             Runtime.getRuntime().exec(args, null);
+@@ -75,25 +77,15 @@ public class LaunchUtil
+     }
+     /**
+-     * Reconstructs the path to the JVM used to launch this process.
+-     */
+-    public static String getJVMPath (File appdir)
+-    {
+-        return getJVMPath(appdir, false);
+-    }
+-
+-    /**
+-     * Reconstructs the path to the JVM used to launch this process.
+-     *
++     * Resolves a path to a JVM binary.
++     * @param javaLocalDir JRE location within appdir.
+      * @param windebug if true we will use java.exe instead of javaw.exe on Windows.
++     * @return the path to the JVM binary used to launch this process.
+      */
+-    public static String getJVMPath (File appdir, boolean windebug)
++    public static String getJVMBinaryPath (File javaLocalDir, boolean windebug)
+     {
+         // first look in our application directory for an installed VM
+-        String vmpath = checkJVMPath(new File(appdir, LOCAL_JAVA_DIR).getAbsolutePath(), windebug);
+-        if (vmpath == null && isMacOS()) {
+-                      vmpath = checkJVMPath(new File(appdir, LOCAL_JAVA_DIR + "/Contents/Home").getAbsolutePath(), windebug);
+-        }
++        String vmpath = checkJVMPath(javaLocalDir.getAbsolutePath(), windebug);
+         // then fall back to the VM in which we're already running
+         if (vmpath == null) {
+@@ -102,7 +94,7 @@ public class LaunchUtil
+         // then throw up our hands and hope for the best
+         if (vmpath == null) {
+-            log.warning("Unable to find java [appdir=" + appdir +
++            log.warning("Unable to find java [local=" + javaLocalDir +
+                         ", java.home=" + System.getProperty("java.home") + "]!");
+             vmpath = "java";
+         }
+@@ -153,7 +145,7 @@ public class LaunchUtil
+             if (newgd.renameTo(curgd)) {
+                 FileUtil.deleteHarder(oldgd); // yay!
+                 try {
+-                    // copy the moved file back to getdown-dop-new.jar so that we don't end up
++                    // copy the moved file back to newgd so that we don't end up
+                     // downloading another copy next time
+                     FileUtil.copy(curgd, newgd);
+                 } catch (IOException e) {
+@@ -185,23 +177,23 @@ public class LaunchUtil
+     public static boolean mustMonitorChildren ()
+     {
+         String osname = System.getProperty("os.name", "").toLowerCase(Locale.ROOT);
+-        return (osname.indexOf("windows 98") != -1 || osname.indexOf("windows me") != -1);
++        return (osname.contains("windows 98") || osname.contains("windows me"));
+     }
+     /**
+      * Returns true if we're running in a JVM that identifies its operating system as Windows.
+      */
+-    public static final boolean isWindows () { return _isWindows; }
++    public static boolean isWindows () { return _isWindows; }
+     /**
+      * Returns true if we're running in a JVM that identifies its operating system as MacOS.
+      */
+-    public static final boolean isMacOS () { return _isMacOS; }
++    public static boolean isMacOS () { return _isMacOS; }
+     /**
+      * Returns true if we're running in a JVM that identifies its operating system as Linux.
+      */
+-    public static final boolean isLinux () { return _isLinux; }
++    public static boolean isLinux () { return _isLinux; }
+     /**
+      * Checks whether a Java Virtual Machine can be located in the supplied path.
+@@ -240,10 +232,9 @@ public class LaunchUtil
+         try {
+             String osname = System.getProperty("os.name");
+             osname = (osname == null) ? "" : osname;
+-            _isWindows = (osname.indexOf("Windows") != -1);
+-            _isMacOS = (osname.indexOf("Mac OS") != -1 ||
+-                        osname.indexOf("MacOS") != -1);
+-            _isLinux = (osname.indexOf("Linux") != -1);
++            _isWindows = (osname.contains("Windows"));
++            _isMacOS = (osname.contains("Mac OS") || osname.contains("MacOS"));
++            _isLinux = (osname.contains("Linux"));
+         } catch (Exception e) {
+             // can't grab system properties; we'll just pretend we're not on any of these OSes
+         }
+diff --git a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/MessageUtil.java b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/MessageUtil.java
+index 28dbdcff5..f2ee9a265 100644
+--- a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/MessageUtil.java
++++ b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/MessageUtil.java
+@@ -5,7 +5,7 @@
+ package com.threerings.getdown.util;
+-public class MessageUtil {
++public final class MessageUtil {
+     /**
+      * Returns whether or not the provided string is tainted. See {@link #taint}. Null strings
+@@ -101,11 +101,11 @@ public class MessageUtil {
+     }
+     /**
+-     * Used to escape single quotes so that they are not interpreted by <code>MessageFormat</code>.
++     * Used to escape single quotes so that they are not interpreted by {@code MessageFormat}.
+      * As we assume all single quotes are to be escaped, we cannot use the characters
+      * <code>{</code> and <code>}</code> in our translation strings, but this is a small price to
+      * pay to have to differentiate between messages that will and won't eventually be parsed by a
+-     * <code>MessageFormat</code> instance.
++     * {@code MessageFormat} instance.
+      */
+     public static String escape (String message)
+     {
+diff --git a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/ProgressObserver.java b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/ProgressObserver.java
+index ad4c560a4..ac3e0a3de 100644
+--- a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/ProgressObserver.java
++++ b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/ProgressObserver.java
+@@ -14,5 +14,5 @@ public interface ProgressObserver
+      * Informs the observer that we have completed the specified
+      * percentage of the process.
+      */
+-    public void progress (int percent);
++    void progress (int percent);
+ }
+diff --git a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/StreamUtil.java b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/StreamUtil.java
+index 373cfff00..5cb1a405b 100644
+--- a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/StreamUtil.java
++++ b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/StreamUtil.java
+@@ -11,11 +11,10 @@ import java.io.InputStream;
+ import java.io.OutputStream;
+ import java.io.Reader;
+ import java.io.Writer;
+-import java.nio.charset.Charset;
+ import static com.threerings.getdown.Log.log;
+-public class StreamUtil {
++public final class StreamUtil {
+     /**
+      * Convenient close for a stream. Use in a finally clause and love life.
+      */
+diff --git a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/StringUtil.java b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/StringUtil.java
+index 03d3c9ccd..86277c881 100644
+--- a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/StringUtil.java
++++ b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/StringUtil.java
+@@ -7,14 +7,14 @@ package com.threerings.getdown.util;
+ import java.util.StringTokenizer;
+-public class StringUtil {
++public final class StringUtil {
+     /**
+      * @return true if the specified string could be a valid URL (contains no illegal characters)
+      */
+     public static boolean couldBeValidUrl (String url)
+     {
+-        return url.matches("[A-Za-z0-9\\-\\._~:/\\?#\\[\\]@!$&'\\(\\)\\*\\+,;=%]+");
++        return url.matches("[A-Za-z0-9\\-._~:/?#\\[\\]@!$&'()*+,;=%]+");
+     }
+     /**
+@@ -84,7 +84,7 @@ public class StringUtil {
+         source = source.replace(",,", "%COMMA%");
+         // count up the number of tokens
+-        while ((tpos = source.indexOf(",", tpos+1)) != -1) {
++        while ((tpos = source.indexOf(',', tpos+1)) != -1) {
+             tcount++;
+         }
+@@ -92,7 +92,7 @@ public class StringUtil {
+         tpos = -1; tcount = 0;
+         // do the split
+-        while ((tpos = source.indexOf(",", tpos+1)) != -1) {
++        while ((tpos = source.indexOf(',', tpos+1)) != -1) {
+             tokens[tcount] = source.substring(tstart, tpos);
+             tokens[tcount] = tokens[tcount].trim().replace("%COMMA%", ",");
+             if (intern) {
+@@ -119,7 +119,7 @@ public class StringUtil {
+     /**
+      * Generates a string from the supplied bytes that is the HEX encoded representation of those
+-     * bytes.  Returns the empty string for a <code>null</code> or empty byte array.
++     * bytes.  Returns the empty string for a {@code null} or empty byte array.
+      *
+      * @param bytes the bytes for which we want a string representation.
+      * @param count the number of bytes to stop at (which will be coerced into being {@code <=} the
+@@ -185,7 +185,7 @@ public class StringUtil {
+     }
+     /**
+-     * Helper function for the various <code>join</code> methods.
++     * Helper function for the various {@code join} methods.
+      */
+     protected static String join (Object[] values, String separator, boolean escape)
+     {
+@@ -201,6 +201,6 @@ public class StringUtil {
+         return buf.toString();
+     }
+-    /** Used by {@link #hexlate} and {@link #unhexlate}. */
++    /** Used by {@link #hexlate}. */
+     protected static final String XLATE = "0123456789abcdef";
+ }
+diff --git a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/VersionUtil.java b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/VersionUtil.java
+index 49e4e6e0e..b2f289415 100644
+--- a/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/VersionUtil.java
++++ b/getdown/src/getdown/core/src/main/java/com/threerings/getdown/util/VersionUtil.java
+@@ -22,7 +22,7 @@ import static java.nio.charset.StandardCharsets.UTF_8;
+ /**
+  * Version related utilities.
+  */
+-public class VersionUtil
++public final class VersionUtil
+ {
+     /**
+      * Reads a version number from a file.
+diff --git a/getdown/src/getdown/core/src/main/java/jalview/bin/MemorySetting.java b/getdown/src/getdown/core/src/main/java/jalview/bin/MemorySetting.java
+deleted file mode 100644
+index 8af09da6d..000000000
+--- a/getdown/src/getdown/core/src/main/java/jalview/bin/MemorySetting.java
++++ /dev/null
+@@ -1,51 +0,0 @@
+-package jalview.bin;
+-
+-import java.lang.management.ManagementFactory;
+-import java.lang.management.OperatingSystemMXBean;
+-
+-public class MemorySetting
+-{
+-  public static final long leaveFreeMinMemory = 536870912; // 0.5 GB
+-
+-  public static final long applicationMinMemory = 536870912; // 0.5 GB
+-
+-  protected static long getPhysicalMemory()
+-  {
+-    final OperatingSystemMXBean o = ManagementFactory
+-            .getOperatingSystemMXBean();
+-
+-    try
+-    {
+-      if (o instanceof com.sun.management.OperatingSystemMXBean)
+-      {
+-        final com.sun.management.OperatingSystemMXBean osb = (com.sun.management.OperatingSystemMXBean) o;
+-        return osb.getTotalPhysicalMemorySize();
+-      }
+-    } catch (NoClassDefFoundError e)
+-    {
+-      // com.sun.management.OperatingSystemMXBean doesn't exist in this JVM
+-      System.err.println("No com.sun.management.OperatingSystemMXBean");
+-    }
+-
+-    // We didn't get a com.sun.management.OperatingSystemMXBean.
+-    return -1;
+-  }
+-
+-  public static long memPercent(int percent)
+-  {
+-    long memPercent = -1;
+-
+-    long physicalMem = getPhysicalMemory();
+-    if (physicalMem > applicationMinMemory)
+-    {
+-      // try and set at least applicationMinMemory and thereafter ensure
+-      // leaveFreeMinMemory is left for the OS
+-      memPercent = Math.max(applicationMinMemory,
+-              physicalMem - Math.max(physicalMem * (100 - percent) / 100,
+-                      leaveFreeMinMemory));
+-    }
+-
+-    return memPercent;
+-  }
+-
+-}
+diff --git a/getdown/src/getdown/core/src/test/java/com/threerings/getdown/cache/GarbageCollectorTest.java b/getdown/src/getdown/core/src/test/java/com/threerings/getdown/cache/GarbageCollectorTest.java
+index d5a393745..c7fcf7271 100644
+--- a/getdown/src/getdown/core/src/test/java/com/threerings/getdown/cache/GarbageCollectorTest.java
++++ b/getdown/src/getdown/core/src/test/java/com/threerings/getdown/cache/GarbageCollectorTest.java
+@@ -7,23 +7,36 @@ package com.threerings.getdown.cache;
+ import java.io.File;
+ import java.io.IOException;
++import java.util.Arrays;
++import java.util.Collection;
+ import java.util.concurrent.TimeUnit;
+ import org.junit.*;
+ import org.junit.rules.TemporaryFolder;
+-
++import org.junit.runner.RunWith;
++import org.junit.runners.Parameterized;
+ import static org.junit.Assert.*;
+ import static org.junit.Assume.assumeTrue;
+ /**
+  * Validates that cache garbage is collected and deleted correctly.
+  */
++@RunWith(Parameterized.class)
+ public class GarbageCollectorTest
+ {
++    @Parameterized.Parameters(name = "{0}")
++    public static Collection<Object[]> data() {
++        return Arrays.asList(new Object[][] {{ ".jar" }, { ".zip" }});
++    }
++
++    @Parameterized.Parameter
++    public String extension;
++
+     @Before public void setupFiles () throws IOException
+     {
+-        _cachedFile = _folder.newFile("abc123.jar");
+-        _lastAccessedFile = _folder.newFile("abc123.jar" + ResourceCache.LAST_ACCESSED_FILE_SUFFIX);
++        _cachedFile = _folder.newFile("abc123" + extension);
++        _lastAccessedFile = _folder.newFile(
++            "abc123" + extension + ResourceCache.LAST_ACCESSED_FILE_SUFFIX);
+     }
+     @Test public void shouldDeleteCacheEntryIfRetentionPeriodIsReached ()
+diff --git a/getdown/src/getdown/core/src/test/java/com/threerings/getdown/cache/ResourceCacheTest.java b/getdown/src/getdown/core/src/test/java/com/threerings/getdown/cache/ResourceCacheTest.java
+index 860c72a37..6acffda32 100644
+--- a/getdown/src/getdown/core/src/test/java/com/threerings/getdown/cache/ResourceCacheTest.java
++++ b/getdown/src/getdown/core/src/test/java/com/threerings/getdown/cache/ResourceCacheTest.java
+@@ -7,26 +7,38 @@ package com.threerings.getdown.cache;
+ import java.io.File;
+ import java.io.IOException;
++import java.util.Arrays;
++import java.util.Collection;
+ import java.util.concurrent.TimeUnit;
+ import org.junit.*;
+ import org.junit.rules.TemporaryFolder;
+-
++import org.junit.runner.RunWith;
++import org.junit.runners.Parameterized;
+ import static org.junit.Assert.*;
+ /**
+  * Asserts the correct functionality of the {@link ResourceCache}.
+  */
++@RunWith(Parameterized.class)
+ public class ResourceCacheTest
+ {
++    @Parameterized.Parameters(name = "{0}")
++    public static Collection<Object[]> data() {
++        return Arrays.asList(new Object[][] {{ ".jar" }, { ".zip" }});
++    }
++
++    @Parameterized.Parameter
++    public String extension;
++
+     @Before public void setupCache () throws IOException {
+-        _fileToCache = _folder.newFile("filetocache.jar");
++        _fileToCache = _folder.newFile("filetocache" + extension);
+         _cache = new ResourceCache(_folder.newFolder(".cache"));
+     }
+     @Test public void shouldCacheFile () throws IOException
+     {
+-        assertEquals("abc123.jar", cacheFile().getName());
++        assertEquals("abc123" + extension, cacheFile().getName());
+     }
+     private File cacheFile() throws IOException
+@@ -36,7 +48,7 @@ public class ResourceCacheTest
+     @Test public void shouldTrackFileUsage () throws IOException
+     {
+-        String name = "abc123.jar" + ResourceCache.LAST_ACCESSED_FILE_SUFFIX;
++        String name = "abc123" + extension + ResourceCache.LAST_ACCESSED_FILE_SUFFIX;
+         File lastAccessedFile = new File(cacheFile().getParentFile(), name);
+         assertTrue(lastAccessedFile.exists());
+     }
+diff --git a/getdown/src/getdown/core/src/test/java/com/threerings/getdown/data/EnvConfigTest.java b/getdown/src/getdown/core/src/test/java/com/threerings/getdown/data/EnvConfigTest.java
+index 61786518c..04a73d38b 100644
+--- a/getdown/src/getdown/core/src/test/java/com/threerings/getdown/data/EnvConfigTest.java
++++ b/getdown/src/getdown/core/src/test/java/com/threerings/getdown/data/EnvConfigTest.java
+@@ -19,13 +19,13 @@ public class EnvConfigTest {
+     static String TESTID = "testid";
+     static String TESTBASE = "https://test.com/test";
+-    private void debugNotes(List<EnvConfig.Note> notes) {
++    private void debugNotes (List<EnvConfig.Note> notes) {
+         for (EnvConfig.Note note : notes) {
+             System.out.println(note.message);
+         }
+     }
+-    private void checkNoNotes (List<EnvConfig.Note> notes) {
++    static void checkNoNotes (List<EnvConfig.Note> notes) {
+         StringBuilder msg = new StringBuilder();
+         for (EnvConfig.Note note : notes) {
+             if (note.level != EnvConfig.Note.Level.INFO) {
+diff --git a/getdown/src/getdown/launcher/.project-MOVED b/getdown/src/getdown/launcher/.project-MOVED
+deleted file mode 100644
+index d77a6e8db..000000000
+--- a/getdown/src/getdown/launcher/.project-MOVED
++++ /dev/null
+@@ -1,23 +0,0 @@
+-<?xml version="1.0" encoding="UTF-8"?>
+-<projectDescription>
+-      <name>getdown-launcher</name>
+-      <comment></comment>
+-      <projects>
+-      </projects>
+-      <buildSpec>
+-              <buildCommand>
+-                      <name>org.eclipse.jdt.core.javabuilder</name>
+-                      <arguments>
+-                      </arguments>
+-              </buildCommand>
+-              <buildCommand>
+-                      <name>org.eclipse.m2e.core.maven2Builder</name>
+-                      <arguments>
+-                      </arguments>
+-              </buildCommand>
+-      </buildSpec>
+-      <natures>
+-              <nature>org.eclipse.jdt.core.javanature</nature>
+-              <nature>org.eclipse.m2e.core.maven2Nature</nature>
+-      </natures>
+-</projectDescription>
+diff --git a/getdown/src/getdown/launcher/.settings/org.eclipse.core.resources.prefs b/getdown/src/getdown/launcher/.settings/org.eclipse.core.resources.prefs
+deleted file mode 100644
+index abdea9ac0..000000000
+--- a/getdown/src/getdown/launcher/.settings/org.eclipse.core.resources.prefs
++++ /dev/null
+@@ -1,4 +0,0 @@
+-eclipse.preferences.version=1
+-encoding//src/main/java=UTF-8
+-encoding//src/main/resources=UTF-8
+-encoding/<project>=UTF-8
+diff --git a/getdown/src/getdown/launcher/.settings/org.eclipse.jdt.core.prefs b/getdown/src/getdown/launcher/.settings/org.eclipse.jdt.core.prefs
+deleted file mode 100644
+index 54e56721d..000000000
+--- a/getdown/src/getdown/launcher/.settings/org.eclipse.jdt.core.prefs
++++ /dev/null
+@@ -1,6 +0,0 @@
+-eclipse.preferences.version=1
+-org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.7
+-org.eclipse.jdt.core.compiler.compliance=1.7
+-org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning
+-org.eclipse.jdt.core.compiler.release=disabled
+-org.eclipse.jdt.core.compiler.source=1.7
+diff --git a/getdown/src/getdown/launcher/.settings/org.eclipse.m2e.core.prefs b/getdown/src/getdown/launcher/.settings/org.eclipse.m2e.core.prefs
+deleted file mode 100644
+index f897a7f1c..000000000
+--- a/getdown/src/getdown/launcher/.settings/org.eclipse.m2e.core.prefs
++++ /dev/null
+@@ -1,4 +0,0 @@
+-activeProfiles=
+-eclipse.preferences.version=1
+-resolveWorkspaceProjects=true
+-version=1
+diff --git a/getdown/src/getdown/launcher/pom.xml b/getdown/src/getdown/launcher/pom.xml
+index e77061a3b..f55234977 100644
+--- a/getdown/src/getdown/launcher/pom.xml
++++ b/getdown/src/getdown/launcher/pom.xml
+@@ -4,7 +4,7 @@
+   <parent>
+     <groupId>com.threerings.getdown</groupId>
+     <artifactId>getdown</artifactId>
+-    <version>1.8.3-SNAPSHOT</version>
++    <version>1.8.7-SNAPSHOT</version>
+   </parent>
+   <artifactId>getdown-launcher</artifactId>
+@@ -24,11 +24,13 @@
+       <groupId>com.threerings.getdown</groupId>
+       <artifactId>getdown-core</artifactId>
+       <version>${project.version}</version>
++      <optional>true</optional>
+     </dependency>
+     <dependency>
+       <groupId>com.samskivert</groupId>
+       <artifactId>samskivert</artifactId>
+       <version>1.2</version>
++      <optional>true</optional>
+     </dependency>
+     <dependency>
+       <groupId>jregistrykey</groupId>
+@@ -36,22 +38,47 @@
+       <version>1.0</version>
+       <optional>true</optional>
+     </dependency>
++    <dependency>
++      <groupId>junit</groupId>
++      <artifactId>junit</artifactId>
++      <version>4.12</version>
++      <scope>test</scope>
++    </dependency>
+   </dependencies>
+   <build>
+     <plugins>
+-      <!--
++      <plugin>
++        <groupId>org.codehaus.mojo</groupId>
++        <artifactId>native2ascii-maven-plugin</artifactId>
++        <version>2.0.1</version>
++        <executions>
++          <execution>
++            <id>utf8-to-latin1</id>
++            <goals>
++              <goal>inplace</goal>
++            </goals>
++            <phase>process-resources</phase>
++            <configuration>
++              <dir>${project.build.outputDirectory}</dir>
++              <encoding>${project.build.sourceEncoding}</encoding>
++              <includes>
++                <include>**/*.properties</include>
++              </includes>
++            </configuration>
++          </execution>
++        </executions>
++      </plugin>
++
+       <plugin>
+         <groupId>com.github.wvengen</groupId>
+         <artifactId>proguard-maven-plugin</artifactId>
+         <version>2.0.14</version>
+         <executions>
+-         <execution>
+-           <phase>package</phase>
+-           <goals>
+-             <goal>proguard</goal>
+-           </goals>
+-         </execution>
++          <execution>
++            <phase>package</phase>
++            <goals><goal>proguard</goal></goals>
++          </execution>
+         </executions>
+         <dependencies>
+           <dependency>
+@@ -109,7 +136,7 @@
+           <addMavenDescriptor>false</addMavenDescriptor>
+         </configuration>
+       </plugin>
+-      -->
++
+       <plugin>
+         <groupId>org.apache.maven.plugins</groupId>
+         <artifactId>maven-jar-plugin</artifactId>
+@@ -130,40 +157,6 @@
+           </archive>
+         </configuration>
+       </plugin>
+-
+-      <plugin>
+-        <groupId>org.apache.maven.plugins</groupId>
+-        <artifactId>maven-shade-plugin</artifactId>
+-        <version>3.2.1</version>
+-        <configuration>
+-          <!-- put your configurations here -->
+-        </configuration>
+-        <executions>
+-          <execution>
+-            <phase>package</phase>
+-            <goals>
+-              <goal>shade</goal>
+-            </goals>
+-            <!--
+-            <configuration>
+-              <minimizeJar>true</minimizeJar>
+-              <filters>
+-                <filter>
+-                  <artifact>install4j-runtime</artifact>
+-                  <includes>
+-                    <include>**</include>
+-                  </includes>
+-                </filter>
+-              </filters>
+-            </configuration>
+-            -->
+-          </execution>
+-        </executions>
+-      </plugin>
+-
+-
+-
+-
+     </plugins>
+   </build>
+@@ -202,6 +195,7 @@
+                 <lib>${java.home}/jmods/java.base.jmod</lib>
+                 <lib>${java.home}/jmods/java.desktop.jmod</lib>
+                 <lib>${java.home}/jmods/java.logging.jmod</lib>
++                <lib>${java.home}/jmods/java.scripting.jmod</lib>
+                 <lib>${java.home}/jmods/jdk.jsobject.jmod</lib>
+               </libs>
+             </configuration>
+diff --git a/getdown/src/getdown/launcher/src/main/java/com/threerings/getdown/launcher/AbortPanel.java b/getdown/src/getdown/launcher/src/main/java/com/threerings/getdown/launcher/AbortPanel.java
+index dc1e54e46..4bd9f90b0 100644
+--- a/getdown/src/getdown/launcher/src/main/java/com/threerings/getdown/launcher/AbortPanel.java
++++ b/getdown/src/getdown/launcher/src/main/java/com/threerings/getdown/launcher/AbortPanel.java
+@@ -72,7 +72,7 @@ public final class AbortPanel extends JFrame
+     public void actionPerformed (ActionEvent e)
+     {
+         String cmd = e.getActionCommand();
+-        if (cmd.equals("ok")) {
++        if ("ok".equals(cmd)) {
+             System.exit(0);
+         } else {
+             setVisible(false);
+diff --git a/getdown/src/getdown/launcher/src/main/java/com/threerings/getdown/launcher/Getdown.java b/getdown/src/getdown/launcher/src/main/java/com/threerings/getdown/launcher/Getdown.java
+index 5750fce66..1cc6922c8 100644
+--- a/getdown/src/getdown/launcher/src/main/java/com/threerings/getdown/launcher/Getdown.java
++++ b/getdown/src/getdown/launcher/src/main/java/com/threerings/getdown/launcher/Getdown.java
+@@ -9,51 +9,73 @@ import java.awt.BorderLayout;
+ import java.awt.Container;
+ import java.awt.Dimension;
+ import java.awt.EventQueue;
+-import java.awt.Graphics;
+ import java.awt.GraphicsEnvironment;
+ import java.awt.Image;
+ import java.awt.event.ActionEvent;
+ import java.awt.image.BufferedImage;
+ import java.io.BufferedReader;
+ import java.io.File;
+-import java.io.FileInputStream;
+ import java.io.FileNotFoundException;
+-import java.io.FileReader;
+ import java.io.IOException;
+ import java.io.InputStream;
+ import java.io.InputStreamReader;
+ import java.io.PrintStream;
+ import java.net.HttpURLConnection;
+-import java.net.MalformedURLException;
+ import java.net.URL;
+-import java.util.*;
++import java.util.ArrayList;
++import java.util.Arrays;
++import java.util.Collection;
++import java.util.HashSet;
++import java.util.List;
++import java.util.Locale;
++import java.util.ResourceBundle;
++import java.util.Set;
++import java.util.concurrent.TimeUnit;
+ import javax.imageio.ImageIO;
+ import javax.swing.AbstractAction;
+ import javax.swing.JButton;
+ import javax.swing.JFrame;
+ import javax.swing.JLayeredPane;
+-import javax.swing.JPanel;
+ import com.samskivert.swing.util.SwingUtil;
+-import com.threerings.getdown.data.*;
++import com.threerings.getdown.data.Application;
+ import com.threerings.getdown.data.Application.UpdateInterface.Step;
++import com.threerings.getdown.data.Build;
++import com.threerings.getdown.data.EnvConfig;
++import com.threerings.getdown.data.Resource;
++import com.threerings.getdown.data.SysProps;
+ import com.threerings.getdown.net.Downloader;
+-import com.threerings.getdown.net.HTTPDownloader;
+ import com.threerings.getdown.tools.Patcher;
+-import com.threerings.getdown.util.*;
+-
++import com.threerings.getdown.util.Config;
++import com.threerings.getdown.util.FileUtil;
++import com.threerings.getdown.util.LaunchUtil;
++import com.threerings.getdown.util.MessageUtil;
++import com.threerings.getdown.util.ProgressAggregator;
++import com.threerings.getdown.util.ProgressObserver;
++import com.threerings.getdown.util.StringUtil;
++import com.threerings.getdown.util.VersionUtil;
+ import static com.threerings.getdown.Log.log;
+ /**
+  * Manages the main control for the Getdown application updater and deployment system.
+  */
+-public abstract class Getdown extends Thread
++public abstract class Getdown
+     implements Application.StatusDisplay, RotatingBackgrounds.ImageLoader
+ {
++    /**
++     * Starts a thread to run Getdown and ultimately (hopefully) launch the target app.
++     */
++    public static void run (final Getdown getdown) {
++        new Thread("Getdown") {
++            @Override public void run () {
++                getdown.run();
++            }
++        }.start();
++    }
++
+     public Getdown (EnvConfig envc)
+     {
+-        super("Getdown");
+         try {
+             // If the silent property exists, install without bringing up any gui. If it equals
+             // launch, start the application after installing. Otherwise, just install and exit.
+@@ -80,7 +102,7 @@ public abstract class Getdown extends Thread
+             // welcome to hell, where java can't cope with a classpath that contains jars that live
+             // in a directory that contains a !, at least the same bug happens on all platforms
+             String dir = envc.appDir.toString();
+-            if (dir.equals(".")) {
++            if (".".equals(dir)) {
+                 dir = System.getProperty("user.dir");
+             }
+             String errmsg = "The directory in which this application is installed:\n" + dir +
+@@ -107,7 +129,7 @@ public abstract class Getdown extends Thread
+     {
+         if (SysProps.noInstall()) {
+             log.info("Skipping install due to 'no_install' sysprop.");
+-        } else if (_readyToInstall) {
++        } else if (isUpdateAvailable()) {
+             log.info("Installing " + _toInstallResources.size() + " downloaded resources:");
+             for (Resource resource : _toInstallResources) {
+                 resource.install(true);
+@@ -120,8 +142,28 @@ public abstract class Getdown extends Thread
+         }
+     }
+-    @Override
+-    public void run ()
++    /**
++     * Configures our proxy settings (called by {@link ProxyPanel}) and fires up the launcher.
++     */
++    public void configProxy (String host, String port, String username, String password)
++    {
++        log.info("User configured proxy", "host", host, "port", port);
++        ProxyUtil.configProxy(_app, host, port, username, password);
++
++        // clear out our UI
++        disposeContainer();
++        _container = null;
++
++        // fire up a new thread
++        run(this);
++    }
++
++    /**
++     * The main entry point of Getdown: does some sanity checks and preparation, then delegates the
++     * actual getting down to {@link #getdown}. This is not called directly, but rather via the
++     * static {@code run} method as Getdown does its main work on a separate thread.
++     */
++    protected void run ()
+     {
+         // if we have no messages, just bail because we're hosed; the error message will be
+         // displayed to the user already
+@@ -135,95 +177,48 @@ public abstract class Getdown extends Thread
+         File instdir = _app.getLocalPath("");
+         if (!instdir.canWrite()) {
+             String path = instdir.getPath();
+-            if (path.equals(".")) {
++            if (".".equals(path)) {
+                 path = System.getProperty("user.dir");
+             }
+             fail(MessageUtil.tcompose("m.readonly_error", path));
+             return;
+         }
+-        try {
+-            _dead = false;
+-            // if we fail to detect a proxy, but we're allowed to run offline, then go ahead and
+-            // run the app anyway because we're prepared to cope with not being able to update
+-            if (detectProxy() || _app.allowOffline()) {
+-                getdown();
+-            } else if (_silent) {
+-                log.warning("Need a proxy, but we don't want to bother anyone.  Exiting.");
+-            } else {
+-                // create a panel they can use to configure the proxy settings
+-                _container = createContainer();
+-                // allow them to close the window to abort the proxy configuration
+-                _dead = true;
+-                configureContainer();
+-                ProxyPanel panel = new ProxyPanel(this, _msgs);
+-                // set up any existing configured proxy
+-                String[] hostPort = ProxyUtil.loadProxy(_app);
+-                panel.setProxy(hostPort[0], hostPort[1]);
+-                _container.add(panel, BorderLayout.CENTER);
+-                showContainer();
+-            }
+-
+-        } catch (Exception e) {
+-            log.warning("run() failed.", e);
+-            String msg = e.getMessage();
+-            if (msg == null) {
+-                msg = MessageUtil.compose("m.unknown_error", _ifc.installError);
+-            } else if (!msg.startsWith("m.")) {
+-                // try to do something sensible based on the type of error
+-                if (e instanceof FileNotFoundException) {
+-                    msg = MessageUtil.compose(
+-                        "m.missing_resource", MessageUtil.taint(msg), _ifc.installError);
+-                } else {
+-                    msg = MessageUtil.compose(
+-                        "m.init_error", MessageUtil.taint(msg), _ifc.installError);
+-                }
+-            }
+-            fail(msg);
+-        }
+-    }
+-
+-    /**
+-     * Configures our proxy settings (called by {@link ProxyPanel}) and fires up the launcher.
+-     */
+-    public void configProxy (String host, String port, String username, String password)
+-    {
+-        log.info("User configured proxy", "host", host, "port", port);
+-
+-        if (!StringUtil.isBlank(host)) {
+-            ProxyUtil.configProxy(_app, host, port, username, password);
+-        }
+-
+-        // clear out our UI
+-        disposeContainer();
+-        _container = null;
+-
+-        // fire up a new thread
+-        new Thread(this).start();
++        _dead = false;
++        // if we fail to detect a proxy, but we're allowed to run offline, then go ahead and
++        // run the app anyway because we're prepared to cope with not being able to update
++        if (detectProxy() || _app.allowOffline()) getdown();
++        else requestProxyInfo(false);
+     }
+     protected boolean detectProxy () {
+-        if (ProxyUtil.autoDetectProxy(_app)) {
+-            return true;
+-        }
+-
+-        // otherwise see if we actually need a proxy; first we have to initialize our application
+-        // to get some sort of interface configuration and the appbase URL
++        // first we have to initialize our application to get the appbase URL, etc.
+         log.info("Checking whether we need to use a proxy...");
+         try {
+             readConfig(true);
+         } catch (IOException ioe) {
+             // no worries
+         }
++
++        boolean tryNoProxy = SysProps.tryNoProxyFirst();
++        if (!tryNoProxy && ProxyUtil.autoDetectProxy(_app)) {
++            return true;
++        }
++
++        // see if we actually need a proxy
+         updateStatus("m.detecting_proxy");
+-        if (!ProxyUtil.canLoadWithoutProxy(_app.getConfigResource().getRemote())) {
+-            return false;
++        URL configURL = _app.getConfigResource().getRemote();
++        if (!ProxyUtil.canLoadWithoutProxy(configURL, tryNoProxy ? 2 : 5)) {
++            // if we didn't auto-detect proxy first thing, do auto-detect now
++            return tryNoProxy ? ProxyUtil.autoDetectProxy(_app) : false;
+         }
+-        // we got through, so we appear not to require a proxy; make a blank proxy config so that
+-        // we don't go through this whole detection process again next time
+         log.info("No proxy appears to be needed.");
+-        ProxyUtil.saveProxy(_app, null, null);
++        if (!tryNoProxy)  {
++            // we got through, so we appear not to require a proxy; make a blank proxy config so
++            // that we don't go through this whole detection process again next time
++            ProxyUtil.saveProxy(_app, null, null);
++        }
+         return true;
+     }
+@@ -233,6 +228,25 @@ public abstract class Getdown extends Thread
+         _ifc = new Application.UpdateInterface(config);
+     }
++    protected void requestProxyInfo (boolean reinitAuth) {
++        if (_silent) {
++            log.warning("Need a proxy, but we don't want to bother anyone. Exiting.");
++            return;
++        }
++
++        // create a panel they can use to configure the proxy settings
++        _container = createContainer();
++        // allow them to close the window to abort the proxy configuration
++        _dead = true;
++        configureContainer();
++        ProxyPanel panel = new ProxyPanel(this, _msgs, reinitAuth);
++        // set up any existing configured proxy
++        String[] hostPort = ProxyUtil.loadProxy(_app);
++        panel.setProxy(hostPort[0], hostPort[1]);
++        _container.add(panel, BorderLayout.CENTER);
++        showContainer();
++    }
++
+     /**
+      * Downloads and installs (without verifying) any resources that are marked with a
+      * {@code PRELOAD} attribute.
+@@ -247,13 +261,15 @@ public abstract class Getdown extends Thread
+             }
+         }
+-        try {
+-            download(predownloads);
+-            for (Resource rsrc : predownloads) {
+-                rsrc.install(false); // install but don't validate yet
++        if (!predownloads.isEmpty()) {
++            try {
++                download(predownloads);
++                for (Resource rsrc : predownloads) {
++                    rsrc.install(false); // install but don't validate yet
++                }
++            } catch (IOException ioe) {
++                log.warning("Failed to predownload resources. Continuing...", ioe);
+             }
+-        } catch (IOException ioe) {
+-            log.warning("Failed to predownload resources. Continuing...", ioe);
+         }
+     }
+@@ -263,7 +279,7 @@ public abstract class Getdown extends Thread
+     protected void getdown ()
+     {
+         try {
+-            // first parses our application deployment file
++            // first parse our application deployment file
+             try {
+                 readConfig(true);
+             } catch (IOException ioe) {
+@@ -278,7 +294,7 @@ public abstract class Getdown extends Thread
+                 throw new MultipleGetdownRunning();
+             }
+-            // Update the config modtime so a sleeping getdown will notice the change.
++            // update the config modtime so a sleeping getdown will notice the change
+             File config = _app.getLocalPath(Application.CONFIG_FILE);
+             if (!config.setLastModified(System.currentTimeMillis())) {
+                 log.warning("Unable to set modtime on config file, will be unable to check for " +
+@@ -290,7 +306,7 @@ public abstract class Getdown extends Thread
+                 // Store the config modtime before waiting the delay amount of time
+                 long lastConfigModtime = config.lastModified();
+                 log.info("Waiting " + _delay + " minutes before beginning actual work.");
+-                Thread.sleep(_delay * 60 * 1000);
++                TimeUnit.MINUTES.sleep(_delay);
+                 if (lastConfigModtime < config.lastModified()) {
+                     log.warning("getdown.txt was modified while getdown was waiting.");
+                     throw new MultipleGetdownRunning();
+@@ -339,11 +355,7 @@ public abstract class Getdown extends Thread
+                 if (toDownload.size() > 0) {
+                     // we have resources to download, also note them as to-be-installed
+-                    for (Resource r : toDownload) {
+-                        if (!_toInstallResources.contains(r)) {
+-                            _toInstallResources.add(r);
+-                        }
+-                    }
++                    _toInstallResources.addAll(toDownload);
+                     try {
+                         // if any of our resources have already been marked valid this is not a
+@@ -427,24 +439,20 @@ public abstract class Getdown extends Thread
+             throw new IOException("m.unable_to_repair");
+         } catch (Exception e) {
+-            log.warning("getdown() failed.", e);
+-            String msg = e.getMessage();
+-            if (msg == null) {
+-                msg = MessageUtil.compose("m.unknown_error", _ifc.installError);
+-            } else if (!msg.startsWith("m.")) {
+-                // try to do something sensible based on the type of error
+-                if (e instanceof FileNotFoundException) {
+-                    msg = MessageUtil.compose(
+-                        "m.missing_resource", MessageUtil.taint(msg), _ifc.installError);
+-                } else {
+-                    msg = MessageUtil.compose(
+-                        "m.init_error", MessageUtil.taint(msg), _ifc.installError);
+-                }
++            // if we failed due to proxy errors, ask for proxy info
++            switch (_app.conn.state) {
++            case NEED_PROXY:
++                requestProxyInfo(false);
++                break;
++            case NEED_PROXY_AUTH:
++                requestProxyInfo(true);
++                break;
++            default:
++                log.warning("getdown() failed.", e);
++                fail(e);
++                _app.releaseLock();
++                break;
+             }
+-            // Since we're dead, clear off the 'time remaining' label along with displaying the
+-            // error message
+-            fail(msg);
+-            _app.releaseLock();
+         }
+     }
+@@ -499,6 +507,18 @@ public abstract class Getdown extends Thread
+             throw new IOException("m.java_download_failed");
+         }
++        // on Windows, if the local JVM is in use, we will not be able to replace it with an
++        // updated JVM; we detect this by attempting to rename the java.dll to its same name, which
++        // will fail on Windows for in use files; hackery!
++        File javaLocalDir = _app.getJavaLocalDir();
++        File javaDll = new File(javaLocalDir, "bin" + File.separator + "java.dll");
++        if (javaDll.exists()) {
++            if (!javaDll.renameTo(javaDll)) {
++                log.info("Cannot update local Java VM as it is in use.");
++                return;
++            }
++        }
++
+         reportTrackingEvent("jvm_start", -1);
+         updateStatus("m.downloading_java");
+@@ -507,21 +527,24 @@ public abstract class Getdown extends Thread
+         download(list);
+         reportTrackingEvent("jvm_unpack", -1);
+-
+         updateStatus("m.unpacking_java");
+-        vmjar.install(true);
++        try {
++            vmjar.install(true);
++        } catch (IOException ioe) {
++            throw new IOException("m.java_unpack_failed", ioe);
++        }
+         // these only run on non-Windows platforms, so we use Unix file separators
+-        String localJavaDir = LaunchUtil.LOCAL_JAVA_DIR + "/";
+-        FileUtil.makeExecutable(_app.getLocalPath(localJavaDir + "bin/java"));
+-        FileUtil.makeExecutable(_app.getLocalPath(localJavaDir + "lib/jspawnhelper"));
+-        FileUtil.makeExecutable(_app.getLocalPath(localJavaDir + "lib/amd64/jspawnhelper"));
++        FileUtil.makeExecutable(new File(javaLocalDir, "bin/java"));
++        FileUtil.makeExecutable(new File(javaLocalDir, "lib/jspawnhelper"));
++        FileUtil.makeExecutable(new File(javaLocalDir, "lib/amd64/jspawnhelper"));
+         // lastly regenerate the .jsa dump file that helps Java to start up faster
+-        String vmpath = LaunchUtil.getJVMPath(_app.getLocalPath(""));
++        String vmpath = LaunchUtil.getJVMBinaryPath(javaLocalDir, false);
++        String[] command = { vmpath, "-Xshare:dump" };
+         try {
+             log.info("Regenerating classes.jsa for " + vmpath + "...");
+-            Runtime.getRuntime().exec(vmpath + " -Xshare:dump");
++            Runtime.getRuntime().exec(command);
+         } catch (Exception e) {
+             log.warning("Failed to regenerate .jsa dump file", "error", e);
+         }
+@@ -578,6 +601,8 @@ public abstract class Getdown extends Thread
+             int ii = 0; for (Resource prsrc : list) {
+                 ProgressObserver pobs = pragg.startElement(ii++);
+                 try {
++                    // if this patch file failed to download, skip it
++                    if (!prsrc.getLocalNew().exists()) continue;
+                     // install the patch file (renaming them from _new)
+                     prsrc.install(false);
+                     // now apply the patch
+@@ -613,7 +638,7 @@ public abstract class Getdown extends Thread
+         // create our user interface
+         createInterfaceAsync(false);
+-        Downloader dl = new HTTPDownloader(_app.proxy) {
++        Downloader dl = new Downloader(_app.conn) {
+             @Override protected void resolvingDownloads () {
+                 updateStatus("m.resolving");
+             }
+@@ -640,6 +665,10 @@ public abstract class Getdown extends Thread
+                 log.warning("Download failed", "rsrc", rsrc, e);
+             }
++            @Override protected void resourceMissing (Resource rsrc) {
++                log.warning("Resource missing (got 404)", "rsrc", rsrc);
++            }
++
+             /** The last percentage at which we checked for another getdown running, or -1 for not
+              * having checked at all. */
+             protected int _lastCheck = -1;
+@@ -725,7 +754,7 @@ public abstract class Getdown extends Thread
+             long minshow = _ifc.minShowSeconds * 1000L;
+             if (_container != null && uptime < minshow) {
+                 try {
+-                    Thread.sleep(minshow - uptime);
++                    TimeUnit.MILLISECONDS.sleep(minshow - uptime);
+                 } catch (Exception e) {
+                 }
+             }
+@@ -750,10 +779,9 @@ public abstract class Getdown extends Thread
+         if (_silent || (_container != null && !reinit)) {
+             return;
+         }
+-/*
++
+         EventQueue.invokeLater(new Runnable() {
+             public void run () {
+-*/
+                 if (_container == null || reinit) {
+                     if (_container == null) {
+                         _container = createContainer();
+@@ -762,42 +790,6 @@ public abstract class Getdown extends Thread
+                     }
+                     configureContainer();
+                     _layers = new JLayeredPane();
+-                    
+-                    
+-                    
+-                    // added in the instant display of a splashscreen
+-                    try {
+-                      readConfig(false);
+-                      Graphics g = _container.getGraphics();
+-                      String imageFile = _ifc.backgroundImage;
+-                      BufferedImage bgImage = loadImage(_ifc.backgroundImage);
+-                      int bwidth = bgImage.getWidth();
+-                      int bheight = bgImage.getHeight();
+-
+-                      instantSplashPane = new JPanel() {
+-                        @Override
+-                        protected void paintComponent(Graphics g)
+-                        {
+-                          super.paintComponent(g);
+-                          // attempt to draw a background image...
+-                          if (bgImage != null) {
+-                            g.drawImage(bgImage, 0, 0, this);
+-                          }
+-                        }
+-                      };
+-
+-                      instantSplashPane.setSize(bwidth,bheight);
+-                      instantSplashPane.setPreferredSize(new Dimension(bwidth,bheight));
+-
+-                      _layers.add(instantSplashPane, Integer.valueOf(0));
+-                    
+-                      _container.setPreferredSize(new Dimension(bwidth,bheight));
+-                    } catch (Exception e) {
+-                      log.warning("Failed to set instant background image", "bg", _ifc.backgroundImage);
+-                    }
+-
+-                    
+-                    
+                     _container.add(_layers, BorderLayout.CENTER);
+                     _patchNotes = new JButton(new AbstractAction(_msgs.getString("m.patch_notes")) {
+                         @Override public void actionPerformed (ActionEvent event) {
+@@ -807,14 +799,12 @@ public abstract class Getdown extends Thread
+                     _patchNotes.setFont(StatusPanel.FONT);
+                     _layers.add(_patchNotes);
+                     _status = new StatusPanel(_msgs);
+-                    _layers.add(_status, Integer.valueOf(10));
++                    _layers.add(_status);
+                     initInterface();
+                 }
+                 showContainer();
+-/*
+             }
+         });
+-*/
+     }
+     /**
+@@ -845,12 +835,13 @@ public abstract class Getdown extends Thread
+     protected RotatingBackgrounds getBackground ()
+     {
+-        if (_ifc.rotatingBackgrounds != null && _ifc.rotatingBackgrounds.size() > 0) {
++        if (_ifc.rotatingBackgrounds != null) {
+             if (_ifc.backgroundImage != null) {
+                 log.warning("ui.background_image and ui.rotating_background were both specified. " +
+-                            "The background image is being used.");
++                            "The rotating images are being used.");
+             }
+-            return new RotatingBackgrounds(_ifc.rotatingBackgrounds, _ifc.errorBackground, Getdown.this);
++            return new RotatingBackgrounds(_ifc.rotatingBackgrounds, _ifc.errorBackground,
++                Getdown.this);
+         } else if (_ifc.backgroundImage != null) {
+             return new RotatingBackgrounds(loadImage(_ifc.backgroundImage));
+         } else {
+@@ -879,8 +870,23 @@ public abstract class Getdown extends Thread
+         }
+     }
++    private void fail (Exception e) {
++        String msg = e.getMessage();
++        if (msg == null) {
++            msg = MessageUtil.compose("m.unknown_error", _ifc.installError);
++        } else if (!msg.startsWith("m.")) {
++            // try to do something sensible based on the type of error
++            msg = MessageUtil.taint(msg);
++            msg = e instanceof FileNotFoundException ?
++                MessageUtil.compose("m.missing_resource", msg, _ifc.installError) :
++                MessageUtil.compose("m.init_error", msg, _ifc.installError);
++        }
++        // since we're dead, clear off the 'time remaining' label along with displaying the error
++        fail(msg);
++    }
++
+     /**
+-     * Update the status to indicate getdown has failed for the reason in <code>message</code>.
++     * Update the status to indicate getdown has failed for the reason in {@code message}.
+      */
+     protected void fail (String message)
+     {
+@@ -961,14 +967,14 @@ public abstract class Getdown extends Thread
+             do {
+                 URL url = _app.getTrackingProgressURL(++_reportedProgress);
+                 if (url != null) {
+-                    new ProgressReporter(url).start();
++                    reportProgress(url);
+                 }
+             } while (_reportedProgress <= progress);
+         } else {
+             URL url = _app.getTrackingURL(event);
+             if (url != null) {
+-                new ProgressReporter(url).start();
++                reportProgress(url);
+             }
+         }
+     }
+@@ -1031,44 +1037,40 @@ public abstract class Getdown extends Thread
+     }
+     /** Used to fetch a progress report URL. */
+-    protected class ProgressReporter extends Thread
+-    {
+-        public ProgressReporter (URL url) {
+-            setDaemon(true);
+-            _url = url;
+-        }
+-
+-        @Override
+-        public void run () {
+-            try {
+-                HttpURLConnection ucon = ConnectionUtil.openHttp(_app.proxy, _url, 0, 0);
+-
+-                // if we have a tracking cookie configured, configure the request with it
+-                if (_app.getTrackingCookieName() != null &&
+-                    _app.getTrackingCookieProperty() != null) {
+-                    String val = System.getProperty(_app.getTrackingCookieProperty());
+-                    if (val != null) {
+-                        ucon.setRequestProperty("Cookie", _app.getTrackingCookieName() + "=" + val);
++    protected void reportProgress (final URL url) {
++        Thread reporter = new Thread("Progress reporter") {
++            public void run () {
++                try {
++                    HttpURLConnection ucon = _app.conn.openHttp(url, 0, 0);
++
++                    // if we have a tracking cookie configured, configure the request with it
++                    if (_app.getTrackingCookieName() != null &&
++                        _app.getTrackingCookieProperty() != null) {
++                        String val = System.getProperty(_app.getTrackingCookieProperty());
++                        if (val != null) {
++                            ucon.setRequestProperty(
++                                "Cookie", _app.getTrackingCookieName() + "=" + val);
++                        }
+                     }
+-                }
+-                // now request our tracking URL and ensure that we get a non-error response
+-                ucon.connect();
+-                try {
+-                    if (ucon.getResponseCode() != HttpURLConnection.HTTP_OK) {
+-                        log.warning("Failed to report tracking event",
+-                            "url", _url, "rcode", ucon.getResponseCode());
++                    // now request our tracking URL and ensure that we get a non-error response
++                    ucon.connect();
++                    try {
++                        if (ucon.getResponseCode() != HttpURLConnection.HTTP_OK) {
++                            log.warning("Failed to report tracking event",
++                                        "url", url, "rcode", ucon.getResponseCode());
++                        }
++                    } finally {
++                        ucon.disconnect();
+                     }
+-                } finally {
+-                    ucon.disconnect();
+-                }
+-            } catch (IOException ioe) {
+-                log.warning("Failed to report tracking event", "url", _url, "error", ioe);
++                } catch (IOException ioe) {
++                    log.warning("Failed to report tracking event", "url", url, "error", ioe);
++                }
+             }
+-        }
+-
+-        protected URL _url;
++        };
++        reporter.setDaemon(true);
++        reporter.start();
+     }
+     /** Used to pass progress on to our user interface. */
+@@ -1084,7 +1086,6 @@ public abstract class Getdown extends Thread
+     protected ResourceBundle _msgs;
+     protected Container _container;
+     protected JLayeredPane _layers;
+-    protected JPanel instantSplashPane;
+     protected StatusPanel _status;
+     protected JButton _patchNotes;
+     protected AbortPanel _abort;
+@@ -1112,5 +1113,4 @@ public abstract class Getdown extends Thread
+     protected static final int MAX_LOOPS = 5;
+     protected static final long FALLBACK_CHECK_TIME = 1000L;
+-    
+ }
+diff --git a/getdown/src/getdown/launcher/src/main/java/com/threerings/getdown/launcher/GetdownApp.java b/getdown/src/getdown/launcher/src/main/java/com/threerings/getdown/launcher/GetdownApp.java
+index 2d1908904..3859f6a09 100644
+--- a/getdown/src/getdown/launcher/src/main/java/com/threerings/getdown/launcher/GetdownApp.java
++++ b/getdown/src/getdown/launcher/src/main/java/com/threerings/getdown/launcher/GetdownApp.java
+@@ -28,258 +28,225 @@ import javax.swing.KeyStroke;
+ import javax.swing.WindowConstants;
+ import com.samskivert.swing.util.SwingUtil;
+-import com.threerings.getdown.data.Application;
+ import com.threerings.getdown.data.EnvConfig;
+ import com.threerings.getdown.data.SysProps;
+ import com.threerings.getdown.util.LaunchUtil;
+ import com.threerings.getdown.util.StringUtil;
+ import static com.threerings.getdown.Log.log;
+-import jalview.bin.StartupNotificationListener;
+ /**
+  * The main application entry point for Getdown.
+  */
+ public class GetdownApp
+ {
+-  public static String startupFilesParameterString = "";
+-  /**
+-   * The main entry point of the Getdown launcher application.
+-   */
+-  public static void main (String[] argv) {
+-    try {
+-      start(argv);
+-    } catch (Exception e) {
+-      log.warning("main() failed.", e);
+-    }
+-  }
+-
+-  /**
+-   * Runs Getdown as an application, using the arguments supplie as {@code argv}.
+-   * @return the {@code Getdown} instance that is running. {@link Getdown#start} will have been
+-   * called on it.
+-   * @throws Exception if anything goes wrong starting Getdown.
+-   */
+-  public static Getdown start (String[] argv) throws Exception {
+-    List<EnvConfig.Note> notes = new ArrayList<>();
+-    EnvConfig envc = EnvConfig.create(argv, notes);
+-    if (envc == null) {
+-      if (!notes.isEmpty()) for (EnvConfig.Note n : notes) System.err.println(n.message);
+-      else System.err.println("Usage: java -jar getdown.jar [app_dir] [app_id] [app args]");
+-      System.exit(-1);
+-    }
+-
+-    // pipe our output into a file in the application directory
+-    if (!SysProps.noLogRedir()) {
+-      File logFile = new File(envc.appDir, "launcher.log");
+-      try {
+-        PrintStream logOut = new PrintStream(
+-                new BufferedOutputStream(new FileOutputStream(logFile)), true);
+-        System.setOut(logOut);
+-        System.setErr(logOut);
+-      } catch (IOException ioe) {
+-        log.warning("Unable to redirect output to '" + logFile + "': " + ioe);
+-      }
+-    }
+-
+-    // report any notes from reading our env config, and abort if necessary
+-    boolean abort = false;
+-    for (EnvConfig.Note note : notes) {
+-      switch (note.level) {
+-      case INFO: log.info(note.message); break;
+-      case WARN: log.warning(note.message); break;
+-      case ERROR: log.error(note.message); abort = true; break;
+-      }
++    /**
++     * The main entry point of the Getdown launcher application.
++     */
++    public static void main (String[] argv) {
++        try {
++            start(argv);
++        } catch (Exception e) {
++            log.warning("main() failed.", e);
++        }
+     }
+-    if (abort) System.exit(-1);
+-    try
+-    {
+-      jalview.bin.StartupNotificationListener.setListener();
+-    } catch (Exception e)
+-    {
+-      e.printStackTrace();
+-    } catch (NoClassDefFoundError e)
+-    {
+-      log.warning("Starting without install4j classes");
+-    } catch (Throwable t)
+-    {
+-      t.printStackTrace();
+-    }
+-    
+-    // record a few things for posterity
+-    log.info("------------------ VM Info ------------------");
+-    log.info("-- OS Name: " + System.getProperty("os.name"));
+-    log.info("-- OS Arch: " + System.getProperty("os.arch"));
+-    log.info("-- OS Vers: " + System.getProperty("os.version"));
+-    log.info("-- Java Vers: " + System.getProperty("java.version"));
+-    log.info("-- Java Home: " + System.getProperty("java.home"));
+-    log.info("-- User Name: " + System.getProperty("user.name"));
+-    log.info("-- User Home: " + System.getProperty("user.home"));
+-    log.info("-- Cur dir: " + System.getProperty("user.dir"));
+-    log.info("-- startupFilesParameterString: " + startupFilesParameterString);
+-    log.info("---------------------------------------------");
++    /**
++     * Runs Getdown as an application, using the arguments supplie as {@code argv}.
++     * @return the {@code Getdown} instance that is running. {@link Getdown#run} will have been
++     * called on it.
++     * @throws Exception if anything goes wrong starting Getdown.
++     */
++    public static Getdown start (String[] argv) throws Exception {
++        List<EnvConfig.Note> notes = new ArrayList<>();
++        EnvConfig envc = EnvConfig.create(argv, notes);
++        if (envc == null) {
++            if (!notes.isEmpty()) for (EnvConfig.Note n : notes) System.err.println(n.message);
++            else System.err.println("Usage: java -jar getdown.jar [app_dir] [app_id] [app args]");
++            System.exit(-1);
++        }
+-    Getdown app = new Getdown(envc) {
+-      @Override
+-      protected Container createContainer () {
+-        // create our user interface, and display it
+-        if (_frame == null) {
+-          _frame = new JFrame("");
+-          _frame.addWindowListener(new WindowAdapter() {
+-            @Override
+-            public void windowClosing (WindowEvent evt) {
+-              handleWindowClose();
+-            }
+-          });
+-          // handle close on ESC
+-          String cancelId = "Cancel"; // $NON-NLS-1$
+-          _frame.getRootPane().getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(
+-                  KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), cancelId);
+-          _frame.getRootPane().getActionMap().put(cancelId, new AbstractAction() {
+-            public void actionPerformed (ActionEvent e) {
+-              handleWindowClose();
++        // pipe our output into a file in the application directory
++        if (!SysProps.noLogRedir() && !SysProps.debug()) {
++            File logFile = new File(envc.appDir, "launcher.log");
++            try {
++                PrintStream logOut = new PrintStream(
++                    new BufferedOutputStream(new FileOutputStream(logFile)), true);
++                System.setOut(logOut);
++                System.setErr(logOut);
++            } catch (IOException ioe) {
++                log.warning("Unable to redirect output to '" + logFile + "': " + ioe);
+             }
+-          });
+-          // this cannot be called in configureContainer as it is only allowed before the
+-          // frame has been displayed for the first time
+-          _frame.setUndecorated(_ifc.hideDecorations);
+-          _frame.setResizable(false);
+-        } else {
+-          _frame.getContentPane().removeAll();
+         }
+-        _frame.setDefaultCloseOperation(WindowConstants.DO_NOTHING_ON_CLOSE);
+-        return _frame.getContentPane();
+-      }
+-
+-      @Override
+-      protected void configureContainer () {
+-        if (_frame == null) return;
+-
+-        _frame.setTitle(_ifc.name);
+-        try {
+-          _frame.setBackground(new Color(_ifc.background, true));
+-        } catch (Exception e) {
+-          log.warning("Failed to set background", "bg", _ifc.background, e);
++        // report any notes from reading our env config, and abort if necessary
++        boolean abort = false;
++        for (EnvConfig.Note note : notes) {
++            switch (note.level) {
++            case INFO: log.info(note.message); break;
++            case WARN: log.warning(note.message); break;
++            case ERROR: log.error(note.message); abort = true; break;
++            }
+         }
++        if (abort) System.exit(-1);
++
++        // record a few things for posterity
++        log.info("------------------ VM Info ------------------");
++        log.info("-- OS Name: " + System.getProperty("os.name"));
++        log.info("-- OS Arch: " + System.getProperty("os.arch"));
++        log.info("-- OS Vers: " + System.getProperty("os.version"));
++        log.info("-- Java Vers: " + System.getProperty("java.version"));
++        log.info("-- Java Home: " + System.getProperty("java.home"));
++        log.info("-- User Name: " + System.getProperty("user.name"));
++        log.info("-- User Home: " + System.getProperty("user.home"));
++        log.info("-- Cur dir: " + System.getProperty("user.dir"));
++        log.info("---------------------------------------------");
++
++        Getdown getdown = new Getdown(envc) {
++            @Override
++            protected Container createContainer () {
++                // create our user interface, and display it
++                if (_frame == null) {
++                    _frame = new JFrame("");
++                    _frame.addWindowListener(new WindowAdapter() {
++                        @Override
++                        public void windowClosing (WindowEvent evt) {
++                            handleWindowClose();
++                        }
++                    });
++                    // handle close on ESC
++                    String cancelId = "Cancel"; // $NON-NLS-1$
++                    _frame.getRootPane().getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(
++                        KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), cancelId);
++                    _frame.getRootPane().getActionMap().put(cancelId, new AbstractAction() {
++                        public void actionPerformed (ActionEvent e) {
++                            handleWindowClose();
++                        }
++                    });
++                    // this cannot be called in configureContainer as it is only allowed before the
++                    // frame has been displayed for the first time
++                    _frame.setUndecorated(_ifc.hideDecorations);
++                    _frame.setResizable(false);
++                } else {
++                    _frame.getContentPane().removeAll();
++                }
++                _frame.setDefaultCloseOperation(WindowConstants.DO_NOTHING_ON_CLOSE);
++                return _frame.getContentPane();
++            }
+-        if (_ifc.iconImages != null && _ifc.iconImages.size() > 0) {
+-          ArrayList<Image> icons = new ArrayList<>();
+-          for (String path : _ifc.iconImages) {
+-            Image img = loadImage(path);
+-            if (img == null) {
+-              log.warning("Error loading icon image", "path", path);
+-            } else {
+-              icons.add(img);
++            @Override
++            protected void configureContainer () {
++                if (_frame == null) return;
++
++                _frame.setTitle(_ifc.name);
++
++                try {
++                    _frame.setBackground(new Color(_ifc.background, true));
++                } catch (Exception e) {
++                    log.warning("Failed to set background", "bg", _ifc.background, e);
++                }
++
++                if (_ifc.iconImages != null) {
++                    List<Image> icons = new ArrayList<>();
++                    for (String path : _ifc.iconImages) {
++                        Image img = loadImage(path);
++                        if (img == null) {
++                            log.warning("Error loading icon image", "path", path);
++                        } else {
++                            icons.add(img);
++                        }
++                    }
++                    if (icons.isEmpty()) {
++                        log.warning("Failed to load any icons", "iconImages", _ifc.iconImages);
++                    } else {
++                        _frame.setIconImages(icons);
++                    }
++                }
+             }
+-          }
+-          if (icons.isEmpty()) {
+-            log.warning("Failed to load any icons", "iconImages", _ifc.iconImages);
+-          } else {
+-            _frame.setIconImages(icons);
+-          }
+-        }
+-      }
+-      @Override
+-      protected void showContainer () {
+-        if (_frame != null) {
+-          _frame.pack();
+-          SwingUtil.centerWindow(_frame);
+-          _frame.setVisible(true);
+-        }
+-      }
++            @Override
++            protected void showContainer () {
++                if (_frame != null) {
++                    _frame.pack();
++                    SwingUtil.centerWindow(_frame);
++                    _frame.setVisible(true);
++                }
++            }
+-      @Override
+-      protected void disposeContainer () {
+-        if (_frame != null) {
+-          _frame.dispose();
+-          _frame = null;
+-        }
+-      }
++            @Override
++            protected void disposeContainer () {
++                if (_frame != null) {
++                    _frame.dispose();
++                    _frame = null;
++                }
++            }
+-      @Override
+-      protected void showDocument (String url) {
+-        if (!StringUtil.couldBeValidUrl(url)) {
+-          // command injection would be possible if we allowed e.g. spaces and double quotes
+-          log.warning("Invalid document URL.", "url", url);
+-          return;
+-        }
+-        String[] cmdarray;
+-        if (LaunchUtil.isWindows()) {
+-          String osName = System.getProperty("os.name", "");
+-          if (osName.indexOf("9") != -1 || osName.indexOf("Me") != -1) {
+-            cmdarray = new String[] {
+-                "command.com", "/c", "start", "\"" + url + "\"" };
+-          } else {
+-            cmdarray = new String[] {
+-                "cmd.exe", "/c", "start", "\"\"", "\"" + url + "\"" };
+-          }
+-        } else if (LaunchUtil.isMacOS()) {
+-          cmdarray = new String[] { "open", url };
+-        } else { // Linux, Solaris, etc.
+-          cmdarray = new String[] { "firefox", url };
+-        }
+-        try {
+-          Runtime.getRuntime().exec(cmdarray);
+-        } catch (Exception e) {
+-          log.warning("Failed to open browser.", "cmdarray", cmdarray, e);
+-        }
+-      }
++            @Override
++            protected void showDocument (String url) {
++                if (!StringUtil.couldBeValidUrl(url)) {
++                    // command injection would be possible if we allowed e.g. spaces and double quotes
++                    log.warning("Invalid document URL.", "url", url);
++                    return;
++                }
++                String[] cmdarray;
++                if (LaunchUtil.isWindows()) {
++                    String osName = System.getProperty("os.name", "");
++                    if (osName.contains("9") || osName.contains("Me")) {
++                        cmdarray = new String[] {
++                            "command.com", "/c", "start", "\"" + url + "\"" };
++                    } else {
++                        cmdarray = new String[] {
++                            "cmd.exe", "/c", "start", "\"\"", "\"" + url + "\"" };
++                    }
++                } else if (LaunchUtil.isMacOS()) {
++                    cmdarray = new String[] { "open", url };
++                } else { // Linux, Solaris, etc.
++                    cmdarray = new String[] { "firefox", url };
++                }
++                try {
++                    Runtime.getRuntime().exec(cmdarray);
++                } catch (Exception e) {
++                    log.warning("Failed to open browser.", "cmdarray", cmdarray, e);
++                }
++            }
+-      @Override
+-      protected void exit (int exitCode) {
+-        // if we're running the app in the same JVM, don't call System.exit, but do
+-        // make double sure that the download window is closed.
+-        if (invokeDirect()) {
+-          disposeContainer();
+-        } else {
+-          System.exit(exitCode);
+-        }
+-      }
++            @Override
++            protected void exit (int exitCode) {
++                // if we're running the app in the same JVM, don't call System.exit, but do
++                // make double sure that the download window is closed.
++                if (invokeDirect()) {
++                    disposeContainer();
++                } else {
++                    System.exit(exitCode);
++                }
++            }
+-      @Override
+-      protected void fail (String message) {
+-        super.fail(message);
+-        // super.fail causes the UI to be created (if needed) on the next UI tick, so we
+-        // want to wait until that happens before we attempt to redecorate the window
+-        EventQueue.invokeLater(new Runnable() {
+-          @Override
+-          public void run() {
+-            // if the frame was set to be undecorated, make window decoration available
+-            // to allow the user to close the window
+-            if (_frame != null && _frame.isUndecorated()) {
+-              _frame.dispose();
+-              Color bg = _frame.getBackground();
+-              if (bg != null && bg.getAlpha() < 255) {
+-                // decorated windows do not allow alpha backgrounds
+-                _frame.setBackground(
+-                        new Color(bg.getRed(), bg.getGreen(), bg.getBlue()));
+-              }
+-              _frame.setUndecorated(false);
+-              showContainer();
++            @Override
++            protected void fail (String message) {
++                super.fail(message);
++                // super.fail causes the UI to be created (if needed) on the next UI tick, so we
++                // want to wait until that happens before we attempt to redecorate the window
++                EventQueue.invokeLater(new Runnable() {
++                    @Override public void run () {
++                        // if the frame was set to be undecorated, make window decoration available
++                        // to allow the user to close the window
++                        if (_frame != null && _frame.isUndecorated()) {
++                            _frame.dispose();
++                            Color bg = _frame.getBackground();
++                            if (bg != null && bg.getAlpha() < 255) {
++                                // decorated windows do not allow alpha backgrounds
++                                _frame.setBackground(
++                                    new Color(bg.getRed(), bg.getGreen(), bg.getBlue()));
++                            }
++                            _frame.setUndecorated(false);
++                            showContainer();
++                        }
++                    }
++                });
+             }
+-          }
+-        });
+-      }
+-      protected JFrame _frame;
+-    };
+-    
+-    String startupFile = getStartupFilesParameterString();
+-    if (!StringUtil.isBlank(startupFile)) {
+-      Application.setStartupFilesFromParameterString(startupFile);
++            protected JFrame _frame;
++        };
++        Getdown.run(getdown);
++        return getdown;
+     }
+- 
+-    app.start();
+-    return app;
+-  }
+-  
+-  public static void setStartupFilesParameterString(String parameters) {
+-    startupFilesParameterString = parameters;
+-  }
+-  
+-  public static String getStartupFilesParameterString() {
+-    return startupFilesParameterString;
+-  }
+ }
+diff --git a/getdown/src/getdown/launcher/src/main/java/com/threerings/getdown/launcher/ProxyPanel.java b/getdown/src/getdown/launcher/src/main/java/com/threerings/getdown/launcher/ProxyPanel.java
+index 217827364..5f18896c4 100644
+--- a/getdown/src/getdown/launcher/src/main/java/com/threerings/getdown/launcher/ProxyPanel.java
++++ b/getdown/src/getdown/launcher/src/main/java/com/threerings/getdown/launcher/ProxyPanel.java
+@@ -35,14 +35,16 @@ import static com.threerings.getdown.Log.log;
+  */
+ public final class ProxyPanel extends JPanel implements ActionListener
+ {
+-    public ProxyPanel (Getdown getdown, ResourceBundle msgs)
++    public ProxyPanel (Getdown getdown, ResourceBundle msgs, boolean updateAuth)
+     {
+         _getdown = getdown;
+         _msgs = msgs;
++        _updateAuth = updateAuth;
+         setLayout(new VGroupLayout());
+         setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
+-        add(new SaneLabelField(get("m.configure_proxy")));
++        String title = get(updateAuth ? "m.update_proxy_auth" : "m.configure_proxy");
++        add(new SaneLabelField(title));
+         add(new Spacer(5, 5));
+         JPanel row = new JPanel(new GridLayout());
+@@ -61,19 +63,20 @@ public final class ProxyPanel extends JPanel implements ActionListener
+         row.add(new SaneLabelField(get("m.proxy_auth_required")), BorderLayout.WEST);
+         _useAuth = new JCheckBox();
+         row.add(_useAuth);
++        _useAuth.setSelected(updateAuth);
+         add(row);
+         row = new JPanel(new GridLayout());
+         row.add(new SaneLabelField(get("m.proxy_username")), BorderLayout.WEST);
+         _username = new SaneTextField();
+-        _username.setEnabled(false);
++        _username.setEnabled(updateAuth);
+         row.add(_username);
+         add(row);
+         row = new JPanel(new GridLayout());
+         row.add(new SaneLabelField(get("m.proxy_password")), BorderLayout.WEST);
+         _password = new SanePasswordField();
+-        _password.setEnabled(false);
++        _password.setEnabled(updateAuth);
+         row.add(_password);
+         add(row);
+@@ -112,7 +115,13 @@ public final class ProxyPanel extends JPanel implements ActionListener
+     public void addNotify ()
+     {
+         super.addNotify();
+-        _host.requestFocusInWindow();
++        if (_updateAuth) {
++            // we are asking the user to update the credentials for an existing proxy
++            // configuration, so focus that instead of the proxy host config
++            _username.requestFocusInWindow();
++        } else {
++            _host.requestFocusInWindow();
++        }
+     }
+     // documentation inherited
+@@ -131,7 +140,7 @@ public final class ProxyPanel extends JPanel implements ActionListener
+     public void actionPerformed (ActionEvent e)
+     {
+         String cmd = e.getActionCommand();
+-        if (cmd.equals("ok")) {
++        if ("ok".equals(cmd)) {
+             String user = null, pass = null;
+             if (_useAuth.isSelected()) {
+                 user = _username.getText();
+@@ -184,8 +193,9 @@ public final class ProxyPanel extends JPanel implements ActionListener
+         return dim;
+     }
+-    protected Getdown _getdown;
+-    protected ResourceBundle _msgs;
++    protected final Getdown _getdown;
++    protected final ResourceBundle _msgs;
++    protected final boolean _updateAuth;
+     protected JTextField _host;
+     protected JTextField _port;
+diff --git a/getdown/src/getdown/launcher/src/main/java/com/threerings/getdown/launcher/ProxyUtil.java b/getdown/src/getdown/launcher/src/main/java/com/threerings/getdown/launcher/ProxyUtil.java
+index a36b5fa67..8962d35b9 100644
+--- a/getdown/src/getdown/launcher/src/main/java/com/threerings/getdown/launcher/ProxyUtil.java
++++ b/getdown/src/getdown/launcher/src/main/java/com/threerings/getdown/launcher/ProxyUtil.java
+@@ -8,31 +8,41 @@ package com.threerings.getdown.launcher;
+ import java.io.File;
+ import java.io.FileOutputStream;
+ import java.io.IOException;
++import java.io.InputStreamReader;
+ import java.io.PrintStream;
++import java.io.Reader;
+ import java.net.Authenticator;
+ import java.net.HttpURLConnection;
++import java.net.InetAddress;
+ import java.net.InetSocketAddress;
+ import java.net.PasswordAuthentication;
+ import java.net.Proxy;
+ import java.net.URL;
+ import java.net.URLConnection;
++import java.net.UnknownHostException;
+ import java.util.Iterator;
+ import java.util.ServiceLoader;
++import javax.script.Bindings;
++import javax.script.Invocable;
++import javax.script.ScriptContext;
++import javax.script.ScriptEngine;
++import javax.script.ScriptEngineManager;
++
+ import ca.beq.util.win32.registry.RegistryKey;
+ import ca.beq.util.win32.registry.RegistryValue;
+ import ca.beq.util.win32.registry.RootKey;
+ import com.threerings.getdown.data.Application;
++import com.threerings.getdown.net.Connector;
+ import com.threerings.getdown.spi.ProxyAuth;
+ import com.threerings.getdown.util.Config;
+-import com.threerings.getdown.util.ConnectionUtil;
+ import com.threerings.getdown.util.LaunchUtil;
+ import com.threerings.getdown.util.StringUtil;
+ import static com.threerings.getdown.Log.log;
+-public class ProxyUtil {
++public final class ProxyUtil {
+     public static boolean autoDetectProxy (Application app)
+     {
+@@ -57,25 +67,40 @@ public class ProxyUtil {
+                 RegistryKey r = new RegistryKey(RootKey.HKEY_CURRENT_USER, PROXY_REGISTRY);
+                 for (Iterator<?> iter = r.values(); iter.hasNext(); ) {
+                     RegistryValue value = (RegistryValue)iter.next();
+-                    if (value.getName().equals("ProxyEnable")) {
+-                        enabled = value.getStringValue().equals("1");
++                    if ("ProxyEnable".equals(value.getName())) {
++                        enabled = "1".equals(value.getStringValue());
+                     }
+                     if (value.getName().equals("ProxyServer")) {
+-                        String strval = value.getStringValue();
+-                        int cidx = strval.indexOf(":");
+-                        if (cidx != -1) {
+-                            rport = strval.substring(cidx+1);
+-                            strval = strval.substring(0, cidx);
++                        String[] hostPort = splitHostPort(value.getStringValue());
++                        rhost = hostPort[0];
++                        rport = hostPort[1];
++                    }
++                    if (value.getName().equals("AutoConfigURL")) {
++                        String acurl = value.getStringValue();
++                        Reader acjs = new InputStreamReader(new URL(acurl).openStream());
++                        // technically we should be returning all this info and trying each proxy
++                        // in succession, but that's complexity we'll leave for another day
++                        URL configURL = app.getConfigResource().getRemote();
++                        for (String proxy : findPACProxiesForURL(acjs, configURL)) {
++                            if (proxy.startsWith("PROXY ")) {
++                                String[] hostPort = splitHostPort(proxy.substring(6));
++                                rhost = hostPort[0];
++                                rport = hostPort[1];
++                                // TODO: is this valid? Does AutoConfigURL imply proxy enabled?
++                                enabled = true;
++                                break;
++                            }
+                         }
+-                        rhost = strval;
+                     }
+                 }
++
+                 if (enabled) {
+                     host = rhost;
+                     port = rport;
+                 } else {
+                     log.info("Detected no proxy settings in the registry.");
+                 }
++
+             } catch (Throwable t) {
+                 log.info("Failed to find proxy settings in Windows registry", "error", t);
+             }
+@@ -97,32 +122,31 @@ public class ProxyUtil {
+         return true;
+     }
+-    public static boolean canLoadWithoutProxy (URL rurl)
++    public static boolean canLoadWithoutProxy (URL rurl, int timeoutSeconds)
+     {
+-        log.info("Testing whether proxy is needed, via: " + rurl);
++        log.info("Attempting to fetch without proxy: " + rurl);
+         try {
+-            // try to make a HEAD request for this URL (use short connect and read timeouts)
+-            URLConnection conn = ConnectionUtil.open(Proxy.NO_PROXY, rurl, 5, 5);
+-            if (conn instanceof HttpURLConnection) {
+-                HttpURLConnection hcon = (HttpURLConnection)conn;
+-                try {
+-                    hcon.setRequestMethod("HEAD");
+-                    hcon.connect();
+-                    // make sure we got a satisfactory response code
+-                    int rcode = hcon.getResponseCode();
+-                    if (rcode == HttpURLConnection.HTTP_PROXY_AUTH ||
+-                        rcode == HttpURLConnection.HTTP_FORBIDDEN) {
+-                        log.warning("Got an 'HTTP credentials needed' response", "code", rcode);
+-                    } else {
+-                        return true;
+-                    }
+-                } finally {
+-                    hcon.disconnect();
+-                }
+-            } else {
+-                // if the appbase is not an HTTP/S URL (like file:), then we don't need a proxy
++            URLConnection conn = Connector.DEFAULT.open(rurl, timeoutSeconds, timeoutSeconds);
++            // if the appbase is not an HTTP/S URL (like file:), then we don't need a proxy
++            if (!(conn instanceof HttpURLConnection)) {
+                 return true;
+             }
++            // otherwise, try to make a HEAD request for this URL
++            HttpURLConnection hcon = (HttpURLConnection)conn;
++            try {
++                hcon.setRequestMethod("HEAD");
++                hcon.connect();
++                // make sure we got a satisfactory response code
++                int rcode = hcon.getResponseCode();
++                if (rcode == HttpURLConnection.HTTP_PROXY_AUTH ||
++                    rcode == HttpURLConnection.HTTP_FORBIDDEN) {
++                    log.warning("Got an 'HTTP credentials needed' response", "code", rcode);
++                } else {
++                    return true;
++                }
++            } finally {
++                hcon.disconnect();
++            }
+         } catch (IOException ioe) {
+             log.info("Failed to HEAD " + rurl + ": " + ioe);
+             log.info("We probably need a proxy, but auto-detection failed.");
+@@ -190,9 +214,14 @@ public class ProxyUtil {
+         }
+         boolean haveCreds = !StringUtil.isBlank(username) && !StringUtil.isBlank(password);
+-        int pport = StringUtil.isBlank(port) ? 80 : Integer.valueOf(port);
+-        log.info("Using proxy", "host", host, "port", pport, "haveCreds", haveCreds);
+-        app.proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(host, pport));
++        if (StringUtil.isBlank(host)) {
++            log.info("Using no proxy");
++            app.conn = new Connector(Proxy.NO_PROXY);
++        } else {
++            int pp = StringUtil.isBlank(port) ? 80 : Integer.valueOf(port);
++            log.info("Using proxy", "host", host, "port", pp, "haveCreds", haveCreds);
++            app.conn = new Connector(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(host, pp)));
++        }
+         if (haveCreds) {
+             final String fuser = username;
+@@ -205,6 +234,61 @@ public class ProxyUtil {
+         }
+     }
++    public static class Resolver {
++        public String dnsResolve (String host) {
++            try {
++                return InetAddress.getByName(host).getHostAddress();
++            } catch (UnknownHostException uhe) {
++                return null;
++            }
++        }
++        public String myIpAddress () {
++            try {
++                return InetAddress.getLocalHost().getHostAddress();
++            } catch (UnknownHostException uhe) {
++                return null;
++            }
++        }
++    }
++
++    public static String[] findPACProxiesForURL (Reader pac, URL url) {
++        ScriptEngineManager manager = new ScriptEngineManager();
++        ScriptEngine engine = manager.getEngineByName("javascript");
++        Bindings globals = engine.createBindings();
++        globals.put("resolver", new Resolver());
++        engine.setBindings(globals, ScriptContext.GLOBAL_SCOPE);
++        try {
++            URL utils = ProxyUtil.class.getResource("PacUtils.js");
++            if (utils == null) {
++                log.error("Unable to load PacUtils.js");
++                return new String[0];
++            }
++            engine.eval(new InputStreamReader(utils.openStream()));
++            Object res = engine.eval(pac);
++            if (engine instanceof Invocable) {
++                Object[] args = new Object[] { url.toString(), url.getHost() };
++                res = ((Invocable) engine).invokeFunction("FindProxyForURL", args);
++            }
++            String[] proxies = res.toString().split(";");
++            for (int ii = 0; ii < proxies.length; ii += 1) {
++                proxies[ii] = proxies[ii].trim();
++            }
++            return proxies;
++        } catch (Exception e) {
++            log.warning("Failed to resolve PAC proxy", e);
++        }
++        return new String[0];
++    }
++
++    private static String[] splitHostPort (String hostPort) {
++        int cidx = hostPort.indexOf(":");
++        if (cidx == -1) {
++            return new String[] { hostPort, null};
++        } else {
++            return new String[] { hostPort.substring(0, cidx), hostPort.substring(cidx+1) };
++        }
++    }
++
+     protected static final String PROXY_REGISTRY =
+         "Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings";
+ }
+diff --git a/getdown/src/getdown/launcher/src/main/java/com/threerings/getdown/launcher/RotatingBackgrounds.java b/getdown/src/getdown/launcher/src/main/java/com/threerings/getdown/launcher/RotatingBackgrounds.java
+index d3aa2bd25..d64e5f02d 100644
+--- a/getdown/src/getdown/launcher/src/main/java/com/threerings/getdown/launcher/RotatingBackgrounds.java
++++ b/getdown/src/getdown/launcher/src/main/java/com/threerings/getdown/launcher/RotatingBackgrounds.java
+@@ -14,7 +14,7 @@ public final class RotatingBackgrounds
+ {
+     public interface ImageLoader {
+         /** Loads and returns the image with the supplied path. */
+-        public Image loadImage (String path);
++        Image loadImage (String path);
+     }
+     /**
+@@ -35,7 +35,7 @@ public final class RotatingBackgrounds
+     }
+     /**
+-     * Create a sequence of images to be rotated through from <code>backgrounds</code>.
++     * Create a sequence of images to be rotated through from {@code backgrounds}.
+      *
+      * Each String in backgrounds should be the path to the image, a semicolon, and the minimum
+      * amount of time to display the image in seconds. Each image will be active for an equal
+diff --git a/getdown/src/getdown/launcher/src/main/java/com/threerings/getdown/launcher/StatusPanel.java b/getdown/src/getdown/launcher/src/main/java/com/threerings/getdown/launcher/StatusPanel.java
+index 99f44ca51..197dc9170 100644
+--- a/getdown/src/getdown/launcher/src/main/java/com/threerings/getdown/launcher/StatusPanel.java
++++ b/getdown/src/getdown/launcher/src/main/java/com/threerings/getdown/launcher/StatusPanel.java
+@@ -26,12 +26,9 @@ import com.samskivert.swing.Label;
+ import com.samskivert.swing.LabelStyleConstants;
+ import com.samskivert.swing.util.SwingUtil;
+ import com.samskivert.util.Throttle;
+-
+ import com.threerings.getdown.data.Application.UpdateInterface;
+ import com.threerings.getdown.util.MessageUtil;
+ import com.threerings.getdown.util.Rectangle;
+-import com.threerings.getdown.util.StringUtil;
+-
+ import static com.threerings.getdown.Log.log;
+ /**
+@@ -344,7 +341,7 @@ public final class StatusPanel extends JComponent
+     {
+         String msg = get(key);
+         if (msg != null) return MessageFormat.format(MessageUtil.escape(msg), (Object[])args);
+-        return key + String.valueOf(Arrays.asList(args));
++        return key + Arrays.asList(args);
+     }
+     /** Used by {@link #setStatus}, and {@link #setProgress}. */
+diff --git a/getdown/src/getdown/launcher/src/main/java/jalview/bin/StartupNotificationListener.java b/getdown/src/getdown/launcher/src/main/java/jalview/bin/StartupNotificationListener.java
+deleted file mode 100644
+index 5c6c7c393..000000000
+--- a/getdown/src/getdown/launcher/src/main/java/jalview/bin/StartupNotificationListener.java
++++ /dev/null
+@@ -1,29 +0,0 @@
+-package jalview.bin;
+-
+-import com.threerings.getdown.launcher.GetdownApp;
+-import static com.threerings.getdown.Log.log;
+-
+-public class StartupNotificationListener {
+-
+-  public static void setListener() {
+-
+-    
+-    try {
+-      com.install4j.api.launcher.StartupNotification.registerStartupListener(
+-        new com.install4j.api.launcher.StartupNotification.Listener() {
+-          @Override
+-          public void startupPerformed(String parameters) { 
+-            log.info("StartupNotification.Listener.startupPerformed: '"+parameters+"'");
+-            GetdownApp.setStartupFilesParameterString(parameters);
+-          }
+-        }
+-      );
+-    } catch (Exception e) {
+-      e.printStackTrace();
+-    } catch (NoClassDefFoundError t) {
+-      log.warning("Starting without install4j classes");
+-    }
+-
+-  }
+-
+-}
+diff --git a/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages.properties b/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages.properties
+index 19b2999e1..7a33ca0e4 100644
+--- a/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages.properties
++++ b/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages.properties
+@@ -12,11 +12,14 @@ m.abort_cancel = Continue installation
+ m.detecting_proxy = Trying to auto-detect proxy settings
+ m.configure_proxy = <html>We were unable to connect to the application server to download data. \
+-  <p> Please make sure that no virus scanner or firewall is blocking network communicaton with \
++  <p> Please make sure that no virus scanner or firewall is blocking network communication with \
+   the server. \
+   <p> Your computer may access the Internet through a proxy and we were unable to automatically \
+   detect your proxy settings. If you know your proxy settings, you can enter them below.</html>
++m.update_proxy_auth = <html>The stored proxy user/password is wrong or obsolete. \
++  <p>Please provide an updated user/password combination.</html>
++
+ m.proxy_extra = <html>If you are sure that you don't use a proxy then \
+   perhaps there is a temporary Internet outage that is preventing us from \
+   communicating with the servers. In this case, you can cancel and try \
+@@ -41,10 +44,7 @@ m.checking = Checking for update
+ m.validating = Validating
+ m.patching = Patching
+ m.launching = Launching
+-
+ m.patch_notes = Patch Notes
+-m.play_again = Play Again
+-
+ m.complete = {0}% complete
+ m.remain = {0} remaining
+@@ -99,7 +99,7 @@ m.default_install_error = the support section of the website
+ m.another_getdown_running = Multiple instances of this application's \
+  installer are running. This one will stop and let another complete.
+-m.applet_stopped = Getdown's applet was told to stop working.
++m.verify_timeout = Verifying resources took too long.
+ # application/digest errors
+ m.missing_appbase = The configuration file is missing the 'appbase'.
+diff --git a/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages_de.properties b/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages_de.properties
+index 8e3683594..db35593b2 100644
+--- a/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages_de.properties
++++ b/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages_de.properties
+@@ -1,13 +1,9 @@
+ #
+-# $Id$
+-#
+-# Getdown translation messages
++# Getdown German translation messages
+ m.abort_title = Installation abbrechen?
+-m.abort_confirm = <html>Bist du sicher, dass du die Installation abbrechen \
+-m\u00f6chtest? \
+-  Du kannst sp\u00e4ter fortfahren, indem du die Anwendung erneut \
+-ausf\u00fchrst.</html>
++m.abort_confirm = <html>Bist du sicher, dass du die Installation abbrechen möchtest? \
++  Du kannst später fortfahren, indem du die Anwendung erneut ausführst.</html>
+ m.abort_ok = Beenden
+ m.abort_cancel = Installation fortsetzen
+@@ -17,9 +13,12 @@ m.configure_proxy = <html>Es konnte keine Verbindung zum Applikations-Server auf
+   <p>Bitte kontrollieren Sie die Proxyeinstellungen und stellen Sie sicher, dass keine lokal oder \
+   im Netzwerk betriebene Sicherheitsanwendung (Virenscanner, Firewall, etc.) die Kommunikation \
+   mit dem Server blockiert.<br> \
+-  Wenn kein Proxy verwendet werden soll, l\u00f6schen Sie bitte alle Eintr\u00e4ge in den unten \
++  Wenn kein Proxy verwendet werden soll, löschen Sie bitte alle Einträge in den unten \
+   stehenden Feldern und klicken sie auf OK.</html>
++m.update_proxy_auth = <html>Gespeicherte Proxy User/Passwort Kombination ungültig oder obsolet. \
++  <p>Bitte geben Sie die korrekte User/Passwort Kombination ein.</html>
++
+ m.proxy_extra = <html>Sollten Sie keine Proxyeinstellungen gesetzt haben wenden Sie sich bitte \
+   an Ihren Administrator.</html>
+@@ -46,71 +45,68 @@ m.launching = Starte
+ m.patch_notes = Patchnotes
+ m.complete = {0}% abgeschlossen
+-m.remain = {0} \u00fcbrig
++m.remain = {0} übrig
+ m.updating_metadata = Lade Steuerungsdateien herunter
+-m.init_failed = Unsere Konfigurationsdatei fehlt oder ist besch\u00e4digt. \
+-Versuche, eine neue Kopie herunterzuladen...
++m.init_failed = Unsere Konfigurationsdatei fehlt oder ist beschädigt. \
++  Versuche, eine neue Kopie herunterzuladen...
+-m.java_download_failed = Wir konnten die notwendige Javaversion f\u00fcr deinen \
+-Computer nicht automatisch herunterladen. \n\n \
+-Bitte auf www.java.com die aktuelle Javaversion herunterladen und dann die \
+-Anwendung erneut starten.
++m.java_download_failed = Wir konnten die notwendige Javaversion für deinen \
++  Computer nicht automatisch herunterladen. \n\n \
++  Bitte auf www.java.com die aktuelle Javaversion herunterladen und dann die \
++  Anwendung erneut starten.
+ m.java_unpack_failed = Wir konnten die aktualisierte Javaversion nicht \
+-entpacken. Bitte stelle sicher, dass wenigstens 100MB Platz auf der \
+-Festplatte frei sind und versuche dann die Anwendung erneut zu \
+-starten.\n\n\ \
+-Falls das das Problem nicht beseitigt, bitte auf www.java.com die aktuelle \
+-Javaversion herunterladen und installieren und dann erneut versuchen.
++  entpacken. Bitte stelle sicher, dass wenigstens 100MB Platz auf der \
++  Festplatte frei sind und versuche dann die Anwendung erneut zu \
++  starten.\n\n\ \
++  Falls das das Problem nicht beseitigt, bitte auf www.java.com die aktuelle \
++  Javaversion herunterladen und installieren und dann erneut versuchen.
+ m.unable_to_repair = Wir konnten die notwendigen Dateien nach 5 Versuchen \
+-nicht herunterladen. Du kannst versuchen, die Anwendung erneut zu starten, \
+-aber wenn dies erneut fehlschl\u00e4gt, musst du die Anwendung deinstallieren \
+-und erneut installieren.
++  nicht herunterladen. Du kannst versuchen, die Anwendung erneut zu starten, \
++  aber wenn dies erneut fehlschlägt, musst du die Anwendung deinstallieren \
++  und erneut installieren.
+ m.unknown_error = Die Anwendung konnte wegen eines unbekannten Fehlers \
+-nicht gestartet werden. Bitte auf \n{0} weiterlesen.
++  nicht gestartet werden. Bitte auf \n{0} weiterlesen.
+ m.init_error = Die Anwendung konnte wegen folgendem Fehler nicht gestartet \
+-werden:\n{0}\n\n Bitte auf \n{1} weiterlesen, um zu erfahren, wie bei \
+-solchen Problemen vorzugehen ist.
++  werden:\n{0}\n\n Bitte auf \n{1} weiterlesen, um zu erfahren, wie bei \
++  solchen Problemen vorzugehen ist.
+-m.readonly_error = Das Verzeichnis, in dem die Anwendung installiert ist: \
+- \n{0}\nist nicht schreibberechtigt. Bitte in ein Verzeichnis mit \
+-Schreibzugriff installieren.
++m.readonly_error = Das Verzeichnis, in dem die Anwendung installiert ist:\n{0}\n \
++  ist nicht schreibberechtigt. Bitte in ein Verzeichnis mit Schreibzugriff installieren.
+ m.missing_resource = Die Anwendung konnte nicht gestartet werden, da die \
+-folgende Quelle nicht gefunden wurde:\n{0}\n\n\ Bitte auf \n{1} \
+-weiterlesen, um zu erfahren, wie bei solchen Problemen vorzugehen ist.
++  folgende Quelle nicht gefunden wurde:\n{0}\n\n\ Bitte auf \n{1} \
++  weiterlesen, um zu erfahren, wie bei solchen Problemen vorzugehen ist.
+ m.insufficient_permissions_error = Du hast die digitale Signatur dieser \
+-Anwendung nicht akzeptiert. Falls du diese Anwendung benutzen willst, \
+-musst du ihre digitale Signatur akzeptieren. \n\Um das zu tun, musst du \
+-deinen Browser beenden, neu starten und erneut die Anwendung von dieser \
+-Webseite aus starten. Wenn die Sicherheitsabfrage erscheint, bitte die \
+-digitale Signatur akzeptieren, um der Anwendung die n\u00f6tigen Rechte zu \
+-geben, die sie braucht, um zu laufen.
++  Anwendung nicht akzeptiert. Falls du diese Anwendung benutzen willst, \
++  musst du ihre digitale Signatur akzeptieren. \n\Um das zu tun, musst du \
++  deinen Browser beenden, neu starten und erneut die Anwendung von dieser \
++  Webseite aus starten. Wenn die Sicherheitsabfrage erscheint, bitte die \
++  digitale Signatur akzeptieren, um der Anwendung die nötigen Rechte zu \
++  geben, die sie braucht, um zu laufen.
+ m.corrupt_digest_signature_error = Wir konnten die digitale Signatur \
+-dieser Anwendung nicht \u00fcberpr\u00fcfen.\nBitte \u00fcberpr\u00fcfe, ob du die Anwendung \
+-von der richtigen Webseite aus startest.
++  dieser Anwendung nicht überprüfen.\nBitte überprüfe, ob du die Anwendung \
++  von der richtigen Webseite aus startest.
+ m.default_install_error = der Support-Webseite
+-m.another_getdown_running = Diese Installationsanwendung l\u00e4uft in mehreren \
+-Instanzen. Diese Instanz wird sich beenden und eine andere Instanz den \
+-Vorgang erledigen lassen.
+-
+-m.applet_stopped = Die Anwendung wurde beendet.
++m.another_getdown_running = Diese Installationsanwendung läuft in mehreren \
++  Instanzen. Diese Instanz wird sich beenden und eine andere Instanz den \
++  Vorgang erledigen lassen.
++m.verify_timeout = Timeout beim Verifizieren der Resourcen.
+ # application/digest errors
+ m.missing_appbase = In der Konfigurationsdatei fehlt die 'appbase'.
+ m.invalid_version = In der Konfigurationsdatei steht die falsche Version.
+ m.invalid_appbase = In der Konfigurationsdatei steht die falsche 'appbase'.
+ m.missing_class = In der Konfigurationsdatei fehlt die Anwendungsklasse.
+-m.missing_code = Die Konfigurationsdatei enth\u00e4lt keine Codequellen.
+-m.invalid_digest_file = Die Hashwertedatei ist ung\u00fcltig.
+-
++m.missing_code = Die Konfigurationsdatei enthält keine Codequellen.
++m.invalid_digest_file = Die Hashwertedatei ist ungültig.
+diff --git a/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages_es.properties b/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages_es.properties
+index 609b02524..46cd64ac9 100644
+--- a/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages_es.properties
++++ b/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages_es.properties
+@@ -1,36 +1,34 @@
+ #
+-# $Id$
+-#
+-# Getdown translation messages
++# Getdown Spanish translation messages
+-m.abort_title = \u00bfCancelar la instalaci\u00f3n?
+-m.abort_confirm = <html>\u00bfEst\u00e1s seguro de querer cancelar la instalaci\u00f3n? \
+-  Puedes continuarla despu\u00e9s si corres de nuevo la aplicaci\u00f3n.</html>
++m.abort_title = ¿Cancelar la instalación?
++m.abort_confirm = <html>¿Estás seguro de querer cancelar la instalación? \
++  Puedes continuarla después si corres de nuevo la aplicación.</html>
+ m.abort_ok = Cancelar
+-m.abort_cancel = Continuar la instalaci\u00f3n
++m.abort_cancel = Continuar la instalación
+-m.detecting_proxy = Detectando autom\u00e1ticamente la configuraci\u00f3n proxy
++m.detecting_proxy = Detectando automáticamente la configuración proxy
+ m.configure_proxy = <html>No ha sido posible conectar con nuestros servidores para \
+   descargar los datos del juego. \
+   <ul><li> Si el cortafuegos de Windows o Norton Internet Security tiene instrucciones \
+-  de bloquear <code>javaw.exe</code> no podemos descargar el juego. Necesitar\u00e1s \
++  de bloquear <code>javaw.exe</code> no podemos descargar el juego. Necesitarás \
+   permitir que <code>javaw.exe</code> tenga acceso al Internet. Puedes intentar \
+   correr el juego de nuevo, pero es posible que debas dar permisos a javaw.exe en la \
+-  configuraci\u00f3n de tu cortafuegos ( Inicio -> Panel de control -> Firewall de Windows ).</ul> \
++  configuración de tu cortafuegos ( Inicio -> Panel de control -> Firewall de Windows ).</ul> \
+   <p> Es posible que tu computadora tenga acceso al Internet por medio de un proxy por lo que \
+-  no ha sido posible detectar autom\u00e1ticamente tu configuraci\u00f3n. Si conoces tu \
+-  configuraci\u00f3n proxy, puedes anotarla abajo.</html>
++  no ha sido posible detectar automáticamente tu configuración. Si conoces tu \
++  configuración proxy, puedes anotarla abajo.</html>
+-m.proxy_extra = <html>Si est\u00e1s seguro de que no tienes un proxy entonces \
+-  tal vez exista un falla temporal en el Internet que est\u00e1 evitando que podamos \
++m.proxy_extra = <html>Si estás seguro de que no tienes un proxy entonces \
++  tal vez exista un falla temporal en el Internet que está evitando que podamos \
+   comunicarnos con los servidores. En este caso, puedes cancelar e intentar \
+-  instalarla de nuevo m\u00e1s tarde.</html>
++  instalarla de nuevo más tarde.</html>
+ m.proxy_host = IP proxy
+ m.proxy_port = Puerto proxy
+ m.proxy_username = Nombre de usuario
+-m.proxy_password = Contrase\u00f1a
++m.proxy_password = Contraseña
+ m.proxy_auth_required = Autenticacion requerida
+ m.proxy_ok = OK
+ m.proxy_cancel = Cancelar
+@@ -54,62 +52,59 @@ m.remain = {0} restante
+ m.updating_metadata = Descargando los archivos de control
+-m.init_failed = Un archivo de configuraci\u00f3n est\u00e1 faltante o est\u00e1 corrupto. Intentando \
++m.init_failed = Un archivo de configuración está faltante o está corrupto. Intentando \
+   descargar una nueva copia...
+-m.java_download_failed = No ha sido posible descargar autom\u00e1ticamente la \
+-  versi\u00f3n de Java necesaria para tu computadora.\n\n\
+-  Por favor ve a www.java.com y descarga la \u00faltima versi\u00f3n de \
+-  Java, despu\u00e9s intenta correr de nuevo la aplicaci\u00f3n.
++m.java_download_failed = No ha sido posible descargar automáticamente la \
++  versión de Java necesaria para tu computadora.\n\n\
++  Por favor ve a www.java.com y descarga la última versión de \
++  Java, después intenta correr de nuevo la aplicación.
+-m.java_unpack_failed = No ha sido posible desempacar una versi\u00f3n actualizada de \
+-  Java. Por favor aseg\u00farate de tener al menos 100 MB de espacio libre en tu \
+-  disco duro e intenta correr de nuevo la aplicaci\u00f3n.\n\n\
++m.java_unpack_failed = No ha sido posible desempacar una versión actualizada de \
++  Java. Por favor asegúrate de tener al menos 100 MB de espacio libre en tu \
++  disco duro e intenta correr de nuevo la aplicación.\n\n\
+   Si eso no soluciona el problema, ve a www.java.com y descarga e \
+-  instala la \u00faltima versi\u00f3n de Java e intenta de nuevo.
++  instala la última versión de Java e intenta de nuevo.
+-m.unable_to_repair = No ha sido posible descargar los archivos necesarios despu\u00e9s de \
+-  cinco intentos. Puedes intentar correr de nuevo la aplicaci\u00f3n, pero si falla \
+-  de nuevo podr\u00edas necesitar desinstalar y reinstalar.
++m.unable_to_repair = No ha sido posible descargar los archivos necesarios después de \
++  cinco intentos. Puedes intentar correr de nuevo la aplicación, pero si falla \
++  de nuevo podrías necesitar desinstalar y reinstalar.
+-m.unknown_error = La aplicaci\u00f3n no ha podido iniciar debido a un extra\u00f1o \
+-  error del que no se pudo recobrar. Por favor visita\n{0} para ver informaci\u00f3n acerca \
++m.unknown_error = La aplicación no ha podido iniciar debido a un extraño \
++  error del que no se pudo recobrar. Por favor visita\n{0} para ver información acerca \
+   de como recuperarla.
+-m.init_error = La aplicaci\u00f3n no ha podido iniciar debido al siguiente \
++m.init_error = La aplicación no ha podido iniciar debido al siguiente \
+   error:\n{0}\n\nPor favor visita\n{1} para \
+-  ver informaci\u00f3n acerca de como manejar ese tipo de problemas.
++  ver información acerca de como manejar ese tipo de problemas.
+-m.readonly_error = El directorio en el que esta aplicaci\u00f3n est\u00e1 instalada: \
+-  \n{0}\nes solo lectura. Por favor instala la aplicaci\u00f3n en un directorio en el cual \
++m.readonly_error = El directorio en el que esta aplicación está instalada: \
++  \n{0}\nes solo lectura. Por favor instala la aplicación en un directorio en el cual \
+   tengas acceso de escritura.
+-m.missing_resource = La aplicaci\u00f3n no ha podido iniciar debido a un recurso \
+-  faltante:\n{0}\n\nPor favor visita\n{1} para informaci\u00f3n acerca de como solucionar \
++m.missing_resource = La aplicación no ha podido iniciar debido a un recurso \
++  faltante:\n{0}\n\nPor favor visita\n{1} para información acerca de como solucionar \
+   estos problemas.
+ m.insufficient_permissions_error = No aceptaste la firma digital de \
+- esta aplicaci\u00f3n. Si quieres correr la aplicaci\u00f3n, necesitas aceptar \
++ esta aplicación. Si quieres correr la aplicación, necesitas aceptar \
+  su firma digital.\n\nPara hacerlo, necesitas cerrar tu navegador, \
+- reiniciarlo, y regresar a esta p\u00e1gina web para reiniciar la aplicaci\u00f3n. Cuando se muestre \
+- el di\u00e1logo de seguridad, haz clic en el bot\u00f3n para aceptar la firmar digital \
+- y otorgar a esta aplicaci\u00f3n los privilegios que necesita para correr.
++ reiniciarlo, y regresar a esta página web para reiniciar la aplicación. Cuando se muestre \
++ el diálogo de seguridad, haz clic en el botón para aceptar la firmar digital \
++ y otorgar a esta aplicación los privilegios que necesita para correr.
+ m.corrupt_digest_signature_error = No pudimos verificar la firma digital \
+- de la aplicaci\u00f3n.\nPor favor revisa que est\u00e9s lanzando la aplicaci\u00f3n desde\nel \
++ de la aplicación.\nPor favor revisa que estés lanzando la aplicación desde\nel \
+  sitio web correcto.
+-m.default_install_error = la secci\u00f3n de asistencia de este sitio web
+-
+-m.another_getdown_running = Est\u00e1n corriendo m\u00faltiples instancias de \
+- este instalador.  Este se detendr\u00e1 para permitir que otra contin\u00fae.
++m.default_install_error = la sección de asistencia de este sitio web
+-m.applet_stopped = Se le dijo al applet de Getdown que dejara de trabajar.
++m.another_getdown_running = Están corriendo múltiples instancias de \
++ este instalador.  Este se detendrá para permitir que otra continúe.
+ # application/digest errors
+-m.missing_appbase = Al archivo de configuraci\u00f3n le falta el 'appbase'.
+-m.invalid_version = El archivo de configuraci\u00f3n especifica una versi\u00f3n no v\u00e1lida.
+-m.invalid_appbase = El archivo de configuraci\u00f3n especifica un 'appbase' no v\u00e1lido.
+-m.missing_class = Al archivo de configuraci\u00f3n le falta la clase de aplicaci\u00f3n.
+-m.missing_code = El archivo de configuraci\u00f3n especifica que no hay recursos de c\u00f3digo.
+-m.invalid_digest_file = El archivo digest no es v\u00e1lido.
+-
++m.missing_appbase = Al archivo de configuración le falta el 'appbase'.
++m.invalid_version = El archivo de configuración especifica una versión no válida.
++m.invalid_appbase = El archivo de configuración especifica un 'appbase' no válido.
++m.missing_class = Al archivo de configuración le falta la clase de aplicación.
++m.missing_code = El archivo de configuración especifica que no hay recursos de código.
++m.invalid_digest_file = El archivo digest no es válido.
+diff --git a/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages_fr.properties b/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages_fr.properties
+index 3666204e2..5eb8ec9cd 100644
+--- a/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages_fr.properties
++++ b/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages_fr.properties
+@@ -1,29 +1,27 @@
+ #
+-# $Id: messages.properties 485 2012-03-08 22:05:30Z ray.j.greenwell $
+-#
+-# Getdown translation messages
++# Getdown French translation messages
+ m.abort_title = Annuler l'installation?
+-m.abort_confirm =<html>\u00cates-vous s\u00fbr de vouloir annuler l'installation? \
+-   Vous pourrez reprendre l'installation en ex\u00e9cutant l'application de nouveau.</html>
++m.abort_confirm =<html>Êtes-vous sûr de vouloir annuler l'installation? \
++   Vous pourrez reprendre l'installation en exécutant l'application de nouveau.</html>
+ m.abort_ok = Quitter
+ m.abort_cancel = Continuer l'installation
+-m.detecting_proxy = D\u00e9tection automatique des r\u00e9glages proxy
++m.detecting_proxy = Détection automatique des réglages proxy
+ m.configure_proxy =<html>Connexion au serveur impossible. \
+-   <ul><li>  Veuillez v\u00e9rifier que <code>javaw.exe</code> n'est bloqu\u00e9 \
++   <ul><li>  Veuillez vérifier que <code>javaw.exe</code> n'est bloqué \
+    par aucun pare-feu ou antivirus. \
+    Vous pouvez vous rendre sur la configuration du pare-feu windows via \
+-   (D\u00e9marrer ->  Panneau de Configuration ->  Pare-feu Windows ).</ul> \
+-   <p>  Il est \u00e9galement possible que vous soyez derri\u00e8re un proxy que l'application \
+-   est incapable de d\u00e9tecter automatiquement. \
+-   Si tel est le cas, veuillez saisir les r\u00e9glages proxy ci-dessous.</html>
++   (Démarrer ->  Panneau de Configuration ->  Pare-feu Windows ).</ul> \
++   <p>  Il est également possible que vous soyez derrière un proxy que l'application \
++   est incapable de détecter automatiquement. \
++   Si tel est le cas, veuillez saisir les réglages proxy ci-dessous.</html>
+-m.proxy_extra =<html>Si vous \u00eates certain de ne pas utiliser de proxy, il est \
+-   possible qu'une interruption temporaire de la connexion internet emp\u00fbche la \
++m.proxy_extra =<html>Si vous êtes certain de ne pas utiliser de proxy, il est \
++   possible qu'une interruption temporaire de la connexion internet empûche la \
+    communication avec les serveurs. Dans ce cas, vous pouvez relancer \
+-   l'installation ult\u00e9rieurement.</html>
++   l'installation ultérieurement.</html>
+ m.proxy_host = Proxy IP
+ m.proxy_port = Proxy port
+@@ -33,79 +31,77 @@ m.proxy_auth_required = Identification requise
+ m.proxy_ok = OK
+ m.proxy_cancel = Annuler
+-m.downloading_java = T\u00e9l\u00e9chargement en cours de la Machine Virtuelle Java
+-m.unpacking_java = D\u00e9compression en cours de la Machine Virtuelle Java
++m.downloading_java = Téléchargement en cours de la Machine Virtuelle Java
++m.unpacking_java = Décompression en cours de la Machine Virtuelle Java
+-m.resolving = R\u00e9solution des t\u00e9l\u00e9chargements en cours
+-m.downloading = T\u00e9l\u00e9chargement des donn\u00e9es en cours
+-m.failure = \u00c9chec du t\u00e9l\u00e9chargement: {0}
++m.resolving = Résolution des téléchargements en cours
++m.downloading = Téléchargement des données en cours
++m.failure = Échec du téléchargement: {0}
+-m.checking = V\u00e9rification de la mise-\u00e0-jour en cours
++m.checking = Vérification de la mise-à-jour en cours
+ m.validating = Validation en cours
+ m.patching = Modification en cours
+ m.launching = Lancement en cours
+-m.patch_notes = Notes de mise-\u00e0-jour
++m.patch_notes = Notes de mise-à-jour
+-m.complete = Complet \u00e0 {0}%
++m.complete = Complet à {0}%
+ m.remain = {0} restant
+-m.updating_metadata = T\u00e9l\u00e9chargement des fichiers de contr\u00f4les en cours
++m.updating_metadata = Téléchargement des fichiers de contrôles en cours
+-m.init_failed = Notre fichier de configuration est perdu ou corrompu. T\u00e9l\u00e9chargement \
++m.init_failed = Notre fichier de configuration est perdu ou corrompu. Téléchargement \
+    d'une nouvelle copie en cours ...
+-m.java_download_failed = Impossible de t\u00e9l\u00e9charger automatiquement la \
+-   version de Java n\u00e9cessaire.\n\n\
+-   Veuillez vous rendre sur www.java.com et t\u00e9l\u00e9charger et installer la version \
+-   la plus r\u00e9cente de Java, avant d'ex\u00e9cuter l'application \u00e0 nouveau.
++m.java_download_failed = Impossible de télécharger automatiquement la \
++   version de Java nécessaire.\n\n\
++   Veuillez vous rendre sur www.java.com et télécharger et installer la version \
++   la plus récente de Java, avant d'exécuter l'application à nouveau.
+-m.java_unpack_failed = Impossible de d\u00e9compresser la version de \
+-   Java n\u00e9cessaire. Veuillez v\u00e9rifier que vous avez au moins 100 MB d'espace libre \
+-   sur votre disque dur puis tenter d'ex\u00e9cuter l'application \u00e0 nouveau.\n\n\
+-   Si le probl\u00e8me persiste, rendez vous www.java.com et t\u00e9l\u00e9chargez et \
+-   installez la version plus r\u00e9cente de Java puis essayez de nouveau.
++m.java_unpack_failed = Impossible de décompresser la version de \
++   Java nécessaire. Veuillez vérifier que vous avez au moins 100 MB d'espace libre \
++   sur votre disque dur puis tenter d'exécuter l'application à nouveau.\n\n\
++   Si le problème persiste, rendez vous www.java.com et téléchargez et \
++   installez la version plus récente de Java puis essayez de nouveau.
+-m.unable_to_repair = Impossible de t\u00e9l\u00e9charger les fichiers n\u00e9cessaires apr\u00e8s \
+-   cinq tentatives. Vous pouvez tenter d'ex\u00e9cuter l'application \u00e0 nouveau, mais il est \
+-   possible qu'une d\u00e9sinstallation / r\u00e9installation soit n\u00e9cessaire.
++m.unable_to_repair = Impossible de télécharger les fichiers nécessaires après \
++   cinq tentatives. Vous pouvez tenter d'exécuter l'application à nouveau, mais il est \
++   possible qu'une désinstallation / réinstallation soit nécessaire.
+-m.unknown_error = Une erreur inconnue a fait \u00e9chouer le lancement de l'application. \
++m.unknown_error = Une erreur inconnue a fait échouer le lancement de l'application. \
+    Veuillez visiter\n{0} pour plus d'informations.
+-m.init_error = Le lancement de l'application a \u00e9chou\u00e9 \u00e0 cause de l'erreur \
++m.init_error = Le lancement de l'application a échoué à cause de l'erreur \
+    suivante:\n{0}\n\nVeuillez visiter\n{1} pour plus d'informations.
+-m.readonly_error = Le r\u00e9pertoire d'installation de cette application: \
+-   \n{0}\nest en lecture seule. Veuillez installer l'application dans un r\u00e9pertoire avec \
+-   un acc\u00e8s en \u00e9criture.
++m.readonly_error = Le répertoire d'installation de cette application: \
++   \n{0}\nest en lecture seule. Veuillez installer l'application dans un répertoire avec \
++   un accès en écriture.
+-m.missing_resource = Le lancement de l'application a \u00e9chou\u00e9 \u00e0 cause d'une \
++m.missing_resource = Le lancement de l'application a échoué à cause d'une \
+    ressource manquante:\n{0}\n\nVeuillez visiter\n{1} pour plus d'informations.
+ m.insufficient_permissions_error = Vous n'avez pas accepter la signature \
+-  num\u00e9rique de cette application. Si vous souhaitez ex\u00e9cuter cette application, vous \
+-  devez accepter sa signature num\u00e9rique.\n\nAfin de le faire, vous devez quitter votre \
+-  navigateur, le red\u00e9marrer, retourner \u00e0 cette page puis relancer l'application. \
+-  Une fois la bo\u00eete de dialogue de s\u00e9curit\u00e9 affich\u00e9e, cliquez sur le bouton \
+-  pour accepter la signature num\u00e9rique et accorder les permissions n\u00e9cessaires au bon \
++  numérique de cette application. Si vous souhaitez exécuter cette application, vous \
++  devez accepter sa signature numérique.\n\nAfin de le faire, vous devez quitter votre \
++  navigateur, le redémarrer, retourner à cette page puis relancer l'application. \
++  Une fois la boîte de dialogue de sécurité affichée, cliquez sur le bouton \
++  pour accepter la signature numérique et accorder les permissions nécessaires au bon \
+   fonctionnement de l'application.
+-m.corrupt_digest_signature_error = Nous ne pouvons pas v\u00e9rifier la signature num\u00e9rique \
+-  de l'application.\nVeuillez v\u00e9rifier que vous lancez l'application \ndepuis \
++m.corrupt_digest_signature_error = Nous ne pouvons pas vérifier la signature numérique \
++  de l'application.\nVeuillez vérifier que vous lancez l'application \ndepuis \
+   la bonne adresse internet.
+ m.default_install_error = la section de support du site
+ m.another_getdown_running = Plusieurs instances d'installation de cette \
+-  application sont d\u00e9j\u00e0 en cours d'ex\u00e9cution.  Cette instance va s'arr\u00eater \
++  application sont déjà en cours d'exécution.  Cette instance va s'arrêter \
+   afin de permettre aux autres d'aboutir.
+-m.applet_stopped = L'appelet Getdown a \u00e9t\u00e9 stopp\u00e9e.
+-
+ # application/digest errors
+ m.missing_appbase = Le fichier de configuration ne contient pas 'appbase'.
+-m.invalid_version = Le fichier de configuration sp\u00e9cifie une version invalide.
+-m.invalid_appbase = Le fichier de configuration sp\u00e9cifie un 'appbase' invalide.
++m.invalid_version = Le fichier de configuration spécifie une version invalide.
++m.invalid_appbase = Le fichier de configuration spécifie un 'appbase' invalide.
+ m.missing_class = Le fichier de configuration ne contient pas la classe de l'application.
+-m.missing_code = Le fichier de configuration ne sp\u00e9cifie aucune ressource de code.
++m.missing_code = Le fichier de configuration ne spécifie aucune ressource de code.
+ m.invalid_digest_file = Le fichier digest est invalide.
+diff --git a/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages_it.properties b/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages_it.properties
+index 33b3260ce..aea9e9017 100644
+--- a/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages_it.properties
++++ b/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages_it.properties
+@@ -1,7 +1,5 @@
+ #
+-# $Id$
+-#
+-# Getdown translation messages
++# Getdown Italian translation messages
+ m.abort_title = Annullare l'installazione?
+ m.abort_confirm = <html>Sei sicuro di voler annullare l'installazione? \
+@@ -22,7 +20,11 @@ m.configure_proxy = <html>Impossibile collegarsi al server per \
+   questo potrebbe non essere stato riconosciuto automaticamente. Se conosci le \
+   tue impostazioni del proxy, puoi inserirle di seguito.</html>
+-m.proxy_extra = <html>Se sei sicuro di non usare proxy  \
++m.update_proxy_auth = <html>Combinazione User/Password salvata per il proxy non è valida \
++  oppure obsoleta. \
++  <p>Perfavore inserire una combinazione User/Password valida.</html>
++
++m.proxy_extra = <html>Se sei sicuro di non usare proxy \
+   potrebbe essere un problema di internet o di collegamento con il server. \
+   In questo caso puoi annullare e ripetere l'installazione più tardi.</html>
+@@ -47,8 +49,6 @@ m.patching = Applico le patch
+ m.launching = Avvio
+ m.patch_notes = Note delle Patch
+-m.play_again = Avvia Nuovamente
+-
+ m.complete = {0}% completato
+ m.remain = {0} rimasto
+@@ -103,8 +103,6 @@ m.default_install_error = la sezione di supporto del sito
+ m.another_getdown_running = E' già in esecuzione un'istanza del programma. \
+  Questa verrà chiusa.
+-m.applet_stopped = L'applet di Getdown è stata interrotta.
+-
+ # application/digest errors
+ m.missing_appbase = Il tag "appbase" è mancante.
+ m.invalid_version = Il file di configurazione non contiene una versione valida (tag "version").
+diff --git a/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages_ja.properties b/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages_ja.properties
+index c344c16e0..f3538d0ac 100644
+--- a/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages_ja.properties
++++ b/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages_ja.properties
+@@ -1,107 +1,105 @@
+ #
+-# $Id$
+-#
+-# Getdown translation messages
+-
+-m.abort_title = \u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u3092\u4e2d\u6b62\u3057\u307e\u3059\u304b\uff1f 
+-m.abort_confirm = <html>\u672c\u5f53\u306b\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u3092\u4e2d\u6b62\u3057\u307e\u3059\u304b\uff1f  \
+-  \u5f8c\u3067\u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3\u3092\u8d77\u52d5\u3057\u305f\u969b\u306b\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u3092\u518d\u958b\u3067\u304d\u307e\u3059\u3002</html> 
+-m.abort_ok = \u4e2d\u6b62 
+-m.abort_cancel = \u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u306e\u7d9a\u884c 
+-
+-m.detecting_proxy = \u81ea\u52d5\u30d7\u30ed\u30ad\u30b7\u8a2d\u5b9a\u5b9f\u884c\u4e2d
+-
+-m.configure_proxy = <html>\u30b5\u30fc\u30d0\u306b\u63a5\u7d9a\u3067\u304d\u306a\u3044\u305f\u3081\u3001\u30b2\u30fc\u30e0\u306e\u30c0\u30a6\u30f3\u30ed\u30fc\u30c9\u306b \
+-  \u5931\u6557\u3057\u307e\u3057\u305f\u3002  \
+-  <ul><li>\u30a6\u30a3\u30f3\u30c9\u30a6\u30ba\u30d5\u30a1\u30a4\u30a2\u30a6\u30a9\u30fc\u30eb\u307e\u305f\u306f\u30ce\u30fc\u30c8\u30f3\u30a4\u30f3\u30bf\u30fc\u30cd\u30c3\u30c8\u30bb\u30ad\u30e5\u30ea\u30c6\u30a3\u304c \
+-  <code>javaw.exe</code>\u3092\u30d6\u30ed\u30c3\u30af\u3059\u308b\u3088\u3046\u8a2d\u5b9a\u3057\u3066\u3042\u308b\u5834\u5408\u306f\u3001\u30b2\u30fc\u30e0\u3092\u30c0\u30a6\u30f3\u30ed\u30fc\u30c9\u3067\u304d\u307e\u305b\u3093\u3002  \u8a2d\u5b9a\u3092 \
+-  <code>javaw.exe</code>\u7d4c\u7531\u3067\u30a4\u30f3\u30bf\u30fc\u30cd\u30c3\u30c8\u306b\u30a2\u30af\u30bb\u30b9\u3067\u304d\u308b\u3088\u3046\u306b\u5909\u66f4\u3057\u3066\u304f\u3060\u3055\u3044\u3002  \u30b2\u30fc\u30e0\u3092\u518d\u8d77\u52d5 \
+-  \u3057\u305f\u5f8c\u3001\u30d5\u30a1\u30a4\u30a2\u30a6\u30a9\u30fc\u30eb\u306e\u8a2d\u5b9a\u304b\u3089javaw.exe \u3092\u524a\u9664 \
+-  \u3057\u3066\u304f\u3060\u3055\u3044\uff08\u30b9\u30bf\u30fc\u30c8\u2192\u30b3\u30f3\u30c8\u30ed\u30fc\u30eb\u30d1\u30cd\u30eb\u2192\u30d5\u30a1\u30a4\u30a2\u30a6\u30a9\u30fc\u30eb\uff09\u3002</ul> \
+-  <p>\u30d7\u30ed\u30ad\u30b7\u8a2d\u5b9a\u306e\u81ea\u52d5\u691c\u51fa\u304c\u3067\u304d\u307e\u305b\u3093\u3002\u304a\u4f7f\u3044\u306e\u30b3\u30f3\u30d4\u30e5\u30fc\u30bf\u30fc\u306f \
+-  \u30d7\u30ed\u30ad\u30b7\u3092\u4f7f\u7528\u3057\u3066\u30a4\u30f3\u30bf\u30fc\u30cd\u30c3\u30c8\u3078\u30a2\u30af\u30bb\u30b9\u3057\u3066\u3044\u307e\u3059\u3002  \u30d7\u30ed\u30ad\u30b7\u8a2d\u5b9a\u306e\u8a73\u7d30\u304c \
+-  \u308f\u304b\u3063\u3066\u3044\u308b\u5834\u5408\u306f\u3001\u4e0b\u306b\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002</html> 
+-
+-m.proxy_extra = <html>\u30d7\u30ed\u30ad\u30b7\u3092\u4f7f\u7528\u3057\u3066\u3044\u306a\u3044\u5834\u5408\u306f\u3001\u4e00\u6642\u7684\u306a \
+-  \u30a4\u30f3\u30bf\u30fc\u30cd\u30c3\u30c8\u306e\u4e0d\u5177\u5408\u306b\u3088\u308a\u3001\u30b5\u30fc\u30d0\u3068\u4ea4\u4fe1\u3067\u304d\u306a\u3044\u72b6\u614b\u306b\u3042\u308b \
+-  \u53ef\u80fd\u6027\u304c\u3042\u308a\u307e\u3059\u3002  \u305d\u306e\u5834\u5408\u306f\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u3092\u30ad\u30e3\u30f3\u30bb\u30eb\u3057\u3066\u3001 \
+-  \u5f8c\u307b\u3069\u6539\u3081\u3066\u5b9f\u884c\u3057\u3066\u304f\u3060\u3055\u3044\u3002</html> 
+-
+-m.proxy_host = \u30d7\u30ed\u30ad\u30b7IP 
+-m.proxy_port = \u30d7\u30ed\u30ad\u30b7\u30dd\u30fc\u30c8
++# Getdown Japanese translation messages
++
++m.abort_title = インストールを中止しますか?
++m.abort_confirm = <html>本当にインストールを中止しますか?  \
++  後でアプリケーションを起動した際にインストールを再開できます。</html>
++m.abort_ok = 中止
++m.abort_cancel = インストールの続行
++
++m.detecting_proxy = 自動プロキシ設定実行中
++
++m.configure_proxy = <html>サーバに接続できないため、ゲームのダウンロードに \
++  失敗しました。  \
++  <ul><li>ウィンドウズファイアウォールまたはノートンインターネットセキュリティが \
++  <code>javaw.exe</code>をブロックするよう設定してある場合は、ゲームをダウンロードできません。  設定を \
++  <code>javaw.exe</code>経由でインターネットにアクセスできるように変更してください。  ゲームを再起動 \
++  した後、ファイアウォールの設定からjavaw.exe を削除 \
++  してください(スタート→コントロールパネル→ファイアウォール)。</ul> \
++  <p>プロキシ設定の自動検出ができません。お使いのコンピューターは \
++  プロキシを使用してインターネットへアクセスしています。  プロキシ設定の詳細が \
++  わかっている場合は、下に入力してください。</html>
++
++m.proxy_extra = <html>プロキシを使用していない場合は、一時的な \
++  インターネットの不具合により、サーバと交信できない状態にある \
++  可能性があります。  その場合はインストールをキャンセルして、 \
++  後ほど改めて実行してください。</html>
++
++m.proxy_host = プロキシIP
++m.proxy_port = プロキシポート
+ m.proxy_username = Username
+ m.proxy_password = Password
+ m.proxy_auth_required = Authentication required
+-m.proxy_ok = OK  
+-m.proxy_cancel = \u30ad\u30e3\u30f3\u30bb\u30eb 
++m.proxy_ok = OK
++m.proxy_cancel = キャンセル
+-m.downloading_java = Java\u30d0\u30fc\u30c1\u30e3\u30eb\u30de\u30b7\u30f3\u306e\u30c0\u30a6\u30f3\u30ed\u30fc\u30c9\u4e2d
+-m.unpacking_java = Java\u30d0\u30fc\u30c1\u30e3\u30eb\u30de\u30b7\u30f3\u306e\u89e3\u51cd\u4e2d
++m.downloading_java = Javaバーチャルマシンのダウンロード中
++m.unpacking_java = Javaバーチャルマシンの解凍中
+-m.resolving = \u30c0\u30a6\u30f3\u30ed\u30fc\u30c9\u306e\u8a2d\u5b9a\u4e2d
+-m.downloading = \u30c7\u30fc\u30bf\u306e\u30c0\u30a6\u30f3\u30ed\u30fc\u30c9\u4e2d
+-m.failure = \u30c0\u30a6\u30f3\u30ed\u30fc\u30c9\u5931\u6557\uff1a  {0} 
++m.resolving = ダウンロードの設定中
++m.downloading = データのダウンロード中
++m.failure = ダウンロード失敗:  {0}
+-m.checking = \u30a2\u30c3\u30d7\u30c7\u30fc\u30c8\u306e\u78ba\u8a8d\u4e2d
+-m.validating = \u8a8d\u8a3c\u4e2d
+-m.patching = \u4fee\u6b63\u30d7\u30ed\u30b0\u30e9\u30e0\u306e\u5b9f\u884c\u4e2d
+-m.launching = \u5b9f\u884c\u4e2d
++m.checking = アップデートの確認中
++m.validating = 認証中
++m.patching = 修正プログラムの実行中
++m.launching = 実行中
+-m.complete = {0}\uff05\u5b8c\u4e86 
+-m.remain = \u3000\u6b8b\u308a{0} 
++m.complete = {0}%完了
++m.remain =  残り{0}
+-m.updating_metadata = \u30b3\u30f3\u30c8\u30ed\u30fc\u30eb\u30d5\u30a1\u30a4\u30eb\u306e\u30c0\u30a6\u30f3\u30ed\u30fc\u30c9\u4e2d
++m.updating_metadata = コントロールファイルのダウンロード中
+-m.init_failed = \u74b0\u5883\u8a2d\u5b9a\u30d5\u30a1\u30a4\u30eb\u304c\u5b58\u5728\u3057\u306a\u3044\u304b\u3001\u307e\u305f\u306f\u58ca\u308c\u3066\u3044\u307e\u3059\u3002  \u65b0\u30d0\u30fc\u30b8\u30e7\u30f3\u3092 \
+-  \u30c0\u30a6\u30f3\u30ed\u30fc\u30c9\u4e2d\u2026 
++m.init_failed = 環境設定ファイルが存在しないか、または壊れています。  新バージョンを \
++  ダウンロード中…
+-m.java_download_failed = \u304a\u4f7f\u3044\u306e\u30b3\u30f3\u30d4\u30e5\u30fc\u30bf\u30fc\u306b\u3001Java\u30d7\u30ed\u30b0\u30e9\u30e0\u306e\u6700\u65b0 \
+-  \u30d0\u30fc\u30b8\u30e7\u30f3\u3092\u81ea\u52d5\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002\n\n \
+-  www.java.com \u304b\u3089\u6700\u65b0\u30d0\u30fc\u30b8\u30e7\u30f3\u3092\u624b\u52d5\u3067\u30c0\u30a6\u30f3\u30ed\u30fc\u30c9\u3057\u3066\u3001 \
+-  \u518d\u5ea6\u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3\u3092\u8d77\u52d5\u3057\u3066\u304f\u3060\u3055\u3044\u3002 
++m.java_download_failed = お使いのコンピューターに、Javaプログラムの最新 \
++  バージョンを自動インストールできませんでした。\n\n \
++  www.java.com から最新バージョンを手動でダウンロードして、 \
++  再度アプリケーションを起動してください。
+-m.java_unpack_failed = Java\u306e\u30a2\u30c3\u30d7\u30c7\u30fc\u30c8\u30d0\u30fc\u30b8\u30e7\u30f3\u304c\u89e3\u51cd \
+-  \u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002  \u30cf\u30fc\u30c9\u30c9\u30e9\u30a4\u30d6\u306e\u30e1\u30e2\u30ea\u304c100MB\u4ee5\u4e0a\u3042\u308b\u3053\u3068\u3092\u78ba\u8a8d\u3057\u3066\u304b\u3089 \
+-  \u518d\u5ea6\u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3\u3092\u8d77\u52d5\u3057\u3066\u304f\u3060\u3055\u3044\u3002\n\n \
+-  \u554f\u984c\u304c\u89e3\u6c7a\u3057\u306a\u3044\u5834\u5408\u306f\u3001www.java.com \u304b\u3089Java\u306e\u6700\u65b0\u30d0\u30fc\u30b8\u30e7\u30f3\u3092 \
+-  \u30c0\u30a6\u30f3\u30ed\u30fc\u30c9\u3057\u3066\u304b\u3089\u3001\u518d\u5ea6\u304a\u8a66\u3057\u304f\u3060\u3055\u3044\u3002 
++m.java_unpack_failed = Javaのアップデートバージョンが解凍 \
++  できませんでした。  ハードドライブのメモリが100MB以上あることを確認してから \
++  再度アプリケーションを起動してください。\n\n \
++  問題が解決しない場合は、www.java.com からJavaの最新バージョンを \
++  ダウンロードしてから、再度お試しください。
+-m.unable_to_repair = 5\u56de\u8a66\u884c\u3057\u307e\u3057\u305f\u304c\u3001\u5fc5\u8981\u306a\u30d5\u30a1\u30a4\u30eb\u3092\u30c0\u30a6\u30f3\u30ed\u30fc\u30c9 \
+-  \u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002  \u5f8c\u307b\u3069\u6539\u3081\u3066\u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3\u3092\u5b9f\u884c\u3057\u3066\u304f\u3060\u3055\u3044\u3002 \
+-  \u518d\u5ea6\u5931\u6557\u3057\u305f\u5834\u5408\u306f\u3001\u30a2\u30f3\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u5f8c\u306b\u518d\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u3057\u3066\u304f\u3060\u3055\u3044\u3002 
++m.unable_to_repair = 5回試行しましたが、必要なファイルをダウンロード \
++  できませんでした。  後ほど改めてアプリケーションを実行してください。 \
++  再度失敗した場合は、アンインストール後に再インストールしてください。
+-m.unknown_error = \u539f\u56e0\u4e0d\u660e\u306e\u30a8\u30e9\u30fc\u306b\u3088\u308a\u3001\u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3\u304c \
+-  \u5b9f\u884c\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002  \u89e3\u6c7a\u65b9\u6cd5\u3092\n{0}\u3067 \
+-  \u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002 
+-m.init_error = \u6b21\u306e\u30a8\u30e9\u30fc\u306b\u3088\u308a\u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3\u3092\u5b9f\u884c\u3067\u304d\u307e\u305b\u3093 \
+-  \u3067\u3057\u305f\u3002\n{0}\n\n\u5bfe\u51e6\u65b9\u6cd5\u3092\n{1}\u3067 \
+-  \u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002 
++m.unknown_error = 原因不明のエラーにより、アプリケーションが \
++  実行できませんでした。  解決方法を\n{0}で \
++  確認してください。
++m.init_error = 次のエラーによりアプリケーションを実行できません \
++  でした。\n{0}\n\n対処方法を\n{1}で \
++  確認してください。
+-m.readonly_error = \u3053\u306e\u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3\u304c\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u3055\u308c\u305f\u30d5\u30a9\u30eb\u30c0\u306f  \
+-  \n{0}\n\u8aad\u307f\u53d6\u308a\u5c02\u7528\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002  \u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3\u3092\u66f8\u304d\u8fbc\u307f\u304c\u3067\u304d\u308b\u30d5\u30a9\u30eb\u30c0\u306b \
+-  \u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u3057\u3066\u304f\u3060\u3055\u3044\u3002 
++m.readonly_error = このアプリケーションがインストールされたフォルダは  \
++  \n{0}\n読み取り専用に設定されています。  アプリケーションを書き込みができるフォルダに \
++  インストールしてください。
+-m.missing_resource = \u30ea\u30bd\u30fc\u30b9\u4e0d\u660e\u306e\u305f\u3081\u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3\u3092\u5b9f\u884c\u3067\u304d\u307e\u305b\u3093 \
+-  \u3067\u3057\u305f\u3002\n{0}\n\n\u5bfe\u51e6\u65b9\u6cd5\u3092\n{1}\u3067\u78ba\u8a8d \
+-  \u3057\u3066\u304f\u3060\u3055\u3044\u3002 
++m.missing_resource = リソース不明のためアプリケーションを実行できません \
++  でした。\n{0}\n\n対処方法を\n{1}で確認 \
++  してください。
+-m.insufficient_permissions_error = \u3053\u306e\u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3\u306e\u30c7\u30b8\u30bf\u30eb\u7f72\u540d\u304c\u62d2\u5426 \
+- \u3055\u308c\u307e\u3057\u305f\u3002  \u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3\u3092\u5b9f\u884c\u3059\u308b\u5834\u5408\u306f\u3001\u30c7\u30b8\u30bf\u30eb\u7f72\u540d\u306e\u627f\u8a8d\u304c \
+- \u5fc5\u8981\u3067\u3059\u3002\n\n\u627f\u8a8d\u306b\u306f\u3001\u30d6\u30e9\u30a6\u30b6\u3092\u9589\u3058\u3066\u304b\u3089\u518d\u5ea6\u958b\u304d\u3001 \
+- \u672c\u30db\u30fc\u30e0\u30da\u30fc\u30b8\u3092\u518d\u8868\u793a\u3057\u3066\u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3\u3092\u518d\u5ea6\u5b9f\u884c\u3057\u3066\u304f\u3060\u3055\u3044  \u30bb\u30ad\u30e5\u30ea\u30c6\u30a3\u306e \
+- \u8b66\u544a\u304c\u8868\u793a\u3055\u308c\u305f\u6642\u306f\u3001\u5b9f\u884c\u3092\u30af\u30ea\u30c3\u30af\u3057\u3066\u30c7\u30b8\u30bf\u30eb\u7f72\u540d\u3092\u627f\u8a8d\u3057\u3001 \
+- \u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3\u3092\u5b9f\u884c\u3057\u3066\u304f\u3060\u3055\u3044\u3002 
++m.insufficient_permissions_error = このアプリケーションのデジタル署名が拒否 \
++ されました。  アプリケーションを実行する場合は、デジタル署名の承認が \
++ 必要です。\n\n承認には、ブラウザを閉じてから再度開き、 \
++ 本ホームページを再表示してアプリケーションを再度実行してください  セキュリティの \
++ 警告が表示された時は、実行をクリックしてデジタル署名を承認し、 \
++ アプリケーションを実行してください。
+-m.corrupt_digest_signature_error = \u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3\u306e\u30c7\u30b8\u30bf\u30eb\u7f72\u540d\u304c\u8a8d\u8a3c \
+- \u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002\n\u6307\u5b9a\u30db\u30fc\u30e0\u30da\u30fc\u30b8\u304b\u3089\u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3\u3092\u5b9f\u884c\u3057\u3066\u3044\u308b\u304b\n \
+- \u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002 
++m.corrupt_digest_signature_error = アプリケーションのデジタル署名が認証 \
++ できませんでした。\n指定ホームページからアプリケーションを実行しているか\n \
++ 確認してください。
+-m.default_install_error = \u30db\u30fc\u30e0\u30da\u30fc\u30b8\u3067\u306e\u30b5\u30dd\u30fc\u30c8\u8868\u793a 
++m.default_install_error = ホームページでのサポート表示
+ # application/digest errors
+-m.missing_appbase = \u8a2d\u5b9a\u30d5\u30a1\u30a4\u30eb\u306eappbase\u304c\u4e0d\u660e\u3067\u3059\u3002 
+-m.invalid_version = \u8a2d\u5b9a\u30d5\u30a1\u30a4\u30eb\u306f\u7121\u52b9\u306a\u30d0\u30fc\u30b8\u30e7\u30f3\u3092\u6307\u5b9a\u3057\u3066\u3044\u307e\u3059\u3002 
+-m.invalid_appbase = \u8a2d\u5b9a\u30d5\u30a1\u30a4\u30eb\u304c\u7121\u52b9\u306aappbase\u3092\u6307\u5b9a\u3057\u3066\u3044\u307e\u3059\u3002 
+-m.missing_class = \u8a2d\u5b9a\u30d5\u30a1\u30a4\u30eb\u306e\u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3\u30af\u30e9\u30b9\u304c\u4e0d\u660e\u3067\u3059\u3002 
+-m.missing_code = \u8a2d\u5b9a\u30d5\u30a1\u30a4\u30eb\u3067\u30b3\u30fc\u30c9\u30ea\u30bd\u30fc\u30b9\u304c\u6307\u5b9a\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002 
+-m.invalid_digest_file = \u30c0\u30a4\u30b8\u30a7\u30b9\u30c8\u30d5\u30a1\u30a4\u30eb\u304c\u7121\u52b9\u3067\u3059\u3002 
++m.missing_appbase = 設定ファイルのappbaseが不明です。
++m.invalid_version = 設定ファイルは無効なバージョンを指定しています。
++m.invalid_appbase = 設定ファイルが無効なappbaseを指定しています。
++m.missing_class = 設定ファイルのアプリケーションクラスが不明です。
++m.missing_code = 設定ファイルでコードリソースが指定されていません。
++m.invalid_digest_file = ダイジェストファイルが無効です。
+diff --git a/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages_ko.properties b/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages_ko.properties
+index 3f8a47f35..05700d363 100644
+--- a/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages_ko.properties
++++ b/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages_ko.properties
+@@ -1,102 +1,96 @@
+ #
+-# $Id$
+-#
+-# Getdown translation messages
++# Getdown Korean translation messages
+-m.abort_title = \uC124\uCE58\uB97C \uCDE8\uC18C\uD558\uC2DC\uACA0\uC2B5\uB2C8\uAE4C?
+-m.abort_confirm = <html>\uC815\uB9D0\uB85C \uC124\uCE58\uB97C \uCDE8\uC18C\uD558\uC2DC\uACA0\uC2B5\uB2C8\uAE4C? \
+-  \uB098\uC911\uC5D0 \uC5B4\uD50C\uB9AC\uCF00\uC774\uC158\uC744 \uC2E4\uD589\uD558\uC5EC \uC124\uCE58\uB97C \uC7AC\uAC1C\uD558\uC5EC \uC8FC\uC2ED\uC2DC\uC624.</html>
+-m.abort_ok = \uC911\uC9C0
+-m.abort_cancel = \uACC4\uC18D\uD558\uC5EC \uC124\uCE58
++m.abort_title = 설치를 취소하시겠습니까?
++m.abort_confirm = <html>정말로 설치를 취소하시겠습니까? \
++  나중에 어플리케이션을 실행하여 설치를 재개하여 주십시오.</html>
++m.abort_ok = 중지
++m.abort_cancel = 계속하여 설치
+-m.detecting_proxy = \uC790\uB3D9 \uD504\uB85D\uC2DC\uB97C \uC124\uC815\uC744 \uC2DC\uB3C4
++m.detecting_proxy = 자동 프록시를 설정을 시도
+-m.configure_proxy = <html>\uAC8C\uC784 \uB370\uC774\uD130\uB97C \uBC1B\uAE30 \uC704\uD55C \uC11C\uBC84 \uC811\uC18D\uC5D0 \uC2E4\uD328\uD558\uC600\uC2B5\uB2C8\uB2E4.\
+-  <ul><li>\uC708\uB3C4\uC6B0 \uBC29\uD654\uBCBD \uB610\uB294 \uB178\uD134 \uC778\uD130\uB137 \uC2DC\uD050\uB9AC\uD2F0\uAC00 <code>javaw.exe</code>\uC774 \uC124\uC815\uC5D0\uC11C \uCC28\uB2E8\uB418\uC5B4 \uC788\uC744 \uACBD\uC6B0, \
+-  \uAC8C\uC784 \uB370\uC774\uD130\uB97C \uB2E4\uC6B4\uB85C\uB4DC \uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. \
+-  <code>javaw.exe</code>\uAC00 \uC778\uD130\uB137 \uC5F0\uACB0\uC744 \uD560 \uC218 \uC788\uB3C4\uB85D \uC124\uC815\uC744 \uBCC0\uACBD\uD558\uC5EC \uC8FC\uC2ED\uC2DC\uC624. \
+-  \uAC8C\uC784\uC744 \uB2E4\uC2DC \uC2E4\uD589\uD55C \uD6C4, \uBC29\uD654\uBCBD \uC124\uC815\uC5D0\uC11C javaw.exe\uB97C \uC0AD\uC81C\uD558\uC5EC \uC8FC\uC2ED\uC2DC\uC624. \
+-  ( \uC2DC\uC791 -> \uC81C\uC5B4\uD310 -> \uC708\uB3C4\uC6B0 \uBC29\uD654\uBCBD )</ul> \
+-  <p> \uCEF4\uD4E8\uD130\uAC00 \uD504\uB85D\uC2DC \uC11C\uBC84\uB97C \uD1B5\uD574 \uC778\uD130\uB137\uC5D0 \uC5F0\uACB0\uB418\uC5B4 \uC788\uB2E4\uBA74, \uD504\uB85D\uC2DC \uC124\uC815\uC758 \uC790\uB3D9 \uAD6C\uC131\uC744 \uC0AC\uC6A9\uD560 \uC218 \uC5C6\uC73C\uBBC0\uB85C, \
+-  \uC0AC\uC6A9\uD558\uB294 \uD504\uB85D\uC2DC \uC124\uC815\uC744 \uC54C\uACE0 \uC788\uC744 \uACBD\uC6B0 \uC544\uB798\uC5D0 \uC785\uB825\uD558\uC5EC \uC8FC\uC2DC\uAE38 \uBC14\uB78D\uB2C8\uB2E4.</html>
++m.configure_proxy = <html>게임 데이터를 받기 위한 서버 접속에 실패하였습니다.\
++  <ul><li>윈도우 방화벽 또는 노턴 인터넷 시큐리티가 <code>javaw.exe</code>이 설정에서 차단되어 있을 경우, \
++  게임 데이터를 다운로드 할 수 없습니다. \
++  <code>javaw.exe</code>가 인터넷 연결을 할 수 있도록 설정을 변경하여 주십시오. \
++  게임을 다시 실행한 후, 방화벽 설정에서 javaw.exe를 삭제하여 주십시오. \
++  ( 시작 -> 제어판 -> 윈도우 방화벽 )</ul> \
++  <p> 컴퓨터가 프록시 서버를 통해 인터넷에 연결되어 있다면, 프록시 설정의 자동 구성을 사용할 수 없으므로, \
++  사용하는 프록시 설정을 알고 있을 경우 아래에 입력하여 주시길 바랍니다.</html>
+-m.proxy_extra = \uC790\uB3D9 \uD504\uB85D\uC2DC\uB97C \uC124\uC815\uC744 \uC2DC\uB3C4
++m.proxy_extra = 자동 프록시를 설정을 시도
+-m.proxy_host = \uD504\uB85D\uC2DC IP
+-m.proxy_port = \uD504\uB85D\uC2DC \uD3EC\uD2B8
++m.proxy_host = 프록시 IP
++m.proxy_port = 프록시 포트
+ m.proxy_username = Username
+ m.proxy_password = Password
+ m.proxy_auth_required = Authentication required
+ m.proxy_ok = OK
+-m.proxy_cancel = \uCDE8\uC18C
+-
+-m.downloading_java = \uC790\uBC14 \uAC00\uC0C1 \uBA38\uC2E0(JVM) \uB2E4\uC6B4\uB85C\uB4DC \uC911
+-m.unpacking_java = \uC790\uBC14 \uAC00\uC0C1 \uBA38\uC2E0(JVM) \uC555\uCD95\uC744 \uD574\uC81C\uD558\uB294 \uC911
+-
+-m.resolving = \uB2E4\uC6B4\uB85C\uB4DC \uBD84\uC11D \uC911
+-m.downloading = \uB370\uC774\uD130 \uB2E4\uC6B4\uB85C\uB4DC \uC911
+-m.failure = \uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328: {0}
++m.proxy_cancel = 취소
+-m.checking = \uC5C5\uB370\uC774\uD2B8 \uCCB4\uD06C
+-m.validating = \uC720\uD6A8\uC131 \uAC80\uC0AC \uC911
+-m.patching = \uD328\uCE58 \uC911
+-m.launching = \uC2E4\uD589 \uC911
++m.downloading_java = 자바 가상 머신(JVM) 다운로드 중
++m.unpacking_java = 자바 가상 머신(JVM) 압축을 해제하는 중
+-m.patch_notes = \uD328\uCE58 \uB178\uD2B8
+-m.play_again = \uB2E4\uC2DC \uC2E4\uD589
++m.resolving = 다운로드 분석 중
++m.downloading = 데이터 다운로드 중
++m.failure = 다운로드 실패: {0}
+-m.complete = {0}% \uC644\uB8CC
+-m.remain = {0} \uB0A8\uC74C
++m.checking = 업데이트 체크
++m.validating = 유효성 검사 중
++m.patching = 패치 중
++m.launching = 실행 중
+-m.updating_metadata = \uCEE8\uD2B8\uB864 \uD30C\uC77C\uC744 \uB2E4\uC6B4\uB85C\uB4DC \uC911
++m.patch_notes = 패치 노트
++m.complete = {0}% 완료
++m.remain = {0} 남음
+-m.init_failed = \uC124\uC815 \uD30C\uC77C\uC774 \uB204\uB77D\uB418\uC5C8\uAC70\uB098 \uBCC0\uD615\uB418\uC5C8\uC2B5\uB2C8\uB2E4. \
+-  \uC0C8\uB85C\uC6B4 \uBCF5\uC0AC\uBCF8\uC744 \uB2E4\uC6B4\uB85C\uB4DC \uC911\uC785\uB2C8\uB2E4...
++m.updating_metadata = 컨트롤 파일을 다운로드 중
+-m.java_download_failed = \uC774 \uCEF4\uD4E8\uD130\uC5D0 \uD544\uC694\uD55C \uC0C8\uB85C\uC6B4 \uBC84\uC804\uC758 \uC790\uBC14\uB97C \uC790\uB3D9\uC73C\uB85C \uB2E4\uC6B4\uB85C\uB4DC\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.\n\n\
+-  \uC790\uBC14 \uC6F9\uC0AC\uC774\uD2B8(www.java.com)\uB85C \uAC00\uC11C \uCD5C\uC2E0\uC758 \uC790\uBC14\uB97C \uB2E4\uC6B4\uB85C\uB4DC \uBC1B\uC73C\uC2E0 \uD6C4, \
+-  \uC5B4\uD50C\uB9AC\uCF00\uC774\uC158\uC744 \uB2E4\uC2DC \uC2E4\uD589\uD574 \uC8FC\uC2ED\uC2DC\uC624.
++m.init_failed = 설정 파일이 누락되었거나 변형되었습니다. \
++  새로운 복사본을 다운로드 중입니다...
+-m.java_unpack_failed = \uC5C5\uB370\uC774\uD2B8\uB41C \uBC84\uC804\uC758 \uC790\uBC14\uC758 \uC555\uCD95\uC744 \uD480 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. \
+-  \uD558\uB4DC\uB4DC\uB77C\uC774\uBE0C\uC5D0 \uCD5C\uC18C\uD55C 100MB\uC758 \uC6A9\uB7C9\uC744 \uD655\uBCF4\uD55C \uC774\uD6C4, \uC5B4\uD50C\uB9AC\uCF00\uC774\uC158\uC744 \uB2E4\uC2DC \uC2E4\uD589\uD574 \uC8FC\uC2ED\uC2DC\uC624.\n\n\
+-  \uB9CC\uC57D \uBB38\uC81C\uAC00 \uD574\uACB0\uB418\uC9C0 \uC54A\uB294\uB2E4\uBA74, \uC790\uBC14 \uC6F9\uC0AC\uC774\uD2B8(www.java.com)\uB85C \uAC00\uC11C \uCD5C\uC2E0\uC758 \uC790\uBC14\uB97C \uB2E4\uC6B4\uB85C\uB4DC \uBC1B\uC73C\uC2E0 \uD6C4, \
+-  \uC5B4\uD50C\uB9AC\uCF00\uC774\uC158\uC744 \uB2E4\uC2DC \uC2E4\uD589\uD574 \uC8FC\uC2ED\uC2DC\uC624.
++m.java_download_failed = 이 컴퓨터에 필요한 새로운 버전의 자바를 자동으로 다운로드할 수 없습니다.\n\n\
++  자바 웹사이트(www.java.com)로 가서 최신의 자바를 다운로드 받으신 후, \
++  어플리케이션을 다시 실행해 주십시오.
+-m.unable_to_repair = \uB2E4\uC12F\uBC88\uC758 \uC2DC\uB3C4\uC5D0\uB3C4 \uD544\uC694\uD55C \uD30C\uC77C\uC744 \uB2E4\uC6B4\uB85C\uB4DC\uD558\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4. \
+-  \uC5B4\uD50C\uB9AC\uCF00\uC774\uC158\uC744 \uB2E4\uC2DC \uC2DC\uC791\uD574\uBCF4\uC2DC\uACE0, \uADF8\uB798\uB3C4 \uB2E4\uC6B4\uB85C\uB4DC\uC5D0 \uC2E4\uD328\uD55C\uB2E4\uBA74, \uC5B4\uD50C\uB9AC\uCF00\uC774\uC158\uC744 \uC81C\uAC70\uD55C \uD6C4, \uB2E4\uC2DC \uC2E4\uD589\uD574\uBCF4\uC2DC\uAE30 \uBC14\uB78D\uB2C8\uB2E4.
++m.java_unpack_failed = 업데이트된 버전의 자바의 압축을 풀 수 없습니다. \
++  하드드라이브에 최소한 100MB의 용량을 확보한 이후, 어플리케이션을 다시 실행해 주십시오.\n\n\
++  만약 문제가 해결되지 않는다면, 자바 웹사이트(www.java.com)로 가서 최신의 자바를 다운로드 받으신 후, \
++  어플리케이션을 다시 실행해 주십시오.
+-m.unknown_error = \uBCF5\uAD6C\uB420 \uC218 \uC5C6\uB294 \uC624\uB958\uB85C \uC778\uD558\uC5EC \uC5B4\uD50C\uB9AC\uCF00\uC774\uC158\uC758 \uC2E4\uD589\uC774 \uC911\uB2E8\uB418\uC5C8\uC2B5\uB2C8\uB2E4. \
+-  \n{0}\uC5D0 \uB300\uD55C \uBCF5\uAD6C \uBC29\uBC95\uC744 \uCC3E\uAE30 \uC704\uD574\uC11C \uBC29\uBB38\uD558\uC2DC\uAE38 \uBC14\uB78D\uB2C8\uB2E4.
++m.unable_to_repair = 다섯번의 시도에도 필요한 파일을 다운로드하지 못했습니다. \
++  어플리케이션을 다시 시작해보시고, 그래도 다운로드에 실패한다면, 어플리케이션을 제거한 후, 다시 실행해보시기 바랍니다.
+-m.init_error = \uC5B4\uD50C\uB9AC\uCF00\uC774\uC158\uC774 \uC544\uB798\uC640 \uAC19\uC740 \uC5D0\uB7EC\uB85C \uC2E4\uD589\uC774 \uC911\uB2E8\uB418\uC5C8\uC2B5\uB2C8\uB2E4. \uC5D0\uB7EC:\
+-  \n{0}\n\n{1}\uC5D0 \uB300\uD55C \uBB38\uC81C \uD574\uACB0 \uBC29\uBC95\uC744 \uCC3E\uAE30 \uC704\uD574\uC11C \uBC29\uBB38\uD558\uC2DC\uAE38 \uBC14\uB78D\uB2C8\uB2E4.
++m.unknown_error = 복구될 수 없는 오류로 인하여 어플리케이션의 실행이 중단되었습니다. \
++  \n{0}에 대한 복구 방법을 찾기 위해서 방문하시길 바랍니다.
+-m.readonly_error = \uC5B4\uD50C\uB9AC\uCF00\uC774\uC158\uC774 \uC124\uCE58\uB41C \uB514\uB809\uD1A0\uB9AC: \
+-  \n{0}\n\uAC00 \uC77D\uAE30 \uC804\uC6A9\uC785\uB2C8\uB2E4. \uC77D\uAE30 \uAD8C\uD55C\uC774 \uC2B9\uC778\uB41C \uB809\uD1A0\uB9AC\uC5D0 \uC5B4\uD50C\uB9AC\uCF00\uC774\uC158\uC744 \uC124\uCE58\uD558\uC2DC\uAE38 \uBC14\uB78D\uB2C8\uB2E4.
++m.init_error = 어플리케이션이 아래와 같은 에러로 실행이 중단되었습니다. 에러:\
++  \n{0}\n\n{1}에 대한 문제 해결 방법을 찾기 위해서 방문하시길 바랍니다.
+-m.missing_resource = \uB9AC\uC18C\uC2A4\uC758 \uC190\uC2E4\uB85C \uC778\uD558\uC5EC \uC5B4\uD50C\uB9AC\uCF00\uC774\uC158\uC758 \uC2E4\uD589\uC774 \uC911\uB2E8\uB418\uC5C8\uC2B5\uB2C8\uB2E4. : \
+-  \n{0}\n\n{1}\uC5D0 \uB300\uD55C \uBB38\uC81C \uD574\uACB0 \uBC29\uBC95\uC744 \uCC3E\uAE30 \uC704\uD574\uC11C \uBC29\uBB38\uD558\uC2DC\uAE38 \uBC14\uB78D\uB2C8\uB2E4.
++m.readonly_error = 어플리케이션이 설치된 디렉토리: \
++  \n{0}\n가 읽기 전용입니다. 읽기 권한이 승인된 렉토리에 어플리케이션을 설치하시길 바랍니다.
+-m.insufficient_permissions_error = \uC774 \uC5B4\uD50C\uB9AC\uCF00\uC774\uC158\uC758 \uB514\uC9C0\uD0C8 \uC11C\uBA85\uC744 \uD655\uC778\uD558\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4. \
+- \uC5B4\uD50C\uB9AC\uCF00\uC774\uC158\uC744 \uC2E4\uD589\uD558\uAE30 \uC704\uD574\uC11C \uB514\uC9C0\uD0C8 \uC11C\uBA85\uC744 \uD655\uC778\uD558\uC5EC \uC8FC\uC2ED\uC2DC\uC624. \
+- \n\n\uADF8\uB9AC\uACE0 \uB098\uC11C \uC6F9 \uBE0C\uB77C\uC6B0\uC800\uB97C \uB2EB\uACE0 \uB2E4\uC2DC \uC2DC\uC791\uD558\uC5EC \uC6F9\uD398\uC774\uC9C0\uB85C \uB3CC\uC544\uC640 \uC5B4\uD50C\uB9AC\uCF00\uC774\uC158\uC744 \uC7AC\uC2DC\uC791\uD574\uC8FC\uC2DC\uAE30 \uBC14\uB78D\uB2C8\uB2E4. \
+- \uBCF4\uC548\uC5D0 \uB300\uD55C \uB300\uD654\uC0C1\uC790\uAC00 \uBCF4\uC774\uBA74, \uB514\uC9C0\uD0C8 \uC11C\uBA85\uC5D0 \uB300\uD55C \uD655\uC778 \uBC84\uD2BC\uC744 \uD074\uB9AD\uD558\uACE0, \uC5B4\uD50C\uB9AC\uCF00\uC774\uC158\uC774 \uC2E4\uD589\uB418\uAE30 \uC704\uD55C \
+- \uAD8C\uD55C\uC744 \uBD80\uC5EC\uD574\uC8FC\uC2DC\uAE30 \uBC14\uB78D\uB2C8\uB2E4.
++m.missing_resource = 리소스의 손실로 인하여 어플리케이션의 실행이 중단되었습니다. : \
++  \n{0}\n\n{1}에 대한 문제 해결 방법을 찾기 위해서 방문하시길 바랍니다.
+-m.corrupt_digest_signature_error = \uC5B4\uD50C\uB9AC\uCF00\uC774\uC158\uC758 \uB514\uC9C0\uD0C8 \uC11C\uBA85\uC744 \uD655\uC778\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.\n \
+- \uC62C\uBC14\uB978 \uC6F9\uC0AC\uC774\uD2B8\uC5D0\uC11C \uC5B4\uD50C\uB9AC\uCF00\uC774\uC158\uC774 \uC2E4\uD589\uB418\uACE0 \uC788\uB294 \uC9C0 \uD655\uC778\uBC14\uB78D\uB2C8\uB2E4.
++m.insufficient_permissions_error = 이 어플리케이션의 디지탈 서명을 확인하지 않았습니다. \
++ 어플리케이션을 실행하기 위해서 디지탈 서명을 확인하여 주십시오. \
++ \n\n그리고 나서 웹 브라우저를 닫고 다시 시작하여 웹페이지로 돌아와 어플리케이션을 재시작해주시기 바랍니다. \
++ 보안에 대한 대화상자가 보이면, 디지탈 서명에 대한 확인 버튼을 클릭하고, 어플리케이션이 실행되기 위한 \
++ 권한을 부여해주시기 바랍니다.
+-m.default_install_error = \uC6F9\uC0AC\uC774\uD2B8\uC758 \uC9C0\uC6D0 \uBA54\uB274(support section)\uB97C \uD655\uC778\uD558\uC2DC\uAE30 \uBC14\uB78D\uB2C8\uB2E4.
++m.corrupt_digest_signature_error = 어플리케이션의 디지탈 서명을 확인할 수 없습니다.\n \
++ 올바른 웹사이트에서 어플리케이션이 실행되고 있는 지 확인바랍니다.
+-m.another_getdown_running = \uC774 \uC5B4\uD50C\uB9AC\uCF00\uC774\uC158 \uC778\uC2A4\uD1A8\uB7EC\uC758 \uB2E4\uC911 \uC778\uC2A4\uD134\uC2A4\uAC00 \uC2E4\uD589\uC911\uC785\uB2C8\uB2E4. \
+- \uD558\uB098\uAC00 \uC644\uB8CC\uB420 \uB54C\uAE4C\uC9C0 \uC911\uB2E8\uB429\uB2C8\uB2E4.
++m.default_install_error = 웹사이트의 지원 메뉴(support section)를 확인하시기 바랍니다.
+-m.applet_stopped = Getdown \uC560\uD50C\uB9BF \uC2E4\uD589\uC774 \uC911\uB2E8\uB418\uC5C8\uC2B5\uB2C8\uB2E4.
++m.another_getdown_running = 이 어플리케이션 인스톨러의 다중 인스턴스가 실행중입니다. \
++ 하나가 완료될 때까지 중단됩니다.
+ # application/digest errors
+-m.missing_appbase = \uC124\uC815 \uD30C\uC77C\uC5D0 'appbase' \uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.
+-m.invalid_version = \uC124\uC815 \uD30C\uC77C\uC5D0 \uC798\uBABB\uB41C \uBC84\uC804\uC774 \uBA85\uC2DC\uB418\uC5B4 \uC788\uC2B5\uB2C8\uB2E4.
+-m.invalid_appbase = \uC124\uC815 \uD30C\uC77C\uC5D0 \uC798\uBABB\uB41C 'appbase'\uAC00 \uBA85\uC2DC\uB418\uC5B4 \uC788\uC2B5\uB2C8\uB2E4.
+-m.missing_class = \uC124\uC815 \uD30C\uC77C\uC5D0 \uC5B4\uD50C\uB9AC\uCF00\uC774\uC158 \uD074\uB798\uC2A4\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.
+-m.missing_code = \uC124\uC815 \uD30C\uC77C\uC5D0 \uB9AC\uC18C\uC2A4\uC5D0 \uB300\uD55C \uCF54\uB4DC\uAC00 \uBA85\uC2DC\uB418\uC5B4 \uC788\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.
+-m.invalid_digest_file = \uB2E4\uC774\uC81C\uC2A4\uD2B8 \uD30C\uC77C\uC774 \uC798\uBABB\uB418\uC5C8\uC2B5\uB2C8\uB2E4.
++m.missing_appbase = 설정 파일에 'appbase' 가 없습니다.
++m.invalid_version = 설정 파일에 잘못된 버전이 명시되어 있습니다.
++m.invalid_appbase = 설정 파일에 잘못된 'appbase'가 명시되어 있습니다.
++m.missing_class = 설정 파일에 어플리케이션 클래스가 없습니다.
++m.missing_code = 설정 파일에 리소스에 대한 코드가 명시되어 있지 않습니다.
++m.invalid_digest_file = 다이제스트 파일이 잘못되었습니다.
+diff --git a/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages_pt.properties b/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages_pt.properties
+index 47db91c90..e59ed20b2 100644
+--- a/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages_pt.properties
++++ b/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages_pt.properties
+@@ -1,118 +1,112 @@
+ #
+-# $Id$
+-#
+-# Getdown translation messages
++# Getdown Portuguese translation messages
+-m.abort_title = Cancelar a instala\u00E7\u00E3o?
+-m.abort_confirm = <html>Tem certeza que deseja cancelar a instala\u00E7\u00E3o? \
+-  Voc\u00EA pode continuar a instala\u00E7\u00E3o mais tarde, \
+-  basta executar a aplica\u00E7\u00E3o novamente.</html>
++m.abort_title = Cancelar a instalação?
++m.abort_confirm = <html>Tem certeza que deseja cancelar a instalação? \
++  Você pode continuar a instalação mais tarde, \
++  basta executar a aplicação novamente.</html>
+ m.abort_ok = Sair
+-m.abort_cancel = Continuar a instala\u00E7\u00E3o
++m.abort_cancel = Continuar a instalação
+-m.detecting_proxy = Tentando detectar automaticamente as configura\u00E7\u00F5es de proxy
++m.detecting_proxy = Tentando detectar automaticamente as configurações de proxy
+-m.configure_proxy = <html>N\u00E3o foi poss\u00EDvel conectar aos nossos servidores para \
++m.configure_proxy = <html>Não foi possível conectar aos nossos servidores para \
+   fazer o download dos dados. \
+-  <ul><li> Se o Firewall do Windows ou o Norton Internet Security est\u00E1 configurado \
+-  para bloquear o programa <code>javaw.exe</code> n\u00E3o ser\u00E1 poss\u00EDvel realizar \
+-  o download. Voc\u00EA ter\u00E1 que permitir que o programa <code>javaw.exe</code> acesse \
+-  a internet. Voc\u00EA pode tentar executar o programa novamente, mas voc\u00EA precisa \
+-  remover o programa javaw.exe das configura\u00E7\u00F5es do firewall (Iniciar -> Painel \
++  <ul><li> Se o Firewall do Windows ou o Norton Internet Security está configurado \
++  para bloquear o programa <code>javaw.exe</code> não será possível realizar \
++  o download. Você terá que permitir que o programa <code>javaw.exe</code> acesse \
++  a internet. Você pode tentar executar o programa novamente, mas você precisa \
++  remover o programa javaw.exe das configurações do firewall (Iniciar -> Painel \
+   de controle -> Firewall do Windows).</ul> \
+-  <p> Seu computador pode estar acessando a internet atrav\u00E9s de um proxy e n\u00E3o foi \
+-  capaz de detectar automaticamente as configura\u00E7\u00F5es de proxy. \
+-  Voc\u00EA pode informar esses dados abaixo.</html>
++  <p> Seu computador pode estar acessando a internet através de um proxy e não foi \
++  capaz de detectar automaticamente as configurações de proxy. \
++  Você pode informar esses dados abaixo.</html>
+-m.proxy_extra = <html>Se voc\u00EA tem certeza que n\u00E3o usa um proxy, ent\u00E3o pode ser \
+-  que exista um problema tempor\u00E1rio que est\u00E1 impedindo a comunica\u00E7\u00E3o \
+-  com os nossos servidores. Neste caso voc\u00EA pode cancelar e tentar instalar novamente \
++m.proxy_extra = <html>Se você tem certeza que não usa um proxy, então pode ser \
++  que exista um problema temporário que está impedindo a comunicação \
++  com os nossos servidores. Neste caso você pode cancelar e tentar instalar novamente \
+   mais tarde.</html>
+ m.proxy_host = IP do Proxy
+ m.proxy_port = Porta do Proxy
+-m.proxy_username = Nome de usu\u00e1rio
++m.proxy_username = Nome de usuário
+ m.proxy_password = Senha
+-m.proxy_auth_required = Autentifica\u00e7\u00e3o requerida
++m.proxy_auth_required = Autentificação requerida
+ m.proxy_ok = OK
+ m.proxy_cancel = Cancelar
+-m.downloading_java = Fazendo o download da m\u00E1quina virtual Java
+-m.unpacking_java = Descompactando a m\u00E1quina virtual Java
++m.downloading_java = Fazendo o download da máquina virtual Java
++m.unpacking_java = Descompactando a máquina virtual Java
+ m.resolving = Resolvendo downloads
+ m.downloading = Transferindo dados
+ m.failure = Download falhou: {0}
+-m.checking = Verificando atualiza\u00E7\u00F5es
++m.checking = Verificando atualizações
+ m.validating = Validando
+ m.patching = Atualizando
+ m.launching = Executando
+ m.patch_notes = Corrigir notas
+-m.play_again = Jogar de novo
+-
+ m.complete = {0}% completo
+ m.remain = {0} Permanecer
+ m.updating_metadata = Transferindo arquivos de controle
+-m.init_failed = Nosso arquivo de configura\u00E7\u00E3o est\u00E1 ausente ou corrompido. Tente \
+-  baixar uma nova c\u00F3pia...
++m.init_failed = Nosso arquivo de configuração está ausente ou corrompido. Tente \
++  baixar uma nova cópia...
+-m.java_download_failed = N\u00E3o conseguimos baixar automaticamente a\
+-  vers\u00E3o necess\u00E1ria do Java para o seu computador.\n\n\
+-  Por favor, acesse www.java.com, baixe e instale a \u00FAltima vers\u00E3o do \
++m.java_download_failed = Não conseguimos baixar automaticamente a\
++  versão necessária do Java para o seu computador.\n\n\
++  Por favor, acesse www.java.com, baixe e instale a última versão do \
+   Java, em seguida, tente executar o aplicativo novamente.
+-m.java_unpack_failed = N\u00E3o conseguimos descompactar uma vers\u00E3o atualizada do \
+-  Java. Por favor, certifique-se de ter pelo menos 100 MB de espa\u00E7o livre em seu \
+-  disco r\u00EDgido e tente executar o aplicativo novamente. \n\n\
+-  Se isso n\u00E3o resolver o problema, acesse www.java.com,baixe e \
+-  instale a \u00FAltima vers\u00E3o do Java e tente novamente.
++m.java_unpack_failed = Não conseguimos descompactar uma versão atualizada do \
++  Java. Por favor, certifique-se de ter pelo menos 100 MB de espaço livre em seu \
++  disco rígido e tente executar o aplicativo novamente. \n\n\
++  Se isso não resolver o problema, acesse www.java.com,baixe e \
++  instale a última versão do Java e tente novamente.
+-m.unable_to_repair = N\u00E3o conseguimos baixar os arquivos necess\u00E1rios depois de \
+-  cinco tentativas. Voc\u00EA pode tentar executar o aplicativo novamente, mas se ele \
+-  falhar pode ser necess\u00E1rio desinstalar e reinstalar.
++m.unable_to_repair = Não conseguimos baixar os arquivos necessários depois de \
++  cinco tentativas. Você pode tentar executar o aplicativo novamente, mas se ele \
++  falhar pode ser necessário desinstalar e reinstalar.
+-m.unknown_error = A aplica\u00E7\u00E3o falhou ao iniciar devido a algum erro estranho \
+-  do qual n\u00E3o conseguimos recuperar. Por favor, visite \n{0} para obter \
+-  informa\u00E7\u00F5es sobre como recuperar.
+-m.init_error = A aplica\u00E7\u00E3o falhou ao iniciar devido ao seguinte \
++m.unknown_error = A aplicação falhou ao iniciar devido a algum erro estranho \
++  do qual não conseguimos recuperar. Por favor, visite \n{0} para obter \
++  informações sobre como recuperar.
++m.init_error = A aplicação falhou ao iniciar devido ao seguinte \
+   erro:\n{0}\n\nPor favor visite \n{1} para \
+-  informa\u00E7\u00F5es sobre como lidar com esse problema.
++  informações sobre como lidar com esse problema.
+-m.readonly_error =O diret\u00F3rio no qual este aplicativo est\u00E1 instalado: \
+-  \n{0}\n \u00E9 somente leitura. Por favor, instale o aplicativo em um diret\u00F3rio onde \
+-  voc\u00EA tem acesso de grava\u00E7\u00E3o.
++m.readonly_error =O diretório no qual este aplicativo está instalado: \
++  \n{0}\n é somente leitura. Por favor, instale o aplicativo em um diretório onde \
++  você tem acesso de gravação.
+-m.missing_resource = A aplica\u00E7\u00E3o falhou ao iniciar devido a uma falta \
+-  de recurso:\n{0}\n\n Por favor, visite\n{1} para obter informa\u00E7\u00F5es sobre \
++m.missing_resource = A aplicação falhou ao iniciar devido a uma falta \
++  de recurso:\n{0}\n\n Por favor, visite\n{1} para obter informações sobre \
+   como lidar com tal problema.
+-m.insufficient_permissions_error = Voc\u00EA n\u00E3o aceitou a assinatura digital \
+-  do aplicativo. Se voc\u00EA quiser executar o aplicativo, voc\u00EA ter\u00E1 que aceitar \
+-  a assinatura digital. \n\nPara fazer isso, voc\u00EA ter\u00E1 que sair do seu navegador, \
+-  reinici\u00E1-lo, e retornar a esta p\u00E1gina web para executar a aplica\u00E7\u00E3o. \
+-  Quando o di\u00E1logo de seguran\u00E7a aparecer, clique no bot\u00E3o para aceitar a \
+-  assinatura digital e conceder a este aplicativo os privil\u00E9gios necess\u00E1rios \
++m.insufficient_permissions_error = Você não aceitou a assinatura digital \
++  do aplicativo. Se você quiser executar o aplicativo, você terá que aceitar \
++  a assinatura digital. \n\nPara fazer isso, você terá que sair do seu navegador, \
++  reiniciá-lo, e retornar a esta página web para executar a aplicação. \
++  Quando o diálogo de segurança aparecer, clique no botão para aceitar a \
++  assinatura digital e conceder a este aplicativo os privilégios necessários \
+   para executar.
+-m.corrupt_digest_signature_error = N\u00E3o conseguimos verificar a assinatura digital \
+-  do aplicativo.\nPor favor, verifique se voc\u00EA est\u00E1 utilizando o aplicativo \nde um \
++m.corrupt_digest_signature_error = Não conseguimos verificar a assinatura digital \
++  do aplicativo.\nPor favor, verifique se você está utilizando o aplicativo \nde um \
+   site correto.
+-m.default_install_error = a se\u00E7\u00E3o de suporte do site
+-
+-m.another_getdown_running = V\u00E1rias inst\u00E2ncias desta aplica\u00E7\u00E3o \
+-  est\u00E3o em execu\u00E7\u00E3o. Esta ir\u00E1 parar e deixar outra completar suas atividades.
++m.default_install_error = a seção de suporte do site
+-m.applet_stopped = Foi solicitado ao miniaplicativo GetDow que parasse de trabalhar.
++m.another_getdown_running = Várias instâncias desta aplicação \
++  estão em execução. Esta irá parar e deixar outra completar suas atividades.
+ # application/digest errors
+-m.missing_appbase = O arquivo de configura\u00E7\u00E3o n\u00E3o possui o 'AppBase'.
+-m.invalid_version = O arquivo de configura\u00E7\u00E3o especifica uma vers\u00E3o inv\u00E1lida.
+-m.invalid_appbase = O arquivo de configura\u00E7\u00E3o especifica um 'AppBase' inv\u00E1lido.
+-m.missing_class = O arquivo de configura\u00E7\u00E3o n\u00E3o possui a classe de aplicativo.
+-m.missing_code = O arquivo de configura\u00E7\u00E3o n\u00E3o especifica um recurso de c\u00F3digo.
+-m.invalid_digest_file = O arquivo digest \u00E9 inv\u00E1lido.
++m.missing_appbase = O arquivo de configuração não possui o 'AppBase'.
++m.invalid_version = O arquivo de configuração especifica uma versão inválida.
++m.invalid_appbase = O arquivo de configuração especifica um 'AppBase' inválido.
++m.missing_class = O arquivo de configuração não possui a classe de aplicativo.
++m.missing_code = O arquivo de configuração não especifica um recurso de código.
++m.invalid_digest_file = O arquivo digest é inválido.
+diff --git a/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages_zh.properties b/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages_zh.properties
+index 2c275437b..fa74fb293 100644
+--- a/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages_zh.properties
++++ b/getdown/src/getdown/launcher/src/main/resources/com/threerings/getdown/messages_zh.properties
+@@ -1,61 +1,57 @@
+ #
+-# $Id$
+-#
+-# Getdown translation messages
++# Getdown Chinese translation messages
+-m.detecting_proxy = \u641c\u5bfb\u4ee3\u7406\u670d\u52a1\u5668
++m.detecting_proxy = 搜寻代理服务器
+-m.configure_proxy = <html>\u6211\u4eec\u65e0\u6cd5\u8fde\u63a5\u5230\u670d\u52a1\u5668\u4e0b\u8f7d\u6e38\u620f\u6570\u636e\u3002\u8fd9\u53ef\u80fd\u662f\u7531\u4e8e \
+-  \u60a8\u7684\u8ba1\u7b97\u673a\u662f\u901a\u8fc7\u4ee3\u7406\u670d\u52a1\u5668\u8fde\u63a5\u4e92\u8054\u7f51\u7684\uff0c\u5e76\u4e14\u6211\u4eec\u65e0\u6cd5\u81ea\u52a8\u83b7\u5f97\u4ee3\u7406\u670d\u52a1\u5668\u7684 \
+-  \u8bbe\u7f6e\u3002\u5982\u679c\u60a8\u77e5\u9053\u60a8\u4ee3\u7406\u670d\u52a1\u5668\u7684\u8bbe\u7f6e\uff0c\u60a8\u53ef\u4ee5\u5728\u4e0b\u9762\u8f93\u5165\u3002</html>
++m.configure_proxy = <html>我们无法连接到服务器下载游戏数据。这可能是由于 \
++  您的计算机是通过代理服务器连接互联网的,并且我们无法自动获得代理服务器的 \
++  设置。如果您知道您代理服务器的设置,您可以在下面输入。</html>
+-m.proxy_extra = <html>\u5982\u679c\u60a8\u786e\u5b9a\u60a8\u6ca1\u6709\u4f7f\u7528\u4ee3\u7406\u670d\u52a1\u5668\uff0c\u8fd9\u53ef\u80fd\u662f\u7531\u4e8e\u6682\u65f6\u65e0\u6cd5 \
+-  \u8fde\u63a5\u5230\u4e92\u8054\u7f51\uff0c\u5bfc\u81f4\u65e0\u6cd5\u548c\u670d\u52a1\u5668\u901a\u8baf\u3002\u8fd9\u79cd\u60c5\u51b5\uff0c\u60a8\u53ef\u4ee5\u53d6\u6d88\uff0c\u7a0d\u5019\u518d\u91cd\u65b0\u5b89\u88c5\u3002<br><br> \
+-  \u5982\u679c\u60a8\u65e0\u6cd5\u786e\u5b9a\u60a8\u662f\u5426\u4f7f\u7528\u4e86\u4ee3\u7406\u670d\u52a1\u5668\uff0c\u8bf7\u8bbf\u95ee\u6211\u4eec\u7f51\u7ad9\u4e2d\u7684\u6280\u672f\u652f\u6301\u90e8\u4efd\uff0c \
+-  \u4e86\u89e3\u5982\u4f55\u68c0\u6d4b\u60a8\u7684\u4ee3\u7406\u670d\u52a1\u5668\u8bbe\u7f6e\u3002</html>
++m.proxy_extra = <html>如果您确定您没有使用代理服务器,这可能是由于暂时无法 \
++  连接到互联网,导致无法和服务器通讯。这种情况,您可以取消,稍候再重新安装。<br><br> \
++  如果您无法确定您是否使用了代理服务器,请访问我们网站中的技术支持部份, \
++  了解如何检测您的代理服务器设置。</html>
+-m.proxy_host = \u4ee3\u7406\u670d\u52a1\u5668\u7684IP\u5730\u5740
+-m.proxy_port = \u4ee3\u7406\u670d\u52a1\u5668\u7684\u7aef\u53e3\u53f7
++m.proxy_host = 代理服务器的IP地址
++m.proxy_port = 代理服务器的端口号
+ m.proxy_username = Username
+ m.proxy_password = Password
+ m.proxy_auth_required = Authentication required
+-m.proxy_ok = \u786e\u5b9a
+-m.proxy_cancel = \u53d6\u6d88
+-
+-m.resolving = \u5206\u6790\u9700\u4e0b\u8f7d\u5185\u5bb9
+-m.downloading = \u4e0b\u8f7d\u6570\u636e
+-m.failure = \u4e0b\u8f7d\u5931\u8d25: {0}
+-
+-m.checking = \u68c0\u67e5\u66f4\u65b0\u5185\u5bb9
+-m.validating = \u786e\u8ba4
+-m.patching = \u5347\u7ea7
+-m.launching = \u542f\u52a8
++m.proxy_ok = 确定
++m.proxy_cancel = 取消
+-m.complete = {0}% \u5b8c\u6210
+-m.remain = {0} \u5269\u4f59\u65f6\u95f4
++m.resolving = 分析需下载内容
++m.downloading = 下载数据
++m.failure = 下载失败: {0}
+-m.updating_metadata = \u4e0b\u8f7d\u63a7\u5236\u6587\u4ef6
++m.checking = 检查更新内容
++m.validating = 确认
++m.patching = 升级
++m.launching = 启动
+-m.init_failed = \u65e0\u6cd5\u627e\u5230\u914d\u7f6e\u6587\u4ef6\u6216\u5df2\u635f\u574f\u3002\u5c1d\u8bd5\u91cd\u65b0\u4e0b\u8f7d...
++m.complete = {0}% 完成
++m.remain = {0} 剩余时间
+-m.unable_to_repair = \u7ecf\u8fc75\u6b21\u5c1d\u8bd5\uff0c\u4f9d\u7136\u65e0\u6cd5\u4e0b\u8f7d\u6240\u9700\u7684\u6587\u4ef6\u3002\
+-\u60a8\u53ef\u4ee5\u91cd\u65b0\u8fd0\u884c\u7a0b\u5e8f\uff0c\u4f46\u662f\u5982\u679c\u4f9d\u7136\u5931\u8d25\uff0c\u60a8\u53ef\u80fd\u9700\u8981\u53cd\u5b89\u88c5\u5e76\u91cd\u65b0\u5b89\u88c5\u3002
++m.updating_metadata = 下载控制文件
++m.init_failed = 无法找到配置文件或已损坏。尝试重新下载...
+-m.unknown_error = \u7531\u4e8e\u4e00\u4e9b\u65e0\u6cd5\u56de\u590d\u7684\u4e25\u91cd\u9519\u8bef\uff0c\u7a0b\u5e8f\u542f\u52a8\u5931\u8d25\u3002\
+-\u8bf7\u8bbf\u95ee\u6211\u4eec\u7684\u7f51\u7ad9\u7684\u6280\u672f\u652f\u6301\u90e8\u4efd\uff0c\u4e86\u89e3\u5982\u4f55\u89e3\u51b3\u95ee\u9898\u3002
++m.unable_to_repair = 经过5次尝试,依然无法下载所需的文件。\
++您可以重新运行程序,但是如果依然失败,您可能需要反安装并重新安装。
+-m.init_error = \u7531\u4e8e\u4e0b\u5217\u9519\u8bef\uff0c\u7a0b\u5e8f\u542f\u52a8\u5931\u8d25\uff1a\n{0}\n\n \
+-\u8bf7\u8bbf\u95ee\u6211\u4eec\u7684\u7f51\u7ad9\u7684\u6280\u672f\u652f\u6301\u90e8\u4efd\uff0c\u4e86\u89e3\u5982\u4f55\u5904\u7406\u8fd9\u4e9b\u9519\u8bef\u3002
++m.unknown_error = 由于一些无法回复的严重错误,程序启动失败。\
++请访问我们的网站的技术支持部份,了解如何解决问题。
++m.init_error = 由于下列错误,程序启动失败:\n{0}\n\n \
++请访问我们的网站的技术支持部份,了解如何处理这些错误。
+-m.missing_resource = \u7531\u4e8e\u65e0\u6cd5\u627e\u5230\u4e0b\u5217\u8d44\u6e90\uff0c\u7a0b\u5e8f\u542f\u52a8\u5931\u8d25\uff1a\n{0}\n\n \
+-\u8bf7\u8bbf\u95ee\u6211\u4eec\u7684\u7f51\u7ad9\u7684\u6280\u672f\u652f\u6301\u90e8\u4efd\uff0c\u4e86\u89e3\u5982\u4f55\u5904\u7406\u8fd9\u4e9b\u95ee\u9898\u3002
++m.missing_resource = 由于无法找到下列资源,程序启动失败:\n{0}\n\n \
++请访问我们的网站的技术支持部份,了解如何处理这些问题。
+ # application/digest errors
+-m.missing_appbase = \u914d\u7f6e\u6587\u4ef6\u4e2d\u65e0\u6cd5\u627e\u5230 'appbase'\u3002
+-m.invalid_version = \u914d\u7f6e\u6587\u4ef6\u6307\u5b9a\u4e86\u65e0\u6548\u7684\u7248\u672c\u3002
+-m.invalid_appbase = \u914d\u7f6e\u6587\u4ef6\u6307\u5b9a\u4e86\u65e0\u6548\u7684 'appbase'\u3002
+-m.missing_class = \u914d\u7f6e\u6587\u4ef6\u4e2d\u65e0\u6cd5\u627e\u5230\u7a0b\u5e8f\u6587\u4ef6\u3002
+-m.missing_code = \u914d\u7f6e\u6587\u4ef6\u4e2d\u65e0\u6cd5\u627e\u5230\u6307\u5b9a\u7684\u8d44\u6e90\u3002
+-m.invalid_digest_file = \u65e0\u6548\u7684\u914d\u7f6e\u6587\u4ef6\u3002
++m.missing_appbase = 配置文件中无法找到 'appbase'。
++m.invalid_version = 配置文件指定了无效的版本。
++m.invalid_appbase = 配置文件指定了无效的 'appbase'。
++m.missing_class = 配置文件中无法找到程序文件。
++m.missing_code = 配置文件中无法找到指定的资源。
++m.invalid_digest_file = 无效的配置文件。
+diff --git a/getdown/src/getdown/lib/commons-compress-1.18.jar b/getdown/src/getdown/lib/commons-compress-1.18.jar
+deleted file mode 100644
+index e401046b5..000000000
+Binary files a/getdown/src/getdown/lib/commons-compress-1.18.jar and /dev/null differ
+diff --git a/getdown/src/getdown/mvn_cmd b/getdown/src/getdown/mvn_cmd
+deleted file mode 100644
+index 0ce786ff8..000000000
+--- a/getdown/src/getdown/mvn_cmd
++++ /dev/null
+@@ -1 +0,0 @@
+-mvn clean package -Dgetdown.host.whitelist="jalview.org,*.jalview.org" && cp launcher/target/getdown-launcher-1.8.3-SNAPSHOT.jar ../../../getdown/lib/getdown-launcher.jar && cp core/target/getdown-core-1.8.3-SNAPSHOT.jar ../../../getdown/lib/getdown-core-1.8.3-SNAPSHOT.jar && cp core/target/getdown-core-1.8.3-SNAPSHOT.jar ../../../j8lib/getdown-core.jar && cp core/target/getdown-core-1.8.3-SNAPSHOT.jar ../../../j11lib/getdown-core.jar 
+diff --git a/getdown/src/getdown/pom.xml b/getdown/src/getdown/pom.xml
+index 78d67b0a5..ae1370dde 100644
+--- a/getdown/src/getdown/pom.xml
++++ b/getdown/src/getdown/pom.xml
+@@ -10,7 +10,7 @@
+   <groupId>com.threerings.getdown</groupId>
+   <artifactId>getdown</artifactId>
+   <packaging>pom</packaging>
+-  <version>1.8.3-SNAPSHOT</version>
++  <version>1.8.7-SNAPSHOT</version>
+   <name>getdown</name>
+   <description>An application installer and updater.</description>
+@@ -35,6 +35,10 @@
+     </developer>
+   </developers>
++  <properties>
++    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
++  </properties>
++
+   <scm>
+     <connection>scm:git:git://github.com/threerings/getdown.git</connection>
+     <developerConnection>scm:git:git@github.com:threerings/getdown.git</developerConnection>
+@@ -47,28 +51,6 @@
+     <module>ant</module>
+   </modules>
+-  <repositories>
+-      <repository>
+-         <id>ej-technologies</id>
+-         <url>https://maven.ej-technologies.com/repository</url>
+-      </repository>
+-  </repositories>
+-
+-
+-  <dependencies>
+-    <dependency>
+-      <groupId>org.apache.commons</groupId>
+-      <artifactId>commons-compress</artifactId>
+-      <version>1.18</version>
+-    </dependency>
+-    <dependency>
+-              <groupId>com.install4j</groupId>
+-              <artifactId>install4j-runtime</artifactId>
+-              <version>7.0.11</version>
+-              <scope>provided</scope>
+-      </dependency>
+-  </dependencies>
+-
+   <build>
+     <plugins>
+       <plugin>
+@@ -83,6 +65,20 @@
+           <stagingProfileId>aa555c46fc37d0</stagingProfileId>
+         </configuration>
+       </plugin>
++
++      <plugin>
++        <groupId>org.apache.maven.plugins</groupId>
++        <artifactId>maven-javadoc-plugin</artifactId>
++        <version>3.1.0</version>
++        <configuration>
++          <quiet>true</quiet>
++          <show>public</show>
++          <additionalOptions>
++            <additionalOption>-Xdoclint:all</additionalOption>
++            <additionalOption>-Xdoclint:-missing</additionalOption>
++          </additionalOptions>
++        </configuration>
++      </plugin>
+     </plugins>
+     <!-- Common plugin configuration for all children -->
+@@ -91,10 +87,10 @@
+         <plugin>
+           <groupId>org.apache.maven.plugins</groupId>
+           <artifactId>maven-compiler-plugin</artifactId>
+-          <version>3.8.1</version>
++          <version>3.7.0</version>
+           <configuration>
+-            <source>1.8</source>
+-            <target>1.8</target>
++            <source>1.7</source>
++            <target>1.7</target>
+             <fork>true</fork>
+             <showDeprecation>true</showDeprecation>
+             <showWarnings>true</showWarnings>
+@@ -110,20 +106,12 @@
+           <groupId>org.apache.maven.plugins</groupId>
+           <artifactId>maven-resources-plugin</artifactId>
+           <version>3.0.2</version>
+-          <configuration>
+-            <encoding>UTF-8</encoding>
+-          </configuration>
+         </plugin>
+         <plugin>
+           <groupId>org.apache.maven.plugins</groupId>
+-          <artifactId>maven-javadoc-plugin</artifactId>
+-          <version>3.0.0-M1</version>
+-          <configuration>
+-            <quiet>true</quiet>
+-            <show>public</show>
+-            <additionalparam>-Xdoclint:all -Xdoclint:-missing</additionalparam>
+-          </configuration>
++          <artifactId>maven-gpg-plugin</artifactId>
++          <version>1.6</version>
+         </plugin>
+       </plugins>
+     </pluginManagement>
+@@ -181,7 +169,6 @@
+           <plugin>
+             <groupId>org.apache.maven.plugins</groupId>
+             <artifactId>maven-gpg-plugin</artifactId>
+-            <version>1.6</version>
+             <executions>
+               <execution>
+                 <id>sign-artifacts</id>