first commit

This commit is contained in:
Vhati 2013-08-21 13:23:01 -04:00
parent 352e1653f8
commit 16a197e856
44 changed files with 5942 additions and 3 deletions

View file

@ -0,0 +1,36 @@
<assembly>
<id>dist-unix</id>
<formats>
<format>tar</format>
</formats>
<fileSets>
<fileSet>
<directory>skel_common</directory>
<outputDirectory>/</outputDirectory>
</fileSet>
<fileSet> <!-- Non-executables. -->
<directory>skel_unix</directory>
<outputDirectory>/</outputDirectory>
<excludes>
<exclude>**/*.command</exclude>
<exclude>**/*.sh</exclude>
</excludes>
</fileSet>
<fileSet> <!-- Executables. -->
<directory>skel_unix</directory>
<outputDirectory>/</outputDirectory>
<includes>
<include>**/*.command</include>
<include>**/*.sh</include>
</includes>
<fileMode>755</fileMode>
</fileSet>
<fileSet>
<directory>${project.build.directory}</directory>
<outputDirectory>/</outputDirectory>
<includes>
<include>modman.jar</include>
</includes>
</fileSet>
</fileSets>
</assembly>

View file

@ -0,0 +1,29 @@
<assembly>
<id>dist-win</id>
<formats>
<format>zip</format>
</formats>
<fileSets>
<fileSet>
<directory>skel_common</directory>
<outputDirectory>/</outputDirectory>
<includes>
<include>*</include>
</includes>
</fileSet>
<fileSet>
<directory>skel_win</directory>
<outputDirectory>/</outputDirectory>
<includes>
<include>*</include>
</includes>
</fileSet>
<fileSet>
<directory>${project.build.directory}</directory>
<outputDirectory>/</outputDirectory>
<includes>
<include>modman.jar</include>
</includes>
</fileSet>
</fileSets>
</assembly>

View file

@ -0,0 +1,914 @@
package net.vhati.ftldat;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.Charset;
import java.nio.charset.CharsetEncoder;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Stack;
import net.vhati.ftldat.FileChannelRegionInputStream;
import net.vhati.ftldat.SizeLimitInputStream;
public class FTLDat {
/**
* Splits a path on "/" the way FTL expects them in .dat files.
*/
public static String[] ftlPathSplit( String path ) {
return path.split( "/" );
}
/**
* Concatenates an array of Strings with "/" between them,
* as seen in .dat files.
* Backslashes will become forward-slashes.
*/
public static String ftlPathJoin( String[] chunks ) {
StringBuilder buf = new StringBuilder();
boolean first = true;
for ( String chunk : chunks ) {
if ( chunk.length() == 0 ) continue;
if (first) {
buf.append( "/" );
first = false;
}
buf.append( chunk );
}
return buf.toString().replace("\\", "/");
}
public static String ftlPathJoin( String a, String b ) {
StringBuilder buf = new StringBuilder();
if ( a.length() > 0 ) buf.append( a );
if ( a.length() * b.length() > 0 ) buf.append( "/" );
if ( b.length() > 0 ) buf.append( b );
return buf.toString().replace("\\", "/");
}
/**
* Copies all bytes from one file to another.
*/
public static void copyFile( File srcFile, File dstFile ) throws IOException {
FileInputStream is = null;
FileOutputStream os = null;
try {
is = new FileInputStream( srcFile );
os = new FileOutputStream( dstFile );
byte[] buf = new byte[4096];
int len;
while ( (len = is.read(buf)) >= 0 ) {
os.write( buf, 0, len );
}
}
finally {
try {if (is != null) is.close();}
catch (IOException e) {}
try {if (os != null) os.close();}
catch (IOException e) {}
}
}
/**
* Calculates an MD5 hash of data from an InputStream.
*
* The returned string will be lowercase hexadecimal.
*/
public static String calcStreamMD5( InputStream is ) throws NoSuchAlgorithmException, IOException {
MessageDigest md = MessageDigest.getInstance( "MD5" );
byte[] buf = new byte[4096];
int len;
while ( (len = is.read(buf)) >= 0 ) {
md.update( buf, 0, len );
}
byte[] hashBytes = md.digest();
StringBuilder hashStringBuf = new StringBuilder();
for ( byte b : hashBytes ) {
hashStringBuf.append( Integer.toString( (b & 0xff) + 0x100, 16 ).substring(1) );
}
return hashStringBuf.toString();
}
public static String calcFileMD5( File f ) throws NoSuchAlgorithmException, IOException {
String result = null;
FileInputStream is = null;
try {
is = new FileInputStream(f);
result = FTLDat.calcStreamMD5( is );
}
finally {
try {if (is != null) is.close();}
catch (Exception e) {}
}
return result;
}
/**
* Returns an approximate byte count for humans.
*/
private String humanReadableByteCount( long bytes, boolean si ) {
int unit = si ? 1000 : 1024;
if ( bytes < unit ) return bytes +" B";
int exp = (int)( Math.log(bytes) / Math.log(unit) );
String pre = (si ? "kMGTPE" : "KMGTPE").charAt(exp-1) + (si ? "" : "i");
return String.format( "%.1f %sB", (bytes / Math.pow(unit, exp)), pre );
}
public static class FTLDatException extends RuntimeException {
public FTLDatException() {
super();
}
public FTLDatException( String message ) {
super( message );
}
}
/**
* Information about an innerFile within a dat.
*
* entryOffset - Offset (written in header) to
* the dataSize + innerPath + data.
* innerPath - A virtual location ("dir/dir/filename").
* dataOffset - Offset to the innerFile.
* dataSize - Size of the innerFile.
*/
public static class DatEntry {
public long entryOffset = 0;
public String innerPath = null;
public long dataOffset = 0;
public long dataSize = 0;
public DatEntry() {
}
}
/**
* A holder for (innerPath + size) results from listSizes().
*/
public static class PathAndSize {
public String path = null;
public long size = 0;
public PathAndSize( String path, long size ) {
this.path = path;
this.size = size;
}
}
/**
* A holder for results after repacking a dat.
*/
public static class RepackResult {
public long oldDatLength = 0;
public long newDatLength = 0;
public long bytesChanged = 0;
public RepackResult( long oldDatLength, long newDatLength, long bytesChanged ) {
this.oldDatLength = oldDatLength;
this.newDatLength = newDatLength;
this.bytesChanged = bytesChanged;
}
}
/**
* An InputStream wrapping a ByteBuffer.
*
* A memory-mapped region of a file is a ByteBuffer.
*/
public static class ByteBufferBackedInputStream extends InputStream {
ByteBuffer buf;
public ByteBufferBackedInputStream( ByteBuffer buf ) {
this.buf = buf;
}
@Override
public synchronized int available() throws IOException {
if ( !buf.hasRemaining() ) return 0;
return buf.remaining();
}
@Override
public synchronized int read() throws IOException {
if ( !buf.hasRemaining() ) return -1;
return buf.get() & 0xFF;
}
@Override
public synchronized int read( byte[] bytes, int off, int len ) throws IOException {
if ( !buf.hasRemaining() ) return -1;
len = Math.min( len, buf.remaining() );
buf.get(bytes, off, len);
return len;
}
}
public abstract static class AbstractPack {
/**
* Returns a descriptive name for this dat.
*/
public String getName() {
throw new UnsupportedOperationException();
}
/**
* Returns an list of all innerPaths.
*/
public List<String> list() {
throw new UnsupportedOperationException();
}
/**
* Returns a list of pairs of (innerPath, filesize).
*/
public List<PathAndSize> listSizes() {
throw new UnsupportedOperationException();
}
/**
* Adds bytes read from srcFile to the pack, as innerPath.
*/
public void add( String innerPath, InputStream is ) throws IOException {
throw new UnsupportedOperationException();
}
/**
* Writes the contents of the file with innerPath to dstFile.
*/
public void extractTo( String innerPath, OutputStream os ) throws FileNotFoundException, IOException {
throw new UnsupportedOperationException();
}
/**
* Removes the file with innerPath from the pack.
*/
public void remove( String innerPath ) throws FileNotFoundException, IOException {
throw new UnsupportedOperationException();
}
/**
* Returns whether innerPath is in the pack.
*/
public boolean contains( String innerPath ) {
throw new UnsupportedOperationException();
}
/**
* Returns an InputStream get bytes from an innerFile.
*
* Close all input streams before calling methods to
* modify this dat. Do not pass an input stream from
* this dat instance into another of its own methods.
*/
public InputStream getInputStream( String innerPath ) throws FileNotFoundException, IOException {
throw new UnsupportedOperationException();
}
/**
* Closes this dat and releases any system resources associated with the stream.
*/
public void close() throws IOException {
}
}
/**
* A pseudo-dat backed by a real filesystem.
*
* Files can be independently added/removed/altered
* directly, so long as this class is not busy
* at the time.
*/
public static class FolderPack extends AbstractPack {
private File rootDir;
public FolderPack( File rootDir ) {
this.rootDir = rootDir;
}
@Override
public String getName() {
return rootDir.getName();
}
@Override
public List<String> list() {
List<String> result = new ArrayList<String>();
Stack<String> pendingPaths = new Stack<String>();
pendingPaths.push( "" );
while ( !pendingPaths.isEmpty() ) {
String current = pendingPaths.pop();
File tmpFile = new File( rootDir, current );
if ( tmpFile.isFile() ) {
result.add( current );
}
else if ( tmpFile.isDirectory() ) {
for ( String childName : tmpFile.list() ) {
pendingPaths.push( FTLDat.ftlPathJoin(current, childName) );
}
}
}
return result;
}
@Override
public List<PathAndSize> listSizes() {
List<PathAndSize> result = new ArrayList<PathAndSize>();
List<String> innerPaths = list();
for ( String innerPath : innerPaths ) {
File tmpFile = getFile( innerPath );
result.add( new PathAndSize( innerPath, tmpFile.length() ) );
}
return result;
}
@Override
public void add( String innerPath, InputStream is ) throws IOException {
File dstFile = getFile( innerPath );
if ( dstFile.exists() ) throw new FTLDatException( "File already exists" );
dstFile.getParentFile().mkdirs();
FileOutputStream os = null;
try {
os = new FileOutputStream( dstFile );
byte[] buf = new byte[4096];
int len;
while ( (len = is.read(buf)) >= 0 ) {
os.write( buf, 0, len );
}
}
finally {
try {if (os != null) os.close();}
catch (IOException e) {}
}
}
@Override
public void extractTo( String innerPath, OutputStream os ) throws IOException {
File srcFile = getFile( innerPath );
FileInputStream is = null;
try {
is = new FileInputStream( srcFile );
byte[] buf = new byte[4096];
int len;
while ( (len = is.read(buf)) >= 0 ) {
os.write( buf, 0, len );
}
}
finally {
try {if (is != null) is.close();}
catch (IOException e) {}
}
}
@Override
public void remove( String innerPath ) {
File tmpFile = getFile( innerPath );
if ( tmpFile.exists() && tmpFile.isFile() ) {
tmpFile.delete();
}
}
@Override
public boolean contains( String innerPath ) {
File tmpFile = getFile( innerPath );
return tmpFile.exists();
}
@Override
public InputStream getInputStream( String innerPath ) throws FileNotFoundException, IOException {
return new FileInputStream( getFile( innerPath ) );
}
/**
* Returns a File object for an innerPath.
* The location it represents is not guaranteed to exist.
*/
public File getFile( String innerPath ) {
return new File( rootDir, innerPath );
}
}
public static class FTLPack extends AbstractPack {
private CharsetEncoder asciiEncoder = Charset.forName("US-ASCII").newEncoder();
private File datFile = null;
private RandomAccessFile raf = null;
private ArrayList<DatEntry> entryList = null;
private Map<String,Integer> pathToIndexMap = null;
private ByteBuffer byteBuffer = null;
public FTLPack( File datFile, boolean create ) throws IOException {
this( datFile, create, 2048 );
}
public FTLPack( File datFile, boolean create, int indexSize ) throws IOException {
this.datFile = datFile;
raf = new RandomAccessFile( datFile, "rw" );
if ( create ) {
createIndex( indexSize );
} else {
readIndex();
}
}
/**
* Reads a little-endian unsigned int.
* Java doesn't have an unsigned int primitive,
* so a long holds the value instead.
*/
private long readLittleUInt() throws IOException {
if ( byteBuffer == null || !byteBuffer.hasArray() || byteBuffer.array().length < 4 ) {
byteBuffer = ByteBuffer.wrap( new byte[4] );
byteBuffer.order( ByteOrder.LITTLE_ENDIAN );
}
raf.readFully( byteBuffer.array(), 0, 4 );
// Read a signed int, then discard sign
// by casting to long and hacking off bits.
long result = byteBuffer.getInt( 0 );
result &= 0x00000000FFFFFFFFL;
return result;
}
private void writeLittleUInt( long n ) throws IOException {
if ( byteBuffer == null || !byteBuffer.hasArray() || byteBuffer.array().length < 4 ) {
byteBuffer = ByteBuffer.wrap( new byte[4] );
byteBuffer.order( ByteOrder.LITTLE_ENDIAN );
}
// Write a signed int, after discarding sign
// by casting from long and hacking off bits.
byteBuffer.putInt( 0, (int)(n & 0x00000000FFFFFFFFL) );
raf.write( byteBuffer.array(), 0, 4 );
}
private String readLittleUString() throws IOException {
long strLen = readLittleUInt();
byte[] strBytes = new byte[ (int)strLen ];
raf.readFully( strBytes );
return new String( strBytes, asciiEncoder.charset().name() );
}
private void writeLittleUString( String s ) throws IOException {
writeLittleUInt( s.length() );
byte[] strBytes = s.getBytes( asciiEncoder.charset().name() );
raf.write( strBytes );
}
/**
* Returns the offset to seek within the header,
* in order to read the offset of an innerFile entry.
*
* @param n the nth index.
*/
private long getHeaderIndexPosition( int n ) {
return ( 4 + n*4 ); // 4-byte indexSize + 4-byte indeces.
}
/**
* Creates a new index.
* WARNING: This will erase the file.
*/
private void createIndex( int indexSize ) throws IOException {
entryList = new ArrayList<DatEntry>( indexSize );
for ( int i=0; i < indexSize; i++ )
entryList.add( null );
pathToIndexMap = new HashMap<String,Integer>( indexSize );
raf.seek( 0 );
raf.setLength( 0 );
writeLittleUInt( indexSize );
for ( int i=0; i < indexSize; i++ )
writeLittleUInt( 0 );
}
/**
* Reads (or re-reads) the index from the file.
*/
private void readIndex() throws IOException {
raf.seek( 0 );
int indexSize = (int)readLittleUInt();
if ( indexSize * 4 > raf.length() ) {
throw new IOException( String.format( "Corrupt dat file (%s): Its header claims to be larger than the entire file.", getName() ) );
}
entryList = new ArrayList<DatEntry>( indexSize );
for ( int i=0; i < indexSize; i++ )
entryList.add( null );
pathToIndexMap = new HashMap<String,Integer>( indexSize );
// Store partial DatEntry objects in entryList (leaving nulls where absent).
for ( int i=0; i < indexSize; i++ ) {
long entryOffset = readLittleUInt();
if ( entryOffset != 0 ) {
DatEntry entry = new DatEntry();
entry.entryOffset = entryOffset;
entryList.set( i, entry );
}
}
for ( int i=0; i < indexSize; i++ ) {
DatEntry entry = entryList.get( i );
if ( entry == null ) continue;
raf.seek( entry.entryOffset );
entry.dataSize = readLittleUInt();
entry.innerPath = readLittleUString();
entry.dataOffset = raf.getChannel().position();
if ( pathToIndexMap.containsKey( entry.innerPath ) ) {
throw new IOException( "InnerPath occurs more than once: "+ entry.innerPath );
}
pathToIndexMap.put( entry.innerPath, new Integer(i) );
}
}
/**
* Moves the nth index's entry to the end of the file.
* It will still be nth in the header, however.
* Used by growIndex().
*/
private void moveEntryToEOF( int n ) throws IOException {
DatEntry entry = entryList.get( n );
long oldOffset = entry.entryOffset;
long newOffset = raf.length();
long totalBytes = (entry.dataOffset-entry.entryOffset) + entry.dataSize;
long bytesRemaining = totalBytes;
byte[] buf = new byte[4096];
int len;
while ( bytesRemaining > 0 ) {
raf.seek( oldOffset + totalBytes - bytesRemaining );
len = raf.read( buf, 0, (int)Math.min(buf.length, bytesRemaining) );
if ( len == -1 ) {
throw new IOException( "EOF prematurely reached reading innerPath: "+ entry.innerPath );
}
raf.seek( newOffset + totalBytes - bytesRemaining );
raf.write( buf, 0, len );
bytesRemaining -= len;
}
// Update the index.
raf.seek( getHeaderIndexPosition( n ) );
writeLittleUInt( newOffset );
entry.dataOffset = ( newOffset + (entry.dataOffset-entry.entryOffset) );
entry.entryOffset = newOffset;
}
/**
* Ensures the index has room for at least <amount> entries.
* This is done by moving the first innerFile after the index
* to the end of the file. The region it used to occupy can then
* be filled with additional indeces.
*/
private void growIndex( int amount ) throws IOException {
int freeRoom = -1;
while ( true ) {
int vacancyCount = Collections.frequency( entryList, null );
if ( entryList.size() - vacancyCount == 0 ) {
// There is no innerFile after the index. We can grow
// as much as we like. Limit ourselves to amount.
freeRoom = amount;
break;
}
else {
// Find the used index with the lowest entryOffset.
int earliestUsedIndex = -1;
long minEntryOffset = Long.MAX_VALUE;
for ( int i=0; i < entryList.size(); i++ ) {
DatEntry entry = entryList.get( i );
if ( entry.entryOffset < minEntryOffset ) {
earliestUsedIndex = i;
minEntryOffset = entry.entryOffset;
}
}
// (region between header and first innerFile entry) / (possible 4-byte ints).
freeRoom = (int)( ( minEntryOffset - getHeaderIndexPosition( entryList.size() ) ) / 4 );
if ( freeRoom >= amount ) {
freeRoom = amount; // We don't need hundreds of thousands more.
break;
}
// If it's not enough, move the first file and check again.
moveEntryToEOF( earliestUsedIndex );
}
}
// Expand the header to claim the vacated region.
for ( int i=0; i < freeRoom; i++ ) {
entryList.add( null );
}
raf.seek( 0 );
writeLittleUInt( entryList.size() );
raf.seek( getHeaderIndexPosition(entryList.size() - freeRoom) );
for ( int i=0; i < freeRoom; i++ ) {
writeLittleUInt( 0 );
}
}
@Override
public String getName() {
return datFile.getName();
}
@Override
public List<String> list() {
List<String> result = new ArrayList<String>();
result.addAll( pathToIndexMap.keySet() );
return result;
}
@Override
public List<PathAndSize> listSizes() {
List<PathAndSize> result = new ArrayList<PathAndSize>();
for ( DatEntry entry : entryList ) {
if ( entry == null ) continue;
PathAndSize pas = new PathAndSize( entry.innerPath, entry.dataSize );
result.add( pas );
}
return result;
}
@Override
public void add( String innerPath, InputStream is ) throws IOException {
if ( pathToIndexMap.containsKey( innerPath ) ) {
throw new FTLDatException( "InnerPath already exists: "+ innerPath );
}
if ( !asciiEncoder.canEncode( innerPath ) ) {
throw new FTLDatException( "InnerPath contains non-ascii characters: "+ innerPath );
}
// Find a vacancy in the header, or create one.
int entryIndex = entryList.indexOf( null );
if ( entryIndex == -1 ) {
growIndex( 50 ); // Save effort for 49 future adds.
entryIndex = entryList.indexOf( null );
}
DatEntry entry = new DatEntry();
entry.entryOffset = raf.length();
entry.innerPath = innerPath;
entry.dataSize = 0; // Write this later.
raf.seek( getHeaderIndexPosition( entryIndex ) );
writeLittleUInt( entry.entryOffset );
raf.seek( entry.entryOffset );
writeLittleUInt( entry.dataSize );
writeLittleUString( entry.innerPath );
entry.dataOffset = raf.getChannel().position();
byte[] buf = new byte[4096];
int len;
while ( (len = is.read(buf)) >= 0 ) {
raf.write( buf, 0, len );
}
// Go back and fill in the dataSize.
entry.dataSize = raf.getChannel().position() - entry.dataOffset;
raf.seek( entry.entryOffset );
writeLittleUInt( entry.dataSize );
entryList.set( entryIndex, entry );
pathToIndexMap.put( innerPath, entryIndex );
}
@Override
public void extractTo( String innerPath, OutputStream os ) throws FileNotFoundException, IOException {
if ( !pathToIndexMap.containsKey( innerPath ) ) {
throw new FileNotFoundException( "InnerPath does not exist: "+ innerPath );
}
int entryIndex = pathToIndexMap.get( innerPath ).intValue();
DatEntry entry = entryList.get( entryIndex );
raf.seek( entry.dataOffset );
long bytesRemaining = entry.dataSize;
byte[] buf = new byte[4096];
int len;
while ( bytesRemaining > 0 ) {
raf.seek( entry.dataOffset + entry.dataSize - bytesRemaining );
len = raf.read( buf, 0, (int)Math.min(buf.length, bytesRemaining) );
if ( len == -1 ) {
throw new IOException( "EOF prematurely reached reading innerPath: "+ entry.innerPath );
}
os.write( buf, 0, len );
}
}
@Override
public void remove( String innerPath ) throws FileNotFoundException, IOException {
if ( !pathToIndexMap.containsKey( innerPath ) ) {
throw new FileNotFoundException( "InnerPath does not exist: "+ innerPath );
}
int entryIndex = pathToIndexMap.get( innerPath ).intValue();
pathToIndexMap.remove( innerPath );
DatEntry removedEntry = entryList.set( entryIndex, null );
raf.seek( getHeaderIndexPosition( entryIndex ) );
writeLittleUInt( 0 );
if ( removedEntry.dataOffset + removedEntry.dataSize == raf.length() ) {
// Data appeared at the end. Truncate.
raf.setLength( removedEntry.entryOffset );
}
}
@Override
public boolean contains( String innerPath ) {
return pathToIndexMap.containsKey( innerPath );
}
@Override
public InputStream getInputStream( String innerPath ) throws FileNotFoundException, IOException {
if ( !pathToIndexMap.containsKey( innerPath ) ) {
throw new FileNotFoundException( "InnerPath does not exist: "+ innerPath );
}
int entryIndex = pathToIndexMap.get( innerPath ).intValue();
DatEntry entry = entryList.get( entryIndex );
// Create a stream that can only see this region.
// Multiple read-only streams can coexist (each has its own position).
InputStream stream = new FileChannelRegionInputStream( raf.getChannel(), entry.dataOffset, entry.dataSize );
// Create a stream that can only see this region.
// There must be no other reads/seeks until it is closed.
//InputStream tmpStream = Channels.newInputStream( raf.getChannel().position( entry.dataOffset ) );
//InputStream stream = new SizeLimitInputStream( tmpStream, entry.dataSize );
// Mapped regions may not garbage collect promptly.
// That would keep the file in use: bad.
// Closing raf doesn't affect them. :/
// This method has best I/O performance though.
//MappedByteBuffer buf = raf.getChannel().map( FileChannel.MapMode.READ_ONLY, entry.dataOffset, entry.dataSize );
//buf.load();
//InputStream stream = new ByteBufferBackedInputStream( buf );
return stream;
}
@Override
public void close() throws IOException {
raf.close();
}
public List<DatEntry> listMetadata() {
return new ArrayList<DatEntry>( entryList );
}
/**
* Repacks the dat file. This will remove gaps, which could
* be created when adding, removing or replacing files.
*/
public RepackResult repack() throws IOException {
// Build a list of non-null entries, sorted in the order their data appears.
ArrayList<DatEntry> tmpEntries = new ArrayList<DatEntry>( pathToIndexMap.size() );
for ( Map.Entry<String,Integer> mapping : pathToIndexMap.entrySet() ) {
Integer iObj = mapping.getValue();
DatEntry entry = entryList.get( iObj.intValue() );
if ( entry != null ) {
tmpEntries.add( entry );
} else { // The following should never happen!
throw new IOException ( "Bad entryIndex for innerPath: "+ mapping.getKey() );
}
}
Collections.sort( tmpEntries, new Comparator<DatEntry>() {
public int compare( DatEntry a, DatEntry b ) {
if ( b == null ) return -1;
if ( a == null ) return 1;
DatEntry dA = (DatEntry)a;
DatEntry dB = (DatEntry)b;
if ( dA.entryOffset < dB.entryOffset ) return -1;
if ( dA.entryOffset > dB.entryOffset ) return 1;
return 0;
}
@Override
public boolean equals( Object o ) {
return ( o != null ? o == this : false );
}
} );
for ( int i=0; i < tmpEntries.size()-1; i++ ) {
DatEntry a = tmpEntries.get( i );
DatEntry b = tmpEntries.get( i+1 );
if ( a.dataOffset+a.dataSize > b.entryOffset ) {
throw new IOException( String.format( "Cannot repack datfile with overlapping entries (\"%s\" and \"%s\").", a.innerPath, b.innerPath ) );
}
}
pathToIndexMap.clear(); // entryList gets replaced later.
long bytesChanged = 0;
// Write the header size.
if ( tmpEntries.size() != entryList.size() ) {
raf.seek( 0 );
writeLittleUInt( tmpEntries.size() );
bytesChanged += 4;
}
long pendingEntryOffset = getHeaderIndexPosition( tmpEntries.size() );
for ( int i=0; i < tmpEntries.size(); i++ ) {
DatEntry entry = tmpEntries.get ( i );
pathToIndexMap.put( entry.innerPath, new Integer(i) );
// Write the header index.
raf.seek( getHeaderIndexPosition( i ) );
writeLittleUInt( pendingEntryOffset );
bytesChanged += 4;
// Shift the entry toward the start of the dat.
if ( pendingEntryOffset != entry.entryOffset ) {
long totalBytes = (entry.dataOffset-entry.entryOffset) + entry.dataSize;
long bytesRemaining = totalBytes;
byte[] buf = new byte[4096];
int len;
while ( bytesRemaining > 0 ) {
raf.seek( entry.entryOffset + totalBytes - bytesRemaining );
len = raf.read( buf, 0, (int)Math.min(buf.length, bytesRemaining) );
if ( len == -1 ) {
throw new IOException( "EOF prematurely reached reading innerPath: "+ entry.innerPath );
}
raf.seek( pendingEntryOffset + totalBytes - bytesRemaining );
raf.write( buf, 0, len );
bytesRemaining -= len;
}
entry.dataOffset = pendingEntryOffset + (entry.dataOffset-entry.entryOffset);
entry.entryOffset = pendingEntryOffset;
bytesChanged += totalBytes;
}
pendingEntryOffset += (entry.dataOffset-entry.entryOffset) + entry.dataSize;
}
entryList = tmpEntries;
long oldDatLength = raf.length();
long newDatLength = pendingEntryOffset;
raf.setLength( newDatLength );
return new RepackResult( oldDatLength, newDatLength, bytesChanged );
}
}
}

