/*
* SK's Minecraft Launcher
* Copyright (C) 2010, 2011 Albert Pham <http://www.sk89q.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.timvisee.minecraftrunner.update;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.math.BigInteger;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLEncoder;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.EventObject;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.Map.Entry;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.swing.event.EventListenerList;
import com.sk89q.mclauncher.DownloadListener;
import com.sk89q.mclauncher.DownloadProgressEvent;
import com.sk89q.mclauncher.Launcher;
import com.sk89q.mclauncher.ProgressListener;
import com.sk89q.mclauncher.StatusChangeEvent;
import com.sk89q.mclauncher.TitleChangeEvent;
import com.sk89q.mclauncher.ValueChangeEvent;
import com.sk89q.mclauncher.security.X509KeyRing.Ring;
import com.sk89q.mclauncher.update.PackageFile.MessageDigestAlgorithm;
import com.sk89q.mclauncher.util.Downloader;
import com.sk89q.mclauncher.util.SocketDownloader;
import com.sk89q.mclauncher.util.URLConnectionDownloader;
import com.sk89q.mclauncher.util.Util;
/**
* Downloads and applies an update.
*
* @author sk89q
*/
public class Updater implements DownloadListener {
private static final Logger logger = Logger.getLogger(Updater.class.getCanonicalName());
private boolean verifying = true;
private InputStream packageStream;
private File rootDir;
private UpdateCache cache;
private int downloadTries = 5;
private long retryDelay = 5000;
private boolean forced = false;
private Map<String, String> parameters = new HashMap<String, String>();
private EventListenerList listenerList = new EventListenerList();
private double subprogressOffset = 0;
private double subprogressSize = 1;
private volatile boolean running = true;
private Downloader downloader;
private List<PackageFile> fileList;
private int currentIndex = 0;
private long totalEstimatedSize = 0;
private long downloadedEstimatedSize = 0;
/**
* Construct the updater.
*
* @param packageStream
* @param rootDir
* @param cache update cache
*/
public Updater(InputStream packageStream, File rootDir, UpdateCache cache) {
this.packageStream = packageStream;
this.rootDir = rootDir;
this.cache = cache;
}
/**
* Returns whether signatures are verified after the download.
*
* @return true if verifying
*/
public boolean isVerifying() {
return verifying;
}
/**
* Set whether the signatures of downloaded files should be verified.
*
* @param verifying true to verify
*/
public void setVerifying(boolean verifying) {
this.verifying = verifying;
}
/**
* Get the number of download tries.
*
* @return download try count
*/
public int getDownloadTries() {
return downloadTries;
}
/**
* Set the number of download tries
*
* @param downloadTries count
*/
public void setDownloadTries(int downloadTries) {
this.downloadTries = downloadTries;
}
/**
* Returns whether everything is being reinstalled (all files are
* updated).
*
* @return status
*/
public boolean isReinstalling() {
return forced;
}
/**
* Set whether all files should be re-downloaded and re-installed.
*
* @param forced true to reinstall
*/
public void setReinstall(boolean forced) {
this.forced = forced;
}
/**
* Register a request parameter.
*
* @param param key
* @param value value
*/
public void registerParameter(String param, String value) {
parameters.put(param, value);
}
/**
* Replaces parameters within a URL.
*
* @param url url to parameterize
* @return new URL
*/
private URL parameterizeURL(URL url) {
String urlStr = url.toString();
for (Map.Entry<String, String> entry : parameters.entrySet()) {
try {
urlStr = urlStr.replace("%" + entry.getKey() + "%",
URLEncoder.encode(entry.getValue(), "UTF-8"));
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
}
try {
return new URL(urlStr);
} catch (MalformedURLException e) {
throw new RuntimeException(e);
}
}
/**
* Tries to load the MessageDigest instance for the given algorithm.
*
* @param type type algorithm
* @return the message digest
* @throws NoSuchAlgorithmException no algorithm is registered
*/
private MessageDigest loadMessageDigest(MessageDigestAlgorithm type)
throws NoSuchAlgorithmException {
return MessageDigest.getInstance(type.getJavaDigestName());
}
/**
* Returns whether two digests (in hex) match.
*
* @param s1 digest 1
* @param s2 digest 2
* @return true for match
*/
private boolean matchesDigest(String s1, String s2) {
return s1.replaceAll("^0+", "").equalsIgnoreCase(s2.replaceAll("^0+", ""));
}
/**
* Checks to make sure that the process is still running. Otherwise,
* throw a {@link CancelledUpdateException}.
*
* @throws CancelledUpdateException on cancel
*/
private void checkRunning() throws CancelledUpdateException {
if (!running) {
throw new CancelledUpdateException();
}
}
/**
* Parse the package file.
*
* @throws UpdateException on package parse error
*/
private void parsePackageFile() throws UpdateException {
try {
PackageDefinition def = PackageDefinition.parse(rootDir, packageStream);
fileList = def.getFileList();
totalEstimatedSize = def.getEstimatedTotalSize();
} catch (Throwable e) {
logger.log(Level.SEVERE, "Failed to read package file", e);
throw new UpdateException("Could not read package.xml file. The update cannot continue.\n\nThe error: " + e.getMessage(), e);
}
}
/**
* Download the files.
*
* @throws UpdateException on download error
*/
private void downloadFiles() throws UpdateException {
currentIndex = 0;
for (PackageFile file : fileList) {
checkRunning();
fireDownloadStatusChange("Connecting...");
OutputStream out;
boolean isVerifying = false;
boolean firstTry = true;
MessageDigest m = null;
URL url = parameterizeURL(file.getURL());
String cacheId = getRelative(rootDir, file.getFile());
// Load the MessageDigest
if (!forced && file.getVerifyType() != null) {
isVerifying = true;
try {
m = loadMessageDigest(file.getVerifyType());
} catch (NoSuchAlgorithmException e) {
isVerifying = false;
m = null;
// Guess we're not going to verify files
}
}
// Create the folder
file.getTempFile().getParentFile().mkdirs();
int retryNum = 0;
for (int trial = downloadTries; trial >= -1; trial--) {
checkRunning();
try {
out = new BufferedOutputStream(new FileOutputStream(file.getTempFile()));
} catch (IOException e) {
throw new UpdateException("Could not write to " +
file.getTempFile().getAbsolutePath() + ".", e);
}
// Attempt downloading
try {
if (url.getProtocol().equalsIgnoreCase("http") && firstTry) {
logger.info("Using SocketDownloader for URL " + url.toString());
downloader = new SocketDownloader(url, out);
} else {
logger.info("Using URLConnectionDownloader for URL " + url.toString());
downloader = new URLConnectionDownloader(url, out);
}
firstTry = false;
if (isVerifying) {
downloader.setMessageDigest(m);
downloader.setEtagCheck(cache.getCachedHash(cacheId));
}
downloader.addDownloadListener(this);
if (downloader.download()) {
checkRunning();
// Check MD5 hash
if (isVerifying) {
String signature = new BigInteger(1, m.digest()).toString(16);
if (!matchesDigest(downloader.getEtag(), signature)) {
throw new UpdateException(
String.format("Signature for %s did not match; expected %s, got %s",
file.getURL(), downloader.getEtag(), signature));
}
cache.putCachedHash(cacheId, signature);
}
} else { // File already downloaded
file.setIgnored(true);
fireDownloadStatusChange("Already up-to-date.");
fireAdjustedValueChange((downloadedEstimatedSize + file.getTotalEstimatedSize())
/ (double) totalEstimatedSize);
}
break;
} catch (IOException e) {
logger.log(Level.WARNING, "Failed to fetch " + url, e);
if (trial == -1) {
throw new UpdateException("Could not download " + file.getURL() + ": " +
e.getMessage(), e);
}
} finally {
downloader = null;
Util.close(out);
}
retryNum++;
Util.sleep(retryDelay);
fireDownloadStatusChange("Download failed; retrying (" + retryNum + ")...");
}
currentIndex++;
downloadedEstimatedSize += file.getTotalEstimatedSize();
}
}
/**
* Verify newly-downloaded updates.
*
* @throws UpdateException
*/
private void verify() throws UpdateException {
if (!isVerifying()) { // No verification!
return;
}
currentIndex = 0;
SignatureVerifier signatureVerifier = new SignatureVerifier(
Launcher.getInstance().getKeyRing().getKeyStore(Ring.UPDATE));
for (PackageFile file : fileList) {
checkRunning();
if (file.isIgnored()) {
continue;
}
fireAdjustedValueChange(currentIndex / fileList.size());
fireStatusChange(String.format("Verifying %s (%d/%d)...", file.getFile().getName(),
currentIndex + 1, fileList.size()));
try {
file.verify(signatureVerifier);
} catch (SecurityException e) {
logger.log(Level.WARNING, "Failed to deploy " + file, e);
throw new UpdateException("The digital signature(s) of " +
file.getFile().getAbsolutePath() + " could not be verified: " + e.getMessage(), e);
} catch (IOException e) {
logger.log(Level.WARNING, "Failed to deploy " + file, e);
throw new UpdateException("Could not install to " +
file.getFile().getAbsolutePath() + ": " + e.getMessage(), e);
} catch (Throwable e) {
logger.log(Level.WARNING, "Failed to deploy " + file, e);
throw new UpdateException("Could not install " +
file.getFile().getAbsolutePath() + ": " + e.getMessage(), e);
}
currentIndex++;
}
}
/**
* Deploy newly-downloaded updates.
*
* @throws UpdateException
*/
private void deploy(UninstallLog log) throws UpdateException {
currentIndex = 0;
for (PackageFile file : fileList) {
checkRunning();
if (file.isIgnored()) {
continue;
}
fireAdjustedValueChange(currentIndex / fileList.size());
fireStatusChange(String.format("Installing %s (%d/%d)...", file.getFile().getName(),
currentIndex + 1, fileList.size()));
try {
file.deploy(log);
} catch (SecurityException e) {
logger.log(Level.WARNING, "Failed to deploy " + file, e);
throw new UpdateException("The digital signature(s) of " +
file.getFile().getAbsolutePath() + " could not be verified: " + e.getMessage(), e);
} catch (IOException e) {
logger.log(Level.WARNING, "Failed to deploy " + file, e);
throw new UpdateException("Could not install to " +
file.getFile().getAbsolutePath() + ": " + e.getMessage(), e);
} catch (Throwable e) {
logger.log(Level.WARNING, "Failed to deploy " + file, e);
throw new UpdateException("Could not install " +
file.getFile().getAbsolutePath() + ": " + e.getMessage(), e);
}
currentIndex++;
}
}
/**
* Delete old files from the previous installation.
*
* @param oldLog old log
* @param newLog new log
* @throws UpdateException update exception
*/
private void deleteOldFiles(UninstallLog oldLog, UninstallLog newLog) throws UpdateException {
for (PackageFile file : fileList) {
checkRunning();
if (file.isIgnored()) {
newLog.copyGroupFrom(oldLog, file.getFile());
}
}
for (Entry<String, Set<String>> entry : oldLog.getEntrySet()) {
for (String path : entry.getValue()) {
checkRunning();
if (!newLog.has(path)) {
new File(rootDir, path).delete();
}
}
}
}
/**
* Perform the update.
*
* @throws UpdateException
*/
public void performUpdate() throws UpdateException {
File logFile = new File(rootDir, "uninstall.dat");
fireStatusChange("Parsing package .xml...");
parsePackageFile();
try {
fireStatusChange("Downloading files...");
setSubprogress(0, 0.8);
downloadFiles();
UninstallLog oldLog = new UninstallLog();
UninstallLog newLog = new UninstallLog();
newLog.setBaseDir(rootDir);
try {
oldLog.read(logFile);
} catch (IOException e) {
}
fireStatusChange("Verifying signatures...");
setSubprogress(0.8, 0.1);
verify();
fireStatusChange("Installing...");
setSubprogress(0.9, 0.1);
deploy(newLog);
fireStatusChange("Removing old files...");
deleteOldFiles(oldLog, newLog);
// Save install log
try {
newLog.write(logFile);
} catch (IOException e) {
logger.log(Level.WARNING, "Failed to write " + logFile, e);
throw new UpdateException("The uninstall log file could not be written to. " +
"The update has been aborted.", e);
}
} finally {
// Cleanup
fireStatusChange("Cleaning up temporary files...");
for (PackageFile file : fileList) {
File tempFile = file.getTempFile();
if (tempFile != null) {
tempFile.delete();
}
}
}
}
/**
* Fires a status message for the currently downloading file.
*
* @param message message to show
*/
private void fireDownloadStatusChange(String message) {
fireStatusChange(String.format("(%d/%d) %s: %s", currentIndex + 1,
fileList.size(), fileList.get(currentIndex).getFile().getName(), message));
}
/**
* Called whenever a HTTP download connection is created.
*/
@Override
public void connectionStarted(EventObject event) {
fireDownloadStatusChange("Connected.");
}
/**
* Called with the length is known in an HTTP download.
*/
@Override
public void lengthKnown(EventObject event) {
}
/**
* Called when download progress is made.
*/
@Override
public void downloadProgress(DownloadProgressEvent event) {
long total = ((Downloader) event.getSource()).getTotalLength();
PackageFile download = fileList.get(currentIndex);
// If length is known
if (total > 0) {
fireDownloadStatusChange(String.format("Downloaded %,d/%,d KB...",
event.getDownloadedLength() / 1024, total / 1024));
fireAdjustedValueChange((downloadedEstimatedSize / (double) totalEstimatedSize) +
(download.getTotalEstimatedSize() / (double) totalEstimatedSize) *
(event.getDownloadedLength() / (double) total));
} else {
fireDownloadStatusChange(String.format("Downloaded %,d KB...",
(event.getDownloadedLength() / 1024)));
}
}
/**
* Called when a download completes.
*/
@Override
public void downloadCompleted(EventObject event) {
PackageFile download = fileList.get(currentIndex);
fireDownloadStatusChange("Download completed.");
fireAdjustedValueChange((downloadedEstimatedSize / (double) totalEstimatedSize) +
(download.getTotalEstimatedSize() / (double) totalEstimatedSize));
}
/**
* Set a sub-progress range with is used by {@link #fireAdjustedValueChange(double)}.
*
* @param offset offset, between 0 and 1
* @param size size, between 0 and 1
*/
protected void setSubprogress(double offset, double size) {
this.subprogressOffset = offset;
this.subprogressSize = size;
}
/**
* Fire a title change.
*
* @param message title
*/
protected void fireTitleChange(String message) {
Object[] listeners = listenerList.getListenerList();
for (int i = listeners.length - 2; i >= 0; i -= 2) {
((ProgressListener) listeners[i + 1]).titleChanged(
new TitleChangeEvent(this, message));
}
}
/**
* Fire a status change.
*
* @param message new status
*/
protected void fireStatusChange(String message) {
Object[] listeners = listenerList.getListenerList();
for (int i = listeners.length - 2; i >= 0; i -= 2) {
((ProgressListener) listeners[i + 1]).statusChanged(
new StatusChangeEvent(this, message));
}
}
/**
* Fire a value change.
*
* @param value value between 0 and 1
*/
protected void fireValueChange(double value) {
Object[] listeners = listenerList.getListenerList();
for (int i = listeners.length - 2; i >= 0; i -= 2) {
((ProgressListener) listeners[i + 1]).valueChanged(
new ValueChangeEvent(this, value));
}
}
/**
* Fire an adjusted value change, which is adjusted with
* {@link #setSubprogress(double, double)}.
*
* @param value value between 0 and 1
*/
protected void fireAdjustedValueChange(double value) {
fireValueChange(value * subprogressSize + subprogressOffset);
}
/**
* Gets the relative path between a base and a path.
*
* @param base base path containing path
* @param path path
* @return relative path
*/
private String getRelative(File base, File path) {
return base.toURI().relativize(path.toURI()).getPath();
}
/**
* Registers a progress listener.
*
* @param l listener
*/
public void addProgressListener(ProgressListener l) {
listenerList.add(ProgressListener.class, l);
}
/**
* Unregister a progress listener.
*
* @param l listener
*/
public void removeProgressListener(ProgressListener l) {
listenerList.remove(ProgressListener.class, l);
}
/**
* Cancel the update.
*/
public void cancel() {
running = false;
Downloader downloader = this.downloader;
if (downloader != null) {
downloader.cancel();
}
}
}