diff --git a/src/main/java/net/vhati/modmanager/FTLModManager.java b/src/main/java/net/vhati/modmanager/FTLModManager.java index 9f1c8be..61d4666 100644 --- a/src/main/java/net/vhati/modmanager/FTLModManager.java +++ b/src/main/java/net/vhati/modmanager/FTLModManager.java @@ -48,6 +48,7 @@ public class FTLModManager { config.setProperty( "use_default_ui", "false" ); config.setProperty( "remember_geometry", "true" ); // "update_catalog" doesn't have a default. + // "update_app" doesn't have a default. // "manager_geometry" doesn't have a default. // Read the config file. @@ -128,19 +129,30 @@ public class FTLModManager { } // Prompt if update_catalog is invalid or hasn't been set. + boolean askAboutUpdates = false; + String catalogUpdateInterval = config.getProperty( "update_catalog" ); + String appUpdateInterval = config.getProperty( "update_app" ); - String updateCatalog = config.getProperty( "update_catalog" ); - if ( updateCatalog == null || !updateCatalog.matches("^true|false$") ) { + if ( catalogUpdateInterval == null || !catalogUpdateInterval.matches("^\\d+$") ) + askAboutUpdates = true; + if ( appUpdateInterval == null || !appUpdateInterval.matches("^\\d+$") ) + askAboutUpdates = true; + + if ( askAboutUpdates ) { String message = ""; message += "Would you like Slipstream to periodically\n"; - message += "download descriptions for the latest mods?\n\n"; + message += "check for updates and download descriptions\n"; + message += "for the latest mods?\n\n"; message += "You can change this later in modman.cfg."; - int response = JOptionPane.showConfirmDialog(null, message, "Catalog Updates", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE); - if ( response == JOptionPane.YES_OPTION ) - config.setProperty( "update_catalog", "true" ); - else - config.setProperty( "update_catalog", "false" ); + int response = JOptionPane.showConfirmDialog(null, message, "Updates", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE); + if ( response == JOptionPane.YES_OPTION ) { + config.setProperty( "update_catalog", "7" ); + config.setProperty( "update_app", "4" ); + } else { + config.setProperty( "update_catalog", "0" ); + config.setProperty( "update_app", "0" ); + } } diff --git a/src/main/java/net/vhati/modmanager/core/AutoUpdateInfo.java b/src/main/java/net/vhati/modmanager/core/AutoUpdateInfo.java new file mode 100644 index 0000000..65006f7 --- /dev/null +++ b/src/main/java/net/vhati/modmanager/core/AutoUpdateInfo.java @@ -0,0 +1,45 @@ +package net.vhati.modmanager.core; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import net.vhati.modmanager.core.ComparableVersion; + + +/** + * Holds info about available updates. + */ +public class AutoUpdateInfo { + private ComparableVersion latestVersion = null; + private Map latestURLs = new TreeMap(); + private Map> changelog = new TreeMap>( Collections.reverseOrder() ); + + + public void setLatestVersion( ComparableVersion version ) { + latestVersion = version; + } + + public ComparableVersion getLatestVersion() { + return latestVersion; + } + + + public void putLatestURL( String os, String url ) { + latestURLs.put( os, url ); + } + + public void putChanges( ComparableVersion version, List changeList ) { + changelog.put( version, changeList ); + } + + + public Map getLatestURLs() { + return latestURLs; + } + + public Map> getChangelog() { + return changelog; + } +} diff --git a/src/main/java/net/vhati/modmanager/core/SlipstreamConfig.java b/src/main/java/net/vhati/modmanager/core/SlipstreamConfig.java index 1a4839d..d93cce0 100644 --- a/src/main/java/net/vhati/modmanager/core/SlipstreamConfig.java +++ b/src/main/java/net/vhati/modmanager/core/SlipstreamConfig.java @@ -29,6 +29,14 @@ public class SlipstreamConfig { return config.setProperty( key, value ); } + public int getPropertyAsInt( String key, int defaultValue ) { + String s = config.getProperty( key ); + if ( s != null && s.matches("^\\d+$") ) + return Integer.parseInt( s ); + else + return defaultValue; + } + public String getProperty( String key, String defaultValue ) { return config.getProperty( key, defaultValue ); } @@ -48,7 +56,8 @@ public class SlipstreamConfig { configComments += " allow_zip - Sets whether to treat .zip files as .ftl files. Default: false.\n"; configComments += " ftl_dats_path - The path to FTL's resources folder. If invalid, you'll be prompted.\n"; configComments += " never_run_ftl - If true, there will be no offer to run FTL after patching. Default: false.\n"; - configComments += " update_catalog - If true, periodically download descriptions for the latest mods.\n"; + configComments += " update_catalog - If a number greater than 0, check for new mod descriptions every N days.\n"; + configComments += " update_app - If a number greater than 0, check for new app version every N days.\n"; configComments += " use_default_ui - If true, no attempt will be made to resemble a native GUI. Default: false.\n"; configComments += " remember_geometry - If true, window geometry will be saved on exit and restored on startup.\n"; configComments += "\n"; diff --git a/src/main/java/net/vhati/modmanager/json/JacksonAutoUpdateReader.java b/src/main/java/net/vhati/modmanager/json/JacksonAutoUpdateReader.java new file mode 100644 index 0000000..8b9dd62 --- /dev/null +++ b/src/main/java/net/vhati/modmanager/json/JacksonAutoUpdateReader.java @@ -0,0 +1,77 @@ +package net.vhati.modmanager.json; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +import net.vhati.modmanager.core.AutoUpdateInfo; +import net.vhati.modmanager.core.ComparableVersion; + +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + + +public class JacksonAutoUpdateReader { + + private static final Logger log = LogManager.getLogger(JacksonAutoUpdateReader.class); + + + public static AutoUpdateInfo parse( File jsonFile ) { + AutoUpdateInfo aui = new AutoUpdateInfo(); + + Exception exception = null; + try { + ObjectMapper mapper = new ObjectMapper(); + mapper.configure( JsonParser.Feature.ALLOW_SINGLE_QUOTES, true ); + mapper.setVisibility( PropertyAccessor.FIELD, Visibility.ANY ); + + JsonNode rootNode = mapper.readTree( jsonFile ); + JsonNode historiesNode = rootNode.get( "history_versions" ); + JsonNode historyNode = historiesNode.get( "1" ); + + JsonNode latestNode = historyNode.get( "latest" ); + aui.setLatestVersion( new ComparableVersion( latestNode.get( "version" ).textValue() ) ); + + Iterator> fieldIt = latestNode.get( "urls" ).fields(); + while ( fieldIt.hasNext() ) { + Map.Entry entry = fieldIt.next(); + aui.putLatestURL( entry.getKey(), entry.getValue().textValue() ); + } + + JsonNode changelogNode = historyNode.get( "changelog" ); + + for ( JsonNode releaseNode : changelogNode ) { + String releaseVersion = releaseNode.get( "version" ).textValue(); + + List changeList = new ArrayList( releaseNode.get( "changes" ).size() ); + for ( JsonNode changeNode : releaseNode.get( "changes" ) ) { + changeList.add( changeNode.textValue() ); + } + aui.putChanges( new ComparableVersion( releaseVersion ), changeList ); + } + } + catch ( JsonProcessingException e ) { + exception = e; + } + catch ( IOException e ) { + exception = e; + } + if ( exception != null ) { + log.error( exception ); + return null; + } + + return aui; + } +} diff --git a/src/main/java/net/vhati/modmanager/json/URLFetcher.java b/src/main/java/net/vhati/modmanager/json/URLFetcher.java index d553cb4..5d32797 100644 --- a/src/main/java/net/vhati/modmanager/json/URLFetcher.java +++ b/src/main/java/net/vhati/modmanager/json/URLFetcher.java @@ -76,7 +76,7 @@ public class URLFetcher { int responseCode = httpConn.getResponseCode(); if ( responseCode == HttpURLConnection.HTTP_NOT_MODIFIED ) { - log.debug( String.format( "The server's \"%s\" has not been modified since the previous check.", httpConn.getURL().getFile() ) ); + log.debug( String.format( "No need to update \"%s\", the server's copy has not been modified since the previous check.", localFile.getName() ) ); // Update the local file's timestamp as if it had downloaded. localFile.setLastModified( new Date().getTime() ); @@ -98,7 +98,7 @@ public class URLFetcher { } } else { - log.error( String.format( "Download request failed: HTTP Code %d (%s).", responseCode, httpConn.getResponseMessage() ) ); + log.error( String.format( "Download request failed for \"%s\": HTTP Code %d (%s).", httpConn.getURL(), responseCode, httpConn.getResponseMessage() ) ); return false; } } diff --git a/src/main/java/net/vhati/modmanager/ui/ManagerFrame.java b/src/main/java/net/vhati/modmanager/ui/ManagerFrame.java index 7a74623..4289bd2 100644 --- a/src/main/java/net/vhati/modmanager/ui/ManagerFrame.java +++ b/src/main/java/net/vhati/modmanager/ui/ManagerFrame.java @@ -33,6 +33,7 @@ import java.util.HashMap; import java.util.ArrayList; import java.util.Iterator; import java.util.List; +import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.swing.BorderFactory; @@ -59,6 +60,7 @@ import javax.swing.event.ListSelectionListener; import javax.swing.table.DefaultTableModel; import net.vhati.ftldat.FTLDat; +import net.vhati.modmanager.core.AutoUpdateInfo; import net.vhati.modmanager.core.ComparableVersion; import net.vhati.modmanager.core.FTLUtilities; import net.vhati.modmanager.core.HashObserver; @@ -72,6 +74,7 @@ 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.JacksonAutoUpdateReader; import net.vhati.modmanager.json.JacksonGrognakCatalogReader; import net.vhati.modmanager.json.URLFetcher; import net.vhati.modmanager.ui.ChecklistTableModel; @@ -92,26 +95,30 @@ public class ManagerFrame extends JFrame implements ActionListener, HashObserver private static final Logger log = LogManager.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 AUTOUPDATE_URL = "https://raw.github.com/Vhati/Slipstream-Mod-Manager/master/auto-update.json"; + public static final String APP_UPDATE_URL = "https://raw.github.com/Vhati/Slipstream-Mod-Manager/master/auto_update.json"; private File backupDir = new File( "./backup/" ); private File modsDir = new File( "./mods/" ); - private int catalogFetchInterval = 7; // Days. private File catalogFile = new File( backupDir, "current_catalog.json" ); private File catalogETagFile = new File( backupDir, "current_catalog_etag.txt" ); + private File appUpdateFile = new File( backupDir, "auto_update.json" ); + private File appUpdateETagFile = new File( backupDir, "auto_update_etag.txt" ); + private SlipstreamConfig appConfig; private String appName; private ComparableVersion appVersion; private String appURL; private String appAuthor; - private NerfListener nerfListener = new NerfListener( this ); - private HashMap modFileHashes = new HashMap(); private ModDB modDB = new ModDB(); + private AutoUpdateInfo appUpdateInfo = null; + + private NerfListener nerfListener = new NerfListener( this ); + private ChecklistTableModel localModsTableModel; private JTable localModsTable; @@ -128,6 +135,7 @@ public class ManagerFrame extends JFrame implements ActionListener, HashObserver private JButton toggleAllBtn; private JButton validateBtn; private JButton modsFolderBtn; + private JButton updateBtn; private JSplitPane splitPane; private ModInfoArea infoArea; @@ -198,9 +206,16 @@ public class ManagerFrame extends JFrame implements ActionListener, HashObserver 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.setEnabled( false ); + modActionsPanel.add( updateBtn ); + topPanel.add( modActionsPanel, BorderLayout.EAST ); - JButton[] actionBtns = new JButton[] {patchBtn, toggleAllBtn, validateBtn, modsFolderBtn }; + JButton[] actionBtns = new JButton[] {patchBtn, toggleAllBtn, validateBtn, modsFolderBtn, updateBtn }; int actionBtnWidth = Integer.MIN_VALUE; int actionBtnHeight = Integer.MIN_VALUE; for ( JButton btn : actionBtns ) { @@ -402,6 +417,7 @@ public class ManagerFrame extends JFrame implements ActionListener, HashObserver List preferredOrder = loadModOrder(); rescanMods( preferredOrder ); + int catalogUpdateInterval = appConfig.getPropertyAsInt( "update_catalog", 0 ); boolean needNewCatalog = false; if ( catalogFile.exists() ) { @@ -409,15 +425,17 @@ public class ManagerFrame extends JFrame implements ActionListener, HashObserver ModDB currentDB = JacksonGrognakCatalogReader.parse( catalogFile ); if ( currentDB != null ) modDB = currentDB; - // Check if the downloaded catalog is stale. - Date catalogDate = new Date( catalogFile.lastModified() ); - Calendar cal = Calendar.getInstance(); - cal.add( Calendar.DATE, catalogFetchInterval * -1 ); - if ( catalogDate.before( cal.getTime() ) ) { - log.debug( String.format( "Catalog is older than %d days.", catalogFetchInterval ) ); - needNewCatalog = true; - } else { - log.debug( "Catalog isn't stale yet." ); + if ( catalogUpdateInterval > 0 ) { + // Check if the downloaded catalog is stale. + Date catalogDate = new Date( catalogFile.lastModified() ); + Calendar cal = Calendar.getInstance(); + cal.add( Calendar.DATE, catalogUpdateInterval * -1 ); + if ( catalogDate.before( cal.getTime() ) ) { + log.debug( String.format( "Catalog is older than %d days.", catalogUpdateInterval ) ); + needNewCatalog = true; + } else { + log.debug( "Catalog isn't stale yet." ); + } } } else { @@ -426,8 +444,7 @@ public class ManagerFrame extends JFrame implements ActionListener, HashObserver } // Don't update if the user doesn't want to. - String updatesAllowed = appConfig.getProperty( "update_catalog", "false" ); - if ( !updatesAllowed.equals("true") ) needNewCatalog = false; + if ( catalogUpdateInterval <= 0 ) needNewCatalog = false; if ( needNewCatalog ) { Runnable fetchTask = new Runnable() { @@ -441,11 +458,56 @@ public class ManagerFrame extends JFrame implements ActionListener, HashObserver Thread fetchThread = new Thread( fetchTask ); fetchThread.start(); } + + int appUpdateInterval = appConfig.getPropertyAsInt( "update_app", 0 ); + boolean needAppUpdate = false; + + if ( appUpdateFile.exists() ) { + // Load the info first, before downloading. + AutoUpdateInfo aui = JacksonAutoUpdateReader.parse( appUpdateFile ); + if ( aui != null ) { + appUpdateInfo = aui; + updateBtn.setEnabled( appVersion.compareTo(appUpdateInfo.getLatestVersion()) < 0 ); + } + + if ( appUpdateInterval > 0 ) { + // Check if the app update info is stale. + Date catalogDate = new Date( appUpdateFile.lastModified() ); + Calendar cal = Calendar.getInstance(); + cal.add( Calendar.DATE, catalogUpdateInterval * -1 ); + if ( catalogDate.before( cal.getTime() ) ) { + 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; + } + + // Don't update if the user doesn't want to. + if ( appUpdateInterval <= 0 ) needAppUpdate = false; + + if ( needAppUpdate ) { + Runnable fetchTask = new Runnable() { + @Override + public void run() { + boolean fetched = URLFetcher.refetchURL( APP_UPDATE_URL, appUpdateFile, appUpdateETagFile ); + + if ( fetched ) reloadAppUpdateInfo(); + } + }; + Thread fetchThread = new Thread( fetchTask ); + fetchThread.start(); + } } /** - * Reparses and replace the downloaded ModDB catalog. (thread-safe) + * Reparses and replaces the downloaded ModDB catalog. (thread-safe) */ public void reloadCatalog() { SwingUtilities.invokeLater(new Runnable() { @@ -459,6 +521,24 @@ public class ManagerFrame extends JFrame implements ActionListener, HashObserver }); } + /** + * Reparses info about available app updates. (thread-safe) + */ + public void reloadAppUpdateInfo() { + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + if ( appUpdateFile.exists() ) { + AutoUpdateInfo aui = JacksonAutoUpdateReader.parse( appUpdateFile ); + if ( aui != null ) { + appUpdateInfo = aui; + updateBtn.setEnabled( appVersion.compareTo(appUpdateInfo.getLatestVersion()) < 0 ); + } + } + } + }); + } + /** * Returns a mod list with names sorted in a preferred order. @@ -579,6 +659,42 @@ public class ManagerFrame extends JFrame implements ActionListener, HashObserver infoArea.setDescription( appName, appAuthor, appVersion.toString(), appURL, body ); } + public void showAppUpdateInfo() { + StringBuilder buf = new StringBuilder(); + + 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" ); + } + } + + try { + infoArea.clear(); + infoArea.appendTitleText( "What's New\n" ); + 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" ); + 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. */ @@ -720,6 +836,9 @@ public class ManagerFrame extends JFrame implements ActionListener, HashObserver log.error( "Error opening mods/ folder.", f ); } } + else if ( source == updateBtn ) { + showAppUpdateInfo(); + } else if ( source == rescanMenuItem ) { setStatusText( "" ); if ( rescanMenuItem.isEnabled() == false ) return;