/* Copyright (c) 2013-2016 Jesper Öqvist <jesper@llbit.se>
*
* This file is part of Chunky.
*
* Chunky 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.
*
* Chunky 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 Chunky. If not, see <http://www.gnu.org/licenses/>.
*/
package se.llbit.chunky.launcher;
import se.llbit.chunky.launcher.VersionInfo.Library;
import se.llbit.chunky.launcher.VersionInfo.LibraryStatus;
import se.llbit.chunky.launcher.ui.ChunkyLauncherController;
import se.llbit.chunky.resources.SettingsDirectory;
import se.llbit.json.JsonArray;
import se.llbit.json.JsonObject;
import se.llbit.json.JsonParser;
import se.llbit.json.JsonParser.SyntaxError;
import se.llbit.json.JsonValue;
import se.llbit.log.Level;
import se.llbit.log.Log;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.function.Consumer;
import java.util.stream.Collectors;
/**
* This class is responsible for launching Chunky after the
* launcher has constructed the command line. The deployer also
* tracks installed Chunky versions, and deploys the embedded version.
*
* @author Jesper Öqvist <jesper@llbit.se>
*/
public final class ChunkyDeployer {
public interface LoggerBuilder {
Logger build();
}
private ChunkyDeployer() {
}
/**
* Check the integrity of an installed version.
*
* @return <code>true</code> if the version is installed locally
*/
public static boolean checkVersionIntegrity(String version) {
File chunkyDir = SettingsDirectory.getSettingsDirectory();
if (chunkyDir == null) {
return false;
}
File versionsDir = new File(chunkyDir, "versions");
File libDir = new File(chunkyDir, "lib");
if (!versionsDir.isDirectory() || !libDir.isDirectory()) {
return false;
}
File versionFile = new File(versionsDir, version + ".json");
if (!versionFile.isFile()) {
return false;
}
// Check version.
try {
FileInputStream in = new FileInputStream(versionFile);
JsonParser parser = new JsonParser(in);
JsonObject obj = parser.parse().object();
in.close();
String versionName = obj.get("name").stringValue("");
if (!versionName.equals(version)) {
System.err.println("Stored version name does not match file name");
return false;
}
JsonArray array = obj.get("libraries").array();
for (JsonValue value : array) {
VersionInfo.Library lib = new VersionInfo.Library(value.object());
switch (lib.testIntegrity(libDir)) {
case INCOMPLETE_INFO:
System.err.println("Missing library name or checksum");
return false;
case MD5_MISMATCH:
System.err.println("Library MD5 checksum mismatch");
return false;
case MISSING:
System.err.println("Missing library " + lib.name);
return false;
default:
break;
}
}
return true;
} catch (IOException e) {
System.err.println("Could not read version info file: " + e.getMessage());
} catch (SyntaxError e) {
System.err.println("Corrupted version info file: " + e.getMessage());
}
return false;
}
/**
* Unpacks the embedded Chunky jar files.
* <p>
* <p>Updates the settings to use the latest version if a new embedded version is installed.
*/
public static void deploy(LauncherSettings settings) {
List<VersionInfo> versions = availableVersions();
VersionInfo embedded = embeddedVersion();
if (embedded != null && (!versions.contains(embedded) || !checkVersionIntegrity(
embedded.name))) {
Log.infof("Deploying embedded version: %s", embedded.name);
deployEmbeddedVersion(embedded);
if (!settings.version.equals(VersionInfo.LATEST.name)) {
settings.version = VersionInfo.LATEST.name;
settings.save();
}
}
}
/**
* @return a list of available Chunky versions sorted by release date.
*/
public static List<VersionInfo> availableVersions() {
File chunkyDir = SettingsDirectory.getSettingsDirectory();
if (chunkyDir == null) {
return Collections.emptyList();
}
File versionsDir = new File(chunkyDir, "versions");
if (!versionsDir.isDirectory()) {
return Collections.emptyList();
}
File[] versionFiles = versionsDir.listFiles();
if (versionFiles == null) {
return Collections.emptyList();
}
List<VersionInfo> versions = new ArrayList<>();
for (File versionFile : versionFiles) {
if (versionFile.getName().endsWith(".json")) {
try {
FileInputStream in = new FileInputStream(versionFile);
JsonParser parser = new JsonParser(in);
versions.add(new VersionInfo(parser.parse().object()));
in.close();
} catch (IOException e) {
System.err.println("Could not read version info file: " + e.getMessage());
} catch (SyntaxError e) {
System.err.println("Corrupted version info file: " + e.getMessage());
}
}
}
Collections.sort(versions);
return versions;
}
/**
* Unpack embedded libraries and deploy the embedded Chunky version.
*/
@SuppressWarnings("ResultOfMethodCallIgnored")
private static void deployEmbeddedVersion(VersionInfo version) {
File chunkyDir = SettingsDirectory.getSettingsDirectory();
if (chunkyDir == null) {
return;
}
File versionsDir = new File(chunkyDir, "versions");
if (!versionsDir.isDirectory()) {
versionsDir.mkdirs();
}
File libDir = new File(chunkyDir, "lib");
if (!libDir.isDirectory()) {
libDir.mkdirs();
}
try {
File versionJson = new File(versionsDir, version.name + ".json");
version.writeTo(versionJson);
ClassLoader parentCL = ChunkyDeployer.class.getClassLoader();
// Deploy libraries that were not already installed correctly.
for (Library lib : version.libraries) {
if (lib.testIntegrity(libDir) != LibraryStatus.PASSED) {
unpackLibrary(parentCL, "lib/" + lib.name, new File(libDir, lib.name));
}
}
} catch (SecurityException | IllegalArgumentException | IOException e) {
e.printStackTrace();
}
}
/**
* Unpack the jar file to the target directory.
*
* @param dest destination file
* @throws IOException
*/
private static void unpackLibrary(ClassLoader parentCL, String name, File dest)
throws IOException {
BufferedOutputStream out = new BufferedOutputStream(new FileOutputStream(dest));
InputStream in = parentCL.getResourceAsStream(name);
byte[] buffer = new byte[4096];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
out.close();
}
/** Gets the version info descriptor for the Chunky version embedded in this Jar. */
private static VersionInfo embeddedVersion() {
try {
ClassLoader parentCL = ChunkyDeployer.class.getClassLoader();
try (InputStream in = parentCL.getResourceAsStream("version.json")) {
if (in != null) {
JsonParser parser = new JsonParser(in);
return new VersionInfo(parser.parse().object());
}
} catch (IOException | SyntaxError ignored) {
// Ignored.
}
} catch (SecurityException ignored) {
// Ignored.
}
return null;
}
/**
* Launch a specific Chunky version.
*
* @return zero on success, non-zero if there is any problem
* launching Chunky (waits 200ms to see if everything launched)
*/
public static int launchChunky(LauncherSettings settings, VersionInfo version,
LaunchMode mode, Consumer<String> failureHandler, LoggerBuilder loggerBuilder) {
List<String> command = buildCommandLine(version, settings);
if (settings.verboseLauncher || Log.level == Level.INFO) {
System.out.println(commandString(command));
}
int exitValue = launchChunky(mode, command, loggerBuilder);
if (exitValue != 0) {
failureHandler.accept(commandString(command));
}
return exitValue;
}
public static int launchChunky(LaunchMode mode, List<String> command,
LoggerBuilder loggerBuilder) {
ProcessBuilder processBuilder = new ProcessBuilder(command);
final Logger logger = loggerBuilder.build();
try {
final Process process = processBuilder.start();
Runtime.getRuntime().addShutdownHook(new Thread() {
@Override public void run() {
// Kill the subprocess.
process.destroy();
}
});
final Thread outputScanner = new Thread("Output Logger") {
@Override public void run() {
try (InputStream is = process.getInputStream()) {
byte[] buffer = new byte[4096];
while (true) {
int size = is.read(buffer, 0, buffer.length);
if (size == -1) {
break;
}
logger.appendStdout(buffer, size);
}
} catch (IOException ignored) {
}
}
};
outputScanner.start();
final Thread errorScanner = new Thread("Error Logger") {
@Override public void run() {
try (InputStream is = process.getErrorStream()) {
byte[] buffer = new byte[4096];
while (true) {
int size = is.read(buffer, 0, buffer.length);
if (size == -1) {
break;
}
logger.appendStderr(buffer, size);
}
} catch (IOException ignored) {
}
}
};
errorScanner.start();
ShutdownThread shutdownThread = new ShutdownThread(process, logger, outputScanner, errorScanner);
shutdownThread.start();
try {
if (mode == LaunchMode.GUI) {
// Just wait a little while to check for startup errors.
Thread.sleep(3000);
return shutdownThread.exitValue;
} else {
// Wait until completion so we can return correct exit code.
return shutdownThread.exitValue();
}
} catch (InterruptedException ignored) {
// Ignored.
}
return 0;
} catch (IOException e) {
logger.appendErrorLine(e.getMessage());
// TODO(jesper): Add constant for this return value.
// Exit code 3 indicates launcher error.
return 3;
}
}
/**
* Convert a command in list form to string.
*
* @return command in string form
*/
public static String commandString(List<String> command) {
StringBuilder sb = new StringBuilder();
for (String part : command) {
if (sb.length() > 0) {
sb.append(" ");
}
sb.append(part);
}
return sb.toString();
}
private static List<String> buildCommandLine(VersionInfo version, LauncherSettings settings) {
List<String> cmd = new LinkedList<>();
cmd.add(JreUtil.javaCommand(settings.javaDir));
cmd.add("-Xmx" + settings.memoryLimit + "m");
File settingsDirectory = SettingsDirectory.getSettingsDirectory();
if (settingsDirectory != null) {
cmd.add("-Dchunky.home=" + settingsDirectory.getAbsolutePath());
}
String[] parts = settings.javaOptions.split(" ");
for (String part : parts) {
if (!part.isEmpty()) {
cmd.add(part);
}
}
cmd.add("-classpath");
cmd.add(classpath(version));
if (settings.verboseLogging) {
cmd.add("-DlogLevel=INFO");
}
cmd.add("se.llbit.chunky.main.Chunky");
parts = settings.chunkyOptions.split(" ");
for (String part : parts) {
if (!part.isEmpty()) {
cmd.add(part);
}
}
return cmd;
}
private static String classpath(VersionInfo version) {
File chunkyDir = SettingsDirectory.getSettingsDirectory();
File libDir = new File(chunkyDir, "lib");
List<File> jars = version.libraries.stream()
.map(library -> library.getFile(libDir))
.collect(Collectors.toList());
String classpath = "";
for (File file : jars) {
if (!classpath.isEmpty()) {
classpath += File.pathSeparator;
}
classpath += file.getAbsolutePath();
}
return classpath;
}
private static class ShutdownThread extends Thread {
public volatile int exitValue = 0;
private final Thread outputScanner;
private final Thread errorScanner;
private final Process proc;
private final Logger logger;
private boolean finished = false;
public ShutdownThread(Process proc, Logger logger, Thread output, Thread error) {
this.proc = proc;
this.logger = logger;
this.outputScanner = output;
this.errorScanner = error;
}
public synchronized int exitValue() throws InterruptedException {
while (!finished) {
wait();
}
return exitValue;
}
@Override public void run() {
try {
outputScanner.join();
} catch (InterruptedException ignored) {
}
try {
errorScanner.join();
} catch (InterruptedException ignored) {
}
try {
proc.waitFor();
exitValue = proc.exitValue();
logger.processExited(exitValue);
} catch (InterruptedException ignored) {
}
synchronized (this) {
finished = true;
notifyAll();
}
}
}
public static VersionInfo resolveVersion(String name) {
List<VersionInfo> versions = availableVersions();
VersionInfo version = VersionInfo.LATEST;
for (VersionInfo info : versions) {
if (info.name.equals(name)) {
version = info;
break;
}
}
if (version == VersionInfo.LATEST) {
if (versions.size() > 0) {
return versions.get(0);
} else {
return VersionInfo.NONE;
}
} else {
return version;
}
}
public static boolean canLaunch(VersionInfo version, ChunkyLauncherController launcher,
boolean reportErrors) {
if (version == VersionInfo.NONE) {
// Version not available!
System.err.println("Found no installed Chunky version.");
if (reportErrors) {
launcher.launcherError("No Chunky Available",
"There is no local Chunky version installed. Please try updating.");
}
return false;
}
if (!ChunkyDeployer.checkVersionIntegrity(version.name)) {
// TODO: add a way to fix this (delete corrupt version and then update)!
System.err.println("Version integrity check failed for version " + version.name);
if (reportErrors) {
launcher.launcherError("Chunky Version is Corrupt",
"Version integrity check failed for version "
+ version.name + ". Please select another version.");
}
return false;
}
return true;
}
}