View file

@ -0,0 +1,115 @@
package net.vhati.ftldat;
import java.io.InputStream;
import java.io.IOException;
import java.nio.BufferUnderflowException;
import java.nio.ByteBuffer;
import java.nio.channels.ClosedChannelException;
import java.nio.channels.FileChannel;
public class FileChannelRegionInputStream extends InputStream {
private FileChannel channel;
private long regionOffset;
private long regionLength;
// A buffer holds an even narrower region of the file.
// When possible read() calls will reuse this,
// rather than pester the channel.
private ByteBuffer buf = null;
private long bufOffset = 0; // Relative to regionOffset.
private int bufLength = 0;
private long intraPos = 0;
public FileChannelRegionInputStream( FileChannel channel, long offset, long length ) {
this( channel, offset, length, 4096 );
}
public FileChannelRegionInputStream( FileChannel channel, long offset, long length, int bufferSize ) {
this.channel = channel;
this.regionOffset = offset;
this.regionLength = length;
buf = ByteBuffer.allocate( bufferSize );
}
@Override
public int available() throws IOException {
if ( !channel.isOpen() ) throw new ClosedChannelException();
return bufLength;
}
@Override
public int read() throws IOException {
if ( !channel.isOpen() ) throw new ClosedChannelException();
if ( intraPos >= regionLength ) return -1;
if ( intraPos < bufOffset || intraPos >= bufOffset+bufLength ) {
// The requested byte isn't currently buffered.
bufOffset = intraPos;
int len = 0; // Get *something*.
buf.position( 0 );
while ( len == 0 ) {
len = channel.read( buf, regionOffset + bufOffset );
}
if ( len == -1 ) {
bufLength = 0;
return -1;
} else {
bufLength = len;
}
}
// Do an absolute get() from the buffer.
int result = buf.get( (int)(intraPos - bufOffset) );
intraPos++;
return result;
}
@Override
public int read( byte[] b, int bOff, int bLen ) throws IOException {
if ( bLen == 0 ) return 0;
if ( bOff < 0 ) throw new IndexOutOfBoundsException( String.format( "Index: %d, Size: %d", bOff, bLen ) );
if ( bOff + bLen > b.length ) throw new IndexOutOfBoundsException( String.format( "Index: %d, Size: %d", (bOff+bLen), bLen ) );
if ( !channel.isOpen() ) throw new ClosedChannelException();
if ( intraPos >= regionLength ) return -1;
int bytesTotal = Math.min( bLen, (int)(regionLength - intraPos) );
int bytesRemaining = bytesTotal;
if ( intraPos >= bufOffset || intraPos < bufOffset+bufLength ) {
// Read part of the current buffer, possibly until the end.
buf.position( (int)(intraPos - bufOffset) );
int bufTodo = Math.min( bytesRemaining, bufLength - (int)(intraPos - bufOffset) );
buf.get( b, bOff, bufTodo );
bytesRemaining -= bufTodo;
intraPos += bufTodo;
}
if ( bytesRemaining > 0 ) {
// Refill the buffer at the current intraPos.
bufOffset = intraPos;
int len = 0;
buf.position( 0 );
len = channel.read( buf, regionOffset + bufOffset );
if ( len == -1 ) {
bufLength = 0;
throw new BufferUnderflowException();
} else {
bufLength = len;
}
buf.position( 0 );
int bufTodo = Math.min( bytesRemaining, bufLength );
buf.get( b, bOff, bufTodo );
bytesRemaining -= bufTodo;
intraPos += bufTodo;
}
return ( bytesTotal - bytesRemaining ); // Return number of bytes read.
}
}

View file

@ -0,0 +1,247 @@
/*
* Input stream wrapper with a byte limit.
* Copyright (C) 2004 Stephen Ostermiller
* http://ostermiller.org/contact.pl?regarding=Java+Utilities
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program 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 General Public License for more details.
*
* See COPYING.TXT for details.
*/
package net.vhati.ftldat;
import java.io.*;
/**
* An input stream wrapper that will read only a set number of bytes from the
* underlying stream.
*
* @author Stephen Ostermiller http://ostermiller.org/contact.pl?regarding=Java+Utilities
* @since ostermillerutils 1.04.00
*/
public class SizeLimitInputStream extends InputStream {
/**
* The input stream that is being protected.
* All methods should be forwarded to it,
* after checking the size that has been read.
*
* @since ostermillerutils 1.04.00
*/
protected InputStream in;
/**
* The number of bytes to read at most from this
* Stream. Read methods should
* check to ensure that bytesRead never
* exceeds maxBytesToRead.
*
* @since ostermillerutils 1.04.00
*/
protected long maxBytesToRead = 0;
/**
* The number of bytes that have been read
* from this stream. Read methods should
* check to ensure that bytesRead never
* exceeds maxBytesToRead.
*
* @since ostermillerutils 1.04.00
*/
protected long bytesRead = 0;
/**
* The number of bytes that have been read
* from this stream since mark() was called.
*
* @since ostermillerutils 1.04.00
*/
protected long bytesReadSinceMark = 0;
/**
* The number of bytes the user has request
* to have been marked for reset.
*
* @since ostermillerutils 1.04.00
*/
protected long markReadLimitBytes = -1;
/**
* Get the number of bytes actually read
* from this stream.
*
* @return number of bytes that have already been taken from this stream.
*
* @since ostermillerutils 1.04.00
*/
public long getBytesRead(){
return bytesRead;
}
/**
* Get the maximum number of bytes left to read
* before the limit (set in the constructor) is reached.
*
* @return The number of bytes that (at a maximum) are left to be taken from this stream.
*
* @since ostermillerutils 1.04.00
*/
public long getBytesLeft(){
return maxBytesToRead - bytesRead;
}
/**
* Tell whether the number of bytes specified
* in the constructor have been read yet.
*
* @return true iff the specified number of bytes have all been read.
*
* @since ostermillerutils 1.04.00
*/
public boolean allBytesRead(){
return getBytesLeft() == 0;
}
/**
* Get the number of total bytes (including bytes already read)
* that can be read from this stream (as set in the constructor).
* @return Maximum bytes that can be read until the size limit runs out
*
* @since ostermillerutils 1.04.00
*/
public long getMaxBytesToRead(){
return maxBytesToRead;
}
/**
* Create a new size limit input stream from
* another stream given a size limit.
*
* @param in The input stream.
* @param maxBytesToRead the max number of bytes to allow to be read from the underlying stream.
*
* @since ostermillerutils 1.04.00
*/
public SizeLimitInputStream( InputStream in, long maxBytesToRead ){
this.in = in;
this.maxBytesToRead = maxBytesToRead;
}
/**
* {@inheritDoc}
*/
@Override public int read() throws IOException {
if ( bytesRead >= maxBytesToRead ){
return -1;
}
int b = in.read();
if ( b != -1 ){
bytesRead++;
bytesReadSinceMark++;
}
return b;
}
/**
* {@inheritDoc}
*/
@Override public int read( byte[] b ) throws IOException {
return this.read( b, 0, b.length );
}
/**
* {@inheritDoc}
*/
@Override public int read( byte[] b, int off, int len ) throws IOException {
if ( bytesRead >= maxBytesToRead ){
return -1;
}
long bytesLeft = getBytesLeft();
if ( len > bytesLeft ){
len = (int)bytesLeft;
}
int bytesJustRead = in.read( b, off, len );
bytesRead += bytesJustRead;
bytesReadSinceMark += bytesJustRead;
return bytesJustRead;
}
/**
* {@inheritDoc}
*/
@Override public long skip(long n) throws IOException {
if ( bytesRead >= maxBytesToRead ){
return -1;
}
long bytesLeft = getBytesLeft();
if ( n > bytesLeft ){
n = bytesLeft;
}
return in.skip(n);
}
/**
* {@inheritDoc}
*/
@Override public int available() throws IOException {
int available = in.available();
long bytesLeft = getBytesLeft();
if ( available > bytesLeft ){
available = (int)bytesLeft;
}
return available;
}
/**
* Close this stream and underlying streams.
* Calling this method may make data on the
* underlying stream unavailable.
* <p>
* Consider wrapping this stream in a NoCloseStream
* so that clients can
* call close() with no effect.
*
* @since ostermillerutils 1.04.00
*/
@Override public void close() throws IOException {
in.close();
}
/**
* {@inheritDoc}
*/
@Override public void mark(int readlimit){
if ( in.markSupported() ){
markReadLimitBytes = readlimit;
bytesReadSinceMark = 0;
in.mark(readlimit);
}
}
/**
* {@inheritDoc}
*/
@Override public void reset() throws IOException {
if ( in.markSupported() && bytesReadSinceMark <= markReadLimitBytes ){
bytesRead -= bytesReadSinceMark;
in.reset();
bytesReadSinceMark = 0;
}
}
/**
* {@inheritDoc}
*/
@Override public boolean markSupported(){
return in.markSupported();
}
}

View file

