package com.faforever.client.patch; import com.faforever.client.i18n.I18n; import com.faforever.client.io.ByteCopier; import com.faforever.client.mod.ModService; import com.faforever.client.notification.NotificationService; import com.faforever.client.notification.PersistentNotification; import com.faforever.client.notification.Severity; import com.faforever.client.os.OperatingSystem; import com.faforever.client.preferences.PreferencesService; import com.faforever.client.task.CompletableTask; import com.faforever.client.task.ResourceLocks; import com.google.common.hash.Hashing; import javafx.beans.Observable; import javafx.collections.ObservableList; import net.dongliu.vcdiff.VcdiffDecoder; import net.dongliu.vcdiff.exception.VcdiffDecodeException; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.env.Environment; import javax.annotation.Resource; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.lang.invoke.MethodHandles; import java.net.HttpURLConnection; import java.net.URL; import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.stream.Collectors; import static com.faforever.client.game.GameType.FAF; import static com.faforever.client.game.GameType.LADDER_1V1; import static com.github.nocatch.NoCatch.noCatch; import static com.google.common.net.UrlEscapers.urlPathSegmentEscaper; import static javafx.collections.FXCollections.observableArrayList; public class UpdateGameFilesTask extends CompletableTask<Void> implements UpdateServerResponseListener { private static final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); private static final long TIMEOUT = 30; private static final TimeUnit TIMEOUT_UNIT = TimeUnit.SECONDS; private final ObservableList<String> filesToUpdate; @Resource I18n i18n; @Resource ModService modService; @Resource NotificationService notificationService; @Resource UpdateServerAccessor updateServerAccessor; @Resource PreferencesService preferencesService; @Resource Environment environment; private String targetDirectoryName; private String gameType; private Set<String> simMods; private Map<String, Integer> modVersions; private int numberOfFilesToUpdate; private String gameVersion; public UpdateGameFilesTask() { super(Priority.HIGH); filesToUpdate = observableArrayList(); } @Override protected Void call() throws Exception { updateTitle(i18n.get("prepareGameUpdateTask.preparing")); copyGameFilesToFafBinDirectory(); updateServerAccessor.connect(this); downloadMissingSimMods(); modService.enableSimMods(simMods); try { if (FAF.getString().equals(gameType) || LADDER_1V1.getString().equals(gameType)) { updateFiles("bin", "FAF"); updateFiles("gamedata", "FAFGAMEDATA"); } else { updateFiles("bin", "FAF"); updateFiles("gamedata", "FAFGAMEDATA"); updateFiles("bin", gameType); updateFiles("gamedata", gameType + "GameData"); } } catch (IOException | InterruptedException | ExecutionException | TimeoutException e) { notificationService.addNotification( new PersistentNotification(i18n.get("update.error.updateFailed", e.getLocalizedMessage()), Severity.WARN) ); throw e; } finally { updateServerAccessor.disconnect(); } return null; } protected void copyGameFilesToFafBinDirectory() throws IOException { logger.info("Copying game files from FA to FAF folder"); Path faBinDirectory = preferencesService.getPreferences().getForgedAlliance().getPath().resolve("bin"); try (DirectoryStream<Path> directoryStream = Files.newDirectoryStream(faBinDirectory)) { for (Path source : directoryStream) { Path destination = preferencesService.getFafBinDirectory().resolve(source.getFileName()); if (Files.exists(destination)) { continue; } logger.debug("Copying file '{}' to '{}'", source, destination); Files.createDirectories(destination.getParent()); Files.copy(source, destination, StandardCopyOption.REPLACE_EXISTING); if (OperatingSystem.current() == OperatingSystem.WINDOWS) { Files.setAttribute(destination, "dos:readonly", false); } } } } private void downloadMissingSimMods() throws InterruptedException, ExecutionException, TimeoutException, IOException { Set<String> uidsOfRequiredSimMods = simMods; if (uidsOfRequiredSimMods.isEmpty()) { return; } Set<String> uidsOfInstalledMods = modService.getInstalledModUids(); Set<String> uidsOfModsToInstall = uidsOfRequiredSimMods.stream() .filter(uid -> !uidsOfInstalledMods.contains(uid)) .collect(Collectors.toSet()); for (String uid : uidsOfModsToInstall) { downloadMod(uid); } } private void updateFiles(String targetDirectoryName, String fileGroup) throws IOException, InterruptedException, ExecutionException, TimeoutException { CompletableFuture<List<String>> filesToUpdateFuture = new CompletableFuture<>(); updateServerAccessor.requestFilesToUpdate(fileGroup) .thenApply(filesToUpdateFuture::complete) .exceptionally(throwable -> { logger.warn("Files could not be updated", throwable); return null; }); filesToUpdate.setAll(filesToUpdateFuture.get(TIMEOUT, TIMEOUT_UNIT)); numberOfFilesToUpdate = filesToUpdate.size(); requestFiles(targetDirectoryName, fileGroup); CountDownLatch filesUpdatedLatch = new CountDownLatch(1); filesToUpdate.addListener((Observable observable) -> { synchronized (filesToUpdate) { updateTitle(i18n.get("updatingGameTask.updatingFile", numberOfFilesToUpdate - filesToUpdate.size(), numberOfFilesToUpdate)); if (filesToUpdate.isEmpty()) { filesUpdatedLatch.countDown(); } } }); filesUpdatedLatch.await(); logger.debug("File group '{}' for game type '{}' has been updated", fileGroup, gameType); } private void downloadMod(String uid) { noCatch(() -> { String modPath = updateServerAccessor.requestSimPath(uid).toCompletableFuture().get(TIMEOUT, TIMEOUT_UNIT); URL url = new URL(environment.getProperty("vault.modRoot") + urlPathSegmentEscaper().escape(modPath.replace("mods/", ""))); modService.downloadAndInstallMod(url) .exceptionally(throwable -> { logger.warn("Mod '" + uid + "' could not be downloaded", throwable); return null; }).toCompletableFuture().get(); updateServerAccessor.incrementModDownloadCount(uid); }); } private void requestFiles(String targetDirectoryName, String fileGroup) throws IOException { this.targetDirectoryName = targetDirectoryName; Path targetDirectory = preferencesService.getFafDataDirectory().resolve(targetDirectoryName); synchronized (filesToUpdate) { for (String filename : filesToUpdate) { Path fileToPatch = targetDirectory.resolve(filename); logger.debug("Updating file {}", fileToPatch.toAbsolutePath()); if (Files.notExists(fileToPatch)) { if (gameVersion != null) { if (FAF.getString().equals(gameType) || LADDER_1V1.getString().equals(gameType) || fileGroup.equals("FAF") || fileGroup.equals("FAFGAMEDATA")) { updateServerAccessor.requestVersion(targetDirectoryName, filename, gameVersion); } else { updateServerAccessor.requestModVersion(targetDirectoryName, filename, modVersions); } } else { updateServerAccessor.requestPath(targetDirectoryName, filename); } } else { String currentMd5 = com.google.common.io.Files.hash(fileToPatch.toFile(), Hashing.md5()).toString(); if (gameVersion != null) { if (FAF.getString().equals(gameType) || LADDER_1V1.getString().equals(gameType) || fileGroup.equals("FAF") || fileGroup.equals("FAFGAMEDATA")) { updateServerAccessor.patchTo(targetDirectoryName, filename, currentMd5, gameVersion); } else { updateServerAccessor.modPatchTo(targetDirectoryName, filename, currentMd5, modVersions); } } else { updateServerAccessor.update(targetDirectoryName, filename, currentMd5); } } } } } @Override public void onFileUpToDate(String file) { logger.debug("File is already up to date: {}", file); synchronized (filesToUpdate) { filesToUpdate.remove(file); } } @Override public void onFileUrl(String targetDirectoryName, String fileToCopy, String url) { Path targetFile = preferencesService.getFafDataDirectory().resolve(targetDirectoryName).resolve(fileToCopy); try { downloadFile(new URL(url), targetFile); synchronized (filesToUpdate) { filesToUpdate.remove(fileToCopy); } } catch (IOException e) { throw new RuntimeException(e); } } @Override public void onPatchUrl(String targetDirectoryName, String fileToUpdate, String url) { Path targetFile = preferencesService.getFafDataDirectory().resolve(targetDirectoryName).resolve(fileToUpdate); Path patchFile = preferencesService.getFafDataDirectory().resolve(targetDirectoryName).resolve("patch.tmp"); try { downloadFile(new URL(url), patchFile); applyPatch(patchFile, targetFile); synchronized (filesToUpdate) { filesToUpdate.remove(fileToUpdate); } } catch (IOException e) { throw new RuntimeException(e); } } @Override public void onVersionPatchNotFound(String response) { updateServerAccessor.requestVersion(targetDirectoryName, response, gameVersion); } @Override public void onVersionModPatchNotFound(String response) { updateServerAccessor.requestModVersion(targetDirectoryName, response, modVersions); } @Override public void onPatchNotFound(String response) { updateServerAccessor.request(targetDirectoryName, response); } private void applyPatch(Path patchFile, Path targetFile) throws IOException { Path oldFile = targetFile.getParent().resolve(targetFile.getFileName().toString() + ".old"); Files.move(targetFile, oldFile); try { VcdiffDecoder.decode(oldFile.toFile(), patchFile.toFile(), targetFile.toFile()); } catch (VcdiffDecodeException e) { Files.delete(targetFile); Files.move(oldFile, targetFile); throw new IOException(e); } Files.delete(oldFile); Files.delete(patchFile); } private void downloadFile(URL url, Path targetFile) throws IOException { logger.debug("Downloading file {} to {}", url, targetFile); Files.createDirectories(targetFile.getParent()); Path tempFile = targetFile.getParent().resolve(targetFile.getFileName().toString() + ".tmp"); HttpURLConnection urlConnection = (HttpURLConnection) url.openConnection(); try (InputStream inputStream = url.openStream(); OutputStream outputStream = Files.newOutputStream(tempFile)) { ResourceLocks.acquireDownloadLock(); updateTitle(i18n.get("downloadingGamePatchTask.downloadingFile", url)); ByteCopier.from(inputStream) .to(outputStream) .totalBytes(urlConnection.getContentLength()) .listener(this::updateProgress) .copy(); Files.move(tempFile, targetFile, StandardCopyOption.REPLACE_EXISTING); } finally { ResourceLocks.freeDownloadLock(); try { Files.deleteIfExists(tempFile); } catch (IOException e) { logger.warn("Could not delete temporary file: " + tempFile.toAbsolutePath(), e); } } } public void setSimMods(@NotNull Set<String> simMods) { this.simMods = simMods; } public void setModVersions(@NotNull Map<String, Integer> modVersions) { this.modVersions = modVersions; } public void setGameType(@NotNull String gameType) { this.gameType = gameType; } public void setGameVersion(String gameVersion) { this.gameVersion = gameVersion; } }