// // $Id: ResourceManager.java,v 1.41 2004/06/15 18:25:13 mdb Exp $ package com.threerings.resource; import java.awt.EventQueue; import java.io.BufferedInputStream; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.net.MalformedURLException; import java.net.URL; import java.util.ArrayList; import java.util.Enumeration; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Properties; import java.util.StringTokenizer; import javax.imageio.stream.FileImageInputStream; import javax.imageio.stream.ImageInputStream; import javax.imageio.stream.MemoryCacheImageInputStream; import com.samskivert.util.ResultListener; import com.samskivert.util.StringUtil; import com.threerings.resource.DownloadManager.DownloadDescriptor; import com.threerings.resource.DownloadManager.DownloadObserver; /** * The resource manager is responsible for maintaining a repository of * resources that are synchronized with a remote source. This is * accomplished in the form of sets of jar files (resource bundles) that * contain resources and that are updated from a remote resource * repository via HTTP. These resource bundles are organized into * resource sets. A resource set contains one or more resource bundles and * is defined much like a classpath. * *

The resource manager can load resources from the default resource * set, and can make available named resource sets to entities that wish * to do their own resource loading. If the resource manager fails to * locate a resource in the default resource set, it falls back to loading * the resource via the classloader (which will search the classpath). * *

Applications that wish to make use of resource sets and their * associated bundles must call {@link #initBundles} after constructing * the resource manager, providing the URL of a resource definition file * which describes these resource sets. The definition file will be loaded * and the resource bundles defined within will be loaded relative to the * resource definition URL. The bundles will be cached in the user's home * directory and only reloaded when the source resources have been * updated. The resource definition file looks something like the * following: * *

 * resource.set.default = sets/misc/config.jar: \
 *                        sets/misc/icons.jar
 * resource.set.tiles = sets/tiles/ground.jar: \
 *                      sets/tiles/objects.jar: \
 *                      /global/resources/tiles/ground.jar: \
 *                      /global/resources/tiles/objects.jar
 * resource.set.sounds = sets/sounds/sfx.jar: \
 *                       sets/sounds/music.jar: \
 *                       /global/resources/sounds/sfx.jar: \
 *                       /global/resources/sounds/music.jar
 * 
* *

All resource set definitions are prefixed with * resource.set. and all text following that string is * considered to be the name of the resource set. The resource set named * default is the default resource set and is the one that is * searched for resources is a call to {@link #getResource}. * *

When a resource is loaded from a resource set, the set is searched * in the order that entries are specified in the definition. */ public class ResourceManager { /** * Provides facilities for notifying an observer of resource bundle * download progress. */ public interface BundleDownloadObserver { /** * If this method returns true the download observer callbacks * will be called on the AWT thread, allowing the observer to do * things like safely update user interfaces, etc. If false, it * will be called on a special download thread. */ public boolean notifyOnAWTThread (); /** * Called when the resource manager is about to check for an * update of any of our resource sets. */ public void checkingForUpdate (); /** * Called to inform the observer of ongoing progress toward * completion of the overall bundle downloading task. The caller * is guaranteed to get at least one call reporting 100% * completion. * * @param percent the percent completion of the download. * @param remaining the estimated download time remaining in * seconds, or -1 if the time can not yet be * determined. */ public void downloadProgress (int percent, long remaining); /** * Called to inform the observer that the bundle patching process * has begun. This follows the download phase, but is optional. */ public void patching (); /** * Called to inform the observer that the bundle unpacking process * has begun. This follows the download or patching phase. */ public void unpacking (); /** * Called to indicate that we have validated and unpacked our * bundles and are fully ready to go. A call to {@link * #dowloadProgress} with 100 percent completion is guaranteed to * precede this call and optionally one to {@link #patching} and * {@link #unpacking}. */ public void downloadComplete (); /** * Called if a failure occurs while checking for an update or * downloading all resource sets. */ public void downloadFailed (Exception e); } /** * Constructs a resource manager which will load resources via the * classloader, prepending resourceRoot to their path. * * @param resourceRoot the path to prepend to resource paths prior to * attempting to load them via the classloader. When resources are * bundled into the default resource bundle, they don't need this * prefix, but if they're to be loaded from the classpath, it's likely * that they'll live in some sort of resources directory * to isolate them from the rest of the files in the classpath. This * is not a platform dependent path (forward slash is always used to * separate path elements). */ public ResourceManager (String resourceRoot) { // keep track of our root path _rootPath = resourceRoot; // make root path end with a slash (not the platform dependent // file system separator character as resource paths are passed to // ClassLoader.getResource() which requires / as its separator) if (!_rootPath.endsWith("/")) { _rootPath = _rootPath + "/"; } // use the classloader that loaded us _loader = getClass().getClassLoader(); // set up a URL handler so that things can be loaded via // urls with the 'resource' protocol Handler.registerHandler(this); // sort our our default cache path _baseCachePath = ""; try { // first check for an explicitly specified cache directory _baseCachePath = System.getProperty("narya.resource.cache_dir"); // if that's null, try putting it into their home directory if (_baseCachePath == null) { _baseCachePath = System.getProperty("user.home"); } if (!_baseCachePath.endsWith(File.separator)) { _baseCachePath += File.separator; } _baseCachePath += (CACHE_PATH + File.separator); } catch (SecurityException se) { Log.info("Can't obtain user.home system property. Probably " + "won't be able to create our cache directory " + "either. [error=" + se + "]."); } } /** * Instructs the resource manager to store cached resources in the * specified directory. This must be called before {@link * #initBundles} if it is going to be called at all. If it is not * called, the default cache directory will be used. */ public void setCachePath (String cachePath) { if (!cachePath.endsWith(File.separator)) { cachePath += File.separator; } _baseCachePath = cachePath; } /** * Initializes the bundle sets to be made available by this resource * manager. Applications that wish to make use of resource bundles * should call this method after constructing the resource manager. * * @param resourceURL the base URL from which resources are loaded. * Relative paths specified in the resource definition file will be * loaded relative to this path. If this is null, the system property * resource_url will be used, if available. * @param configPath the path (relative to the resource URL) of the * resource definition file. * @param version the version of the resource bundles to be * downloaded. * @param downloadObs the bundle download observer to notify of * download progress and success or failure, or null if * the caller doesn't care to be informed; note that in the latter * case, the calling thread will block until bundle updating is * complete. * * @exception IOException thrown if we are unable to download our * resource manager configuration from the support resource URL. */ public void initBundles (String resourceURL, String configPath, String version, BundleDownloadObserver downloadObs) throws IOException { _version = version; // if the resource URL wasn't provided, we try to figure it out // for ourselves if (resourceURL == null) { try { // first look for the explicit system property resourceURL = System.getProperty("resource_url"); // if that doesn't work, fall back to the current directory if (resourceURL == null) { resourceURL = "file:" + System.getProperty("user.dir"); } } catch (SecurityException se) { resourceURL = "file:" + File.separator; } } // make sure there's a slash at the end of the URL if (!resourceURL.endsWith("/")) { resourceURL += "/"; } try { _rurl = new URL(resourceURL); } catch (MalformedURLException mue) { Log.warning("Invalid resource URL [url=" + resourceURL + ", error=" + mue + "]."); throw new IOException("Invalid resource url '" + resourceURL + "'"); } // if no dynamic resource URL has been specified, use the normal // resource URL in its stead if (_drurl == null) { _drurl = _rurl; } Log.debug("Resource manager ready [rurl=" + _rurl + ", drurl=" + _drurl + "]."); // load up our configuration Properties config = new Properties(); URL curl = null; try { if (configPath != null) { curl = new URL(_rurl, configPath); config.load(curl.openStream()); } } catch (MalformedURLException mue) { Log.warning("Unable to construct config URL [resourceURL=" + _rurl + ", configPath=" + configPath + ", error=" + mue + "]."); String errmsg = "Invalid config url [resourceURL=" + _rurl + ", configPath=" + configPath + "]"; throw new IOException(errmsg); } catch (Exception e) { Log.warning("Unable to load resource mgr properties " + "[resourceURL=" + _rurl + ", configPath=" + configPath + ", error=" + e + "]."); Log.logStackTrace(e); String errmsg = "Unable to load resource manager config " + "[resourceURL=" + _rurl + ", configPath=" + configPath + "]"; throw new IOException(errmsg); } // resolve the configured resource sets ArrayList dlist = new ArrayList(); Enumeration names = config.propertyNames(); while (names.hasMoreElements()) { String key = (String)names.nextElement(); if (!key.startsWith(RESOURCE_SET_PREFIX)) { continue; } String setName = key.substring(RESOURCE_SET_PREFIX.length()); resolveResourceSet(setName, config.getProperty(key), dlist); } // start the download, blocking if we've no observer DownloadManager dlmgr = new DownloadManager(); if (downloadObs == null) { downloadBlocking(dlmgr, dlist); } else { downloadNonBlocking(dlmgr, dlist, downloadObs); } } /** * Configures the resource manager with a custom dynamic resource * URL. If it is not thusly configured, it will use the normal * resource URL for dynamic resources. */ public void setDynamicResourceURL (String dynResURL) { // make sure there's a slash at the end of the URL if (!dynResURL.endsWith("/")) { dynResURL += "/"; } try { _drurl = new URL(dynResURL); } catch (MalformedURLException mue) { Log.warning("Invalid dynamic resource URL " + "[url=" + dynResURL + ", error=" + mue + "]."); } } /** * Create the resource bundle for the specified set and path. */ protected ResourceBundle createResourceBundle (String setName, String path) { createCacheDirectory(setName); File cfile = new File(genCachePath(setName, path)); return new ResourceBundle(cfile, true, true); } /** * Checks to see if the specified dynamic bundle is already here * and ready to roll. */ public boolean checkDynamicBundle (String path) { return createResourceBundle(_dnset, path).isUnpacked(); } /** * Resolve the specified dynamic bundle and return it on the specified * result listener. */ public void resolveDynamicBundle (String path, String version, final ResultListener listener) { URL burl; try { burl = new URL(_drurl, path); } catch (MalformedURLException mue) { listener.requestFailed(mue); return; } // note our dynamic bundle set _dnset = DYNAMIC_BUNDLE_SET + StringUtil.md5hex(_drurl.toString()); final ResourceBundle bundle = createResourceBundle(_dnset, path); if (bundle.isUnpacked()) { if (bundle.sourceIsReady()) { listener.requestCompleted(bundle); } else { String errmsg = "Bundle initialization failed."; listener.requestFailed(new IOException(errmsg)); } return; } // slap this on the list for retrieval or update by the download // manager ArrayList list = new ArrayList(); list.add(new DownloadDescriptor(burl, bundle.getSource(), version)); // TODO: There should only be one download manager for all dynamic // bundles, with each bundle waiting its turn to use it. DownloadObserver obs = new DownloadObserver() { public boolean notifyOnAWTThread () { return false; } public void resolvingDownloads () { } public void downloadProgress (int percent, long remaining) { } public void patching () { } public void postDownloadHook () { } public void downloadComplete () { final boolean unpacked = bundle.sourceIsReady(); EventQueue.invokeLater(new Runnable() { public void run () { if (unpacked) { listener.requestCompleted(bundle); } else { String errmsg = "Bundle initialization failed."; listener.requestFailed(new IOException(errmsg)); } } }); } public void downloadFailed ( DownloadDescriptor desc, final Exception e) { Log.warning("Failed to download file " + "[desc=" + desc + ", e=" + e + "]."); EventQueue.invokeLater(new Runnable() { public void run () { listener.requestFailed(e); } }); } }; DownloadManager dlmgr = new DownloadManager(); dlmgr.download(list, true, obs); } /** * Downloads the files in the supplied download list, blocking the * calling thread until the download is complete or a failure has * occurred. */ protected void downloadBlocking (DownloadManager dlmgr, List dlist) { // create an object to wait on while the download takes place final Object lock = new Object(); // create the observer that will notify us when all is finished DownloadObserver obs = new DownloadObserver() { public boolean notifyOnAWTThread () { return false; } public void resolvingDownloads () { // nothing for now } public void downloadProgress (int percent, long remaining) { // nothing for now } public void patching () { // nothing for now } public void postDownloadHook () { bundlesDownloaded(); } public void downloadComplete () { synchronized (lock) { // wake things up as the download is finished lock.notify(); } } public void downloadFailed (DownloadDescriptor desc, Exception e) { Log.warning("Failed to download file " + "[desc=" + desc + ", e=" + e + "]."); synchronized (lock) { // wake things up since we're fragile and so a // single failure means all is booched lock.notify(); } } }; synchronized (lock) { // pass the descriptors on to the download manager dlmgr.download(dlist, true, obs); try { // block until the download has completed lock.wait(); } catch (InterruptedException ie) { Log.warning("Thread interrupted while waiting for download " + "to complete [ie=" + ie + "]."); } } } /** * Downloads the files in the supplied download list asynchronously, * notifying the download observer of ongoing progress. */ protected void downloadNonBlocking ( DownloadManager dlmgr, List dlist, final BundleDownloadObserver obs) { // pass the descriptors on to the download manager dlmgr.download(dlist, true, new DownloadObserver() { public boolean notifyOnAWTThread () { return obs.notifyOnAWTThread(); } public void resolvingDownloads () { obs.checkingForUpdate(); } public void downloadProgress (int percent, long remaining) { obs.downloadProgress(percent, remaining); } public void patching () { obs.patching(); } public void postDownloadHook () { obs.unpacking(); _unpacked = bundlesDownloaded(); } public void downloadComplete () { if (_unpacked) { obs.downloadComplete(); } else { String errmsg = "Bundle(s) failed initialization."; obs.downloadFailed(new IOException(errmsg)); } } public void downloadFailed (DownloadDescriptor desc, Exception e) { obs.downloadFailed(e); } protected boolean _unpacked; }); } /** * Called when our resource bundle downloads have completed. * * @return true if we are fully operational, false if one or more * bundles failed to initialize. */ protected boolean bundlesDownloaded () { // let our bundles know that it's ok for them to access their // resource files Iterator iter = _sets.values().iterator(); boolean unpacked = true; while (iter.hasNext()) { ResourceBundle[] bundles = (ResourceBundle[])iter.next(); for (int ii = 0; ii < bundles.length; ii++) { unpacked = bundles[ii].sourceIsReady() && unpacked; } } return unpacked; } /** * Ensures that the cache directory for the specified resource set is * created. * * @return true if the directory was successfully created (or was * already there), false if we failed to create it. */ protected boolean createCacheDirectory (String setName) { // compute the path to the top-level cache directory if we haven't // already been so configured if (_cachePath == null) { // start with the base cache path _cachePath = _baseCachePath; if (!createDirectory(_cachePath)) { return false; } // next incorporate the resource URL into the cache path so // that files fetched from different resource roots do not // overwrite one another _cachePath += StringUtil.md5hex(_rurl.toString()) + File.separator; if (!createDirectory(_cachePath)) { return false; } Log.debug("Caching bundles in '" + _cachePath + "'."); } // ensure that the set-specific cache directory exists return createDirectory(_cachePath + setName); } /** * Creates the specified directory if it doesn't already exist. * * @return true if directory was created (or existed), false if not. */ protected boolean createDirectory (String path) { File cdir = new File(path); if (cdir.exists()) { if (!cdir.isDirectory()) { Log.warning("Cache dir exists but isn't a directory?! " + "[path=" + path + "]."); return false; } } else { if (!cdir.mkdirs()) { Log.warning("Unable to create cache dir. " + "[path=" + path + "]."); return false; } } return true; } /** * Generates the name of the bundle cache file given the name of the * resource set to which it belongs and the relative path URL. */ protected String genCachePath (String setName, String resourcePath) { return _cachePath + setName + File.separator + StringUtil.replace(resourcePath, "/", "-"); } /** * Loads up a resource set based on the supplied definition * information. */ protected void resolveResourceSet ( String setName, String definition, List dlist) { StringTokenizer tok = new StringTokenizer(definition, ":"); ArrayList set = new ArrayList(); while (tok.hasMoreTokens()) { String path = tok.nextToken().trim(); URL burl = null; try { burl = new URL(_rurl, path); // make sure the cache directory exists for this set createCacheDirectory(setName); // compute the versioned path for this bundle if we have a // version configured String vpath = path; if (DownloadManager.VERSIONING && !StringUtil.blank(_version)) { vpath = versionPath( path, _version, getSuffix(path, ".jar")); } // compute the path to the cache file for this bundle File cfile = new File(genCachePath(setName, path)); File vfile = new File(genCachePath(setName, vpath)); // slap this on the list for retrieval or update by the // download manager dlist.add(new DownloadDescriptor(burl, cfile, _version)); // finally, add the file that will be cached to the set as // a resource bundle set.add(new ResourceBundle(vfile, true, true)); } catch (MalformedURLException mue) { Log.warning("Unable to create URL for resource " + "[set=" + setName + ", path=" + path + ", error=" + mue + "]."); } } // convert our array list into an array and stick it in the table ResourceBundle[] setvec = new ResourceBundle[set.size()]; set.toArray(setvec); _sets.put(setName, setvec); // if this is our default resource bundle, keep a reference to it if (DEFAULT_RESOURCE_SET.equals(setName)) { _default = setvec; } } /** * Fetches a resource from the local repository. * * @param path the path to the resource * (ie. "config/miso.properties"). This should not begin with a slash. * * @exception IOException thrown if a problem occurs locating or * reading the resource. */ public InputStream getResource (String path) throws IOException { InputStream in = null; // first look for this resource in our default resource bundle for (int i = 0; i < _default.length; i++) { in = _default[i].getResource(path); if (in != null) { return in; } } // if we didn't find anything, try the classloader String rpath = _rootPath + path; in = _loader.getResourceAsStream(rpath); if (in != null) { return in; } // if we still haven't found it, we throw an exception String errmsg = "Unable to locate resource [path=" + path + "]"; throw new FileNotFoundException(errmsg); } /** * Fetches the specified resource as an {@link ImageInputStream} and * one that takes advantage, if possible, of caching of unpacked * resources on the local filesystem. * * @exception FileNotFoundException thrown if the resource could not * be located in any of the bundles in the specified set, or if the * specified set does not exist. * @exception IOException thrown if a problem occurs locating or * reading the resource. */ public ImageInputStream getImageResource (String path) throws IOException { // first look for this resource in our default resource bundle for (int i = 0; i < _default.length; i++) { File file = _default[i].getResourceFile(path); if (file != null) { return new FileImageInputStream(file); } } // if we didn't find anything, try the classloader String rpath = _rootPath + path; InputStream in = _loader.getResourceAsStream(rpath); if (in != null) { return new MemoryCacheImageInputStream(new BufferedInputStream(in)); } // if we still haven't found it, we throw an exception String errmsg = "Unable to locate image resource [path=" + path + "]"; throw new FileNotFoundException(errmsg); } /** * Returns an input stream from which the requested resource can be * loaded. Note: this performs a linear search of all of the * bundles in the set and returns the first resource found with the * specified path, thus it is not extremely efficient and will behave * unexpectedly if you use the same paths in different resource * bundles. * * @exception FileNotFoundException thrown if the resource could not * be located in any of the bundles in the specified set, or if the * specified set does not exist. * @exception IOException thrown if a problem occurs locating or * reading the resource. */ public InputStream getResource (String rset, String path) throws IOException { // grab the resource bundles in the specified resource set ResourceBundle[] bundles = getResourceSet(rset); if (bundles == null) { throw new FileNotFoundException( "Unable to locate resource [set=" + rset + ", path=" + path + "]"); } // look for the resource in any of the bundles int size = bundles.length; for (int ii = 0; ii < size; ii++) { InputStream instr = bundles[ii].getResource(path); if (instr != null) { // Log.info("Found resource [rset=" + rset + // ", bundle=" + bundles[ii].getSource().getPath() + // ", path=" + path + ", in=" + instr + "]."); return instr; } } throw new FileNotFoundException( "Unable to locate resource [set=" + rset + ", path=" + path + "]"); } /** * Fetches the specified resource as an {@link ImageInputStream} and * one that takes advantage, if possible, of caching of unpacked * resources on the local filesystem. * * @exception FileNotFoundException thrown if the resource could not * be located in any of the bundles in the specified set, or if the * specified set does not exist. * @exception IOException thrown if a problem occurs locating or * reading the resource. */ public ImageInputStream getImageResource (String rset, String path) throws IOException { // grab the resource bundles in the specified resource set ResourceBundle[] bundles = getResourceSet(rset); if (bundles == null) { throw new FileNotFoundException( "Unable to locate image resource [set=" + rset + ", path=" + path + "]"); } // look for the resource in any of the bundles int size = bundles.length; for (int ii = 0; ii < size; ii++) { File file = bundles[ii].getResourceFile(path); if (file != null) { // Log.info("Found image resource [rset=" + rset + // ", bundle=" + bundles[ii].getSource() + // ", path=" + path + ", file=" + file + "]."); return new FileImageInputStream(file); } } String errmsg = "Unable to locate image resource [set=" + rset + ", path=" + path + "]"; throw new FileNotFoundException(errmsg); } /** * Returns a reference to the resource set with the specified name, or * null if no set exists with that name. Services that wish to load * their own resources can allow the resource manager to load up a * resource set for them, from which they can easily load their * resources. */ public ResourceBundle[] getResourceSet (String name) { return (ResourceBundle[])_sets.get(name); } /** * Creates a versioned path using the supplied version string, path * and file suffix. For example foo/bar/baz.jar becomes * foo/bar/baz__Vversion.jar. */ public static String versionPath ( String path, String version, String suffix) { if (!suffix.startsWith(".")) { suffix = "." + suffix; } int sidx = path.lastIndexOf(suffix); if (sidx == -1) { Log.warning("Invalid unversioned path, missing suffix " + "[path=" + path + ", suffix=" + suffix + "]."); return path; } return path.substring(0, sidx) + "__V" + version + suffix; } /** * Creates an unversioned version of the supplied path. If the path is * not versioned, the existing path will be returned. */ public static String unversionPath (String path, String suffix) { if (!suffix.startsWith(".")) { suffix = "." + suffix; } int vidx = path.indexOf("__V"); if (vidx == -1) { return path; } int sidx = path.lastIndexOf(suffix); if (sidx == -1) { Log.warning("Invalid versioned path, missing suffix " + "[path=" + path + ", suffix=" + suffix + "]."); return path; } return path.substring(0, vidx) + suffix; } /** * Returns the suffix of the specified path, ie. .jar if * the path references a file that ends in .jar. */ public static String getSuffix (String path, String defsuf) { int didx = path.lastIndexOf("."); if (didx == -1) { return defsuf; } else { return path.substring(didx); } } /** The classloader we use for classpath-based resource loading. */ protected ClassLoader _loader; /** The version of the resource bundles we'll be downloading. */ protected String _version; /** The url via which we download our bundles. */ protected URL _rurl; /** The url via which we download our dynamically generated bundles. */ protected URL _drurl; /** The resource set we use for dynamic resources from a particular * dynamic bundle URL. */ protected String _dnset; /** The prefix we prepend to resource paths before attempting to load * them from the classpath. */ protected String _rootPath; /** The path to the directory that will contain our bundle cache. */ protected String _baseCachePath; /** The path to our bundle cache directory. */ protected String _cachePath; /** Our default resource set. */ protected ResourceBundle[] _default = new ResourceBundle[0]; /** A table of our resource sets. */ protected HashMap _sets = new HashMap(); /** The prefix of configuration entries that describe a resource * set. */ protected static final String RESOURCE_SET_PREFIX = "resource.set."; /** The name of the default resource set. */ protected static final String DEFAULT_RESOURCE_SET = "default"; /** The name of our resource bundle cache directory. */ protected static final String CACHE_PATH = ".narya"; /** The name of the resource set where we store dynamic bundles. */ protected static final String DYNAMIC_BUNDLE_SET = "_dynamic"; }