@ -0,0 +1,190 @@
package net.vhati.modmanager;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.util.Properties;
import javax.swing.JOptionPane;
import javax.swing.SwingUtilities;
import javax.swing.UIManager;
import net.vhati.modmanager.core.ComparableVersion;
import net.vhati.modmanager.core.FTLUtilities;
import net.vhati.modmanager.ui.ManagerFrame;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class FTLModManager {
private static final Logger log = LogManager.getLogger(FTLModManager.class);
private static final String APP_NAME = "Slipstream Mod Manager";
private static final ComparableVersion APP_VERSION = new ComparableVersion( "1.0" );
public static void main( String[] args ) {
log.debug( String.format( "%s v%s", APP_NAME, APP_VERSION ) );
log.debug( System.getProperty("os.name") +" "+ System.getProperty("os.version") +" "+ System.getProperty("os.arch") );
log.debug( System.getProperty("java.vm.name") +", "+ System.getProperty("java.version") );
File configFile = new File( "modman.cfg" );
boolean writeConfig = false;
Properties config = new Properties();
config.setProperty( "allow_zip", "false" );
config.setProperty( "ftl_dats_path", "" );
config.setProperty( "never_run_ftl", "false" );
config.setProperty( "use_default_ui", "false" );
// "update_catalog" doesn't have a default.
// Read the config file.
InputStream in = null;
try {
if ( configFile.exists() ) {
log.trace( "Loading properties from config file." );
in = new FileInputStream( configFile );
config.load( new InputStreamReader( in, "UTF-8" ) );
} else {
writeConfig = true; // Create a new cfg, but only if necessary.
}
}
catch (IOException e) {
log.error( "Error loading config.", e );
showErrorDialog( "Error loading config from "+ configFile.getPath() );
}
finally {
try {if ( in != null ) in.close();}
catch ( IOException e ) {}
}
// Look-and-Feel.
String useDefaultUI = config.getProperty( "use_default_ui", "false" );
if ( !useDefaultUI.equals("true") ) {
try {
log.trace( "Using system Look and Feel" );
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
}
catch (Exception e) {
log.error( "Error setting system Look and Feel.", e );
log.info( "Setting 'useDefaultUI=true' in the config file will prevent this error." );
}
} else {
log.debug( "Using default Look and Feel." );
}
// FTL Resources Path.
File datsDir = null;
String datsPath = config.getProperty( "ftl_dats_path", "" );
if ( datsPath.length() > 0 ) {
log.info( "Using FTL dats path from config: "+ datsPath );
datsDir = new File( datsPath );
if ( FTLUtilities.isDatsDirValid( datsDir ) == false ) {
log.error( "The config's ftl_dats_path does not exist, or it lacks data.dat." );
datsDir = null;
}
} else {
log.trace( "No FTL dats path previously set." );
}
// Find/prompt for the path to set in the config.
if ( datsDir == null ) {
datsDir = FTLUtilities.findDatsDir();
if ( datsDir != null ) {
int response = JOptionPane.showConfirmDialog(null, "FTL resources were found in:\n"+ datsDir.getPath() +"\nIs this correct?", "Confirm", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE);
if ( response == JOptionPane.NO_OPTION ) datsDir = null;
}
if ( datsDir == null ) {
log.debug( "FTL dats path was not located automatically. Prompting user for location." );
datsDir = FTLUtilities.promptForDatsDir( null );
}
if ( datsDir != null ) {
config.setProperty( "ftl_dats_path", datsDir.getAbsolutePath() );
writeConfig = true;
log.info( "FTL dats located at: "+ datsDir.getAbsolutePath() );
}
}
if ( datsDir == null ) {
showErrorDialog( "FTL resources were not found.\nThe Mod Manager will now exit." );
log.debug( "No FTL dats path found, exiting." );
System.exit( 1 );
}
// Prompt if update_catalog is invalid or hasn't been set.
String updateCatalog = config.getProperty( "update_catalog" );
if ( updateCatalog == null || !updateCatalog.matches("^true|false$") ) {
String message = "";
message += "Would you like Slipstream to periodically\n";
message += "download descriptions for the latest mods?\n\n";
message += "You can change this later in modman.cfg.";
int response = JOptionPane.showConfirmDialog(null, message, "Catalog Updates", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE);
if ( response == JOptionPane.YES_OPTION )
config.setProperty( "update_catalog", "true" );
else
config.setProperty( "update_catalog", "false" );
}
if ( writeConfig ) {
OutputStream out = null;
try {
out = new FileOutputStream( configFile );
String configComments = "";
configComments += "\n";
configComments += " allow_zip - Sets whether to treat .zip files as .ftl files. Default: false.\n";
configComments += " ftl_dats_path - The path to FTL's resources folder. If invalid, you'll be prompted.\n";
configComments += " never_run_ftl - If true, there will be no offer to run FTL after patching. Default: false.\n";
configComments += " update_catalog - If true, periodically download descriptions for the latest mods. If invalid, you'll be prompted.\n";
configComments += " use_default_ui - If true, no attempt will be made to resemble a native GUI. Default: false.\n";
config.store( new OutputStreamWriter( out, "UTF-8" ), configComments );
}
catch ( IOException e ) {
log.error( "Error saving config to "+ configFile.getPath(), e );
showErrorDialog( "Error saving config to "+ configFile.getPath() );
}
finally {
try {if ( out != null ) out.close();}
catch ( IOException e ) {}
}
}
// Create the GUI.
try {
final ManagerFrame frame = new ManagerFrame( config, APP_NAME, APP_VERSION );
frame.setDefaultCloseOperation( frame.EXIT_ON_CLOSE );
frame.setVisible(true);
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
frame.init();
}
});
}
catch ( Exception e ) {
log.error( "Exception while creating ManagerFrame.", e );
System.exit(1);
}
}
private static void showErrorDialog( String message ) {
JOptionPane.showMessageDialog(null, message, "Error", JOptionPane.ERROR_MESSAGE);
}
}

View file

@ -0,0 +1,340 @@
package net.vhati.modmanager.core;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* A version string (eg, 10.4.2_17 or 2.7.5rc1 ).
*
* It is composed of three parts:
* - A series of period-separated positive ints.
*
* - The numbers may be immediately followed by a short
* suffix string.
*
* - Finally, a string comment, separated from the rest
* by a space.
*
* The (numbers + suffix) or comment may appear alone.
*
* For details, see the string constructor and compareTo().
*/
public class ComparableVersion implements Comparable<ComparableVersion> {
private Pattern numbersPtn = Pattern.compile( "^((?:\\d+[.])*\\d+)" );
private Pattern suffixPtn = Pattern.compile( "([-_]|(?:[-_]?(?:[ab]|r|rc)))(\\d+)|([A-Za-z](?= |$))" );
private Pattern commentPtn = Pattern.compile( "(.+)$" );
private int[] numbers;
private String suffix;
private String comment;
private String suffixDivider; // Suffix prior to a number, if there was a number.
private int suffixNum;
public ComparableVersion( int[] numbers, String suffix, String comment ) {
this.numbers = numbers;
setSuffix( suffix );
setComment( comment );
}
/**
* Constructs an AppVersion by parsing a string.
*
* The suffix can be:
* - A divider string followed by a number.
* - Optional Hyphen/underscore, then a|b|r|rc, then 0-9+.
* - Hyphen/underscore, then 0-9+.
* Or the suffix can be a single letter without a number.
*
* Examples:
* 1
* 1 Blah
* 1.2 Blah
* 1.2.3 Blah
* 1.2.3-8 Blah
* 1.2.3_b9 Blah
* 1.2.3a1 Blah
* 1.2.3b1 Blah
* 1.2.3rc2 Blah
* 1.2.3z Blah
* 1.2.3D
* Alpha
*
* @throws IllegalArgumentException if the string is unsuitable
*/
public ComparableVersion( String s ) {
boolean noNumbers = true;
boolean noComment = true;
Matcher numbersMatcher = numbersPtn.matcher( s );
Matcher suffixMatcher = suffixPtn.matcher( s );
Matcher commentMatcher = commentPtn.matcher( s );
if ( numbersMatcher.lookingAt() ) {
noNumbers = false;
setNumbers( numbersMatcher.group( 0 ) );
commentMatcher.region( numbersMatcher.end(), s.length() );
// We have numbers; do we have a suffix?
suffixMatcher.region( numbersMatcher.end(), s.length() );
if ( suffixMatcher.lookingAt() ) {
setSuffix( suffixMatcher.group( 0 ) );
commentMatcher.region( suffixMatcher.end(), s.length() );
}
else {
setSuffix( null );
}
// If a space occurs after (numbers +suffix?), skip it.
// Thus the comment matcher will start on the first comment char.
//
if ( commentMatcher.regionStart()+1 < s.length() ) {
if ( s.charAt( commentMatcher.regionStart() ) == ' ' ) {
commentMatcher.region( commentMatcher.regionStart()+1, s.length() );
}
}
}
else {
numbers = new int[0];
setSuffix( null );
}
// Check for a comment (at the start, elsewhere if region was set).
if ( commentMatcher.lookingAt() ) {
noComment = false;
setComment( commentMatcher.group( 1 ) );
}
if ( noNumbers && noComment ) {
throw new IllegalArgumentException( "Could not parse version string: "+ s );
}
}
private void setNumbers( String s ) {
if ( s == null || s.length() == 0 ) {
numbers = new int[0];
return;
}
Matcher m = numbersPtn.matcher( s );
if ( m.matches() ) {
String numString = m.group( 1 );
String[] numChunks = numString.split("[.]");
numbers = new int[ numChunks.length ];
for ( int i=0; i < numChunks.length; i++ ) {
numbers[i] = Integer.parseInt( numChunks[i] );
}
}
else {
throw new IllegalArgumentException( "Could not parse version numbers string: "+ s );
}
}
private void setSuffix( String s ) {
if ( s == null || s.length() == 0 ) {
suffix = null;
suffixNum = -1;
return;
}
Matcher m = suffixPtn.matcher( s );
if ( m.matches() ) {
suffix = s;
// Matched groups 1 and 2... or 3.
if ( m.group(1) != null ) {
suffixDivider = m.group(1);
}
if ( m.group(2) != null ) {
suffixNum = Integer.parseInt( m.group(2) );
} else {
suffixNum = -1;
}
suffix = m.group(0);
}
else {
throw new IllegalArgumentException( "Could not parse version suffix string: "+ s );
}
}
private void setComment( String s ) {
if ( s == null || s.length() == 0 ) {
comment = null;
return;
}
Matcher m = commentPtn.matcher( s );
if ( m.matches() ) {
comment = m.group(1);
}
else {
throw new IllegalArgumentException( "Could not parse version comment string: "+ s );
}
}
/**
* Returns the array of major/minor/etc version numbers.
*/
public int[] getNumbers() {
return numbers;
}
/**
* Returns the pre-number portion of the suffix, or null if there was no number.
*/
public String getSuffixDivider() {
return suffixDivider;
}
/**
* Returns the number in the suffix, or -1 if there was no number.
*/
public int getSuffixNumber() {
return suffixNum;
}
/**
* Returns the entire suffix, or null.
*/
public String getSuffix() {
return suffix;
}
/**
* Returns the human-readable comment, or null.
*/
public String getComment() {
return comment;
}
@Override
public String toString() {
StringBuilder buf = new StringBuilder();
for ( int number : numbers ) {
if ( buf.length() > 0 ) buf.append( "." );
buf.append( number );
}
if ( suffix != null ) {
buf.append( suffix );
}
if ( comment != null ) {
if ( buf.length() > 0 ) buf.append( " " );
buf.append( comment );
}
return buf.toString();
}
/**
* Compares this object with the specified object for order.
*
* - The ints are compared arithmetically. In case of ties,
* the version with the most numbers wins.
* - If both versions' suffixes have a number, and the same
* characters appear before that number, then the suffix number
* is compared arithmetically.
* - Then the entire suffix is compared alphabetically.
* - Then the comment is compared alphabetically.
*/
@Override
public int compareTo( ComparableVersion other ) {
if ( other == null ) return -1;
if ( other == this ) return 0;
int[] oNumbers = other.getNumbers();
for ( int i=0; i < numbers.length && i < oNumbers.length; i++ ) {
if ( numbers[i] < oNumbers[i] ) return -1;
if ( numbers[i] > oNumbers[i] ) return 1;
}
if ( numbers.length < oNumbers.length ) return -1;
if ( numbers.length > oNumbers.length ) return 1;
if ( suffixDivider != null && other.getSuffixDivider() != null ) {
if ( suffixDivider.equals( other.getSuffixDivider() ) ) {
if ( suffixNum < other.getSuffixNumber() ) return -1;
if ( suffixNum > other.getSuffixNumber() ) return 1;
}
}
if ( suffix == null && other.getSuffix() != null ) return -1;
if ( suffix != null && other.getSuffix() == null ) return 1;
if ( suffix != null && other.getSuffix() != null ) {
int cmp = suffix.compareTo( other.getSuffix() );
if ( cmp != 0 ) return cmp;
}
if ( comment == null && other.getComment() != null ) return -1;
if ( comment != null && other.getComment() == null ) return 1;
if ( comment != null && other.getComment() != null ) {
int cmp = comment.compareTo( other.getComment() );
if ( cmp != 0 ) return cmp;
}
return 0;
}
@Override
public boolean equals( Object o ) {
if ( o == null ) return false;
if ( o == this ) return true;
if ( o instanceof ComparableVersion == false ) return false;
ComparableVersion other = (ComparableVersion)o;
int[] oNumbers = other.getNumbers();
for ( int i=0; i < numbers.length && i < oNumbers.length; i++ ) {
if ( numbers[i] != oNumbers[i] ) return false;
}
if ( numbers.length != oNumbers.length ) return false;
if ( suffix == null && other.getSuffix() != null ) return false;
if ( suffix != null && other.getSuffix() == null ) return false;
if ( !suffix.equals( other.getSuffix() ) ) return false;
if ( comment == null && other.getComment() != null ) return false;
if ( comment != null && other.getComment() == null ) return false;
if ( !comment.equals( other.getComment() ) ) return false;
return true;
}
@Override
public int hashCode() {
int result = 79;
int salt = 35;
int nullCode = 13;
List<Integer> tmpNumbers = new ArrayList<Integer>( getNumbers().length );
for ( int n : getNumbers() )
tmpNumbers.add( new Integer(n) );
result = salt * result + tmpNumbers.hashCode();
String tmpSuffix = getSuffix();
if ( tmpSuffix == null )
result = salt * result + nullCode;
else
result = salt * result + tmpSuffix.hashCode();
String tmpComment = getComment();
if ( tmpComment == null )
result = salt * result + nullCode;
else
result = salt * result + tmpComment.hashCode();
return result;
}
}

View file

@ -0,0 +1,163 @@
package net.vhati.modmanager.core;
import java.awt.Component;
import java.io.File;
import java.io.IOException;
import javax.swing.JFileChooser;
import javax.swing.JOptionPane;
import javax.swing.filechooser.FileFilter;
public class FTLUtilities {
/**
* Confirms the FTL resources dir exists and contains the dat files.
*/
public static boolean isDatsDirValid( File d ) {
if ( !d.exists() || !d.isDirectory() ) return false;
if ( !new File(d, "data.dat").exists() ) return false;
if ( !new File(d, "resource.dat").exists() ) return false;
return true;
}
/**
* Returns the FTL resources dir, or null.
*/
public static File findDatsDir() {
String steamPath = "Steam/steamapps/common/FTL Faster Than Light/resources";
String gogPath = "GOG.com/Faster Than Light/resources";
String xdgDataHome = System.getenv("XDG_DATA_HOME");
if (xdgDataHome == null)
xdgDataHome = System.getProperty("user.home") +"/.local/share";
File[] candidates = new File[] {
// Windows - Steam
new File( new File(""+System.getenv("ProgramFiles(x86)")), steamPath ),
new File( new File(""+System.getenv("ProgramFiles")), steamPath ),
// Windows - GOG
new File( new File(""+System.getenv("ProgramFiles(x86)")), gogPath ),
new File( new File(""+System.getenv("ProgramFiles")), gogPath ),
// Linux - Steam
new File( xdgDataHome +"/Steam/SteamApps/common/FTL Faster Than Light/data/resources" ),
// OSX - Steam
new File( System.getProperty("user.home") +"/Library/Application Support/Steam/SteamApps/common/FTL Faster Than Light/FTL.app/Contents/Resources" ),
// OSX
new File( "/Applications/FTL.app/Contents/Resources" )
};
File result = null;
for ( File candidate : candidates ) {
if ( isDatsDirValid( candidate ) ) {
result = candidate;
break;
}
}
return result;
}
/**
* Modally prompts the user for the FTL resources dir.
*
* @param parentComponent a parent for Swing popups, or null
*/
public static File promptForDatsDir( Component parentComponent ) {
File result = null;
String message = "";
message += "You will now be prompted to locate FTL manually.\n";
message += "Select '(FTL dir)/resources/data.dat'.\n";
message += "Or 'FTL.app', if you're on OSX.";
JOptionPane.showMessageDialog(null, message, "Find FTL", JOptionPane.INFORMATION_MESSAGE);
final JFileChooser fc = new JFileChooser();
fc.setDialogTitle( "Find data.dat or FTL.app" );
fc.addChoosableFileFilter(new FileFilter() {
@Override
public String getDescription() {
return "FTL Data File - (FTL dir)/resources/data.dat";
}
@Override
public boolean accept(File f) {
return f.isDirectory() || f.getName().equals("data.dat") || f.getName().equals("FTL.app");
}
});
fc.setMultiSelectionEnabled(false);
if ( fc.showOpenDialog(null) == JFileChooser.APPROVE_OPTION ) {
File f = fc.getSelectedFile();
if ( f.getName().equals("data.dat") ) {
result = f.getParentFile();
}
else if ( f.getName().endsWith(".app") && f.isDirectory() ) {
File contentsPath = new File(f, "Contents");
if( contentsPath.exists() && contentsPath.isDirectory() && new File(contentsPath, "Resources").exists() )
result = new File(contentsPath, "Resources");
}
}
if ( result != null && isDatsDirValid( result ) ) {
return result;
}
return null;
}
/**
* Returns the executable that will launch FTL, or null.
*
* On Windows, FTLGame.exe is one dir above "resources/".
* On OSX, FTL.app is the grandparent dir itself (a bundle).
*/
public static File findGameExe( File datsDir ) {
File result = null;
if ( System.getProperty("os.name").startsWith("Windows") ) {
File ftlDir = datsDir.getParentFile();
if ( ftlDir != null ) {
File exeFile = new File( ftlDir, "FTLGame.exe" );
if ( exeFile.exists() ) result = exeFile;
}
}
else if ( System.getProperty("os.name").contains("OS X") ) {
// FTL.app/Contents/Resources/
File contentsDir = datsDir.getParentFile();
if ( contentsDir != null ) {
File bundleDir = contentsDir.getParentFile();
if ( bundleDir != null ) {
if ( new File( bundleDir, "Contents/Info.plist" ).exists() ) {
result = bundleDir;
}
}
}
}
return result;
}
/**
* Spawns the game (FTLGame.exe or FTL.app).
*
* @param exeFile see findGameExe()
* @return a Process object, or null
*/
public static Process launchGame( File exeFile ) throws IOException {
if ( exeFile == null ) return null;
Process result = null;
ProcessBuilder pb = null;
if ( System.getProperty("os.name").contains("OS X") ) {
pb = new ProcessBuilder( "open", "-a", exeFile.getAbsolutePath() );
} else {
pb = new ProcessBuilder( exeFile.getAbsolutePath() );
}
if ( pb != null ) {
pb.directory( exeFile.getParentFile() );
result = pb.start();
}
return result;
}
}

