2019-08-30 13:57:43 +01:00

501 lines
16 KiB
Java

package link.infra.packwiz.installer;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonIOException;
import com.google.gson.JsonSyntaxException;
import com.google.gson.annotations.SerializedName;
import com.moandjiezana.toml.Toml;
import link.infra.packwiz.installer.metadata.IndexFile;
import link.infra.packwiz.installer.metadata.ManifestFile;
import link.infra.packwiz.installer.metadata.PackFile;
import link.infra.packwiz.installer.metadata.SpaceSafeURI;
import link.infra.packwiz.installer.metadata.hash.GeneralHashingSource;
import link.infra.packwiz.installer.metadata.hash.Hash;
import link.infra.packwiz.installer.metadata.hash.HashUtils;
import link.infra.packwiz.installer.request.HandlerManager;
import link.infra.packwiz.installer.ui.IExceptionDetails;
import link.infra.packwiz.installer.ui.IUserInterface;
import link.infra.packwiz.installer.ui.InputStateHandler;
import link.infra.packwiz.installer.ui.InstallProgress;
import okio.Okio;
import okio.Source;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.*;
import java.util.concurrent.*;
import java.util.stream.Collectors;
public class UpdateManager {
private final Options opts;
public final IUserInterface ui;
private boolean cancelled;
private boolean cancelledStartGame = false;
private InputStateHandler stateHandler;
private boolean errorsOccurred = false;
public static class Options {
SpaceSafeURI downloadURI = null;
String manifestFile = "packwiz.json"; // TODO: make configurable
String packFolder = ".";
Side side = Side.CLIENT;
public enum Side {
@SerializedName("client")
CLIENT("client"),
@SerializedName("server")
SERVER("server"),
@SerializedName("both")
BOTH("both", new Side[] { CLIENT, SERVER });
private final String sideName;
private final Side[] depSides;
Side(String sideName) {
this.sideName = sideName.toLowerCase();
this.depSides = null;
}
Side(String sideName, Side[] depSides) {
this.sideName = sideName.toLowerCase();
this.depSides = depSides;
}
@Override
public String toString() {
return this.sideName;
}
public boolean hasSide(Side tSide) {
if (this.equals(tSide)) {
return true;
}
if (this.depSides != null) {
for (Side depSide : this.depSides) {
if (depSide.equals(tSide)) {
return true;
}
}
}
return false;
}
public static Side from(String name) {
String lowerName = name.toLowerCase();
for (Side side : Side.values()) {
if (side.sideName.equals(lowerName)) {
return side;
}
}
return null;
}
}
}
UpdateManager(Options opts, IUserInterface ui, InputStateHandler inputStateHandler) {
this.opts = opts;
this.ui = ui;
this.stateHandler = inputStateHandler;
this.start();
}
private void start() {
this.checkOptions();
ui.submitProgress(new InstallProgress("Loading manifest file..."));
Gson gson = new GsonBuilder().registerTypeAdapter(Hash.class, new Hash.TypeHandler()).create();
ManifestFile manifest;
try {
manifest = gson.fromJson(new FileReader(Paths.get(opts.packFolder, opts.manifestFile).toString()),
ManifestFile.class);
} catch (FileNotFoundException e) {
manifest = new ManifestFile();
} catch (JsonSyntaxException | JsonIOException e) {
ui.handleExceptionAndExit(e);
return;
}
if (stateHandler.getCancelButton()) {
showCancellationDialog();
handleCancellation();
}
ui.submitProgress(new InstallProgress("Loading pack file..."));
GeneralHashingSource packFileSource;
try {
Source src = HandlerManager.getFileSource(opts.downloadURI);
packFileSource = HashUtils.getHasher("sha256").getHashingSource(src);
} catch (Exception e) {
// TODO: still launch the game if updating doesn't work?
// TODO: ask user if they want to launch the game, exit(1) if they don't
ui.handleExceptionAndExit(e);
return;
}
PackFile pf;
try {
pf = new Toml().read(Okio.buffer(packFileSource).inputStream()).to(PackFile.class);
} catch (IllegalStateException e) {
ui.handleExceptionAndExit(e);
return;
}
if (stateHandler.getCancelButton()) {
showCancellationDialog();
handleCancellation();
}
ui.submitProgress(new InstallProgress("Checking local files..."));
// Invalidation checking must be done here, as it must happen before pack/index hashes are checked
List<SpaceSafeURI> invalidatedUris = new ArrayList<>();
if (manifest.cachedFiles != null) {
for (Map.Entry<SpaceSafeURI, ManifestFile.File> entry : manifest.cachedFiles.entrySet()) {
// ignore onlyOtherSide files
if (entry.getValue().onlyOtherSide) {
continue;
}
boolean invalid = false;
// if isn't optional, or is optional but optionValue == true
if (!entry.getValue().isOptional || entry.getValue().optionValue) {
if (entry.getValue().cachedLocation != null) {
if (!Files.exists(Paths.get(opts.packFolder, entry.getValue().cachedLocation))) {
invalid = true;
}
} else {
// if cachedLocation == null, should probably be installed!!
invalid = true;
}
}
if (invalid) {
SpaceSafeURI fileUri = entry.getKey();
System.out.println("File " + fileUri.toString() + " invalidated, marked for redownloading");
invalidatedUris.add(fileUri);
}
}
}
if (manifest.packFileHash != null && packFileSource.hashIsEqual(manifest.packFileHash) && invalidatedUris.isEmpty()) {
System.out.println("Modpack is already up to date!");
// todo: --force?
if (!stateHandler.getOptionsButton()) {
return;
}
}
System.out.println("Modpack name: " + pf.name);
if (stateHandler.getCancelButton()) {
showCancellationDialog();
handleCancellation();
}
try {
// This is badly written, I'll probably heavily refactor it at some point
processIndex(HandlerManager.getNewLoc(opts.downloadURI, pf.index.file),
HashUtils.getHash(pf.index.hashFormat, pf.index.hash), pf.index.hashFormat, manifest, invalidatedUris);
} catch (Exception e1) {
ui.handleExceptionAndExit(e1);
}
handleCancellation();
// TODO: update MMC params, java args etc
// If there were errors, don't write the manifest/index hashes, to ensure they are rechecked later
if (errorsOccurred) {
manifest.indexFileHash = null;
manifest.packFileHash = null;
} else {
manifest.packFileHash = packFileSource.getHash();
}
manifest.cachedSide = opts.side;
try (Writer writer = new FileWriter(Paths.get(opts.packFolder, opts.manifestFile).toString())) {
gson.toJson(manifest, writer);
} catch (IOException e) {
// TODO: add message?
ui.handleException(e);
}
}
private void checkOptions() {
// TODO: implement
}
private void processIndex(SpaceSafeURI indexUri, Hash indexHash, String hashFormat, ManifestFile manifest, List<SpaceSafeURI> invalidatedUris) {
if (manifest.indexFileHash != null && manifest.indexFileHash.equals(indexHash) && invalidatedUris.isEmpty()) {
System.out.println("Modpack files are already up to date!");
if (!stateHandler.getOptionsButton()) {
return;
}
}
manifest.indexFileHash = indexHash;
GeneralHashingSource indexFileSource;
try {
Source src = HandlerManager.getFileSource(indexUri);
indexFileSource = HashUtils.getHasher(hashFormat).getHashingSource(src);
} catch (Exception e) {
// TODO: still launch the game if updating doesn't work?
// TODO: ask user if they want to launch the game, exit(1) if they don't
ui.handleExceptionAndExit(e);
return;
}
IndexFile indexFile;
try {
indexFile = new Toml().read(Okio.buffer(indexFileSource).inputStream()).to(IndexFile.class);
} catch (IllegalStateException e) {
ui.handleExceptionAndExit(e);
return;
}
if (!indexFileSource.hashIsEqual(indexHash)) {
// TODO: throw exception
System.out.println("I was meant to put an error message here but I'll do that later");
return;
}
if (stateHandler.getCancelButton()) {
showCancellationDialog();
return;
}
if (manifest.cachedFiles == null) {
manifest.cachedFiles = new HashMap<>();
}
ui.submitProgress(new InstallProgress("Checking local files..."));
Iterator<Map.Entry<SpaceSafeURI, ManifestFile.File>> it = manifest.cachedFiles.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<SpaceSafeURI, ManifestFile.File> entry = it.next();
if (entry.getValue().cachedLocation != null) {
boolean alreadyDeleted = false;
// Delete if option value has been set to false
if (entry.getValue().isOptional && !entry.getValue().optionValue) {
try {
Files.deleteIfExists(Paths.get(opts.packFolder, entry.getValue().cachedLocation));
} catch (IOException e) {
// TODO: should this be shown to the user in some way?
e.printStackTrace();
}
// Set to null, as it doesn't exist anymore
entry.getValue().cachedLocation = null;
alreadyDeleted = true;
}
if (indexFile.files.stream().noneMatch(f -> f.file.equals(entry.getKey()))) {
// File has been removed from the index
if (!alreadyDeleted) {
try {
Files.deleteIfExists(Paths.get(opts.packFolder, entry.getValue().cachedLocation));
} catch (IOException e) {
// TODO: should this be shown to the user in some way?
e.printStackTrace();
}
}
it.remove();
}
}
}
if (stateHandler.getCancelButton()) {
showCancellationDialog();
return;
}
ui.submitProgress(new InstallProgress("Comparing new files..."));
// TODO: progress bar?
if (indexFile.files == null || indexFile.files.size() == 0) {
System.out.println("Warning: Index is empty!");
indexFile.files = new ArrayList<>();
}
List<DownloadTask> tasks = DownloadTask.createTasksFromIndex(indexFile, indexFile.hashFormat, opts.side);
// If the side changes, invalidate EVERYTHING just in case
// Might not be needed, but done just to be safe
boolean invalidateAll = !opts.side.equals(manifest.cachedSide);
if (invalidateAll) {
System.out.println("Side changed, invalidating all mods");
}
tasks.forEach(f -> {
// TODO: should linkedfile be checked as well? should this be done in the download section?
if (invalidateAll) {
f.invalidate();
} else if (invalidatedUris.contains(f.metadata.file)) {
f.invalidate();
}
ManifestFile.File file = manifest.cachedFiles.get(f.metadata.file);
if (file != null) {
// Ensure the file can be reverted later if necessary - the DownloadTask modifies the file so if it fails we need the old version back
file.backup();
}
// If it is null, the DownloadTask will make a new empty cachedFile
f.updateFromCache(file);
});
if (stateHandler.getCancelButton()) {
showCancellationDialog();
return;
}
// Let's hope downloadMetadata is a pure function!!!
tasks.parallelStream().forEach(f -> f.downloadMetadata(indexFile, indexUri));
List<IExceptionDetails> failedTasks = tasks.stream().filter(t -> t.getException() != null).collect(Collectors.toList());
if (!failedTasks.isEmpty()) {
errorsOccurred = true;
IExceptionDetails.ExceptionListResult exceptionListResult;
try {
exceptionListResult = ui.showExceptions(failedTasks, tasks.size(), true).get();
} catch (InterruptedException | ExecutionException e) {
// Interrupted means cancelled???
ui.handleExceptionAndExit(e);
return;
}
switch (exceptionListResult) {
case CONTINUE:
break;
case CANCEL:
cancelled = true;
return;
case IGNORE:
cancelledStartGame = true;
return;
}
}
if (stateHandler.getCancelButton()) {
showCancellationDialog();
return;
}
List<DownloadTask> nonFailedFirstTasks = tasks.stream().filter(t -> t.getException() == null).collect(Collectors.toList());
List<DownloadTask> optionTasks = nonFailedFirstTasks.stream().filter(DownloadTask::correctSide).filter(DownloadTask::isOptional).collect(Collectors.toList());
// If options changed, present all options again
if (stateHandler.getOptionsButton() || optionTasks.stream().anyMatch(DownloadTask::isNewOptional)) {
// new ArrayList is requires so it's an IOptionDetails rather than a DownloadTask list
Future<Boolean> cancelledResult = ui.showOptions(new ArrayList<>(optionTasks));
try {
if (cancelledResult.get()) {
cancelled = true;
// TODO: Should the UI be closed somehow??
return;
}
} catch (InterruptedException | ExecutionException e) {
// Interrupted means cancelled???
ui.handleExceptionAndExit(e);
}
}
ui.disableOptionsButton();
// TODO: different thread pool type?
ExecutorService threadPool = Executors.newFixedThreadPool(10);
CompletionService<DownloadTask> completionService = new ExecutorCompletionService<>(threadPool);
tasks.forEach(t -> completionService.submit(() -> {
t.download(opts.packFolder, indexUri);
return t;
}));
for (int i = 0; i < tasks.size(); i++) {
DownloadTask task;
try {
task = completionService.take().get();
} catch (InterruptedException | ExecutionException e) {
ui.handleException(e);
task = null;
}
// Update manifest - If there were no errors cachedFile has already been modified in place (good old pass by reference)
if (task != null) {
if (task.getException() != null) {
ManifestFile.File file = task.cachedFile.getRevert();
if (file != null) {
manifest.cachedFiles.putIfAbsent(task.metadata.file, file);
}
} else {
// idiot, if it wasn't there in the first place it won't magically appear there
manifest.cachedFiles.putIfAbsent(task.metadata.file, task.cachedFile);
}
}
String progress;
if (task != null) {
if (task.getException() != null) {
progress = "Failed to download " + task.metadata.getName() + ": " + task.getException().getMessage();
task.getException().printStackTrace();
} else {
// TODO: should this be revised for tasks that didn't actually download it?
progress = "Downloaded " + task.metadata.getName();
}
} else {
progress = "Failed to download, unknown reason";
}
ui.submitProgress(new InstallProgress(progress, i + 1, tasks.size()));
if (stateHandler.getCancelButton()) {
// Stop all tasks, don't launch the game (it's in an invalid state!)
threadPool.shutdown();
cancelled = true;
return;
}
}
// Shut down the thread pool when the update is done
threadPool.shutdown();
List<IExceptionDetails> failedTasks2ElectricBoogaloo = nonFailedFirstTasks.stream().filter(t -> t.getException() != null).collect(Collectors.toList());
if (!failedTasks2ElectricBoogaloo.isEmpty()) {
errorsOccurred = true;
IExceptionDetails.ExceptionListResult exceptionListResult;
try {
exceptionListResult = ui.showExceptions(failedTasks2ElectricBoogaloo, tasks.size(), false).get();
} catch (InterruptedException | ExecutionException e) {
// Interrupted means cancelled???
ui.handleExceptionAndExit(e);
return;
}
switch (exceptionListResult) {
case CONTINUE:
break;
case CANCEL:
cancelled = true;
return;
case IGNORE:
cancelledStartGame = true;
}
}
}
private void showCancellationDialog() {
IExceptionDetails.ExceptionListResult exceptionListResult;
try {
exceptionListResult = ui.showCancellationDialog().get();
} catch (InterruptedException | ExecutionException e) {
// Interrupted means cancelled???
ui.handleExceptionAndExit(e);
return;
}
switch (exceptionListResult) {
case CONTINUE:
throw new RuntimeException("Continuation not allowed here!");
case CANCEL:
cancelled = true;
return;
case IGNORE:
cancelledStartGame = true;
}
}
private void handleCancellation() {
if (cancelled) {
System.out.println("Update cancelled by user!");
System.exit(1);
} else if (cancelledStartGame) {
System.out.println("Update cancelled by user! Continuing to start game...");
System.exit(0);
}
}
}