// // $Id$ // // Nenya library - tools for developing networked games // Copyright (C) 2002-2007 Three Rings Design, Inc., All Rights Reserved // http://www.threerings.net/code/nenya/ // // This library is free software; you can redistribute it and/or modify it // under the terms of the GNU Lesser General Public License as published // by the Free Software Foundation; either version 2.1 of the License, or // (at your option) any later version. // // This library is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU // Lesser General Public License for more details. // // You should have received a copy of the GNU Lesser General Public // License along with this library; if not, write to the Free Software // Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA package com.threerings.resource; import java.awt.image.BufferedImage; import java.io.BufferedOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.util.jar.JarEntry; import java.util.jar.JarFile; import com.samskivert.util.FileUtil; import com.samskivert.util.StringUtil; import org.apache.commons.io.IOUtils; /** * A resource bundle provides access to the resources in a jar file. */ public class ResourceBundle { /** * Constructs a resource bundle with the supplied jar file. * * @param source a file object that references our source jar file. */ public ResourceBundle (File source) { this(source, false, false); } /** * Constructs a resource bundle with the supplied jar file. * * @param source a file object that references our source jar file. * @param delay if true, the bundle will wait until someone calls * {@link #sourceIsReady} before allowing access to its resources. * @param unpack if true the bundle will unpack itself into a * temporary directory */ public ResourceBundle (File source, boolean delay, boolean unpack) { _source = source; if (unpack) { String root = stripSuffix(source.getPath()); _unpacked = new File(root + ".stamp"); _cache = new File(root); } if (!delay) { sourceIsReady(); } } /** * Returns the {@link File} from which resources are fetched for this * bundle. */ public File getSource () { return _source; } /** * @return true if the bundle is fully downloaded and successfully * unpacked. */ public boolean isUnpacked () { return (_source.exists() && _unpacked != null && _unpacked.lastModified() == _source.lastModified()); } /** * Called by the resource manager once it has ensured that our * resource jar file is up to date and ready for reading. * * @return true if we successfully unpacked our resources, false if we * encountered errors in doing so. */ public boolean sourceIsReady () { // make a note of our source's last modification time _sourceLastMod = _source.lastModified(); // if we are unpacking files, the time to do so is now if (_unpacked != null && _unpacked.lastModified() != _sourceLastMod) { try { resolveJarFile(); } catch (IOException ioe) { Log.warning("Failure resolving jar file '" + _source + "': " + ioe + "."); wipeBundle(true); return false; } Log.info("Unpacking into " + _cache + "..."); if (!_cache.exists()) { if (!_cache.mkdir()) { Log.warning("Failed to create bundle cache directory '" + _cache + "'."); closeJar(); // we are hopelessly fucked return false; } } else { FileUtil.recursiveClean(_cache); } // unpack the jar file (this will close the jar when it's done) if (!FileUtil.unpackJar(_jarSource, _cache)) { // if something went awry, delete everything in the hopes // that next time things will work wipeBundle(true); return false; } // if everything unpacked smoothly, create our unpack stamp try { _unpacked.createNewFile(); if (!_unpacked.setLastModified(_sourceLastMod)) { Log.warning("Failed to set last mod on stamp file '" + _unpacked + "'."); } } catch (IOException ioe) { Log.warning("Failure creating stamp file '" + _unpacked + "': " + ioe + "."); // no need to stick a fork in things at this point } } return true; } /** * Clears out everything associated with this resource bundle in the * hopes that we can download it afresh and everything will work the * next time around. */ public void wipeBundle (boolean deleteJar) { // clear out our cache directory if (_cache != null) { FileUtil.recursiveClean(_cache); } // delete our unpack stamp file if (_unpacked != null) { _unpacked.delete(); } // clear out any .jarv file that Getdown might be maintaining so // that we ensure that it is revalidated File vfile = new File(FileUtil.resuffix(_source, ".jar", ".jarv")); if (vfile.exists() && !vfile.delete()) { Log.warning("Failed to delete " + vfile + "."); } // close and delete our source jar file if (deleteJar && _source != null) { closeJar(); if (!_source.delete()) { Log.warning("Failed to delete " + _source + " [exists=" + _source.exists() + "]."); } } } /** * Fetches the named resource from this bundle. The path should be * specified as a relative, platform independent path (forward * slashes). For example sounds/scream.au. * * @param path the path to the resource in this jar file. * * @return an input stream from which the resource can be loaded or * null if no such resource exists. * * @exception IOException thrown if an error occurs locating the * resource in the jar file. */ public InputStream getResource (String path) throws IOException { // unpack our resources into a temp directory so that we can load // them quickly and the file system can cache them sensibly File rfile = getResourceFile(path); return (rfile == null) ? null : new FileInputStream(rfile); } /** * Returns a file from which the specified resource can be loaded. * This method will unpack the resource into a temporary directory and * return a reference to that file. * * @param path the path to the resource in this jar file. * * @return a file from which the resource can be loaded or null if no * such resource exists. */ public File getResourceFile (String path) throws IOException { if (resolveJarFile()) { return null; } // if we have been unpacked, return our unpacked file if (_cache != null) { File cfile = new File(_cache, path); if (cfile.exists()) { return cfile; } else { return null; } } // otherwise, we unpack resources as needed into a temp directory String tpath = StringUtil.md5hex(_source.getPath() + "%" + path); File tfile = new File(getCacheDir(), tpath); if (tfile.exists() && (tfile.lastModified() > _sourceLastMod)) { return tfile; } JarEntry entry = _jarSource.getJarEntry(path); if (entry == null) { // Log.info("Couldn't locate " + path + " in " + _jarSource + "."); return null; } // copy the resource into the temporary file BufferedOutputStream fout = new BufferedOutputStream(new FileOutputStream(tfile)); InputStream jin = _jarSource.getInputStream(entry); IOUtils.copy(jin, fout); jin.close(); fout.close(); return tfile; } /** * Decodes and returns the specified image resource. Returns null if no resource exists at the * specified path. */ public BufferedImage getImageResource (String path) throws IOException { return ResourceManager.loadImage(getResourceFile(path)); } /** * Returns true if this resource bundle contains the resource with the * specified path. This avoids actually loading the resource, in the * event that the caller only cares to know that the resource exists. */ public boolean containsResource (String path) { try { if (resolveJarFile()) { return false; } return (_jarSource.getJarEntry(path) != null); } catch (IOException ioe) { return false; } } /** * Returns a string representation of this resource bundle. */ public String toString () { try { resolveJarFile(); return (_jarSource == null) ? "[file=" + _source + "]" : "[path=" + _jarSource.getName() + "]"; } catch (IOException ioe) { return "[file=" + _source + ", ioe=" + ioe + "]"; } } /** * Creates the internal jar file reference if we've not already got * it; we do this lazily so as to avoid any jar- or zip-file-related * antics until and unless doing so is required, and because the * resource manager would like to be able to create bundles before the * associated files have been fully downloaded. * * @return true if the jar file could not yet be resolved because we * haven't yet heard from the resource manager that it is ready for us * to access, false if all is cool. */ protected boolean resolveJarFile () throws IOException { // if we don't yet have our resource bundle's last mod time, we // have not yet been notified that it is ready if (_sourceLastMod == -1) { return true; } if (!_source.exists()) { throw new IOException("Missing jar file for resource bundle: " + _source + "."); } try { if (_jarSource == null) { _jarSource = new JarFile(_source); } return false; } catch (IOException ioe) { String msg = "Failed to resolve resource bundle jar file '" + _source + "'"; Log.warning(msg + "."); Log.logStackTrace(ioe); throw (IOException) new IOException(msg).initCause(ioe); } } /** * Closes our (possibly opened) jar file. */ protected void closeJar () { try { if (_jarSource != null) { _jarSource.close(); } } catch (Exception ioe) { Log.warning("Failed to close jar file [path=" + _source + ", error=" + ioe + "]."); } } /** * Returns the cache directory used for unpacked resources. */ public static File getCacheDir () { if (_tmpdir == null) { String tmpdir = System.getProperty("java.io.tmpdir"); if (tmpdir == null) { Log.info("No system defined temp directory. Faking it."); tmpdir = System.getProperty("user.home"); } setCacheDir(new File(tmpdir)); } return _tmpdir; } /** * Specifies the directory in which our temporary resource files should be stored. */ public static void setCacheDir (File tmpdir) { String rando = Long.toHexString((long)(Math.random() * Long.MAX_VALUE)); _tmpdir = new File(tmpdir, "narcache_" + rando); if (!_tmpdir.exists()) { if (_tmpdir.mkdirs()) { Log.debug("Created narya temp cache directory '" + _tmpdir + "'."); } else { Log.warning("Failed to create temp cache directory '" + _tmpdir + "'."); } } // add a hook to blow away the temp directory when we exit Runtime.getRuntime().addShutdownHook(new Thread() { public void run () { Log.info("Clearing narya temp cache '" + _tmpdir + "'."); FileUtil.recursiveDelete(_tmpdir); } }); } /** Strips the .jar off of jar file paths. */ protected static String stripSuffix (String path) { if (path.endsWith(".jar")) { return path.substring(0, path.length()-4); } else { // we have to change the path somehow return path + "-cache"; } } /** The file from which we construct our jar file. */ protected File _source; /** The last modified time of our source jar file. */ protected long _sourceLastMod = -1; /** A file whose timestamp indicates whether or not our existing jar * file has been unpacked. */ protected File _unpacked; /** A directory into which we unpack files from our bundle. */ protected File _cache; /** The jar file from which we load resources. */ protected JarFile _jarSource; /** A directory in which we temporarily unpack our resource files. */ protected static File _tmpdir; }