View file

@ -0,0 +1,8 @@
package net.vhati.modmanager.core;
import java.io.File;
public interface HashObserver {
public void hashCalculated( File f, String hash );
}

View file

@ -0,0 +1,58 @@
package net.vhati.modmanager.core;
import java.io.File;
import java.io.FileInputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import net.vhati.ftldat.FTLDat;
import net.vhati.modmanager.core.HashObserver;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* A thread to calculate MD5 hashes of files in the background.
*
* As each file is hashed, a class implementing HashObserver is notified.
* Note: The callback on that class needs to be thread-safe.
*/
public class HashThread extends Thread {
private static final Logger log = LogManager.getLogger(HashThread.class);
private List<File> fileList = new ArrayList<File>();
private HashObserver hashObserver = null;
public HashThread( File[] files, HashObserver hashObserver ) {
this.fileList.addAll( Arrays.asList(files) );
this.hashObserver = hashObserver;
}
public void run() {
for ( File f : fileList ) {
String hash = calcFileMD5( f );
if ( hash != null ) {
hashObserver.hashCalculated( f, hash );
}
}
log.info( "Background hashing finished." );
}
private String calcFileMD5( File f ) {
String result = null;
try {
result = FTLDat.calcFileMD5( f );
}
catch ( Exception e ) {
log.error( "Error while calculating hash for file: "+ f.getPath(), e );
}
return result;
}
}

View file

@ -0,0 +1,61 @@
package net.vhati.modmanager.core;
import java.util.ArrayList;
import java.util.HashMap;
import net.vhati.modmanager.core.ModInfo;
public class ModDB {
// Accociates Forum thread urls with hashes of their forst post's content.
private HashMap<String,String> threadHashMap = new HashMap<String,String>();
private ArrayList<ModInfo> catalog = new ArrayList<ModInfo>();
/**
* Returns mod info for a given file hash.
*/
public ModInfo getModInfo( String hash ) {
if ( hash == null ) return null;
for ( ModInfo modInfo : catalog ) {
if ( modInfo.getFileHash().equals(hash) ) {
return modInfo;
}
}
return null;
}
public void addMod( ModInfo modInfo ) {
catalog.add( modInfo );
}
public void removeMod( ModInfo modInfo ) {
catalog.remove( modInfo );
}
/**
* Stores the first-post content hash of a forum thread.
*/
public void putThreadHash( String url, String threadHash ) {
threadHashMap.put( url, threadHash );
}
public String getThreadHash( String url ) {
return threadHashMap.get( url );
}
public void clear() {
threadHashMap.clear();
catalog.clear();
}
/**
* Returns the internal ArrayList of mod info.
*/
public ArrayList<ModInfo> getCatalog() {
return catalog;
}
}

View file

@ -0,0 +1,54 @@
package net.vhati.modmanager.core;
import java.io.File;
public class ModFileInfo implements Comparable<ModFileInfo> {
private File file;
private String name;
public ModFileInfo( File f ) {
this.file = f;
this.name = f.getName().replaceAll( "[.][^.]+$", "" );
}
public File getFile() { return this.file; }
public String getName() { return this.name; }
@Override
public String toString() {
return getName();
}
@Override
public int compareTo( ModFileInfo other ) {
if ( other == null ) return -1;
if ( other == this ) return 0;
return getName().compareTo( other.getName() );
}
@Override
public boolean equals( Object o ) {
if ( o == null ) return false;
if ( o == this ) return true;
if ( o instanceof ModFileInfo == false ) return false;
ModFileInfo other = (ModFileInfo)o;
if ( !getFile().equals( other.getFile() ) ) return false;
return true;
}
@Override
public int hashCode() {
int result = 89;
int salt = 36;
result = salt * result + getFile().hashCode();
return result;
}
}

View file

@ -0,0 +1,62 @@
package net.vhati.modmanager.core;
public class ModInfo {
private String title = "???";
private String author = "???";
private String url = "???";
private String description = "";
private String fileHash = "???";
private String version = "???";
public void setTitle( String s ) { this.title = s; }
public void setAuthor( String s ) { this.author = s; }
public void setURL( String s ) { this.url = s; }
public void setDescription( String s ) { this.description = s; }
public void setFileHash( String s ) { this.fileHash = s; }
public void setVersion( String s ) { this.version = s; }
public String getTitle() { return this.title; }
public String getAuthor() { return this.author; }
public String getURL() { return this.url; }
public String getDescription() { return this.description; }
public String getFileHash() { return this.fileHash; }
public String getVersion() { return this.version; }
@Override
public String toString() {
return getTitle();
}
@Override
public boolean equals( Object o ) {
if ( o == null ) return false;
if ( o == this ) return true;
if ( o instanceof ModInfo == false ) return false;
ModInfo other = (ModInfo)o;
if ( !getTitle().equals( other.getTitle() ) ) return false;
if ( !getAuthor().equals( other.getAuthor() ) ) return false;
if ( !getURL().equals( other.getURL() ) ) return false;
if ( !getDescription().equals( other.getDescription() ) ) return false;
if ( !getFileHash().equals( other.getFileHash() ) ) return false;
if ( !getVersion().equals( other.getVersion() ) ) return false;
return true;
}
@Override
public int hashCode() {
int result = 79;
int salt = 35;
result = salt * result + getTitle().hashCode();
result = salt * result + getAuthor().hashCode();
result = salt * result + getURL().hashCode();
result = salt * result + getDescription().hashCode();
result = salt * result + getFileHash().hashCode();
result = salt * result + getVersion().hashCode();
return result;
}
}

View file

@ -0,0 +1,35 @@
package net.vhati.modmanager.core;
import java.io.File;
public interface ModPatchObserver {
/**
* Updates a progress bar.
*
* If either arg is -1, the bar will become indeterminate.
*
* @param value the new value
* @param max the new maximum
*/
public void patchingProgress( final int value, final int max );
/**
* Non-specific activity.
*
* @param message a string, or null
*/
public void patchingStatus( String message );
/**
* A mod is about to be processed.
*/
public void patchingMod( File modFile );
/**
* Patching ended.
* If anything went wrong, e may be non-null.
*/
public void patchingEnded( boolean success, Exception e );
}

View file

@ -0,0 +1,325 @@
package net.vhati.modmanager.core;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import net.vhati.ftldat.FTLDat;
import net.vhati.ftldat.FTLDat.AbstractPack;
import net.vhati.ftldat.FTLDat.FTLPack;
import net.vhati.modmanager.core.ModPatchObserver;
import net.vhati.modmanager.core.ModUtilities;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class ModPatchThread extends Thread {
private static final Logger log = LogManager.getLogger(ModPatchThread.class);
// Other threads can check or set this.
public volatile boolean keepRunning = true;
private Thread shutdownHook = null;
private List<File> modFiles = new ArrayList<File>();
private BackedUpDat dataDat = null;
private BackedUpDat resDat = null;
private ModPatchObserver observer = null;
private final int progMax = 100;
private final int progBackupMax = 25;
private final int progClobberMax = 25;
private final int progModsMax = 40;
private final int progRepackMax = 5;
private int progMilestone = 0;
public ModPatchThread( List<File> modFiles, BackedUpDat dataDat, BackedUpDat resDat, ModPatchObserver observer ) {
this.modFiles.addAll( modFiles );
this.dataDat = dataDat;
this.resDat = resDat;
this.observer = observer;
}
public void run() {
boolean result;
Exception exception = null;
// When JVM tries to exit, stall until this thread ends on its own.
shutdownHook = new Thread() {
@Override
public void run() {
keepRunning = false;
boolean interrupted = false;
try {
while ( true ) {
try {
ModPatchThread.this.join();
break;
}
catch ( InterruptedException e ) {
interrupted = true;
}
}
}
finally {
if ( interrupted ) Thread.currentThread().interrupt();
}
}
};
Runtime.getRuntime().addShutdownHook( shutdownHook );
try {
result = patch();
}
catch ( Exception e ) {
log.error( "Patching failed. See log for details.", e );
exception = e;
result = false;
}
observer.patchingEnded( result, exception );
Runtime.getRuntime().removeShutdownHook( shutdownHook );
}
private boolean patch() throws IOException {
observer.patchingProgress( 0, progMax );
BackedUpDat[] allDats = new BackedUpDat[] {dataDat, resDat};
FTLPack dataP = null;
FTLPack resP = null;
try {
int backupsCreated = 0;
int datsClobbered = 0;
int modsInstalled = 0;
int datsRepacked = 0;
// Create backup dats, if necessary.
for ( BackedUpDat dat : allDats ) {
if ( !dat.bakFile.exists() ) {
log.info( String.format( "Backing up \"%s\".", dat.datFile.getName() ) );
observer.patchingStatus( String.format( "Backing up \"%s\".", dat.datFile.getName() ) );
FTLDat.copyFile( dat.datFile, dat.bakFile );
backupsCreated++;
observer.patchingProgress( progMilestone + progBackupMax/allDats.length*backupsCreated, progMax );
if ( !keepRunning ) return false;
}
}
progMilestone += progBackupMax;
observer.patchingProgress( progMilestone, progMax );
observer.patchingStatus( null );
if ( backupsCreated != allDats.length ) {
// Clobber current dat files with their respective backups.
// But don't bother if we made those backups just now.
for ( BackedUpDat dat : allDats ) {
log.info( String.format( "Restoring vanilla \"%s\"...", dat.datFile.getName() ) );
observer.patchingStatus( String.format( "Restoring vanilla \"%s\"...", dat.datFile.getName() ) );
FTLDat.copyFile( dat.bakFile, dat.datFile );
datsClobbered++;
observer.patchingProgress( progMilestone + progClobberMax/allDats.length*datsClobbered, progMax );
if ( !keepRunning ) return false;
}
observer.patchingStatus( null );
}
progMilestone += progClobberMax;
observer.patchingProgress( progMilestone, progMax );
if ( modFiles.isEmpty() ) {
// No mods. Nothing else to do.
observer.patchingProgress( progMax, progMax );
return true;
}
dataP = new FTLPack( dataDat.datFile, false );
resP = new FTLPack( resDat.datFile, false );
Map<String,AbstractPack> topFolderMap = new HashMap<String,AbstractPack>();
topFolderMap.put( "data", dataP );
topFolderMap.put( "audio", resP );
topFolderMap.put( "fonts", resP );
topFolderMap.put( "img", resP );
// Track modified innerPaths in case they're clobbered.
List<String> moddedItems = new ArrayList<String>();
List<String> knownPaths = new ArrayList<String>();
knownPaths.addAll( dataP.list() );
knownPaths.addAll( resP.list() );
List<String> knownPathsLower = new ArrayList<String>( knownPaths.size() );
for ( String innerPath : knownPaths ) {
knownPathsLower.add( innerPath.toLowerCase() );
}
// Group1: parentPath, Group2: topFolder, Group3: fileName
Pattern pathPtn = Pattern.compile( "^(([^/]+)/(?:.*/)?)([^/]+)$" );
for ( File modFile : modFiles ) {
if ( !keepRunning ) return false;
ZipInputStream zis = null;
try {
log.info( "" );
log.info( String.format( "Installing mod: %s", modFile.getName() ) );
observer.patchingMod( modFile );
zis = new ZipInputStream( new FileInputStream( modFile ) );
ZipEntry item;
while ( (item = zis.getNextEntry()) != null ) {
if ( item.isDirectory() ) continue;
Matcher m = pathPtn.matcher( item.getName() );
if ( !m.matches() ) {
log.warn( String.format( "Unexpected innerPath: %s", item.getName() ) );
zis.closeEntry();
continue;
}
String parentPath = m.group(1);
String topFolder = m.group(2);
String fileName = m.group(3);
AbstractPack ftlP = topFolderMap.get( topFolder );
if ( ftlP == null ) {
log.warn( String.format( "Unexpected innerPath: %s", item.getName() ) );
zis.closeEntry();
continue;
}
if ( fileName.endsWith( ".xml.append" ) || fileName.endsWith( ".append.xml" ) ) {
String innerPath = parentPath + fileName.replaceAll( "[.](?:xml[.]append|append[.]xml)$", ".xml" );
innerPath = checkCase( innerPath, knownPaths, knownPathsLower );
if ( !ftlP.contains( innerPath ) ) {
log.warn( String.format( "Non-existent innerPath wasn't appended: %s", innerPath ) );
}
else {
InputStream dstStream = null;
try {
dstStream = ftlP.getInputStream(innerPath);
InputStream mergedStream = ModUtilities.appendXMLFile( zis, dstStream, ftlP.getName()+":"+innerPath, modFile.getName()+":"+parentPath+fileName );
dstStream.close();
ftlP.remove( innerPath );
ftlP.add( innerPath, mergedStream );
}
finally {
try {if ( dstStream != null ) dstStream.close();}
catch ( IOException e ) {}
}
if ( !moddedItems.contains(innerPath) )
moddedItems.add( innerPath );
}
}
else {
String innerPath = checkCase( item.getName(), knownPaths, knownPathsLower );
if ( !moddedItems.contains(innerPath) )
moddedItems.add( innerPath );
else
log.warn( String.format( "Clobbering earlier mods: %s", innerPath ) );
if ( ftlP.contains( innerPath ) )
ftlP.remove( innerPath );
ftlP.add( innerPath, zis );
}
zis.closeEntry();
}
}
finally {
try {if (zis != null) zis.close();}
catch ( Exception e ) {}
System.gc();
}
modsInstalled++;
observer.patchingProgress( progMilestone + progModsMax/modFiles.size()*modsInstalled, progMax );
}
progMilestone += progModsMax;
observer.patchingProgress( progMilestone, progMax );
// Prune 'removed' files from dats.
for ( AbstractPack ftlP : new AbstractPack[]{dataP,resP} ) {
if ( ftlP instanceof FTLPack ) {
observer.patchingStatus( String.format( "Repacking \"%s\"...", ftlP.getName() ) );
long bytesChanged = ((FTLPack)ftlP).repack().bytesChanged;
log.info( String.format( "Repacked \"%s\" (%d bytes affected)", ftlP.getName(), bytesChanged ) );
datsRepacked++;
observer.patchingProgress( progMilestone + progRepackMax/allDats.length*datsRepacked, progMax );
}
}
progMilestone += progRepackMax;
observer.patchingProgress( progMilestone, progMax );
observer.patchingProgress( 100, progMax );
return true;
}
finally {
try {if (dataP != null) dataP.close();}
catch( Exception e ) {}
try {if (resP != null) resP.close();}
catch( Exception e ) {}
}
}
/**
* Checks if an innerPath exists, ignoring letter case.
*
* If there is no collision, the innerPath is added to the known lists.
* A warning will be logged if a path with differing case exists.
*
* @param knownPaths a list of innerPaths seen so far
* @param knownPathsLower a copy of knownPaths, lower-cased
* @return the existing path (if different), or innerPath
*/
private String checkCase( String innerPath, List<String> knownPaths, List<String> knownPathsLower ) {
if ( knownPaths.contains( innerPath ) ) return innerPath;
String lowerPath = innerPath.toLowerCase();
int lowerIndex = knownPathsLower.indexOf( lowerPath );
if ( lowerIndex != -1 ) {
String knownPath = knownPaths.get( lowerIndex );
log.warn( String.format( "Modded file's case doesn't match existing path: \"%s\" vs \"%s\"", innerPath, knownPath ) );
return knownPath;
}
knownPaths.add( innerPath );
knownPathsLower.add( lowerPath );
return innerPath;
}
public static class BackedUpDat {
public File datFile = null;
public File bakFile = null;
}
}

View file

