From 8175064600bdf3bc775c84cfa5ce93548fa879e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9da=20Housni=20Alaoui?= Date: Thu, 9 Jun 2016 00:51:05 +0200 Subject: [PATCH 01/15] #57 Let the user close the getdown window on error, even when hideDecorations is set to true --- .../com/threerings/getdown/launcher/GetdownApp.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/main/java/com/threerings/getdown/launcher/GetdownApp.java b/src/main/java/com/threerings/getdown/launcher/GetdownApp.java index 06c8e5a..dc6b06a 100644 --- a/src/main/java/com/threerings/getdown/launcher/GetdownApp.java +++ b/src/main/java/com/threerings/getdown/launcher/GetdownApp.java @@ -184,6 +184,19 @@ public class GetdownApp System.exit(exitCode); } } + + @Override + protected void fail(String message) { + // 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(); + _frame.setUndecorated(false); + showContainer(); + } + super.fail(message); + } + protected JFrame _frame; }; app.start(); From caa8a5ca16d702538f3e8c66357ea343fd37749f Mon Sep 17 00:00:00 2001 From: Michael Bayne Date: Mon, 20 Jun 2016 07:43:14 -0700 Subject: [PATCH 02/15] Formatting tweaks. --- .../java/com/threerings/getdown/launcher/GetdownApp.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/threerings/getdown/launcher/GetdownApp.java b/src/main/java/com/threerings/getdown/launcher/GetdownApp.java index dc6b06a..57818ff 100644 --- a/src/main/java/com/threerings/getdown/launcher/GetdownApp.java +++ b/src/main/java/com/threerings/getdown/launcher/GetdownApp.java @@ -186,10 +186,10 @@ public class GetdownApp } @Override - protected void fail(String message) { - // If the frame was set to be undecorated, make window decoration available + protected void fail (String message) { + // 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()){ + if (_frame != null && _frame.isUndecorated()) { _frame.dispose(); _frame.setUndecorated(false); showContainer(); From 4fad760399f332d469ade2f40764d24600e44e71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20K=C3=BCstermann?= Date: Fri, 9 Sep 2016 12:29:11 +0200 Subject: [PATCH 03/15] Compiler to use Java 6 as source and target As the sources cannot be compiled with Java 5 the compliance level has been changed to Java 6. --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 96de9f2..c5f0621 100644 --- a/pom.xml +++ b/pom.xml @@ -109,8 +109,8 @@ maven-compiler-plugin 3.3 - 1.5 - 1.5 + 1.6 + 1.6 true true true From 282aac0a6f02e02785945d81198d34f9d79c75f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20K=C3=BCstermann?= Date: Fri, 9 Sep 2016 17:53:53 +0200 Subject: [PATCH 04/15] Added "Trusted-Library" manifest attribute The "Trusted-Library" manifest attribute is required for Applets and Java Web Start. Otherwise classes from the Getdown package might be silently ignored (ClassNotFoundException). --- pom.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/pom.xml b/pom.xml index 96de9f2..bcaf254 100644 --- a/pom.xml +++ b/pom.xml @@ -211,6 +211,7 @@ * * * + true From 4fe57ead005dbcc2973eb7b46ca7f47fa5ef1a13 Mon Sep 17 00:00:00 2001 From: Michael Bayne Date: Sun, 11 Sep 2016 12:40:14 -0700 Subject: [PATCH 05/15] Fix license confusion. We relicensed as New BSD a long time ago, so the POM should reflect that. Also fixed the authors referenced in the LICENSE file. Closes #66 --- LICENSE | 2 +- pom.xml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/LICENSE b/LICENSE index 883d10c..0d9b255 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ Getdown - application installer, patcher and launcher -Copyright (C) 2004-2014 Three Rings Design, Inc. +Copyright (C) 2004-2016 Getdown authors Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: diff --git a/pom.xml b/pom.xml index bcaf254..845e4fe 100644 --- a/pom.xml +++ b/pom.xml @@ -25,8 +25,8 @@ - GNU Lesser General Public License (LGPL), Version 2.1 - http://www.fsf.org/licensing/licenses/lgpl.txt + The (New) BSD License + http://www.opensource.org/licenses/bsd-license.php repo From e1c59ab6d02d7be10c6b0fe0ff44d4fe9ce31165 Mon Sep 17 00:00:00 2001 From: Michael Bayne Date: Sun, 11 Sep 2016 13:14:34 -0700 Subject: [PATCH 06/15] Nix unused import. --- src/main/java/com/threerings/getdown/data/Resource.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/threerings/getdown/data/Resource.java b/src/main/java/com/threerings/getdown/data/Resource.java index 688db55..7ad0a1e 100644 --- a/src/main/java/com/threerings/getdown/data/Resource.java +++ b/src/main/java/com/threerings/getdown/data/Resource.java @@ -13,7 +13,6 @@ import java.util.Comparator; import java.util.List; import java.util.jar.JarEntry; import java.util.jar.JarFile; -import java.util.jar.JarInputStream; import com.samskivert.io.StreamUtil; import com.samskivert.util.StringUtil; From 0a34632c0ab40eef6119ba9c381b57445bb3adce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20K=C3=BCstermann?= Date: Tue, 20 Sep 2016 13:22:19 +0200 Subject: [PATCH 07/15] Issue #64 introduced a code cache to support multiple app instances Whenever 'use_code_cache' is set to 'true' the application's code resources are copied to a cache directory prior to launching the application. This is done in order to support multiple open application instances of different versions. --- pom.xml | 6 + .../classpath/CacheBasedClassPathBuilder.java | 61 +++++++ .../getdown/classpath/ClassPath.java | 69 ++++++++ .../getdown/classpath/ClassPathBuilder.java | 13 ++ .../classpath/ClassPathBuilderBase.java | 14 ++ .../classpath/ClassPathBuilderFactory.java | 22 +++ .../getdown/classpath/ClassPathElement.java | 47 +++++ .../classpath/DefaultClassPathBuilder.java | 30 ++++ .../classpath/cache/GarbageCollector.java | 91 ++++++++++ .../classpath/cache/ResourceCache.java | 73 ++++++++ .../threerings/getdown/data/Application.java | 162 ++++++++++++------ .../com/threerings/getdown/data/Digest.java | 15 +- .../com/threerings/getdown/util/FileUtil.java | 31 +++- .../getdown/util/file/FileVisitor.java | 6 + .../getdown/util/file/FileWalker.java | 42 +++++ .../CacheBasedClassPathBuilderTest.java | 44 +++++ .../ClassPathBuilderFactoryTest.java | 37 ++++ .../classpath/ClassPathBuilderTestBase.java | 48 ++++++ .../getdown/classpath/ClassPathTest.java | 60 +++++++ .../DefaultClassPathBuilderTest.java | 31 ++++ .../classpath/cache/GarbageCollectorTest.java | 91 ++++++++++ .../classpath/cache/ResourceCacheTest.java | 81 +++++++++ .../threerings/getdown/util/FileUtilTest.java | 33 +++- .../getdown/util/file/FileWalkerTest.java | 41 +++++ 24 files changed, 1082 insertions(+), 66 deletions(-) create mode 100644 src/main/java/com/threerings/getdown/classpath/CacheBasedClassPathBuilder.java create mode 100644 src/main/java/com/threerings/getdown/classpath/ClassPath.java create mode 100644 src/main/java/com/threerings/getdown/classpath/ClassPathBuilder.java create mode 100644 src/main/java/com/threerings/getdown/classpath/ClassPathBuilderBase.java create mode 100644 src/main/java/com/threerings/getdown/classpath/ClassPathBuilderFactory.java create mode 100644 src/main/java/com/threerings/getdown/classpath/ClassPathElement.java create mode 100644 src/main/java/com/threerings/getdown/classpath/DefaultClassPathBuilder.java create mode 100644 src/main/java/com/threerings/getdown/classpath/cache/GarbageCollector.java create mode 100644 src/main/java/com/threerings/getdown/classpath/cache/ResourceCache.java create mode 100644 src/main/java/com/threerings/getdown/util/file/FileVisitor.java create mode 100644 src/main/java/com/threerings/getdown/util/file/FileWalker.java create mode 100644 src/test/java/com/threerings/getdown/classpath/CacheBasedClassPathBuilderTest.java create mode 100644 src/test/java/com/threerings/getdown/classpath/ClassPathBuilderFactoryTest.java create mode 100644 src/test/java/com/threerings/getdown/classpath/ClassPathBuilderTestBase.java create mode 100644 src/test/java/com/threerings/getdown/classpath/ClassPathTest.java create mode 100644 src/test/java/com/threerings/getdown/classpath/DefaultClassPathBuilderTest.java create mode 100644 src/test/java/com/threerings/getdown/classpath/cache/GarbageCollectorTest.java create mode 100644 src/test/java/com/threerings/getdown/classpath/cache/ResourceCacheTest.java create mode 100644 src/test/java/com/threerings/getdown/util/file/FileWalkerTest.java diff --git a/pom.xml b/pom.xml index 30f40e5..acb34bd 100644 --- a/pom.xml +++ b/pom.xml @@ -90,6 +90,12 @@ 4.8.1 test + + org.mockito + mockito-core + 1.10.19 + test + diff --git a/src/main/java/com/threerings/getdown/classpath/CacheBasedClassPathBuilder.java b/src/main/java/com/threerings/getdown/classpath/CacheBasedClassPathBuilder.java new file mode 100644 index 0000000..e98b824 --- /dev/null +++ b/src/main/java/com/threerings/getdown/classpath/CacheBasedClassPathBuilder.java @@ -0,0 +1,61 @@ +package com.threerings.getdown.classpath; + +import java.io.File; +import java.io.IOException; +import java.util.LinkedHashSet; +import java.util.concurrent.TimeUnit; + +import com.threerings.getdown.classpath.cache.GarbageCollector; +import com.threerings.getdown.classpath.cache.ResourceCache; +import com.threerings.getdown.data.Application; +import com.threerings.getdown.data.Resource; +import com.threerings.getdown.util.file.FileWalker; + +/** + * Instead of building the class path from the application directory, this class path builder puts + * the files that make up the class path into a cache and assembles the class path from the cache + * directory. + */ +public class CacheBasedClassPathBuilder extends ClassPathBuilderBase +{ + static final String CACHE_DIR = ".cache"; + + public CacheBasedClassPathBuilder(Application application) + { + super(application); + } + + @Override + public ClassPath buildClassPath () throws IOException + { + File cacheDir = new File(_application.getAppdir(), CACHE_DIR); + + // a negative value of code_cache_retention_days allows to clean up the cache forcefully + if (_application.getCodeCacheRetentionDays() <= 0) { + runGarbageCollection(cacheDir); + } + + ResourceCache cache = new ResourceCache(cacheDir); + + LinkedHashSet classPathEntries = new LinkedHashSet(); + + for (Resource resource: _application.getActiveCodeResources()) { + classPathEntries.add( + new ClassPathElement( + cache.cacheFile(resource.getFinalTarget(), _application.getDigest(resource)))); + } + + if (_application.getCodeCacheRetentionDays() > 0) { + runGarbageCollection(cacheDir); + } + + return new ClassPath(classPathEntries); + } + + private void runGarbageCollection(File cacheDir) { + GarbageCollector gc = new GarbageCollector( + new FileWalker(cacheDir), _application.getCodeCacheRetentionDays(), TimeUnit.DAYS); + + gc.collectGarbage(); + } +} diff --git a/src/main/java/com/threerings/getdown/classpath/ClassPath.java b/src/main/java/com/threerings/getdown/classpath/ClassPath.java new file mode 100644 index 0000000..e5da5c6 --- /dev/null +++ b/src/main/java/com/threerings/getdown/classpath/ClassPath.java @@ -0,0 +1,69 @@ +package com.threerings.getdown.classpath; + +import java.io.File; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * Represents the class path and it's elements of the application to be launched. The class path can + * either be represented as an {@link #asArgumentString() argument string} for the java command line + * or as an {@link #asUrls() array of URLs} to be used by a {@link URLClassLoader}. + */ +public class ClassPath +{ + public ClassPath (LinkedHashSet classPathEntries) + { + this._classPathEntries = Collections.unmodifiableSet(classPathEntries); + } + + /** + * Returns the class path as an java command line argument string, e.g + * + *
+     *   /path/to/a.jar:/path/to/b.jar
+     * 
+ */ + public String asArgumentString () + { + StringBuilder builder = new StringBuilder(); + String delimiter = ""; + + for (ClassPathElement entry: _classPathEntries) + { + builder + .append(delimiter) + .append(entry.getAbsolutePath()); + + delimiter = File.pathSeparator; + } + + return builder.toString(); + } + + /** + * Returns the class path entries as an array of URLs to be used for example by an + * {@link URLClassLoader}. + */ + public URL[] asUrls () + { + URL[] urls = new URL[_classPathEntries.size()]; + + int i = 0; + + for (ClassPathElement entry : _classPathEntries) { + urls[i++] = entry.getURL(); + } + + return urls; + } + + public Set getClassPathEntries () + { + return _classPathEntries; + } + + private final Set _classPathEntries; +} diff --git a/src/main/java/com/threerings/getdown/classpath/ClassPathBuilder.java b/src/main/java/com/threerings/getdown/classpath/ClassPathBuilder.java new file mode 100644 index 0000000..a220760 --- /dev/null +++ b/src/main/java/com/threerings/getdown/classpath/ClassPathBuilder.java @@ -0,0 +1,13 @@ +package com.threerings.getdown.classpath; + +import java.io.IOException; + +/** + * A class path builder compiles the class path of the application to be launched. + */ +public interface ClassPathBuilder { + /** + * Builds and returns the class path of the application to be launched. + */ + ClassPath buildClassPath () throws IOException; +} diff --git a/src/main/java/com/threerings/getdown/classpath/ClassPathBuilderBase.java b/src/main/java/com/threerings/getdown/classpath/ClassPathBuilderBase.java new file mode 100644 index 0000000..0cc5613 --- /dev/null +++ b/src/main/java/com/threerings/getdown/classpath/ClassPathBuilderBase.java @@ -0,0 +1,14 @@ +package com.threerings.getdown.classpath; + +import com.threerings.getdown.data.Application; + +/** + * Base class for {@link ClassPathBuilder class path builders}. + */ +abstract class ClassPathBuilderBase implements ClassPathBuilder { + public ClassPathBuilderBase(Application _application) { + this._application = _application; + } + + protected final Application _application; +} diff --git a/src/main/java/com/threerings/getdown/classpath/ClassPathBuilderFactory.java b/src/main/java/com/threerings/getdown/classpath/ClassPathBuilderFactory.java new file mode 100644 index 0000000..5f93fdb --- /dev/null +++ b/src/main/java/com/threerings/getdown/classpath/ClassPathBuilderFactory.java @@ -0,0 +1,22 @@ +package com.threerings.getdown.classpath; + +import com.threerings.getdown.data.Application; + +/** + * A factory for {@link ClassPathBuilder class path builders}. + */ +public abstract class ClassPathBuilderFactory { + private ClassPathBuilderFactory () { + } + + /** + * Creates a class path builder that is able to compile the class path of the supplied + * {@link Application application}. + */ + public static ClassPathBuilder create (Application application) + { + return application.isUseCodeCache() + ? new CacheBasedClassPathBuilder(application) + : new DefaultClassPathBuilder(application); + } +} diff --git a/src/main/java/com/threerings/getdown/classpath/ClassPathElement.java b/src/main/java/com/threerings/getdown/classpath/ClassPathElement.java new file mode 100644 index 0000000..edf64a2 --- /dev/null +++ b/src/main/java/com/threerings/getdown/classpath/ClassPathElement.java @@ -0,0 +1,47 @@ +package com.threerings.getdown.classpath; + +import java.io.File; +import java.net.MalformedURLException; +import java.net.URL; + +/** + * Represents an element of an applications class path. + */ +public class ClassPathElement +{ + public ClassPathElement (File _file) + { + this._file = _file; + } + + /** + * Returns the code resource. + */ + public File getFile () + { + return _file; + } + + + /** + * Returns the elements absolute path on the file system. + */ + public String getAbsolutePath () + { + return _file.getAbsolutePath(); + } + + /** + * Returns the file URL of this class path element. + */ + public URL getURL () + { + try { + return _file.toURI().toURL(); + } catch (MalformedURLException e) { + throw new IllegalStateException("URL of file is illegal: " + getAbsolutePath(), e); + } + } + + private final File _file; +} diff --git a/src/main/java/com/threerings/getdown/classpath/DefaultClassPathBuilder.java b/src/main/java/com/threerings/getdown/classpath/DefaultClassPathBuilder.java new file mode 100644 index 0000000..b6f375b --- /dev/null +++ b/src/main/java/com/threerings/getdown/classpath/DefaultClassPathBuilder.java @@ -0,0 +1,30 @@ +package com.threerings.getdown.classpath; + +import java.util.LinkedHashSet; + +import com.threerings.getdown.data.Application; +import com.threerings.getdown.data.Resource; + +/** + * The default class path builder assembles the class path from the code resources to be found in + * the application directory. + */ +public class DefaultClassPathBuilder extends ClassPathBuilderBase +{ + public DefaultClassPathBuilder(Application _application) { + super(_application); + } + + @Override + public ClassPath buildClassPath () + { + LinkedHashSet classPathEntries = new LinkedHashSet(); + + for (Resource resource: _application.getActiveCodeResources()) { + classPathEntries.add(new ClassPathElement(resource.getFinalTarget())); + } + + return new ClassPath(classPathEntries); + } + +} diff --git a/src/main/java/com/threerings/getdown/classpath/cache/GarbageCollector.java b/src/main/java/com/threerings/getdown/classpath/cache/GarbageCollector.java new file mode 100644 index 0000000..6a40ae1 --- /dev/null +++ b/src/main/java/com/threerings/getdown/classpath/cache/GarbageCollector.java @@ -0,0 +1,91 @@ +package com.threerings.getdown.classpath.cache; + +import java.io.File; +import java.util.concurrent.TimeUnit; + +import com.threerings.getdown.util.file.FileVisitor; +import com.threerings.getdown.util.file.FileWalker; + +/** + * Collects elements in the {@link ResourceCache cache} which became unused and deletes them + * afterwards. + */ +public class GarbageCollector +{ + public GarbageCollector ( + FileWalker _fileWalker, int retentionPeriod, TimeUnit retentionPeriodTimeUnit) + { + this._fileWalker = _fileWalker; + this._retentionMillis = retentionPeriodTimeUnit.toMillis(retentionPeriod); + } + + /** + * Collect and delete the garbage in the cache. + */ + public void collectGarbage () + { + _fileWalker.walkTree(new FileVisitor() { + @Override + public void visit(File file) + { + collect(file); + } + }); + } + + private void collect (File file) { + File cachedFile = getCachedFile(file); + File lastAccessedFile = getLastAccessedFile(file); + + if (!cachedFile.exists() || !lastAccessedFile.exists()) { + if (cachedFile.exists()) { + cachedFile.delete(); + } else { + lastAccessedFile.delete(); + } + } else if (shouldDelete(lastAccessedFile)) { + lastAccessedFile.delete(); + cachedFile.delete(); + } + + File folder = file.getParentFile(); + + if (folder.list().length == 0) { + folder.delete(); + } + } + + private boolean shouldDelete(File lastAccessedFile) { + return System.currentTimeMillis() - lastAccessedFile.lastModified() > _retentionMillis; + } + + private File getLastAccessedFile (File file) + { + if (isLastAccessedFile(file)) { + return file; + } + + return new File( + file.getParentFile(), + file.getName() + ResourceCache.LAST_ACCESSED_FILE_SUFFIX); + } + + private boolean isLastAccessedFile (File file) + { + return file.getName().endsWith(ResourceCache.LAST_ACCESSED_FILE_SUFFIX); + } + + private File getCachedFile (File file) + { + if (!isLastAccessedFile(file)) { + return file; + } + + return new File( + file.getParentFile(), + file.getName().substring(0, file.getName().lastIndexOf("."))); + } + + private final FileWalker _fileWalker; + private final long _retentionMillis; +} diff --git a/src/main/java/com/threerings/getdown/classpath/cache/ResourceCache.java b/src/main/java/com/threerings/getdown/classpath/cache/ResourceCache.java new file mode 100644 index 0000000..6e35b3f --- /dev/null +++ b/src/main/java/com/threerings/getdown/classpath/cache/ResourceCache.java @@ -0,0 +1,73 @@ +package com.threerings.getdown.classpath.cache; + +import java.io.File; +import java.io.IOException; + +import com.threerings.getdown.util.FileUtil; + +/** + * Maintains a cache of code resources. The cache allows multiple application instances of different + * versions to open at the same time. + */ +public class ResourceCache +{ + public ResourceCache (File _cacheDir) throws IOException + { + this._cacheDir = _cacheDir; + + createDirectoryIfNecessary(_cacheDir); + } + + private void createDirectoryIfNecessary (File dir) throws IOException + { + if (!dir.exists() && !dir.mkdirs()) { + throw new IOException("unable to create directory: " + dir.getAbsolutePath()); + } + } + + /** + * Caches the given file under it's {@code digest}. + * + * @return the cached file + */ + public File cacheFile (File fileToCache, String digest) throws IOException + { + File cacheLocation = new File(_cacheDir, digest.substring(0, 2)); + createDirectoryIfNecessary(cacheLocation); + + File cachedFile = new File(cacheLocation, digest + getFileSuffix(fileToCache)); + File lastAccessedFile = new File( + cacheLocation, cachedFile.getName() + LAST_ACCESSED_FILE_SUFFIX); + + if (!cachedFile.exists()) { + createNewFile(cachedFile); + FileUtil.copy(fileToCache, cachedFile); + } + + if (lastAccessedFile.exists()) { + lastAccessedFile.setLastModified(System.currentTimeMillis()); + } else { + createNewFile(lastAccessedFile); + } + + return cachedFile; + } + + private void createNewFile (File fileToCreate) throws IOException + { + if (!fileToCreate.exists() && !fileToCreate.createNewFile()) { + throw new IOException("unable to create new file: " + fileToCreate.getAbsolutePath()); + } + } + + private String getFileSuffix (File fileToCache) { + String fileName = fileToCache.getName(); + int index = fileName.lastIndexOf("."); + + return index > -1 ? fileName.substring(index) : ""; + } + + private final File _cacheDir; + + static final String LAST_ACCESSED_FILE_SUFFIX = ".lastAccessed"; +} diff --git a/src/main/java/com/threerings/getdown/data/Application.java b/src/main/java/com/threerings/getdown/data/Application.java index 9b6e961..4590f64 100644 --- a/src/main/java/com/threerings/getdown/data/Application.java +++ b/src/main/java/com/threerings/getdown/data/Application.java @@ -5,9 +5,21 @@ package com.threerings.getdown.data; +import static com.threerings.getdown.Log.log; + import java.awt.Color; import java.awt.Rectangle; -import java.io.*; +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.FileReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.PrintStream; +import java.io.RandomAccessFile; import java.lang.reflect.Method; import java.net.MalformedURLException; import java.net.URL; @@ -16,27 +28,46 @@ import java.net.URLConnection; import java.nio.channels.FileChannel; import java.nio.channels.FileLock; import java.nio.channels.OverlappingFileLockException; -import java.security.*; +import java.security.AllPermission; +import java.security.CodeSource; +import java.security.GeneralSecurityException; +import java.security.PermissionCollection; +import java.security.Permissions; +import java.security.Signature; import java.security.cert.Certificate; -import java.util.*; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.swing.JApplet; +import org.apache.commons.codec.binary.Base64; + import com.samskivert.io.StreamUtil; import com.samskivert.text.MessageUtil; import com.samskivert.util.ArrayUtil; import com.samskivert.util.RandomUtil; import com.samskivert.util.RunAnywhere; import com.samskivert.util.StringUtil; - -import org.apache.commons.codec.binary.Base64; - +import com.threerings.getdown.classpath.ClassPath; +import com.threerings.getdown.classpath.ClassPathBuilderFactory; import com.threerings.getdown.launcher.RotatingBackgrounds; -import com.threerings.getdown.util.*; - -import static com.threerings.getdown.Log.log; +import com.threerings.getdown.util.ConfigUtil; +import com.threerings.getdown.util.ConnectionUtil; +import com.threerings.getdown.util.FileUtil; +import com.threerings.getdown.util.LaunchUtil; +import com.threerings.getdown.util.ProgressAggregator; +import com.threerings.getdown.util.ProgressObserver; +import com.threerings.getdown.util.VersionUtil; /** * Parses and provide access to the information contained in the getdown.txt @@ -226,10 +257,10 @@ public class Application { _appdir = appdir; _appid = appid; - _signers = (signers == null) ? Collections.emptyList() : signers; + _signers = signers == null ? Collections.emptyList() : signers; _config = getLocalPath(CONFIG_FILE); - _extraJvmArgs = (jvmargs == null) ? ArrayUtil.EMPTY_STRING : jvmargs; - _extraAppArgs = (appargs == null) ? ArrayUtil.EMPTY_STRING : appargs; + _extraJvmArgs = jvmargs == null ? ArrayUtil.EMPTY_STRING : jvmargs; + _extraAppArgs = appargs == null ? ArrayUtil.EMPTY_STRING : appargs; } /** @@ -352,7 +383,7 @@ public class Application return null; } - String infix = (auxgroup == null) ? "" : ("-" + auxgroup); + String infix = auxgroup == null ? "" : "-" + auxgroup; String pfile = "patch" + infix + _version + ".dat"; try { URL remote = new URL(createVAppBase(_targetVersion), encodePath(pfile)); @@ -514,7 +545,7 @@ public class Application // if we are a versioned deployment, create a versioned appbase try { - _vappbase = (_version < 0) ? new URL(_appbase) : createVAppBase(_version); + _vappbase = _version < 0 ? new URL(_appbase) : createVAppBase(_version); } catch (MalformedURLException mue) { String err = MessageUtil.tcompose("m.invalid_appbase", _appbase); throw (IOException) new IOException(err).initCause(mue); @@ -531,7 +562,7 @@ public class Application } } - String appPrefix = StringUtil.isBlank(_appid) ? "" : (_appid + "."); + String appPrefix = StringUtil.isBlank(_appid) ? "" : _appid + "."; // determine our application class name _class = (String)cdata.get(appPrefix + "class"); @@ -656,6 +687,11 @@ public class Application // obtain a thread dump of the running JVM _windebug = getLocalPath("debug.txt").exists(); + // whether to cache code resources and launch from cache + _useCodeCache = Boolean.parseBoolean((String) cdata.get("use_code_cache")); + _codeCacheRetentionDays = cdata.containsKey("code_cache_retention_days") + ? Integer.parseInt((String) cdata.get("use_code_cache")) : 7; + // parse and return our application config UpdateInterface ui = new UpdateInterface(); _name = ui.name = (String)cdata.get("ui.name"); @@ -673,9 +709,9 @@ public class Application } // and now ui.background can refer to the background color, but fall back to black // or white, depending on the brightness of the progressText - Color defaultBackground = (.5f < Color.RGBtoHSB( + Color defaultBackground = .5f < Color.RGBtoHSB( ui.progressText.getRed(), ui.progressText.getGreen(), ui.progressText.getBlue(), - null)[2]) + null)[2] ? Color.BLACK : Color.WHITE; ui.background = parseColor(cdata, "ui.background", defaultBackground); @@ -690,7 +726,7 @@ public class Application // On an installation error, where do we point the user. String installError = parseUrl(cdata, "ui.install_error", null); - ui.installError = (installError == null) ? + ui.installError = installError == null ? "m.default_install_error" : MessageUtil.taint(installError); // the patch notes bits @@ -802,8 +838,8 @@ public class Application } } - boolean minVersionOK = (_javaMinVersion == 0) || (version >= _javaMinVersion); - boolean maxVersionOK = (_javaMaxVersion == 0) || (version <= _javaMaxVersion); + boolean minVersionOK = _javaMinVersion == 0 || version >= _javaMinVersion; + boolean maxVersionOK = _javaMaxVersion == 0 || version <= _javaMaxVersion; return minVersionOK && maxVersionOK; } catch (RuntimeException re) { @@ -901,16 +937,11 @@ public class Application boolean dashJarMode = MANIFEST_CLASS.equals(_class); // add the -classpath arguments if we're not in -jar mode - StringBuilder cpbuf = new StringBuilder(); - for (Resource rsrc : getActiveCodeResources()) { - if (cpbuf.length() > 0) { - cpbuf.append(File.pathSeparator); - } - cpbuf.append(rsrc.getFinalTarget().getAbsolutePath()); - } + ClassPath classPath = ClassPathBuilderFactory.create(this).buildClassPath(); + if (!dashJarMode) { args.add("-classpath"); - args.add(cpbuf.toString()); + args.add(classPath.asArgumentString()); } // we love our Mac users, so we do nice things to preserve our application identity @@ -960,7 +991,7 @@ public class Application // if we're in -jar mode add those arguments, otherwise add the app class name if (dashJarMode) { args.add("-jar"); - args.add(cpbuf.toString()); + args.add(classPath.asArgumentString()); } else { args.add(_class); } @@ -1006,20 +1037,17 @@ public class Application /** * Runs this application directly in the current VM. + * @throws IOException */ - public void invokeDirect (JApplet applet) + public void invokeDirect (JApplet applet) throws IOException { - // create a custom class loader - ArrayList jars = new ArrayList(); - for (Resource rsrc : getActiveCodeResources()) { - try { - jars.add(new URL("file", "", rsrc.getFinalTarget().getAbsolutePath())); - } catch (Exception e) { - e.printStackTrace(System.err); - } - } + ClassPath classPath = ClassPathBuilderFactory.create(this).buildClassPath(); + + URL[] jarUrls = classPath.asUrls(); + + // create custom class loader URLClassLoader loader = new URLClassLoader( - jars.toArray(new URL[jars.size()]), + jarUrls, ClassLoader.getSystemClassLoader()) { @Override protected PermissionCollection getPermissions (CodeSource code) { Permissions perms = new Permissions(); @@ -1030,7 +1058,9 @@ public class Application Thread.currentThread().setContextClassLoader(loader); log.info("Configured URL class loader:"); - for (URL url : jars) log.info(" " + url); + for (URL url : jarUrls) { + log.info(" " + url); + } // configure any system properties that we can for (String jvmarg : _jvmargs) { @@ -1126,12 +1156,6 @@ public class Application log.info("Verifying application: " + _vappbase); log.info("Version: " + _version); log.info("Class: " + _class); -// log.info("Code: " + -// StringUtil.toString(getCodeResources().iterator())); -// log.info("Resources: " + -// StringUtil.toString(getActiveResources().iterator())); -// log.info("JVM Args: " + StringUtil.toString(_jvmargs.iterator())); -// log.info("App Args: " + StringUtil.toString(_appargs.iterator())); // this will read in the contents of the digest file and validate itself try { @@ -1145,7 +1169,7 @@ public class Application if (_version == -1) { // make a note of the old meta-digest, if this changes we need to revalidate all of our // resources as one or more of them have also changed - String olddig = (_digest == null) ? "" : _digest.getMetaDigest(); + String olddig = _digest == null ? "" : _digest.getMetaDigest(); try { status.updateStatus("m.checking"); downloadDigestFile(); @@ -1299,7 +1323,7 @@ public class Application failures.add(rsrc); } - return (failures.size() == 0) ? null : failures; + return failures.size() == 0 ? null : failures; } /** @@ -1357,6 +1381,24 @@ public class Application return _version; } + /** + * Returns whether the application should cache code resources prior to launching the + * application. + */ + public boolean isUseCodeCache () + { + return _useCodeCache; + } + + /** + * Returns the number of days a cached code resource is allowed to stay unused before it + * becomes eligible for deletion. + */ + public int getCodeCacheRetentionDays () + { + return _codeCacheRetentionDays; + } + /** * Creates a versioned application base URL for the specified version. */ @@ -1602,7 +1644,7 @@ public class Application { String value = (String)cdata.get(name); Rectangle rect = parseRect(name, value); - return (rect == null) ? def : rect; + return rect == null ? def : rect; } /** Helper function to add all values in {@code values} (if non-null) to {@code target}. */ @@ -1647,7 +1689,7 @@ public class Application { String value = (String)cdata.get(name); Color color = parseColor(value); - return (color == null) ? def : color; + return color == null ? def : color; } /** @@ -1670,7 +1712,7 @@ public class Application protected String[] parseList (Map cdata, String name) { String value = (String)cdata.get(name); - return (value == null) ? ArrayUtil.EMPTY_STRING : StringUtil.parseStringArray(value); + return value == null ? ArrayUtil.EMPTY_STRING : StringUtil.parseStringArray(value); } /** @@ -1746,6 +1788,19 @@ public class Application } } + /** + * Returns the digest of the given {@code resource}. + */ + public String getDigest (Resource resource) + { + return _digest.getDigest(resource); + } + + public File getAppdir() + { + return _appdir; + } + protected File _appdir; protected String _appid; protected File _config; @@ -1780,6 +1835,9 @@ public class Application protected List _codes = new ArrayList(); protected List _resources = new ArrayList(); + protected boolean _useCodeCache; + protected int _codeCacheRetentionDays; + protected Map _auxgroups = new HashMap(); protected Map _auxactive = new HashMap(); diff --git a/src/main/java/com/threerings/getdown/data/Digest.java b/src/main/java/com/threerings/getdown/data/Digest.java index ecdde99..5cdddf2 100644 --- a/src/main/java/com/threerings/getdown/data/Digest.java +++ b/src/main/java/com/threerings/getdown/data/Digest.java @@ -5,27 +5,24 @@ package com.threerings.getdown.data; +import static com.threerings.getdown.Log.log; + import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStreamWriter; import java.io.PrintWriter; - import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; - import java.util.HashMap; import java.util.List; import com.samskivert.io.StreamUtil; import com.samskivert.text.MessageUtil; import com.samskivert.util.StringUtil; - import com.threerings.getdown.util.ConfigUtil; import com.threerings.getdown.util.ProgressObserver; -import static com.threerings.getdown.Log.log; - /** * Manages the digest.txt file and the computing and processing of MD5 digests for an * application. @@ -148,6 +145,14 @@ public class Digest data.append(path).append(" = ").append(digest).append("\n"); } + /** + * Returns the digest of the given {@code resource}. + */ + public String getDigest (Resource resource) + { + return _digests.get(resource.getPath()); + } + protected HashMap _digests = new HashMap(); protected String _metaDigest = ""; } diff --git a/src/main/java/com/threerings/getdown/util/FileUtil.java b/src/main/java/com/threerings/getdown/util/FileUtil.java index c957e59..bbfcb2a 100644 --- a/src/main/java/com/threerings/getdown/util/FileUtil.java +++ b/src/main/java/com/threerings/getdown/util/FileUtil.java @@ -5,7 +5,15 @@ package com.threerings.getdown.util; -import java.io.*; +import static com.threerings.getdown.Log.log; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; import java.util.ArrayList; import java.util.List; import java.util.jar.JarOutputStream; @@ -14,8 +22,6 @@ import java.util.zip.GZIPInputStream; import com.samskivert.io.StreamUtil; -import static com.threerings.getdown.Log.log; - /** * File related utilities. */ @@ -109,7 +115,6 @@ public class FileUtil extends com.samskivert.util.FileUtil Pack200.Unpacker unpacker = Pack200.newUnpacker(); unpacker.unpack(packedJarIn, jarOutputStream); return true; - } catch (IOException e) { log.warning("Failed to unpack packed 200 jar file", "jar", packedJar, "error", e); return false; @@ -120,4 +125,22 @@ public class FileUtil extends com.samskivert.util.FileUtil StreamUtil.close(packedJarIn); } } + + /** + * Copies the given {@code source} file to the given {@code target}. + */ + public static void copy(File source, File target) throws IOException { + FileInputStream in = null; + FileOutputStream out = null; + + try { + in = new FileInputStream(source); + out = new FileOutputStream(target); + + StreamUtil.copy(in, out); + } finally { + StreamUtil.close(in); + StreamUtil.close(out); + } + } } diff --git a/src/main/java/com/threerings/getdown/util/file/FileVisitor.java b/src/main/java/com/threerings/getdown/util/file/FileVisitor.java new file mode 100644 index 0000000..29f01ed --- /dev/null +++ b/src/main/java/com/threerings/getdown/util/file/FileVisitor.java @@ -0,0 +1,6 @@ +package com.threerings.getdown.util.file; +import java.io.File; + +public interface FileVisitor { + void visit(File file); +} diff --git a/src/main/java/com/threerings/getdown/util/file/FileWalker.java b/src/main/java/com/threerings/getdown/util/file/FileWalker.java new file mode 100644 index 0000000..2ffc613 --- /dev/null +++ b/src/main/java/com/threerings/getdown/util/file/FileWalker.java @@ -0,0 +1,42 @@ +package com.threerings.getdown.util.file; + +import java.io.File; +import java.util.ArrayDeque; +import java.util.Arrays; +import java.util.Deque; + +/** + * Recursively walks over files and folders. A {@link FileVisitor visitor} is informed for each path + * traversed. + */ +public class FileWalker +{ + public FileWalker (File start) + { + this._start = start; + } + + /** + * Walks over the directory tree with the provided {@code visitor}. + */ + public void walkTree (FileVisitor visitor) + { + Deque stack = new ArrayDeque(Arrays.asList(_start.listFiles())); + + while (!stack.isEmpty()) { + File currentFile = stack.pop(); + + if (currentFile.exists()) { + visitor.visit(currentFile); + + if (currentFile.isDirectory()) { + for (File file: currentFile.listFiles()) { + stack.push(file); + } + } + } + } + } + + private final File _start; +} diff --git a/src/test/java/com/threerings/getdown/classpath/CacheBasedClassPathBuilderTest.java b/src/test/java/com/threerings/getdown/classpath/CacheBasedClassPathBuilderTest.java new file mode 100644 index 0000000..f76a317 --- /dev/null +++ b/src/test/java/com/threerings/getdown/classpath/CacheBasedClassPathBuilderTest.java @@ -0,0 +1,44 @@ +package com.threerings.getdown.classpath; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.IOException; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.runners.MockitoJUnitRunner; + +import com.threerings.getdown.util.FileUtil; + +/** + * Tests {@link CacheBasedClassPathBuilderTest}. + */ +@RunWith(MockitoJUnitRunner.class) +public class CacheBasedClassPathBuilderTest extends ClassPathBuilderTestBase +{ + @Test + public void shouldBuildCacheBasedClassPath () throws IOException + { + when(_application.getDigest(_firstJar)).thenReturn("first"); + when(_application.getDigest(_secondJar)).thenReturn("second"); + when(_application.getCodeCacheRetentionDays()).thenReturn(1); + + File firstCachedJarFile = FileUtil.newFile( + _appdir.getRoot(), + CacheBasedClassPathBuilder.CACHE_DIR, "fi", "first.jar"); + + File secondCachedJarFile = FileUtil.newFile( + _appdir.getRoot(), + CacheBasedClassPathBuilder.CACHE_DIR, "se", "second.jar"); + + String expectedClassPath = firstCachedJarFile.getAbsolutePath() + + File.pathSeparator + + secondCachedJarFile.getAbsolutePath(); + + ClassPath classPath = new CacheBasedClassPathBuilder(_application).buildClassPath(); + + assertEquals(expectedClassPath, classPath.asArgumentString()); + } +} diff --git a/src/test/java/com/threerings/getdown/classpath/ClassPathBuilderFactoryTest.java b/src/test/java/com/threerings/getdown/classpath/ClassPathBuilderFactoryTest.java new file mode 100644 index 0000000..d16e5df --- /dev/null +++ b/src/test/java/com/threerings/getdown/classpath/ClassPathBuilderFactoryTest.java @@ -0,0 +1,37 @@ +package com.threerings.getdown.classpath; + +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.when; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; + +import com.threerings.getdown.data.Application; + +/** + * Checks wether the correct {@link ClassPathBuilder class path builders} are created. + */ +@RunWith(MockitoJUnitRunner.class) +public class ClassPathBuilderFactoryTest +{ + @Test + public void shouldCreateDefaultClassPathBuilder () + { + when(_application.isUseCodeCache()).thenReturn(Boolean.FALSE); + + assertTrue(ClassPathBuilderFactory.create(_application) instanceof DefaultClassPathBuilder); + } + + @Test + public void shouldCreateCacheClassPathBuilder () + { + when(_application.isUseCodeCache()).thenReturn(Boolean.TRUE); + + assertTrue(ClassPathBuilderFactory.create(_application) instanceof CacheBasedClassPathBuilder); + } + + @Mock + private Application _application; +} diff --git a/src/test/java/com/threerings/getdown/classpath/ClassPathBuilderTestBase.java b/src/test/java/com/threerings/getdown/classpath/ClassPathBuilderTestBase.java new file mode 100644 index 0000000..a6a80e2 --- /dev/null +++ b/src/test/java/com/threerings/getdown/classpath/ClassPathBuilderTestBase.java @@ -0,0 +1,48 @@ +package com.threerings.getdown.classpath; + +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.IOException; +import java.util.Arrays; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.rules.TemporaryFolder; +import org.mockito.Mock; + +import com.threerings.getdown.data.Application; +import com.threerings.getdown.data.Resource; + +/** + * Base class for {@link ClassPathBuilder} tests. + */ +public abstract class ClassPathBuilderTestBase { + @Before + public void setupFilesAndResources () throws IOException + { + _firstJarFile = _appdir.newFile("a.jar"); + _secondJarFile = _appdir.newFile("b.jar"); + + when(_firstJar.getFinalTarget()).thenReturn(_firstJarFile); + when(_secondJar.getFinalTarget()).thenReturn(_secondJarFile); + when(_application.getActiveCodeResources()).thenReturn(Arrays.asList(_firstJar, _secondJar)); + when(_application.getAppdir()).thenReturn(_appdir.getRoot()); + } + + @Mock + protected Application _application; + + @Mock + protected Resource _firstJar; + + protected File _firstJarFile; + + @Mock + protected Resource _secondJar; + + protected File _secondJarFile; + + @Rule + public TemporaryFolder _appdir = new TemporaryFolder(); +} diff --git a/src/test/java/com/threerings/getdown/classpath/ClassPathTest.java b/src/test/java/com/threerings/getdown/classpath/ClassPathTest.java new file mode 100644 index 0000000..aaf9e9a --- /dev/null +++ b/src/test/java/com/threerings/getdown/classpath/ClassPathTest.java @@ -0,0 +1,60 @@ +package com.threerings.getdown.classpath; + +import static org.junit.Assert.assertEquals; + +import java.io.File; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URISyntaxException; +import java.net.URL; +import java.util.LinkedHashSet; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +/** + * Tests for {@link ClassPath}. + */ +public class ClassPathTest +{ + @Before + public void createJarsAndSetupClassPath () throws IOException + { + _firstJar = _folder.newFile("a.jar"); + _secondJar = _folder.newFile("b.jar"); + + LinkedHashSet classPathEntries = new LinkedHashSet(); + + classPathEntries.add(new ClassPathElement(_firstJar)); + classPathEntries.add(new ClassPathElement(_secondJar)); + + _classPath = new ClassPath(classPathEntries); + } + + @Test + public void shouldCreateValidArgumentString () + { + assertEquals( + _firstJar.getAbsolutePath() + File.pathSeparator + _secondJar.getAbsolutePath(), + _classPath.asArgumentString()); + } + + @Test + public void shouldProvideJarUrls () throws MalformedURLException, URISyntaxException + { + URL[] actualUrls = _classPath.asUrls(); + + assertEquals(_firstJar, new File(actualUrls[0].toURI())); + assertEquals(_secondJar, new File(actualUrls[1].toURI())); + } + + @Rule + public TemporaryFolder _folder = new TemporaryFolder(); + + private File _firstJar; + private File _secondJar; + + private ClassPath _classPath; +} diff --git a/src/test/java/com/threerings/getdown/classpath/DefaultClassPathBuilderTest.java b/src/test/java/com/threerings/getdown/classpath/DefaultClassPathBuilderTest.java new file mode 100644 index 0000000..96a088e --- /dev/null +++ b/src/test/java/com/threerings/getdown/classpath/DefaultClassPathBuilderTest.java @@ -0,0 +1,31 @@ +package com.threerings.getdown.classpath; + +import static org.junit.Assert.assertEquals; + +import java.io.File; +import java.io.IOException; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.runners.MockitoJUnitRunner; + +/** + * Tests the {@link DefaultClassPathBuilder}. + */ +@RunWith(MockitoJUnitRunner.class) +public class DefaultClassPathBuilderTest extends ClassPathBuilderTestBase +{ + @Test + public void shouldBuildClassPath () throws IOException + { + ClassPath classPath = new DefaultClassPathBuilder(_application).buildClassPath(); + + String expectedClassPath = _firstJarFile.getAbsolutePath() + + File.pathSeparator + + _secondJarFile.getAbsolutePath(); + + assertEquals(expectedClassPath, classPath.asArgumentString()); + } + + +} diff --git a/src/test/java/com/threerings/getdown/classpath/cache/GarbageCollectorTest.java b/src/test/java/com/threerings/getdown/classpath/cache/GarbageCollectorTest.java new file mode 100644 index 0000000..1a9975d --- /dev/null +++ b/src/test/java/com/threerings/getdown/classpath/cache/GarbageCollectorTest.java @@ -0,0 +1,91 @@ +package com.threerings.getdown.classpath.cache; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeTrue; + +import java.io.File; +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import com.threerings.getdown.util.file.FileWalker; + +/** + * Validates that cache garbage is collected and deleted correctly. + */ +public class GarbageCollectorTest +{ + @Before + public void setupFiles () throws IOException + { + _cachedFile = _folder.newFile("abc123.jar"); + _lastAccessedFile = _folder.newFile("abc123.jar" + ResourceCache.LAST_ACCESSED_FILE_SUFFIX); + } + + @Test + public void shouldDeleteCacheEntryIfRetentionPeriodIsReached () + { + gcNow(); + + assertFalse(_cachedFile.exists()); + assertFalse(_lastAccessedFile.exists()); + } + + @Test + public void shouldDeleteCacheFolderIfFolderIsEmpty () + { + gcNow(); + + assertFalse(_folder.getRoot().exists()); + } + + private void gcNow() { + GarbageCollector collector = new GarbageCollector( + new FileWalker(_folder.getRoot()), -1, TimeUnit.MILLISECONDS); + + collector.collectGarbage(); + } + + @Test + public void shouldKeepFilesInCacheIfRententionPeriodIsNotReached () + { + GarbageCollector collector = new GarbageCollector( + new FileWalker(_folder.getRoot()), 1, TimeUnit.DAYS); + + collector.collectGarbage(); + + assertTrue(_cachedFile.exists()); + assertTrue(_lastAccessedFile.exists()); + } + + @Test + public void shouldDeleteCachedFileIfLastAccessedFileIsMissing () + { + assumeTrue(_lastAccessedFile.delete()); + + gcNow(); + + assertFalse(_cachedFile.exists()); + } + + @Test + public void shouldDeleteLastAccessedFileIfCachedFileIsMissing () + { + assumeTrue(_cachedFile.delete()); + + gcNow(); + + assertFalse(_lastAccessedFile.exists()); + } + + @Rule + public TemporaryFolder _folder = new TemporaryFolder(); + + private File _cachedFile; + private File _lastAccessedFile; +} diff --git a/src/test/java/com/threerings/getdown/classpath/cache/ResourceCacheTest.java b/src/test/java/com/threerings/getdown/classpath/cache/ResourceCacheTest.java new file mode 100644 index 0000000..5c87e2c --- /dev/null +++ b/src/test/java/com/threerings/getdown/classpath/cache/ResourceCacheTest.java @@ -0,0 +1,81 @@ +package com.threerings.getdown.classpath.cache; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +/** + * Asserts the correct functionality of the {@link ResourceCache}. + */ +public class ResourceCacheTest +{ + @Before + public void setupCache () throws IOException { + _fileToCache = _folder.newFile("filetocache.jar"); + _cache = new ResourceCache(_folder.newFolder(".cache")); + } + + @Test + public void shouldCacheFile () throws IOException + { + assertEquals("abc123.jar", cacheFile().getName()); + } + + private File cacheFile() throws IOException + { + return _cache.cacheFile(_fileToCache, "abc123"); + } + + @Test + public void shouldTrackFileUsage () throws IOException + { + File lastAccessedFile = new File( + cacheFile().getParentFile(), + "abc123.jar" + ResourceCache.LAST_ACCESSED_FILE_SUFFIX); + + assertTrue(lastAccessedFile.exists()); + } + + @Test + public void shouldNotCacheTheSameFile () throws Exception + { + File cachedFile = cacheFile(); + + TimeUnit.MILLISECONDS.sleep(5); + + // caching it another time + File sameCachedFile = cacheFile(); + + assertEquals(cachedFile.lastModified(), sameCachedFile.lastModified()); + } + + @Test + public void shouldRememberWhenFileWasRequested () throws Exception + { + cacheFile(); + + TimeUnit.MILLISECONDS.sleep(5); + + // caching it another time + File sameCachedFile = cacheFile(); + File lastAccessed = new File( + sameCachedFile.getParentFile(), + sameCachedFile.getName() + ResourceCache.LAST_ACCESSED_FILE_SUFFIX); + + assertTrue(lastAccessed.lastModified() > sameCachedFile.lastModified()); + } + + @Rule + public TemporaryFolder _folder = new TemporaryFolder(); + + private File _fileToCache; + private ResourceCache _cache; +} diff --git a/src/test/java/com/threerings/getdown/util/FileUtilTest.java b/src/test/java/com/threerings/getdown/util/FileUtilTest.java index c5c64f4..30214bf 100644 --- a/src/test/java/com/threerings/getdown/util/FileUtilTest.java +++ b/src/test/java/com/threerings/getdown/util/FileUtilTest.java @@ -5,26 +5,49 @@ package com.threerings.getdown.util; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.io.File; import java.io.IOException; import java.io.StringReader; import java.util.List; -import org.junit.*; -import static org.junit.Assert.*; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; /** * Tests {@link FileUtil}. */ public class FileUtilTest { - @Test public void testReadLines () throws IOException + @Test + public void testReadLines () throws IOException { String data = "This is a test\nof a file with\na few lines\n"; List lines = FileUtil.readLines(new StringReader(data)); String[] linesBySplit = data.split("\n"); assertEquals(linesBySplit.length, lines.size()); - for (int ii = 0; ii < lines.size(); ii++) { - assertEquals(linesBySplit[ii], lines.get(ii)); + for (int i = 0; i < lines.size(); i++) { + assertEquals(linesBySplit[i], lines.get(i)); } } + + @Test + public void shouldCopyFile () throws IOException + { + File source = _folder.newFile("source.txt"); + File target = new File(_folder.getRoot(), "target.txt"); + + assertFalse(target.exists()); + + FileUtil.copy(source, target); + + assertTrue(target.exists()); + } + + @Rule + public TemporaryFolder _folder = new TemporaryFolder(); } diff --git a/src/test/java/com/threerings/getdown/util/file/FileWalkerTest.java b/src/test/java/com/threerings/getdown/util/file/FileWalkerTest.java new file mode 100644 index 0000000..c4787e0 --- /dev/null +++ b/src/test/java/com/threerings/getdown/util/file/FileWalkerTest.java @@ -0,0 +1,41 @@ +package com.threerings.getdown.util.file; + +import static org.junit.Assert.assertEquals; + +import java.io.File; +import java.io.IOException; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +/** + * Class under test: {@link FileWalker}. + */ +public class FileWalkerTest +{ + @Test + public void shouldRecursivelyWalkOverFilesAndFolders () throws IOException + { + _folder.newFile("a.txt"); + new File(_folder.newFolder("b"), "b.txt").createNewFile(); + + CountingVisitor visitor = new CountingVisitor(); + new FileWalker(_folder.getRoot()).walkTree(visitor); + + assertEquals(3, visitor.fileCount); + } + + public static class CountingVisitor implements FileVisitor + { + int fileCount = 0; + + @Override + public void visit(File file) { + fileCount++; + } + } + + @Rule + public TemporaryFolder _folder = new TemporaryFolder(); +} From 5690570f93c62241ce33d7838d6c7b6e11c638b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20K=C3=BCstermann?= Date: Tue, 20 Sep 2016 13:50:52 +0200 Subject: [PATCH 08/15] increase the "sleep time" --- .../threerings/getdown/classpath/cache/ResourceCacheTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/threerings/getdown/classpath/cache/ResourceCacheTest.java b/src/test/java/com/threerings/getdown/classpath/cache/ResourceCacheTest.java index 5c87e2c..3eef676 100644 --- a/src/test/java/com/threerings/getdown/classpath/cache/ResourceCacheTest.java +++ b/src/test/java/com/threerings/getdown/classpath/cache/ResourceCacheTest.java @@ -49,7 +49,7 @@ public class ResourceCacheTest { File cachedFile = cacheFile(); - TimeUnit.MILLISECONDS.sleep(5); + TimeUnit.MILLISECONDS.sleep(50); // caching it another time File sameCachedFile = cacheFile(); @@ -62,7 +62,7 @@ public class ResourceCacheTest { cacheFile(); - TimeUnit.MILLISECONDS.sleep(5); + TimeUnit.MILLISECONDS.sleep(50); // caching it another time File sameCachedFile = cacheFile(); From 310be0e3264f24be48352363dd3711efbc5fdb04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20K=C3=BCstermann?= Date: Tue, 20 Sep 2016 14:20:07 +0200 Subject: [PATCH 09/15] further increased the time to wait before proceeding with the test --- .../threerings/getdown/classpath/cache/ResourceCacheTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/threerings/getdown/classpath/cache/ResourceCacheTest.java b/src/test/java/com/threerings/getdown/classpath/cache/ResourceCacheTest.java index 3eef676..8921d4e 100644 --- a/src/test/java/com/threerings/getdown/classpath/cache/ResourceCacheTest.java +++ b/src/test/java/com/threerings/getdown/classpath/cache/ResourceCacheTest.java @@ -49,7 +49,7 @@ public class ResourceCacheTest { File cachedFile = cacheFile(); - TimeUnit.MILLISECONDS.sleep(50); + TimeUnit.MILLISECONDS.sleep(500); // caching it another time File sameCachedFile = cacheFile(); @@ -62,7 +62,7 @@ public class ResourceCacheTest { cacheFile(); - TimeUnit.MILLISECONDS.sleep(50); + TimeUnit.MILLISECONDS.sleep(500); // caching it another time File sameCachedFile = cacheFile(); From 33dafbaa4adafae5fa0c73045c38fe1992c6203d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20K=C3=BCstermann?= Date: Tue, 20 Sep 2016 14:31:22 +0200 Subject: [PATCH 10/15] completely get rid of the "sleep" --- .../classpath/cache/ResourceCacheTest.java | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/test/java/com/threerings/getdown/classpath/cache/ResourceCacheTest.java b/src/test/java/com/threerings/getdown/classpath/cache/ResourceCacheTest.java index 8921d4e..dd589d8 100644 --- a/src/test/java/com/threerings/getdown/classpath/cache/ResourceCacheTest.java +++ b/src/test/java/com/threerings/getdown/classpath/cache/ResourceCacheTest.java @@ -49,28 +49,34 @@ public class ResourceCacheTest { File cachedFile = cacheFile(); - TimeUnit.MILLISECONDS.sleep(500); + cachedFile.setLastModified(YESTERDAY); + + long expectedLastModified = cachedFile.lastModified(); // caching it another time File sameCachedFile = cacheFile(); - assertEquals(cachedFile.lastModified(), sameCachedFile.lastModified()); + assertEquals(expectedLastModified, sameCachedFile.lastModified()); } @Test public void shouldRememberWhenFileWasRequested () throws Exception { - cacheFile(); + File cachedFile = cacheFile(); - TimeUnit.MILLISECONDS.sleep(500); + File lastAccessedFile = new File( + cachedFile.getParentFile(), + cachedFile.getName() + ResourceCache.LAST_ACCESSED_FILE_SUFFIX); + + lastAccessedFile.setLastModified(YESTERDAY); + + long lastAccessed = lastAccessedFile.lastModified(); // caching it another time - File sameCachedFile = cacheFile(); - File lastAccessed = new File( - sameCachedFile.getParentFile(), - sameCachedFile.getName() + ResourceCache.LAST_ACCESSED_FILE_SUFFIX); + cacheFile(); - assertTrue(lastAccessed.lastModified() > sameCachedFile.lastModified()); + + assertTrue(lastAccessedFile.lastModified() > lastAccessed); } @Rule @@ -78,4 +84,6 @@ public class ResourceCacheTest private File _fileToCache; private ResourceCache _cache; + + private static final long YESTERDAY = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1); } From fda9e2b1e96957ec990f151ec51c35bf78ae1499 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20K=C3=BCstermann?= Date: Tue, 20 Sep 2016 14:40:44 +0200 Subject: [PATCH 11/15] Use JUnit assert instead of the java keyword --- .../threerings/getdown/data/SysPropsTest.java | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/test/java/com/threerings/getdown/data/SysPropsTest.java b/src/test/java/com/threerings/getdown/data/SysPropsTest.java index 2b83ba1..cac3d3f 100644 --- a/src/test/java/com/threerings/getdown/data/SysPropsTest.java +++ b/src/test/java/com/threerings/getdown/data/SysPropsTest.java @@ -5,16 +5,27 @@ package com.threerings.getdown.data; -import org.junit.*; +import static org.junit.Assert.assertTrue; -public class SysPropsTest { +import org.junit.Test; - @Test public void testParseJavaVersion () { - long vers = SysProps.parseJavaVersion("java.version", "(\\d+)\\.(\\d+)\\.(\\d+)(_\\d+)?"); - assert(vers > 1060000); +public class SysPropsTest +{ + @Test + public void shouldParseJavaVersion () + { + long version = SysProps.parseJavaVersion("java.version", + "(\\d+)\\.(\\d+)\\.(\\d+)(_\\d+)?"); - long runVers = SysProps.parseJavaVersion("java.runtime.version", - "(\\d+)\\.(\\d+)\\.(\\d+)(_\\d+)?(-b\\d+)?"); - assert(runVers > 106000000); - } + assertTrue(version > 1060000); + } + + @Test + public void shouldParseJavaRuntimeVersion () + { + long version = SysProps.parseJavaVersion("java.runtime.version", + "(\\d+)\\.(\\d+)\\.(\\d+)(_\\d+)?(-b\\d+)?"); + + assertTrue(version > 106000000); + } } From 7f7ac709a6aa3a446acb942cfff78756849ffd4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20K=C3=BCstermann?= Date: Tue, 20 Sep 2016 15:04:26 +0200 Subject: [PATCH 12/15] fixed a bunch of typos and german translations --- .../com/threerings/getdown/messages.properties | 4 ++-- .../com/threerings/getdown/messages_de.properties | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/main/resources/com/threerings/getdown/messages.properties b/src/main/resources/com/threerings/getdown/messages.properties index b73f08e..c219af9 100644 --- a/src/main/resources/com/threerings/getdown/messages.properties +++ b/src/main/resources/com/threerings/getdown/messages.properties @@ -56,7 +56,7 @@ m.init_failed = Our configuration file is missing or corrupt. Attempting \ to download a new copy... m.java_download_failed = We were unable to automatically download the \ - necessary verson of Java for your computer.\n\n\ + necessary version of Java for your computer.\n\n\ Please go to www.java.com and download the latest version of \ Java, then try running the application again. @@ -99,7 +99,7 @@ m.corrupt_digest_signature_error = We couldn't verify the application's digital 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. + installer are running. This one will stop and let another complete. m.applet_stopped = Getdown's applet was told to stop working. diff --git a/src/main/resources/com/threerings/getdown/messages_de.properties b/src/main/resources/com/threerings/getdown/messages_de.properties index 5326af8..8d9b83c 100644 --- a/src/main/resources/com/threerings/getdown/messages_de.properties +++ b/src/main/resources/com/threerings/getdown/messages_de.properties @@ -66,7 +66,7 @@ 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 \ +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. @@ -113,10 +113,10 @@ m.applet_stopped = Die Anwendung wurde beendet. # application/digest errors -m.missing_appbase = In der Konfigurations-Datei fehlt die 'appbase'. -m.invalid_version = In der Konfigurations-Datei steht die falsche Version. -m.invalid_appbase = In der Konfigurations-Datei steht die falsche 'appbase'. -m.missing_class = In der Konfigurations-Datei fehlt die Anwendungs-Klasse. -m.missing_code = Die Konfigurations-Datei enth\u00e4lt keine Code-Quellen. -m.invalid_digest_file = Die \u00dcbersichts-Datei ist ung\u00fcltig. +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. From 49a7e2821606e2c445d4081dbb946e693f842505 Mon Sep 17 00:00:00 2001 From: Michael Bayne Date: Sun, 25 Sep 2016 10:28:50 -0700 Subject: [PATCH 13/15] Undid style changes, reduced OO boilerplate. --- .../classpath/CacheBasedClassPathBuilder.java | 61 ------- .../getdown/classpath/ClassPath.java | 38 ++-- .../getdown/classpath/ClassPathBuilder.java | 13 -- .../classpath/ClassPathBuilderBase.java | 14 -- .../classpath/ClassPathBuilderFactory.java | 22 --- .../getdown/classpath/ClassPathElement.java | 47 ----- .../getdown/classpath/ClassPaths.java | 76 ++++++++ .../classpath/DefaultClassPathBuilder.java | 30 ---- .../classpath/cache/GarbageCollector.java | 96 ++++------ .../threerings/getdown/data/Application.java | 164 +++++++----------- .../com/threerings/getdown/data/Digest.java | 21 +-- .../com/threerings/getdown/util/FileUtil.java | 47 +++-- .../threerings/getdown/util/LaunchUtil.java | 5 +- .../getdown/util/file/FileVisitor.java | 6 - .../getdown/util/file/FileWalker.java | 42 ----- .../CacheBasedClassPathBuilderTest.java | 44 ----- .../ClassPathBuilderFactoryTest.java | 37 ---- .../classpath/ClassPathBuilderTestBase.java | 48 ----- .../getdown/classpath/ClassPathTest.java | 37 ++-- .../getdown/classpath/ClassPathsTest.java | 68 ++++++++ .../DefaultClassPathBuilderTest.java | 31 ---- .../classpath/cache/GarbageCollectorTest.java | 49 ++---- .../classpath/cache/ResourceCacheTest.java | 48 ++--- .../threerings/getdown/util/FileUtilTest.java | 41 +++-- .../getdown/util/file/FileWalkerTest.java | 41 ----- 25 files changed, 373 insertions(+), 753 deletions(-) delete mode 100644 src/main/java/com/threerings/getdown/classpath/CacheBasedClassPathBuilder.java delete mode 100644 src/main/java/com/threerings/getdown/classpath/ClassPathBuilder.java delete mode 100644 src/main/java/com/threerings/getdown/classpath/ClassPathBuilderBase.java delete mode 100644 src/main/java/com/threerings/getdown/classpath/ClassPathBuilderFactory.java delete mode 100644 src/main/java/com/threerings/getdown/classpath/ClassPathElement.java create mode 100644 src/main/java/com/threerings/getdown/classpath/ClassPaths.java delete mode 100644 src/main/java/com/threerings/getdown/classpath/DefaultClassPathBuilder.java delete mode 100644 src/main/java/com/threerings/getdown/util/file/FileVisitor.java delete mode 100644 src/main/java/com/threerings/getdown/util/file/FileWalker.java delete mode 100644 src/test/java/com/threerings/getdown/classpath/CacheBasedClassPathBuilderTest.java delete mode 100644 src/test/java/com/threerings/getdown/classpath/ClassPathBuilderFactoryTest.java delete mode 100644 src/test/java/com/threerings/getdown/classpath/ClassPathBuilderTestBase.java create mode 100644 src/test/java/com/threerings/getdown/classpath/ClassPathsTest.java delete mode 100644 src/test/java/com/threerings/getdown/classpath/DefaultClassPathBuilderTest.java delete mode 100644 src/test/java/com/threerings/getdown/util/file/FileWalkerTest.java diff --git a/src/main/java/com/threerings/getdown/classpath/CacheBasedClassPathBuilder.java b/src/main/java/com/threerings/getdown/classpath/CacheBasedClassPathBuilder.java deleted file mode 100644 index e98b824..0000000 --- a/src/main/java/com/threerings/getdown/classpath/CacheBasedClassPathBuilder.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.threerings.getdown.classpath; - -import java.io.File; -import java.io.IOException; -import java.util.LinkedHashSet; -import java.util.concurrent.TimeUnit; - -import com.threerings.getdown.classpath.cache.GarbageCollector; -import com.threerings.getdown.classpath.cache.ResourceCache; -import com.threerings.getdown.data.Application; -import com.threerings.getdown.data.Resource; -import com.threerings.getdown.util.file.FileWalker; - -/** - * Instead of building the class path from the application directory, this class path builder puts - * the files that make up the class path into a cache and assembles the class path from the cache - * directory. - */ -public class CacheBasedClassPathBuilder extends ClassPathBuilderBase -{ - static final String CACHE_DIR = ".cache"; - - public CacheBasedClassPathBuilder(Application application) - { - super(application); - } - - @Override - public ClassPath buildClassPath () throws IOException - { - File cacheDir = new File(_application.getAppdir(), CACHE_DIR); - - // a negative value of code_cache_retention_days allows to clean up the cache forcefully - if (_application.getCodeCacheRetentionDays() <= 0) { - runGarbageCollection(cacheDir); - } - - ResourceCache cache = new ResourceCache(cacheDir); - - LinkedHashSet classPathEntries = new LinkedHashSet(); - - for (Resource resource: _application.getActiveCodeResources()) { - classPathEntries.add( - new ClassPathElement( - cache.cacheFile(resource.getFinalTarget(), _application.getDigest(resource)))); - } - - if (_application.getCodeCacheRetentionDays() > 0) { - runGarbageCollection(cacheDir); - } - - return new ClassPath(classPathEntries); - } - - private void runGarbageCollection(File cacheDir) { - GarbageCollector gc = new GarbageCollector( - new FileWalker(cacheDir), _application.getCodeCacheRetentionDays(), TimeUnit.DAYS); - - gc.collectGarbage(); - } -} diff --git a/src/main/java/com/threerings/getdown/classpath/ClassPath.java b/src/main/java/com/threerings/getdown/classpath/ClassPath.java index e5da5c6..d96b712 100644 --- a/src/main/java/com/threerings/getdown/classpath/ClassPath.java +++ b/src/main/java/com/threerings/getdown/classpath/ClassPath.java @@ -1,6 +1,7 @@ package com.threerings.getdown.classpath; import java.io.File; +import java.net.MalformedURLException; import java.net.URL; import java.net.URLClassLoader; import java.util.Collections; @@ -14,13 +15,13 @@ import java.util.Set; */ public class ClassPath { - public ClassPath (LinkedHashSet classPathEntries) + public ClassPath (LinkedHashSet classPathEntries) { - this._classPathEntries = Collections.unmodifiableSet(classPathEntries); + _classPathEntries = Collections.unmodifiableSet(classPathEntries); } /** - * Returns the class path as an java command line argument string, e.g + * Returns the class path as an java command line argument string, e.g. * *
      *   /path/to/a.jar:/path/to/b.jar
@@ -30,16 +31,10 @@ public class ClassPath
     {
         StringBuilder builder = new StringBuilder();
         String delimiter = "";
-
-        for (ClassPathElement entry: _classPathEntries)
-        {
-            builder
-                .append(delimiter)
-                .append(entry.getAbsolutePath());
-
+        for (File entry: _classPathEntries) {
+            builder.append(delimiter).append(entry.getAbsolutePath());
             delimiter = File.pathSeparator;
         }
-
         return builder.toString();
     }
 
@@ -50,20 +45,27 @@ public class ClassPath
     public URL[] asUrls ()
     {
         URL[] urls = new URL[_classPathEntries.size()];
-
         int i = 0;
-
-        for (ClassPathElement entry : _classPathEntries) {
-            urls[i++] = entry.getURL();
+        for (File entry : _classPathEntries) {
+            urls[i++] = getURL(entry);
         }
-
         return urls;
     }
 
-    public Set getClassPathEntries ()
+    public Set getClassPathEntries ()
     {
         return _classPathEntries;
     }
 
-    private final Set _classPathEntries;
+
+    private static URL getURL (File file)
+    {
+        try {
+            return file.toURI().toURL();
+        } catch (MalformedURLException e) {
+            throw new IllegalStateException("URL of file is illegal: " + file.getAbsolutePath(), e);
+        }
+    }
+
+    private final Set _classPathEntries;
 }
diff --git a/src/main/java/com/threerings/getdown/classpath/ClassPathBuilder.java b/src/main/java/com/threerings/getdown/classpath/ClassPathBuilder.java
deleted file mode 100644
index a220760..0000000
--- a/src/main/java/com/threerings/getdown/classpath/ClassPathBuilder.java
+++ /dev/null
@@ -1,13 +0,0 @@
-package com.threerings.getdown.classpath;
-
-import java.io.IOException;
-
-/**
- * A class path builder compiles the class path of the application to be launched.
- */
-public interface ClassPathBuilder {
-    /**
-     * Builds and returns the class path of the application to be launched.
-     */
-    ClassPath buildClassPath () throws IOException;
-}
diff --git a/src/main/java/com/threerings/getdown/classpath/ClassPathBuilderBase.java b/src/main/java/com/threerings/getdown/classpath/ClassPathBuilderBase.java
deleted file mode 100644
index 0cc5613..0000000
--- a/src/main/java/com/threerings/getdown/classpath/ClassPathBuilderBase.java
+++ /dev/null
@@ -1,14 +0,0 @@
-package com.threerings.getdown.classpath;
-
-import com.threerings.getdown.data.Application;
-
-/**
- * Base class for {@link ClassPathBuilder class path builders}.
- */
-abstract class ClassPathBuilderBase implements ClassPathBuilder {
-    public ClassPathBuilderBase(Application _application) {
-        this._application = _application;
-    }
-
-    protected final Application _application;
-}
diff --git a/src/main/java/com/threerings/getdown/classpath/ClassPathBuilderFactory.java b/src/main/java/com/threerings/getdown/classpath/ClassPathBuilderFactory.java
deleted file mode 100644
index 5f93fdb..0000000
--- a/src/main/java/com/threerings/getdown/classpath/ClassPathBuilderFactory.java
+++ /dev/null
@@ -1,22 +0,0 @@
-package com.threerings.getdown.classpath;
-
-import com.threerings.getdown.data.Application;
-
-/**
- * A factory for {@link ClassPathBuilder class path builders}.
- */
-public abstract class ClassPathBuilderFactory {
-    private ClassPathBuilderFactory () {
-    }
-
-    /**
-     * Creates a class path builder that is able to compile the class path of the supplied
-     * {@link Application application}.
-     */
-    public static ClassPathBuilder create (Application application)
-    {
-        return application.isUseCodeCache()
-                ? new CacheBasedClassPathBuilder(application)
-                        : new DefaultClassPathBuilder(application);
-    }
-}
diff --git a/src/main/java/com/threerings/getdown/classpath/ClassPathElement.java b/src/main/java/com/threerings/getdown/classpath/ClassPathElement.java
deleted file mode 100644
index edf64a2..0000000
--- a/src/main/java/com/threerings/getdown/classpath/ClassPathElement.java
+++ /dev/null
@@ -1,47 +0,0 @@
-package com.threerings.getdown.classpath;
-
-import java.io.File;
-import java.net.MalformedURLException;
-import java.net.URL;
-
-/**
- * Represents an element of an applications class path.
- */
-public class ClassPathElement
-{
-    public ClassPathElement (File _file)
-    {
-        this._file = _file;
-    }
-
-    /**
-     * Returns the code resource.
-     */
-    public File getFile ()
-    {
-        return _file;
-    }
-
-
-    /**
-     * Returns the elements absolute path on the file system.
-     */
-    public String getAbsolutePath ()
-    {
-        return _file.getAbsolutePath();
-    }
-
-    /**
-     * Returns the file URL of this class path element.
-     */
-    public URL getURL ()
-    {
-        try {
-            return _file.toURI().toURL();
-        } catch (MalformedURLException e) {
-            throw new IllegalStateException("URL of file is illegal: " + getAbsolutePath(), e);
-        }
-    }
-
-    private final File _file;
-}
diff --git a/src/main/java/com/threerings/getdown/classpath/ClassPaths.java b/src/main/java/com/threerings/getdown/classpath/ClassPaths.java
new file mode 100644
index 0000000..84b5607
--- /dev/null
+++ b/src/main/java/com/threerings/getdown/classpath/ClassPaths.java
@@ -0,0 +1,76 @@
+//
+// Getdown - application installer, patcher and launcher
+// Copyright (C) 2004-2016 Getdown authors
+// https://github.com/threerings/getdown/blob/master/LICENSE
+
+package com.threerings.getdown.classpath;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.LinkedHashSet;
+import java.util.concurrent.TimeUnit;
+
+import com.threerings.getdown.classpath.cache.GarbageCollector;
+import com.threerings.getdown.classpath.cache.ResourceCache;
+import com.threerings.getdown.data.Application;
+import com.threerings.getdown.data.Resource;
+
+public class ClassPaths
+{
+    static final String CACHE_DIR = ".cache";
+
+    /**
+     * Builds either a default or cached classpath based on {@code app}'s configuration.
+     */
+    public static ClassPath buildClassPath (Application app) throws IOException
+    {
+        return app.useCodeCache() ? buildDefaultClassPath(app) : buildCachedClassPath(app);
+    }
+
+    /**
+     * Builds a {@link ClassPath} instance for {@code app} using the code resources in place in
+     * the app directory.
+     */
+    public static ClassPath buildDefaultClassPath (Application app)
+    {
+        LinkedHashSet classPathEntries = new LinkedHashSet();
+        for (Resource resource: app.getActiveCodeResources()) {
+            classPathEntries.add(resource.getFinalTarget());
+        }
+        return new ClassPath(classPathEntries);
+    }
+
+    /**
+     * Builds a {@link ClassPath} instance for {@code app} by first copying the code resources into
+     * a cache directory and then referencing them from there. This avoids problems with
+     * overwriting in-use classpath elements when the application is later updated. This also
+     * "garbage collects" expired caches if necessary.
+     */
+    public static ClassPath buildCachedClassPath (Application app) throws IOException
+    {
+        File cacheDir = new File(app.getAppdir(), CACHE_DIR);
+        // a negative value of code_cache_retention_days allows to clean up the cache forcefully
+        if (app.getCodeCacheRetentionDays() <= 0) {
+            runGarbageCollection(app, cacheDir);
+        }
+
+        ResourceCache cache = new ResourceCache(cacheDir);
+        LinkedHashSet classPathEntries = new LinkedHashSet();
+        for (Resource resource: app.getActiveCodeResources()) {
+            File entry = cache.cacheFile(resource.getFinalTarget(), app.getDigest(resource));
+            classPathEntries.add(entry);
+        }
+
+        if (app.getCodeCacheRetentionDays() > 0) {
+            runGarbageCollection(app, cacheDir);
+        }
+
+        return new ClassPath(classPathEntries);
+    }
+
+    private static void runGarbageCollection (Application app, File cacheDir)
+    {
+        long retainMillis = TimeUnit.DAYS.toMillis(app.getCodeCacheRetentionDays());
+        GarbageCollector.collect(cacheDir, retainMillis);
+    }
+}
diff --git a/src/main/java/com/threerings/getdown/classpath/DefaultClassPathBuilder.java b/src/main/java/com/threerings/getdown/classpath/DefaultClassPathBuilder.java
deleted file mode 100644
index b6f375b..0000000
--- a/src/main/java/com/threerings/getdown/classpath/DefaultClassPathBuilder.java
+++ /dev/null
@@ -1,30 +0,0 @@
-package com.threerings.getdown.classpath;
-
-import java.util.LinkedHashSet;
-
-import com.threerings.getdown.data.Application;
-import com.threerings.getdown.data.Resource;
-
-/**
- * The default class path builder assembles the class path from the code resources to be found in
- * the application directory.
- */
-public class DefaultClassPathBuilder extends ClassPathBuilderBase
-{
-    public DefaultClassPathBuilder(Application _application) {
-        super(_application);
-    }
-
-    @Override
-    public ClassPath buildClassPath ()
-    {
-        LinkedHashSet classPathEntries = new LinkedHashSet();
-
-        for (Resource resource: _application.getActiveCodeResources()) {
-            classPathEntries.add(new ClassPathElement(resource.getFinalTarget()));
-        }
-
-        return new ClassPath(classPathEntries);
-    }
-
-}
diff --git a/src/main/java/com/threerings/getdown/classpath/cache/GarbageCollector.java b/src/main/java/com/threerings/getdown/classpath/cache/GarbageCollector.java
index 6a40ae1..6c1dbc7 100644
--- a/src/main/java/com/threerings/getdown/classpath/cache/GarbageCollector.java
+++ b/src/main/java/com/threerings/getdown/classpath/cache/GarbageCollector.java
@@ -1,10 +1,7 @@
 package com.threerings.getdown.classpath.cache;
 
 import java.io.File;
-import java.util.concurrent.TimeUnit;
-
-import com.threerings.getdown.util.file.FileVisitor;
-import com.threerings.getdown.util.file.FileWalker;
+import com.threerings.getdown.util.FileUtil;
 
 /**
  * Collects elements in the {@link ResourceCache cache} which became unused and deletes them
@@ -12,80 +9,53 @@ import com.threerings.getdown.util.file.FileWalker;
  */
 public class GarbageCollector
 {
-    public GarbageCollector (
-            FileWalker _fileWalker, int retentionPeriod, TimeUnit retentionPeriodTimeUnit)
-    {
-        this._fileWalker = _fileWalker;
-        this._retentionMillis = retentionPeriodTimeUnit.toMillis(retentionPeriod);
-    }
-
     /**
      * Collect and delete the garbage in the cache.
      */
-    public void collectGarbage ()
+    public static void collect (File cacheDir, final long retentionPeriodMillis)
     {
-        _fileWalker.walkTree(new FileVisitor() {
-            @Override
-            public void visit(File file)
-            {
-                collect(file);
+        FileUtil.walkTree(cacheDir, new FileUtil.Visitor() {
+            @Override public void visit (File file) {
+                File cachedFile = getCachedFile(file);
+                File lastAccessedFile = getLastAccessedFile(file);
+                if (!cachedFile.exists() || !lastAccessedFile.exists()) {
+                    if (cachedFile.exists()) {
+                        cachedFile.delete();
+                    } else {
+                        lastAccessedFile.delete();
+                    }
+                } else if (shouldDelete(lastAccessedFile, retentionPeriodMillis)) {
+                    lastAccessedFile.delete();
+                    cachedFile.delete();
+                }
+
+                File folder = file.getParentFile();
+                if (folder.list().length == 0) {
+                    folder.delete();
+                }
             }
         });
     }
 
-    private void collect (File file) {
-        File cachedFile = getCachedFile(file);
-        File lastAccessedFile = getLastAccessedFile(file);
-
-        if (!cachedFile.exists() || !lastAccessedFile.exists()) {
-            if (cachedFile.exists()) {
-                cachedFile.delete();
-            } else {
-                lastAccessedFile.delete();
-            }
-        } else if (shouldDelete(lastAccessedFile)) {
-            lastAccessedFile.delete();
-            cachedFile.delete();
-        }
-
-        File folder = file.getParentFile();
-
-        if (folder.list().length == 0) {
-            folder.delete();
-        }
-    }
-
-    private boolean shouldDelete(File lastAccessedFile) {
-        return System.currentTimeMillis() - lastAccessedFile.lastModified() > _retentionMillis;
-    }
-
-    private File getLastAccessedFile (File file)
+    private static boolean shouldDelete (File lastAccessedFile, long retentionMillis)
     {
-        if (isLastAccessedFile(file)) {
-            return file;
-        }
-
-        return new File(
-                file.getParentFile(),
-                file.getName() + ResourceCache.LAST_ACCESSED_FILE_SUFFIX);
+        return System.currentTimeMillis() - lastAccessedFile.lastModified() > retentionMillis;
     }
 
-    private boolean isLastAccessedFile (File file)
+    private static File getLastAccessedFile (File file)
+    {
+        return isLastAccessedFile(file) ? file : new File(
+            file.getParentFile(), file.getName() + ResourceCache.LAST_ACCESSED_FILE_SUFFIX);
+    }
+
+    private static boolean isLastAccessedFile (File file)
     {
         return file.getName().endsWith(ResourceCache.LAST_ACCESSED_FILE_SUFFIX);
     }
 
-    private File getCachedFile (File file)
+    private static File getCachedFile (File file)
     {
-        if (!isLastAccessedFile(file)) {
-            return file;
-        }
-
-        return new File(
-                file.getParentFile(),
-                file.getName().substring(0, file.getName().lastIndexOf(".")));
+        return !isLastAccessedFile(file) ? file : new File(
+            file.getParentFile(), file.getName().substring(0, file.getName().lastIndexOf(".")));
     }
-
-    private final FileWalker _fileWalker;
-    private final long _retentionMillis;
 }
diff --git a/src/main/java/com/threerings/getdown/data/Application.java b/src/main/java/com/threerings/getdown/data/Application.java
index 4590f64..ce72f50 100644
--- a/src/main/java/com/threerings/getdown/data/Application.java
+++ b/src/main/java/com/threerings/getdown/data/Application.java
@@ -5,21 +5,9 @@
 
 package com.threerings.getdown.data;
 
-import static com.threerings.getdown.Log.log;
-
 import java.awt.Color;
 import java.awt.Rectangle;
-import java.io.BufferedReader;
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.FileNotFoundException;
-import java.io.FileOutputStream;
-import java.io.FileReader;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.io.PrintStream;
-import java.io.RandomAccessFile;
+import java.io.*;
 import java.lang.reflect.Method;
 import java.net.MalformedURLException;
 import java.net.URL;
@@ -28,46 +16,29 @@ import java.net.URLConnection;
 import java.nio.channels.FileChannel;
 import java.nio.channels.FileLock;
 import java.nio.channels.OverlappingFileLockException;
-import java.security.AllPermission;
-import java.security.CodeSource;
-import java.security.GeneralSecurityException;
-import java.security.PermissionCollection;
-import java.security.Permissions;
-import java.security.Signature;
+import java.security.*;
 import java.security.cert.Certificate;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.EnumMap;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-import java.util.Set;
+import java.util.*;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
 import javax.swing.JApplet;
 
-import org.apache.commons.codec.binary.Base64;
-
 import com.samskivert.io.StreamUtil;
 import com.samskivert.text.MessageUtil;
 import com.samskivert.util.ArrayUtil;
 import com.samskivert.util.RandomUtil;
 import com.samskivert.util.RunAnywhere;
 import com.samskivert.util.StringUtil;
+
+import org.apache.commons.codec.binary.Base64;
+
+import com.threerings.getdown.classpath.ClassPaths;
 import com.threerings.getdown.classpath.ClassPath;
-import com.threerings.getdown.classpath.ClassPathBuilderFactory;
 import com.threerings.getdown.launcher.RotatingBackgrounds;
-import com.threerings.getdown.util.ConfigUtil;
-import com.threerings.getdown.util.ConnectionUtil;
-import com.threerings.getdown.util.FileUtil;
-import com.threerings.getdown.util.LaunchUtil;
-import com.threerings.getdown.util.ProgressAggregator;
-import com.threerings.getdown.util.ProgressObserver;
-import com.threerings.getdown.util.VersionUtil;
+import com.threerings.getdown.util.*;
+
+import static com.threerings.getdown.Log.log;
 
 /**
  * Parses and provide access to the information contained in the getdown.txt
@@ -257,10 +228,36 @@ public class Application
     {
         _appdir = appdir;
         _appid = appid;
-        _signers = signers == null ? Collections.emptyList() : signers;
+        _signers = (signers == null) ? Collections.emptyList() : signers;
         _config = getLocalPath(CONFIG_FILE);
-        _extraJvmArgs = jvmargs == null ? ArrayUtil.EMPTY_STRING : jvmargs;
-        _extraAppArgs = appargs == null ? ArrayUtil.EMPTY_STRING : appargs;
+        _extraJvmArgs = (jvmargs == null) ? ArrayUtil.EMPTY_STRING : jvmargs;
+        _extraAppArgs = (appargs == null) ? ArrayUtil.EMPTY_STRING : appargs;
+    }
+
+    /**
+     * Returns the configured application directory.
+     */
+    public File getAppdir()
+    {
+        return _appdir;
+    }
+
+    /**
+     * Returns whether the application should cache code resources prior to launching the
+     * application.
+     */
+    public boolean useCodeCache ()
+    {
+        return _useCodeCache;
+    }
+
+    /**
+     * Returns the number of days a cached code resource is allowed to stay unused before it
+     * becomes eligible for deletion.
+     */
+    public int getCodeCacheRetentionDays ()
+    {
+        return _codeCacheRetentionDays;
     }
 
     /**
@@ -291,6 +288,14 @@ public class Application
         return _resources;
     }
 
+    /**
+     * Returns the digest of the given {@code resource}.
+     */
+    public String getDigest (Resource resource)
+    {
+        return _digest.getDigest(resource);
+    }
+
     /**
      * Returns a list of all the active {@link Resource} objects used by this application (code and
      * non-code).
@@ -383,7 +388,7 @@ public class Application
             return null;
         }
 
-        String infix = auxgroup == null ? "" : "-" + auxgroup;
+        String infix = (auxgroup == null) ? "" : ("-" + auxgroup);
         String pfile = "patch" + infix + _version + ".dat";
         try {
             URL remote = new URL(createVAppBase(_targetVersion), encodePath(pfile));
@@ -545,7 +550,7 @@ public class Application
 
         // if we are a versioned deployment, create a versioned appbase
         try {
-            _vappbase = _version < 0 ? new URL(_appbase) : createVAppBase(_version);
+            _vappbase = (_version < 0) ? new URL(_appbase) : createVAppBase(_version);
         } catch (MalformedURLException mue) {
             String err = MessageUtil.tcompose("m.invalid_appbase", _appbase);
             throw (IOException) new IOException(err).initCause(mue);
@@ -562,7 +567,7 @@ public class Application
             }
         }
 
-        String appPrefix = StringUtil.isBlank(_appid) ? "" : _appid + ".";
+        String appPrefix = StringUtil.isBlank(_appid) ? "" : (_appid + ".");
 
         // determine our application class name
         _class = (String)cdata.get(appPrefix + "class");
@@ -689,8 +694,8 @@ public class Application
 
         // whether to cache code resources and launch from cache
         _useCodeCache = Boolean.parseBoolean((String) cdata.get("use_code_cache"));
-        _codeCacheRetentionDays = cdata.containsKey("code_cache_retention_days")
-                ? Integer.parseInt((String) cdata.get("use_code_cache")) : 7;
+        _codeCacheRetentionDays = cdata.containsKey("code_cache_retention_days") ?
+            Integer.parseInt((String) cdata.get("use_code_cache")) : 7;
 
         // parse and return our application config
         UpdateInterface ui = new UpdateInterface();
@@ -709,9 +714,9 @@ public class Application
         }
         // and now ui.background can refer to the background color, but fall back to black
         // or white, depending on the brightness of the progressText
-        Color defaultBackground = .5f < Color.RGBtoHSB(
+        Color defaultBackground = (.5f < Color.RGBtoHSB(
                 ui.progressText.getRed(), ui.progressText.getGreen(), ui.progressText.getBlue(),
-                null)[2]
+                null)[2])
             ? Color.BLACK
             : Color.WHITE;
         ui.background = parseColor(cdata, "ui.background", defaultBackground);
@@ -726,7 +731,7 @@ public class Application
 
         // On an installation error, where do we point the user.
         String installError = parseUrl(cdata, "ui.install_error", null);
-        ui.installError = installError == null ?
+        ui.installError = (installError == null) ?
             "m.default_install_error" : MessageUtil.taint(installError);
 
         // the patch notes bits
@@ -838,8 +843,8 @@ public class Application
                 }
             }
 
-            boolean minVersionOK = _javaMinVersion == 0 || version >= _javaMinVersion;
-            boolean maxVersionOK = _javaMaxVersion == 0 || version <= _javaMaxVersion;
+            boolean minVersionOK = (_javaMinVersion == 0) || (version >= _javaMinVersion);
+            boolean maxVersionOK = (_javaMaxVersion == 0) || (version <= _javaMaxVersion);
             return minVersionOK && maxVersionOK;
 
         } catch (RuntimeException re) {
@@ -937,7 +942,7 @@ public class Application
         boolean dashJarMode = MANIFEST_CLASS.equals(_class);
 
         // add the -classpath arguments if we're not in -jar mode
-        ClassPath classPath = ClassPathBuilderFactory.create(this).buildClassPath();
+        ClassPath classPath = ClassPaths.buildClassPath(this);
 
         if (!dashJarMode) {
             args.add("-classpath");
@@ -1037,18 +1042,14 @@ public class Application
 
     /**
      * Runs this application directly in the current VM.
-     * @throws IOException
      */
     public void invokeDirect (JApplet applet) throws IOException
     {
-        ClassPath classPath = ClassPathBuilderFactory.create(this).buildClassPath();
-
+        ClassPath classPath = ClassPaths.buildClassPath(this);
         URL[] jarUrls = classPath.asUrls();
 
         // create custom class loader
-        URLClassLoader loader = new URLClassLoader(
-            jarUrls,
-            ClassLoader.getSystemClassLoader()) {
+        URLClassLoader loader = new URLClassLoader(jarUrls, ClassLoader.getSystemClassLoader()) {
             @Override protected PermissionCollection getPermissions (CodeSource code) {
                 Permissions perms = new Permissions();
                 perms.add(new AllPermission());
@@ -1058,9 +1059,7 @@ public class Application
         Thread.currentThread().setContextClassLoader(loader);
 
         log.info("Configured URL class loader:");
-        for (URL url : jarUrls) {
-            log.info("  " + url);
-        }
+        for (URL url : jarUrls) log.info("  " + url);
 
         // configure any system properties that we can
         for (String jvmarg : _jvmargs) {
@@ -1169,7 +1168,7 @@ public class Application
         if (_version == -1) {
             // make a note of the old meta-digest, if this changes we need to revalidate all of our
             // resources as one or more of them have also changed
-            String olddig = _digest == null ? "" : _digest.getMetaDigest();
+            String olddig = (_digest == null) ? "" : _digest.getMetaDigest();
             try {
                 status.updateStatus("m.checking");
                 downloadDigestFile();
@@ -1323,7 +1322,7 @@ public class Application
             failures.add(rsrc);
         }
 
-        return failures.size() == 0 ? null : failures;
+        return (failures.size() == 0) ? null : failures;
     }
 
     /**
@@ -1381,24 +1380,6 @@ public class Application
         return _version;
     }
 
-    /**
-     * Returns whether the application should cache code resources prior to launching the
-     * application.
-     */
-    public boolean isUseCodeCache ()
-    {
-        return _useCodeCache;
-    }
-
-    /**
-     * Returns the number of days a cached code resource is allowed to stay unused before it
-     * becomes eligible for deletion.
-     */
-    public int getCodeCacheRetentionDays ()
-    {
-        return _codeCacheRetentionDays;
-    }
-
     /**
      * Creates a versioned application base URL for the specified version.
      */
@@ -1644,7 +1625,7 @@ public class Application
     {
         String value = (String)cdata.get(name);
         Rectangle rect = parseRect(name, value);
-        return rect == null ? def : rect;
+        return (rect == null) ? def : rect;
     }
 
     /** Helper function to add all values in {@code values} (if non-null) to {@code target}. */
@@ -1689,7 +1670,7 @@ public class Application
     {
         String value = (String)cdata.get(name);
         Color color = parseColor(value);
-        return color == null ? def : color;
+        return (color == null) ? def : color;
     }
 
     /**
@@ -1712,7 +1693,7 @@ public class Application
     protected String[] parseList (Map cdata, String name)
     {
         String value = (String)cdata.get(name);
-        return value == null ? ArrayUtil.EMPTY_STRING : StringUtil.parseStringArray(value);
+        return (value == null) ? ArrayUtil.EMPTY_STRING : StringUtil.parseStringArray(value);
     }
 
     /**
@@ -1788,19 +1769,6 @@ public class Application
         }
     }
 
-    /**
-     * Returns the digest of the given {@code resource}.
-     */
-    public String getDigest (Resource resource)
-    {
-        return _digest.getDigest(resource);
-    }
-
-    public File getAppdir()
-    {
-        return _appdir;
-    }
-
     protected File _appdir;
     protected String _appid;
     protected File _config;
diff --git a/src/main/java/com/threerings/getdown/data/Digest.java b/src/main/java/com/threerings/getdown/data/Digest.java
index 5cdddf2..9d30c6f 100644
--- a/src/main/java/com/threerings/getdown/data/Digest.java
+++ b/src/main/java/com/threerings/getdown/data/Digest.java
@@ -5,8 +5,6 @@
 
 package com.threerings.getdown.data;
 
-import static com.threerings.getdown.Log.log;
-
 import java.io.File;
 import java.io.FileOutputStream;
 import java.io.IOException;
@@ -20,9 +18,12 @@ import java.util.List;
 import com.samskivert.io.StreamUtil;
 import com.samskivert.text.MessageUtil;
 import com.samskivert.util.StringUtil;
+
 import com.threerings.getdown.util.ConfigUtil;
 import com.threerings.getdown.util.ProgressObserver;
 
+import static com.threerings.getdown.Log.log;
+
 /**
  * Manages the digest.txt file and the computing and processing of MD5 digests for an
  * application.
@@ -92,6 +93,14 @@ public class Digest
         return false;
     }
 
+    /**
+     * Returns the digest of the given {@code resource}.
+     */
+    public String getDigest (Resource resource)
+    {
+        return _digests.get(resource.getPath());
+    }
+
     /**
      * Creates a digest file at the specified location using the supplied list of resources.
      */
@@ -145,14 +154,6 @@ public class Digest
         data.append(path).append(" = ").append(digest).append("\n");
     }
 
-    /**
-     * Returns the digest of the given {@code resource}.
-     */
-    public String getDigest (Resource resource)
-    {
-        return _digests.get(resource.getPath());
-    }
-
     protected HashMap _digests = new HashMap();
     protected String _metaDigest = "";
 }
diff --git a/src/main/java/com/threerings/getdown/util/FileUtil.java b/src/main/java/com/threerings/getdown/util/FileUtil.java
index bbfcb2a..f8362eb 100644
--- a/src/main/java/com/threerings/getdown/util/FileUtil.java
+++ b/src/main/java/com/threerings/getdown/util/FileUtil.java
@@ -5,16 +5,11 @@
 
 package com.threerings.getdown.util;
 
-import static com.threerings.getdown.Log.log;
-
-import java.io.BufferedReader;
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.Reader;
+import java.io.*;
+import java.util.ArrayDeque;
 import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Deque;
 import java.util.List;
 import java.util.jar.JarOutputStream;
 import java.util.jar.Pack200;
@@ -22,6 +17,8 @@ import java.util.zip.GZIPInputStream;
 
 import com.samskivert.io.StreamUtil;
 
+import static com.threerings.getdown.Log.log;
+
 /**
  * File related utilities.
  */
@@ -115,6 +112,7 @@ public class FileUtil extends com.samskivert.util.FileUtil
             Pack200.Unpacker unpacker = Pack200.newUnpacker();
             unpacker.unpack(packedJarIn, jarOutputStream);
             return true;
+
         } catch (IOException e) {
             log.warning("Failed to unpack packed 200 jar file", "jar", packedJar, "error", e);
             return false;
@@ -129,18 +127,43 @@ public class FileUtil extends com.samskivert.util.FileUtil
     /**
      * Copies the given {@code source} file to the given {@code target}.
      */
-    public static void copy(File source, File target) throws IOException {
+    public static void copy (File source, File target) throws IOException {
         FileInputStream in = null;
         FileOutputStream out = null;
-
         try {
             in = new FileInputStream(source);
             out = new FileOutputStream(target);
-
             StreamUtil.copy(in, out);
         } finally {
             StreamUtil.close(in);
             StreamUtil.close(out);
         }
     }
+
+    /**
+     * Used by {@link #walkTree}.
+     */
+    public interface Visitor
+    {
+        void visit (File file);
+    }
+
+    /**
+     * Walks all files in {@code root}, calling {@code visitor} on each file in the tree.
+     */
+    public static void walkTree (File root, Visitor visitor)
+    {
+        Deque stack = new ArrayDeque(Arrays.asList(root.listFiles()));
+        while (!stack.isEmpty()) {
+            File currentFile = stack.pop();
+            if (currentFile.exists()) {
+                visitor.visit(currentFile);
+                if (currentFile.isDirectory()) {
+                    for (File file: currentFile.listFiles()) {
+                        stack.push(file);
+                    }
+                }
+            }
+        }
+    }
 }
diff --git a/src/main/java/com/threerings/getdown/util/LaunchUtil.java b/src/main/java/com/threerings/getdown/util/LaunchUtil.java
index 1aeabdf..716961d 100644
--- a/src/main/java/com/threerings/getdown/util/LaunchUtil.java
+++ b/src/main/java/com/threerings/getdown/util/LaunchUtil.java
@@ -10,7 +10,6 @@ import java.io.FileInputStream;
 import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.PrintStream;
-import com.samskivert.io.StreamUtil;
 import com.samskivert.util.RunAnywhere;
 import com.samskivert.util.StringUtil;
 
@@ -155,7 +154,7 @@ public class LaunchUtil
                 try {
                     // copy the moved file back to getdown-dop-new.jar so that we don't end up
                     // downloading another copy next time
-                    StreamUtil.copy(new FileInputStream(curgd), new FileOutputStream(newgd));
+                    FileUtil.copy(curgd, newgd);
                 } catch (IOException e) {
                     log.warning("Error copying updated Getdown back: " + e);
                 }
@@ -172,7 +171,7 @@ public class LaunchUtil
         // that didn't work, let's try copying it
         log.info("Attempting to upgrade by copying over " + curgd + "...");
         try {
-            StreamUtil.copy(new FileInputStream(newgd), new FileOutputStream(curgd));
+            FileUtil.copy(newgd, curgd);
         } catch (IOException ioe) {
             log.warning("Mayday! Brute force copy method also failed.", ioe);
         }
diff --git a/src/main/java/com/threerings/getdown/util/file/FileVisitor.java b/src/main/java/com/threerings/getdown/util/file/FileVisitor.java
deleted file mode 100644
index 29f01ed..0000000
--- a/src/main/java/com/threerings/getdown/util/file/FileVisitor.java
+++ /dev/null
@@ -1,6 +0,0 @@
-package com.threerings.getdown.util.file;
-import java.io.File;
-
-public interface FileVisitor {
-    void visit(File file);
-}
diff --git a/src/main/java/com/threerings/getdown/util/file/FileWalker.java b/src/main/java/com/threerings/getdown/util/file/FileWalker.java
deleted file mode 100644
index 2ffc613..0000000
--- a/src/main/java/com/threerings/getdown/util/file/FileWalker.java
+++ /dev/null
@@ -1,42 +0,0 @@
-package com.threerings.getdown.util.file;
-
-import java.io.File;
-import java.util.ArrayDeque;
-import java.util.Arrays;
-import java.util.Deque;
-
-/**
- * Recursively walks over files and folders. A {@link FileVisitor visitor} is informed for each path
- * traversed.
- */
-public class FileWalker
-{
-    public FileWalker (File start)
-    {
-        this._start = start;
-    }
-
-    /**
-     * Walks over the directory tree with the provided {@code visitor}.
-     */
-    public void walkTree (FileVisitor visitor)
-    {
-        Deque stack = new ArrayDeque(Arrays.asList(_start.listFiles()));
-
-        while (!stack.isEmpty()) {
-            File currentFile = stack.pop();
-
-            if (currentFile.exists()) {
-                visitor.visit(currentFile);
-
-                if (currentFile.isDirectory()) {
-                    for (File file: currentFile.listFiles()) {
-                        stack.push(file);
-                    }
-                }
-            }
-        }
-    }
-
-    private final File _start;
-}
diff --git a/src/test/java/com/threerings/getdown/classpath/CacheBasedClassPathBuilderTest.java b/src/test/java/com/threerings/getdown/classpath/CacheBasedClassPathBuilderTest.java
deleted file mode 100644
index f76a317..0000000
--- a/src/test/java/com/threerings/getdown/classpath/CacheBasedClassPathBuilderTest.java
+++ /dev/null
@@ -1,44 +0,0 @@
-package com.threerings.getdown.classpath;
-
-import static org.junit.Assert.assertEquals;
-import static org.mockito.Mockito.when;
-
-import java.io.File;
-import java.io.IOException;
-
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.mockito.runners.MockitoJUnitRunner;
-
-import com.threerings.getdown.util.FileUtil;
-
-/**
- * Tests {@link CacheBasedClassPathBuilderTest}.
- */
-@RunWith(MockitoJUnitRunner.class)
-public class CacheBasedClassPathBuilderTest extends ClassPathBuilderTestBase
-{
-    @Test
-    public void shouldBuildCacheBasedClassPath () throws IOException
-    {
-        when(_application.getDigest(_firstJar)).thenReturn("first");
-        when(_application.getDigest(_secondJar)).thenReturn("second");
-        when(_application.getCodeCacheRetentionDays()).thenReturn(1);
-
-        File firstCachedJarFile = FileUtil.newFile(
-                _appdir.getRoot(),
-                CacheBasedClassPathBuilder.CACHE_DIR, "fi", "first.jar");
-
-        File secondCachedJarFile = FileUtil.newFile(
-                _appdir.getRoot(),
-                CacheBasedClassPathBuilder.CACHE_DIR, "se", "second.jar");
-
-        String expectedClassPath = firstCachedJarFile.getAbsolutePath()
-                + File.pathSeparator
-                + secondCachedJarFile.getAbsolutePath();
-
-        ClassPath classPath = new CacheBasedClassPathBuilder(_application).buildClassPath();
-
-        assertEquals(expectedClassPath, classPath.asArgumentString());
-    }
-}
diff --git a/src/test/java/com/threerings/getdown/classpath/ClassPathBuilderFactoryTest.java b/src/test/java/com/threerings/getdown/classpath/ClassPathBuilderFactoryTest.java
deleted file mode 100644
index d16e5df..0000000
--- a/src/test/java/com/threerings/getdown/classpath/ClassPathBuilderFactoryTest.java
+++ /dev/null
@@ -1,37 +0,0 @@
-package com.threerings.getdown.classpath;
-
-import static org.junit.Assert.assertTrue;
-import static org.mockito.Mockito.when;
-
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.mockito.Mock;
-import org.mockito.runners.MockitoJUnitRunner;
-
-import com.threerings.getdown.data.Application;
-
-/**
- * Checks wether the correct {@link ClassPathBuilder class path builders} are created.
- */
-@RunWith(MockitoJUnitRunner.class)
-public class ClassPathBuilderFactoryTest
-{
-    @Test
-    public void shouldCreateDefaultClassPathBuilder ()
-    {
-        when(_application.isUseCodeCache()).thenReturn(Boolean.FALSE);
-
-        assertTrue(ClassPathBuilderFactory.create(_application) instanceof DefaultClassPathBuilder);
-    }
-
-    @Test
-    public void shouldCreateCacheClassPathBuilder ()
-    {
-        when(_application.isUseCodeCache()).thenReturn(Boolean.TRUE);
-
-        assertTrue(ClassPathBuilderFactory.create(_application) instanceof CacheBasedClassPathBuilder);
-    }
-
-    @Mock
-    private Application _application;
-}
diff --git a/src/test/java/com/threerings/getdown/classpath/ClassPathBuilderTestBase.java b/src/test/java/com/threerings/getdown/classpath/ClassPathBuilderTestBase.java
deleted file mode 100644
index a6a80e2..0000000
--- a/src/test/java/com/threerings/getdown/classpath/ClassPathBuilderTestBase.java
+++ /dev/null
@@ -1,48 +0,0 @@
-package com.threerings.getdown.classpath;
-
-import static org.mockito.Mockito.when;
-
-import java.io.File;
-import java.io.IOException;
-import java.util.Arrays;
-
-import org.junit.Before;
-import org.junit.Rule;
-import org.junit.rules.TemporaryFolder;
-import org.mockito.Mock;
-
-import com.threerings.getdown.data.Application;
-import com.threerings.getdown.data.Resource;
-
-/**
- * Base class for {@link ClassPathBuilder} tests.
- */
-public abstract class ClassPathBuilderTestBase {
-    @Before
-    public void setupFilesAndResources () throws IOException
-    {
-        _firstJarFile = _appdir.newFile("a.jar");
-        _secondJarFile = _appdir.newFile("b.jar");
-
-        when(_firstJar.getFinalTarget()).thenReturn(_firstJarFile);
-        when(_secondJar.getFinalTarget()).thenReturn(_secondJarFile);
-        when(_application.getActiveCodeResources()).thenReturn(Arrays.asList(_firstJar, _secondJar));
-        when(_application.getAppdir()).thenReturn(_appdir.getRoot());
-    }
-
-    @Mock
-    protected Application _application;
-
-    @Mock
-    protected Resource _firstJar;
-
-    protected File _firstJarFile;
-
-    @Mock
-    protected Resource _secondJar;
-
-    protected File _secondJarFile;
-
-    @Rule
-    public TemporaryFolder _appdir = new TemporaryFolder();
-}
diff --git a/src/test/java/com/threerings/getdown/classpath/ClassPathTest.java b/src/test/java/com/threerings/getdown/classpath/ClassPathTest.java
index aaf9e9a..fb90a80 100644
--- a/src/test/java/com/threerings/getdown/classpath/ClassPathTest.java
+++ b/src/test/java/com/threerings/getdown/classpath/ClassPathTest.java
@@ -1,7 +1,5 @@
 package com.threerings.getdown.classpath;
 
-import static org.junit.Assert.assertEquals;
-
 import java.io.File;
 import java.io.IOException;
 import java.net.MalformedURLException;
@@ -9,52 +7,43 @@ import java.net.URISyntaxException;
 import java.net.URL;
 import java.util.LinkedHashSet;
 
-import org.junit.Before;
-import org.junit.Rule;
-import org.junit.Test;
+import org.junit.*;
 import org.junit.rules.TemporaryFolder;
 
+import static org.junit.Assert.assertEquals;
+
 /**
  * Tests for {@link ClassPath}.
  */
 public class ClassPathTest
 {
-    @Before
-    public void createJarsAndSetupClassPath () throws IOException
+    @Before public void createJarsAndSetupClassPath () throws IOException
     {
         _firstJar = _folder.newFile("a.jar");
         _secondJar = _folder.newFile("b.jar");
 
-        LinkedHashSet classPathEntries = new LinkedHashSet();
-
-        classPathEntries.add(new ClassPathElement(_firstJar));
-        classPathEntries.add(new ClassPathElement(_secondJar));
-
+        LinkedHashSet classPathEntries = new LinkedHashSet();
+        classPathEntries.add(_firstJar);
+        classPathEntries.add(_secondJar);
         _classPath = new ClassPath(classPathEntries);
     }
 
-    @Test
-    public void shouldCreateValidArgumentString ()
+    @Test public void shouldCreateValidArgumentString ()
     {
         assertEquals(
-                _firstJar.getAbsolutePath() + File.pathSeparator + _secondJar.getAbsolutePath(),
-                _classPath.asArgumentString());
+            _firstJar.getAbsolutePath() + File.pathSeparator + _secondJar.getAbsolutePath(),
+            _classPath.asArgumentString());
     }
 
-    @Test
-    public void shouldProvideJarUrls () throws MalformedURLException, URISyntaxException
+    @Test public void shouldProvideJarUrls () throws MalformedURLException, URISyntaxException
     {
         URL[] actualUrls = _classPath.asUrls();
-
         assertEquals(_firstJar, new File(actualUrls[0].toURI()));
         assertEquals(_secondJar, new File(actualUrls[1].toURI()));
     }
 
-    @Rule
-    public TemporaryFolder _folder = new TemporaryFolder();
-
-    private File _firstJar;
-    private File _secondJar;
+    @Rule public TemporaryFolder _folder = new TemporaryFolder();
 
+    private File _firstJar, _secondJar;
     private ClassPath _classPath;
 }
diff --git a/src/test/java/com/threerings/getdown/classpath/ClassPathsTest.java b/src/test/java/com/threerings/getdown/classpath/ClassPathsTest.java
new file mode 100644
index 0000000..956585a
--- /dev/null
+++ b/src/test/java/com/threerings/getdown/classpath/ClassPathsTest.java
@@ -0,0 +1,68 @@
+package com.threerings.getdown.classpath;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Arrays;
+
+import org.junit.*;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import static org.junit.Assert.assertEquals;
+
+import org.mockito.Mock;
+import org.mockito.runners.MockitoJUnitRunner;
+import static org.mockito.Mockito.when;
+
+import com.threerings.getdown.data.Application;
+import com.threerings.getdown.data.Resource;
+import com.threerings.getdown.util.FileUtil;
+
+@RunWith(MockitoJUnitRunner.class)
+public class ClassPathsTest
+{
+    @Before public void setupFilesAndResources () throws IOException
+    {
+        _firstJarFile = _appdir.newFile("a.jar");
+        _secondJarFile = _appdir.newFile("b.jar");
+
+        when(_firstJar.getFinalTarget()).thenReturn(_firstJarFile);
+        when(_secondJar.getFinalTarget()).thenReturn(_secondJarFile);
+        when(_application.getActiveCodeResources()).thenReturn(Arrays.asList(_firstJar, _secondJar));
+        when(_application.getAppdir()).thenReturn(_appdir.getRoot());
+    }
+
+    @Test public void shouldBuildDefaultClassPath () throws IOException
+    {
+        ClassPath classPath = ClassPaths.buildDefaultClassPath(_application);
+        String expectedClassPath = _firstJarFile.getAbsolutePath() + File.pathSeparator +
+            _secondJarFile.getAbsolutePath();
+        assertEquals(expectedClassPath, classPath.asArgumentString());
+    }
+
+    @Test public void shouldBuildCachedClassPath () throws IOException
+    {
+        when(_application.getDigest(_firstJar)).thenReturn("first");
+        when(_application.getDigest(_secondJar)).thenReturn("second");
+        when(_application.getCodeCacheRetentionDays()).thenReturn(1);
+
+        File firstCachedJarFile = FileUtil.newFile(
+            _appdir.getRoot(), ClassPaths.CACHE_DIR, "fi", "first.jar");
+
+        File secondCachedJarFile = FileUtil.newFile(
+            _appdir.getRoot(), ClassPaths.CACHE_DIR, "se", "second.jar");
+
+        String expectedClassPath = firstCachedJarFile.getAbsolutePath() + File.pathSeparator +
+            secondCachedJarFile.getAbsolutePath();
+
+        ClassPath classPath = ClassPaths.buildCachedClassPath(_application);
+        assertEquals(expectedClassPath, classPath.asArgumentString());
+    }
+
+    @Mock protected Application _application;
+    @Mock protected Resource _firstJar;
+    @Mock protected Resource _secondJar;
+
+    protected File _firstJarFile, _secondJarFile;
+
+    @Rule public TemporaryFolder _appdir = new TemporaryFolder();
+}
diff --git a/src/test/java/com/threerings/getdown/classpath/DefaultClassPathBuilderTest.java b/src/test/java/com/threerings/getdown/classpath/DefaultClassPathBuilderTest.java
deleted file mode 100644
index 96a088e..0000000
--- a/src/test/java/com/threerings/getdown/classpath/DefaultClassPathBuilderTest.java
+++ /dev/null
@@ -1,31 +0,0 @@
-package com.threerings.getdown.classpath;
-
-import static org.junit.Assert.assertEquals;
-
-import java.io.File;
-import java.io.IOException;
-
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.mockito.runners.MockitoJUnitRunner;
-
-/**
- * Tests the {@link DefaultClassPathBuilder}.
- */
-@RunWith(MockitoJUnitRunner.class)
-public class DefaultClassPathBuilderTest extends ClassPathBuilderTestBase
-{
-    @Test
-    public void shouldBuildClassPath () throws IOException
-    {
-        ClassPath classPath = new DefaultClassPathBuilder(_application).buildClassPath();
-
-        String expectedClassPath = _firstJarFile.getAbsolutePath()
-                + File.pathSeparator
-                + _secondJarFile.getAbsolutePath();
-
-        assertEquals(expectedClassPath, classPath.asArgumentString());
-    }
-
-
-}
diff --git a/src/test/java/com/threerings/getdown/classpath/cache/GarbageCollectorTest.java b/src/test/java/com/threerings/getdown/classpath/cache/GarbageCollectorTest.java
index 1a9975d..ee86685 100644
--- a/src/test/java/com/threerings/getdown/classpath/cache/GarbageCollectorTest.java
+++ b/src/test/java/com/threerings/getdown/classpath/cache/GarbageCollectorTest.java
@@ -1,90 +1,65 @@
 package com.threerings.getdown.classpath.cache;
 
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertTrue;
-import static org.junit.Assume.assumeTrue;
-
 import java.io.File;
 import java.io.IOException;
 import java.util.concurrent.TimeUnit;
 
-import org.junit.Before;
-import org.junit.Rule;
-import org.junit.Test;
+import org.junit.*;
 import org.junit.rules.TemporaryFolder;
 
-import com.threerings.getdown.util.file.FileWalker;
+import static org.junit.Assert.*;
+import static org.junit.Assume.assumeTrue;
 
 /**
  * Validates that cache garbage is collected and deleted correctly.
  */
 public class GarbageCollectorTest
 {
-    @Before
-    public void setupFiles () throws IOException
+    @Before public void setupFiles () throws IOException
     {
         _cachedFile = _folder.newFile("abc123.jar");
         _lastAccessedFile = _folder.newFile("abc123.jar" + ResourceCache.LAST_ACCESSED_FILE_SUFFIX);
     }
 
-    @Test
-    public void shouldDeleteCacheEntryIfRetentionPeriodIsReached ()
+    @Test public void shouldDeleteCacheEntryIfRetentionPeriodIsReached ()
     {
         gcNow();
-
         assertFalse(_cachedFile.exists());
         assertFalse(_lastAccessedFile.exists());
     }
 
-    @Test
-    public void shouldDeleteCacheFolderIfFolderIsEmpty ()
+    @Test public void shouldDeleteCacheFolderIfFolderIsEmpty ()
     {
         gcNow();
-
         assertFalse(_folder.getRoot().exists());
     }
 
     private void gcNow() {
-        GarbageCollector collector = new GarbageCollector(
-                new FileWalker(_folder.getRoot()), -1, TimeUnit.MILLISECONDS);
-
-        collector.collectGarbage();
+        GarbageCollector.collect(_folder.getRoot(), -1);
     }
 
-    @Test
-    public void shouldKeepFilesInCacheIfRententionPeriodIsNotReached ()
+    @Test public void shouldKeepFilesInCacheIfRententionPeriodIsNotReached ()
     {
-        GarbageCollector collector = new GarbageCollector(
-                new FileWalker(_folder.getRoot()), 1, TimeUnit.DAYS);
-
-        collector.collectGarbage();
-
+        GarbageCollector.collect(_folder.getRoot(), TimeUnit.DAYS.toMillis(1));
         assertTrue(_cachedFile.exists());
         assertTrue(_lastAccessedFile.exists());
     }
 
-    @Test
-    public void shouldDeleteCachedFileIfLastAccessedFileIsMissing ()
+    @Test public void shouldDeleteCachedFileIfLastAccessedFileIsMissing ()
     {
         assumeTrue(_lastAccessedFile.delete());
-
         gcNow();
-
         assertFalse(_cachedFile.exists());
     }
 
-    @Test
-    public void shouldDeleteLastAccessedFileIfCachedFileIsMissing ()
+    @Test public void shouldDeleteLastAccessedFileIfCachedFileIsMissing ()
     {
         assumeTrue(_cachedFile.delete());
-
         gcNow();
-
         assertFalse(_lastAccessedFile.exists());
     }
 
-    @Rule
-    public TemporaryFolder _folder = new TemporaryFolder();
+    @Rule public TemporaryFolder _folder = new TemporaryFolder();
 
     private File _cachedFile;
     private File _lastAccessedFile;
diff --git a/src/test/java/com/threerings/getdown/classpath/cache/ResourceCacheTest.java b/src/test/java/com/threerings/getdown/classpath/cache/ResourceCacheTest.java
index dd589d8..8002f0b 100644
--- a/src/test/java/com/threerings/getdown/classpath/cache/ResourceCacheTest.java
+++ b/src/test/java/com/threerings/getdown/classpath/cache/ResourceCacheTest.java
@@ -1,30 +1,25 @@
 package com.threerings.getdown.classpath.cache;
 
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertTrue;
-
 import java.io.File;
 import java.io.IOException;
 import java.util.concurrent.TimeUnit;
 
-import org.junit.Before;
-import org.junit.Rule;
-import org.junit.Test;
+import org.junit.*;
 import org.junit.rules.TemporaryFolder;
 
+import static org.junit.Assert.*;
+
 /**
  * Asserts the correct functionality of the {@link ResourceCache}.
  */
 public class ResourceCacheTest
 {
-    @Before
-    public void setupCache () throws IOException {
+    @Before public void setupCache () throws IOException {
         _fileToCache = _folder.newFile("filetocache.jar");
         _cache = new ResourceCache(_folder.newFolder(".cache"));
     }
 
-    @Test
-    public void shouldCacheFile () throws IOException
+    @Test public void shouldCacheFile () throws IOException
     {
         assertEquals("abc123.jar", cacheFile().getName());
     }
@@ -34,53 +29,36 @@ public class ResourceCacheTest
         return _cache.cacheFile(_fileToCache, "abc123");
     }
 
-    @Test
-    public void shouldTrackFileUsage () throws IOException
+    @Test public void shouldTrackFileUsage () throws IOException
     {
-        File lastAccessedFile = new File(
-                cacheFile().getParentFile(),
-                "abc123.jar" + ResourceCache.LAST_ACCESSED_FILE_SUFFIX);
-
+        String name = "abc123.jar" + ResourceCache.LAST_ACCESSED_FILE_SUFFIX;
+        File lastAccessedFile = new File(cacheFile().getParentFile(), name);
         assertTrue(lastAccessedFile.exists());
     }
 
-    @Test
-    public void shouldNotCacheTheSameFile () throws Exception
+    @Test public void shouldNotCacheTheSameFile () throws Exception
     {
         File cachedFile = cacheFile();
-
         cachedFile.setLastModified(YESTERDAY);
-
         long expectedLastModified = cachedFile.lastModified();
-
         // caching it another time
         File sameCachedFile = cacheFile();
-
         assertEquals(expectedLastModified, sameCachedFile.lastModified());
     }
 
-    @Test
-    public void shouldRememberWhenFileWasRequested () throws Exception
+    @Test public void shouldRememberWhenFileWasRequested () throws Exception
     {
         File cachedFile = cacheFile();
-
-        File lastAccessedFile = new File(
-                cachedFile.getParentFile(),
-                cachedFile.getName() + ResourceCache.LAST_ACCESSED_FILE_SUFFIX);
-
+        String name = cachedFile.getName() + ResourceCache.LAST_ACCESSED_FILE_SUFFIX;
+        File lastAccessedFile = new File(cachedFile.getParentFile(), name);
         lastAccessedFile.setLastModified(YESTERDAY);
-
         long lastAccessed = lastAccessedFile.lastModified();
-
         // caching it another time
         cacheFile();
-
-
         assertTrue(lastAccessedFile.lastModified() > lastAccessed);
     }
 
-    @Rule
-    public TemporaryFolder _folder = new TemporaryFolder();
+    @Rule public TemporaryFolder _folder = new TemporaryFolder();
 
     private File _fileToCache;
     private ResourceCache _cache;
diff --git a/src/test/java/com/threerings/getdown/util/FileUtilTest.java b/src/test/java/com/threerings/getdown/util/FileUtilTest.java
index 30214bf..b9d68cd 100644
--- a/src/test/java/com/threerings/getdown/util/FileUtilTest.java
+++ b/src/test/java/com/threerings/getdown/util/FileUtilTest.java
@@ -5,49 +5,56 @@
 
 package com.threerings.getdown.util;
 
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertTrue;
-
 import java.io.File;
 import java.io.IOException;
 import java.io.StringReader;
 import java.util.List;
 
-import org.junit.Rule;
-import org.junit.Test;
+import org.junit.*;
 import org.junit.rules.TemporaryFolder;
 
+import static org.junit.Assert.*;
+
 /**
  * Tests {@link FileUtil}.
  */
 public class FileUtilTest
 {
-    @Test
-    public void testReadLines () throws IOException
+    @Test public void testReadLines () throws IOException
     {
         String data = "This is a test\nof a file with\na few lines\n";
         List lines = FileUtil.readLines(new StringReader(data));
         String[] linesBySplit = data.split("\n");
         assertEquals(linesBySplit.length, lines.size());
-        for (int i = 0; i < lines.size(); i++) {
-            assertEquals(linesBySplit[i], lines.get(i));
+        for (int ii = 0; ii < lines.size(); ii++) {
+            assertEquals(linesBySplit[ii], lines.get(ii));
         }
     }
 
-    @Test
-    public void shouldCopyFile () throws IOException
+    @Test public void shouldCopyFile () throws IOException
     {
         File source = _folder.newFile("source.txt");
         File target = new File(_folder.getRoot(), "target.txt");
-
         assertFalse(target.exists());
-
         FileUtil.copy(source, target);
-
         assertTrue(target.exists());
     }
 
-    @Rule
-    public TemporaryFolder _folder = new TemporaryFolder();
+    @Test public void shouldRecursivelyWalkOverFilesAndFolders () throws IOException
+    {
+        _folder.newFile("a.txt");
+        new File(_folder.newFolder("b"), "b.txt").createNewFile();
+
+        class CountingVisitor implements FileUtil.Visitor {
+            int fileCount = 0;
+            @Override public void visit(File file) {
+                fileCount++;
+            }
+        }
+        CountingVisitor visitor = new CountingVisitor();
+        FileUtil.walkTree(_folder.getRoot(), visitor);
+        assertEquals(3, visitor.fileCount);
+    }
+
+    @Rule public TemporaryFolder _folder = new TemporaryFolder();
 }
diff --git a/src/test/java/com/threerings/getdown/util/file/FileWalkerTest.java b/src/test/java/com/threerings/getdown/util/file/FileWalkerTest.java
deleted file mode 100644
index c4787e0..0000000
--- a/src/test/java/com/threerings/getdown/util/file/FileWalkerTest.java
+++ /dev/null
@@ -1,41 +0,0 @@
-package com.threerings.getdown.util.file;
-
-import static org.junit.Assert.assertEquals;
-
-import java.io.File;
-import java.io.IOException;
-
-import org.junit.Rule;
-import org.junit.Test;
-import org.junit.rules.TemporaryFolder;
-
-/**
- * Class under test: {@link FileWalker}.
- */
-public class FileWalkerTest
-{
-    @Test
-    public void shouldRecursivelyWalkOverFilesAndFolders () throws IOException
-    {
-        _folder.newFile("a.txt");
-        new File(_folder.newFolder("b"), "b.txt").createNewFile();
-
-        CountingVisitor visitor = new CountingVisitor();
-        new FileWalker(_folder.getRoot()).walkTree(visitor);
-
-        assertEquals(3, visitor.fileCount);
-    }
-
-    public static class CountingVisitor implements FileVisitor
-    {
-        int fileCount = 0;
-
-        @Override
-        public void visit(File file) {
-            fileCount++;
-        }
-    }
-
-    @Rule
-    public TemporaryFolder _folder = new TemporaryFolder();
-}

From 10c43a62527dbdbaed30a9ee8939d4316966bdd0 Mon Sep 17 00:00:00 2001
From: Michael Bayne 
Date: Sun, 25 Sep 2016 10:45:45 -0700
Subject: [PATCH 14/15] Format tweaks.

---
 .../com/threerings/getdown/data/SysPropsTest.java  | 14 ++++++--------
 1 file changed, 6 insertions(+), 8 deletions(-)

diff --git a/src/test/java/com/threerings/getdown/data/SysPropsTest.java b/src/test/java/com/threerings/getdown/data/SysPropsTest.java
index cac3d3f..342f4f6 100644
--- a/src/test/java/com/threerings/getdown/data/SysPropsTest.java
+++ b/src/test/java/com/threerings/getdown/data/SysPropsTest.java
@@ -5,27 +5,25 @@
 
 package com.threerings.getdown.data;
 
-import static org.junit.Assert.assertTrue;
-
 import org.junit.Test;
 
+import static org.junit.Assert.assertTrue;
+
 public class SysPropsTest
 {
     @Test
     public void shouldParseJavaVersion ()
     {
-        long version = SysProps.parseJavaVersion("java.version",
-                "(\\d+)\\.(\\d+)\\.(\\d+)(_\\d+)?");
-
+        long version = SysProps.parseJavaVersion(
+            "java.version", "(\\d+)\\.(\\d+)\\.(\\d+)(_\\d+)?");
         assertTrue(version > 1060000);
     }
 
     @Test
     public void shouldParseJavaRuntimeVersion ()
     {
-        long version = SysProps.parseJavaVersion("java.runtime.version",
-                "(\\d+)\\.(\\d+)\\.(\\d+)(_\\d+)?(-b\\d+)?");
-
+        long version = SysProps.parseJavaVersion(
+            "java.runtime.version", "(\\d+)\\.(\\d+)\\.(\\d+)(_\\d+)?(-b\\d+)?");
         assertTrue(version > 106000000);
     }
 }

From ba5b31300ef264a3af8ac2b866eb518174bc9641 Mon Sep 17 00:00:00 2001
From: Michael Bayne 
Date: Sun, 25 Sep 2016 10:46:09 -0700
Subject: [PATCH 15/15] Demote verbose logging to debug.

---
 src/main/java/com/threerings/getdown/util/ConfigUtil.java | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/main/java/com/threerings/getdown/util/ConfigUtil.java b/src/main/java/com/threerings/getdown/util/ConfigUtil.java
index ae82b4e..0a8bf2b 100644
--- a/src/main/java/com/threerings/getdown/util/ConfigUtil.java
+++ b/src/main/java/com/threerings/getdown/util/ConfigUtil.java
@@ -142,8 +142,8 @@ public class ConfigUtil
                 // if we're checking qualifiers and the os doesn't match this qualifier, skip it
                 String quals = pair[1].substring(1, qidx);
                 if (osname != null && !checkQualifiers(quals, osname, osarch)) {
-                    log.info("Skipping", "quals", quals, "osname", osname, "osarch", osarch,
-                             "key", pair[0], "value", pair[1]);
+                    log.debug("Skipping", "quals", quals, "osname", osname, "osarch", osarch,
+                              "key", pair[0], "value", pair[1]);
                     continue;
                 }
                 // otherwise filter out the qualifier text