package net.vhati.modmanager.cli; import java.io.BufferedOutputStream; import java.io.File; import java.io.FileFilter; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.InputStream; import java.io.InputStreamReader; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Properties; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; import picocli.CommandLine; import picocli.CommandLine.Command; import picocli.CommandLine.IVersionProvider; import picocli.CommandLine.Option; import picocli.CommandLine.ParameterException; import picocli.CommandLine.Parameters; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import net.vhati.ftldat.AbstractPack; import net.vhati.ftldat.FolderPack; import net.vhati.ftldat.PkgPack; import net.vhati.ftldat.FTLPack; import net.vhati.modmanager.FTLModManager; import net.vhati.modmanager.core.DelayedDeleteHook; import net.vhati.modmanager.core.FTLUtilities; import net.vhati.modmanager.core.ModPatchObserver; import net.vhati.modmanager.core.ModPatchThread; import net.vhati.modmanager.core.ModUtilities; import net.vhati.modmanager.core.Report; import net.vhati.modmanager.core.Report.ReportFormatter; import net.vhati.modmanager.core.Report.ReportMessage; import net.vhati.modmanager.core.SlipstreamConfig; public class SlipstreamCLI { private static final Logger log = LoggerFactory.getLogger( SlipstreamCLI.class ); private static File backupDir = new File( "./backup/" ); private static File modsDir = new File( "./mods/" ); private static Thread.UncaughtExceptionHandler exceptionHandler = null; public static void main( String[] args ) { exceptionHandler = new Thread.UncaughtExceptionHandler() { @Override public void uncaughtException( Thread t, Throwable e ) { log.error( "Uncaught exception in thread: "+ t.toString(), e ); System.exit( 1 ); } }; SlipstreamCommand slipstreamCmd = new SlipstreamCommand(); CommandLine commandLine = new CommandLine( slipstreamCmd ); try { commandLine.parse( args ); } catch ( ParameterException e ) { //For multiple subcommands, e.getCommandLine() returns the one that failed. System.err.println( "Error parsing commandline: "+ e.getMessage() ); System.exit( 1 ); } if ( commandLine.isUsageHelpRequested() ) { commandLine.usage( System.out ); System.exit( 0 ); } if ( commandLine.isVersionHelpRequested() ) { commandLine.printVersionHelp( System.out ); System.exit( 0 ); } DelayedDeleteHook deleteHook = new DelayedDeleteHook(); Runtime.getRuntime().addShutdownHook( deleteHook ); if ( slipstreamCmd.validate ) { // Exits (0/1). log.info( "Validating..." ); StringBuilder resultBuf = new StringBuilder(); ReportFormatter formatter = new ReportFormatter(); boolean anyInvalid = false; for ( String modFileName : slipstreamCmd.modFileNames ) { File modFile = new File( modsDir, modFileName ); if ( modFile.isDirectory() ) { log.info( String.format( "Zipping dir: %s/", modFile.getName() ) ); try { modFile = createTempMod( modFile ); deleteHook.addDoomedFile( modFile ); } catch ( IOException e ) { log.error( String.format( "Error zipping dir: %s/", modFile.getName() ), e ); List tmpMessages = new ArrayList(); tmpMessages.add( new ReportMessage( ReportMessage.SECTION, modFileName ) ); tmpMessages.add( new ReportMessage( ReportMessage.EXCEPTION, e.getMessage() ) ); formatter.format( tmpMessages, resultBuf, 0 ); resultBuf.append( "\n" ); anyInvalid = true; continue; } } Report validateReport = ModUtilities.validateModFile( modFile ); formatter.format( validateReport.messages, resultBuf, 0 ); resultBuf.append( "\n" ); if ( validateReport.outcome == false ) anyInvalid = true; } if ( resultBuf.length() == 0 ) { resultBuf.append( "No mods were checked." ); } System.out.println(); System.out.println( resultBuf.toString() ); System.exit( anyInvalid ? 1 : 0 ); } File configFile = new File( "modman.cfg" ); SlipstreamConfig appConfig = getConfig( configFile ); if ( slipstreamCmd.listMods ) { // Exits. log.info( "Listing mods..." ); boolean allowZip = appConfig.getProperty( SlipstreamConfig.ALLOW_ZIP, "false" ).equals( "true" ); File[] modFiles = modsDir.listFiles( new ModAndDirFileFilter( allowZip, true ) ); List dirList = new ArrayList(); List fileList = new ArrayList(); for ( File f : modFiles ) { if ( f.isDirectory() ) dirList.add( f.getName() +"/" ); else fileList.add( f.getName() ); } Collections.sort( dirList ); Collections.sort( fileList ); for ( String s : dirList ) System.out.println( s ); for ( String s : fileList ) System.out.println( s ); System.exit( 0 ); } File datsDir = null; if ( slipstreamCmd.extractDatsDir != null || slipstreamCmd.patch || slipstreamCmd.runftl ) { datsDir = getDatsDir( appConfig ); } if ( slipstreamCmd.extractDatsDir != null ) { // Exits (0/1). log.info( "Extracting dats..." ); File extractDir = slipstreamCmd.extractDatsDir; FolderPack dstPack = null; List srcPacks = new ArrayList( 2 ); InputStream is = null; try { File ftlDatFile = new File( datsDir, "ftl.dat" ); File dataDatFile = new File( datsDir, "data.dat" ); File resourceDatFile = new File( datsDir, "resource.dat" ); if ( ftlDatFile.exists() ) { // FTL 1.6.1. AbstractPack ftlPack = new PkgPack( ftlDatFile, "r" ); srcPacks.add( ftlPack ); } else if ( dataDatFile.exists() && resourceDatFile.exists() ) { // FTL 1.01-1.5.13. AbstractPack dataPack = new FTLPack( dataDatFile, "r" ); AbstractPack resourcePack = new FTLPack( resourceDatFile, "r" ); srcPacks.add( dataPack ); srcPacks.add( resourcePack ); } else { throw new FileNotFoundException( String.format( "Could not find either \"%s\" or both \"%s\" and \"%s\"", ftlDatFile.getName(), dataDatFile.getName(), resourceDatFile.getName() ) ); } if ( !extractDir.exists() ) extractDir.mkdirs(); dstPack = new FolderPack( extractDir ); for ( AbstractPack srcPack : srcPacks ) { List innerPaths = srcPack.list(); for ( String innerPath : innerPaths ) { if ( dstPack.contains( innerPath ) ) { log.info( "While extracting resources, this file was overwritten: "+ innerPath ); dstPack.remove( innerPath ); } is = srcPack.getInputStream( innerPath ); dstPack.add( innerPath, is ); } srcPack.close(); } } catch ( IOException e ) { log.error( "Error extracting dats", e ); System.exit( 1 ); } finally { try {if ( is != null ) is.close();} catch ( IOException ex ) {} try {if ( dstPack != null ) dstPack.close();} catch ( IOException ex ) {} for ( AbstractPack pack : srcPacks ) { try {pack.close();} catch ( IOException ex ) {} } } System.exit( 0 ); } if ( slipstreamCmd.patch ) { // Exits sometimes (1 on failure). log.info( "Patching..." ); List modFiles = new ArrayList(); for ( String modFileName : slipstreamCmd.modFileNames ) { File modFile = new File( modsDir, modFileName ); if ( modFile.isDirectory() ) { log.info( String.format( "Zipping dir: %s/", modFile.getName() ) ); try { modFile = createTempMod( modFile ); deleteHook.addDoomedFile( modFile ); } catch ( IOException e ) { log.error( String.format( "Error zipping dir: %s/", modFile.getName() ), e ); System.exit( 1 ); } } modFiles.add( modFile ); } boolean globalPanic = slipstreamCmd.globalPanic; SilentPatchObserver patchObserver = new SilentPatchObserver(); ModPatchThread patchThread = new ModPatchThread( modFiles, datsDir, backupDir, globalPanic, patchObserver ); patchThread.setDefaultUncaughtExceptionHandler( exceptionHandler ); deleteHook.addWatchedThread( patchThread ); patchThread.start(); while ( patchThread.isAlive() ) { try {patchThread.join();} catch ( InterruptedException e ) {} } if ( !patchObserver.hasSucceeded() ) System.exit( 1 ); } if ( slipstreamCmd.runftl ) { // Exits (0/1). log.info( "Running FTL..." ); File exeFile = null; String[] exeArgs = null; // Try to run via Steam. if ( "true".equals( appConfig.getProperty( SlipstreamConfig.RUN_STEAM_FTL, "false" ) ) ) { String steamPath = appConfig.getProperty( SlipstreamConfig.STEAM_EXE_PATH ); if ( steamPath.length() > 0 ) { exeFile = new File( steamPath ); if ( exeFile.exists() ) { exeArgs = new String[] {"-applaunch", FTLUtilities.STEAM_APPID_FTL}; } else { log.warn( String.format( "%s does not exist: %s", SlipstreamConfig.STEAM_EXE_PATH, exeFile.getAbsolutePath() ) ); exeFile = null; } } if ( exeFile == null ) { log.warn( "Steam executable could not be found, so FTL will be launched directly" ); } } // Try to run directly. if ( exeFile == null ) { exeFile = FTLUtilities.findGameExe( datsDir ); if ( exeFile != null ) { exeArgs = new String[0]; } else { log.warn( "FTL executable could not be found" ); } } if ( exeFile != null ) { try { FTLUtilities.launchExe( exeFile, exeArgs ); } catch ( Exception e ) { log.error( "Error launching FTL", e ); System.exit( 1 ); } } else { log.error( "No executables were found to launch FTL" ); System.exit( 1 ); } System.exit( 0 ); } System.exit( 0 ); } /** * Loads settings from a config file. * * If an error occurs, it'll be logged, * and default settings will be returned. */ private static SlipstreamConfig getConfig( File configFile ) { Properties props = new Properties(); props.setProperty( SlipstreamConfig.ALLOW_ZIP, "false" ); props.setProperty( SlipstreamConfig.FTL_DATS_PATH, "" ); props.setProperty( SlipstreamConfig.STEAM_EXE_PATH, "" ); props.setProperty( SlipstreamConfig.RUN_STEAM_FTL, "false" ); props.setProperty( SlipstreamConfig.NEVER_RUN_FTL, "false" ); props.setProperty( SlipstreamConfig.USE_DEFAULT_UI, "false" ); props.setProperty( SlipstreamConfig.REMEMBER_GEOMETRY, "true" ); // "update_catalog" doesn't have a default. // "update_app" doesn't have a default. // "manager_geometry" 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 ); props.load( new InputStreamReader( in, "UTF-8" ) ); } } catch ( IOException e ) { log.error( "Error loading config", e ); } finally { try {if ( in != null ) in.close();} catch ( IOException e ) {} } SlipstreamConfig appConfig = new SlipstreamConfig( props, configFile ); return appConfig; } /** * Checks the validity of the config's dats path and returns it. * Or exits if the path is invalid. */ private static File getDatsDir( SlipstreamConfig appConfig ) { File datsDir = null; String datsPath = appConfig.getProperty( SlipstreamConfig.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 "+ SlipstreamConfig.FTL_DATS_PATH +" does not exist, or it is invalid" ); datsDir = null; } } else { log.error( "No FTL dats path previously set" ); } if ( datsDir == null ) { log.error( "Run the GUI once, or edit the config file, and try again" ); System.exit( 1 ); } return datsDir; } /** * Returns a temporary zip made from a directory. * * Empty subdirs will be omitted. * The archive will be not be deleted on exit (handle that elsewhere). */ private static File createTempMod( File dir ) throws IOException { File tempFile = File.createTempFile( dir.getName() +"_temp-", ".zip" ); FileOutputStream fos = null; try { fos = new FileOutputStream( tempFile ); ZipOutputStream zos = new ZipOutputStream( new BufferedOutputStream( fos ) ); addDirToArchive( zos, dir, null ); zos.close(); } finally { try {if ( fos != null ) fos.close();} catch ( IOException e ) {} } return tempFile; } private static void addDirToArchive( ZipOutputStream zos, File dir, String pathPrefix ) throws IOException { if ( pathPrefix == null ) pathPrefix = ""; for ( File f : dir.listFiles() ) { if ( f.isDirectory() ) { addDirToArchive( zos, f, pathPrefix + f.getName() +"/" ); continue; } FileInputStream is = null; try { is = new FileInputStream( f ); zos.putNextEntry( new ZipEntry( pathPrefix + f.getName() ) ); byte[] buf = new byte[4096]; int len; while ( (len = is.read(buf)) >= 0 ) { zos.write( buf, 0, len ); } zos.closeEntry(); } finally { try {if ( is != null ) is.close();} catch ( IOException e ) {} } } } @Command( name = "modman", abbreviateSynopsis = true, sortOptions = false, description = "Perform actions against an FTL installation and/or a list of named mods.", footer = "%nIf a named mod is a directory, a temporary zip will be created.", versionProvider = SlipstreamVersionProvider.class ) public static class SlipstreamCommand { @Option(names = "--extract-dats", paramLabel = "DIR", description = "extract FTL resources into a dir") File extractDatsDir; @Option(names = "--global-panic", description = "patch as if advanced find tags had panic='true'") boolean globalPanic; @Option(names = "--list-mods", description = "list available mod names") boolean listMods; @Option(names = "--runftl", description = "run the game (standalone or with 'patch')") boolean runftl; @Option(names = "--patch", description = "revert to vanilla and add named mods (if any)") boolean patch; @Option(names = "--validate", description = "check named mods for problems") boolean validate; @Option(names = {"-h", "--help"}, usageHelp = true, description = "display this help and exit") boolean helpRequested; @Option(names = "--version", versionHelp = true, description = "output version information and exit") boolean versionRequested; @Parameters(paramLabel = "MODFILE", description = "names of files or directories in the mods/ dir") String[] modFileNames; } public static class SlipstreamVersionProvider implements IVersionProvider { @Override public String[] getVersion() { return new String[] { String.format( "%s %s", FTLModManager.APP_NAME, FTLModManager.APP_VERSION ), "Copyright (C) 2013,2014,2017,2018 David Millis", "", "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; version 2.", "", "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.", "", "You should have received a copy of the GNU General Public License", "along with this program. If not, see http://www.gnu.org/licenses/.", "", }; } } private static class SilentPatchObserver implements ModPatchObserver { private boolean done = false; private boolean succeeded = false; @Override public void patchingProgress( final int value, final int max ) { } @Override public void patchingStatus( String message ) { } @Override public void patchingMod( File modFile ) { } @Override public synchronized void patchingEnded( boolean outcome, Exception e ) { succeeded = outcome; done = true; } public synchronized boolean isDone() { return done; } public synchronized boolean hasSucceeded() { return succeeded; } } private static class ModAndDirFileFilter implements FileFilter { private boolean allowZip; private boolean allowDirs; public ModAndDirFileFilter( boolean allowZip, boolean allowDirs ) { this.allowZip = allowZip; this.allowDirs = allowDirs; } @Override public boolean accept( File f ) { if ( f.isDirectory() ) return allowDirs; if ( f.getName().endsWith(".ftl") ) return true; if ( allowZip ) { if ( f.getName().endsWith(".zip") ) return true; } return false; } } }