diff --git a/pom.xml b/pom.xml
index 9cbb927..53264db 100644
--- a/pom.xml
+++ b/pom.xml
@@ -67,7 +67,7 @@
info.picocli
picocli
- 4.7.7
+ 2.2.0
diff --git a/src/main/java/net/vhati/modmanager/FTLModManager.java b/src/main/java/net/vhati/modmanager/FTLModManager.java
index edc78a1..c431262 100644
--- a/src/main/java/net/vhati/modmanager/FTLModManager.java
+++ b/src/main/java/net/vhati/modmanager/FTLModManager.java
@@ -1,8 +1,14 @@
package net.vhati.modmanager;
import java.io.File;
+import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Date;
+import javax.swing.JFileChooser;
+import javax.swing.JOptionPane;
+import javax.swing.LookAndFeel;
+import javax.swing.SwingUtilities;
+import javax.swing.UIManager;
import ch.qos.logback.classic.LoggerContext;
import ch.qos.logback.classic.spi.ILoggingEvent;
@@ -14,16 +20,22 @@ import org.slf4j.bridge.SLF4JBridgeHandler;
import net.vhati.modmanager.cli.SlipstreamCLI;
import net.vhati.modmanager.core.ComparableVersion;
+import net.vhati.modmanager.core.FTLUtilities;
+import net.vhati.modmanager.core.SlipstreamConfig;
+import net.vhati.modmanager.ui.ManagerFrame;
public class FTLModManager {
- private static final Logger log = LoggerFactory.getLogger(FTLModManager.class);
+ private static final Logger log = LoggerFactory.getLogger( FTLModManager.class );
public static final String APP_NAME = "Slipstream Mod Manager";
- public static final ComparableVersion APP_VERSION = new ComparableVersion("1.9.1");
+ public static final ComparableVersion APP_VERSION = new ComparableVersion( "1.9.1" );
+ public static final String APP_URL = "TODO";
+ public static final String APP_AUTHOR = "jan-leila";
- public static void main(String[] args) {
+
+ public static void main( String[] args ) {
// Redirect any libraries' java.util.Logging messages.
SLF4JBridgeHandler.removeHandlersForRootLogger();
SLF4JBridgeHandler.install();
@@ -35,29 +47,299 @@ public class FTLModManager {
LoggerContext lc = (LoggerContext)LoggerFactory.getILoggerFactory();
PatternLayoutEncoder encoder = new PatternLayoutEncoder();
- encoder.setContext(lc);
+ encoder.setContext( lc );
encoder.setCharset(StandardCharsets.UTF_8);
- encoder.setPattern("%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n");
+ encoder.setPattern( "%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" );
encoder.start();
- FileAppender fileAppender = new FileAppender<>();
- fileAppender.setContext(lc);
- fileAppender.setName("LogFile");
- fileAppender.setFile(new File("./modman-log.txt").getAbsolutePath());
- fileAppender.setAppend(false);
- fileAppender.setEncoder(encoder);
+ FileAppender fileAppender = new FileAppender();
+ fileAppender.setContext( lc );
+ fileAppender.setName( "LogFile" );
+ fileAppender.setFile( new File( "./modman-log.txt" ).getAbsolutePath() );
+ fileAppender.setAppend( false );
+ fileAppender.setEncoder( encoder );
fileAppender.start();
- lc.getLogger(Logger.ROOT_LOGGER_NAME).addAppender(fileAppender);
+ lc.getLogger( Logger.ROOT_LOGGER_NAME ).addAppender( fileAppender );
// Log a welcome message.
- log.debug("Started: {}", new Date());
- log.debug("{} v{}", APP_NAME, APP_VERSION);
- log.debug("OS: {} {}", System.getProperty("os.name"), System.getProperty("os.version"));
- log.debug("VM: {}, {}, {}", System.getProperty("java.vm.name"), System.getProperty("java.version"), System.getProperty("os.arch"));
+ log.debug( "Started: {}", new Date() );
+ log.debug( "{} v{}", APP_NAME, APP_VERSION );
+ log.debug( "OS: {} {}", System.getProperty( "os.name" ), System.getProperty( "os.version" ) );
+ log.debug( "VM: {}, {}, {}", System.getProperty( "java.vm.name" ), System.getProperty( "java.version" ), System.getProperty( "os.arch" ) );
- Thread.setDefaultUncaughtExceptionHandler((t, e) -> log.error("Uncaught exception in thread: {}", t.toString(), e));
+ Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
+ @Override
+ public void uncaughtException( Thread t, Throwable e ) {
+ log.error("Uncaught exception in thread: {}", t.toString(), e);
+ }
+ });
- SlipstreamCLI.main(args);
+ if ( args.length > 0 ) {
+ SlipstreamCLI.main( args );
+ return;
+ }
+
+ // Ensure all popups are triggered from the event dispatch thread.
+ SwingUtilities.invokeLater(FTLModManager::guiInit);
+ }
+
+
+ private static void guiInit() {
+ try {
+ // TODO: get mods file from env var
+ // Nag if the jar was double-clicked.
+ if (!new File("./mods/").exists()) {
+ String currentPath = new File( "." ).getAbsoluteFile().getParentFile().getAbsolutePath();
+
+ log.error( String.format( "Slipstream could not find its own folder (Currently in \"%s\"), exiting...", currentPath ) );
+ showErrorDialog( String.format( "Slipstream could not find its own folder.\nCurrently in: %s\n\nRun one of the following instead of the jar...\nWindows: modman.exe or modman_admin.exe\nLinux/OSX: modman.command or modman-cli.sh\n\nSlipstream will now exit.", currentPath ) );
+
+ throw new ExitException();
+ }
+
+ // TODO: get config file from env var
+ File configFile = new File( "modman.cfg" );
+
+ SlipstreamConfig appConfig = new SlipstreamConfig(configFile);
+
+ // Look-and-Feel.
+ boolean useDefaultUI = Boolean.parseBoolean(appConfig.getProperty(SlipstreamConfig.USE_DEFAULT_UI, "false"));
+
+ if ( !useDefaultUI ) {
+ LookAndFeel defaultLaf = UIManager.getLookAndFeel();
+ log.debug( "Default look and feel is: "+ defaultLaf.getName() );
+
+ try {
+ log.debug( "Setting system look and feel: "+ UIManager.getSystemLookAndFeelClassName() );
+
+ // SystemLaf is risky. It may throw an exception, or lead to graphical bugs.
+ // Problems are generally caused by custom Windows themes.
+ UIManager.setLookAndFeel( UIManager.getSystemLookAndFeelClassName() );
+ }
+ catch ( Exception e ) {
+ log.error( "Failed to set system look and feel", e );
+ log.info( "Setting "+ SlipstreamConfig.USE_DEFAULT_UI +"=true in the config file to prevent this error..." );
+
+ appConfig.setProperty( SlipstreamConfig.USE_DEFAULT_UI, "true" );
+
+ try {
+ UIManager.setLookAndFeel( defaultLaf );
+ }
+ catch ( Exception f ) {
+ log.error( "Error returning to the default look and feel after failing to set system look and feel", f );
+
+ // Write an emergency config and exit.
+ try {
+ appConfig.writeConfig();
+ }
+ catch ( IOException g ) {
+ log.error( String.format( "Error writing config to \"%s\"", configFile.getPath(), g ) );
+ }
+
+ throw new ExitException();
+ }
+ }
+ }
+ else {
+ log.debug( "Using default Look and Feel" );
+ }
+
+ // FTL Resources Path.
+ 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.debug( "No "+ SlipstreamConfig.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 ) {
+ appConfig.setProperty( SlipstreamConfig.FTL_DATS_PATH, datsDir.getAbsolutePath() );
+ log.info( "FTL dats located at: "+ datsDir.getAbsolutePath() );
+ }
+ }
+
+ if ( datsDir == null ) {
+ showErrorDialog( "FTL resources were not found.\nSlipstream will now exit." );
+ log.debug( "No FTL dats path found, exiting" );
+
+ throw new ExitException();
+ }
+
+ // Ask about Steam.
+ if ( appConfig.getProperty( SlipstreamConfig.STEAM_DISTRO, "" ).length() == 0 ) {
+ int steamBasedResponse = JOptionPane.showConfirmDialog( null, "Was FTL installed via Steam?", "Confirm", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE );
+ if ( steamBasedResponse == JOptionPane.YES_OPTION ) {
+ appConfig.setProperty( SlipstreamConfig.STEAM_DISTRO, "true" );
+ }
+ else {
+ appConfig.setProperty( SlipstreamConfig.STEAM_DISTRO, "false" );
+ }
+ }
+
+ // If this is a Steam distro.
+ if ( "true".equals( appConfig.getProperty( SlipstreamConfig.STEAM_DISTRO, "false" ) ) ) {
+
+ // Find Steam's executable.
+ if ( appConfig.getProperty( SlipstreamConfig.STEAM_EXE_PATH, "" ).length() == 0 ) {
+
+ File steamExeFile = FTLUtilities.findSteamExe();
+
+ if ( steamExeFile == null && System.getProperty( "os.name" ).startsWith( "Windows" ) ) {
+ try {
+ String registryExePath = FTLUtilities.queryRegistryKey( "HKCU\\Software\\Valve\\Steam", "SteamExe", "REG_SZ" );
+ if ( registryExePath != null && !(steamExeFile=new File( registryExePath )).exists() ) {
+ steamExeFile = null;
+ }
+ }
+ catch( IOException e ) {
+ log.error( "Error while querying registry for Steam's path", e );
+ }
+ }
+
+ if ( steamExeFile != null ) {
+ int response = JOptionPane.showConfirmDialog( null, "Steam was found at:\n"+ steamExeFile.getPath() +"\nIs this correct?", "Confirm", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE );
+ if ( response == JOptionPane.NO_OPTION ) steamExeFile = null;
+ }
+
+ if ( steamExeFile == null ) {
+ log.debug( "Steam was not located automatically. Prompting user for location" );
+
+ String steamPrompt = ""
+ + "You will be prompted to locate Steam's executable.\n"
+ + "- Windows: Steam.exe\n"
+ + "- Linux: steam\n"
+ + "- OSX: Steam.app\n"
+ + "\n"
+ + "If you can't find it, you can cancel and set it later.";
+ JOptionPane.showMessageDialog( null, steamPrompt, "Find Steam", JOptionPane.INFORMATION_MESSAGE );
+
+ JFileChooser steamExeChooser = new JFileChooser();
+ steamExeChooser.setDialogTitle( "Find Steam.exe or steam or Steam.app" );
+ steamExeChooser.setFileHidingEnabled( false );
+ steamExeChooser.setMultiSelectionEnabled( false );
+
+ if ( steamExeChooser.showOpenDialog( null ) == JFileChooser.APPROVE_OPTION ) {
+ steamExeFile = steamExeChooser.getSelectedFile();
+ if ( !steamExeFile.exists() ) steamExeFile = null;
+ }
+ }
+
+ if ( steamExeFile != null ) {
+ appConfig.setProperty( SlipstreamConfig.STEAM_EXE_PATH, steamExeFile.getAbsolutePath() );
+ log.info( "Steam located at: "+ steamExeFile.getAbsolutePath() );
+ }
+ }
+
+ if ( appConfig.getProperty( SlipstreamConfig.STEAM_EXE_PATH, "" ).length() > 0 ) {
+
+ if ( appConfig.getProperty( SlipstreamConfig.RUN_STEAM_FTL, "" ).length() == 0 ) {
+
+ String[] launchOptions = new String[] {"Directly", "Steam"};
+ int launchResponse = JOptionPane.showOptionDialog( null, "Would you prefer to launch FTL directly, or via Steam?", "How to Launch?", JOptionPane.DEFAULT_OPTION, JOptionPane.PLAIN_MESSAGE, null, launchOptions, launchOptions[1] );
+ if ( launchResponse == 0 ) {
+ appConfig.setProperty( SlipstreamConfig.RUN_STEAM_FTL, "false" );
+ }
+ else if ( launchResponse == 1 ) {
+ appConfig.setProperty( SlipstreamConfig.RUN_STEAM_FTL, "true" );
+ }
+ }
+ }
+ }
+
+ // Prompt if update_catalog is invalid or hasn't been set.
+ boolean askAboutUpdates = false;
+ if ( !appConfig.getProperty( SlipstreamConfig.UPDATE_CATALOG, "" ).matches( "^\\d+$" ) )
+ askAboutUpdates = true;
+ if ( !appConfig.getProperty( SlipstreamConfig.UPDATE_APP, "" ).matches( "^\\d+$" ) )
+ askAboutUpdates = true;
+
+ if ( askAboutUpdates ) {
+ String updatePrompt = ""
+ + "Would you like Slipstream to periodically check for updates?\n"
+ + "\n"
+ + "You can change this later.";
+
+ int response = JOptionPane.showConfirmDialog( null, updatePrompt, "Updates", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE );
+ if ( response == JOptionPane.YES_OPTION ) {
+ appConfig.setProperty( SlipstreamConfig.UPDATE_CATALOG, "7" );
+ appConfig.setProperty( SlipstreamConfig.UPDATE_APP, "4" );
+ }
+ else {
+ appConfig.setProperty( SlipstreamConfig.UPDATE_CATALOG, "0" );
+ appConfig.setProperty( SlipstreamConfig.UPDATE_APP, "0" );
+ }
+ }
+
+ ManagerFrame frame = null;
+ try {
+ frame = new ManagerFrame( appConfig, APP_NAME, APP_VERSION, APP_URL, APP_AUTHOR );
+ frame.init();
+ frame.setVisible( true );
+ }
+ catch ( Exception e ) {
+ log.error( "Failed to create and init the main window", e );
+
+ // If the frame is constructed, but an exception prevents it
+ // becoming visible, that *must* be caught. The frame registers
+ // itself as a global uncaught exception handler. It doesn't
+ // dispose() itself in the handler, so EDT will wait forever
+ // for an invisible window to close.
+
+ if ( frame != null && frame.isDisplayable() ) {
+ frame.setDisposeNormally( false );
+ frame.dispose();
+ }
+
+ throw new ExitException();
+ }
+ }
+ catch ( ExitException e ) {
+ System.gc();
+ // System.exit( 1 ); // Don't do this (InterruptedException). Let EDT end gracefully.
+ return;
+ }
+ }
+
+ private static void showErrorDialog( String message ) {
+ JOptionPane.showMessageDialog( null, message, "Error", JOptionPane.ERROR_MESSAGE );
+ }
+
+ private static class ExitException extends RuntimeException {
+ public ExitException() {
+ }
+
+ public ExitException( String message ) {
+ super( message );
+ }
+
+ public ExitException( Throwable cause ) {
+ super( cause );
+ }
+
+ public ExitException( String message, Throwable cause ) {
+ super( message, cause );
+ }
}
}
diff --git a/src/main/java/net/vhati/modmanager/cli/SlipstreamCLI.java b/src/main/java/net/vhati/modmanager/cli/SlipstreamCLI.java
index 330051d..a10f407 100644
--- a/src/main/java/net/vhati/modmanager/cli/SlipstreamCLI.java
+++ b/src/main/java/net/vhati/modmanager/cli/SlipstreamCLI.java
@@ -7,13 +7,21 @@ 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.*;
+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.*;
+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;
@@ -33,633 +41,443 @@ 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 final Logger log = LoggerFactory.getLogger( SlipstreamCLI.class );
- private static final File backupDir = new File("./backup/");
- private static final File modsDir = new File("./mods/");
+ 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) {
+ public static void main( String[] args ) {
- exceptionHandler = (t, e) -> {
- log.error("Uncaught exception in thread: {}", t.toString(), e);
- System.exit(1);
- };
+ exceptionHandler = new Thread.UncaughtExceptionHandler() {
+ @Override
+ public void uncaughtException( Thread t, Throwable e ) {
+ log.error( "Uncaught exception in thread: "+ t.toString(), e );
+ System.exit( 1 );
+ }
+ };
- CommandLine commandLine = new CommandLine(new BaseCommand())
- .addSubcommand("start", new StartCommand())
- .addSubcommand("patch", new PatchCommand())
- .addSubcommand("list", new ListCommand())
- .addSubcommand("clean", new CleanCommand());
+ 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.
- commandLine.execute(args);
+ System.err.println( "Error parsing commandline: "+ e.getMessage() );
+ System.exit( 1 );
+ }
-// 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);
-// }
-//
-// ActionFlow actionFlow = getCommandAction(commandLine);
-//
-// switch (actionFlow) {
-// case VERSION -> {
-// commandLine.usage(System.out);
-// System.exit(0);
-// }
-// case HELP -> {
-// commandLine.printVersionHelp(System.out);
-// System.exit(0);
-// }
-// }
+ if ( commandLine.isUsageHelpRequested() ) {
+ commandLine.usage( System.out );
+ System.exit( 0 );
+ }
+ if ( commandLine.isVersionHelpRequested() ) {
+ commandLine.printVersionHelp( System.out );
+ System.exit( 0 );
+ }
-// if (slipstreamCmd.validate) {
-// boolean success = validate(slipstreamCmd.modFileNames);
-// System.exit(success ? 0 : 1);
-// }
-//
-// File configFile = new File("modman.cfg");
-// SlipstreamConfig appConfig = new SlipstreamConfig(configFile);
-//
-// if (slipstreamCmd.listMods) {
-// listMods(appConfig);
-// System.exit(0);
-// }
-//
-// File datsDir = null;
-// if (slipstreamCmd.extractDatsDir != null ||
-// slipstreamCmd.patch ||
-// slipstreamCmd.runftl) {
-// datsDir = getDatsDir(appConfig);
-// }
-//
-// if (slipstreamCmd.extractDatsDir != null) {
-// extractDatsDir(slipstreamCmd, datsDir);
-// System.exit(0);
-// }
-//
-// if (slipstreamCmd.patch) {
-// boolean success = patch(slipstreamCmd, datsDir);
-// if (!success) {
-// System.exit(1);
-// }
-// }
-//
-// if (slipstreamCmd.runftl) {
-// boolean success = runFtl(appConfig, datsDir);
-// System.exit(success ? 0: 1);
-// }
-//
-// System.exit(0);
+ if ( slipstreamCmd.validate ) {
+ boolean success = validate(slipstreamCmd.modFileNames);
+ System.exit( success ? 0 : 1);
+ }
+
+ File configFile = new File( "modman.cfg" );
+ SlipstreamConfig appConfig = new SlipstreamConfig( configFile );
+
+ if ( slipstreamCmd.listMods ) {
+ listMods(appConfig);
+ System.exit( 0 );
+ }
+
+ File datsDir = null;
+ if ( slipstreamCmd.extractDatsDir != null ||
+ slipstreamCmd.patch ||
+ slipstreamCmd.runftl ) {
+ datsDir = getDatsDir( appConfig );
+ }
+
+ if ( slipstreamCmd.extractDatsDir != null ) {
+ extractDatsDir(slipstreamCmd, datsDir);
+ System.exit( 0 );
+ }
+
+ if ( slipstreamCmd.patch ) {
+ boolean success = patch(slipstreamCmd, datsDir);
+ if (!success) {
+ System.exit( 1 );
+ }
+ }
+
+ if ( slipstreamCmd.runftl ) {
+ boolean success = runFtl(appConfig, datsDir);
+ System.exit(success ? 0 : 1 );
+ }
+
+ System.exit( 0 );
}
-// private static ActionFlow getCommandAction(CommandLine commandLine) {
-// if (commandLine.isUsageHelpRequested()) {
-// return ActionFlow.HELP;
-// }
-// if (commandLine.isVersionHelpRequested()) {
-// return ActionFlow.VERSION;
-// }
-//
-// return null;
-// }
+ private static void extractDatsDir(SlipstreamCommand slipstreamCmd, File datsDir) {
+ log.info( "Extracting dats..." );
-// private static void extractDatsDir(RunCommand slipstreamCmd, File datsDir) {
-// 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()) {
-// boolean success = extractDir.mkdirs();
-// if (success) {
-// log.error("Error extracting dats");
-// System.exit(1);
-// }
-// };
-//
-// 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 ignored) {}
-//
-// try {if (dstPack != null) dstPack.close();}
-// catch (IOException ignored) {}
-//
-// for (AbstractPack pack : srcPacks) {
-// try {pack.close();}
-// catch (IOException ignored) {}
-// }
-// }
-// }
-//
-// private static boolean patch(RunCommand slipstreamCmd, File datsDir) {
-// log.info("Patching...");
-//
-// DelayedDeleteHook deleteHook = new DelayedDeleteHook();
-// Runtime.getRuntime().addShutdownHook(deleteHook);
-//
-// List modFiles = new ArrayList();
-// if (slipstreamCmd.modFileNames != null) {
-// for (String modFileName : slipstreamCmd.modFileNames) {
-// File modFile = new File(modsDir, modFileName);
-//
-// if (modFile.isDirectory()) {
-// log.info("Zipping dir: {}/", modFile.getName());
-// try {
-// modFile = createTempMod(modFile);
-// deleteHook.addDoomedFile(modFile);
-// }
-// catch (IOException e) {
-// log.error("Error zipping dir: {}/", modFile.getName(), e);
-// return false;
-// }
-// }
-//
-// modFiles.add(modFile);
-// }
-// }
-//
-// boolean globalPanic = slipstreamCmd.globalPanic;
-//
-// SilentPatchObserver patchObserver = new SilentPatchObserver();
-// ModPatchThread patchThread = new ModPatchThread(modFiles, datsDir, backupDir, globalPanic, patchObserver);
-// Thread.setDefaultUncaughtExceptionHandler(exceptionHandler);
-// deleteHook.addWatchedThread(patchThread);
-//
-// patchThread.start();
-// while (patchThread.isAlive()) {
-// try {patchThread.join();}
-// catch (InterruptedException ignored) {}
-// }
-//
-// return patchObserver.hasSucceeded();
-// }
+ File extractDir = slipstreamCmd.extractDatsDir;
-// private static boolean validate(String[] modFileNames) {
-// DelayedDeleteHook deleteHook = new DelayedDeleteHook();
-// Runtime.getRuntime().addShutdownHook(deleteHook);
-//
-// log.info("Validating...");
-//
-// StringBuilder resultBuf = new StringBuilder();
-// ReportFormatter formatter = new ReportFormatter();
-// boolean anyInvalid = false;
-//
-// for (String modFileName : modFileNames) {
-// File modFile = new File(modsDir, modFileName);
-//
-// if (modFile.isDirectory()) {
-// log.info("Zipping dir: {}/", modFile.getName());
-// try {
-// modFile = createTempMod(modFile);
-// deleteHook.addDoomedFile(modFile);
-// }
-// catch (IOException e) {
-// log.error("Error zipping dir: {}/", 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) anyInvalid = true;
-// }
-// if (resultBuf.isEmpty()) {
-// resultBuf.append("No mods were checked.");
-// }
-//
-// System.out.println();
-// System.out.println(resultBuf);
-// return !anyInvalid;
-// }
-//
-// private static void listMods(SlipstreamConfig appConfig) {
-// 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<>();
-// assert modFiles != null;
-// 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);
-// }
-//
-// private static boolean runFtl(SlipstreamConfig appConfig, File datsDir) {
-// 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.isEmpty()) {
-// 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);
-// return false;
-// }
-// }
-// else {
-// log.error("No executables were found to launch FTL");
-// return false;
-// }
-//
-// return true;
-// }
+ 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" );
-// /**
-// * 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.isEmpty()) {
-// log.info("Using FTL dats path from config: "+ datsPath);
-// datsDir = new File(datsPath);
-// if (!FTLUtilities.isDatsDirValid(datsDir)) {
-// 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;
-// }
+ 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 ) {}
+ }
+ }
+ }
+
+ private static boolean patch(SlipstreamCommand slipstreamCmd, File datsDir) {
+ log.info( "Patching..." );
+
+ DelayedDeleteHook deleteHook = new DelayedDeleteHook();
+ Runtime.getRuntime().addShutdownHook( deleteHook );
+
+ List modFiles = new ArrayList();
+ if ( slipstreamCmd.modFileNames != null ) {
+ 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 );
+ return false;
+ }
+ }
+
+ 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() ) return false;
+ return true;
+ }
+
+ private static boolean validate(String[] modFileNames) {
+ DelayedDeleteHook deleteHook = new DelayedDeleteHook();
+ Runtime.getRuntime().addShutdownHook( deleteHook );
+
+ log.info( "Validating..." );
+
+ StringBuilder resultBuf = new StringBuilder();
+ ReportFormatter formatter = new ReportFormatter();
+ boolean anyInvalid = false;
+
+ for ( String modFileName : 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);
+ return !anyInvalid;
+ }
+
+ private static void listMods(SlipstreamConfig appConfig) {
+ 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 );
+ }
+
+ private static boolean runFtl(SlipstreamConfig appConfig, File datsDir) {
+ 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 );
+ return false;
+ }
+ }
+ else {
+ log.error( "No executables were found to launch FTL" );
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * 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");
+ private static File createTempMod( File dir ) throws IOException {
+ File tempFile = File.createTempFile( dir.getName() +"_temp-", ".zip" );
- try (FileOutputStream fos = new FileOutputStream(tempFile)) {
- ZipOutputStream zos = new ZipOutputStream(new BufferedOutputStream(fos));
- addDirToArchive(zos, dir, null);
- zos.close();
- } catch (IOException ignored) {
- }
+ 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 = "";
+ private static void addDirToArchive( ZipOutputStream zos, File dir, String pathPrefix ) throws IOException {
+ if ( pathPrefix == null ) pathPrefix = "";
- for (File f : Objects.requireNonNull(dir.listFiles())) {
- if (f.isDirectory()) {
- addDirToArchive(zos, f, pathPrefix + f.getName() +"/");
+ for ( File f : dir.listFiles() ) {
+ if ( f.isDirectory() ) {
+ addDirToArchive( zos, f, pathPrefix + f.getName() +"/" );
continue;
}
- try (FileInputStream is = new FileInputStream(f)) {
- zos.putNextEntry(new ZipEntry(pathPrefix + f.getName()));
+ 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();
- } catch (IOException ignored) {
- }
- }
- }
-
- @Command(
- name = "list",
- description = "list all available mods"
- )
- public static class ListCommand implements Runnable {
- @Override
- public void run() {
- System.out.println("list command");
- }
- }
-
- @Command(
- name = "patch",
- description = "create a patched binary using the available mods"
- )
- public static class PatchCommand implements Runnable {
- @Parameters(index = "0", description = "the location unpatched binary is located at")
- File binary;
-
- @Parameters(description = "list of mods that the binary will be patched with before it starts. (defaults to all mods if non are provided)", mapFallbackValue = Option.NULL_VALUE)
- String mods;
-
- @Option(names = "--dry", description = "skip the patching step but still run the rest of the application")
- boolean dry;
-
- @Override
- public void run() {
- System.out.println("patch command");
- }
- }
-
- @Command(
- name = "clean",
- description = "remove all patched binaries"
- )
- public static class CleanCommand implements Runnable {
- @Override
- public void run() {
- System.out.println("clean command");
- }
- }
-
- @Command(
- name = "install",
- description = "install the slipstream mod manager"
- )
- public static class InstallCommand implements Runnable {
- @Override
- public void run() {
- System.out.println("install command");
- }
- }
-
- @Command(
- name = "uninstall",
- description = "uninstall the slipstream mod manager"
- )
- public static class UninstallCommand implements Runnable {
- @Override
- public void run() {
- System.out.println("uninstall command");
- }
- }
-
- @Command(
- name = "start",
- abbreviateSynopsis = true,
- sortOptions = false,
- description = "Creates and starts a patched version of FTL.",
- versionProvider = SlipstreamVersionProvider.class
- )
- public static class StartCommand implements Runnable {
- @Option(names = "--version", versionHelp = true, description = "output version information and exit")
- boolean isVersionCommand;
-
- @Option(names = { "--game-folder", "--binary", "--data-folder" }, description = "the location of the game files", converter = GameDirectoryConverter.class, mapFallbackValue = Option.NULL_VALUE)
- GameDirectory gameDirectory;
-
- @Parameters(description = "list of mods that the binary will be patched with before it starts. (defaults to all mods if non are provided)", mapFallbackValue = Option.NULL_VALUE)
- String[] mods;
-
- @Option(names = "--dry", description = "skip the patching step but still run the rest of the application")
- boolean dry;
-
- @Override
- public void run() {
- System.out.println("start command: " + gameDirectory.root.getAbsolutePath());
- }
- }
-
- static class GameDirectoryConverter implements ITypeConverter {
-
- private GameDirectory getGameDirectory(String fileName) {
- if (fileName == null ) {
- // TODO: get this from env variables
- // TODO: if env variable not set check some common places and prompt the user if they want to use that
- return null;
- }
- return GameDirectory.createGameDirectory(new File(fileName));
- }
-
- @Override
- public GameDirectory convert(String fileName) {
- GameDirectory gameDirectory = getGameDirectory(fileName);
- if (gameDirectory == null) {
- System.out.println("Game not found. Please specify the --game-folder argument, or set the FTL_GAME_FOLDER environment variable.");
- System.exit(1);
- }
- return gameDirectory;
- }
- }
-
- static class GameDirectory {
-
- private static final String GAME_FOLDER_NAME = "FTL Faster Than Light";
- private static final String GAME_DATA_FOLDER_NAME = "data";
- private static final String MODS_FOLDER_NAME = "mods";
- private static final String BACKUP_FOLDER_NAME = "backup";
- private static final String LAUNCH_SCRIPT_FILE_NAME = "FTL";
- private static final String X86_BINARY_FILE_NAME = "FTL.x86";
-
- private static boolean isValidGameDirectory(File gameDirectory) {
- if (
- !(
- gameDirectory.exists()
- && gameDirectory.isDirectory()
- && gameDirectory.getName().equals(GAME_FOLDER_NAME)
- )
- ) {
- return false;
- }
-
- File gameDataFolder = new File(gameDirectory, GAME_DATA_FOLDER_NAME);
- if (
- !(
- gameDataFolder.exists()
- && gameDataFolder.isDirectory()
- )
- ) {
- return false;
- }
-
- File gameLaunchScript = new File(gameDataFolder, LAUNCH_SCRIPT_FILE_NAME);
- if (
- !(
- gameLaunchScript.exists()
- && gameLaunchScript.isFile()
- )
- ) {
- return false;
- }
-
- // TODO: deal with FTL.amd64
- File gameBinary = new File(gameDataFolder, X86_BINARY_FILE_NAME);
- return gameBinary.exists() && gameBinary.isFile();
- }
-
- static GameDirectory createGameDirectory(File file) {
- if (file.isFile()) {
- String filename = file.getName();
- if (filename.equals(X86_BINARY_FILE_NAME)) {
- File root = file.getParentFile().getParentFile();
- if (isValidGameDirectory(root)) {
- return new GameDirectory((root));
- }
- }
- }
- if (file.isDirectory()){
- if (isValidGameDirectory(file)) {
- return new GameDirectory(file);
+ byte[] buf = new byte[4096];
+ int len;
+ while ( (len = is.read(buf)) >= 0 ) {
+ zos.write( buf, 0, len );
}
- String directoryName = file.getName();
- if (directoryName.equals(GAME_DATA_FOLDER_NAME)) {
- File root = file.getParentFile();
- if (isValidGameDirectory(root)) {
- return new GameDirectory((root));
- }
- }
+ zos.closeEntry();
+ }
+ finally {
+ try {if ( is != null ) is.close();}
+ catch ( IOException e ) {}
}
- return null;
- }
-
- public final File root;
- public final File dataDir;
- public final File modsDir;
- public final File backupDir;
- public final File launchScript;
- public final File binary;
-
- GameDirectory(File root) {
- this.root = root;
- this.dataDir = new File(root, "data");
- this.modsDir = new File(this.dataDir, "mods");
- this.backupDir = new File(this.dataDir, "backup");
- this.launchScript = new File(this.dataDir, "FTL");
- // TODO: this should probably change when we need to use FTL.amd64 instead
- this.binary = new File(this.dataDir, X86_BINARY_FILE_NAME);
}
}
- @Command(name = "slipstream")
- public static class BaseCommand implements Runnable {
+
+
+ @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 isHelpCommand;
+ boolean helpRequested;
- @Spec
- Model.CommandSpec spec;
-
- @Override
- public void run() {
- // if the command was invoked without subcommand, show the usage help
- spec.commandLine().usage(System.err);
- }
+ @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),
+ 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",
@@ -685,19 +503,19 @@ public class SlipstreamCLI {
private boolean succeeded = false;
@Override
- public void patchingProgress(final int value, final int max) {
+ public void patchingProgress( final int value, final int max ) {
}
@Override
- public void patchingStatus(String message) {
+ public void patchingStatus( String message ) {
}
@Override
- public void patchingMod(File modFile) {
+ public void patchingMod( File modFile ) {
}
@Override
- public synchronized void patchingEnded(boolean outcome, Exception e) {
+ public synchronized void patchingEnded( boolean outcome, Exception e ) {
succeeded = outcome;
done = true;
}
@@ -707,18 +525,26 @@ public class SlipstreamCLI {
}
- private record ModAndDirFileFilter(boolean allowZip, boolean allowDirs) implements FileFilter {
+
+ 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;
+ public boolean accept( File f ) {
+ if ( f.isDirectory() ) return allowDirs;
- if (f.getName().endsWith(".ftl")) return true;
+ if ( f.getName().endsWith(".ftl") ) return true;
- if (allowZip) {
- return f.getName().endsWith(".zip");
- }
- return false;
+ if ( allowZip ) {
+ if ( f.getName().endsWith(".zip") ) return true;
}
+ return false;
}
+ }
}
diff --git a/src/main/java/net/vhati/modmanager/ui/ClipboardMenuMouseListener.java b/src/main/java/net/vhati/modmanager/ui/ClipboardMenuMouseListener.java
new file mode 100644
index 0000000..0a15eec
--- /dev/null
+++ b/src/main/java/net/vhati/modmanager/ui/ClipboardMenuMouseListener.java
@@ -0,0 +1,104 @@
+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.
+ *
+ * Add this listener after any others. Any pressed/released listeners that want
+ * to preempt this menu should call e.consume().
+ */
+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.isConsumed() ) return;
+ if ( e.isPopupTrigger() ) showMenu( e );
+ }
+
+ @Override
+ public void mouseReleased( MouseEvent e ) {
+ if ( e.isConsumed() ) return;
+ 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 );
+
+ e.consume();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/net/vhati/modmanager/ui/CreateModDialog.java b/src/main/java/net/vhati/modmanager/ui/CreateModDialog.java
new file mode 100644
index 0000000..bda5804
--- /dev/null
+++ b/src/main/java/net/vhati/modmanager/ui/CreateModDialog.java
@@ -0,0 +1,213 @@
+package net.vhati.modmanager.ui;
+
+import java.awt.BorderLayout;
+import java.awt.Desktop;
+import java.awt.Dimension;
+import java.awt.Frame;
+import java.awt.Point;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.io.File;
+import java.io.IOException;
+import javax.swing.BorderFactory;
+import javax.swing.Box;
+import javax.swing.BoxLayout;
+import javax.swing.JButton;
+import javax.swing.JDialog;
+import javax.swing.JOptionPane;
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import javax.swing.JTextField;
+import javax.swing.ScrollPaneConstants;
+import javax.swing.event.AncestorEvent;
+import javax.swing.event.AncestorListener;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import net.vhati.modmanager.ui.FieldEditorPanel;
+import net.vhati.modmanager.ui.FieldEditorPanel.ContentType;
+import net.vhati.modmanager.ui.RegexDocument;
+import net.vhati.modmanager.xml.JDOMModMetadataWriter;
+
+
+public class CreateModDialog extends JDialog implements ActionListener {
+
+ private static final Logger log = LoggerFactory.getLogger( CreateModDialog.class );
+
+ protected static final String DIR_NAME = "Directory Name";
+ protected static final String AUDIO_ROOT = "audio/";
+ protected static final String DATA_ROOT = "data/";
+ protected static final String FONTS_ROOT = "fonts/";
+ protected static final String IMG_ROOT = "img/";
+ protected static final String XML_COMMENTS = "XML Comments";
+ protected static final String TITLE = "Title";
+ protected static final String URL = "Thread URL";
+ protected static final String AUTHOR = "Author";
+ protected static final String VERSION = "Version";
+ protected static final String DESC = "Description";
+
+ protected FieldEditorPanel editorPanel;
+ protected JButton applyBtn;
+
+ protected File modsDir;
+
+
+ public CreateModDialog( Frame owner, File modsDir ) {
+ super( owner, "New Mod" );
+ this.setDefaultCloseOperation( JDialog.DISPOSE_ON_CLOSE );
+
+ this.modsDir = modsDir;
+
+ editorPanel = new FieldEditorPanel( false );
+ editorPanel.setBorder( BorderFactory.createEmptyBorder( 10, 10, 0, 10 ) );
+ editorPanel.setNameWidth( 100 );
+ editorPanel.setValueWidth( 350 );
+
+ editorPanel.addRow( DIR_NAME, ContentType.STRING );
+ editorPanel.getString( DIR_NAME ).setDocument( new RegexDocument( "[^\\/:;*?<>|^\"]*" ) );
+ editorPanel.addTextRow( String.format( "The name of a directory to create in the %s/ folder.", modsDir.getName() ) );
+ editorPanel.addSeparatorRow();
+ editorPanel.addRow( AUDIO_ROOT, ContentType.BOOLEAN );
+ editorPanel.addRow( DATA_ROOT, ContentType.BOOLEAN );
+ editorPanel.addRow( FONTS_ROOT, ContentType.BOOLEAN );
+ editorPanel.addRow( IMG_ROOT, ContentType.BOOLEAN );
+ editorPanel.getBoolean( AUDIO_ROOT ).setHorizontalAlignment( javax.swing.SwingConstants.LEFT );
+ editorPanel.getBoolean( DATA_ROOT ).setHorizontalAlignment( javax.swing.SwingConstants.LEFT );
+ editorPanel.getBoolean( FONTS_ROOT ).setHorizontalAlignment( javax.swing.SwingConstants.LEFT );
+ editorPanel.getBoolean( IMG_ROOT ).setHorizontalAlignment( javax.swing.SwingConstants.LEFT );
+ editorPanel.addTextRow( "Create empty top-level directories?" );
+ editorPanel.addSeparatorRow();
+ editorPanel.addRow( XML_COMMENTS, ContentType.BOOLEAN );
+ editorPanel.getBoolean( XML_COMMENTS ).setHorizontalAlignment( javax.swing.SwingConstants.LEFT );
+ editorPanel.addTextRow( "Include XML comments about the purpose of these fields?" );
+ editorPanel.addSeparatorRow();
+ editorPanel.addRow( TITLE, ContentType.STRING );
+ editorPanel.addTextRow( "The title of this mod." );
+ editorPanel.addSeparatorRow();
+ editorPanel.addRow( URL, ContentType.STRING );
+ editorPanel.addTextRow( "This mod's thread on the forum." );
+ editorPanel.addSeparatorRow();
+ editorPanel.addRow( AUTHOR, ContentType.STRING );
+ editorPanel.addTextRow( "Your forum user name." );
+ editorPanel.addSeparatorRow();
+ editorPanel.addRow( VERSION, ContentType.STRING );
+ editorPanel.addTextRow( "The revision/variant of this release, preferably at least a number." );
+ editorPanel.addSeparatorRow();
+ editorPanel.addRow( DESC, ContentType.TEXT_AREA );
+ editorPanel.getTextArea( DESC ).setRows( 15 );
+ editorPanel.addTextRow( "Summary of gameplay effects; flavor; features; concerns about compatibility, recommended patch order, requirements; replaced ship slot; etc." );
+
+ editorPanel.getBoolean( XML_COMMENTS ).setSelected( true );
+
+ JPanel ctrlPanel = new JPanel();
+ ctrlPanel.setLayout( new BoxLayout( ctrlPanel, BoxLayout.X_AXIS ) );
+ ctrlPanel.setBorder( BorderFactory.createEmptyBorder( 10, 0, 10, 0 ) );
+ ctrlPanel.add( Box.createHorizontalGlue() );
+ applyBtn = new JButton( "Generate Mod" );
+ applyBtn.addActionListener( this );
+ ctrlPanel.add( applyBtn );
+ ctrlPanel.add( Box.createHorizontalGlue() );
+
+ final JScrollPane editorScroll = new JScrollPane( editorPanel );
+ editorScroll.setVerticalScrollBarPolicy( ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS );
+ editorScroll.getVerticalScrollBar().setUnitIncrement( 10 );
+ int vbarWidth = editorScroll.getVerticalScrollBar().getPreferredSize().width;
+ editorScroll.setPreferredSize( new Dimension( editorPanel.getPreferredSize().width+vbarWidth+5, 400 ) );
+
+ JPanel contentPane = new JPanel( new BorderLayout() );
+ contentPane.add( editorScroll, BorderLayout.CENTER );
+ contentPane.add( ctrlPanel, BorderLayout.SOUTH );
+ this.setContentPane( contentPane );
+ this.pack();
+ this.setMinimumSize( new Dimension( 250, 250 ) );
+
+
+ editorScroll.addAncestorListener(new AncestorListener() {
+ @Override
+ public void ancestorAdded( AncestorEvent e ) {
+ editorScroll.getViewport().setViewPosition( new Point( 0, 0 ) );
+ }
+ @Override
+ public void ancestorMoved( AncestorEvent e ) {
+ }
+ @Override
+ public void ancestorRemoved( AncestorEvent e ) {
+ }
+ });
+ }
+
+ @Override
+ public void actionPerformed( ActionEvent e ) {
+ Object source = e.getSource();
+
+ if ( source == applyBtn ) {
+ String dirName = editorPanel.getString( DIR_NAME ).getText().trim();
+ String modTitle = editorPanel.getString( TITLE ).getText().trim();
+ String modURL = editorPanel.getString( URL ).getText().trim();
+ String modAuthor = editorPanel.getString( AUTHOR ).getText().trim();
+ String modVersion = editorPanel.getString( VERSION ).getText().trim();
+ String modDesc = editorPanel.getTextArea( DESC ).getText().trim();
+ boolean xmlComments = editorPanel.getBoolean( XML_COMMENTS ).isSelected();
+
+ if ( dirName.length() == 0 ) {
+ JOptionPane.showMessageDialog( CreateModDialog.this, "No directory name was given.", "Nothing to do", JOptionPane.WARNING_MESSAGE );
+ return;
+ }
+
+ File genDir = new File( modsDir, dirName );
+ if ( !genDir.exists() ) {
+ try {
+ // Generate the mod.
+ if ( genDir.mkdir() ) {
+ File appendixDir = new File ( genDir, "mod-appendix" );
+ if ( appendixDir.mkdir() ) {
+ File metadataFile = new File( appendixDir, "metadata.xml" );
+
+ JDOMModMetadataWriter.writeMetadata( metadataFile, modTitle, modURL, modAuthor, modVersion, modDesc, xmlComments );
+ }
+ else {
+ throw new IOException( String.format( "Failed to create directory: %s", appendixDir.getName() ) );
+ }
+ }
+ else {
+ throw new IOException( String.format( "Failed to create directory: %s", genDir.getName() ) );
+ }
+
+ // Create root dirs.
+ if ( editorPanel.getBoolean( AUDIO_ROOT ).isSelected() )
+ new File( genDir, "audio" ).mkdir();
+ if ( editorPanel.getBoolean( DATA_ROOT ).isSelected() )
+ new File( genDir, "data" ).mkdir();
+ if ( editorPanel.getBoolean( FONTS_ROOT ).isSelected() )
+ new File( genDir, "fonts" ).mkdir();
+ if ( editorPanel.getBoolean( IMG_ROOT ).isSelected() )
+ new File( genDir, "img" ).mkdir();
+
+ // Show the folder.
+ try {
+ if ( Desktop.isDesktopSupported() ) {
+ Desktop.getDesktop().open( genDir.getCanonicalFile() );
+ } else {
+ log.error( String.format( "Java cannot open the %s/ folder for you on this OS", genDir.getName() ) );
+ }
+ }
+ catch ( IOException f ) {
+ log.error( String.format( "Error opening %s/ folder", genDir.getName() ), f );
+ }
+
+ // All done.
+ CreateModDialog.this.dispose();
+ }
+ catch ( IOException f ) {
+ log.error( String.format( "Failed to generate new mod: %s", genDir.getName() ), f );
+
+ JOptionPane.showMessageDialog( CreateModDialog.this, String.format( "Failed to generate new mod: %s\n%s", genDir.getName(), f.getMessage() ), "Error", JOptionPane.ERROR_MESSAGE );
+ }
+ }
+ else {
+ JOptionPane.showMessageDialog( CreateModDialog.this, String.format( "A directory named \"%s\" already exists.", genDir.getName() ), "Nothing to do", JOptionPane.WARNING_MESSAGE );
+ }
+ }
+ }
+}
diff --git a/src/main/java/net/vhati/modmanager/ui/DatExtractDialog.java b/src/main/java/net/vhati/modmanager/ui/DatExtractDialog.java
new file mode 100644
index 0000000..eeb4151
--- /dev/null
+++ b/src/main/java/net/vhati/modmanager/ui/DatExtractDialog.java
@@ -0,0 +1,163 @@
+package net.vhati.modmanager.ui;
+
+import java.awt.Frame;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.InputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import javax.swing.JDialog;
+import javax.swing.SwingUtilities;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import net.vhati.ftldat.AbstractPack;
+import net.vhati.ftldat.FolderPack;
+import net.vhati.ftldat.FTLPack;
+import net.vhati.ftldat.PkgPack;
+
+import net.vhati.modmanager.ui.ProgressDialog;
+
+
+public class DatExtractDialog extends ProgressDialog {
+
+ private static final Logger log = LoggerFactory.getLogger( DatExtractDialog.class );
+
+ private boolean started = false;
+
+ private File extractDir = null;
+ private File datsDir = null;
+
+ private DatExtractThread workerThread = null;
+
+
+ public DatExtractDialog( Frame owner, File extractDir, File datsDir ) {
+ super( owner, false );
+ this.setTitle( "Extracting..." );
+
+ this.extractDir = extractDir;
+ this.datsDir = datsDir;
+
+ this.setSize( 400, 160 );
+ this.setMinimumSize( this.getPreferredSize() );
+ this.setLocationRelativeTo( owner );
+
+ workerThread = new DatExtractThread( extractDir, datsDir );
+ }
+
+ /**
+ * Returns the worker thread that does the extracting.
+ *
+ * This method is provided so other classes can customize the thread
+ * before calling extract().
+ */
+ public Thread getWorkerThread() {
+ return workerThread;
+ }
+
+ /**
+ * Starts the background extraction thread.
+ * Call this immediately before setVisible().
+ */
+ public void extract() {
+ if ( started ) return;
+
+ workerThread.start();
+ started = true;
+ }
+
+ @Override
+ protected void setTaskOutcome( boolean outcome, Exception e ) {
+ super.setTaskOutcome( outcome, e );
+ if ( !this.isShowing() ) return;
+
+ if ( succeeded ) {
+ setStatusText( "All resources extracted successfully." );
+ } else {
+ setStatusText( String.format( "Error extracting dats: %s", e ) );
+ }
+ }
+
+
+
+ private class DatExtractThread extends Thread {
+
+ private File extractDir = null;
+ private File datsDir = null;
+
+
+ public DatExtractThread( File extractDir, File datsDir ) {
+ this.extractDir = extractDir;
+ this.datsDir = datsDir;
+ }
+
+ @Override
+ public void run() {
+ AbstractPack dstPack = null;
+ List srcPacks = new ArrayList( 2 );
+ InputStream is = null;
+ int progress = 0;
+
+ 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 ) {
+ progress = 0;
+ List innerPaths = srcPack.list();
+ setProgressLater( progress, innerPaths.size() );
+
+ for ( String innerPath : innerPaths ) {
+ setStatusTextLater( innerPath );
+ 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 );
+ setProgressLater( progress++ );
+ }
+ srcPack.close();
+ }
+ setTaskOutcomeLater( true, null );
+ }
+ catch ( Exception e ) {
+ log.error( "Error extracting dats", e );
+ setTaskOutcomeLater( false, e );
+ }
+ finally {
+ try {if ( is != null ) is.close();}
+ catch ( IOException e ) {}
+
+ try {if ( dstPack != null ) dstPack.close();}
+ catch ( IOException e ) {}
+
+ for ( AbstractPack pack : srcPacks ) {
+ try {pack.close();}
+ catch ( IOException ex ) {}
+ }
+ }
+ }
+ }
+}
diff --git a/src/main/java/net/vhati/modmanager/ui/FieldEditorPanel.java b/src/main/java/net/vhati/modmanager/ui/FieldEditorPanel.java
new file mode 100644
index 0000000..ee4a149
--- /dev/null
+++ b/src/main/java/net/vhati/modmanager/ui/FieldEditorPanel.java
@@ -0,0 +1,457 @@
+package net.vhati.modmanager.ui;
+
+import java.awt.Component;
+import java.awt.Dimension;
+import java.awt.GridBagConstraints;
+import java.awt.GridBagLayout;
+import java.awt.Insets;
+import java.util.HashMap;
+import java.util.Map;
+import javax.swing.BorderFactory;
+import javax.swing.Box;
+import javax.swing.BoxLayout;
+import javax.swing.JButton;
+import javax.swing.JCheckBox;
+import javax.swing.JComboBox;
+import javax.swing.JComponent;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JSeparator;
+import javax.swing.JSlider;
+import javax.swing.JTextArea;
+import javax.swing.JTextField;
+import javax.swing.SwingConstants;
+import javax.swing.UIManager;
+import javax.swing.event.ChangeEvent;
+import javax.swing.event.ChangeListener;
+
+import net.vhati.modmanager.ui.RegexDocument;
+
+
+public class FieldEditorPanel extends JPanel {
+ public enum ContentType { WRAPPED_LABEL, LABEL, STRING, TEXT_AREA, INTEGER, BOOLEAN, SLIDER, COMBO, CHOOSER }
+
+ private Map wrappedLabelMap = new HashMap();
+ private Map labelMap = new HashMap();
+ private Map stringMap = new HashMap();
+ private Map textAreaMap = new HashMap();
+ private Map intMap = new HashMap();
+ private Map boolMap = new HashMap();
+ private Map sliderMap = new HashMap();
+ private Map comboMap = new HashMap();
+ private Map chooserMap = new HashMap();
+ private Map reminderMap = new HashMap();
+
+ private GridBagConstraints gridC = new GridBagConstraints();
+
+ private Component nameStrut = Box.createHorizontalStrut( 1 );
+ private Component valueStrut = Box.createHorizontalStrut( 120 );
+ private Component reminderStrut = Box.createHorizontalStrut( 90 );
+
+ private boolean remindersVisible;
+
+
+ public FieldEditorPanel( boolean remindersVisible ) {
+ super( new GridBagLayout() );
+ this.remindersVisible = remindersVisible;
+
+ gridC.anchor = GridBagConstraints.WEST;
+ gridC.fill = GridBagConstraints.HORIZONTAL;
+ gridC.weightx = 0.0;
+ gridC.weighty = 0.0;
+ gridC.gridwidth = 1;
+ gridC.gridx = 0;
+ gridC.gridy = 0;
+
+ // No default width for col 0.
+ gridC.gridx = 0;
+ this.add( nameStrut, gridC );
+ gridC.gridx++;
+ this.add( valueStrut, gridC );
+ gridC.gridx++;
+ if ( remindersVisible ) {
+ this.add( reminderStrut, gridC );
+ gridC.gridy++;
+ }
+
+ gridC.insets = new Insets( 2, 4, 2, 4 );
+ }
+
+
+ public void setNameWidth( int width ) {
+ nameStrut.setMinimumSize( new Dimension( width, 0 ) );
+ nameStrut.setPreferredSize( new Dimension( width, 0 ) );
+ }
+
+ public void setValueWidth( int width ) {
+ valueStrut.setMinimumSize( new Dimension( width, 0 ) );
+ valueStrut.setPreferredSize( new Dimension( width, 0 ) );
+ }
+
+ public void setReminderWidth( int width ) {
+ reminderStrut.setMinimumSize( new Dimension( width, 0 ) );
+ reminderStrut.setPreferredSize( new Dimension( width, 0 ) );
+ }
+
+
+ /**
+ * Constructs JComponents for a given type of value.
+ * A row consists of a static label, some JComponent,
+ * and a reminder label.
+ *
+ * The component and reminder will be accessable later
+ * via getter methods.
+ */
+ public void addRow( String valueName, ContentType contentType ) {
+ gridC.fill = GridBagConstraints.HORIZONTAL;
+ gridC.gridwidth = 1;
+ gridC.weighty = 0.0;
+ gridC.gridx = 0;
+ this.add( new JLabel( valueName +":" ), gridC );
+
+ gridC.gridx++;
+ if ( contentType == ContentType.WRAPPED_LABEL ) {
+ gridC.anchor = GridBagConstraints.WEST;
+ JTextArea valueArea = new JTextArea();
+ valueArea.setBackground( null );
+ valueArea.setEditable( false );
+ valueArea.setBorder( null );
+ valueArea.setLineWrap( true );
+ valueArea.setWrapStyleWord( true );
+ valueArea.setFocusable( false );
+ valueArea.setFont( UIManager.getFont( "Label.font" ) );
+
+ wrappedLabelMap.put( valueName, valueArea );
+ this.add( valueArea, gridC );
+ }
+ else if ( contentType == ContentType.LABEL ) {
+ gridC.anchor = GridBagConstraints.WEST;
+ JLabel valueLbl = new JLabel();
+ valueLbl.setHorizontalAlignment( SwingConstants.CENTER );
+ labelMap.put( valueName, valueLbl );
+ this.add( valueLbl, gridC );
+ }
+ else if ( contentType == ContentType.STRING ) {
+ gridC.anchor = GridBagConstraints.WEST;
+ JTextField valueField = new JTextField();
+ stringMap.put( valueName, valueField );
+ this.add( valueField, gridC );
+ }
+ else if ( contentType == ContentType.TEXT_AREA ) {
+ gridC.anchor = GridBagConstraints.WEST;
+ JTextArea valueArea = new JTextArea();
+ valueArea.setEditable( true );
+ valueArea.setBorder( BorderFactory.createCompoundBorder( BorderFactory.createEtchedBorder(), BorderFactory.createEmptyBorder( 2, 2, 2, 2 ) ) );
+ valueArea.setLineWrap( true );
+ valueArea.setWrapStyleWord( true );
+ valueArea.setFocusable( true );
+ valueArea.setFont( UIManager.getFont( "TextField.font" ) ); // Override small default font on systemLaf.
+
+ textAreaMap.put( valueName, valueArea );
+ this.add( valueArea, gridC );
+ }
+ else if ( contentType == ContentType.INTEGER ) {
+ gridC.anchor = GridBagConstraints.WEST;
+ JTextField valueField = new JTextField();
+ valueField.setHorizontalAlignment( JTextField.RIGHT );
+ valueField.setDocument( new RegexDocument( "[0-9]*" ) );
+ intMap.put( valueName, valueField );
+ this.add( valueField, gridC );
+ }
+ else if ( contentType == ContentType.BOOLEAN ) {
+ gridC.anchor = GridBagConstraints.CENTER;
+ JCheckBox valueCheck = new JCheckBox();
+ valueCheck.setHorizontalAlignment( SwingConstants.CENTER );
+ boolMap.put( valueName, valueCheck );
+ this.add( valueCheck, gridC );
+ }
+ else if ( contentType == ContentType.SLIDER ) {
+ gridC.anchor = GridBagConstraints.CENTER;
+ JPanel panel = new JPanel();
+ panel.setLayout( new BoxLayout( panel, BoxLayout.X_AXIS ) );
+ final JSlider valueSlider = new JSlider( JSlider.HORIZONTAL );
+ valueSlider.setPreferredSize( new Dimension( 50, valueSlider.getPreferredSize().height ) );
+ sliderMap.put( valueName, valueSlider );
+ panel.add( valueSlider );
+ final JTextField valueField = new JTextField( 3 );
+ valueField.setMaximumSize( valueField.getPreferredSize() );
+ valueField.setHorizontalAlignment( JTextField.RIGHT );
+ valueField.setEditable( false );
+ panel.add( valueField );
+ this.add( panel, gridC );
+
+ valueSlider.addChangeListener(new ChangeListener() {
+ @Override
+ public void stateChanged( ChangeEvent e ) {
+ valueField.setText( ""+valueSlider.getValue() );
+ }
+ });
+ }
+ else if ( contentType == ContentType.COMBO ) {
+ gridC.anchor = GridBagConstraints.CENTER;
+ JComboBox valueCombo = new JComboBox();
+ valueCombo.setEditable( false );
+ comboMap.put( valueName, valueCombo );
+ this.add( valueCombo, gridC );
+ }
+ else if ( contentType == ContentType.CHOOSER ) {
+ gridC.anchor = GridBagConstraints.WEST;
+ JPanel panel = new JPanel();
+ panel.setLayout( new BoxLayout( panel, BoxLayout.X_AXIS ) );
+
+ JTextField chooserField = new JTextField();
+ panel.add( chooserField );
+ panel.add( Box.createHorizontalStrut( 5 ) );
+ JButton chooserBtn = new JButton( "..." );
+ chooserBtn.setMargin( new Insets( 1,2,1,2 ) );
+ panel.add( chooserBtn );
+ Chooser valueChooser = new Chooser( chooserField, chooserBtn );
+ chooserMap.put( valueName, valueChooser );
+
+ this.add( panel, gridC );
+ }
+ gridC.gridx++;
+
+ if ( remindersVisible ) {
+ gridC.anchor = GridBagConstraints.WEST;
+ JLabel valueReminder = new JLabel();
+ reminderMap.put( valueName, valueReminder );
+ this.add( valueReminder, gridC );
+ }
+
+ gridC.gridy++;
+ }
+
+ public void addTextRow( String text ) {
+ gridC.fill = GridBagConstraints.HORIZONTAL;
+ gridC.weighty = 0.0;
+ gridC.gridwidth = GridBagConstraints.REMAINDER;
+ gridC.gridx = 0;
+
+ gridC.anchor = GridBagConstraints.WEST;
+ JTextArea textArea = new JTextArea( text );
+ textArea.setBackground( null );
+ textArea.setEditable( false );
+ textArea.setBorder( null );
+ textArea.setLineWrap( true );
+ textArea.setWrapStyleWord( true );
+ textArea.setFocusable( false );
+ textArea.setFont( UIManager.getFont( "Label.font" ) );
+
+ this.add( textArea, gridC );
+ gridC.gridy++;
+ }
+
+ public void addSeparatorRow() {
+ gridC.fill = GridBagConstraints.HORIZONTAL;
+ gridC.weighty = 0.0;
+ gridC.gridwidth = GridBagConstraints.REMAINDER;
+ gridC.gridx = 0;
+
+ JPanel panel = new JPanel();
+ panel.setLayout( new BoxLayout( panel, BoxLayout.Y_AXIS ) );
+ panel.add( Box.createVerticalStrut( 8 ) );
+ JSeparator sep = new JSeparator();
+ sep.setPreferredSize( new Dimension( 1, sep.getPreferredSize().height ) );
+ panel.add( sep );
+ panel.add( Box.createVerticalStrut( 8 ) );
+
+ this.add( panel, gridC );
+ gridC.gridy++;
+ }
+
+ public void addBlankRow() {
+ gridC.fill = GridBagConstraints.NONE;
+ gridC.weighty = 0.0;
+ gridC.gridwidth = GridBagConstraints.REMAINDER;
+ gridC.gridx = 0;
+
+ this.add( Box.createVerticalStrut( 12 ), gridC );
+ gridC.gridy++;
+ }
+
+ public void addFillRow() {
+ gridC.fill = GridBagConstraints.VERTICAL;
+ gridC.weighty = 1.0;
+ gridC.gridwidth = GridBagConstraints.REMAINDER;
+ gridC.gridx = 0;
+
+ this.add( Box.createVerticalGlue(), gridC );
+ gridC.gridy++;
+ }
+
+
+ public void setStringAndReminder( String valueName, String s ) {
+ JTextField valueField = stringMap.get( valueName );
+ if ( valueField != null ) valueField.setText( s );
+ if ( remindersVisible ) setReminder( valueName, s );
+ }
+
+ public void setIntAndReminder( String valueName, int n ) {
+ setIntAndReminder( valueName, n, ""+n );
+ }
+ public void setIntAndReminder( String valueName, int n, String s ) {
+ JTextField valueField = intMap.get( valueName );
+ if ( valueField != null ) valueField.setText( ""+n );
+ if ( remindersVisible ) setReminder( valueName, s );
+ }
+
+ public void setBoolAndReminder( String valueName, boolean b ) {
+ setBoolAndReminder( valueName, b, ""+b );
+ }
+ public void setBoolAndReminder( String valueName, boolean b, String s ) {
+ JCheckBox valueCheck = boolMap.get( valueName );
+ if ( valueCheck != null ) valueCheck.setSelected( b );
+ if ( remindersVisible ) setReminder( valueName, s );
+ }
+
+ public void setSliderAndReminder( String valueName, int n ) {
+ setSliderAndReminder( valueName, n, ""+n );
+ }
+ public void setSliderAndReminder( String valueName, int n, String s ) {
+ JSlider valueSlider = sliderMap.get( valueName );
+ if ( valueSlider != null ) valueSlider.setValue( n );
+ if ( remindersVisible ) setReminder( valueName, s );
+ }
+
+ public void setComboAndReminder( String valueName, Object o ) {
+ setComboAndReminder( valueName, o, o.toString() );
+ }
+ public void setComboAndReminder( String valueName, Object o, String s ) {
+ JComboBox valueCombo = comboMap.get( valueName );
+ if ( valueCombo != null ) valueCombo.setSelectedItem( o );
+ if ( remindersVisible ) setReminder( valueName, s );
+ }
+
+ public void setChooserAndReminder( String valueName, String s ) {
+ Chooser valueChooser = chooserMap.get( valueName );
+ if ( valueChooser != null ) valueChooser.getTextField().setText( s );
+ if ( remindersVisible ) setReminder( valueName, s );
+ }
+
+ public void setReminder( String valueName, String s ) {
+ JLabel valueReminder = reminderMap.get( valueName );
+ if ( valueReminder != null ) valueReminder.setText( "( "+ s +" )" );
+ }
+
+ public JTextArea getWrappedLabel( String valueName ) {
+ return wrappedLabelMap.get( valueName );
+ }
+
+ public JLabel getLabel( String valueName ) {
+ return labelMap.get( valueName );
+ }
+
+ public JTextField getString( String valueName ) {
+ return stringMap.get( valueName );
+ }
+
+ public JTextArea getTextArea( String valueName ) {
+ return textAreaMap.get( valueName );
+ }
+
+ public JTextField getInt( String valueName ) {
+ return intMap.get( valueName );
+ }
+
+ public JCheckBox getBoolean( String valueName ) {
+ return boolMap.get( valueName );
+ }
+
+ public JSlider getSlider( String valueName ) {
+ return sliderMap.get( valueName );
+ }
+
+ public JComboBox getCombo( String valueName ) {
+ return comboMap.get( valueName );
+ }
+
+ public Chooser getChooser( String valueName ) {
+ return chooserMap.get( valueName );
+ }
+
+
+ public void reset() {
+ for ( JTextArea valueArea : wrappedLabelMap.values() )
+ valueArea.setText( "" );
+
+ for ( JLabel valueLbl : labelMap.values() )
+ valueLbl.setText( "" );
+
+ for ( JTextField valueField : stringMap.values() )
+ valueField.setText( "" );
+
+ for ( JTextArea valueArea : textAreaMap.values() )
+ valueArea.setText( "" );
+
+ for ( JTextField valueField : intMap.values() )
+ valueField.setText( "" );
+
+ for ( JCheckBox valueCheck : boolMap.values() )
+ valueCheck.setSelected( false );
+
+ for ( JSlider valueSlider : sliderMap.values() )
+ valueSlider.setValue( 0 );
+
+ for ( JComboBox valueCombo : comboMap.values() )
+ valueCombo.removeAllItems();
+
+ for ( Chooser valueChooser : chooserMap.values() )
+ valueChooser.getTextField().setText( "" );
+
+ for ( JLabel valueReminder : reminderMap.values() )
+ valueReminder.setText( "" );
+ }
+
+ @Override
+ public void removeAll() {
+ wrappedLabelMap.clear();
+ labelMap.clear();
+ stringMap.clear();
+ textAreaMap.clear();
+ intMap.clear();
+ boolMap.clear();
+ sliderMap.clear();
+ comboMap.clear();
+ chooserMap.clear();
+ reminderMap.clear();
+ super.removeAll();
+ gridC = new GridBagConstraints();
+
+ gridC.anchor = GridBagConstraints.WEST;
+ gridC.fill = GridBagConstraints.HORIZONTAL;
+ gridC.weightx = 0.0;
+ gridC.weighty = 0.0;
+ gridC.gridwidth = 1;
+ gridC.gridx = 0;
+ gridC.gridy = 0;
+
+ // No default width for col 0.
+ gridC.gridx = 0;
+ this.add( Box.createVerticalStrut( 1 ), gridC );
+ gridC.gridx++;
+ this.add( valueStrut, gridC );
+ gridC.gridx++;
+ if ( remindersVisible ) {
+ this.add( reminderStrut, gridC );
+ gridC.gridy++;
+ }
+
+ gridC.insets = new Insets( 2, 4, 2, 4 );
+ }
+
+
+
+ public static class Chooser {
+ private JTextField textField;
+ private JButton button;
+
+ public Chooser( JTextField textField, JButton button ) {
+ this.textField = textField;
+ this.button = button;
+ }
+
+ public JTextField getTextField() { return textField; }
+ public JButton getButton() { return button; }
+ }
+}
diff --git a/src/main/java/net/vhati/modmanager/ui/InertPanel.java b/src/main/java/net/vhati/modmanager/ui/InertPanel.java
new file mode 100644
index 0000000..df73ec0
--- /dev/null
+++ b/src/main/java/net/vhati/modmanager/ui/InertPanel.java
@@ -0,0 +1,62 @@
+package net.vhati.modmanager.ui;
+
+import java.awt.Component;
+import java.awt.Cursor;
+import java.awt.KeyEventDispatcher;
+import java.awt.KeyboardFocusManager;
+import java.awt.Window;
+import java.awt.event.KeyAdapter;
+import java.awt.event.KeyEvent;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import javax.swing.JFrame;
+import javax.swing.JPanel;
+import javax.swing.SwingUtilities;
+
+
+/**
+ * A panel that consumes all mouse/keyboard events, for use as a glass pane.
+ */
+public class InertPanel extends JPanel {
+
+ private KeyEventDispatcher nullDispatcher;
+
+
+ public InertPanel() {
+ super();
+
+ nullDispatcher = new KeyEventDispatcher() {
+ @Override
+ public boolean dispatchKeyEvent( KeyEvent e ) {
+ Object source = e.getSource();
+ if ( source instanceof Component == false ) return false;
+
+ Window ancestor = SwingUtilities.getWindowAncestor( (Component)source );
+ if ( ancestor instanceof JFrame == false ) return false;
+
+ return ( InertPanel.this == ((JFrame)ancestor).getGlassPane() );
+ }
+ };
+
+ this.addMouseListener(new MouseAdapter() {
+ @Override
+ public void mousePressed( MouseEvent e ) {e.consume();}
+ @Override
+ public void mouseReleased( MouseEvent e ) {e.consume();}
+ });
+
+ this.setCursor(Cursor.getPredefinedCursor( Cursor.WAIT_CURSOR ));
+ this.setOpaque( false );
+ }
+
+
+ @Override
+ public void setVisible( boolean b ) {
+ super.setVisible( b );
+ if ( b ) {
+ KeyboardFocusManager.getCurrentKeyboardFocusManager().addKeyEventDispatcher( nullDispatcher );
+ } else {
+ KeyboardFocusManager.getCurrentKeyboardFocusManager().removeKeyEventDispatcher( nullDispatcher );
+ }
+ }
+}
diff --git a/src/main/java/net/vhati/modmanager/ui/ManagerFrame.java b/src/main/java/net/vhati/modmanager/ui/ManagerFrame.java
new file mode 100644
index 0000000..742e6f7
--- /dev/null
+++ b/src/main/java/net/vhati/modmanager/ui/ManagerFrame.java
@@ -0,0 +1,1171 @@
+package net.vhati.modmanager.ui;
+
+import java.awt.BorderLayout;
+import java.awt.Color;
+import java.awt.Component;
+import java.awt.Desktop;
+import java.awt.Dimension;
+import java.awt.Insets;
+import java.awt.Rectangle;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.awt.event.KeyEvent;
+import java.awt.event.WindowAdapter;
+import java.awt.event.WindowEvent;
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileFilter;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.nio.charset.Charset;
+import java.text.SimpleDateFormat;
+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.Map;
+import java.util.concurrent.locks.Condition;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import javax.swing.BorderFactory;
+import javax.swing.BoxLayout;
+import javax.swing.JButton;
+import javax.swing.JFileChooser;
+import javax.swing.JFrame;
+import javax.swing.JLabel;
+import javax.swing.JMenu;
+import javax.swing.JMenuBar;
+import javax.swing.JMenuItem;
+import javax.swing.JOptionPane;
+import javax.swing.JPanel;
+import javax.swing.JSeparator;
+import javax.swing.JSplitPane;
+import javax.swing.JTable;
+import javax.swing.JTextArea;
+import javax.swing.SwingUtilities;
+import javax.swing.UIManager;
+import javax.swing.event.ListSelectionEvent;
+import javax.swing.event.ListSelectionListener;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import net.vhati.modmanager.core.AutoUpdateInfo;
+import net.vhati.modmanager.core.ComparableVersion;
+import net.vhati.modmanager.core.FTLUtilities;
+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.ModsScanObserver;
+import net.vhati.modmanager.core.ModsScanThread;
+import net.vhati.modmanager.core.ModUtilities;
+import net.vhati.modmanager.core.Report;
+import net.vhati.modmanager.core.Report.ReportFormatter;
+import net.vhati.modmanager.core.SlipstreamConfig;
+import net.vhati.modmanager.json.JacksonCatalogWriter;
+import net.vhati.modmanager.json.URLFetcher;
+import net.vhati.modmanager.ui.InertPanel;
+import net.vhati.modmanager.ui.ManagerInitThread;
+import net.vhati.modmanager.ui.ModInfoArea;
+import net.vhati.modmanager.ui.ModPatchDialog;
+import net.vhati.modmanager.ui.ModXMLSandbox;
+import net.vhati.modmanager.ui.SlipstreamConfigDialog;
+import net.vhati.modmanager.ui.Statusbar;
+import net.vhati.modmanager.ui.StatusbarMouseListener;
+import net.vhati.modmanager.ui.table.ChecklistTablePanel;
+import net.vhati.modmanager.ui.table.ListState;
+
+
+public class ManagerFrame extends JFrame implements ActionListener, ModsScanObserver, Nerfable, Statusbar, Thread.UncaughtExceptionHandler {
+
+ private static final Logger log = LoggerFactory.getLogger( ManagerFrame.class );
+
+ public static final String CATALOG_URL = "https://raw.github.com/Vhati/Slipstream-Mod-Manager/master/skel_common/backup/current_catalog.json";
+ public static final String APP_UPDATE_URL = "https://raw.github.com/Vhati/Slipstream-Mod-Manager/master/skel_common/backup/auto_update.json";
+
+ private File backupDir = new File( "./backup/" );
+ private File modsDir = new File( "./mods/" );
+
+ private File modsTableStateFile = new File( modsDir, "modorder.txt" );
+
+ private File metadataFile = new File( backupDir, "cached_metadata.json" );
+
+ private File catalogFile = new File( backupDir, "current_catalog.json" );
+ private File catalogETagFile = new File( backupDir, "current_catalog_etag.txt" );
+
+ private File appUpdateFile = new File( backupDir, "auto_update.json" );
+ private File appUpdateETagFile = new File( backupDir, "auto_update_etag.txt" );
+
+ private boolean disposeNormally = true;
+ private boolean ranInit = false;
+ private Thread.UncaughtExceptionHandler previousUncaughtExceptionHandler = null;
+
+ private final Lock managerLock = new ReentrantLock();
+ private final Condition scanEndedCond = managerLock.newCondition();
+ private boolean scanning = false;
+
+ private SlipstreamConfig appConfig;
+ private String appName;
+ private ComparableVersion appVersion;
+ private String appURL;
+ private String appAuthor;
+
+ private Map modFileHashes = new HashMap();
+ private Map modFileDates = new HashMap();
+ private ModDB catalogModDB = new ModDB();
+ private ModDB localModDB = new ModDB();
+
+ private AutoUpdateInfo appUpdateInfo = null;
+ private Color updateBtnDisabledColor = UIManager.getColor( "Button.foreground" );
+ private Color updateBtnEnabledColor = new Color( 0, 124, 0 );
+
+ private NerfListener nerfListener = new NerfListener( this );
+
+ private ChecklistTablePanel modsTablePanel;
+
+ private JMenuBar menubar;
+ private JMenu fileMenu;
+ private JMenuItem rescanMenuItem;
+ private JMenuItem extractDatsMenuItem;
+ private JMenuItem createModMenuItem;
+ private JMenuItem sandboxMenuItem;
+ private JMenuItem configMenuItem;
+ private JMenuItem exitMenuItem;
+ private JMenu helpMenu;
+ private JMenuItem deleteBackupsMenuItem;
+ private JMenuItem steamVerifyIntegrityMenuItem;
+ private JMenuItem aboutMenuItem;
+
+ private JButton patchBtn;
+ private JButton toggleAllBtn;
+ private JButton validateBtn;
+ private JButton modsFolderBtn;
+ private JButton updateBtn;
+ private JSplitPane splitPane;
+ private ModInfoArea infoArea;
+
+ private JLabel statusLbl;
+
+
+ public ManagerFrame( SlipstreamConfig appConfig, String appName, ComparableVersion appVersion, String appURL, String appAuthor ) {
+ super();
+ this.appConfig = appConfig;
+ this.appName = appName;
+ this.appVersion = appVersion;
+ this.appURL = appURL;
+ this.appAuthor = appAuthor;
+
+ this.setTitle( String.format( "%s v%s", appName, appVersion ) );
+ this.setDefaultCloseOperation( JFrame.DO_NOTHING_ON_CLOSE );
+
+ JPanel contentPane = new JPanel( new BorderLayout() );
+
+ JPanel mainPane = new JPanel( new BorderLayout() );
+ contentPane.add( mainPane, BorderLayout.CENTER );
+
+ JPanel topPanel = new JPanel( new BorderLayout() );
+
+ modsTablePanel = new ChecklistTablePanel();
+ topPanel.add( modsTablePanel, BorderLayout.CENTER );
+
+ JPanel modActionsPanel = new JPanel();
+ modActionsPanel.setLayout( new BoxLayout( modActionsPanel, BoxLayout.Y_AXIS ) );
+ modActionsPanel.setBorder( BorderFactory.createEmptyBorder( 5,5,5,5 ) );
+ 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. Or revert to vanilla, if none are." ) );
+ 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 );
+
+ modsFolderBtn = new JButton( "Open mods/" );
+ modsFolderBtn.setMargin( actionInsets );
+ modsFolderBtn.addMouseListener( new StatusbarMouseListener( this, String.format( "Open the %s/ folder.", modsDir.getName() ) ) );
+ modsFolderBtn.addActionListener( this );
+ modsFolderBtn.setEnabled( Desktop.isDesktopSupported() );
+ modActionsPanel.add( modsFolderBtn );
+
+ updateBtn = new JButton( "Update" );
+ updateBtn.setMargin( actionInsets );
+ updateBtn.addMouseListener( new StatusbarMouseListener( this, String.format( "Show info about the latest version of %s.", appName ) ) );
+ updateBtn.addActionListener( this );
+ updateBtn.setForeground( updateBtnDisabledColor );
+ updateBtn.setEnabled( false );
+ modActionsPanel.add( updateBtn );
+
+ topPanel.add( modActionsPanel, BorderLayout.EAST );
+
+ JButton[] actionBtns = new JButton[] {patchBtn, toggleAllBtn, validateBtn, modsFolderBtn, updateBtn };
+ 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 );
+ }
+
+ infoArea = new ModInfoArea();
+ infoArea.setPreferredSize( new Dimension( 504, 220 ) );
+ infoArea.setStatusbar( this );
+
+ splitPane = new JSplitPane( JSplitPane.VERTICAL_SPLIT );
+ splitPane.setTopComponent( topPanel );
+ splitPane.setBottomComponent( infoArea );
+ mainPane.add( splitPane, 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 );
+
+
+ this.addWindowListener(new WindowAdapter() {
+ @Override
+ public void windowClosing( WindowEvent e ) {
+ // The close button was clicked.
+
+ // This is where an "Are you sure?" popup could go.
+ ManagerFrame.this.setVisible( false );
+ ManagerFrame.this.dispose();
+
+ // The following would also trigger this callback.
+ //Window w = ...;
+ //w.getToolkit().getSystemEventQueue().postEvent( new WindowEvent(w, WindowEvent.WINDOW_CLOSING) );
+ }
+
+ @Override
+ public void windowClosed( WindowEvent e ) {
+ // dispose() was called.
+
+ // Restore the previous exception handler.
+ if ( ranInit ) Thread.setDefaultUncaughtExceptionHandler( previousUncaughtExceptionHandler );
+
+ if ( !disposeNormally ) return; // Something bad happened. Exit quickly.
+
+ ListState tableState = getCurrentModsTableState();
+ saveModsTableState( tableState );
+
+ SlipstreamConfig appConfig = ManagerFrame.this.appConfig;
+
+ if ( appConfig.getProperty( SlipstreamConfig.REMEMBER_GEOMETRY ).equals( "true" ) ) {
+ if ( ManagerFrame.this.getExtendedState() == JFrame.NORMAL ) {
+ Rectangle managerBounds = ManagerFrame.this.getBounds();
+ int dividerLoc = splitPane.getDividerLocation();
+ String geometry = String.format( "x,%d;y,%d;w,%d;h,%d;divider,%d", managerBounds.x, managerBounds.y, managerBounds.width, managerBounds.height, dividerLoc );
+ appConfig.setProperty( SlipstreamConfig.MANAGER_GEOMETRY, geometry );
+ }
+ }
+
+ try {
+ appConfig.writeConfig();
+ }
+ catch ( IOException f ) {
+ log.error( String.format( "Error writing config to \"%s\"", appConfig.getConfigFile().getName() ), f );
+ }
+
+ try {
+ JacksonCatalogWriter.write( localModDB.getCollatedModInfo(), metadataFile );
+ }
+ catch ( IOException f ) {
+ log.error( String.format( "Error writing metadata from local mods to \"%s\"", metadataFile.getName() ), f );
+ }
+
+ System.gc();
+ //System.exit( 0 ); // Don't do this (InterruptedException). Let EDT end gracefully.
+ }
+ });
+
+ // Highlighted row shows mod info.
+ modsTablePanel.getTable().getSelectionModel().addListSelectionListener(new ListSelectionListener() {
+ @Override
+ public void valueChanged( ListSelectionEvent e ) {
+ if ( e.getValueIsAdjusting() ) return;
+
+ int row = modsTablePanel.getTable().getSelectedRow();
+ if ( row == -1 ) return;
+
+ ModFileInfo modFileInfo = modsTablePanel.getTableModel().getItem( row );
+ showLocalModInfo( modFileInfo );
+ }
+ });
+
+ menubar = new JMenuBar();
+ fileMenu = new JMenu( "File" );
+ fileMenu.setMnemonic( KeyEvent.VK_F );
+ rescanMenuItem = new JMenuItem( "Re-Scan mods/" );
+ rescanMenuItem.addMouseListener( new StatusbarMouseListener( this, "Check the mods/ folder for new files." ) );
+ rescanMenuItem.addActionListener( this );
+ fileMenu.add( rescanMenuItem );
+ extractDatsMenuItem = new JMenuItem( "Extract Dats..." );
+ extractDatsMenuItem.addMouseListener( new StatusbarMouseListener( this, "Extract FTL resources into a folder." ) );
+ extractDatsMenuItem.addActionListener( this );
+ fileMenu.add( extractDatsMenuItem );
+ fileMenu.add( new JSeparator() );
+ createModMenuItem = new JMenuItem( "New Mod..." );
+ createModMenuItem.addMouseListener( new StatusbarMouseListener( this, "Generate boilerplace for a new mod." ) );
+ createModMenuItem.addActionListener( this );
+ fileMenu.add( createModMenuItem );
+ sandboxMenuItem = new JMenuItem( "XML Sandbox..." );
+ sandboxMenuItem.addMouseListener( new StatusbarMouseListener( this, "Experiment with advanced mod syntax." ) );
+ sandboxMenuItem.addActionListener( this );
+ fileMenu.add( sandboxMenuItem );
+ fileMenu.add( new JSeparator() );
+ configMenuItem = new JMenuItem( "Preferences..." );
+ configMenuItem.addMouseListener( new StatusbarMouseListener( this, "Edit preferences." ) );
+ configMenuItem.addActionListener( this );
+ fileMenu.add( configMenuItem );
+ fileMenu.add( new JSeparator() );
+ exitMenuItem = new JMenuItem( "Exit" );
+ exitMenuItem.addMouseListener( new StatusbarMouseListener( this, "Exit this application." ) );
+ exitMenuItem.addActionListener( this );
+ fileMenu.add( exitMenuItem );
+ menubar.add( fileMenu );
+ helpMenu = new JMenu( "Help" );
+ helpMenu.setMnemonic( KeyEvent.VK_H );
+ deleteBackupsMenuItem = new JMenuItem( "Delete Backups" );
+ deleteBackupsMenuItem.addMouseListener( new StatusbarMouseListener( this, "Delete backed up resources." ) );
+ deleteBackupsMenuItem.addActionListener( this );
+ helpMenu.add( deleteBackupsMenuItem );
+ steamVerifyIntegrityMenuItem = new JMenuItem( "Steam: Verify integrity of game files" );
+ steamVerifyIntegrityMenuItem.addMouseListener( new StatusbarMouseListener( this, "Tell Steam to 'Verify integrity of game files'." ) );
+ steamVerifyIntegrityMenuItem.addActionListener( this );
+ helpMenu.add( steamVerifyIntegrityMenuItem );
+ helpMenu.add( new JSeparator() );
+ aboutMenuItem = new JMenuItem( "About" );
+ aboutMenuItem.addMouseListener( new StatusbarMouseListener( this, "Show info about this application." ) );
+ aboutMenuItem.addActionListener( this );
+ helpMenu.add( aboutMenuItem );
+ menubar.add( helpMenu );
+ this.setJMenuBar( menubar );
+
+ this.setGlassPane( new InertPanel() );
+
+ this.setContentPane( contentPane );
+ this.pack();
+ this.setMinimumSize( new Dimension( 300, modActionsPanel.getPreferredSize().height+90 ) );
+ this.setLocationRelativeTo( null );
+
+ if ( appConfig.getProperty( SlipstreamConfig.REMEMBER_GEOMETRY ).equals( "true" ) )
+ setGeometryFromConfig();
+
+ showAboutInfo();
+ }
+
+ private void setGeometryFromConfig() {
+ String geometry = appConfig.getProperty( SlipstreamConfig.MANAGER_GEOMETRY );
+ if ( geometry != null ) {
+ int[] xywh = new int[4];
+ int dividerLoc = -1;
+ Matcher m = Pattern.compile( "([^;,]+),(\\d+)" ).matcher( geometry );
+ while ( m.find() ) {
+ if ( m.group( 1 ).equals( "x" ) )
+ xywh[0] = Integer.parseInt( m.group( 2 ) );
+ else if ( m.group( 1 ).equals( "y" ) )
+ xywh[1] = Integer.parseInt( m.group( 2 ) );
+ else if ( m.group( 1 ).equals( "w" ) )
+ xywh[2] = Integer.parseInt( m.group( 2 ) );
+ else if ( m.group( 1 ).equals( "h" ) )
+ xywh[3] = Integer.parseInt( m.group( 2 ) );
+ else if ( m.group( 1 ).equals( "divider" ) )
+ dividerLoc = Integer.parseInt( m.group( 2 ) );
+ }
+ boolean badGeometry = false;
+ for ( int n : xywh ) {
+ if ( n <= 0 ) {
+ badGeometry = true;
+ break;
+ }
+ }
+ if ( !badGeometry && dividerLoc > 0 ) {
+ Rectangle newBounds = new Rectangle( xywh[0], xywh[1], xywh[2], xywh[3] );
+ ManagerFrame.this.setBounds( newBounds );
+ splitPane.setDividerLocation( dividerLoc );
+ }
+ }
+ }
+
+ /**
+ * Extra one-time initialization that must be called after the constructor.
+ */
+ public void init() {
+ if ( ranInit ) return;
+ ranInit = true;
+
+ previousUncaughtExceptionHandler = Thread.getDefaultUncaughtExceptionHandler();
+ Thread.setDefaultUncaughtExceptionHandler( this );
+
+ ManagerInitThread initThread = new ManagerInitThread(
+ this,
+ new SlipstreamConfig( appConfig ),
+ modsDir,
+ modsTableStateFile,
+ metadataFile,
+ catalogFile,
+ catalogETagFile,
+ appUpdateFile,
+ appUpdateETagFile
+ );
+ initThread.setDaemon( true );
+ initThread.setPriority( Thread.MIN_PRIORITY );
+ initThread.start();
+ }
+
+
+ /**
+ * Returns a ListState describing content in the mods table.
+ */
+ public ListState getCurrentModsTableState() {
+ ListState tableState = new ListState();
+
+ for ( ModFileInfo modFileInfo : modsTablePanel.getAllItems() ) {
+ tableState.addItem( modFileInfo );
+ }
+
+ return tableState;
+ }
+
+ /**
+ * Synchronizes a mods table state with a pool of available items.
+ *
+ * Items in the table that also are in the pool are unchanged.
+ * Items in the table that aren't in the pool are pruned.
+ * Items in the pool that weren't in the table are appended in ascending
+ * order.
+ *
+ * @param tableState an existing state to amend
+ * @param unsortedMods the pool of currently available local mods
+ */
+ public void amendModsTableState( ListState tableState, List unsortedMods ) {
+ List availableMods = new ArrayList( unsortedMods );
+ Collections.sort( availableMods );
+
+ for ( ModFileInfo modFileInfo : availableMods ) {
+ if ( !tableState.containsItem( modFileInfo ) ) {
+ tableState.addItem( modFileInfo );
+ }
+ }
+ for ( ModFileInfo modFileInfo : tableState.getItems() ) {
+ if ( !availableMods.contains( modFileInfo ) ) {
+ tableState.removeItem( modFileInfo );
+ }
+ }
+ }
+
+
+ private void saveModsTableState( ListState tableState ) {
+ BufferedWriter bw = null;
+ try {
+ FileOutputStream os = new FileOutputStream( modsTableStateFile );
+ bw = new BufferedWriter(new OutputStreamWriter( os, Charset.forName( "UTF-8" ) ));
+
+ for ( ModFileInfo modFileInfo : tableState.getItems() ) {
+ bw.write( modFileInfo.getFile().getName() );
+ bw.write( "\r\n" );
+ }
+ bw.flush();
+ }
+ catch ( IOException e ) {
+ log.error( String.format( "Error writing \"%s\"", modsTableStateFile.getName() ), e );
+ }
+ finally {
+ try {if ( bw != null ) bw.close();}
+ catch (Exception e) {}
+ }
+ }
+
+
+ /**
+ * Clears and syncs the mods list with mods/ dir, then starts a new hash
+ * thread.
+ */
+ public void rescanMods( ListState tableState ) {
+ managerLock.lock();
+ try {
+ if ( scanning ) return;
+ scanning = true;
+ rescanMenuItem.setEnabled( !scanning );
+ }
+ finally {
+ managerLock.unlock();
+ }
+
+ modFileHashes.clear();
+ modsTablePanel.clear();
+
+ boolean allowZip = appConfig.getProperty( SlipstreamConfig.ALLOW_ZIP, "false" ).equals( "true" );
+ File[] modFiles = modsDir.listFiles( new ModFileFilter( allowZip ) );
+
+ List unsortedMods = new ArrayList();
+ for ( File f : modFiles ) {
+ ModFileInfo modFileInfo = new ModFileInfo( f );
+ unsortedMods.add( modFileInfo );
+ }
+ amendModsTableState( tableState, unsortedMods );
+
+ for ( ModFileInfo modFileInfo : tableState.getItems() ) {
+ modsTablePanel.getTableModel().addItem( modFileInfo );
+ }
+
+ ModsScanThread scanThread = new ModsScanThread( modFiles, localModDB, this );
+ scanThread.setDaemon( true );
+ scanThread.setPriority( Thread.MIN_PRIORITY );
+ scanThread.start();
+ }
+
+
+ public void showAboutInfo() {
+ String body = ""
+ + "- Drag to reorder mods.\n"
+ + "- Click the checkboxes to select.\n"
+ + "- Click 'Patch' to apply mods ( select none for vanilla ).\n"
+ + "\n"
+ + "Thanks for using this mod manager.\n"
+ + "Make sure to visit the forum for updates!";
+
+ infoArea.setDescription( appName, appAuthor, appVersion.toString(), appURL, body );
+ }
+
+ public void showAppUpdateInfo() {
+ StringBuilder buf = new StringBuilder();
+
+ try {
+ infoArea.clear();
+ infoArea.appendTitleText( "What's New\n" );
+
+ // Links.
+ infoArea.appendRegularText( String.format( "Version %s: ", appUpdateInfo.getLatestVersion().toString() ) );
+ boolean first = true;
+ for ( Map.Entry entry : appUpdateInfo.getLatestURLs().entrySet() ) {
+ if ( !first ) infoArea.appendRegularText( " " );
+ infoArea.appendRegularText( "[" );
+ infoArea.appendLinkText( entry.getValue(), entry.getKey() );
+ infoArea.appendRegularText( "]" );
+ first = false;
+ }
+ infoArea.appendRegularText( "\n" );
+ infoArea.appendRegularText( "\n" );
+
+ // Notice.
+ if ( appUpdateInfo.getNotice() != null && appUpdateInfo.getNotice().length() > 0 ) {
+ infoArea.appendRegularText( appUpdateInfo.getNotice() );
+ infoArea.appendRegularText( "\n" );
+ infoArea.appendRegularText( "\n" );
+ }
+
+ // Changelog.
+ for ( Map.Entry> entry : appUpdateInfo.getChangelog().entrySet() ) {
+ if ( appVersion.compareTo( entry.getKey() ) >= 0 ) break;
+
+ if ( buf.length() > 0 ) buf.append( "\n" );
+ buf.append( entry.getKey() ).append( ":\n" );
+
+ for ( String change : entry.getValue() ) {
+ buf.append( " - " ).append( change ).append( "\n" );
+ }
+ }
+ infoArea.appendRegularText( buf.toString() );
+
+ infoArea.setCaretPosition( 0 );
+ }
+ catch ( Exception e ) {
+ log.error( "Error filling info text area", e );
+ }
+ }
+
+ /**
+ * Shows info about a local mod in the text area.
+ *
+ * Priority is given to embedded metadata.xml, but when that's absent,
+ * the gatalog's info is used. If the catalog doesn't have the info,
+ * an 'info missing' notice is shown instead.
+ */
+ public void showLocalModInfo( ModFileInfo modFileInfo ) {
+ String modHash = modFileHashes.get( modFileInfo.getFile() );
+
+ ModInfo modInfo = localModDB.getModInfo( modHash );
+ if ( modInfo == null || modInfo.isBlank() ) {
+ modInfo = catalogModDB.getModInfo( modHash );
+ }
+
+ if ( modInfo != null ) {
+ infoArea.setDescription( modInfo.getTitle(), modInfo.getAuthor(), modInfo.getVersion(), modInfo.getURL(), modInfo.getDescription() );
+ }
+ else {
+ boolean notYetReady = isScanning();
+
+ if ( notYetReady ) {
+ String body = ""
+ + "No info is currently available for the selected mod.\n\n"
+ + "But Slipstream has not yet finished scanning the mods/ folder. "
+ + "Try clicking this mod again after waiting a few seconds.";
+
+ infoArea.setDescription( modFileInfo.getName(), body );
+ }
+ else {
+ Date modDate = modFileDates.get( modHash );
+ if ( modDate == null ) {
+ long epochTime = -1;
+ try {
+ epochTime = ModUtilities.getModFileTime( modFileInfo.getFile() );
+ }
+ catch ( IOException e ) {
+ log.error( String.format( "Error while getting modified time of mod file contents for \"%s\"", modFileInfo.getFile() ), e );
+ }
+ if ( epochTime != -1 ) {
+ modDate = new Date( epochTime );
+ modFileDates.put( modHash, modDate );
+ }
+ }
+
+ StringBuilder bodyBuf = new StringBuilder();
+ bodyBuf.append( "No info is available for the selected mod.\n\n" );
+
+ if ( modDate != null ) {
+ SimpleDateFormat dateFormat = new SimpleDateFormat( "yyyy-MM-dd" );
+ bodyBuf.append( String.format( "It was released some time after %s.\n\n", dateFormat.format( modDate ) ) );
+ }
+ else {
+ bodyBuf.append( "The date of its release could not be determined.\n\n" );
+ }
+
+ bodyBuf.append( "Mods can include an embedded description, but this one did not.\n" );
+
+ infoArea.setDescription( modFileInfo.getName(), bodyBuf.toString() );
+ }
+ }
+ }
+
+ public void exitApp() {
+ this.setVisible( false );
+ this.dispose();
+ }
+
+
+ @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 modFiles = new ArrayList();
+
+ for ( ModFileInfo modFileInfo : modsTablePanel.getSelectedItems() ) {
+ modFiles.add( modFileInfo.getFile() );
+ }
+
+ File datsDir = new File( appConfig.getProperty( SlipstreamConfig.FTL_DATS_PATH ) );
+
+ ModPatchDialog patchDlg = new ModPatchDialog( this, true );
+
+ // Offer to run FTL.
+ if ( !"true".equals( appConfig.getProperty( SlipstreamConfig.NEVER_RUN_FTL, "false" ) ) ) {
+ 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 ) {
+ patchDlg.setSuccessTask( new SpawnGameTask( exeFile, exeArgs ) );
+ }
+ }
+
+ log.info( "" );
+ log.info( "Patching..." );
+ log.info( "" );
+ ModPatchThread patchThread = new ModPatchThread( modFiles, datsDir, backupDir, false, patchDlg );
+ patchThread.start();
+
+ patchDlg.setVisible( true );
+ }
+ else if ( source == toggleAllBtn ) {
+ modsTablePanel.toggleAllItemSelection();
+ }
+ else if ( source == validateBtn ) {
+ StringBuilder resultBuf = new StringBuilder();
+ boolean anyInvalid = false;
+
+ for ( ModFileInfo modFileInfo : modsTablePanel.getSelectedItems() ) {
+ Report validateReport = ModUtilities.validateModFile( modFileInfo.getFile() );
+
+ ReportFormatter formatter = new ReportFormatter();
+ 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." );
+ }
+ else if ( anyInvalid ) {
+ resultBuf.append( "\n" );
+ resultBuf.append( "FTL itself can tolerate lots of XML typos and still run. But malformed XML may " );
+ resultBuf.append( "break tools that do proper parsing, and it hinders the development of new " );
+ resultBuf.append( "tools.\n" );
+ resultBuf.append( "\n" );
+ resultBuf.append( "Slipstream will try to parse XML while patching: first strictly, then failing " );
+ resultBuf.append( "over to a sloppy parser. The sloppy parser will tolerate similar errors, at the " );
+ resultBuf.append( "risk of unforseen behavior, so satisfying the strict parser is advised.\n" );
+ }
+ infoArea.setDescription( "Results", resultBuf.toString() );
+ }
+ else if ( source == modsFolderBtn ) {
+ try {
+ if ( Desktop.isDesktopSupported() ) {
+ Desktop.getDesktop().open( modsDir.getCanonicalFile() );
+ } else {
+ log.error( String.format( "Java cannot open the %s/ folder for you on this OS", modsDir.getName() ) );
+ }
+ }
+ catch ( IOException f ) {
+ log.error( "Error opening mods/ folder", f );
+ }
+ }
+ else if ( source == updateBtn ) {
+ showAppUpdateInfo();
+ }
+ else if ( source == rescanMenuItem ) {
+ setStatusText( "" );
+ if ( rescanMenuItem.isEnabled() == false ) return;
+
+ ListState tableState = getCurrentModsTableState();
+ rescanMods( tableState );
+ }
+ else if ( source == extractDatsMenuItem ) {
+ setStatusText( "" );
+ JFileChooser extractChooser = new JFileChooser();
+ extractChooser.setDialogTitle( "Choose a dir to extract into" );
+ extractChooser.setFileHidingEnabled( false );
+ extractChooser.setFileSelectionMode( JFileChooser.DIRECTORIES_ONLY );
+ extractChooser.setMultiSelectionEnabled( false );
+
+ if ( extractChooser.showSaveDialog( this ) != JFileChooser.APPROVE_OPTION )
+ return;
+
+ File extractDir = extractChooser.getSelectedFile();
+
+ File datsDir = new File( appConfig.getProperty( SlipstreamConfig.FTL_DATS_PATH ) );
+
+ DatExtractDialog extractDlg = new DatExtractDialog( this, extractDir, datsDir );
+ extractDlg.extract();
+ extractDlg.setVisible( true );
+ }
+ else if ( source == createModMenuItem ) {
+ setStatusText( "" );
+
+ CreateModDialog createModDlg = new CreateModDialog( ManagerFrame.this, modsDir );
+ createModDlg.addWindowListener( nerfListener );
+ //configDlg.setSize( 300, 400 );
+ createModDlg.setLocationRelativeTo( null );
+ createModDlg.setVisible( true );
+ }
+ else if ( source == sandboxMenuItem ) {
+ setStatusText( "" );
+ File datsDir = new File( appConfig.getProperty( SlipstreamConfig.FTL_DATS_PATH ) );
+
+ ModXMLSandbox sandboxFrame = new ModXMLSandbox( datsDir );
+ sandboxFrame.addWindowListener( nerfListener );
+ sandboxFrame.setSize( 800, 600 );
+ sandboxFrame.setLocationRelativeTo( null );
+ sandboxFrame.setVisible( true );
+ }
+ else if ( source == configMenuItem ) {
+ setStatusText( "" );
+
+ SlipstreamConfigDialog configDlg = new SlipstreamConfigDialog( ManagerFrame.this, appConfig );
+ configDlg.addWindowListener( nerfListener );
+ //configDlg.setSize( 300, 400 );
+ configDlg.setLocationRelativeTo( null );
+ configDlg.setVisible( true );
+ }
+ else if ( source == exitMenuItem ) {
+ setStatusText( "" );
+ exitApp();
+ }
+ else if ( source == deleteBackupsMenuItem ) {
+ String deletePrompt = ""
+ + "Slipstream uses backups to revert FTL to a state without mods.\n"
+ + "You are about to delete them.\n"
+ + "\n"
+ + "The next time you click 'patch', Slipstream will create fresh backups.\n"
+ + "\n"
+ + "FTL *must be* in a working unmodded state *before* you click 'patch'.\n"
+ + "\n"
+ + "To get FTL into a working unmodded state, you may need to reinstall FTL\n"
+ + "or use Steam's \"Verify integrity of game files\" feature.\n"
+ + "\n"
+ + "Whenever FTL is updated, you will need to delete stale backups or the\n"
+ + "game will break.\n"
+ + "\n"
+ + "Are you sure you want to continue?";
+
+ int response = JOptionPane.showConfirmDialog( ManagerFrame.this, deletePrompt, "Continue?", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE );
+ if ( response == JOptionPane.YES_OPTION ) {
+
+ List failures = new ArrayList( 2 );
+ boolean backupsExist = false;
+ for ( String datName : new String[] {"ftl.dat", "data.dat", "resource.dat"} ) {
+ File bakFile = new File( backupDir, datName +".bak" );
+ if ( bakFile.exists() ) {
+ backupsExist = true;
+
+ if ( !bakFile.delete() ) {
+ log.error( "Unable to delete backup: "+ bakFile.getName() );
+ failures.add( bakFile.getName() );
+ }
+ }
+ }
+ if ( !backupsExist ) {
+ JOptionPane.showMessageDialog( ManagerFrame.this, "There were no backups to delete.", "Nothing to do", JOptionPane.INFORMATION_MESSAGE );
+ }
+ else if ( failures.isEmpty() ) {
+ JOptionPane.showMessageDialog( ManagerFrame.this, "Backups were deleted successfully.", "Success", JOptionPane.INFORMATION_MESSAGE );
+ }
+ else {
+ StringBuilder failBuf = new StringBuilder( "The following files couldn't be deleted:" );
+ for ( String s : failures ) {
+ failBuf.append( "- \"" ).append( s ).append( "\"\n" );
+ }
+ failBuf.append( "\nTry going in the \"SMM/backup/\" folder and deleting them manually?" );
+ JOptionPane.showMessageDialog( ManagerFrame.this, failBuf.toString(), "Error", JOptionPane.ERROR_MESSAGE );
+
+ try {
+ if ( Desktop.isDesktopSupported() ) {
+ Desktop.getDesktop().open( backupDir.getCanonicalFile() );
+ } else {
+ log.error( String.format( "Java cannot open the %s/ folder for you on this OS", backupDir.getName() ) );
+ }
+ }
+ catch ( IOException f ) {
+ log.error( String.format( "Error opening %s/ folder", backupDir.getName() ), f );
+ }
+ }
+ }
+ }
+ else if ( source == steamVerifyIntegrityMenuItem ) {
+ String exePath = appConfig.getProperty( SlipstreamConfig.STEAM_EXE_PATH, "" );
+ File exeFile = null;
+ if ( exePath.length() == 0 || !(exeFile=new File( exePath )).exists() ) {
+ log.warn( "Steam's location was either not set or doesn't exist" );
+ JOptionPane.showMessageDialog( ManagerFrame.this, "Steam's location was either not set or doesn't exist.", "Nothing to do", JOptionPane.WARNING_MESSAGE );
+ return;
+ }
+
+ String verifyPrompt = ""
+ + "Slipstream is about to tell Steam to re-download FTL's resources. This will get\n"
+ + "the game back to a working unmodded state, but it could take a while.\n"
+ + "\n"
+ + "You can do it manually like this...\n"
+ + "- Go to Steam's Library.\n"
+ + "- Right-click FTL, choose \"Properties\".\n"
+ + "- Click the \"Verify integrity of game files...\" button.\n"
+ + "\n"
+ + "If you do not have Steam, you will need to reinstall FTL instead.\n"
+ + "\n"
+ + "Either way, you should delete Slipstream's backups as well.\n"
+ + "\n"
+ + "Are you sure you want to continue?";
+
+ int response = JOptionPane.showConfirmDialog( ManagerFrame.this, verifyPrompt, "Continue?", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE );
+ if ( response == JOptionPane.YES_OPTION ) {
+ try {
+ FTLUtilities.verifySteamGameCache( exeFile, FTLUtilities.STEAM_APPID_FTL );
+ }
+ catch ( IOException f ) {
+ log.error( "Couldn't tell Steam to 'verify integrity of game files'", f );
+ }
+ }
+
+ }
+ else if ( source == aboutMenuItem ) {
+ setStatusText( "" );
+ showAboutInfo();
+ }
+ }
+
+
+ @Override
+ public void hashCalculated( final File f, final String hash ) {
+ Runnable r = new Runnable() {
+ @Override
+ public void run() { modFileHashes.put( f, hash ); }
+ };
+ if ( SwingUtilities.isEventDispatchThread() ) r.run();
+ else SwingUtilities.invokeLater( r );
+ }
+
+ @Override
+ public void localModDBUpdated( ModDB newDB ) {
+ setLocalModDB( newDB );
+ }
+
+ @Override
+ public void modsScanEnded() {
+ Runnable r = new Runnable() {
+ @Override
+ public void run() {
+ managerLock.lock();
+ try {
+ scanning = false;
+ rescanMenuItem.setEnabled( !scanning );
+ scanEndedCond.signalAll();
+ }
+ finally {
+ managerLock.unlock();
+ }
+ }
+ };
+ if ( SwingUtilities.isEventDispatchThread() ) r.run();
+ else SwingUtilities.invokeLater( r );
+ }
+
+
+ /**
+ * Returns a lock for synchronizing thread operations.
+ */
+ public Lock getLock() {
+ return managerLock;
+ }
+
+ /**
+ * Returns a condition that will signal when the "mods/" dir has been scanned.
+ *
+ * Call getLock().lock() first.
+ * Loop while isScanning() is true, calling this condition's await().
+ * Finally, call getLock().unlock().
+ */
+ public Condition getScanEndedCondition() {
+ return scanEndedCond;
+ }
+
+ /**
+ * Returns true if the "mods/" folder is currently being scanned. (thread-safe)
+ */
+ public boolean isScanning() {
+ managerLock.lock();
+ try {
+ return scanning;
+ }
+ finally {
+ managerLock.unlock();
+ }
+ }
+
+
+ @Override
+ public void setNerfed( boolean b ) {
+ Component glassPane = this.getGlassPane();
+ if (b) {
+ glassPane.setVisible( true );
+ glassPane.requestFocusInWindow();
+ } else {
+ glassPane.setVisible( false );
+ }
+ }
+
+
+ /**
+ * Sets the ModDB for local metadata. (thread-safe)
+ */
+ public void setLocalModDB( final ModDB newDB ) {
+ Runnable r = new Runnable() {
+ @Override
+ public void run() { localModDB = newDB; }
+ };
+ if ( SwingUtilities.isEventDispatchThread() ) r.run();
+ else SwingUtilities.invokeLater( r );
+ }
+
+ /**
+ * Sets the ModDB for the catalog.
+ */
+ public void setCatalogModDB( ModDB newDB ) {
+ catalogModDB = newDB;
+ }
+
+ /**
+ * Sets info about available app updates.
+ */
+ public void setAppUpdateInfo( AutoUpdateInfo aui ) {
+ appUpdateInfo = aui;
+ boolean isUpdateAvailable = ( appVersion.compareTo(appUpdateInfo.getLatestVersion()) < 0 );
+ updateBtn.setForeground( isUpdateAvailable ? updateBtnEnabledColor : updateBtnDisabledColor );
+ updateBtn.setEnabled( isUpdateAvailable );
+ }
+
+ /**
+ * Toggles whether to perform the usual actions after disposal.
+ *
+ * Set this to false before an abnormal exit.
+ */
+ public void setDisposeNormally( boolean b ) {
+ disposeNormally = b;
+ }
+
+ @Override
+ public void uncaughtException( Thread t, Throwable e ) {
+ log.error( "Uncaught exception in thread: "+ t.toString(), e );
+
+ final String threadString = t.toString();
+ final String errString = e.toString();
+ SwingUtilities.invokeLater(new Runnable() {
+ @Override
+ public void run() {
+ String message = ""
+ + "An unexpected error has occurred.\n"
+ + "\n"
+ + "Error: "+ errString +"\n"
+ + "\n"
+ + "See the log for details.\n"
+ + "\n"
+ + "If this interrupted patching, FTL's resources were probably corrupted.\n"
+ + "Restart Slipstream and patch without mods to restore vanilla backups.";
+
+ JOptionPane.showMessageDialog( ManagerFrame.this, message, "Error", JOptionPane.ERROR_MESSAGE );
+ }
+ });
+ }
+
+
+
+ private class SpawnGameTask implements Runnable {
+ private final File exeFile;
+ private final String[] exeArgs;
+
+ public SpawnGameTask( File exeFile, String... exeArgs ) {
+ if ( exeArgs == null ) exeArgs = new String[0];
+ this.exeFile = exeFile;
+ this.exeArgs = new String[exeArgs.length];
+ System.arraycopy( exeArgs, 0, this.exeArgs, 0, exeArgs.length );
+ }
+
+ @Override
+ public void run() {
+ 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( "Running FTL..." );
+ try {
+ FTLUtilities.launchExe( exeFile, exeArgs );
+ }
+ catch ( Exception e ) {
+ log.error( "Error launching FTL", e );
+ }
+ exitApp();
+ }
+ }
+ }
+ }
+
+
+
+ /**
+ * Toggles a main window's nerfed state as popups are opened/disposed.
+ *
+ * Requires: setDefaultCloseOperation( JFrame.DISPOSE_ON_CLOSE ).
+ */
+ private static class NerfListener extends WindowAdapter {
+ private Nerfable nerfObj;
+
+ public NerfListener( Nerfable nerfObj ) {
+ this.nerfObj = nerfObj;
+ }
+
+ @Override
+ public void windowOpened( WindowEvent e ) {
+ nerfObj.setNerfed( true );
+ }
+ @Override
+ public void windowClosed( WindowEvent e ) {
+ nerfObj.setNerfed( false );
+ }
+ }
+
+
+
+ private static class ModFileFilter implements FileFilter {
+ boolean allowZip;
+
+ public ModFileFilter( boolean allowZip ) {
+ this.allowZip = allowZip;
+ }
+
+ @Override
+ public boolean accept( File f ) {
+ if ( f.isFile() ) {
+ if ( f.getName().endsWith( ".ftl" ) ) return true;
+
+ if ( allowZip ) {
+ if ( f.getName().endsWith( ".zip" ) ) return true;
+ }
+ }
+ return false;
+ }
+ }
+}
diff --git a/src/main/java/net/vhati/modmanager/ui/ManagerInitThread.java b/src/main/java/net/vhati/modmanager/ui/ManagerInitThread.java
new file mode 100644
index 0000000..7ad4762
--- /dev/null
+++ b/src/main/java/net/vhati/modmanager/ui/ManagerInitThread.java
@@ -0,0 +1,243 @@
+package net.vhati.modmanager.ui;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.List;
+import java.util.concurrent.locks.Lock;
+import javax.swing.SwingUtilities;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import net.vhati.modmanager.core.AutoUpdateInfo;
+import net.vhati.modmanager.core.ModDB;
+import net.vhati.modmanager.core.ModFileInfo;
+import net.vhati.modmanager.core.SlipstreamConfig;
+import net.vhati.modmanager.json.JacksonAutoUpdateReader;
+import net.vhati.modmanager.json.JacksonCatalogReader;
+import net.vhati.modmanager.json.URLFetcher;
+import net.vhati.modmanager.ui.ManagerFrame;
+import net.vhati.modmanager.ui.table.ListState;
+
+
+/**
+ * Performs I/O-related setup for ManagerFrame in the background.
+ *
+ * Reads cached local metadata.
+ * Rescans the "mods/" folder.
+ * Reads saved catalog, and redownloads if stale.
+ * Reads saved info about app updates, and redownloads if stale.
+ */
+public class ManagerInitThread extends Thread {
+
+ private static final Logger log = LoggerFactory.getLogger( ManagerInitThread.class );
+
+ private final ManagerFrame frame;
+ private final SlipstreamConfig appConfig;
+ private final File modsDir;
+ private final File modsTableStateFile;
+ private final File metadataFile;
+ private final File catalogFile;
+ private final File catalogETagFile;
+ private final File appUpdateFile;
+ private final File appUpdateETagFile;
+
+
+ public ManagerInitThread( ManagerFrame frame, SlipstreamConfig appConfig, File modsDir, File modsTableStateFile, File metadataFile, File catalogFile, File catalogETagFile, File appUpdateFile, File appUpdateETagFile ) {
+ super( "init" );
+ this.frame = frame;
+ this.appConfig = appConfig;
+ this.modsDir = modsDir;
+ this.modsTableStateFile = modsTableStateFile;
+ this.metadataFile = metadataFile;
+ this.catalogFile = catalogFile;
+ this.catalogETagFile = catalogETagFile;
+ this.appUpdateFile = appUpdateFile;
+ this.appUpdateETagFile = appUpdateETagFile;
+ }
+
+
+ @Override
+ public void run() {
+ try {
+ init();
+ }
+ catch ( Exception e ) {
+ log.error( "Error during ManagerFrame init.", e );
+ }
+ }
+
+
+ private void init() throws InterruptedException {
+
+ if ( metadataFile.exists() ) {
+ // Load cached metadata first, before scanning for new info.
+ ModDB cachedDB = JacksonCatalogReader.parse( metadataFile );
+ if ( cachedDB != null ) frame.setLocalModDB( cachedDB );
+ }
+
+ final ListState tableState = loadModsTableState();
+
+ Lock managerLock = frame.getLock();
+ managerLock.lock();
+ try {
+ SwingUtilities.invokeLater(new Runnable() {
+ @Override
+ public void run() { frame.rescanMods( tableState ); }
+ });
+
+ // Wait until notified that "mods/" has been scanned.
+ while ( frame.isScanning() ) {
+ frame.getScanEndedCondition().await();
+ }
+ }
+ finally {
+ managerLock.unlock();
+ }
+
+ int catalogUpdateInterval = appConfig.getPropertyAsInt( "update_catalog", 0 );
+ boolean needNewCatalog = false;
+
+ // Load the catalog first, before downloading.
+ if ( catalogFile.exists() ) reloadCatalog();
+
+ if ( catalogUpdateInterval > 0 ) {
+ if ( catalogFile.exists() ) {
+ // Check if the downloaded catalog is stale.
+ if ( isFileStale( catalogFile, catalogUpdateInterval ) ) {
+ log.debug( String.format( "Catalog is older than %d days", catalogUpdateInterval ) );
+ needNewCatalog = true;
+ } else {
+ log.debug( "Catalog isn't stale yet" );
+ }
+ }
+ else {
+ // Catalog file doesn't exist.
+ needNewCatalog = true;
+ }
+ }
+
+ if ( needNewCatalog ) {
+ boolean fetched = URLFetcher.refetchURL( ManagerFrame.CATALOG_URL, catalogFile, catalogETagFile );
+ if ( fetched && catalogFile.exists() ) {
+ reloadCatalog();
+ }
+ }
+
+ // Load the cached info first, before downloading.
+ if ( appUpdateFile.exists() ) reloadAppUpdateInfo();
+
+ int appUpdateInterval = appConfig.getPropertyAsInt( SlipstreamConfig.UPDATE_APP, 0 );
+ boolean needAppUpdate = false;
+
+ if ( appUpdateInterval > 0 ) {
+ if ( appUpdateFile.exists() ) {
+ // Check if the app update info is stale.
+ if ( isFileStale( appUpdateFile, appUpdateInterval ) ) {
+ log.debug( String.format( "App update info is older than %d days", appUpdateInterval ) );
+ needAppUpdate = true;
+ } else {
+ log.debug( "App update info isn't stale yet" );
+ }
+ }
+ else {
+ // App update file doesn't exist.
+ needAppUpdate = true;
+ }
+ }
+
+ if ( needAppUpdate ) {
+ boolean fetched = URLFetcher.refetchURL( ManagerFrame.APP_UPDATE_URL, appUpdateFile, appUpdateETagFile );
+ if ( fetched && appUpdateFile.exists() ) {
+ reloadAppUpdateInfo();
+ }
+ }
+ }
+
+
+ /**
+ * Reads modorder.txt and returns a list of mod names in preferred order.
+ */
+ private ListState loadModsTableState() {
+ List fileNames = new ArrayList();
+
+ BufferedReader br = null;
+ try {
+ FileInputStream is = new FileInputStream( modsTableStateFile );
+ br = new BufferedReader( new InputStreamReader( is, Charset.forName( "UTF-8" ) ) );
+ String line;
+ while ( (line = br.readLine()) != null ) {
+ fileNames.add( line );
+ }
+ }
+ catch ( FileNotFoundException e ) {
+ }
+ catch ( IOException e ) {
+ log.error( String.format( "Error reading \"%s\"", modsTableStateFile.getName() ), e );
+ fileNames.clear();
+ }
+ finally {
+ try {if ( br != null ) br.close();}
+ catch ( Exception e ) {}
+ }
+
+ ListState result = new ListState();
+
+ for ( String fileName : fileNames ) {
+ File modFile = new File( modsDir, fileName );
+ ModFileInfo modFileInfo = new ModFileInfo( modFile );
+ result.addItem( modFileInfo );
+ }
+
+ return result;
+ }
+
+
+ private void reloadCatalog() {
+ final ModDB currentDB = JacksonCatalogReader.parse( catalogFile );
+ if ( currentDB != null ) {
+ SwingUtilities.invokeLater(new Runnable() {
+ @Override
+ public void run() {
+ frame.setCatalogModDB( currentDB );
+ }
+ });
+ }
+ }
+
+ private void reloadAppUpdateInfo() {
+ final AutoUpdateInfo aui = JacksonAutoUpdateReader.parse( appUpdateFile );
+ if ( aui != null ) {
+ SwingUtilities.invokeLater(new Runnable() {
+ @Override
+ public void run() {
+ frame.setAppUpdateInfo( aui );
+ }
+ });
+ }
+ }
+
+
+ /**
+ * Returns true if a file is older than N days.
+ */
+ private boolean isFileStale( File f, int maxDays ) {
+ Calendar fileCal = Calendar.getInstance();
+ fileCal.setTimeInMillis( f.lastModified() );
+ fileCal.getTimeInMillis(); // Re-calculate calendar fields.
+
+ Calendar freshCal = Calendar.getInstance();
+ freshCal.add( Calendar.DATE, maxDays * -1 );
+ freshCal.getTimeInMillis(); // Re-calculate calendar fields.
+
+ return (fileCal.compareTo( freshCal ) < 0);
+ }
+}
diff --git a/src/main/java/net/vhati/modmanager/ui/ModInfoArea.java b/src/main/java/net/vhati/modmanager/ui/ModInfoArea.java
new file mode 100644
index 0000000..f7dfae7
--- /dev/null
+++ b/src/main/java/net/vhati/modmanager/ui/ModInfoArea.java
@@ -0,0 +1,277 @@
+package net.vhati.modmanager.ui;
+
+import java.awt.Color;
+import java.awt.Cursor;
+import java.awt.Desktop;
+import java.awt.Font;
+import java.awt.Toolkit;
+import java.awt.datatransfer.StringSelection;
+import java.awt.event.ActionEvent;
+import java.awt.event.MouseEvent;
+import java.net.URI;
+import javax.swing.AbstractAction;
+import javax.swing.JPopupMenu;
+import javax.swing.JScrollPane;
+import javax.swing.JTextPane;
+import javax.swing.SwingUtilities;
+import javax.swing.event.MouseInputAdapter;
+import javax.swing.text.AttributeSet;
+import javax.swing.text.BadLocationException;
+import javax.swing.text.DefaultStyledDocument;
+import javax.swing.text.SimpleAttributeSet;
+import javax.swing.text.Style;
+import javax.swing.text.StyleConstants;
+import javax.swing.text.StyleContext;
+import javax.swing.text.StyledDocument;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import net.vhati.modmanager.ui.Statusbar;
+
+
+public class ModInfoArea extends JScrollPane {
+
+ private static final Logger log = LoggerFactory.getLogger( ModInfoArea.class );
+
+ private static final String STYLE_REGULAR = "regular";
+ private static final String STYLE_HYPERLINK = "hyperlink";
+ private static final String STYLE_TITLE = "title";
+ private static final String ATTR_HYPERLINK_TARGET = "hyperlink-target";
+
+ public static Color COLOR_HYPER = Color.BLUE;
+ public static final StyleContext DEFAULT_STYLES = ModInfoArea.getDefaultStyleContext();
+
+ private JPopupMenu linkPopup = new JPopupMenu();
+
+ private Statusbar statusbar = null;
+ private String lastClickedLinkTarget = null;
+
+ private JTextPane textPane;
+ private StyledDocument doc;
+ private boolean browseWorks;
+
+
+ public ModInfoArea() {
+ this( DEFAULT_STYLES );
+ }
+
+ public ModInfoArea( StyleContext styleContext ) {
+ super();
+
+ textPane = new JTextPane();
+ textPane.setEditable( false );
+
+ doc = new DefaultStyledDocument( styleContext );
+ textPane.setStyledDocument( doc );
+
+ Desktop desktop = Desktop.isDesktopSupported() ? Desktop.getDesktop() : null;
+ if ( desktop != null && desktop.isSupported( Desktop.Action.BROWSE ) ) {
+ browseWorks = true;
+ }
+
+ linkPopup.add( new AbstractAction( "Copy link address" ) {
+ @Override
+ public void actionPerformed( ActionEvent ae ) {
+ if ( lastClickedLinkTarget != null ) {
+ Toolkit.getDefaultToolkit().getSystemClipboard().setContents( new StringSelection( lastClickedLinkTarget ), null );
+ }
+ }
+ });
+
+ MouseInputAdapter hyperlinkListener = new MouseInputAdapter() {
+ private Cursor defaultCursor = new Cursor( Cursor.DEFAULT_CURSOR );
+ private Cursor linkCursor = new Cursor( Cursor.HAND_CURSOR );
+ private boolean wasOverLink = false;
+
+ @Override
+ public void mousePressed( MouseEvent e ) {
+ if ( e.isConsumed() ) return;
+ if ( e.isPopupTrigger() ) showMenu( e );
+ }
+
+ @Override
+ public void mouseReleased( MouseEvent e ) {
+ if ( e.isConsumed() ) return;
+ if ( e.isPopupTrigger() ) showMenu( e );
+ }
+
+ @Override
+ public void mouseClicked( MouseEvent e ) {
+ if ( e.isConsumed() ) return;
+ if ( !SwingUtilities.isLeftMouseButton( e ) ) return;
+
+ AttributeSet tmpAttr = doc.getCharacterElement( textPane.viewToModel( e.getPoint() ) ).getAttributes();
+ Object targetObj = tmpAttr.getAttribute( ATTR_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 );
+ }
+ }
+ e.consume();
+ }
+ }
+
+ @Override
+ public void mouseMoved( MouseEvent e ) {
+ AttributeSet tmpAttr = doc.getCharacterElement( textPane.viewToModel( e.getPoint() ) ).getAttributes();
+ Object targetObj = tmpAttr.getAttribute( ATTR_HYPERLINK_TARGET );
+ if ( targetObj != null ) {
+ textPane.setCursor( linkCursor );
+ if ( statusbar != null )
+ statusbar.setStatusText( targetObj.toString() );
+ wasOverLink = true;
+ }
+ else {
+ if ( wasOverLink ) {
+ textPane.setCursor( defaultCursor );
+ if ( statusbar != null )
+ statusbar.setStatusText( "" );
+ }
+ wasOverLink = false;
+ }
+ }
+
+ private void showMenu( MouseEvent e ) {
+ AttributeSet tmpAttr = doc.getCharacterElement( textPane.viewToModel( e.getPoint() ) ).getAttributes();
+ Object targetObj = tmpAttr.getAttribute( ATTR_HYPERLINK_TARGET );
+ if ( targetObj != null ) { // Link menu.
+ textPane.requestFocus();
+
+ lastClickedLinkTarget = targetObj.toString();
+
+ int nx = e.getX();
+ if ( nx > 500 ) nx = nx - linkPopup.getSize().width;
+
+ linkPopup.show( e.getComponent(), nx, e.getY() - linkPopup.getSize().height );
+
+ e.consume();
+ }
+ }
+ };
+ 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 ) {
+ Style regularStyle = doc.getStyle( STYLE_REGULAR );
+ try {
+ doc.remove( 0, doc.getLength() );
+ doc.insertString( doc.getLength(), title +"\n", doc.getStyle( STYLE_TITLE ) );
+
+ boolean first = true;
+ if ( author != null ) {
+ doc.insertString( doc.getLength(), String.format( "%sby %s", (first ? "" : " "), author ), regularStyle );
+ first = false;
+ }
+ if ( version != null ) {
+ doc.insertString( doc.getLength(), String.format( "%s(version %s)", (first ? "" : " "), version ), regularStyle );
+ first = false;
+ }
+ if ( !first ) {
+ doc.insertString( doc.getLength(), "\n", regularStyle );
+ }
+
+ if ( url != null ) {
+ doc.insertString( doc.getLength(), "Website: ", regularStyle );
+
+ if ( browseWorks && url.matches( "^(?:https?|ftp)://.*" ) ) {
+ SimpleAttributeSet tmpAttr = new SimpleAttributeSet( doc.getStyle( STYLE_HYPERLINK ) );
+ tmpAttr.addAttribute( ATTR_HYPERLINK_TARGET, url );
+ doc.insertString( doc.getLength(), "Link", tmpAttr );
+ } else {
+ doc.insertString( doc.getLength(), url, regularStyle );
+ }
+
+ doc.insertString( doc.getLength(), "\n", regularStyle );
+ }
+
+ doc.insertString( doc.getLength(), "\n", regularStyle );
+
+ if ( body != null ) {
+ doc.insertString( doc.getLength(), body, regularStyle );
+ }
+ }
+ catch ( BadLocationException e ) {
+ log.error( "Error filling info text area", e );
+ }
+
+ textPane.setCaretPosition( 0 );
+ }
+
+
+ public void setCaretPosition( int n ) {
+ textPane.setCaretPosition( n );
+ }
+
+ public void clear() {
+ try {
+ doc.remove( 0, doc.getLength() );
+ }
+ catch ( BadLocationException e ) {
+ log.error( "Error clearing info text area", e );
+ }
+ }
+
+ public void appendTitleText( String s ) throws BadLocationException {
+ doc.insertString( doc.getLength(), s, doc.getStyle( STYLE_TITLE ) );
+ }
+
+ public void appendRegularText( String s ) throws BadLocationException {
+ doc.insertString( doc.getLength(), s, doc.getStyle( STYLE_REGULAR ) );
+ }
+
+ public void appendLinkText( String linkURL, String linkTitle ) throws BadLocationException {
+ if ( browseWorks && linkURL.matches( "^(?:https?|ftp)://.*" ) ) {
+ SimpleAttributeSet tmpAttr = new SimpleAttributeSet( doc.getStyle( STYLE_HYPERLINK ) );
+ tmpAttr.addAttribute( ATTR_HYPERLINK_TARGET, linkURL );
+ doc.insertString( doc.getLength(), linkTitle, tmpAttr );
+ } else {
+ doc.insertString( doc.getLength(), linkURL, doc.getStyle( STYLE_REGULAR ) );
+ }
+ }
+
+
+ /**
+ * Sets a component with a statusbar to be set during mouse events.
+ */
+ public void setStatusbar( Statusbar comp ) {
+ this.statusbar = comp;
+ }
+
+
+ private static StyleContext getDefaultStyleContext() {
+ StyleContext result = new StyleContext();
+ Style defaultStyle = StyleContext.getDefaultStyleContext().getStyle( StyleContext.DEFAULT_STYLE );
+ Style baseStyle = result.addStyle( "base", defaultStyle );
+
+ Style regularStyle = result.addStyle( STYLE_REGULAR, baseStyle );
+ StyleConstants.setFontFamily( regularStyle, Font.MONOSPACED );
+ StyleConstants.setFontSize( regularStyle, 12 );
+
+ Style hyperStyle = result.addStyle( STYLE_HYPERLINK, regularStyle );
+ StyleConstants.setForeground( hyperStyle, COLOR_HYPER );
+ StyleConstants.setUnderline( hyperStyle, true );
+
+ Style titleStyle = result.addStyle( STYLE_TITLE, baseStyle );
+ StyleConstants.setFontFamily( titleStyle, Font.SANS_SERIF );
+ StyleConstants.setFontSize( titleStyle, 24 );
+ StyleConstants.setBold( titleStyle, true );
+
+ return result;
+ }
+}
diff --git a/src/main/java/net/vhati/modmanager/ui/ModPatchDialog.java b/src/main/java/net/vhati/modmanager/ui/ModPatchDialog.java
new file mode 100644
index 0000000..bfa7932
--- /dev/null
+++ b/src/main/java/net/vhati/modmanager/ui/ModPatchDialog.java
@@ -0,0 +1,78 @@
+package net.vhati.modmanager.ui;
+
+import java.awt.Frame;
+import java.io.File;
+import javax.swing.JDialog;
+import javax.swing.SwingUtilities;
+
+import net.vhati.modmanager.core.ModPatchObserver;
+import net.vhati.modmanager.ui.ProgressDialog;
+
+
+public class ModPatchDialog extends ProgressDialog implements ModPatchObserver {
+
+
+ public ModPatchDialog( Frame owner, boolean continueOnSuccess ) {
+ super( owner, continueOnSuccess );
+ this.setTitle( "Patching..." );
+
+ this.setSize( 400, 160 );
+ this.setMinimumSize( this.getPreferredSize() );
+ this.setLocationRelativeTo( owner );
+ }
+
+
+ /**
+ * 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 ) {
+ this.setProgressLater( value, max );
+ }
+
+ /**
+ * Non-specific activity.
+ *
+ * @param message a string, or null
+ */
+ @Override
+ public void patchingStatus( final String message ) {
+ setStatusTextLater( message != null ? message : "..." );
+ }
+
+ /**
+ * A mod is about to be processed.
+ */
+ @Override
+ public void patchingMod( final File modFile ) {
+ setStatusTextLater( String.format( "Installing mod \"%s\"...", modFile.getName() ) );
+ }
+
+ /**
+ * Patching ended.
+ *
+ * If anything went wrong, e may be non-null.
+ */
+ @Override
+ public void patchingEnded( boolean outcome, Exception e ) {
+ setTaskOutcomeLater( outcome, e );
+ }
+
+
+ @Override
+ protected void setTaskOutcome( boolean outcome, Exception e ) {
+ super.setTaskOutcome( outcome, e );
+ if ( !this.isShowing() ) return;
+
+ if ( succeeded == true ) {
+ setStatusText( "Patching completed." );
+ } else {
+ setStatusText( String.format( "Patching failed: %s", e ) );
+ }
+ }
+}
diff --git a/src/main/java/net/vhati/modmanager/ui/ModXMLSandbox.java b/src/main/java/net/vhati/modmanager/ui/ModXMLSandbox.java
new file mode 100644
index 0000000..401ead3
--- /dev/null
+++ b/src/main/java/net/vhati/modmanager/ui/ModXMLSandbox.java
@@ -0,0 +1,591 @@
+package net.vhati.modmanager.ui;
+
+import java.awt.BorderLayout;
+import java.awt.Component;
+import java.awt.Dimension;
+import java.awt.Font;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.awt.event.FocusAdapter;
+import java.awt.event.FocusEvent;
+import java.awt.event.InputEvent;
+import java.awt.event.KeyEvent;
+import java.io.File;
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.io.IOException;
+import java.io.FileNotFoundException;
+import java.io.StringWriter;
+import java.util.ArrayList;
+import java.util.Enumeration;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import javax.swing.AbstractAction;
+import javax.swing.BorderFactory;
+import javax.swing.Box;
+import javax.swing.BoxLayout;
+import javax.swing.JButton;
+import javax.swing.JComponent;
+import javax.swing.JFrame;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JOptionPane;
+import javax.swing.JScrollPane;
+import javax.swing.JSplitPane;
+import javax.swing.JTabbedPane;
+import javax.swing.JTextArea;
+import javax.swing.JTextField;
+import javax.swing.JTree;
+import javax.swing.KeyStroke;
+import javax.swing.SwingUtilities;
+import javax.swing.event.AncestorEvent;
+import javax.swing.event.AncestorListener;
+import javax.swing.event.CaretEvent;
+import javax.swing.event.CaretListener;
+import javax.swing.event.UndoableEditEvent;
+import javax.swing.event.UndoableEditListener;
+import javax.swing.text.BadLocationException;
+import javax.swing.text.Caret;
+import javax.swing.tree.DefaultMutableTreeNode;
+import javax.swing.tree.DefaultTreeModel;
+import javax.swing.tree.TreePath;
+import javax.swing.undo.CannotRedoException;
+import javax.swing.undo.UndoManager;
+
+import net.vhati.ftldat.AbstractPack;
+import net.vhati.ftldat.FTLPack;
+import net.vhati.ftldat.PkgPack;
+import net.vhati.modmanager.core.ModUtilities;
+import net.vhati.modmanager.core.SloppyXMLOutputProcessor;
+import net.vhati.modmanager.core.XMLPatcher;
+import net.vhati.modmanager.ui.ClipboardMenuMouseListener;
+
+import org.jdom2.JDOMException;
+
+
+/**
+ * A basic text editor to test XML modding.
+ */
+public class ModXMLSandbox extends JFrame implements ActionListener {
+
+ private static final String baseTitle = "Mod XML Sandbox";
+
+ private UndoManager undoManager = new UndoManager();
+ private String mainText = null;
+
+ private File datsDir;
+
+ private JTabbedPane areasPane;
+ private JScrollPane mainScroll;
+ private JScrollPane appendScroll;
+ private JScrollPane resultScroll;
+ private JSplitPane splitPane;
+ private JScrollPane messageScroll;
+
+ private JTextArea mainArea;
+ private JTextArea appendArea;
+ private JTextArea resultArea;
+ private JTextArea messageArea;
+ private JTextField findField;
+ private JButton openBtn;
+ private JButton patchBtn;
+ private JLabel statusLbl;
+
+
+ public ModXMLSandbox( File datsDir ) {
+ super( baseTitle );
+ this.setDefaultCloseOperation( JFrame.DISPOSE_ON_CLOSE );
+
+ this.datsDir = datsDir;
+
+ Font sandboxFont = new Font( Font.MONOSPACED, Font.PLAIN, 13 );
+
+ mainArea = new JTextArea();
+ mainArea.setTabSize( 4 );
+ mainArea.setFont( sandboxFont );
+ mainArea.setEditable( false );
+ mainArea.addMouseListener( new ClipboardMenuMouseListener() );
+ mainScroll = new JScrollPane( mainArea );
+
+ appendArea = new JTextArea();
+ appendArea.setTabSize( 4 );
+ appendArea.setFont( sandboxFont );
+ appendArea.addMouseListener( new ClipboardMenuMouseListener() );
+ appendScroll = new JScrollPane( appendArea );
+
+ resultArea = new JTextArea();
+ resultArea.setTabSize( 4 );
+ resultArea.setFont( sandboxFont );
+ resultArea.setEditable( false );
+ resultArea.addMouseListener( new ClipboardMenuMouseListener() );
+ resultScroll = new JScrollPane( resultArea );
+
+ messageArea = new JTextArea();
+ messageArea.setLineWrap( true );
+ messageArea.setWrapStyleWord( true );
+ messageArea.setTabSize( 4 );
+ messageArea.setFont( sandboxFont );
+ messageArea.setEditable( false );
+ messageArea.addMouseListener( new ClipboardMenuMouseListener() );
+ messageArea.setText( "This is a sandbox to tinker with advanced mod syntax.\n1) Open XML from data.dat to fill the 'main' tab. (ctrl-o)\n2) Write some tags in the 'append' tab. (alt-1,2,3)\n3) Click Patch to see what would happen. (ctrl-p)\nUndo/redo is available. (ctrl-z/ctrl-y)" );
+ messageScroll = new JScrollPane( messageArea );
+
+ JPanel ctrlPanel = new JPanel();
+ ctrlPanel.setLayout( new BoxLayout( ctrlPanel, BoxLayout.X_AXIS ) );
+
+ openBtn = new JButton( "Open Main..." );
+ openBtn.addActionListener( this );
+ ctrlPanel.add( openBtn );
+
+ ctrlPanel.add( Box.createHorizontalGlue() );
+
+ findField = new JTextField( "", 20 );
+ findField.setMaximumSize( new Dimension( 60, findField.getPreferredSize().height ) );
+ ctrlPanel.add( findField );
+
+ ctrlPanel.add( Box.createHorizontalGlue() );
+
+ patchBtn = new JButton( "Patch" );
+ patchBtn.addActionListener( this );
+ ctrlPanel.add( patchBtn );
+
+ areasPane = new JTabbedPane( JTabbedPane.BOTTOM );
+ areasPane.add( "Main", mainScroll );
+ areasPane.add( "Append", appendScroll );
+ areasPane.add( "Result", resultScroll );
+
+ JPanel topPanel = new JPanel( new BorderLayout() );
+ topPanel.add( areasPane, BorderLayout.CENTER );
+ topPanel.add( ctrlPanel, BorderLayout.SOUTH );
+
+ splitPane = new JSplitPane( JSplitPane.VERTICAL_SPLIT );
+ splitPane.setTopComponent( topPanel );
+ splitPane.setBottomComponent( messageScroll );
+
+ 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 );
+
+ JPanel contentPane = new JPanel( new BorderLayout() );
+ contentPane.add( splitPane, BorderLayout.CENTER );
+ contentPane.add( statusPanel, BorderLayout.SOUTH );
+ this.setContentPane( contentPane );
+
+ findField.addFocusListener(new FocusAdapter() {
+ @Override
+ public void focusGained( FocusEvent e ) {
+ findField.selectAll();
+ }
+ });
+ CaretListener caretListener = new CaretListener() {
+ @Override
+ public void caretUpdate( CaretEvent e ) {
+ JTextArea currentArea = getCurrentArea();
+ if ( currentArea == null ) return;
+ if ( e.getSource() != currentArea ) return;
+ updateCaretStatus();
+ }
+ };
+ mainArea.addCaretListener( caretListener );
+ appendArea.addCaretListener( caretListener );
+ resultArea.addCaretListener( caretListener );
+
+ CaretAncestorListener caretAncestorListener = new CaretAncestorListener();
+ mainArea.addAncestorListener( caretAncestorListener );
+ appendArea.addAncestorListener( caretAncestorListener );
+ resultArea.addAncestorListener( caretAncestorListener );
+
+ appendArea.getDocument().addUndoableEditListener(new UndoableEditListener() {
+ @Override
+ public void undoableEditHappened( UndoableEditEvent e ) {
+ undoManager.addEdit( e.getEdit() );
+ }
+ });
+ AbstractAction undoAction = new AbstractAction( "Undo" ) {
+ @Override
+ public void actionPerformed( ActionEvent e ) {
+ try {undoManager.undo();}
+ catch ( CannotRedoException f ) {}
+ }
+ };
+ AbstractAction redoAction = new AbstractAction( "Redo" ) {
+ @Override
+ public void actionPerformed( ActionEvent e ) {
+ try {undoManager.redo();}
+ catch ( CannotRedoException f ) {}
+ }
+ };
+
+ AbstractAction openAction = new AbstractAction( "Open" ) {
+ @Override
+ public void actionPerformed( ActionEvent e ) {
+ open();
+ }
+ };
+ AbstractAction patchAction = new AbstractAction( "Patch" ) {
+ @Override
+ public void actionPerformed( ActionEvent e ) {
+ patch();
+ }
+ };
+ AbstractAction focusFindAction = new AbstractAction( "Focus Find" ) {
+ @Override
+ public void actionPerformed( ActionEvent e ) {
+ findField.requestFocusInWindow();
+ }
+ };
+ AbstractAction findNextAction = new AbstractAction( "Find Next" ) {
+ @Override
+ public void actionPerformed( ActionEvent e ) {
+ findNext();
+ }
+ };
+ AbstractAction findPreviousAction = new AbstractAction( "Find Previous" ) {
+ @Override
+ public void actionPerformed( ActionEvent e ) {
+ findPrevious();
+ }
+ };
+
+ KeyStroke undoShortcut = KeyStroke.getKeyStroke( "control Z" );
+ appendArea.getInputMap().put( undoShortcut, "undo" );
+ appendArea.getActionMap().put( "undo", undoAction );
+ KeyStroke redoShortcut = KeyStroke.getKeyStroke( "control Y" );
+ appendArea.getInputMap().put( redoShortcut, "redo" );
+ appendArea.getActionMap().put( "redo", redoAction );
+
+ KeyStroke openShortcut = KeyStroke.getKeyStroke( "control O" );
+ contentPane.getInputMap( JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT ).put( openShortcut, "open" );
+ contentPane.getActionMap().put( "open", openAction );
+ KeyStroke patchShortcut = KeyStroke.getKeyStroke( "control P" );
+ contentPane.getInputMap( JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT ).put( patchShortcut, "patch" );
+ contentPane.getActionMap().put( "patch", patchAction );
+ KeyStroke focusFindShortcut = KeyStroke.getKeyStroke( "control F" );
+ contentPane.getInputMap( JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT ).put( focusFindShortcut, "focus find" );
+ contentPane.getActionMap().put( "focus find", focusFindAction );
+ KeyStroke findNextShortcut = KeyStroke.getKeyStroke( "F3" );
+ contentPane.getInputMap( JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT ).put( findNextShortcut, "find next" );
+ contentPane.getActionMap().put( "find next", findNextAction );
+ KeyStroke findPreviousShortcut = KeyStroke.getKeyStroke( "shift F3" );
+ contentPane.getInputMap( JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT ).put( findPreviousShortcut, "find previous" );
+ contentPane.getActionMap().put( "find previous", findPreviousAction );
+
+ findField.getInputMap().put( KeyStroke.getKeyStroke( "released ENTER" ), "find next" );
+ findField.getActionMap().put( "find next", findNextAction );
+
+ areasPane.setMnemonicAt( 0, KeyEvent.VK_1 );
+ areasPane.setMnemonicAt( 1, KeyEvent.VK_2 );
+ areasPane.setMnemonicAt( 2, KeyEvent.VK_3 );
+ mainArea.addAncestorListener( new FocusAncestorListener( mainArea ) );
+ appendArea.addAncestorListener( new FocusAncestorListener( appendArea ) );
+ resultArea.addAncestorListener( new FocusAncestorListener( resultArea ) );
+
+ this.pack();
+ }
+
+ @Override
+ public void setVisible( boolean b ) {
+ super.setVisible( b );
+
+ if ( b ) {
+ // Splitpane has to be realized before the divider can be moved.
+ SwingUtilities.invokeLater(new Runnable() {
+ @Override
+ public void run() {
+ splitPane.setDividerLocation( 0.80d );
+ }
+ });
+ }
+ }
+
+ @Override
+ public void actionPerformed( ActionEvent e ) {
+ Object source = e.getSource();
+
+ if ( source == openBtn ) {
+ open();
+ }
+ else if ( source == patchBtn ) {
+ patch();
+ }
+ }
+
+ private void open() {
+ messageArea.setText( "" );
+
+ AbstractPack pack = null;
+ InputStream is = null;
+ try {
+ File ftlDatFile = new File( datsDir, "ftl.dat" );
+ File dataDatFile = new File( datsDir, "data.dat" );
+
+ if ( ftlDatFile.exists() ) { // FTL 1.6.1.
+ pack = new PkgPack( ftlDatFile, "r" );
+ }
+ else if ( dataDatFile.exists() ) { // FTL 1.01-1.5.13.
+ pack = new FTLPack( dataDatFile, "r" );
+ }
+ else {
+ throw new FileNotFoundException( String.format( "Could not find either \"%s\" or \"%s\"", ftlDatFile.getName(), dataDatFile.getName() ) );
+ }
+
+ List innerPaths = pack.list();
+
+ String innerPath = promptForInnerPath( innerPaths );
+ if ( innerPath == null ) return;
+
+ is = pack.getInputStream( innerPath );
+ InputStream rebuiltStream = ModUtilities.rebuildXMLFile( is, "windows-1252", pack.getName()+":"+innerPath );
+ String rebuiltText = ModUtilities.decodeText( rebuiltStream, "Sandbox Main XML" ).text;
+ is.close();
+
+ mainArea.setText( rebuiltText );
+ mainArea.setCaretPosition( 0 );
+ areasPane.setSelectedComponent( mainScroll );
+ resultArea.setText( "" );
+ this.setTitle( String.format( "%s - %s", innerPath, baseTitle ) );
+ }
+ catch ( IOException f ) {
+ messageArea.setText( f.getMessage() );
+ messageArea.setCaretPosition( 0 );
+ }
+ catch ( JDOMException f ) {
+ messageArea.setText( f.getMessage() );
+ messageArea.setCaretPosition( 0 );
+ }
+ finally {
+ try {if ( is != null ) is.close();}
+ catch ( IOException f ) {}
+
+ try {if ( pack != null ) pack.close();}
+ catch ( IOException f ) {}
+ }
+ }
+
+ private void patch() {
+ String mainText = mainArea.getText();
+ if ( mainText.length() == 0 ) return;
+
+ messageArea.setText( "" );
+
+ try {
+ InputStream mainStream = new ByteArrayInputStream( mainText.getBytes( "UTF-8" ) );
+
+ String appendText = appendArea.getText();
+ InputStream appendStream = new ByteArrayInputStream( appendText.getBytes( "UTF-8" ) );
+
+ InputStream resultStream = ModUtilities.patchXMLFile( mainStream, appendStream, "windows-1252", false, "Sandbox Main XML", "Sandbox Append XML" );
+ String resultText = ModUtilities.decodeText( resultStream, "Sandbox Result XML" ).text;
+
+ resultArea.setText( resultText );
+ resultArea.setCaretPosition( 0 );
+ areasPane.setSelectedComponent( resultScroll );
+ }
+ catch ( Exception e ) {
+ messageArea.setText( e.toString() );
+ messageArea.setCaretPosition( 0 );
+ }
+ }
+
+ private void findNext() {
+ JTextArea currentArea = getCurrentArea();
+ if ( currentArea == null ) return;
+
+ String query = findField.getText();
+ if ( query.length() == 0 ) return;
+
+ Caret caret = currentArea.getCaret();
+ int from = Math.max( caret.getDot(), caret.getMark() );
+
+ Pattern ptn = Pattern.compile( "(?i)"+ Pattern.quote( query ) );
+ Matcher m = ptn.matcher( currentArea.getText() );
+ if ( m.find(from) ) {
+ caret.setDot( m.start() );
+ caret.moveDot( m.end() );
+ caret.setSelectionVisible( true );
+ }
+ }
+
+ private void findPrevious() {
+ JTextArea currentArea = getCurrentArea();
+ if ( currentArea == null ) return;
+
+ String query = findField.getText();
+ if ( query.length() == 0 ) return;
+
+ Caret caret = currentArea.getCaret();
+ int from = Math.min( caret.getDot(), caret.getMark() );
+
+ Pattern ptn = Pattern.compile( "(?i)"+ Pattern.quote(query) );
+ Matcher m = ptn.matcher( currentArea.getText() );
+ m.region( 0, from );
+ int lastStart = -1;
+ int lastEnd = -1;
+ while ( m.find() ) {
+ lastStart = m.start();
+ lastEnd = m.end();
+ }
+ if ( lastStart != -1 ) {
+ caret.setDot( lastStart );
+ caret.moveDot( lastEnd );
+ caret.setSelectionVisible( true );
+ }
+ }
+
+ private void updateCaretStatus() {
+ JTextArea currentArea = getCurrentArea();
+ if ( currentArea == null ) return;
+
+ try {
+ int offset = currentArea.getCaretPosition();
+ int line = currentArea.getLineOfOffset( offset );
+ int lineStart = currentArea.getLineStartOffset( line );
+ int col = offset - lineStart;
+ int lineCount = currentArea.getLineCount();
+ statusLbl.setText( String.format( "Line: %4d/%4d Col: %3d", line+1, lineCount, col+1 ) );
+ }
+ catch ( BadLocationException e ) {
+ statusLbl.setText( String.format( "Line: ???/ ??? Col: ???" ) );
+ }
+ }
+
+ private JTextArea getCurrentArea() {
+ if ( areasPane.getSelectedIndex() == 0 )
+ return mainArea;
+ else if ( areasPane.getSelectedIndex() == 1 )
+ return appendArea;
+ else if ( areasPane.getSelectedIndex() == 2 )
+ return resultArea;
+ else
+ return null;
+ }
+
+ /**
+ * Shows a modal prompt with a JTree representing a list of paths.
+ *
+ * @return the selected path, null otherwise
+ */
+ private String promptForInnerPath( List innerPaths ) {
+ String result = null;
+
+ Set sortedPaths = new TreeSet( innerPaths );
+ for ( Iterator it = sortedPaths.iterator(); it.hasNext(); ) {
+ if ( !it.next().endsWith(".xml") ) it.remove();
+ }
+
+ DefaultMutableTreeNode rootNode = new DefaultMutableTreeNode( "/" );
+ DefaultTreeModel treeModel = new DefaultTreeModel( rootNode );
+
+ for ( String innerPath : sortedPaths ) {
+ buildTreeFromString( treeModel, innerPath );
+ }
+
+ JTree pathTree = new JTree( treeModel );
+ pathTree.setRootVisible( false );
+ for ( int i=0; i < pathTree.getRowCount(); i++ ) {
+ pathTree.expandRow( i );
+ }
+ JScrollPane treeScroll = new JScrollPane( pathTree );
+ treeScroll.setPreferredSize( new Dimension( pathTree.getPreferredSize().width, 300 ) );
+
+ pathTree.addAncestorListener( new FocusAncestorListener( pathTree ) );
+
+ int popupResult = JOptionPane.showOptionDialog( this, treeScroll, "Open an XML Resource", JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE, null, new String[]{"OK"}, "OK" );
+
+ if ( popupResult == JOptionPane.OK_OPTION ) {
+ StringBuilder buf = new StringBuilder();
+
+ TreePath selectedPath = pathTree.getSelectionPath();
+ if ( selectedPath != null ) {
+ for ( Object o : selectedPath.getPath() ) {
+ DefaultMutableTreeNode pathComp = (DefaultMutableTreeNode)o;
+ if ( !pathComp.isRoot() ) {
+ Object userObject = pathComp.getUserObject();
+ buf.append( userObject.toString() );
+ }
+ }
+ if ( buf.length() > 0 ) result = buf.toString();
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ * Adds TreeNodes, if they don't already exist, based on a shash-delimited string.
+ */
+ @SuppressWarnings("unchecked")
+ private void buildTreeFromString( DefaultTreeModel treeModel, String path ) {
+// Method commented out to get application to compile. Figure out what this did and fix it
+// DefaultMutableTreeNode rootNode = (DefaultMutableTreeNode)treeModel.getRoot();
+// DefaultMutableTreeNode currentNode = rootNode;
+//
+// String[] chunks = path.split( "/" );
+//
+// for ( int i=0; i < chunks.length; i++ ) {
+// String chunk = chunks[i];
+// if ( i < chunks.length-1 )
+// chunk += "/";
+//
+// boolean found = false;
+// Enumeration enumIt = currentNode.children();
+// while ( enumIt.hasMoreElements() ) {
+// DefaultMutableTreeNode tmpNode = enumIt.nextElement();
+// if ( chunk.equals( tmpNode.getUserObject() ) ) {
+// found = true;
+// currentNode = tmpNode;
+// break;
+// }
+// }
+// if ( !found ) {
+// DefaultMutableTreeNode newNode = new DefaultMutableTreeNode( chunk );
+// currentNode.insert( newNode, currentNode.getChildCount() );
+// currentNode = newNode;
+// }
+// }
+ }
+
+
+
+ private class CaretAncestorListener implements AncestorListener {
+ @Override
+ public void ancestorAdded( AncestorEvent e ) {
+ updateCaretStatus();
+ }
+ @Override
+ public void ancestorMoved( AncestorEvent e ) {
+ }
+ @Override
+ public void ancestorRemoved( AncestorEvent e ) {
+ }
+ }
+
+
+
+ private static class FocusAncestorListener implements AncestorListener {
+ private JComponent comp;
+
+ public FocusAncestorListener( JComponent comp ) {
+ this.comp = comp;
+ }
+
+ @Override
+ public void ancestorAdded( AncestorEvent e ) {
+ comp.requestFocusInWindow();
+ }
+ @Override
+ public void ancestorMoved( AncestorEvent e ) {
+ }
+ @Override
+ public void ancestorRemoved( AncestorEvent e ) {
+ }
+ }
+}
diff --git a/src/main/java/net/vhati/modmanager/ui/Nerfable.java b/src/main/java/net/vhati/modmanager/ui/Nerfable.java
new file mode 100644
index 0000000..cba3762
--- /dev/null
+++ b/src/main/java/net/vhati/modmanager/ui/Nerfable.java
@@ -0,0 +1,16 @@
+package net.vhati.modmanager.ui;
+
+
+/*
+ * An interface to en/disable user interaction.
+ * It was written with JFrames and glass panes in mind.
+ */
+public interface Nerfable {
+
+ /*
+ * Either nerf or restore user interaction.
+ *
+ * @param b the nerfed state
+ */
+ public void setNerfed( boolean b );
+}
diff --git a/src/main/java/net/vhati/modmanager/ui/ProgressDialog.java b/src/main/java/net/vhati/modmanager/ui/ProgressDialog.java
new file mode 100644
index 0000000..7f98c88
--- /dev/null
+++ b/src/main/java/net/vhati/modmanager/ui/ProgressDialog.java
@@ -0,0 +1,224 @@
+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.JScrollPane;
+import javax.swing.JTextArea;
+import javax.swing.SwingUtilities;
+
+
+public class ProgressDialog extends JDialog implements ActionListener {
+
+ protected JScrollPane statusScroll;
+ protected JProgressBar progressBar;
+ protected JTextArea statusArea;
+ protected JButton continueBtn;
+
+ protected boolean continueOnSuccess = false;
+ protected boolean done = false;
+ protected boolean succeeded = false;
+ protected Runnable successTask = null;
+
+
+ public ProgressDialog( Frame owner, boolean continueOnSuccess ) {
+ super( owner, true );
+ this.setDefaultCloseOperation( JDialog.DO_NOTHING_ON_CLOSE );
+
+ this.continueOnSuccess = continueOnSuccess;
+
+ 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( 5, 50 );
+ statusArea.setLineWrap( true );
+ statusArea.setWrapStyleWord( true );
+ statusArea.setFont( statusArea.getFont().deriveFont( 13f ) );
+ statusArea.setEditable( false );
+ statusScroll = new JScrollPane( statusArea );
+
+ JPanel statusHolder = new JPanel( new BorderLayout() );
+ statusHolder.setBorder( BorderFactory.createEmptyBorder( 15, 15, 15, 15 ) );
+ statusHolder.add( statusScroll );
+ 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.pack();
+ this.setMinimumSize( this.getPreferredSize() );
+ this.setLocationRelativeTo( owner );
+ }
+
+
+ @Override
+ public void actionPerformed( ActionEvent e ) {
+ Object source = e.getSource();
+
+ if ( source == continueBtn ) {
+ this.setVisible( false );
+ this.dispose();
+
+ if ( done && succeeded && successTask != null ) {
+ successTask.run();
+ }
+ }
+ }
+
+
+ /**
+ * Updates the text area's content. (Thread-safe)
+ *
+ * @param message a string, or null
+ */
+ public void setStatusTextLater( final String message ) {
+ SwingUtilities.invokeLater(new Runnable() {
+ @Override
+ public void run() {
+ setStatusText( message != null ? message : "..." );
+ }
+ });
+ }
+
+ protected void setStatusText( String message ) {
+ statusArea.setText( message != null ? message : "..." );
+ statusArea.setCaretPosition( 0 );
+ }
+
+
+ /**
+ * Updates the progress bar. (Thread-safe)
+ *
+ * If the arg is -1, the bar will become indeterminate.
+ *
+ * @param value the new value
+ */
+ public void setProgressLater( final int value ) {
+ SwingUtilities.invokeLater(new Runnable() {
+ @Override
+ public void run() {
+ if ( value >= 0 ) {
+ if ( progressBar.isIndeterminate() )
+ progressBar.setIndeterminate( false );
+
+ progressBar.setValue( value );
+ }
+ else {
+ if ( !progressBar.isIndeterminate() )
+ progressBar.setIndeterminate( true );
+ progressBar.setValue( 0 );
+ }
+ }
+ });
+ }
+
+ /**
+ * Updates the progress bar. (Thread-safe)
+ *
+ * If either arg is -1, the bar will become indeterminate.
+ *
+ * @param value the new value
+ * @param max the new maximum
+ */
+ public void setProgressLater( 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 );
+ }
+ }
+ });
+ }
+
+
+ /**
+ * Triggers a response to the immediate task ending. (Thread-safe)
+ *
+ * If anything went wrong, e may be non-null.
+ */
+ public void setTaskOutcomeLater( final boolean success, final Exception e ) {
+ SwingUtilities.invokeLater(new Runnable() {
+ @Override
+ public void run() {
+ setTaskOutcome( success, e );
+ }
+ });
+ }
+
+ protected void setTaskOutcome( final boolean outcome, final Exception e ) {
+ done = true;
+ succeeded = outcome;
+
+ if ( !ProgressDialog.this.isShowing() ) {
+ // The window's not visible, no continueBtn to click.
+ ProgressDialog.this.dispose();
+
+ if ( succeeded && successTask != null ) {
+ successTask.run();
+ }
+ }
+ if ( continueOnSuccess && succeeded && successTask != null ) {
+ ProgressDialog.this.setVisible( false );
+ ProgressDialog.this.dispose();
+ successTask.run();
+ }
+ else {
+ continueBtn.setEnabled( true );
+ continueBtn.requestFocusInWindow();
+ }
+ }
+
+
+ /**
+ * Sets a runnable to trigger after the immediate task ends successfully.
+ */
+ public void setSuccessTask( Runnable r ) {
+ successTask = r;
+ }
+
+ /**
+ * Shows or hides this component depending on the value of parameter b.
+ *
+ * If the immediate task has already completed,
+ * this method will do nothing.
+ */
+ public void setVisible( boolean b ) {
+ if ( !done ) super.setVisible( b );
+ }
+}
diff --git a/src/main/java/net/vhati/modmanager/ui/RegexDocument.java b/src/main/java/net/vhati/modmanager/ui/RegexDocument.java
new file mode 100644
index 0000000..e1a03e9
--- /dev/null
+++ b/src/main/java/net/vhati/modmanager/ui/RegexDocument.java
@@ -0,0 +1,69 @@
+package net.vhati.modmanager.ui;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
+import javax.swing.text.AttributeSet;
+import javax.swing.text.BadLocationException;
+import javax.swing.text.PlainDocument;
+
+
+/**
+ * A Document thats restricts characters based on a regex.
+ *
+ * @see javax.swing.JTextField.setDocument(javax.swing.text.Ducument)
+ */
+public class RegexDocument extends PlainDocument {
+
+ private Pattern pattern = null;
+
+
+ public RegexDocument( Pattern p ) {
+ pattern = p;
+ }
+
+ public RegexDocument( String regex ) {
+ try {
+ if ( regex != null && regex.length() > 0 ) {
+ pattern = Pattern.compile( regex );
+ }
+ }
+ catch ( PatternSyntaxException e ) {}
+ }
+
+ public RegexDocument() {
+ }
+
+
+ @Override
+ public void insertString( int offs, String str, AttributeSet a ) throws BadLocationException {
+ if ( str == null ) return;
+
+ boolean proceed = true;
+
+ if ( pattern != null ) {
+ String tmp = super.getText( 0, offs ) + str + (super.getLength()>offs ? super.getText( offs, super.getLength()-offs ) : "");
+ Matcher m = pattern.matcher( tmp );
+ proceed = m.matches();
+ }
+
+ if ( proceed == true ) super.insertString( offs, str, a );
+ }
+
+
+ @Override
+ public void remove( int offs, int len ) throws BadLocationException {
+ boolean proceed = true;
+
+ if ( pattern != null ) {
+ try {
+ String tmp = super.getText( 0, offs ) + (super.getLength()>(offs+len) ? super.getText( offs+len, super.getLength()-(offs+len) ) : "");
+ Matcher m = pattern.matcher( tmp );
+ proceed = m.matches();
+ }
+ catch ( BadLocationException e ) {}
+ }
+
+ if ( proceed == true ) super.remove( offs, len );
+ }
+}
diff --git a/src/main/java/net/vhati/modmanager/ui/SlipstreamConfigDialog.java b/src/main/java/net/vhati/modmanager/ui/SlipstreamConfigDialog.java
new file mode 100644
index 0000000..85bb94d
--- /dev/null
+++ b/src/main/java/net/vhati/modmanager/ui/SlipstreamConfigDialog.java
@@ -0,0 +1,211 @@
+package net.vhati.modmanager.ui;
+
+import java.awt.BorderLayout;
+import java.awt.Dimension;
+import java.awt.Frame;
+import java.awt.Point;
+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.JCheckBox;
+import javax.swing.JDialog;
+import javax.swing.JFileChooser;
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import javax.swing.JTextField;
+import javax.swing.ScrollPaneConstants;
+import javax.swing.event.AncestorEvent;
+import javax.swing.event.AncestorListener;
+
+import net.vhati.modmanager.core.FTLUtilities;
+import net.vhati.modmanager.core.SlipstreamConfig;
+import net.vhati.modmanager.ui.FieldEditorPanel;
+import net.vhati.modmanager.ui.FieldEditorPanel.ContentType;
+
+
+public class SlipstreamConfigDialog extends JDialog implements ActionListener {
+
+ protected static final String ALLOW_ZIP = SlipstreamConfig.ALLOW_ZIP;
+ protected static final String RUN_STEAM_FTL = SlipstreamConfig.RUN_STEAM_FTL;
+ protected static final String NEVER_RUN_FTL = SlipstreamConfig.NEVER_RUN_FTL;
+ protected static final String USE_DEFAULT_UI = SlipstreamConfig.USE_DEFAULT_UI;
+ protected static final String REMEMBER_GEOMETRY = SlipstreamConfig.REMEMBER_GEOMETRY;
+ protected static final String UPDATE_CATALOG = SlipstreamConfig.UPDATE_CATALOG;
+ protected static final String UPDATE_APP = SlipstreamConfig.UPDATE_APP;
+ protected static final String FTL_DATS_PATH = SlipstreamConfig.FTL_DATS_PATH;
+ protected static final String STEAM_EXE_PATH = SlipstreamConfig.STEAM_EXE_PATH;
+
+ protected SlipstreamConfig appConfig;
+
+ protected FieldEditorPanel editorPanel;
+ protected JButton applyBtn;
+
+
+ public SlipstreamConfigDialog( Frame owner, SlipstreamConfig appConfig ) {
+ super( owner, "Preferences..." );
+ this.setDefaultCloseOperation( JDialog.DISPOSE_ON_CLOSE );
+
+ this.appConfig = appConfig;
+
+ editorPanel = new FieldEditorPanel( false );
+ editorPanel.setBorder( BorderFactory.createEmptyBorder( 10, 10, 0, 10 ) );
+ editorPanel.setNameWidth( 250 );
+
+ editorPanel.addRow( ALLOW_ZIP, ContentType.BOOLEAN );
+ editorPanel.addTextRow( "Treat .zip files as .ftl files." );
+ editorPanel.addSeparatorRow();
+ editorPanel.addRow( RUN_STEAM_FTL, ContentType.BOOLEAN );
+ editorPanel.addTextRow( "Use Steam to run FTL, if possible." );
+ editorPanel.addSeparatorRow();
+ editorPanel.addRow( NEVER_RUN_FTL, ContentType.BOOLEAN );
+ editorPanel.addTextRow( "Don't offer to run FTL after patching." );
+ editorPanel.addSeparatorRow();
+ editorPanel.addRow( USE_DEFAULT_UI, ContentType.BOOLEAN );
+ editorPanel.addTextRow( "Don't attempt to resemble a native GUI." );
+ editorPanel.addSeparatorRow();
+ editorPanel.addRow( REMEMBER_GEOMETRY, ContentType.BOOLEAN );
+ editorPanel.addTextRow( "Save window geometry on exit." );
+ editorPanel.addSeparatorRow();
+ editorPanel.addRow( UPDATE_CATALOG, ContentType.INTEGER );
+ editorPanel.addTextRow( "Check for new mod descriptions every N days. (0 to disable)" );
+ editorPanel.addSeparatorRow();
+ editorPanel.addRow( UPDATE_APP, ContentType.INTEGER );
+ editorPanel.addTextRow( "Check for newer app versions every N days. (0 to disable)" );
+ editorPanel.addSeparatorRow();
+ editorPanel.addRow( FTL_DATS_PATH, ContentType.CHOOSER );
+ editorPanel.addTextRow( "Path to FTL's resources folder." );
+ editorPanel.addSeparatorRow();
+ editorPanel.addRow( STEAM_EXE_PATH, ContentType.CHOOSER );
+ editorPanel.addTextRow( "Path to Steam's executable." );
+ editorPanel.addSeparatorRow();
+ editorPanel.addBlankRow();
+ editorPanel.addTextRow( "Note: Some changes may have no immediate effect." );
+ editorPanel.addBlankRow();
+ editorPanel.addFillRow();
+
+ editorPanel.getBoolean( ALLOW_ZIP ).setSelected( "true".equals( appConfig.getProperty( SlipstreamConfig.ALLOW_ZIP, "false" ) ) );
+ editorPanel.getBoolean( RUN_STEAM_FTL ).setSelected( "true".equals( appConfig.getProperty( SlipstreamConfig.RUN_STEAM_FTL, "false" ) ) );
+ editorPanel.getBoolean( NEVER_RUN_FTL ).setSelected( "true".equals( appConfig.getProperty( SlipstreamConfig.NEVER_RUN_FTL, "false" ) ) );
+ editorPanel.getBoolean( USE_DEFAULT_UI ).setSelected( "true".equals( appConfig.getProperty( SlipstreamConfig.USE_DEFAULT_UI, "false" ) ) );
+ editorPanel.getBoolean( REMEMBER_GEOMETRY ).setSelected( "true".equals( appConfig.getProperty( SlipstreamConfig.REMEMBER_GEOMETRY, "true" ) ) );
+ editorPanel.getInt( UPDATE_CATALOG ).setText( Integer.toString( appConfig.getPropertyAsInt( SlipstreamConfig.UPDATE_CATALOG, 0 ) ) );
+ editorPanel.getInt( UPDATE_APP ).setText( Integer.toString( appConfig.getPropertyAsInt( SlipstreamConfig.UPDATE_APP, 0 ) ) );
+
+ JTextField ftlDatsPathField = editorPanel.getChooser( FTL_DATS_PATH ).getTextField();
+ ftlDatsPathField.setText( appConfig.getProperty( SlipstreamConfig.FTL_DATS_PATH, "" ) );
+ ftlDatsPathField.setPreferredSize( new Dimension( 150, ftlDatsPathField.getPreferredSize().height ) );
+ editorPanel.getChooser( FTL_DATS_PATH ).getButton().addActionListener( this );
+
+ JTextField steamExePathField = editorPanel.getChooser( STEAM_EXE_PATH ).getTextField();
+ steamExePathField.setText( appConfig.getProperty( SlipstreamConfig.STEAM_EXE_PATH, "" ) );
+ steamExePathField.setPreferredSize( new Dimension( 150, steamExePathField.getPreferredSize().height ) );
+ editorPanel.getChooser( STEAM_EXE_PATH ).getButton().addActionListener( this );
+
+ JPanel ctrlPanel = new JPanel();
+ ctrlPanel.setLayout( new BoxLayout( ctrlPanel, BoxLayout.X_AXIS ) );
+ ctrlPanel.setBorder( BorderFactory.createEmptyBorder( 10, 0, 10, 0 ) );
+ ctrlPanel.add( Box.createHorizontalGlue() );
+ applyBtn = new JButton( "Apply" );
+ applyBtn.addActionListener( this );
+ ctrlPanel.add( applyBtn );
+ ctrlPanel.add( Box.createHorizontalGlue() );
+
+ final JScrollPane editorScroll = new JScrollPane( editorPanel );
+ editorScroll.setVerticalScrollBarPolicy( ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS );
+ editorScroll.getVerticalScrollBar().setUnitIncrement( 10 );
+ int vbarWidth = editorScroll.getVerticalScrollBar().getPreferredSize().width;
+ editorScroll.setPreferredSize( new Dimension( editorPanel.getPreferredSize().width+vbarWidth+5, 400 ) );
+
+ JPanel contentPane = new JPanel( new BorderLayout() );
+ contentPane.add( editorScroll, BorderLayout.CENTER );
+ contentPane.add( ctrlPanel, BorderLayout.SOUTH );
+ this.setContentPane( contentPane );
+ this.pack();
+ this.setMinimumSize( new Dimension( 250, 250 ) );
+
+
+ editorScroll.addAncestorListener(new AncestorListener() {
+ @Override
+ public void ancestorAdded( AncestorEvent e ) {
+ editorScroll.getViewport().setViewPosition( new Point( 0, 0 ) );
+ }
+ @Override
+ public void ancestorMoved( AncestorEvent e ) {
+ }
+ @Override
+ public void ancestorRemoved( AncestorEvent e ) {
+ }
+ });
+ }
+
+ @Override
+ public void actionPerformed( ActionEvent e ) {
+ Object source = e.getSource();
+
+ if ( source == applyBtn ) {
+ String tmp;
+ appConfig.setProperty( SlipstreamConfig.ALLOW_ZIP, editorPanel.getBoolean( ALLOW_ZIP ).isSelected() ? "true" : "false" );
+ appConfig.setProperty( SlipstreamConfig.RUN_STEAM_FTL, editorPanel.getBoolean( RUN_STEAM_FTL ).isSelected() ? "true" : "false" );
+ appConfig.setProperty( SlipstreamConfig.NEVER_RUN_FTL, editorPanel.getBoolean( NEVER_RUN_FTL ).isSelected() ? "true" : "false" );
+ appConfig.setProperty( SlipstreamConfig.USE_DEFAULT_UI, editorPanel.getBoolean( USE_DEFAULT_UI ).isSelected() ? "true" : "false" );
+ appConfig.setProperty( SlipstreamConfig.REMEMBER_GEOMETRY, editorPanel.getBoolean( REMEMBER_GEOMETRY ).isSelected() ? "true" : "false" );
+
+ tmp = editorPanel.getInt( UPDATE_CATALOG ).getText();
+ try {
+ int n = Integer.parseInt( tmp );
+ n = Math.max( 0, n );
+ appConfig.setProperty( SlipstreamConfig.UPDATE_CATALOG, Integer.toString( n ) );
+ }
+ catch ( NumberFormatException f ) {}
+
+ tmp = editorPanel.getInt( UPDATE_APP ).getText();
+ try {
+ int n = Integer.parseInt( tmp );
+ n = Math.max( 0, n );
+ appConfig.setProperty( SlipstreamConfig.UPDATE_APP, Integer.toString( n ) );
+ }
+ catch ( NumberFormatException f ) {}
+
+ tmp = editorPanel.getChooser( FTL_DATS_PATH ).getTextField().getText();
+ if ( tmp.length() > 0 && FTLUtilities.isDatsDirValid( new File( tmp ) ) ) {
+ appConfig.setProperty( SlipstreamConfig.FTL_DATS_PATH, tmp );
+ }
+
+ tmp = editorPanel.getChooser( STEAM_EXE_PATH ).getTextField().getText();
+ if ( tmp.length() > 0 && new File( tmp ).exists() ) {
+ appConfig.setProperty( SlipstreamConfig.STEAM_EXE_PATH, tmp );
+ }
+
+ this.setVisible( false );
+ this.dispose();
+ }
+ else if ( source == editorPanel.getChooser( FTL_DATS_PATH ).getButton() ) {
+ File datsDir = FTLUtilities.promptForDatsDir( this );
+ if ( datsDir != null ) {
+ editorPanel.getChooser( FTL_DATS_PATH ).getTextField().setText( datsDir.getAbsolutePath() );
+ }
+ }
+ else if ( source == editorPanel.getChooser( STEAM_EXE_PATH ).getButton() ) {
+ String currentPath = editorPanel.getChooser( STEAM_EXE_PATH ).getTextField().getText();
+
+ JFileChooser steamExeChooser = new JFileChooser();
+ steamExeChooser.setDialogTitle( "Find Steam.exe or steam or Steam.app" );
+ steamExeChooser.setFileHidingEnabled( false );
+ steamExeChooser.setMultiSelectionEnabled( false );
+ if ( currentPath.length() > 0 ) {
+ steamExeChooser.setCurrentDirectory( new File( currentPath ) );
+ }
+
+ if ( steamExeChooser.showOpenDialog( null ) == JFileChooser.APPROVE_OPTION ) {
+ File steamExeFile = steamExeChooser.getSelectedFile();
+ if ( steamExeFile.exists() ) {
+ editorPanel.getChooser( STEAM_EXE_PATH ).getTextField().setText( steamExeFile.getAbsolutePath() );
+ }
+ }
+ }
+ }
+}
diff --git a/src/main/java/net/vhati/modmanager/ui/Statusbar.java b/src/main/java/net/vhati/modmanager/ui/Statusbar.java
new file mode 100644
index 0000000..e148bcd
--- /dev/null
+++ b/src/main/java/net/vhati/modmanager/ui/Statusbar.java
@@ -0,0 +1,6 @@
+package net.vhati.modmanager.ui;
+
+
+public interface Statusbar {
+ public void setStatusText( String text );
+}
diff --git a/src/main/java/net/vhati/modmanager/ui/StatusbarMouseListener.java b/src/main/java/net/vhati/modmanager/ui/StatusbarMouseListener.java
new file mode 100644
index 0000000..b8685e2
--- /dev/null
+++ b/src/main/java/net/vhati/modmanager/ui/StatusbarMouseListener.java
@@ -0,0 +1,38 @@
+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 {
+
+ protected Statusbar bar = null;
+ protected 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( "" );
+ }
+}
diff --git a/src/main/java/net/vhati/modmanager/ui/table/ChecklistTableModel.java b/src/main/java/net/vhati/modmanager/ui/table/ChecklistTableModel.java
new file mode 100644
index 0000000..155d21e
--- /dev/null
+++ b/src/main/java/net/vhati/modmanager/ui/table/ChecklistTableModel.java
@@ -0,0 +1,117 @@
+package net.vhati.modmanager.ui.table;
+
+import java.util.ArrayList;
+import java.util.List;
+import javax.swing.table.AbstractTableModel;
+
+import net.vhati.modmanager.core.ModInfo;
+import net.vhati.modmanager.ui.table.Reorderable;
+
+
+public class ChecklistTableModel 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> rowsList = new ArrayList>();
+
+
+ public void addItem( T o ) {
+ insertItem( rowsList.size(), false, o );
+ }
+
+ public void insertItem( int row, boolean selected, T o ) {
+ int newRowIndex = rowsList.size();
+
+ List