Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
"MSG_CREATE_SEPARATOR": " Creating separator in {0}",
"MSG_GENERATE_META": " Generating meta.ini file",
"MSG_ADDON_DELETE_OLD_VERSION": " Deleting contents of the previous version",
"MSG_DOWNLOADING_ARCHIVE": " Downloading archive...",
"MSG_ADDON_ALREADY_UP_TO_DATE": " Installed version matches archive — skipping reinstall",
"MSG_HASH_MISMATCH_SUMMARY": "\nHash mismatches detected for the following mods:",
"MSG_HASH_MISMATCH_ENTRY": " {0}: expected {1}, got {2}",
"MSG_READ_SETUP" : " Reading setup instructions",
"MSG_COPY_TO": " Copying {0} ==> {1}",
"MSG_COPY": " Copying {0}",
Expand All @@ -33,5 +35,9 @@
"ERR_INSTALLATION_FAILED": "An error occurred during installation: {0}",

"DLG_RESUME_TITLE": "Resume Installation",
"DLG_RESUME_MESSAGE": "Previous installation was interrupted at \"{0}\". Resume from there?"
"DLG_RESUME_MESSAGE": "Previous installation was interrupted at \"{0}\". Resume from there?",

"DLG_HASH_MISMATCH_TITLE": "Hash Mismatch",
"DLG_HASH_MISMATCH_UPDATE_TITLE": "Update Hashes",
"DLG_HASH_MISMATCH_UPDATE_MESSAGE": "Would you like to update the mismatched hashes in modlist.yaml with the actual values?"
}
131 changes: 116 additions & 15 deletions src/main/java/de/tosox/zonerelay/application/InstallCoordinator.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,31 @@
import com.google.inject.Singleton;
import com.google.inject.name.Named;
import de.tosox.zonerelay.domain.model.EntryType;
import de.tosox.zonerelay.domain.model.HashMismatch;
import de.tosox.zonerelay.domain.model.Mod;
import de.tosox.zonerelay.domain.model.ModEntry;
import de.tosox.zonerelay.domain.model.ModlistConfig;
import de.tosox.zonerelay.domain.port.*;
import de.tosox.zonerelay.infrastructure.download.DownloadsManifestStore;
import de.tosox.zonerelay.infrastructure.download.ManifestEntry;
import de.tosox.zonerelay.infrastructure.persistence.ModlistHashUpdater;
import de.tosox.zonerelay.shared.config.AppPaths;
import de.tosox.zonerelay.shared.config.ArchiveCleanupStrategy;
import de.tosox.zonerelay.shared.config.UserSettings;
import de.tosox.zonerelay.shared.i18n.Localizer;
import de.tosox.zonerelay.shared.logging.Logger;
import de.tosox.zonerelay.shared.progress.ProgressListener;
import lombok.Setter;

import org.apache.commons.io.FileUtils;

import javax.swing.*;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;

