package net.classicube.launcher;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigInteger;
import java.net.MalformedURLException;
import java.net.URL;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.zip.CRC32;
import java.util.zip.ZipEntry;
import javax.swing.SwingWorker;
import net.classicube.launcher.gui.UpdateScreen;
import net.classicube.shared.SharedUpdaterCode;
import net.classicube.shared.SharedUpdaterCode.OperatingSystem;
// Handles downloading and deployment of client updates,
// as well as resource files used by the client.
public final class UpdateTask
extends SwingWorker<Boolean, UpdateTask.ProgressUpdate> {
// =============================================================================================
// CONSTANTS & INITIALIZATION
// =============================================================================================
private static final int MAX_PARALLEL_DOWNLOADS = 5;
private static final UpdateTask instance = new UpdateTask();
public static UpdateTask getInstance() {
return instance;
}
private UpdateTask() {
}
// =============================================================================================
// MAIN
// =============================================================================================
private Thread[] workerThreads;
private final List<FileToDownload> files = new ArrayList<>();
private int activeFileNumber, filesDone, totalFiles;
private boolean needLzma;
private boolean updatesApplied;
@Override
protected Boolean doInBackground()
throws Exception {
this.digest = MessageDigest.getInstance("SHA1");
final Logger logger = LogUtil.getLogger();
// build up file list
logger.log(Level.INFO, "Checking for updates.");
files.addAll(pickBinariesToDownload());
files.addAll(pickResourcesToDownload());
if (files.isEmpty()) {
logger.log(Level.INFO, "No updates needed.");
} else {
this.updatesApplied = true;
logger.log(Level.INFO, "Downloading updates: {0}", listFileNames(files));
this.activeFileNumber = 0;
this.totalFiles = files.size();
if (needLzma) {
// We need to get lzma.jar before deploying any other files, because some of them
// may need to be decompressed. "lzma.jar" will always be the first on the list.
processOneFile(getNextFileSync(false));
}
// The rest of the files are processed by worker threads.
int numThreads = Math.min(totalFiles, MAX_PARALLEL_DOWNLOADS);
workerThreads = new Thread[numThreads];
for (int i = 0; i < numThreads; i++) {
workerThreads[i] = new DownloadThread(logger);
workerThreads[i].start();
}
// Wait for all workers to finish
for (int i = 0; i < numThreads; i++) {
workerThreads[i].join();
}
}
// confirm that all required files have been downloaded and deployed
verifyFiles(files);
if (this.updatesApplied) {
logger.log(Level.INFO, "Updates applied.");
}
return true;
}
private void processOneFile(final FileToDownload file)
throws InterruptedException, IOException {
// step 1: download
final File downloadedFile = downloadFile(file);
// step 2: unpack
final File processedFile = SharedUpdaterCode.processDownload(
LogUtil.getLogger(),
downloadedFile, file.baseUrl + file.remoteName, file.targetName.getName());
// step 3: deploy
deployFile(processedFile, file.targetName);
}
// Make a list of all local names, for logging
private static String listFileNames(final List<FileToDownload> files) {
if (files == null) {
throw new NullPointerException("files");
}
final StringBuilder sb = new StringBuilder();
String sep = "";
for (final FileToDownload s : files) {
sb.append(sep).append(s.localName.getName());
sep = ", ";
}
return sb.toString();
}
// Grabs the next file from the list, and sends a progress report to UpdateScreen.
// Returns null when there are no more files left to download.
private synchronized FileToDownload getNextFileSync(boolean fileWasDone) {
if (fileWasDone) {
filesDone++;
}
FileToDownload fileToReturn = null;
String fileNameToReport;
if (activeFileNumber != totalFiles) {
fileToReturn = files.get(activeFileNumber);
activeFileNumber++;
fileNameToReport = fileToReturn.localName.getName();
} else {
fileNameToReport = files.get(totalFiles - 1).localName.getName();
}
int overallProgress = (this.filesDone * 100 + 100) / this.totalFiles;
final String status = String.format("Updating %s (%d/%d)",
fileNameToReport, this.activeFileNumber, this.totalFiles);
this.publish(new ProgressUpdate(status, overallProgress));
return fileToReturn;
}
// =============================================================================================
// CHECKING / DOWNLOADING
// =============================================================================================
private MessageDigest digest;
public static final String FILE_INDEX_URL = "http://www.classicube.net/static/client/version",
RESOURCE_LIST_URL = "http://www.classicube.net/static/client/reslist",
RESOURCE_DOWNLOAD_URL = "https://s3.amazonaws.com/MinecraftResources/",
LAUNCHER_JAR = "launcher.jar";
private List<FileToDownload> pickResourcesToDownload()
throws IOException {
final List<FileToDownload> pickedFiles = new ArrayList<>();
final File resDir = new File(PathUtil.getClientDir(), "resources");
HashMap<String, String> resList = getRemoteResourceList();
if (resList == null) {
// If downloading resList failed, assume that no new resources need to be downloaded.
// This allows the game to still launch in offline mode.
return pickedFiles;
}
for (Map.Entry<String, String> entry : resList.entrySet()) {
String resFileName = entry.getKey();
final File resFile = new File(resDir, resFileName);
boolean doDownload = false;
if (!resFile.exists()) {
// If file does not exist, definitely download it.
doDownload = true;
} else {
// Make sure that the file contents match.
try (InputStream is = new FileInputStream(resFile)) {
String localHash = computeHash(is);
String expectedHash = entry.getValue();
if (!localHash.equals(expectedHash)) {
LogUtil.getLogger().log(Level.WARNING,
"Resource hash mismatch for file {0}! Expected {1}, got {2}. Will re-download.",
new Object[]{resFileName, expectedHash, localHash});
doDownload = true;
}
}
}
if (doDownload) {
pickedFiles.add(new FileToDownload(RESOURCE_DOWNLOAD_URL, resFileName, resFile));
}
}
return pickedFiles;
}
private List<FileToDownload> pickBinariesToDownload()
throws IOException {
final List<FileToDownload> filesToDownload = new ArrayList<>();
final List<FileToDownload> localFiles = listBinaries();
final HashMap<String, RemoteFile> remoteFiles = getRemoteIndex();
final boolean updateExistingFiles = (Prefs.getUpdateMode() != UpdateMode.DISABLED);
// Getting remote file index failed. Abort update.
if (remoteFiles == null) {
return filesToDownload;
}
for (final FileToDownload localFile : localFiles) {
signalCheckProgress(localFile.localName.getName());
final RemoteFile remoteFile = remoteFiles.get(localFile.remoteName);
boolean download = false;
boolean localFileMissing = !localFile.localName.exists();
File fileToHash = localFile.localName;
// lzma.jar and launcher.jar get special treatment
boolean isLzma = (localFile == lzmaJarFile);
boolean isLauncherJar = (localFile == launcherJarFile);
if (isLauncherJar) {
if (localFileMissing) {
// If launcher.jar is missing from its usual location, that means we're
// currently running from somewhere else. We need to take care to avoid
// repeated attempts to update the launcher.
LogUtil.getLogger().log(Level.WARNING,
"launcher.jar is not present in its usual location!");
// We check if "launcher.jar.new" is up-to-date (instead of checking "launcher.jar"),
// and only download it if UpdateMode is not DISABLED.
fileToHash = localFile.targetName;
localFileMissing = !localFile.targetName.exists();
} else if (localFile.targetName.exists()) {
// If "launcher.jar.new" already exists, just check if it's up-to-date.
fileToHash = localFile.targetName;
LogUtil.getLogger().log(Level.WARNING,
"launcher.jar.new already exists: we're probably not running from self-updater.");
}
}
if (localFileMissing) {
// If local file does not exist
LogUtil.getLogger().log(Level.INFO,
"Will download {0}: does not exist locally", localFile.localName.getName());
download = true;
} else if (updateExistingFiles && !isLzma) {
// If local file exists, but may need updating
if (remoteFile != null) {
try {
final String localHash = computeManifestHash(fileToHash);
if (!localHash.equalsIgnoreCase(remoteFile.hash)) {
// If file contents don't match
LogUtil.getLogger().log(Level.INFO,
"Contents of {0} don''t match ({1} vs {2}). Will re-download.",
new Object[]{fileToHash.getName(), localHash, remoteFile.hash});
download = true;
}
} catch (final IOException ex) {
LogUtil.getLogger().log(Level.SEVERE,
"Error computing hash of a local file. Will attempt to re-download.", ex);
download = true;
} catch (final SecurityException ex) {
String logMsg = "Error verifying " + fileToHash.getName() + ". Will re-download.";
LogUtil.getLogger().log(Level.SEVERE, logMsg, ex);
download = true;
}
} else {
LogUtil.getLogger().log(Level.WARNING,
"No remote match for local file {0}", fileToHash.getName());
}
} else if (isLzma) {
// Make sure that lzma.jar is not corrupted
try {
SharedUpdaterCode.testLzma(LogUtil.getLogger());
} catch (Exception ex) {
LogUtil.getLogger().log(Level.SEVERE,
"lzma.jar appears to be corrupted, and will be re-downloaded.", ex);
download = true;
}
}
if (download) {
if (isLzma) {
needLzma = true;
} else if (remoteFile == null) {
String errMsg = String.format("Required file \"%s%s\" cannot be found.",
localFile.baseUrl, localFile.remoteName);
throw new RuntimeException(errMsg);
}
filesToDownload.add(localFile);
}
}
return filesToDownload;
}
private FileToDownload lzmaJarFile, launcherJarFile, nativesFile;
private List<FileToDownload> listBinaries()
throws IOException {
final List<FileToDownload> binaryFiles = new ArrayList<>();
final File clientDir = PathUtil.getClientDir();
final File launcherDir = SharedUpdaterCode.getLauncherDir();
lzmaJarFile = new FileToDownload(SharedUpdaterCode.BASE_URL, "lzma.jar",
new File(launcherDir, "lzma.jar"));
binaryFiles.add(lzmaJarFile);
launcherJarFile = new FileToDownload(SharedUpdaterCode.BASE_URL, "launcher.jar.pack.lzma",
new File(launcherDir, LAUNCHER_JAR),
new File(launcherDir, SharedUpdaterCode.LAUNCHER_NEW_JAR_NAME));
binaryFiles.add(launcherJarFile);
binaryFiles.add(new FileToDownload(SharedUpdaterCode.BASE_URL, "client.jar.pack.lzma",
new File(clientDir, "client.jar")));
binaryFiles.add(new FileToDownload(SharedUpdaterCode.BASE_URL, "lwjgl.jar.pack.lzma",
new File(clientDir, "libs/lwjgl.jar")));
binaryFiles.add(new FileToDownload(SharedUpdaterCode.BASE_URL, "lwjgl_util.jar.pack.lzma",
new File(clientDir, "libs/lwjgl_util.jar")));
binaryFiles.add(new FileToDownload(SharedUpdaterCode.BASE_URL, "jinput.jar.pack.lzma",
new File(clientDir, "libs/jinput.jar")));
nativesFile = pickNativeDownload();
binaryFiles.add(nativesFile);
return binaryFiles;
}
// get a list of binaries available from CC.net
private HashMap<String, RemoteFile> getRemoteIndex() {
final String hashIndex = HttpUtil.downloadString(FILE_INDEX_URL);
final HashMap<String, RemoteFile> remoteFiles = new HashMap<>();
// if getting the list failed, don't panic. Abort update instead.
if (hashIndex == null) {
return null;
}
// special treatment for LZMA
final RemoteFile lzmaFile = new RemoteFile();
lzmaFile.name = SharedUpdaterCode.LZMA_JAR_NAME;
lzmaFile.hash = "N/A";
remoteFiles.put(lzmaFile.name.toLowerCase(), lzmaFile);
// the rest of the files
for (final String line : hashIndex.split("\\r?\\n")) {
final String[] components = line.split(" ");
final RemoteFile file = new RemoteFile();
file.name = components[0];
file.hash = components[2].toLowerCase();
remoteFiles.put(file.name.toLowerCase(), file);
}
return remoteFiles;
}
// Get a list of resource files to download (from MinecraftResources site).
// Returns a map with filenames for keys, and expected SHA1 hashes for values.
private HashMap<String, String> getRemoteResourceList() {
final String hashIndex = HttpUtil.downloadString(RESOURCE_LIST_URL);
final HashMap<String, String> remoteFiles = new HashMap<>();
// if getting the list failed, don't panic. Abort update instead.
if (hashIndex == null) {
return null;
}
// the rest of the files
for (final String line : hashIndex.split("\\r?\\n")) {
final String[] components = line.split(" ");
remoteFiles.put(components[0].toLowerCase(), components[1].toLowerCase());
}
return remoteFiles;
}
// Verifies signatures of all files inside the .jar, and returns SHA1 hash of the manifest.
private String computeManifestHash(final File clientJar)
throws IOException, SecurityException {
if (clientJar == null) {
throw new NullPointerException("clientJar");
}
try (final JarFile jarFile = new JarFile(clientJar)) {
final ZipEntry manifest = jarFile.getEntry("META-INF/MANIFEST.MF");
if (manifest == null) {
return "<none>";
}
// Ensure all the entries' signatures verify correctly
byte[] buffer = new byte[64 * 1024];
for (JarEntry je : Collections.list(jarFile.entries())) {
try (InputStream is = jarFile.getInputStream(je)) {
while (is.read(buffer, 0, buffer.length) != -1) {
// SecurityException will be thrown by .read() if a signature check fails.
}
}
}
try (final InputStream is = jarFile.getInputStream(manifest)) {
return computeHash(is);
}
}
}
private String computeHash(InputStream is)
throws FileNotFoundException, IOException {
final byte[] ioBuffer = new byte[64 * 1024];
try (final DigestInputStream dis = new DigestInputStream(is, digest)) {
while (dis.read(ioBuffer) != -1) {
// DigestInputStream is doing its job, we just need to read through it.
}
}
final byte[] localHashBytes = digest.digest();
final String hashString = new BigInteger(1, localHashBytes).toString(16);
return padLeft(hashString, '0', 40);
}
private static String padLeft(final String s, final char c, final int n) {
if (s == null) {
throw new NullPointerException("s");
}
final StringBuilder sb = new StringBuilder();
for (int toPrepend = n - s.length(); toPrepend > 0; toPrepend--) {
sb.append(c);
}
sb.append(s);
return sb.toString();
}
private static FileToDownload pickNativeDownload() {
final String osName;
switch (OperatingSystem.detect()) {
case WINDOWS:
osName = "windows";
break;
case MACOS:
osName = "macosx";
break;
case NIX:
osName = "linux";
break;
case SOLARIS:
osName = "solaris";
break;
default:
throw new IllegalArgumentException();
}
final String remoteName = osName + "_natives.jar";
final File localPath = new File(PathUtil.getClientDir(),
"natives/" + osName + "_natives.jar");
return new FileToDownload(SharedUpdaterCode.BASE_URL, remoteName, localPath);
}
private File downloadFile(final FileToDownload file)
throws MalformedURLException, FileNotFoundException, IOException, InterruptedException {
if (file == null) {
throw new NullPointerException("file");
}
final File tempFile = File.createTempFile(file.localName.getName(), ".downloaded");
final URL website = new URL(file.baseUrl + file.remoteName);
try (InputStream siteStream = website.openStream()) {
PathUtil.copyStreamToFile(siteStream, tempFile);
}
return tempFile;
}
// =============================================================================================
// POST-DOWNLOAD PROCESSING
// =============================================================================================
private synchronized void deployFile(final File processedFile, File targetFile) {
if (processedFile == null) {
throw new NullPointerException("processedFile");
}
if (targetFile == null) {
throw new NullPointerException("localName");
}
LogUtil.getLogger().log(Level.INFO, "Deploying {0}", targetFile);
try {
final File parentDir = targetFile.getCanonicalFile().getParentFile();
if (!parentDir.exists() && !parentDir.mkdirs()) {
throw new IOException("Unable to make directory " + parentDir);
}
PathUtil.replaceFile(processedFile, targetFile);
// special handling for natives
if (targetFile == nativesFile.targetName) {
extractNatives();
}
} catch (final IOException ex) {
LogUtil.getLogger().log(Level.SEVERE, "Error deploying " + targetFile.getName(), ex);
}
}
// Extract the contents of natives jar file
void extractNatives()
throws FileNotFoundException, IOException {
LogUtil.getLogger().log(Level.FINE, "extractNatives({0})", nativesFile.targetName.getName());
final File nativeFolder = getNativesFolder();
try (final JarFile jarFile = new JarFile(nativesFile.targetName, true)) {
for (final JarEntry entry : Collections.list(jarFile.entries())) {
if (!entry.isDirectory() && (entry.getName().indexOf('/') == -1)) {
final File outFile = new File(nativeFolder, entry.getName());
if (outFile.exists() && !outFile.delete()) {
LogUtil.getLogger().log(Level.SEVERE,
"Could not replace native file: {0}", entry.getName());
return;
}
extractNativeFile(jarFile, entry, outFile);
}
}
}
}
// Makes sure that everything from LWJGL's natives jar is properly deployed.
private void ensureNativesAreExtracted()
throws IOException {
final File nativeFolder = getNativesFolder();
try (final JarFile jarFile = new JarFile(nativesFile.targetName, true)) {
for (final JarEntry entry : Collections.list(jarFile.entries())) {
if (!entry.isDirectory() && (entry.getName().indexOf('/') == -1)) {
final File outFile = new File(nativeFolder, entry.getName());
if (!outFile.exists()) {
LogUtil.getLogger().log(Level.WARNING,
"Native library is missing, and will be re-extracted: {0}", outFile);
extractNativeFile(jarFile, entry, outFile);
} else if (outFile.length() != entry.getSize()
|| computeCRC32(outFile) != entry.getCrc()) {
LogUtil.getLogger().log(Level.WARNING,
"Native library is outdated or corrupted, and will be re-extracted: {0}", outFile);
extractNativeFile(jarFile, entry, outFile);
}
}
}
}
}
// Calculates the CRC32 checksum of a given file
public static long computeCRC32(final File file) throws IOException {
try (final FileInputStream fis = new FileInputStream(file)) {
try (final InputStream inputStream = new BufferedInputStream(fis)) {
final CRC32 crc = new CRC32();
int cnt;
while ((cnt = inputStream.read()) != -1) {
crc.update(cnt);
}
return crc.getValue();
}
}
}
// Finds the folder that contains LWJGL natives. If it does not exist, it's created.
private File getNativesFolder() throws IOException {
final File nativeFolder = new File(PathUtil.getClientDir(), "natives");
if (!nativeFolder.exists() && !nativeFolder.mkdirs()) {
throw new IOException("Unable to make directory " + nativeFolder);
}
return nativeFolder;
}
// Extracts a file from given .jar archive
private void extractNativeFile(final JarFile jarFile, final JarEntry entry, final File destination)
throws IOException {
try (InputStream inStream = jarFile.getInputStream(entry)) {
PathUtil.copyStreamToFile(inStream, destination);
}
}
// Checks all local files to make sure that the client is ready to launch
private void verifyFiles(final List<FileToDownload> files) {
if (files == null) {
throw new NullPointerException("files");
}
try {
ensureNativesAreExtracted();
} catch (IOException ex) {
throw new RuntimeException("Update process failed. Unable to extract a required library file.", ex);
}
for (final FileToDownload file : files) {
if (!LAUNCHER_JAR.equals(file.localName.getName()) && !file.localName.exists()) {
throw new RuntimeException("Update process failed. Missing file: " + file.localName);
}
}
}
// =============================================================================================
// PROGRESS REPORTING
// =============================================================================================
private volatile UpdateScreen updateScreen;
private static boolean updateFinished = false;
public static boolean getUpdateFinished() {
// If "keep open" option is on, we only want the updater to run once (before first launch).
return updateFinished;
}
public static void setUpdateFinished(boolean value) {
// Set to 'true' by UpdateScreen after a successful update
updateFinished = value;
}
@Override
protected synchronized void process(final List<ProgressUpdate> chunks) {
if (chunks == null) {
throw new NullPointerException("chunks");
}
if (this.updateScreen != null) {
this.updateScreen.setStatus(chunks.get(chunks.size() - 1));
}
}
private void signalCheckProgress(final String fileName) {
if (fileName == null) {
throw new NullPointerException("fileName");
}
this.publish(new ProgressUpdate("Checking " + fileName, -1));
}
private void signalDone() {
final String message = (this.updatesApplied ? "Updates applied." : "No updates needed.");
this.publish(new ProgressUpdate(message, 100));
}
@Override
protected synchronized void done() {
if (this.updateScreen != null) {
this.signalDone();
this.updateScreen.onUpdateDone(this.updatesApplied);
}
}
public synchronized void registerUpdateScreen(final UpdateScreen updateScreen) {
if (updateScreen == null) {
throw new NullPointerException("updateScreen");
}
this.updateScreen = updateScreen;
if (this.isDone()) {
this.signalDone();
updateScreen.onUpdateDone(this.updatesApplied);
}
}
// =============================================================================================
// INNER TYPES
// =============================================================================================
public final static class ProgressUpdate {
public String statusString;
public int progress;
public ProgressUpdate(final String statusString, final int progress) {
if (statusString == null) {
throw new NullPointerException("statusString");
}
this.statusString = statusString;
this.progress = progress;
}
}
private final static class FileToDownload {
// remote filename
public final String baseUrl;
public final String remoteName;
public final File localName;
public final File targetName;
FileToDownload(final String baseUrl, final String remoteName, final File localName) {
this(baseUrl, remoteName, localName, localName);
}
FileToDownload(final String baseUrl, final String remoteName, final File localName, final File targetName) {
this.baseUrl = baseUrl;
this.remoteName = remoteName;
this.localName = localName;
this.targetName = targetName;
}
}
private final static class RemoteFile {
String name;
String hash;
}
private class DownloadThread extends Thread {
private final Logger logger;
DownloadThread(Logger logger) {
this.logger = logger;
}
@Override
public void run() {
FileToDownload file = null;
try {
file = getNextFileSync(false);
while (file != null) {
processOneFile(file);
file = getNextFileSync(true);
}
} catch (final Exception ex) {
String fileName = (file != null ? file.remoteName : "?");
logger.log(Level.SEVERE, "Error downloading or deploying an updated file: " + fileName, ex);
}
}
}
}