001/* 002 * Gridarta MMORPG map editor for Crossfire, Daimonin and similar games. 003 * Copyright (C) 2000-2011 The Gridarta Developers. 004 * 005 * This program is free software; you can redistribute it and/or modify 006 * it under the terms of the GNU General Public License as published by 007 * the Free Software Foundation; either version 2 of the License, or 008 * (at your option) any later version. 009 * 010 * This program is distributed in the hope that it will be useful, 011 * but WITHOUT ANY WARRANTY; without even the implied warranty of 012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 013 * GNU General Public License for more details. 014 * 015 * You should have received a copy of the GNU General Public License along 016 * with this program; if not, write to the Free Software Foundation, Inc., 017 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 018 */ 019 020package net.sf.gridarta.updater; 021 022import java.awt.Component; 023import java.io.File; 024import java.io.FileOutputStream; 025import java.io.IOException; 026import java.io.InputStream; 027import java.io.InterruptedIOException; 028import java.io.OutputStream; 029import java.net.Proxy; 030import java.net.URL; 031import java.net.URLConnection; 032import java.net.UnknownHostException; 033import java.util.MissingResourceException; 034import java.util.PropertyResourceBundle; 035import java.util.ResourceBundle; 036import java.util.prefs.Preferences; 037import javax.swing.JOptionPane; 038import javax.swing.ProgressMonitor; 039import javax.swing.ProgressMonitorInputStream; 040import net.sf.gridarta.MainControl; 041import net.sf.gridarta.gui.dialog.prefs.NetPreferences; 042import net.sf.gridarta.utils.ActionBuilderUtils; 043import net.sf.gridarta.utils.Exiter; 044import net.sf.japi.swing.action.ActionBuilder; 045import net.sf.japi.swing.action.ActionBuilderFactory; 046import org.apache.log4j.Category; 047import org.apache.log4j.Logger; 048import org.jetbrains.annotations.NotNull; 049import org.jetbrains.annotations.Nullable; 050 051/** 052 * This class handles updating the map editor. 053 * @author <a href="mailto:cher@riedquat.de">Christian.Hujer</a> 054 * @fixme the updater fails on windows, the user is notified of this but still 055 * it isn't nice. The updater should be a separate application. 056 * @todo move the updater to JAPI 057 */ 058public class Updater implements Runnable { 059 060 /** 061 * Action Builder to create Actions. 062 */ 063 @NotNull 064 private static final ActionBuilder ACTION_BUILDER = ActionBuilderFactory.getInstance().getActionBuilder("net.sf.gridarta"); 065 066 /** 067 * Logger. 068 */ 069 @NotNull 070 private static final Category log = Logger.getLogger(Updater.class); 071 072 /** 073 * Preferences. 074 */ 075 @NotNull 076 private static final Preferences preferences = Preferences.userNodeForPackage(MainControl.class); 077 078 /** 079 * Preferences key for last update. 080 */ 081 @NotNull 082 public static final String LAST_UPDATE_KEY = "UpdateTimestamp"; 083 084 /** 085 * The parentComponent to show dialogs on. 086 */ 087 @Nullable 088 private final Component parentComponent; 089 090 /** 091 * The {@link Exiter} for terminating the application. 092 */ 093 @NotNull 094 private final Exiter exiter; 095 096 /** 097 * The file to update. 098 */ 099 @NotNull 100 private final String updateFileName; 101 102 /** 103 * Buffer size. 104 */ 105 private static final int BUF_SIZE = 4096; 106 107 /** 108 * Create a new instance. 109 * @param parentComponent the parent component to show dialogs on 110 * @param exiter the exiter for terminating the application 111 * @param updateFileName the file to update 112 */ 113 public Updater(@Nullable final Component parentComponent, @NotNull final Exiter exiter, @NotNull final String updateFileName) { 114 this.parentComponent = parentComponent; 115 this.exiter = exiter; 116 this.updateFileName = updateFileName; 117 if (parentComponent != null) { 118 parentComponent.setEnabled(false); 119 } 120 } 121 122 /** 123 * {@inheritDoc} 124 */ 125 @Override 126 public void run() { 127 try { 128 final String propUrl = ACTION_BUILDER.getString("update.url"); 129 if (propUrl == null) { 130 return; 131 } 132 133 final InputStream pin = openStream(propUrl); 134 try { 135 final ResourceBundle updateBundle = new PropertyResourceBundle(pin); 136 final String downloadUrl = updateBundle.getString("update.url"); 137 if (downloadUrl == null) { 138 ACTION_BUILDER.showMessageDialog(parentComponent, "updateError", "invalid server response: update.url is missing"); 139 return; 140 } 141 final VersionInfo update = new VersionInfo(updateBundle, "update"); 142 VersionInfo active = VersionInfo.UNAVAILABLE; 143 try { 144 active = new VersionInfo(ResourceBundle.getBundle("build"), "build"); 145 } catch (final MissingResourceException e) { 146 ACTION_BUILDER.showMessageDialog(parentComponent, "updateActiveVersionUnavailable"); 147 } 148 preferences.putLong(LAST_UPDATE_KEY, System.currentTimeMillis()); 149 if (active == null || update.isNewerThan(active)) { 150 if (askIfUserWantsUpdate(active, update, propUrl, downloadUrl)) { 151 downloadAndInstallUpdate(downloadUrl); 152 } 153 } else { 154 noNewUpdate(active, update, propUrl, downloadUrl); 155 } 156 } finally { 157 pin.close(); 158 } 159 } catch (final UnknownHostException e) { 160 ACTION_BUILDER.showMessageDialog(parentComponent, "updateError", e.getLocalizedMessage()); 161 } catch (final IOException e) { 162 ACTION_BUILDER.showMessageDialog(parentComponent, "updateError", e); 163 } finally { 164 if (parentComponent != null) { 165 parentComponent.setEnabled(true); 166 } 167 } 168 } 169 170 /** 171 * Ask the user whether he wants to update. 172 * @param active VersionInfo of currently installed version 173 * @param update VersionInfo of available update version 174 * @param propUrl URL where properties were downloaded from 175 * @param downloadUrl URL where the update would be downloaded from 176 * @return <code>true</code> if the user chose that he wants to update, 177 * otherwise <code>false</code> 178 */ 179 private boolean askIfUserWantsUpdate(@Nullable final VersionInfo active, @NotNull final VersionInfo update, @NotNull final String propUrl, @NotNull final String downloadUrl) { 180 return ACTION_BUILDER.showConfirmDialog(parentComponent, JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE, "updateAvailable", updateFileName, active == null ? "?" : active.version, update.version, active == null ? "?" : active.developer, update.developer, active == null ? "?" : active.timestamp, update.timestamp, propUrl, downloadUrl) == JOptionPane.YES_OPTION; 181 } 182 183 /** 184 * Tell the user there is no update. 185 * @param active VersionInfo of currently installed version 186 * @param update VersionInfo of available update version 187 * @param propUrl URL where properties were downloaded from 188 * @param downloadUrl URL where the update would be downloaded from 189 */ 190 private void noNewUpdate(@Nullable final VersionInfo active, @NotNull final VersionInfo update, @NotNull final String propUrl, @NotNull final String downloadUrl) { 191 ACTION_BUILDER.showMessageDialog(parentComponent, "updateUnavailable", active == null ? "?" : active.version, update.version, active == null ? "?" : active.developer, update.developer, active == null ? "?" : active.timestamp, update.timestamp, propUrl, downloadUrl); 192 } 193 194 /** 195 * Download and install an update. 196 * @param url URL to get update from 197 */ 198 private void downloadAndInstallUpdate(@NotNull final String url) { 199 final File download = new File(updateFileName + ".tmp"); // TODO: print error message if file already exists 200 final File backup = new File(updateFileName + ".bak"); 201 final File orig = new File(updateFileName); 202 try { 203 final InputStream in = openStream(url); 204 try { 205 final OutputStream out = new FileOutputStream(download); 206 try { 207 final byte[] buf = new byte[BUF_SIZE]; 208 while (true) { 209 final int bytesRead = in.read(buf); 210 if (bytesRead == -1) { 211 break; 212 } 213 out.write(buf, 0, bytesRead); 214 } 215 out.close(); 216 if (/* !backup.delete() || */ !orig.renameTo(backup)) { 217 ACTION_BUILDER.showMessageDialog(parentComponent, "updateFailedNoBackup", updateFileName); 218 } else if (!download.renameTo(orig)) { 219 backup.renameTo(orig); 220 ACTION_BUILDER.showMessageDialog(parentComponent, "updateFailedNoDownload"); 221 } else { 222 ACTION_BUILDER.showMessageDialog(parentComponent, "updateRestart", updateFileName); 223 exiter.doExit(0); 224 } 225 } finally { 226 out.close(); 227 } 228 } finally { 229 in.close(); 230 } 231 } catch (final InterruptedIOException e) { 232 ACTION_BUILDER.showMessageDialog(parentComponent, "updateAborted"); 233 } catch (final Exception e) { 234 log.warn("updateError", e); 235 ACTION_BUILDER.showMessageDialog(parentComponent, "updateError", e); 236 } 237 } 238 239 /** 240 * Opens an InputStream on a URL. 241 * @param url the URL to open InputStream on 242 * @return the InputStream for URL 243 * @throws IOException in case of I/O problems 244 */ 245 @NotNull 246 // The stream is closed by caller 247 private InputStream openStream(@NotNull final String url) throws IOException { 248 final Proxy proxy = NetPreferences.getProxy(); 249 final URLConnection con = new URL(url).openConnection(proxy); 250 final ProgressMonitorInputStream stream = new ProgressMonitorInputStream(parentComponent, ActionBuilderUtils.getString(ACTION_BUILDER, "updateProgress.title"), con.getInputStream()); 251 final ProgressMonitor monitor = stream.getProgressMonitor(); 252 monitor.setMaximum(con.getContentLength()); 253 monitor.setNote(ActionBuilderUtils.getString(ACTION_BUILDER, "updateProgress")); 254 monitor.setMillisToDecideToPopup(10); 255 monitor.setMillisToPopup(10); 256 return stream; 257 } 258 259 /** 260 * Class for holding version information and quickly comparing it. 261 */ 262 private static class VersionInfo { 263 264 /** 265 * Update information: Version of update version, usually the build 266 * number. 267 */ 268 @NotNull 269 private final String version; 270 271 /** 272 * Update information: Time stamp of update version. 273 */ 274 @NotNull 275 private final String timestamp; 276 277 /** 278 * Update information: Developer that created the update version. 279 */ 280 @NotNull 281 private final String developer; 282 283 /** 284 * Special Version "unavailable". 285 */ 286 @NotNull 287 private static final VersionInfo UNAVAILABLE = new VersionInfo(); 288 289 /** 290 * Private constructor used for unavailable versions. 291 */ 292 private VersionInfo() { 293 this("unavailable", "unavailable", "unavailable"); 294 } 295 296 /** 297 * Private constructor to map the strings. 298 * @param version the version 299 * @param timestamp the timestamp 300 * @param developer the developer 301 */ 302 private VersionInfo(@NotNull final String version, @NotNull final String timestamp, @NotNull final String developer) { 303 this.version = version; 304 this.timestamp = timestamp; 305 this.developer = developer; 306 } 307 308 /** 309 * Create update information from a ResourceBundle. The ResourceBundle 310 * should have Strings for <var>prefix</var> + <code>.number</code>, 311 * <code>.tstamp</code> and <code>.developer</code>. 312 * @param bundle ResourceBundle to create update information from 313 * @param prefix Prefix for update information within the resource 314 * bundle 315 */ 316 private VersionInfo(@NotNull final ResourceBundle bundle, @NotNull final String prefix) { 317 this(bundle.getString(prefix + ".number"), bundle.getString(prefix + ".tstamp"), bundle.getString(prefix + ".developer")); 318 } 319 320 /** 321 * Check whether this version is newer than another version. 322 * @param other Other version information to compare to 323 * @return <code>true</code> if this version is newer than 324 * <var>other</var>, otherwise <code>false</code> 325 */ 326 @SuppressWarnings("ObjectEquality") 327 boolean isNewerThan(@NotNull final VersionInfo other) { 328 return this != UNAVAILABLE && (other == UNAVAILABLE || timestamp.compareTo(other.timestamp) > 0); 329 } 330 331 } 332 333}