@ -0,0 +1,660 @@
package net.vhati.modmanager.core;
import java.io.BufferedWriter;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.StringReader;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.CharsetEncoder;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import net.vhati.modmanager.core.Report;
import net.vhati.modmanager.core.Report.ReportFormatter;
import net.vhati.modmanager.core.Report.ReportMessage;
import ar.com.hjg.pngj.PngReader;
import org.jdom2.input.JDOMParseException;
import org.jdom2.input.SAXBuilder;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class ModUtilities {
private static final Logger log = LogManager.getLogger(ModUtilities.class);
/**
* Determines text encoding for an InputStream and decodes its bytes as a string.
*
* CR and CR-LF line endings will be normalized to LF.
*
* @param is a stream to read
* @param description how error messages should refer to the stream, or null
*/
public static DecodeResult decodeText( InputStream is, String description ) throws IOException {
String result = null;
byte[] buf = new byte[4096];
int len;
ByteArrayOutputStream tmpData = new ByteArrayOutputStream();
while ( (len = is.read(buf)) >= 0 ) {
tmpData.write( buf, 0, len );
}
byte[] allBytes = tmpData.toByteArray();
tmpData.reset();
Map<byte[],String> boms = new LinkedHashMap<byte[],String>();
boms.put( new byte[] {(byte)0xEF,(byte)0xBB,(byte)0xBF}, "UTF-8" );
boms.put( new byte[] {(byte)0xFF,(byte)0xFE}, "UTF-16LE" );
boms.put( new byte[] {(byte)0xFE,(byte)0xFF}, "UTF-16BE" );
String encoding = null;
byte[] bom = null;
for ( Map.Entry<byte[],String> entry : boms.entrySet() ) {
byte[] tmpBom = entry.getKey();
byte[] firstBytes = Arrays.copyOfRange( allBytes, 0, tmpBom.length );
if ( Arrays.equals( tmpBom, firstBytes ) ) {
encoding = entry.getValue();
bom = tmpBom;
break;
}
}
if ( encoding != null ) {
// This may throw CharacterCodingException.
CharsetDecoder decoder = Charset.forName( encoding ).newDecoder();
ByteBuffer byteBuffer = ByteBuffer.wrap( allBytes, bom.length, allBytes.length-bom.length );
result = decoder.decode( byteBuffer ).toString();
}
else {
ByteBuffer byteBuffer = ByteBuffer.wrap( allBytes );
Map<String,Exception> errorMap = new LinkedHashMap<String,Exception>();
for ( String guess : new String[] {"UTF-8", "windows-1252"} ) {
try {
CharsetDecoder decoder = Charset.forName( guess ).newDecoder();
result = decoder.decode( byteBuffer ).toString();
encoding = guess;
break;
}
catch ( CharacterCodingException e ) {
errorMap.put( guess, e );
}
}
if ( encoding == null ) {
// All guesses failed!?
String msg = String.format( "Could not guess encoding for %s.", (description!=null ? "\""+description+"\"" : "a file") );
for ( Map.Entry<String,Exception> entry : errorMap.entrySet() ) {
msg += String.format( "\nFailed to decode as %s: %s", entry.getKey(), entry.getValue() );
}
throw new IOException( msg );
}
}
result = result.replaceAll( "\r(?!\n)|\r\n", "\n" );
return new DecodeResult( result, encoding, bom );
}
/**
* Semi-intelligently appends XML from one file (src) onto another (dst).
*
* The two InputStreams are read, and the combined result
* is returned as a new third InputStream.
*
* The returned stream is a ByteArrayInputStream
* which doesn't need closing.
*
* The description arguments identify the streams for log messages.
*/
public static InputStream appendXMLFile( InputStream srcStream, InputStream dstStream, String srcDescription, String dstDescription ) throws IOException {
Pattern xmlDeclPtn = Pattern.compile( "<[?]xml version=\"1.0\" encoding=\"[^\"]+?\"[?]>\n*" );
String srcText = decodeText( srcStream, srcDescription ).text;
srcText = xmlDeclPtn.matcher(srcText).replaceFirst( "" );
String dstText = decodeText( dstStream, dstDescription ).text;
dstText = xmlDeclPtn.matcher(dstText).replaceFirst( "" );
ByteArrayOutputStream tmpData = new ByteArrayOutputStream();
BufferedWriter bw = new BufferedWriter( new OutputStreamWriter( tmpData, "UTF-8" ) );
bw.write( "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" );
bw.write( dstText );
bw.write( "\n\n<!-- Appended by GMM -->\n\n");
bw.write( srcText );
bw.write( "\n" );
bw.flush();
InputStream result = new ByteArrayInputStream( tmpData.toByteArray() );
return result;
}
/**
* Checks a mod file for common problems.
*
* @param modFile an *.ftl file to check
* @param formatter custom message decoration/indention, or null
*/
public static Report validateModFile( File modFile, ReportFormatter formatter ) {
if ( formatter == null ) formatter = new ReportFormatter();
List<ReportMessage> messages = new ArrayList<ReportMessage>();
List<ReportMessage> pendingMsgs = new ArrayList<ReportMessage>();
boolean modValid = true;
boolean seenAppend = false;
Pattern junkFilePtn = Pattern.compile( "[.]DS_Store$|^thumbs[.]db$" );
Pattern validRootDirPtn = Pattern.compile( "^(?:audio|data|fonts|img)/" );
List<String> seenJunkDirs = new ArrayList<String>();
CharsetEncoder asciiEncoder = Charset.forName("US-ASCII").newEncoder();
ZipInputStream zis = null;
try {
zis = new ZipInputStream( new FileInputStream( modFile ) );
ZipEntry item;
while ( (item = zis.getNextEntry()) != null ) {
String innerPath = item.getName();
pendingMsgs.clear();
if ( !asciiEncoder.canEncode( innerPath ) ) {
pendingMsgs.add( new ReportMessage(
ReportMessage.ERROR,
String.format( "Non-ASCII characters in path." )
) );
modValid = false;
}
if ( innerPath.indexOf("/") == -1 ) {
pendingMsgs.add( new ReportMessage(
ReportMessage.WARNING,
String.format( "Extraneous top-level file." )
) );
modValid = false;
}
else if ( !validRootDirPtn.matcher(innerPath).find() ) {
String junkDir = innerPath.replaceFirst( "/.*", "/" );
if ( !seenJunkDirs.contains( junkDir ) ) {
seenJunkDirs.add( junkDir );
pendingMsgs.add( new ReportMessage(
ReportMessage.ERROR,
String.format( "Unsupported top-level folder: %s", junkDir )
) );
}
modValid = false;
}
else if ( item.isDirectory() ) {
}
else if ( junkFilePtn.matcher(innerPath).find() ) {
pendingMsgs.add( new ReportMessage(
ReportMessage.ERROR,
String.format( "Junk file" )
) );
modValid = false;
}
else if ( innerPath.endsWith( ".png" ) ) {
try {
PngReader pngr = new PngReader( zis );
// Check for Truecolor+Alpha (32bit RGBA).
if ( pngr.imgInfo.channels != 4 || pngr.imgInfo.bitDepth != 8 ) {
String colorTypeString = "???";
if ( pngr.imgInfo.channels == 4 )
colorTypeString = "RGB+Alpha";
else if ( pngr.imgInfo.channels == 3 )
colorTypeString = "RGB";
else if ( pngr.imgInfo.channels == 2 )
colorTypeString = "Gray+Alpha";
else if ( pngr.imgInfo.channels == 1 && !pngr.imgInfo.greyscale )
colorTypeString = "Indexed Color";
else if ( pngr.imgInfo.channels == 1 && pngr.imgInfo.greyscale )
colorTypeString = "Gray";
pendingMsgs.add( new ReportMessage(
ReportMessage.WARNING,
String.format( "ColorType: %s (Usually 32bit Truecolor+Alpha)", colorTypeString )
) );
}
}
catch ( Exception e ) {
log.error( String.format( "Error while validating \"%s:%s\".", modFile.getName(), innerPath ), e );
pendingMsgs.add( new ReportMessage(
ReportMessage.ERROR,
"An error occurred. See log for details."
) );
modValid = false;
}
}
else if ( innerPath.matches( "^.*(?:.xml.append|.append.xml|.xml)$" ) ) {
if ( innerPath.matches( "^.*(?:.xml.append|.append.xml)$" ) )
seenAppend = true;
DecodeResult decodeResult = ModUtilities.decodeText( zis, modFile.getName()+":"+innerPath );
if ( decodeResult.bom != null ) {
pendingMsgs.add( new ReportMessage(
ReportMessage.WARNING,
String.format( "%s BOM detected. (ascii is safest)", decodeResult.encoding )
) );
modValid = false;
}
List<Pattern> oddCharPtns = new ArrayList<Pattern>();
Map<Pattern,String> oddCharSuggestions = new HashMap<Pattern,String>();
Map<Pattern,List<Character>> oddCharLists = new HashMap<Pattern,List<Character>>();
oddCharPtns.add( Pattern.compile( "\\u0060|\\u201A|\\u2018|\\u2019" ) );
oddCharSuggestions.put( oddCharPtns.get(oddCharPtns.size()-1), "'" );
oddCharPtns.add( Pattern.compile( "\\u201E|\\u201C|\\u201D" ) );
oddCharSuggestions.put( oddCharPtns.get(oddCharPtns.size()-1), "\"" );
oddCharPtns.add( Pattern.compile( "\\u2013|\\u2014" ) );
oddCharSuggestions.put( oddCharPtns.get(oddCharPtns.size()-1), "-" );
oddCharPtns.add( Pattern.compile( "\\u2026" ) );
oddCharSuggestions.put( oddCharPtns.get(oddCharPtns.size()-1), "..." );
for ( Pattern ptn : oddCharPtns ) {
Matcher m = ptn.matcher( decodeResult.text );
List<Character> chars = null;
while ( m.find() ) {
if ( chars == null )
chars = new ArrayList<Character>();
Character cObj = new Character( m.group(0).charAt(0) );
if ( !chars.contains( cObj ) )
chars.add( cObj );
}
if ( chars != null )
oddCharLists.put( ptn, chars );
}
for ( Pattern ptn : oddCharPtns ) {
List<Character> chars = oddCharLists.get( ptn );
if ( chars != null ) {
String suggestion = oddCharSuggestions.get( ptn );
StringBuilder charBuf = new StringBuilder( chars.size() );
for ( Character cObj : chars )
charBuf.append( cObj.charValue() );
pendingMsgs.add( new ReportMessage(
ReportMessage.WARNING,
String.format( "Odd characters resembling %s : %s", suggestion, charBuf.toString() )
) );
}
}
// TODO: Nag if there are chars FTL can't show.
Report xmlReport = validateModXML( decodeResult.text, formatter );
if ( xmlReport.text.length() > 0 ) {
pendingMsgs.add( new ReportMessage(
ReportMessage.NESTED_BLOCK,
xmlReport.text
) );
}
if ( xmlReport.outcome == false )
modValid = false;
}
if ( !pendingMsgs.isEmpty() ) {
messages.add( new ReportMessage(
ReportMessage.SUBSECTION,
innerPath
) );
messages.addAll( pendingMsgs );
}
zis.closeEntry();
}
if ( !seenAppend ) {
messages.add( new ReportMessage(
ReportMessage.WARNING_SUBSECTION,
"This mod doesn't append. It clobbers."
) );
modValid = false;
}
}
catch ( Exception e ) {
log.error( String.format( "Error while validating mod: %s", modFile.getName() ), e );
messages.add( new ReportMessage(
ReportMessage.ERROR,
"An error occurred. See log for details."
) );
modValid = false;
}
finally {
try {if ( zis != null ) zis.close();}
catch ( IOException e ) {}
}
if ( modValid ) {
//messages.clear(); // Nothing bad enough to mention.
messages.add( new ReportMessage(
ReportMessage.INFO,
String.format( "No Problems", modFile.getName() )
) );
}
// Insert the mod's filename at the top.
messages.add( 0, new ReportMessage(
ReportMessage.SECTION,
String.format( "%s:", modFile.getName() )
) );
StringBuilder resultBuf = new StringBuilder();
formatter.format( messages, resultBuf );
return new Report( resultBuf, modValid );
}
/**
* Checks a mod's xml for problems.
*
* It first tries to preemptively fix and report
* common typos all at once.
* Then a real XML parser runs, which stops at the
* first typo it sees. :/
*
* @param text unparsed xml
* @param formatter custom message decoration/indention, or null
*/
public static Report validateModXML( String text, ReportFormatter formatter ) {
if ( formatter == null ) formatter = new ReportFormatter();
List<ReportMessage> messages = new ArrayList<ReportMessage>();
boolean xmlValid = true;
StringBuffer srcBuf = new StringBuffer( text );
StringBuffer dstBuf = new StringBuffer( text.length() );
StringBuffer tmpBuf; // For swapping;
String ptn;
Matcher m;
// Wrap everything in a root tag, while mindful of the xml declaration.
Pattern xmlDeclPtn = Pattern.compile( "<[?]xml version=\"1.0\" encoding=\"[^\"]+?\"[?]>" );
m = xmlDeclPtn.matcher( srcBuf );
if ( m.find() ) {
if ( m.start() == 0 ) {
dstBuf.append( srcBuf.subSequence( 0, m.end() ) );
dstBuf.append( "\n<wrapper>\n" );
dstBuf.append( srcBuf.subSequence( m.end(), srcBuf.length() ) );
}
else {
messages.add( new ReportMessage(
ReportMessage.ERROR,
"<?xml... ?> should only occur on the first line."
) );
dstBuf.append( "<wrapper>\n" );
dstBuf.append( srcBuf.subSequence( 0, m.start() ) );
dstBuf.append( srcBuf.subSequence( m.end(), srcBuf.length() ) );
}
dstBuf.append( "\n</wrapper>" );
}
else {
dstBuf.insert( 0, "<wrapper>\n" );
dstBuf.append( srcBuf );
dstBuf.append( "\n</wrapper>" );
}
tmpBuf = srcBuf; srcBuf = dstBuf; dstBuf = tmpBuf; dstBuf.setLength(0);
// Comments with long tails or double-dashes.
m = Pattern.compile( "(?s)<!--(-*)(.*?)(-*)-->" ).matcher( srcBuf );
while ( m.find() ) {
if ( m.group(1).length() > 0 || m.group(3).length() > 0 || m.group(2).indexOf("--") != -1 ) {
messages.add( new ReportMessage(
ReportMessage.ERROR,
"<!-- No other dashes should touch. -->"
) );
}
m.appendReplacement( dstBuf, m.quoteReplacement(m.group(2).replaceAll("[^\n]", "")) ); // Strip comments, but preserve line count.
}
m.appendTail( dstBuf );
tmpBuf = srcBuf; srcBuf = dstBuf; dstBuf = tmpBuf; dstBuf.setLength(0);
// Mismatched single-line tags.
// Example: blueprints.xml: <title>...</type>
m = Pattern.compile( "<([^/!][^> ]+?)((?: [^>]+?)?)(?<!/)>([^<]+?)</([^>]+?)>" ).matcher( srcBuf );
while ( m.find() ) {
if ( m.group(1).equals( m.group(4) ) == false ) {
messages.add( new ReportMessage(
ReportMessage.ERROR,
"<"+ m.group(1) +"...>...</"+ m.group(4) +">"
) );
m.appendReplacement( dstBuf, m.quoteReplacement("<"+ m.group(1) + m.group(2) +">"+ m.group(3) +"</"+ m.group(1) +">") );
}
}
m.appendTail( dstBuf );
tmpBuf = srcBuf; srcBuf = dstBuf; dstBuf = tmpBuf; dstBuf.setLength(0);
// <pilot power="1"max="3" room="0"/>
// Groan, \t separates attribs sometimes.
m = Pattern.compile( "<([^> ]+?)( [^>]+?\")([^\"= \t>]+?=\"[^\"]+?\")((?:[^>]+?)?)>" ).matcher( srcBuf );
while ( m.find() ) {
messages.add( new ReportMessage(
ReportMessage.ERROR,
"<"+ m.group(1) +"...\""+ m.group(3) +"...>"
) );
m.appendReplacement( dstBuf, m.quoteReplacement("<"+ m.group(1) + m.group(2) +" "+ m.group(3) + m.group(4) +">") );
}
m.appendTail( dstBuf );
tmpBuf = srcBuf; srcBuf = dstBuf; dstBuf = tmpBuf; dstBuf.setLength(0);
// sector_data.xml closing tag.
m = Pattern.compile( "((?s)<sectorDescription[^>]*>.*?)</sectorDescrption>" ).matcher( srcBuf );
while ( m.find() ) {
messages.add( new ReportMessage(
ReportMessage.ERROR,
"<sectorDescription>...</sectorDescrption>"
) );
m.appendReplacement( dstBuf, m.quoteReplacement(m.group(1) +"</sectorDescription>") );
}
m.appendTail( dstBuf );
tmpBuf = srcBuf; srcBuf = dstBuf; dstBuf = tmpBuf; dstBuf.setLength(0);
// {anyship}.xml: <gib1>...</gib2>
m = Pattern.compile( "(?s)<(gib[0-9]+)>(.*?)</(gib[0-9]+)>" ).matcher( srcBuf );
while ( m.find() ) {
if ( m.group(1).equals( m.group(3) ) == false ) {
messages.add( new ReportMessage(
ReportMessage.ERROR,
"<"+ m.group(1) +">...</"+ m.group(3) +">"
) );
m.appendReplacement( dstBuf, m.quoteReplacement("<"+ m.group(1) +">"+ m.group(2) +"</"+ m.group(1) +">") );
}
else {
m.appendReplacement( dstBuf, m.quoteReplacement(m.group(0)) );
}
}
m.appendTail( dstBuf );
tmpBuf = srcBuf; srcBuf = dstBuf; dstBuf = tmpBuf; dstBuf.setLength(0);
// event*.xml: <choice... hidden="true" hidden="true">
m = Pattern.compile( "<([a-zA-Z0-9_-]+?)((?: [^>]+?)?) ([^>]+?)(=\"[^\">]+?\") \\3(?:=\"[^\">]+?\")([^>]*)>" ).matcher( srcBuf );
while ( m.find() ) {
messages.add( new ReportMessage(
ReportMessage.ERROR,
"<"+ m.group(1) +"... "+ m.group(3) +"=... "+ m.group(3) +"=...>"
) );
m.appendReplacement( dstBuf, m.quoteReplacement("<"+ m.group(1) + m.group(2) +" "+ m.group(3) + m.group(4) +" "+ m.group(5) +">") );
}
m.appendTail( dstBuf );
tmpBuf = srcBuf; srcBuf = dstBuf; dstBuf = tmpBuf; dstBuf.setLength(0);
// <shields>...</slot>
ptn = "";
ptn += "(<shields *(?: [^>]*)?>\\s*";
ptn += "<slot *(?: [^>]*)?>\\s*";
ptn += "(?:<direction>[^<]*</direction>\\s*)?";
ptn += "(?:<number>[^<]*</number>\\s*)?";
ptn += "</slot>\\s*)";
ptn += "</slot>"; // Wrong closing tag.
m = Pattern.compile( ptn ).matcher( srcBuf );
while ( m.find() ) {
messages.add( new ReportMessage(
ReportMessage.ERROR,
"<shields>...</slot>"
) );
m.appendReplacement( dstBuf, m.quoteReplacement(m.group(1) +"</shields>") );
}
m.appendTail( dstBuf );
tmpBuf = srcBuf; srcBuf = dstBuf; dstBuf = tmpBuf; dstBuf.setLength(0);
// <shipBlueprint>...</ship>
ptn = "";
ptn += "(<shipBlueprint *(?: [^>]*)?>\\s*";
ptn += "<class>[^<]*</class>\\s*";
ptn += "<systemList *(?: [^>]*)?>\\s*";
ptn += "(?:<[a-zA-Z]+ *(?: [^>]*)?/>\\s*)*";
ptn += "</systemList>\\s*";
ptn += "(?:<droneList *(?: [^>]*)?>\\s*";
ptn += "(?:<[a-zA-Z]+ *(?: [^>]*)?/>\\s*)*";
ptn += "</droneList>\\s*)?";
ptn += "(?:<weaponList *(?: [^>]*)?>\\s*";
ptn += "(?:<[a-zA-Z]+ *(?: [^>]*)?/>\\s*)*";
ptn += "</weaponList>\\s*)?";
ptn += "(?:<[a-zA-Z]+ *(?: [^>]*)?/>\\s*)*)";
ptn += "</ship>"; // Wrong closing tag.
m = Pattern.compile( ptn ).matcher( srcBuf );
while ( m.find() ) {
messages.add( new ReportMessage(
ReportMessage.ERROR,
"<shipBlueprint>...</ship>"
) );
m.appendReplacement( dstBuf, m.quoteReplacement(m.group(1) +"</shipBlueprint>") );
}
m.appendTail( dstBuf );
tmpBuf = srcBuf; srcBuf = dstBuf; dstBuf = tmpBuf; dstBuf.setLength(0);
// <textList>...</text>
ptn = "";
ptn += "(?u)(<textList *(?: [^>]*)?>\\s*";
ptn += "(?:<text *(?: [^>]*)?>[^<]*</text>\\s*)*)";
ptn += "</text>"; // Wrong closing tag.
m = Pattern.compile( ptn ).matcher( srcBuf );
while ( m.find() ) {
messages.add( new ReportMessage(
ReportMessage.ERROR,
"<textList>...</text>"
) );
m.appendReplacement( dstBuf, m.quoteReplacement(m.group(1) +"</textList>") );
}
m.appendTail( dstBuf );
tmpBuf = srcBuf; srcBuf = dstBuf; dstBuf = tmpBuf; dstBuf.setLength(0);
try {
SAXBuilder saxBuilder = new SAXBuilder();
saxBuilder.build( new StringReader(srcBuf.toString()) );
xmlValid = true;
for ( ReportMessage message : messages ) {
if ( message.type == ReportMessage.ERROR ) {
xmlValid = false;
break;
}
}
}
catch ( JDOMParseException e ) {
int lineNum = e.getLineNumber();
if ( lineNum != -1 ) {
int badStart = -1;
int badEnd = -1;
String badLine = "???";
m = Pattern.compile( "\n" ).matcher( srcBuf );
for ( int i=1; i <= lineNum && m.find(); i++) {
if ( i == lineNum-1 ) {
badStart = m.end();
} else if ( i == lineNum ) {
badEnd = m.start();
badLine = srcBuf.substring( badStart, badEnd );
}
}
String msg = String.format( "Fix this and try again:\n%s", e );
msg += "\n";
msg += "~ ~ ~ ~ ~\n";
msg += badLine +"\n";
msg += "~ ~ ~ ~ ~";
messages.add( new ReportMessage(
ReportMessage.EXCEPTION,
msg
) );
}
else {
messages.add( new ReportMessage(
ReportMessage.EXCEPTION,
"An error occurred. See log for details."
) );
}
xmlValid = false;
}
catch ( Exception e ) {
log.error( "Error while validating mod xml.", e );
messages.add( new ReportMessage(
ReportMessage.EXCEPTION,
"An error occurred. See log for details."
) );
xmlValid = false;
}
StringBuilder resultBuf = new StringBuilder();
ReportMessage prevMessage = null;
for ( ReportMessage message : messages ) {
if ( message.equals(prevMessage) ) continue;
formatter.format( message, resultBuf );
prevMessage = message;
}
return new Report( resultBuf, xmlValid );
}
/**
* A holder for results from decodeText().
*
* text - The decoded string.
* encoding - The encoding used.
* bom - The BOM bytes found, or null.
*/
public static class DecodeResult {
public final String text;
public final String encoding;
public final byte[] bom;
public DecodeResult( String text, String encoding, byte[] bom ) {
this.text = text;
this.encoding = encoding;
this.bom = bom;
}
}
}