@Singleton
public class InstallCoordinator {
Expand All @@ -33,6 +42,9 @@ public class InstallCoordinator {
private final ShortcutCreator shortcutCreator;
private final InstallProgressStore progressStore;
private final AppPaths paths;
private final UserSettings userSettings;
private final DownloadsManifestStore manifestStore;
private final ModlistHashUpdater modlistHashUpdater;

private final AtomicBoolean isInstalling = new AtomicBoolean(false);

Expand All @@ -47,7 +59,9 @@ public InstallCoordinator(@Named("file") Logger fileLogger, @Named("ui") Logger
Localizer localizer, List<ModInstaller> installers,
ArchiveDownloader archiveDownloader, ProfileSetup profileSetup,
SplashImageCopier splashImageCopier, ShortcutCreator shortcutCreator,
InstallProgressStore progressStore, AppPaths paths) {
InstallProgressStore progressStore, AppPaths paths,
UserSettings userSettings, DownloadsManifestStore manifestStore,
ModlistHashUpdater modlistHashUpdater) {
this.fileLogger = fileLogger;
this.uiLogger = uiLogger;
this.localizer = localizer;
Expand All @@ -58,6 +72,9 @@ public InstallCoordinator(@Named("file") Logger fileLogger, @Named("ui") Logger
this.shortcutCreator = shortcutCreator;
this.progressStore = progressStore;
this.paths = paths;
this.userSettings = userSettings;
this.manifestStore = manifestStore;
this.modlistHashUpdater = modlistHashUpdater;
}

public void startInstallation(ModlistConfig config, boolean fullInstall, String resumeFromId) {
Expand Down Expand Up @@ -87,13 +104,14 @@ private void runInstallation(ModlistConfig config, boolean fullInstall, String r
int totalMods = config.getMods().size() + config.getPatches().size() + 1;
AtomicInteger completedMods = new AtomicInteger(0);
AtomicBoolean resumePointFound = new AtomicBoolean(resumeFromId == null);
List<HashMismatch> hashMismatches = new ArrayList<>();

uiLogger.info("\n=================================================================");
uiLogger.info(localizer.translate("MSG_STARTING_INSTALLATION"));
uiLogger.info("=================================================================");
installEntries(config.getMods(), fullInstall, totalMods, completedMods, resumeFromId, resumePointFound);
installEntries(config.getPatches(), fullInstall, totalMods, completedMods, resumeFromId, resumePointFound);
installEntries(config.getSeparators(), fullInstall, totalMods, completedMods, resumeFromId, resumePointFound);
installEntries(config.getMods(), fullInstall, totalMods, completedMods, resumeFromId, resumePointFound, hashMismatches);
installEntries(config.getPatches(), fullInstall, totalMods, completedMods, resumeFromId, resumePointFound, hashMismatches);
installEntries(config.getSeparators(), fullInstall, totalMods, completedMods, resumeFromId, resumePointFound, hashMismatches);

uiLogger.info("\n=================================================================");
uiLogger.info(localizer.translate("MSG_INSTALLATION_MO2_SETUP"));
Expand All @@ -107,6 +125,10 @@ private void runInstallation(ModlistConfig config, boolean fullInstall, String r
progressStore.clear();
FileUtils.deleteQuietly(paths.getTempDir().toFile());

if (!hashMismatches.isEmpty()) {
presentHashMismatchSummary(hashMismatches);
}

uiLogger.info(localizer.translate("MSG_COMPLETE_INSTALLATION"));
fileLogger.info("Installation completed successfully");

Expand All @@ -115,7 +137,8 @@ private void runInstallation(ModlistConfig config, boolean fullInstall, String r

private void installEntries(List<? extends ModEntry> entries, boolean fullInstall,
int totalMods, AtomicInteger completedMods,
String resumeFromId, AtomicBoolean resumePointFound) throws Exception {
String resumeFromId, AtomicBoolean resumePointFound,
List<HashMismatch> hashMismatches) throws Exception {
if (entries == null || entries.isEmpty()) {
return;
}
Expand All @@ -135,24 +158,102 @@ private void installEntries(List<? extends ModEntry> entries, boolean fullInstal
uiLogger.info(localizer.translate("MSG_TITLE_CONFIGENTRY", entry.getName()));
fileLogger.info("Installing entry: %s", entry.getId());

File archive = null;
File archive;
if (entry instanceof Mod mod) {
uiLogger.info(localizer.translate("MSG_DOWNLOADING_ARCHIVE"));
archive = archiveDownloader.download(mod.getUrl(), paths.getDownloadsDir().toFile(), currentProgressListener);
}
ManifestEntry previousEntry = manifestStore.getManifest().getEntry(mod.getId());

ModInstaller installer = installers.stream()
.filter(i -> i.supports(entry))
.findFirst()
.orElseThrow(() -> new IllegalStateException("No installer for entry type: " + entry.getType()));
installer.install(entry, archive, currentProgressListener);
DownloadResult result = archiveDownloader.download(mod.getUrl(), mod.getId(), mod.getHash(),
paths.getDownloadsDir().toFile(), currentProgressListener);
archive = result.archive();

String installedHash = previousEntry != null ? previousEntry.installedHash() : null;
if (installedHash != null && installedHash.equalsIgnoreCase(result.computedHash())) {
uiLogger.info(localizer.translate("MSG_ADDON_ALREADY_UP_TO_DATE"));
fileLogger.info("Installed hash matches archive, skipping reinstall: %s", mod.getId());
if (entry.getType() != EntryType.SEPARATOR) {
totalProgressListener.onProgressUpdate(completedMods.incrementAndGet(), totalMods);
}
continue;
}

if (mod.hasHash()) {
if (!mod.getHash().equalsIgnoreCase(result.computedHash())) {
hashMismatches.add(new HashMismatch(mod.getName(), mod.getId(), mod.getHash(), result.computedHash()));
fileLogger.warn("Hash mismatch for %s: expected=%s actual=%s",
mod.getName(), mod.getHash(), result.computedHash());
} else {
fileLogger.info("Hash OK for %s", mod.getName());
}
}

findInstaller(entry).install(entry, archive, currentProgressListener);

manifestStore.recordInstall(mod.getId(), result.computedHash());
applyArchiveCleanup(result, previousEntry);
} else {
findInstaller(entry).install(entry, null, currentProgressListener);
}

if (entry.getType() != EntryType.SEPARATOR) {
totalProgressListener.onProgressUpdate(completedMods.incrementAndGet(), totalMods);
}
}
}

private ModInstaller findInstaller(ModEntry entry) {
return installers.stream()
.filter(i -> i.supports(entry))
.findFirst()
.orElseThrow(() -> new IllegalStateException("No installer for entry type: " + entry.getType()));
}

private void applyArchiveCleanup(DownloadResult result, ManifestEntry previousEntry) {
ArchiveCleanupStrategy strategy = userSettings.getArchiveCleanupStrategy();
File currentArchive = result.archive();

switch (strategy) {
case DELETE_ALL -> {
FileUtils.deleteQuietly(currentArchive);
fileLogger.info("DELETE_ALL: removed archive %s", currentArchive.getName());
}
case KEEP_LATEST_ONLY -> {
if (previousEntry != null && !previousEntry.filename().equals(currentArchive.getName())) {
File oldArchive = new File(currentArchive.getParentFile(), previousEntry.filename());
if (oldArchive.isFile()) {
FileUtils.deleteQuietly(oldArchive);
fileLogger.info("KEEP_LATEST_ONLY: removed old archive %s", previousEntry.filename());
}
}
}
case KEEP_ALL -> { /* do nothing */ }
}
}

private void presentHashMismatchSummary(List<HashMismatch> mismatches) {
StringBuilder sb = new StringBuilder(localizer.translate("MSG_HASH_MISMATCH_SUMMARY"));
for (HashMismatch m : mismatches) {
sb.append("\n").append(localizer.translate("MSG_HASH_MISMATCH_ENTRY",
m.modName(), m.expectedHash(), m.actualHash()));
}
String message = sb.toString();
uiLogger.warn(message);
fileLogger.warn(message);
SwingUtilities.invokeLater(() -> {
JOptionPane.showMessageDialog(null, message,
localizer.translate("DLG_HASH_MISMATCH_TITLE"), JOptionPane.WARNING_MESSAGE);
int choice = JOptionPane.showConfirmDialog(null,
localizer.translate("DLG_HASH_MISMATCH_UPDATE_MESSAGE"),
localizer.translate("DLG_HASH_MISMATCH_UPDATE_TITLE"),
JOptionPane.YES_NO_OPTION);
if (choice == JOptionPane.YES_OPTION) {
Map<String, String> updates = mismatches.stream()
.collect(Collectors.toMap(HashMismatch::modId, HashMismatch::actualHash));
new Thread(() -> modlistHashUpdater.updateHashes(paths.getModlistYaml(), updates),
"modlist-hash-update").start();
}
});
}

private void setupMo2Environment(ModlistConfig config) {
uiLogger.info(localizer.translate("MSG_CREATE_CUSTOM_PROFILE"));
fileLogger.info("Setting up MO2 profile");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package de.tosox.zonerelay.domain.model;

public record HashMismatch(String modName, String modId, String expectedHash, String actualHash) {}
11 changes: 9 additions & 2 deletions src/main/java/de/tosox/zonerelay/domain/model/Mod.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,25 @@
public class Mod extends ModEntry {
private final String url;
private final List<String> setup;
private final String hash;

@JsonCreator
public Mod(@JsonProperty("id") String id,
@JsonProperty("name") String name,
@JsonProperty("url") String url,
@JsonProperty("hash") String hash,
@JsonProperty("setup") List<String> setup) {
this(id, EntryType.MOD, name, url, setup);
this(id, EntryType.MOD, name, url, hash, setup);
}

protected Mod(String id, EntryType type, String name, String url, List<String> setup) {
protected Mod(String id, EntryType type, String name, String url, String hash, List<String> setup) {
super(id, type, name);
this.url = url;
this.hash = hash;
this.setup = setup != null ? setup : List.of();
}

public boolean hasHash() {
return hash != null && !hash.isBlank();
}
}
3 changes: 2 additions & 1 deletion src/main/java/de/tosox/zonerelay/domain/model/Patch.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ public class Patch extends Mod {
public Patch(@JsonProperty("id") String id,
@JsonProperty("name") String name,
@JsonProperty("url") String url,
@JsonProperty("hash") String hash,
@JsonProperty("setup") List<String> setup) {
super(id, EntryType.PATCH, name, url, setup);
super(id, EntryType.PATCH, name, url, hash, setup);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@
import java.io.File;

public interface ArchiveDownloader {
File download(String url, File destination, ProgressListener listener) throws Exception;
DownloadResult download(String url, String modId, String declaredHash, File destination, ProgressListener listener) throws Exception;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package de.tosox.zonerelay.domain.port;

import java.io.File;

public record DownloadResult(File archive, String computedHash) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package de.tosox.zonerelay.infrastructure.download;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;

import java.util.HashMap;
import java.util.Map;

@Getter
public class DownloadManifest {
private final Map<String, ManifestEntry> mods;

public DownloadManifest() {
this.mods = new HashMap<>();
}

@JsonCreator
public DownloadManifest(@JsonProperty("mods") Map<String, ManifestEntry> mods) {
this.mods = mods != null ? new HashMap<>(mods) : new HashMap<>();
}

public ManifestEntry getEntry(String modId) {
return mods.get(modId);
}

public void putEntry(String modId, ManifestEntry entry) {
mods.put(modId, entry);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package de.tosox.zonerelay.infrastructure.download;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import com.google.inject.name.Named;
import de.tosox.zonerelay.shared.config.AppPaths;
import de.tosox.zonerelay.shared.logging.Logger;
import lombok.Getter;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

@Singleton
public class DownloadsManifestStore {
private static final ObjectMapper MAPPER = new ObjectMapper();

private final Logger logger;
private final Path manifestPath;

@Getter
private final DownloadManifest manifest;

@Inject
public DownloadsManifestStore(@Named("file") Logger logger, AppPaths paths) {
this.logger = logger;
this.manifestPath = paths.getDownloadsDir().resolve("manifest.json");
this.manifest = load();
}

public synchronized void recordDownload(String modId, String url, String filename) {
ManifestEntry existing = manifest.getEntry(modId);
String installedHash = existing != null ? existing.installedHash() : null;
manifest.putEntry(modId, new ManifestEntry(url, filename, installedHash));
persist();
}

public synchronized void recordInstall(String modId, String installedHash) {
ManifestEntry existing = manifest.getEntry(modId);
if (existing != null) {
manifest.putEntry(modId, new ManifestEntry(existing.url(), existing.filename(), installedHash));
persist();
}
}

private DownloadManifest load() {
if (!Files.exists(manifestPath)) {
return new DownloadManifest();
}
try {
return MAPPER.readValue(manifestPath.toFile(), DownloadManifest.class);
} catch (IOException e) {
logger.warn("Failed to load downloads manifest, starting fresh: %s", e.getMessage());
return new DownloadManifest();
}
}

private void persist() {
try {
Files.createDirectories(manifestPath.getParent());
MAPPER.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifest);
} catch (IOException e) {
logger.error("Failed to persist downloads manifest: %s", e.getMessage());
}
}
}
Loading
Loading