View file

@ -0,0 +1,198 @@
package net.vhati.modmanager.core;
import java.io.IOException;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* A human-readable block of text, with a boolean outcome.
*/
public class Report {
public final CharSequence text;
public final boolean outcome;
public Report( CharSequence text, boolean outcome ) {
this.text = text;
this.outcome = outcome;
}
/**
* Formats ReportMessages to include in buffered reports.
*
* Symbols are prepended to indicate type.
*
* Methods can accept a formatter as an argument,
* internally accumulate messages, format them,
* and return an Appendable CharSequence.
*
* To nest reports, that buffer can be intented
* and appended to another buffer; or it can be
* wrapped in a NESTED_BLOCK message of its own.
*
* The Appendable interface claims to throw
* IOException, but StringBuffer and StringBuilder
* never do. So extra methods specifically accept
* those classes and swallow the exception.
*
* If exceptions are desired, cast args to the
* more general type.
*/
public static class ReportFormatter {
protected Pattern breakPtn = Pattern.compile( "(^|\n)(?=[^\n])" );
public String getIndent() { return " "; }
public String getPrefix( int messageType ) {
switch ( messageType ) {
case ReportMessage.WARNING: return "~ ";
case ReportMessage.ERROR: return "! ";
case ReportMessage.EXCEPTION: return "! ";
case ReportMessage.SECTION : return "@ ";
case ReportMessage.SUBSECTION: return "> ";
case ReportMessage.WARNING_SUBSECTION: return "~ ";
case ReportMessage.ERROR_SUBSECTION: return "! ";
case ReportMessage.NESTED_BLOCK: return "";
default: return getIndent();
}
}
public void format( List<ReportMessage> messages, Appendable buf ) throws IOException {
for ( ReportMessage message : messages )
format( message, buf );
}
public void format( ReportMessage message, Appendable buf ) throws IOException {
if ( message.type == ReportMessage.NESTED_BLOCK ) {
// Already formatted this once, indent it instead.
indent( message.text, buf );
return;
}
// Subsections get an extra linebreak above them.
switch ( message.type ) {
case ReportMessage.SUBSECTION:
case ReportMessage.WARNING_SUBSECTION:
case ReportMessage.ERROR_SUBSECTION:
buf.append( "\n" );
default:
// Not a subsection.
}
buf.append( getPrefix( message.type ) );
buf.append( message.text );
buf.append( "\n" );
// Sections get underlined.
if ( message.type == ReportMessage.SECTION ) {
buf.append( getIndent() );
for ( int i=0; i < message.text.length(); i++ )
buf.append( "-" );
buf.append( "\n" );
}
}
public void indent( CharSequence src, Appendable dst ) throws IOException {
Matcher m = breakPtn.matcher( src );
int lastEnd = 0;
while ( m.find() ) {
if ( m.start() - lastEnd > 0 )
dst.append( src.subSequence( lastEnd, m.start() ) );
if ( m.group(1).length() > 0 ) // Didn't match beginning (^).
dst.append( "\n" );
dst.append( getIndent() );
lastEnd = m.end();
}
int srcLen = src.length();
if ( lastEnd < srcLen )
dst.append( src.subSequence( lastEnd, srcLen ) );
}
/** Exception-swallowing wrapper. */
public void format( List<ReportMessage> messages, StringBuffer buf ) {
try { format( messages, (Appendable)buf ); }
catch( IOException e ) {}
}
/** Exception-swallowing wrapper. */
public void format( List<ReportMessage> messages, StringBuilder buf ) {
try { format( messages, (Appendable)buf ); }
catch( IOException e ) {}
}
/** Exception-swallowing wrapper. */
public void format( ReportMessage message, StringBuffer buf ) {
try { format( message, (Appendable)buf ); }
catch( IOException e ) {}
}
/** Exception-swallowing wrapper. */
public void format( ReportMessage message, StringBuilder buf ) {
try { format( message, (Appendable)buf ); }
catch( IOException e ) {}
}
/** Exception-swallowing wrapper. */
public void indent( CharSequence src, StringBuffer dst ) {
try { indent( src, (Appendable)dst ); }
catch( IOException e ) {}
}
/** Exception-swallowing wrapper. */
public void indent( CharSequence src, StringBuilder dst ) {
try { indent( src, (Appendable)dst ); }
catch( IOException e ) {}
}
}
/**
* Notice text, with a formatting hint.
*
* Messages can be compared for equality
* to ignore repeats.
*/
public static class ReportMessage {
public static final int INFO = 0;
public static final int WARNING = 1;
public static final int ERROR = 2;
public static final int EXCEPTION = 3;
public static final int SECTION = 4;
public static final int SUBSECTION = 5;
public static final int WARNING_SUBSECTION = 6;
public static final int ERROR_SUBSECTION = 7;
public static final int NESTED_BLOCK = 8;
public final int type;
public final CharSequence text;
public ReportMessage( int type, CharSequence text ) {
this.type = type;
this.text = text;
}
@Override
public boolean equals( Object o ) {
if ( o == null ) return false;
if ( o == this ) return true;
if ( o instanceof ReportMessage == false ) return false;
ReportMessage other = (ReportMessage)o;
return ( this.type == other.type && this.text.equals(other.text) );
}
@Override
public int hashCode() {
int result = 236;
int salt = 778;
result = salt * result + this.type;
result = salt * result + text.hashCode();
return result;
}
}
}

View file

@ -0,0 +1,142 @@
package net.vhati.modmanager.json;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import java.util.Date;
import java.util.List;
import java.util.Map;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class GrognakCatalogFetcher {
private static final Logger log = LogManager.getLogger(GrognakCatalogFetcher.class);
public static final String CATALOG_URL = "https://raw.github.com/Grognak/Grognaks-Mod-Manager/master/backup/current_catalog.json";
/**
* Downloads the latest mod catalog.
*
* @return true if the catalog successfully downloaded, false otherwise
*/
public static boolean fetchCatalog( String catalogURL, File catalogFile, File eTagFile ) {
String localETag = null;
log.debug( "Attempting to download a newer catalog..." );
if ( eTagFile.exists() ) {
// Load the old eTag.
InputStream etagIn = null;
try {
etagIn = new FileInputStream( eTagFile );
BufferedReader br = new BufferedReader( new InputStreamReader( etagIn, "UTF-8" ) );
String line = br.readLine();
if ( line.length() > 0 )
localETag = line;
}
catch ( IOException e ) {
// Not serious enough to be a real error.
log.debug( String.format( "Error reading catalog eTag from \"%s\".", eTagFile.getName() ), e );
}
finally {
try {if ( etagIn != null ) etagIn.close();}
catch ( IOException e ) {}
}
}
String remoteETag = null;
InputStream urlIn = null;
OutputStream catalogOut = null;
try {
URL url = new URL( catalogURL );
URLConnection conn = url.openConnection();
if ( conn instanceof HttpURLConnection == false ) {
log.error( String.format( "Non-Http(s) URL given for catalog fetching: %s", catalogURL ) );
return false;
}
HttpURLConnection httpConn = (HttpURLConnection)conn;
httpConn.setReadTimeout( 10000 );
if ( localETag != null )
httpConn.setRequestProperty( "If-None-Match", localETag );
httpConn.connect();
int responseCode = httpConn.getResponseCode();
if ( responseCode == HttpURLConnection.HTTP_NOT_MODIFIED ) {
log.debug( "The server's catalog has not been modified since the previous check." );
// Update the catalog file's timestamp as if it had downloaded.
catalogFile.setLastModified( new Date().getTime() );
return false;
}
else if ( responseCode == HttpURLConnection.HTTP_OK ) {
Map<String, List<String>> headerMap = httpConn.getHeaderFields();
List<String> eTagValues = headerMap.get( "ETag" );
if ( eTagValues != null && eTagValues.size() > 0 )
remoteETag = eTagValues.get( 0 );
urlIn = httpConn.getInputStream();
catalogOut = new FileOutputStream( catalogFile );
byte[] buf = new byte[4096];
int len;
while ( (len = urlIn.read(buf)) >= 0 ) {
catalogOut.write( buf, 0, len );
}
}
else {
log.error( String.format( "Catalog download request failed: HTTP Code %d (%s).", responseCode, httpConn.getResponseMessage() ) );
return false;
}
}
catch ( MalformedURLException e ) {
log.error( "Error fetching latest catalog.", e );
}
catch ( IOException e ) {
log.error( "Error fetching latest catalog.", e );
}
finally {
try {if ( urlIn != null ) urlIn.close();}
catch ( IOException e ) {}
try {if ( catalogOut != null ) catalogOut.close();}
catch ( IOException e ) {}
}
if ( remoteETag != null ) {
// Save the new eTag.
OutputStream etagOut = null;
try {
etagOut = new FileOutputStream( eTagFile );
BufferedWriter bw = new BufferedWriter( new OutputStreamWriter( etagOut, "UTF-8" ) );
bw.append( remoteETag );
bw.flush();
}
catch ( IOException e ) {
log.error( String.format( "Error writing catalog eTag to \"%s\".", eTagFile.getName() ), e );
}
finally {
try {if ( etagOut != null ) etagOut.close();}
catch ( IOException e ) {}
}
}
return true;
}
}

View file

@ -0,0 +1,72 @@
package net.vhati.modmanager.json;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import net.vhati.modmanager.core.ModDB;
import net.vhati.modmanager.core.ModInfo;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class JacksonGrognakCatalogReader {
private static final Logger log = LogManager.getLogger(JacksonGrognakCatalogReader.class);
public static ModDB parse( File jsonFile ) {
ModDB modDB = new ModDB();
Exception exception = null;
try {
ObjectMapper mapper = new ObjectMapper();
mapper.configure(JsonParser.Feature.ALLOW_SINGLE_QUOTES, true);
mapper.setVisibility(PropertyAccessor.FIELD, Visibility.ANY);
JsonNode rootNode = mapper.readTree(jsonFile);
JsonNode catalogsNode = rootNode.get("catalog_versions");
JsonNode catalogNode = catalogsNode.get("1");
for ( JsonNode infoNode : catalogNode ) {
String threadURL = infoNode.get("url").textValue();
String threadHash = infoNode.get("thread_hash").textValue();
if ( !threadURL.equals("???") && !threadHash.equals("???") )
modDB.putThreadHash( threadURL, threadHash );
JsonNode versionsNode = infoNode.get("versions");
for ( JsonNode versionNode : versionsNode ) {
ModInfo modInfo = new ModInfo();
modInfo.setTitle( infoNode.get("title").textValue() );
modInfo.setAuthor( infoNode.get("author").textValue() );
modInfo.setURL( infoNode.get("url").textValue() );
modInfo.setDescription( infoNode.get("desc").textValue() );
modInfo.setFileHash( versionNode.get("hash").textValue() );
modInfo.setVersion( versionNode.get("version").textValue() );
modDB.addMod( modInfo );
}
}
}
catch ( JsonProcessingException e ) {
exception = e;
}
catch ( IOException e ) {
exception = e;
}
if ( exception != null ) {
log.error( exception );
return null;
}
return modDB;
}
}

View file

@ -0,0 +1,111 @@
package net.vhati.modmanager.ui;
import java.util.ArrayList;
import java.util.List;
import javax.swing.table.AbstractTableModel;
import net.vhati.modmanager.core.ModInfo;
public class ChecklistTableModel<T> extends AbstractTableModel implements Reorderable {
private static final int COLUMN_CHECK = 0;
private static final int COLUMN_PAYLOAD = 1;
private static final int DATA_CHECK = 0;
private static final int DATA_PAYLOAD = 1;
private String[] columnNames = new String[] {"?", "Name"};
private Class[] columnTypes = new Class[] {Boolean.class, String.class};
private List<List<Object>> rowsList = new ArrayList<List<Object>>();
public void addItem( T o ) {
insertItem( rowsList.size(), false, o );
}
public void insertItem( int row, boolean selected, T o ) {
int newRowIndex = rowsList.size();
List<Object> rowData = new ArrayList<Object>();
rowData.add( new Boolean(selected) );
rowData.add( o );
rowsList.add( row, rowData );
fireTableRowsInserted( row, row );
}
public void removeItem( int row ) {
rowsList.remove( row );
fireTableRowsDeleted( row, row );
}
@SuppressWarnings("unchecked")
public T getItem( int row ) {
return (T)rowsList.get(row).get(DATA_PAYLOAD);
}
@Override
public void reorder( int fromRow, int toRow ) {
if ( toRow > fromRow ) toRow--;
List<Object> rowData = rowsList.get( fromRow );
rowsList.remove( fromRow );
fireTableRowsDeleted( fromRow, fromRow );
rowsList.add( toRow, rowData );
fireTableRowsInserted( toRow, toRow );
}
public void setSelected( int row, boolean b ) {
rowsList.get(row).set( DATA_CHECK, new Boolean(b) );
fireTableRowsUpdated( row, row );
}
@SuppressWarnings("unchecked")
public boolean isSelected( int row ) {
return ((Boolean)rowsList.get(row).get(DATA_CHECK)).booleanValue();
}
@Override
public int getColumnCount() {
return columnNames.length;
}
@Override
public int getRowCount() {
return rowsList.size();
}
@Override
public Object getValueAt( int row, int column ) {
if ( column == COLUMN_CHECK ) {
return rowsList.get(row).get(DATA_CHECK);
}
else if ( column == COLUMN_PAYLOAD ) {
Object o = rowsList.get(row).get(DATA_PAYLOAD);
return o.toString();
}
throw new ArrayIndexOutOfBoundsException();
}
@Override
@SuppressWarnings("unchecked")
public void setValueAt( Object o, int row, int column ) {
if ( column == COLUMN_CHECK ) {
Boolean bool = (Boolean)o;
rowsList.get(row).set( DATA_CHECK, bool );
fireTableRowsUpdated( row, row );
}
}
@Override
public boolean isCellEditable( int row, int column ) {
if ( column == COLUMN_CHECK ) return true;
return false;
}
@Override
public Class getColumnClass( int column ) {
return columnTypes[column];
}
}

View file

@ -0,0 +1,97 @@
package net.vhati.modmanager.ui;
import java.awt.Toolkit;
import java.awt.datatransfer.DataFlavor;
import java.awt.event.ActionEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import javax.swing.AbstractAction;
import javax.swing.Action;
import javax.swing.JMenuItem;
import javax.swing.JPopupMenu;
import javax.swing.text.JTextComponent;
/**
* A Cut/Copy/Paste/SelectAll context menu for JTextComponents.
*/
public class ClipboardMenuMouseListener extends MouseAdapter {
private JPopupMenu popup = new JPopupMenu();
private Action cutAction;
private Action copyAction;
private Action pasteAction;
private Action selectAllAction;
private JTextComponent textComponent = null;
public ClipboardMenuMouseListener() {
cutAction = new AbstractAction( "Cut" ) {
@Override
public void actionPerformed( ActionEvent ae ) {
textComponent.cut();
}
};
copyAction = new AbstractAction( "Copy" ) {
@Override
public void actionPerformed( ActionEvent ae ) {
textComponent.copy();
}
};
pasteAction = new AbstractAction( "Paste" ) {
@Override
public void actionPerformed( ActionEvent ae ) {
textComponent.paste();
}
};
selectAllAction = new AbstractAction( "Select All" ) {
@Override
public void actionPerformed( ActionEvent ae ) {
textComponent.selectAll();
}
};
popup.add( cutAction );
popup.add( copyAction );
popup.add( pasteAction );
popup.addSeparator();
popup.add( selectAllAction );
}
@Override
public void mousePressed( MouseEvent e ) {
if ( e.isPopupTrigger() ) showMenu( e );
}
@Override
public void mouseReleased( MouseEvent e ) {
if ( e.isPopupTrigger() ) showMenu( e );
}
public void showMenu( MouseEvent e ) {
if ( e.getSource() instanceof JTextComponent == false ) return;
textComponent = (JTextComponent)e.getSource();
textComponent.requestFocus();
boolean enabled = textComponent.isEnabled();
boolean editable = textComponent.isEditable();
boolean nonempty = !(textComponent.getText() == null || textComponent.getText().equals(""));
boolean marked = textComponent.getSelectedText() != null;
boolean pasteAvailable = Toolkit.getDefaultToolkit().getSystemClipboard().getContents(null).isDataFlavorSupported(DataFlavor.stringFlavor);
cutAction.setEnabled( enabled && editable && marked );
copyAction.setEnabled( enabled && marked );
pasteAction.setEnabled( enabled && editable && pasteAvailable );
selectAllAction.setEnabled( enabled && nonempty );
int nx = e.getX();
if ( nx > 500 ) nx = nx - popup.getSize().width;
popup.show( e.getComponent(), nx, e.getY() - popup.getSize().height );
}
}

View file

@ -0,0 +1,532 @@
// http://docs.oracle.com/javase/tutorial/uiswing/dnd/emptytable.html
// http://stackoverflow.com/questions/638807/how-do-i-drag-and-drop-a-row-in-a-jtable
// http://www.java2s.com/Tutorial/Java/0240__Swing/UsingdefaultBooleanvaluecelleditorandrenderer.htm
package net.vhati.modmanager.ui;
import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.Insets;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileFilter;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.InputStreamReader;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.nio.charset.Charset;
import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Properties;
import javax.swing.BorderFactory;
import javax.swing.BoxLayout;
import javax.swing.DropMode;
import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTable;
import javax.swing.JTextArea;
import javax.swing.ListSelectionModel;
import javax.swing.SwingUtilities;
import javax.swing.event.ListSelectionEvent;
import javax.swing.event.ListSelectionListener;
import javax.swing.table.DefaultTableModel;
import net.vhati.modmanager.core.ComparableVersion;
import net.vhati.modmanager.core.FTLUtilities;
import net.vhati.modmanager.core.HashObserver;
import net.vhati.modmanager.core.HashThread;
import net.vhati.modmanager.core.ModDB;
import net.vhati.modmanager.core.ModFileInfo;
import net.vhati.modmanager.core.ModInfo;
import net.vhati.modmanager.core.ModPatchThread;
import net.vhati.modmanager.core.ModPatchThread.BackedUpDat;
import net.vhati.modmanager.core.ModUtilities;
import net.vhati.modmanager.core.Report;
import net.vhati.modmanager.json.GrognakCatalogFetcher;
import net.vhati.modmanager.json.JacksonGrognakCatalogReader;
import net.vhati.modmanager.ui.ChecklistTableModel;
import net.vhati.modmanager.ui.ClipboardMenuMouseListener;
import net.vhati.modmanager.ui.ModInfoArea;
import net.vhati.modmanager.ui.ModPatchDialog;
import net.vhati.modmanager.ui.Statusbar;
import net.vhati.modmanager.ui.StatusbarMouseListener;
import net.vhati.modmanager.ui.TableRowTransferHandler;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class ManagerFrame extends JFrame implements ActionListener, HashObserver, Statusbar {
private static final Logger log = LogManager.getLogger(ManagerFrame.class);
private File backupDir = new File( "./backup/" );
private File modsDir = new File( "./mods/" );
private int catalogFetchInterval = 7; // Days.
private File catalogFile = new File( backupDir, "current_catalog.json" );
private File catalogETagFile = new File( backupDir, "current_catalog_etag.txt" );
private Properties config;
private String appName;
private ComparableVersion appVersion;
private HashMap<File,String> modFileHashes = new HashMap<File,String>();
private ModDB modDB = new ModDB();
private ChecklistTableModel<ModFileInfo> localModsTableModel;
private JTable localModsTable;
private JButton patchBtn;
private JButton toggleAllBtn;
private JButton validateBtn;
private JButton aboutBtn;
private ModInfoArea infoArea;
private JLabel statusLbl;
public ManagerFrame( Properties config, String appName, ComparableVersion appVersion ) {
super();
this.config = config;
this.appName = appName;
this.appVersion = appVersion;
this.setTitle( String.format( "%s v%s", appName, appVersion ) );
JPanel contentPane = new JPanel( new BorderLayout() );
JPanel mainPane = new JPanel( new BorderLayout() );
contentPane.add( mainPane, BorderLayout.CENTER );
JPanel topPanel = new JPanel( new BorderLayout() );
localModsTableModel = new ChecklistTableModel<ModFileInfo>();
localModsTable = new JTable( localModsTableModel );
localModsTable.setFillsViewportHeight( true );
localModsTable.setSelectionMode( ListSelectionModel.SINGLE_SELECTION );
localModsTable.setTableHeader( null );
localModsTable.getColumnModel().getColumn(0).setMinWidth(30);
localModsTable.getColumnModel().getColumn(0).setMaxWidth(30);
localModsTable.getColumnModel().getColumn(0).setPreferredWidth(30);
JScrollPane localModsScroll = new JScrollPane( null, JScrollPane.VERTICAL_SCROLLBAR_ALWAYS, JScrollPane.HORIZONTAL_SCROLLBAR_NEVER );
localModsScroll.setViewportView( localModsTable );
//localModsScroll.setColumnHeaderView( null ); // Counterpart to setTableHeader().
localModsScroll.setPreferredSize( new Dimension(Integer.MIN_VALUE, Integer.MIN_VALUE) );
topPanel.add( localModsScroll, BorderLayout.CENTER );
JPanel modActionsPanel = new JPanel();
modActionsPanel.setLayout( new BoxLayout(modActionsPanel, BoxLayout.Y_AXIS) );
modActionsPanel.setBorder( BorderFactory.createEmptyBorder(0,5,5,0) );
Insets actionInsets = new Insets(5,10,5,10);
patchBtn = new JButton("Patch");
patchBtn.setMargin( actionInsets );
patchBtn.addMouseListener( new StatusbarMouseListener( this, "Incorporate all selected mods into the game." ) );
patchBtn.addActionListener(this);
modActionsPanel.add( patchBtn );
toggleAllBtn = new JButton("Toggle All");
toggleAllBtn.setMargin( actionInsets );
toggleAllBtn.addMouseListener( new StatusbarMouseListener( this, "Select all mods, or none." ) );
toggleAllBtn.addActionListener(this);
modActionsPanel.add( toggleAllBtn );
validateBtn = new JButton("Validate");
validateBtn.setMargin( actionInsets );
validateBtn.addMouseListener( new StatusbarMouseListener( this, "Check selected mods for problems." ) );
validateBtn.addActionListener(this);
modActionsPanel.add( validateBtn );
aboutBtn = new JButton("About");
aboutBtn.setMargin( actionInsets );
aboutBtn.addMouseListener( new StatusbarMouseListener( this, "Show info about this program." ) );
aboutBtn.addActionListener(this);
modActionsPanel.add( aboutBtn );
topPanel.add( modActionsPanel, BorderLayout.EAST );
JButton[] actionBtns = new JButton[] {patchBtn, toggleAllBtn, validateBtn, aboutBtn};
int actionBtnWidth = Integer.MIN_VALUE;
int actionBtnHeight = Integer.MIN_VALUE;
for ( JButton btn : actionBtns ) {
actionBtnWidth = Math.max( actionBtnWidth, btn.getPreferredSize().width );
actionBtnHeight = Math.max( actionBtnHeight, btn.getPreferredSize().height );
}
for ( JButton btn : actionBtns ) {
Dimension size = new Dimension( actionBtnWidth, actionBtnHeight );
btn.setPreferredSize( size );
btn.setMinimumSize( size );
btn.setMaximumSize( size );
}
mainPane.add( topPanel, BorderLayout.NORTH );
infoArea = new ModInfoArea();
infoArea.setPreferredSize( new Dimension(504, 220) );
mainPane.add( infoArea, BorderLayout.CENTER );
JPanel statusPanel = new JPanel();
statusPanel.setLayout( new BoxLayout(statusPanel, BoxLayout.Y_AXIS) );
statusPanel.setBorder( BorderFactory.createLoweredBevelBorder() );
statusLbl = new JLabel(" ");
statusLbl.setBorder( BorderFactory.createEmptyBorder(2, 4, 2, 4) );
statusLbl.setAlignmentX( Component.LEFT_ALIGNMENT );
statusPanel.add( statusLbl );
contentPane.add( statusPanel, BorderLayout.SOUTH );
localModsTable.getSelectionModel().addListSelectionListener(new ListSelectionListener() {
@Override
public void valueChanged( ListSelectionEvent e ) {
if ( e.getValueIsAdjusting() ) return;
int row = localModsTable.getSelectedRow();
if ( row == -1 ) return;
ModFileInfo modFileInfo = localModsTableModel.getItem( row );
showLocalModInfo( modFileInfo );
}
});
localModsTable.setTransferHandler( new TableRowTransferHandler( localModsTable ) );
localModsTable.setDropMode( DropMode.INSERT ); // Drop between rows, not on them.
localModsTable.setDragEnabled( true );
this.setContentPane( contentPane );
this.pack();
this.setLocationRelativeTo(null);
showAboutInfo();
}
/**
* Extra initialization that must be called after the constructor.
* This must be called on the Swing event thread (use invokeLater()).
*/
public void init() {
File[] modFiles = modsDir.listFiles(new FileFilter() {
@Override
public boolean accept( File f ) {
if ( f.isFile() ) {
if ( f.getName().endsWith(".ftl") ) return true;
}
return false;
}
});
List<ModFileInfo> unsortedMods = new ArrayList<ModFileInfo>();
for ( File f : modFiles ) {
ModFileInfo modFileInfo = new ModFileInfo( f );
unsortedMods.add( modFileInfo );
}
List<ModFileInfo> sortedMods = loadModOrder( unsortedMods );
for ( ModFileInfo modFileInfo : sortedMods ) {
localModsTableModel.addItem( modFileInfo );
}
HashThread hashThread = new HashThread( modFiles, this );
hashThread.setDaemon( true );
hashThread.start();
boolean needNewCatalog = false;
if ( catalogFile.exists() ) {
// Load the catalog first, before updating.
ModDB currentDB = JacksonGrognakCatalogReader.parse( catalogFile );
if ( currentDB != null ) modDB = currentDB;
// Check if the downloaded catalog is stale.
Date catalogDate = new Date( catalogFile.lastModified() );
Calendar cal = Calendar.getInstance();
cal.add( Calendar.DATE, catalogFetchInterval * -1 );
if ( catalogDate.before( cal.getTime() ) ) {
log.debug( String.format( "Catalog is older than %d days.", catalogFetchInterval ) );
needNewCatalog = true;
} else {
log.debug( "Catalog isn't stale yet." );
}
}
else {
// Catalog file doesn't exist.
needNewCatalog = true;
}
// Don't update if the user doesn't want to.
String updatesAllowed = config.getProperty( "update_catalog", "false" );
if ( !updatesAllowed.equals("true") ) needNewCatalog = false;
if ( needNewCatalog ) {
Runnable fetchTask = new Runnable() {
@Override
public void run() {
String catalogURL = GrognakCatalogFetcher.CATALOG_URL;
boolean fetched = GrognakCatalogFetcher.fetchCatalog( catalogURL, catalogFile, catalogETagFile );
if ( fetched ) reloadCatalog();
}
};
Thread fetchThread = new Thread( fetchTask );
fetchThread.start();
}
}
/**
* Reparses and replace the downloaded ModDB catalog. (thread-safe)
*/
public void reloadCatalog() {
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
if ( catalogFile.exists() ) {
ModDB currentDB = JacksonGrognakCatalogReader.parse( catalogFile );
if ( currentDB != null ) modDB = currentDB;
}
}
});
}
/**
* Reads modorder.txt and returns a mod list in that order.
*
* Mods not mentioned in the text appear at the end, alphabetically.
* If an error occurs, an alphabetized list is returned.
*/
private List<ModFileInfo> loadModOrder( List<ModFileInfo> unsortedMods ) {
List<ModFileInfo> sortedMods = new ArrayList<ModFileInfo>();
List<ModFileInfo> availableMods = new ArrayList<ModFileInfo>( unsortedMods );
Collections.sort( availableMods );
FileInputStream is = null;
try {
is = new FileInputStream( new File( modsDir, "modorder.txt" ) );
BufferedReader br = new BufferedReader(new InputStreamReader( is, Charset.forName("UTF-8") ));
String line;
while ( (line = br.readLine()) != null ) {
Iterator<ModFileInfo> it = availableMods.iterator();
while ( it.hasNext() ) {
ModFileInfo modFileInfo = it.next();
if ( modFileInfo.getName().equals(line) ) {
it.remove();
sortedMods.add( modFileInfo );
break;
}
}
}
}
catch ( FileNotFoundException e ) {
}
catch ( IOException e ) {
log.error( "Error reading modorder.txt.", e );
}
finally {
try {if (is != null) is.close();}
catch (Exception e) {}
}
sortedMods.addAll( availableMods );
return sortedMods;
}
private void saveModOrder( List<ModFileInfo> sortedMods ) {
FileOutputStream os = null;
try {
os = new FileOutputStream( new File( modsDir, "modorder.txt" ) );
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter( os, Charset.forName("UTF-8") ));
for ( ModFileInfo modFileInfo : sortedMods ) {
bw.write( modFileInfo.getName() );
bw.write( "\r\n" );
}
bw.flush();
}
catch ( IOException e ) {
log.error( "Error writing modorder.txt.", e );
}
finally {
try {if (os != null) os.close();}
catch (Exception e) {}
}
}
public void showAboutInfo() {
String body = "";
body += "- Drag to reorder mods.\n";
body += "- Click the checkboxes to select.\n";
body += "- Click 'Patch' to apply mods ( select none for vanilla ).\n";
body += "\n";
body += "Thanks for using this mod manager.\n";
body += "Make sure to visit the forum for updates!";
infoArea.setDescription( appName, "Vhati", appVersion.toString(), "http://abc.net", body );
}
/**
* Shows info about a local mod in the text area.
*/
public void showLocalModInfo( ModFileInfo modFileInfo ) {
String modHash = modFileHashes.get( modFileInfo.getFile() );
ModInfo modInfo = modDB.getModInfo( modHash );
if ( modInfo != null ) {
infoArea.setDescription( modInfo.getTitle(), modInfo.getAuthor(), modInfo.getVersion(), modInfo.getURL(), modInfo.getDescription() );
}
else {
String body = "";
body += "No info is available for the selected mod.\n\n";
body += "If it's stable, please let the Slipstream devs know ";
body += "where you found it and include this md5 hash:\n";
body += modHash +"\n";
infoArea.setDescription( modFileInfo.getName(), body );
log.info( String.format("No info for selected mod: %s (%s).", modFileInfo.getName(), modHash) );
}
}
@Override
public void setStatusText( String text ) {
if (text.length() > 0)
statusLbl.setText(text);
else
statusLbl.setText(" ");
}
@Override
public void actionPerformed( ActionEvent e ) {
Object source = e.getSource();
if ( source == patchBtn ) {
List<ModFileInfo> sortedMods = new ArrayList<ModFileInfo>();
List<File> modFiles = new ArrayList<File>();
for ( int i=0; i < localModsTableModel.getRowCount(); i++ ) {
if ( localModsTableModel.isSelected(i) ) {
sortedMods.add( localModsTableModel.getItem(i) );
modFiles.add( localModsTableModel.getItem(i).getFile() );
}
}
saveModOrder( sortedMods );
File datsDir = new File( config.getProperty( "ftl_dats_path" ) );
BackedUpDat dataDat = new BackedUpDat();
dataDat.datFile = new File( datsDir, "data.dat" );
dataDat.bakFile = new File( backupDir, "data.dat.bak" );
BackedUpDat resDat = new BackedUpDat();
resDat.datFile = new File( datsDir, "resource.dat" );
resDat.bakFile = new File( backupDir, "resource.dat.bak" );
ModPatchDialog patchDlg = new ModPatchDialog( this );
patchDlg.setSuccessTask( new SpawnGameTask() );
log.info( "" );
log.info( "Patching..." );
log.info( "" );
ModPatchThread patchThread = new ModPatchThread( modFiles, dataDat, resDat, patchDlg );
patchThread.start();
patchDlg.setVisible( true );
}
else if ( source == toggleAllBtn ) {
int selectedCount = 0;
for ( int i = localModsTableModel.getRowCount()-1; i >= 0; i-- ) {
if ( localModsTableModel.isSelected(i) ) selectedCount++;
}
boolean b = ( selectedCount != localModsTableModel.getRowCount() );
for ( int i = localModsTableModel.getRowCount()-1; i >= 0; i-- ) {
localModsTableModel.setSelected( i, b );
}
}
else if ( source == validateBtn ) {
StringBuilder resultBuf = new StringBuilder();
boolean anyInvalid = false;
for ( int i = localModsTableModel.getRowCount()-1; i >= 0; i-- ) {
if ( !localModsTableModel.isSelected(i) ) continue;
ModFileInfo modFileInfo = localModsTableModel.getItem( i );
Report validateReport = ModUtilities.validateModFile( modFileInfo.getFile(), null );
resultBuf.append( validateReport.text );
resultBuf.append( "\n" );
if ( validateReport.outcome == false ) anyInvalid = true;
}
if ( resultBuf.length() == 0 ) {
resultBuf.append( "No mods were checked." );
}
else if ( anyInvalid ) {
resultBuf.append( "FTL itself can tolerate lots of errors and still run. " );
resultBuf.append( "But invalid XML may break tools that do proper parsing, " );
resultBuf.append( "and it hinders the development of new tools.\n" );
}
infoArea.setDescription( "Results", resultBuf.toString() );
}
else if ( source == aboutBtn ) {
showAboutInfo();
}
}
@Override
public void hashCalculated( final File f, final String hash ) {
SwingUtilities.invokeLater( new Runnable() {
@Override
public void run() {
modFileHashes.put( f, hash );
}
});
}
private class SpawnGameTask implements Runnable {
@Override
public void run() {
String neverRunFtl = config.getProperty( "never_run_ftl", "false" );
if ( !neverRunFtl.equals("true") ) {
File datsDir = new File( config.getProperty( "ftl_dats_path" ) );
File exeFile = FTLUtilities.findGameExe( datsDir );
if ( exeFile != null ) {
int response = JOptionPane.showConfirmDialog( ManagerFrame.this, "Do you want to run the game now?", "Ready to Play", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE );
if ( response == JOptionPane.YES_OPTION ) {
log.info( "Launching FTL..." );
try {
FTLUtilities.launchGame( exeFile );
} catch ( Exception e ) {
log.error( "Error launching FTL.", e );
}
System.exit( 0 );
}
}
}
}
}
}

View file

@ -0,0 +1,152 @@
package net.vhati.modmanager.ui;
import java.awt.Color;
import java.awt.Cursor;
import java.awt.Desktop;
import java.awt.event.MouseEvent;
import java.net.URI;
import java.util.HashMap;
import javax.swing.JScrollPane;
import javax.swing.JTextPane;
import javax.swing.event.MouseInputAdapter;
import javax.swing.text.AttributeSet;
import javax.swing.text.BadLocationException;
import javax.swing.text.SimpleAttributeSet;
import javax.swing.text.StyleConstants;
import javax.swing.text.StyledDocument;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class ModInfoArea extends JScrollPane {
private static final Logger log = LogManager.getLogger(ModInfoArea.class);
private static final String HYPERLINK_TARGET = "hyperlink-target";
private JTextPane textPane;
private StyledDocument doc;
private HashMap<String,SimpleAttributeSet> attrMap = new HashMap<String,SimpleAttributeSet>();
public ModInfoArea() {
super();
textPane = new JTextPane();
textPane.setEditable( false );
doc = textPane.getStyledDocument();
SimpleAttributeSet tmpAttr = new SimpleAttributeSet();
StyleConstants.setFontFamily( tmpAttr, "Monospaced" );
StyleConstants.setFontSize( tmpAttr, 12 );
attrMap.put( "regular", tmpAttr );
tmpAttr = new SimpleAttributeSet();
StyleConstants.setFontFamily( tmpAttr, "SansSerif" );
StyleConstants.setFontSize( tmpAttr, 24 );
StyleConstants.setBold( tmpAttr, true );
attrMap.put( "title", tmpAttr );
tmpAttr = new SimpleAttributeSet();
StyleConstants.setFontFamily( tmpAttr, "Monospaced" );
StyleConstants.setFontSize( tmpAttr, 12 );
StyleConstants.setForeground( tmpAttr, Color.BLUE );
StyleConstants.setUnderline( tmpAttr, true );
attrMap.put( "hyperlink", tmpAttr );
MouseInputAdapter hyperlinkListener = new MouseInputAdapter() {
@Override
public void mouseClicked( MouseEvent e ) {
AttributeSet tmpAttr = doc.getCharacterElement( textPane.viewToModel(e.getPoint()) ).getAttributes();
Object targetObj = tmpAttr.getAttribute( HYPERLINK_TARGET );
if ( targetObj != null ) {
Desktop desktop = Desktop.isDesktopSupported() ? Desktop.getDesktop() : null;
if ( desktop != null && desktop.isSupported(Desktop.Action.BROWSE) ) {
try {
desktop.browse( new URI(targetObj.toString()) );
}
catch ( Exception f ) {
log.error( "Error browsing clicked url: "+ targetObj.toString(), f );
}
}
}
}
@Override
public void mouseMoved( MouseEvent e ) {
AttributeSet tmpAttr = doc.getCharacterElement( textPane.viewToModel(e.getPoint()) ).getAttributes();
if ( tmpAttr.getAttribute( HYPERLINK_TARGET ) != null ) {
textPane.setCursor( new Cursor(Cursor.HAND_CURSOR) );
} else {
textPane.setCursor( new Cursor(Cursor.DEFAULT_CURSOR) );
}
}
};
textPane.addMouseListener( hyperlinkListener );
textPane.addMouseMotionListener( hyperlinkListener );
textPane.addMouseListener( new ClipboardMenuMouseListener() );
this.setViewportView( textPane );
}
public void setDescription( String title, String body ) {
setDescription( title, null, null, null, body );
}
public void setDescription( String title, String author, String version, String url, String body ) {
try {
doc.remove( 0, doc.getLength() );
doc.insertString( doc.getLength(), title +"\n", attrMap.get("title") );
boolean first = true;
if ( author != null ) {
doc.insertString( doc.getLength(), String.format("%sby %s", (first ? "" : " "), author), attrMap.get("regular") );
first = false;
}
if ( version != null ) {
doc.insertString( doc.getLength(), String.format("%s(version %s)", (first ? "" : " "), version), attrMap.get("regular") );
first = false;
}
if ( !first ) {
doc.insertString( doc.getLength(), "\n", attrMap.get("regular") );
}
if ( url != null ) {
SimpleAttributeSet tmpAttr;
doc.insertString( doc.getLength(), "Website: ", attrMap.get("regular") );
boolean browseWorks = false;
Desktop desktop = Desktop.isDesktopSupported() ? Desktop.getDesktop() : null;
if ( desktop != null && desktop.isSupported(Desktop.Action.BROWSE) ) {
browseWorks = true;
}
if ( browseWorks && url.matches("^(?:https?|ftp)://.*") ) {
tmpAttr = new SimpleAttributeSet( attrMap.get("hyperlink") );
tmpAttr.addAttribute( HYPERLINK_TARGET, url );
doc.insertString( doc.getLength(), "Link", tmpAttr );
} else {
tmpAttr = new SimpleAttributeSet( attrMap.get("regular") );
doc.insertString( doc.getLength(), url, tmpAttr );
}
doc.insertString( doc.getLength(), "\n", attrMap.get("regular") );
}
doc.insertString( doc.getLength(), "\n", attrMap.get("regular") );
if ( body != null ) {
doc.insertString( doc.getLength(), body, attrMap.get("regular") );
}
}
catch ( BadLocationException e) {
log.error( e );
}
textPane.setCaretPosition(0);
}
}

View file

@ -0,0 +1,182 @@
package net.vhati.modmanager.ui;
import java.awt.BorderLayout;
import java.awt.Frame;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.io.File;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JDialog;
import javax.swing.JPanel;
import javax.swing.JProgressBar;
import javax.swing.JTextArea;
import javax.swing.SwingUtilities;
import net.vhati.modmanager.core.ModPatchObserver;
public class ModPatchDialog extends JDialog implements ActionListener, ModPatchObserver {
private JProgressBar progressBar;
private JTextArea statusArea;
private JButton continueBtn;
private boolean done = false;
private boolean patchingSucceeded = false;
private Runnable successTask = null;
public ModPatchDialog( Frame owner ) {
super( owner, "Patching...", true );
this.setDefaultCloseOperation( JDialog.DO_NOTHING_ON_CLOSE );
progressBar = new JProgressBar();
progressBar.setBorderPainted( true );
JPanel progressHolder = new JPanel( new BorderLayout() );
progressHolder.setBorder( BorderFactory.createEmptyBorder( 10, 15, 0, 15 ) );
progressHolder.add( progressBar );
getContentPane().add( progressHolder, BorderLayout.NORTH );
statusArea = new JTextArea();
statusArea.setBorder( BorderFactory.createEtchedBorder() );
statusArea.setLineWrap( true );
statusArea.setWrapStyleWord( true );
statusArea.setEditable( false );
JPanel statusHolder = new JPanel( new BorderLayout() );
statusHolder.setBorder( BorderFactory.createEmptyBorder( 15, 15, 15, 15 ) );
statusHolder.add( statusArea );
getContentPane().add( statusHolder, BorderLayout.CENTER );
continueBtn = new JButton( "Continue" );
continueBtn.setEnabled( false );
continueBtn.addActionListener( this );
JPanel continueHolder = new JPanel();
continueHolder.setLayout( new BoxLayout( continueHolder, BoxLayout.X_AXIS ) );
continueHolder.setBorder( BorderFactory.createEmptyBorder( 0, 0, 10, 0 ) );
continueHolder.add( Box.createHorizontalGlue() );
continueHolder.add( continueBtn );
continueHolder.add( Box.createHorizontalGlue() );
getContentPane().add( continueHolder, BorderLayout.SOUTH );
this.setSize( 400, 160 );
this.setLocationRelativeTo( owner );
}
@Override
public void actionPerformed( ActionEvent e ) {
Object source = e.getSource();
if ( source == continueBtn ) {
this.setVisible( false );
this.dispose();
if ( done && patchingSucceeded && successTask != null ) {
successTask.run();
}
}
}
private void setStatusText( String message ) {
statusArea.setText( message != null ? message : "..." );
}
/**
* Updates the progress bar.
*
* If either arg is -1, the bar will become indeterminate.
*
* @param value the new value
* @param max the new maximum
*/
@Override
public void patchingProgress( final int value, final int max ) {
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
if ( value >= 0 && max >= 0 ) {
if ( progressBar.isIndeterminate() )
progressBar.setIndeterminate( false );
if ( progressBar.getMaximum() != max ) {
progressBar.setValue( 0 );
progressBar.setMaximum( max );
}
progressBar.setValue( value );
}
else {
if ( !progressBar.isIndeterminate() )
progressBar.setIndeterminate( true );
progressBar.setValue( 0 );
}
}
});
}
/**
* Non-specific activity.
*
* @param message a string, or null
*/
@Override
public void patchingStatus( final String message ) {
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
setStatusText( message != null ? message : "..." );
}
});
}
/**
* A mod is about to be processed.
*/
@Override
public void patchingMod( final File modFile ) {
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
setStatusText( String.format( "Installing mod \"%s\"...", modFile.getName() ) );
}
});
}
/**
* Patching ended.
*
* If anything went wrong, e may be non-null.
*/
@Override
public void patchingEnded( final boolean success, final Exception e ) {
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
if ( success )
setStatusText( "Patching completed." );
else
setStatusText( String.format( "Patching failed: %s", e ) );
done = true;
patchingSucceeded = success;
continueBtn.setEnabled( true );
}
});
}
/**
* Sets a runnable to trigger after patching successfully.
*/
public void setSuccessTask( Runnable r ) {
successTask = r;
}
}

View file

@ -0,0 +1,9 @@
package net.vhati.modmanager.ui;
public interface Reorderable {
/**
* Moves an element at fromIndex to toIndex.
*/
public void reorder( int fromIndex, int toIndex );
}

View file

@ -0,0 +1,6 @@
package net.vhati.modmanager.ui;
public interface Statusbar {
public void setStatusText( String text );
}

View file

@ -0,0 +1,36 @@
package net.vhati.modmanager.ui;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.MouseListener;
import net.vhati.modmanager.ui.Statusbar;
/**
* A MouseListener to show rollover help text in a status bar.
*
* Construct this with the help text, and a class
* implementing the Statusbar interface.
*
* Then add this mouseListener to a component.
*/
public class StatusbarMouseListener extends MouseAdapter {
private Statusbar bar = null;
private String text = null;
public StatusbarMouseListener( Statusbar bar, String text ) {
this.bar = bar;
this.text = text;
}
@Override
public void mouseEntered( MouseEvent e ) {
bar.setStatusText( text );
}
@Override
public void mouseExited( MouseEvent e ) {
bar.setStatusText( "" );
}
}

View file

@ -0,0 +1,127 @@
package net.vhati.modmanager.ui;
import java.awt.Cursor;
import java.awt.datatransfer.DataFlavor;
import java.awt.datatransfer.Transferable;
import java.awt.dnd.DragSource;
import javax.swing.JComponent;
import javax.swing.JTable;
import javax.swing.TransferHandler;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* Allows drag and drop reordering of JTable rows.
*
* Its TableModel must implement the Reorderable interface.
*/
public class TableRowTransferHandler extends TransferHandler {
private static final Logger log = LogManager.getLogger(TableRowTransferHandler.class);
private DataFlavor localIntegerFlavor = null;
private JTable table = null;
public TableRowTransferHandler( JTable table ) {
if ( table.getModel() instanceof Reorderable == false ) {
throw new IllegalArgumentException( "The tableModel doesn't implement Reorderable." );
}
this.table = table;
try {
localIntegerFlavor = new DataFlavor( DataFlavor.javaJVMLocalObjectMimeType + ";class=\""+ Integer.class.getName() +"\"" );
}
catch ( ClassNotFoundException e ) {
log.error( e );
}
}
@Override
protected Transferable createTransferable( JComponent c ) {
assert ( c == table );
int row = table.getSelectedRow();
return new IntegerTransferrable( new Integer(row) );
}
@Override
public boolean canImport( TransferHandler.TransferSupport ts ) {
boolean b = ( ts.getComponent() == table && ts.isDrop() && ts.isDataFlavorSupported(localIntegerFlavor) );
table.setCursor( b ? DragSource.DefaultMoveDrop : DragSource.DefaultMoveNoDrop );
return b;
}
@Override
public int getSourceActions( JComponent comp ) {
return TransferHandler.MOVE;
}
@Override
@SuppressWarnings("Unchecked")
public boolean importData( TransferHandler.TransferSupport ts ) {
if ( !canImport(ts) ) return false;
JTable target = (JTable)ts.getComponent();
JTable.DropLocation dl = (JTable.DropLocation)ts.getDropLocation();
int dropRow = dl.getRow();
int rowCount = table.getModel().getRowCount();
if ( dropRow < 0 || dropRow > rowCount ) dropRow = rowCount;
target.setCursor( Cursor.getPredefinedCursor(Cursor.DEFAULT_CURSOR) );
try {
Integer draggedRow = (Integer)ts.getTransferable().getTransferData(localIntegerFlavor);
if ( draggedRow != -1 && draggedRow != dropRow ) {
((Reorderable)table.getModel()).reorder( draggedRow, dropRow );
if ( dropRow > draggedRow ) dropRow--;
target.getSelectionModel().addSelectionInterval( dropRow, dropRow );
return true;
}
} catch (Exception e) {
log.error( e );
}
return false;
}
@Override
protected void exportDone( JComponent source, Transferable data, int action ) {
if ( action == TransferHandler.MOVE || action == TransferHandler.NONE ) {
table.setCursor( Cursor.getPredefinedCursor(Cursor.DEFAULT_CURSOR) );
}
}
/**
* Drag and drop Integer data, constructed with a raw object
* from a drag source, to be transformed into a flavor
* suitable for the drop target.
*/
private class IntegerTransferrable implements Transferable{
private Integer data;
public IntegerTransferrable( Integer data ) {
this.data = data;
}
@Override
public Object getTransferData( DataFlavor flavor ) {
if ( flavor.equals( localIntegerFlavor ) ) {
return data;
}
return null;
}
@Override
public DataFlavor[] getTransferDataFlavors() {
return new DataFlavor[] {localIntegerFlavor};
}
@Override
public boolean isDataFlavorSupported( DataFlavor flavor ) {
return flavor.equals( localIntegerFlavor );
}
}
}

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration status="warn">
<appenders>
<Console name="Console" target="SYSTEM_OUT">
<ThresholdFilter level="INFO" onMatch="ACCEPT" onMismatch="DENY" />
<PatternLayout>
<pattern>%-5level %logger{1} - %msg%throwable%n</pattern>
</PatternLayout>
</Console>
<File name="LogFile" fileName="modman-log.txt" append="false">
<ThresholdFilter level="DEBUG" onMatch="ACCEPT" onMismatch="DENY" />
<PatternLayout>
<pattern>%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%throwable%n</pattern>
</PatternLayout>
</File>
</appenders>
<loggers>
<logger name="net.vhati" level="trace" additivity="false">
<appender-ref ref="Console"/>
<appender-ref ref="LogFile"/>
</logger>
<root level="error">
<appender-ref ref="Console"/>
<appender-ref ref="LogFile"/>
</root>
</loggers>
</configuration>