From 3b816ca898a537f1c22acb2ec184e9f7750e2254 Mon Sep 17 00:00:00 2001 From: CoZooo <2606767372@qq.com> Date: Sun, 18 Oct 2026 22:09:36 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E5=90=8C=E6=AD=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: autumnsentiment <93752442+autumnsentiment@users.noreply.github.com> --- build.gradle.kts | 24 + .../client/config/DlcConfigClient.java | 5 +- .../client/download/DlcDownloadService.java | 7 +- .../client/preset/DlcPresetLoader.java | 10 +- .../client/preset/DlcPresetRuntimeState.java | 6 +- .../client/screen/DlcFilePageScreen.java | 6 + .../client/screen/DlcPageScreen.java | 13 +- .../screen/DlcServerFileManagerScreen.java | 13 +- .../client/screen/DlcServerPushScreen.java | 594 ++++++----------- .../client/screen/DlcSyncFailureScreen.java | 29 + .../client/screen/DlcUiProgress.java | 24 + .../client/screen/DlcUiScroll.java | 8 + .../client/server/DlcServerSession.java | 34 +- .../client/sync/DlcSyncAdapter.java | 247 ++++++++ .../client/sync/DlcSyncContent.java | 89 +++ .../client/sync/DlcSyncPresets.java | 42 ++ .../client/sync/DlcSyncSettings.java | 45 ++ .../client/update/DlcUpdateJournal.java | 9 + .../cozooo/dlc_manager/core/DlcApplier.java | 37 ++ .../cozooo/dlc_manager/core/DlcConfig.java | 53 +- .../cozooo/dlc_manager/core/DlcFileMatch.java | 135 +++- .../cozooo/dlc_manager/core/DlcFileSize.java | 30 + .../dlc_manager/core/DlcResourceHistory.java | 1 + .../server/DlcServerDownloadService.java | 6 + .../dlc_manager/server/DlcServerService.java | 244 ++++--- .../cozooo/dlc_manager/sync/SyncArchive.java | 120 ++++ .../cozooo/dlc_manager/sync/SyncClient.java | 209 ++++++ .../cozooo/dlc_manager/sync/SyncEndpoint.java | 298 +++++++++ .../cozooo/dlc_manager/sync/SyncFailure.java | 60 ++ .../cozooo/dlc_manager/sync/SyncModel.java | 204 ++++++ .../client/config/DlcConfigClient.kt | 5 +- .../client/download/DlcDownloadService.kt | 7 +- .../client/preset/DlcPresetLoader.kt | 11 +- .../client/preset/DlcPresetRuntimeState.kt | 7 +- .../client/screen/DlcFilePageScreen.kt | 6 + .../client/screen/DlcPageScreen.kt | 14 +- .../screen/DlcServerFileManagerScreen.kt | 13 +- .../client/screen/DlcServerPushScreen.kt | 599 ++++++------------ .../client/screen/DlcSyncFailureScreen.kt | 22 + .../client/screen/DlcUiProgress.kt | 24 + .../dlc_manager/client/screen/DlcUiScroll.kt | 8 + .../client/server/DlcServerSession.kt | 34 +- .../dlc_manager/client/sync/DlcSyncAdapter.kt | 244 +++++++ .../dlc_manager/client/sync/DlcSyncContent.kt | 96 +++ .../dlc_manager/client/sync/DlcSyncPresets.kt | 39 ++ .../client/sync/DlcSyncSettings.kt | 38 ++ .../client/update/DlcUpdateJournal.kt | 8 + .../com/cozooo/dlc_manager/core/DlcApplier.kt | 37 ++ .../com/cozooo/dlc_manager/core/DlcConfig.kt | 53 +- .../cozooo/dlc_manager/core/DlcFileMatch.kt | 124 +++- .../cozooo/dlc_manager/core/DlcFileSize.kt | 26 + .../dlc_manager/core/DlcResourceHistory.kt | 1 + .../server/DlcServerDownloadService.kt | 6 + .../dlc_manager/server/DlcServerService.kt | 149 +++-- .../cozooo/dlc_manager/sync/SyncArchive.kt | 122 ++++ .../com/cozooo/dlc_manager/sync/SyncClient.kt | 213 +++++++ .../cozooo/dlc_manager/sync/SyncEndpoint.kt | 287 +++++++++ .../cozooo/dlc_manager/sync/SyncFailure.kt | 50 ++ .../com/cozooo/dlc_manager/sync/SyncModel.kt | 200 ++++++ src/main/resources/META-INF/mods.toml | 3 - .../apricity/dlc_manager/ui/page.css | 5 + .../assets/dlc_manager/lang/en_us.json | 141 ++++- .../assets/dlc_manager/lang/zh_cn.json | 141 ++++- .../client/screen/DlcUiProgressTest.java | 32 + .../client/screen/DlcUiScrollTest.java | 12 + .../client/sync/DlcSyncAdapterTest.java | 454 +++++++++++++ .../client/sync/DlcSyncSettingsTest.java | 110 ++++ .../client/sync/DlcSyncTransportTest.java | 111 ++++ .../dlc_manager/core/DlcFileMatchTest.java | 113 ++++ .../dlc_manager/core/DlcFileSizeTest.java | 41 ++ .../server/DlcMixinRegistrationTest.java | 105 +++ .../dlc_manager/sync/DlcSyncArchiveTest.java | 151 +++++ .../cozooo/dlc_manager/sync/DlcSyncTest.java | 489 ++++++++++++++ .../dlc_manager/sync/SyncFailureTest.java | 55 ++ tools/dlc-bootstrap/build.gradle.kts | 1 + tools/dlc-javafx-manager/build.gradle.kts | 3 + .../dlc_manager/app/DlcFxManagerApp.java | 16 +- .../server/DlcFxServerFileManagerTab.java | 11 +- .../server/DlcFxServerSyncTab.java | 295 +++++++-- .../assets/dlc_manager/lang/en_us.json | 138 +++- .../assets/dlc_manager/lang/zh_cn.json | 138 +++- 81 files changed, 6446 insertions(+), 1164 deletions(-) create mode 100644 src/main/java/com/cozooo/dlc_manager/client/screen/DlcSyncFailureScreen.java create mode 100644 src/main/java/com/cozooo/dlc_manager/client/screen/DlcUiProgress.java create mode 100644 src/main/java/com/cozooo/dlc_manager/client/sync/DlcSyncAdapter.java create mode 100644 src/main/java/com/cozooo/dlc_manager/client/sync/DlcSyncContent.java create mode 100644 src/main/java/com/cozooo/dlc_manager/client/sync/DlcSyncPresets.java create mode 100644 src/main/java/com/cozooo/dlc_manager/client/sync/DlcSyncSettings.java create mode 100644 src/main/java/com/cozooo/dlc_manager/core/DlcFileSize.java create mode 100644 src/main/java/com/cozooo/dlc_manager/sync/SyncArchive.java create mode 100644 src/main/java/com/cozooo/dlc_manager/sync/SyncClient.java create mode 100644 src/main/java/com/cozooo/dlc_manager/sync/SyncEndpoint.java create mode 100644 src/main/java/com/cozooo/dlc_manager/sync/SyncFailure.java create mode 100644 src/main/java/com/cozooo/dlc_manager/sync/SyncModel.java create mode 100644 src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcSyncFailureScreen.kt create mode 100644 src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcUiProgress.kt create mode 100644 src/main/kotlin/com/cozooo/dlc_manager/client/sync/DlcSyncAdapter.kt create mode 100644 src/main/kotlin/com/cozooo/dlc_manager/client/sync/DlcSyncContent.kt create mode 100644 src/main/kotlin/com/cozooo/dlc_manager/client/sync/DlcSyncPresets.kt create mode 100644 src/main/kotlin/com/cozooo/dlc_manager/client/sync/DlcSyncSettings.kt create mode 100644 src/main/kotlin/com/cozooo/dlc_manager/core/DlcFileSize.kt create mode 100644 src/main/kotlin/com/cozooo/dlc_manager/sync/SyncArchive.kt create mode 100644 src/main/kotlin/com/cozooo/dlc_manager/sync/SyncClient.kt create mode 100644 src/main/kotlin/com/cozooo/dlc_manager/sync/SyncEndpoint.kt create mode 100644 src/main/kotlin/com/cozooo/dlc_manager/sync/SyncFailure.kt create mode 100644 src/main/kotlin/com/cozooo/dlc_manager/sync/SyncModel.kt create mode 100644 src/test/java/com/cozooo/dlc_manager/client/screen/DlcUiProgressTest.java create mode 100644 src/test/java/com/cozooo/dlc_manager/client/sync/DlcSyncAdapterTest.java create mode 100644 src/test/java/com/cozooo/dlc_manager/client/sync/DlcSyncSettingsTest.java create mode 100644 src/test/java/com/cozooo/dlc_manager/client/sync/DlcSyncTransportTest.java create mode 100644 src/test/java/com/cozooo/dlc_manager/core/DlcFileMatchTest.java create mode 100644 src/test/java/com/cozooo/dlc_manager/core/DlcFileSizeTest.java create mode 100644 src/test/java/com/cozooo/dlc_manager/server/DlcMixinRegistrationTest.java create mode 100644 src/test/java/com/cozooo/dlc_manager/sync/DlcSyncArchiveTest.java create mode 100644 src/test/java/com/cozooo/dlc_manager/sync/DlcSyncTest.java create mode 100644 src/test/java/com/cozooo/dlc_manager/sync/SyncFailureTest.java diff --git a/build.gradle.kts b/build.gradle.kts index ce7d63df..bf2d9403 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -18,6 +18,8 @@ import org.gradle.api.tasks.JavaExec import org.gradle.api.tasks.SourceSetContainer import org.gradle.api.tasks.bundling.AbstractArchiveTask import org.gradle.api.tasks.bundling.Jar +import org.gradle.api.tasks.testing.Test +import org.gradle.process.CommandLineArgumentProvider import org.gradle.api.tasks.compile.JavaCompile import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension import org.jetbrains.kotlin.gradle.tasks.KotlinCompile @@ -482,6 +484,22 @@ if (stoneNodes.contains(project.name)) { from(rootProject.layout.projectDirectory.file("LICENSE")) { into("META-INF/dlc-manager") } } } + val mixinTestJar = tasks.named("jar").flatMap { it.archiveFile } + tasks.withType().configureEach { + dependsOn(mixinTestJar) + inputs.file(mixinTestJar) + systemProperty("dlc_manager.test.loader", loaderName) + jvmArgumentProviders.add(CommandLineArgumentProvider { + listOf("-Ddlc_manager.test.jar=${mixinTestJar.get().asFile.absolutePath}") + }) + if (loaderName == "forge") { + dependsOn("generateDLIConfig") + val launchConfig = rootProject.layout.projectDirectory.file(".gradle/loom-cache/projects/$nodeName/launch.cfg") + inputs.file(launchConfig) + systemProperty("dlc_manager.test.launchConfig", launchConfig.asFile.absolutePath) + } + } + tasks.withType().configureEach { notCompatibleWithConfigurationCache("StoneCraft processResources actions capture Project.") if (name == "processResources") { @@ -612,6 +630,9 @@ if (stoneNodes.contains(project.name)) { } extensions.configure { + if (loaderName == "forge") { + forge.mixinConfig("dlc_manager.mixins.json") + } runs.named("server") { runDirectory.set(rootProject.layout.projectDirectory.dir("run/$loaderName/$minecraftVersion/server")) } @@ -829,6 +850,9 @@ if (stoneNodes.contains(project.name)) { }?.forEach(File::delete) } } + if (remappedJar.name == "jar") { + tasks.withType().configureEach { dependsOn(packageDistribution) } + } remappedJar.configure { finalizedBy(packageDistribution) } } tasks.named("publishMods") { diff --git a/src/main/java/com/cozooo/dlc_manager/client/config/DlcConfigClient.java b/src/main/java/com/cozooo/dlc_manager/client/config/DlcConfigClient.java index ccb0b936..85cf5951 100644 --- a/src/main/java/com/cozooo/dlc_manager/client/config/DlcConfigClient.java +++ b/src/main/java/com/cozooo/dlc_manager/client/config/DlcConfigClient.java @@ -1,5 +1,6 @@ package com.cozooo.dlc_manager.client.config; +import com.cozooo.dlc_manager.core.DlcFileSize; import com.cozooo.dlc_manager.core.DlcConfig; import java.util.List; @@ -238,7 +239,7 @@ public static void applyToConfig() { DlcConfig.setMaxConcurrentDownloads(MAX_CONCURRENT_DOWNLOADS.getIntegerValue()); DlcConfig.setMaxSegmentsPerFile(MAX_SEGMENTS_PER_FILE.getIntegerValue()); - DlcConfig.setMinSegmentBytes((long) Math.max(1, MIN_SEGMENT_MB.getIntegerValue()) * 1024L * 1024L); + DlcConfig.setMinSegmentBytes((long) Math.max(1, MIN_SEGMENT_MB.getIntegerValue()) * DlcFileSize.MIB); DlcConfig.setMaxRetryCount(MAX_RETRY.getIntegerValue()); DlcConfig.setConnectTimeoutMs(CONNECT_TIMEOUT_SECONDS.getIntegerValue() * 1000); DlcConfig.setTaskTimeoutMs(TASK_TIMEOUT_SECONDS.getIntegerValue() * 1000); @@ -248,7 +249,7 @@ public static void applyToConfig() { } private static int toMb(long bytes) { - long mb = (bytes + 1024L * 1024L - 1) / (1024L * 1024L); + long mb = DlcFileSize.ceilMiB(bytes); if (mb < 1) { return 1; } diff --git a/src/main/java/com/cozooo/dlc_manager/client/download/DlcDownloadService.java b/src/main/java/com/cozooo/dlc_manager/client/download/DlcDownloadService.java index 043d6912..70634cab 100644 --- a/src/main/java/com/cozooo/dlc_manager/client/download/DlcDownloadService.java +++ b/src/main/java/com/cozooo/dlc_manager/client/download/DlcDownloadService.java @@ -1,5 +1,6 @@ package com.cozooo.dlc_manager.client.download; +import com.cozooo.dlc_manager.core.DlcFileSize; import com.cozooo.dlc_manager.core.DlcConfig; import java.io.BufferedInputStream; import java.io.IOException; @@ -30,7 +31,7 @@ public final class DlcDownloadService { private static final long MIRROR_COMPARE_WINDOW_MS = 3000L; private static final int MIRROR_DISABLE_FAILURES = 2; private static final long SPEED_WINDOW_MS = 1000L; - private static final long BYTES_PER_MIB = 1024L * 1024L; + private static final long BYTES_PER_MIB = DlcFileSize.MIB; private static final long MIN_MIRROR_SPEED_CHECK_BYTES = BYTES_PER_MIB; private static final double SEGMENT_SPEED_DROP_RETRY_RATIO = 0.25; private static final String CURSEFORGE_PROBE_PROJECT_ID = "678295"; @@ -92,6 +93,8 @@ public static Path download(String url, Path outputDir, String desiredFileName, } Path target = outputDir.resolve(fileName); Path temp = outputDir.resolve(fileName + ".part"); + com.cozooo.dlc_manager.client.update.DlcUpdateJournal.beforeSyncWrite(target); + com.cozooo.dlc_manager.client.update.DlcUpdateJournal.beforeSyncWrite(temp); if (listener != null) { listener.onProgress(0L, probe.totalBytes); @@ -142,6 +145,8 @@ public static Path downloadKnown(String url, Path outputDir, String desiredFileN } Path target = outputDir.resolve(fileName); Path temp = outputDir.resolve(fileName + ".part"); + com.cozooo.dlc_manager.client.update.DlcUpdateJournal.beforeSyncWrite(target); + com.cozooo.dlc_manager.client.update.DlcUpdateJournal.beforeSyncWrite(temp); long total = expectedSize > 0 ? expectedSize : -1L; DownloadMirrorMode mode = DownloadMirrorMode.fromConfig(DlcConfig.getDownloadMirrorMode()); MirrorPlan mirrorPlan = mirrorPlan(url, fileName, total); diff --git a/src/main/java/com/cozooo/dlc_manager/client/preset/DlcPresetLoader.java b/src/main/java/com/cozooo/dlc_manager/client/preset/DlcPresetLoader.java index bd29b2e5..6e1ab530 100644 --- a/src/main/java/com/cozooo/dlc_manager/client/preset/DlcPresetLoader.java +++ b/src/main/java/com/cozooo/dlc_manager/client/preset/DlcPresetLoader.java @@ -26,7 +26,9 @@ public static List loadAll() { return loadAll(DlcConfig.getDlcRoots()); } - public static List loadAll(List roots) { + public static List loadAll(List roots) { return loadAll(roots, true); } + + public static List loadAll(List roots, boolean migrate) { List entries = new ArrayList<>(); if (roots == null || roots.isEmpty()) { return entries; @@ -44,7 +46,7 @@ public static List loadAll(List roots) { try (var stream = Files.walk(presetRoot)) { stream.filter(path -> path.getFileName() != null && DlcDcFormatUtil.isDcName(path.getFileName().toString())) .forEach(path -> { - DlcPresetEntry entry = readEntry(path); + DlcPresetEntry entry = readEntry(path, migrate); if (entry != null) { entries.add(entry); } @@ -70,7 +72,7 @@ public static Path ensurePresetRoot(Path dlcRoot) { return presetRoot; } - private static DlcPresetEntry readEntry(Path path) { + private static DlcPresetEntry readEntry(Path path, boolean migrate) { JsonObject obj = DlcDcConfigIO.read(path, DlcDcConfigIO.Layout.GENERIC); if (obj == null) { return null; @@ -87,7 +89,7 @@ private static DlcPresetEntry readEntry(Path path) { String fileName = stripExtension(path.getFileName().toString()); String id = readIdentifier(basic, obj, fileName); - migrateIfNeeded(path, obj, id, alarm, enable, conflicts); + if (migrate) migrateIfNeeded(path, obj, id, alarm, enable, conflicts); return new DlcPresetEntry(id, fileName, path, displayName, tip, alarmTip, enable, conflicts, alarm, DlcPresetStatus.OFF); } diff --git a/src/main/java/com/cozooo/dlc_manager/client/preset/DlcPresetRuntimeState.java b/src/main/java/com/cozooo/dlc_manager/client/preset/DlcPresetRuntimeState.java index 4fdd3757..822c16af 100644 --- a/src/main/java/com/cozooo/dlc_manager/client/preset/DlcPresetRuntimeState.java +++ b/src/main/java/com/cozooo/dlc_manager/client/preset/DlcPresetRuntimeState.java @@ -26,9 +26,11 @@ public final class DlcPresetRuntimeState { private DlcPresetRuntimeState() { } - public static void reload() { + public static void reload() { reload(true); } + + public static void reload(boolean migrate) { Set previousEnabled = presetDirty ? new LinkedHashSet<>(ENABLED_PRESETS) : null; - List entries = DlcPresetLoader.loadAll(); + List entries = DlcPresetLoader.loadAll(com.cozooo.dlc_manager.core.DlcConfig.getDlcRoots(), migrate); PRESETS_BY_ID.clear(); for (DlcPresetEntry entry : entries) { if (entry != null && entry.getId() != null && !entry.getId().isBlank()) { diff --git a/src/main/java/com/cozooo/dlc_manager/client/screen/DlcFilePageScreen.java b/src/main/java/com/cozooo/dlc_manager/client/screen/DlcFilePageScreen.java index ae6292c1..cdb02bb5 100644 --- a/src/main/java/com/cozooo/dlc_manager/client/screen/DlcFilePageScreen.java +++ b/src/main/java/com/cozooo/dlc_manager/client/screen/DlcFilePageScreen.java @@ -23,6 +23,11 @@ protected final Element fileList(String title) { protected final void fileRow(Element list, String name, String description, Supplier checked, BooleanSupplier enabled, Runnable select, Runnable open) { + fileRowLine(list, name, description, checked, enabled, select, open); + } + + protected final Element fileRowLine(Element list, String name, String description, Supplier checked, + BooleanSupplier enabled, Runnable select, Runnable open) { Element line = node(list, "div", "page-row file-row"); var button = action(line, "", () -> { if (enabled.getAsBoolean()) { select.run(); syncSelections(); } @@ -59,6 +64,7 @@ protected final void fileRow(Element list, String name, String description, Supp }; selections.add(sync); sync.run(); + return line; } private void syncSelections() { selections.forEach(Runnable::run); } diff --git a/src/main/java/com/cozooo/dlc_manager/client/screen/DlcPageScreen.java b/src/main/java/com/cozooo/dlc_manager/client/screen/DlcPageScreen.java index 0bd578bb..46059e3d 100644 --- a/src/main/java/com/cozooo/dlc_manager/client/screen/DlcPageScreen.java +++ b/src/main/java/com/cozooo/dlc_manager/client/screen/DlcPageScreen.java @@ -242,18 +242,7 @@ protected Element progress(Element parent, java.util.function.DoubleSupplier val Element track = node(parent, "div", "progress-track"); track.setAttribute("role", "progressbar"); track.setAttribute("aria-valuemin", "0"); track.setAttribute("aria-valuemax", "100"); Element fill = node(track, "div", "progress-fill"); - int[] previous = {-1}; - Runnable binding = () -> { - double fraction = value.getAsDouble(); - if (fraction < 0) { - double phase = (System.nanoTime() % 1200000000L) / 1200000000.0; - fill.setAttribute("style", "width:25%;transform:translateX(" + (track.getOffsetWidth() * 0.75 * phase) + "px)"); - track.removeAttribute("aria-valuenow"); track.setAttribute("aria-busy", "true"); previous[0] = -1; return; - } - track.removeAttribute("aria-busy"); - int percent = (int) Math.round(Math.max(0, Math.min(1, fraction)) * 100); - if (percent != previous[0]) { previous[0] = percent; fill.setAttribute("style", "width:" + percent + "%"); track.setAttribute("aria-valuenow", String.valueOf(percent)); } - }; + Runnable binding = DlcUiProgress.binding(track, fill, value); bindings.add(binding); binding.run(); return track; } diff --git a/src/main/java/com/cozooo/dlc_manager/client/screen/DlcServerFileManagerScreen.java b/src/main/java/com/cozooo/dlc_manager/client/screen/DlcServerFileManagerScreen.java index f05d6c06..50cb1c8c 100644 --- a/src/main/java/com/cozooo/dlc_manager/client/screen/DlcServerFileManagerScreen.java +++ b/src/main/java/com/cozooo/dlc_manager/client/screen/DlcServerFileManagerScreen.java @@ -320,18 +320,7 @@ private DlcServerFileEntry getSelectedEntry() { return null; } - private String formatSize(long size) { - if (size <= 0) { - return ""; - } - if (size > 1024 * 1024) { - return String.format("%.1f MB", size / 1024.0 / 1024.0); - } - if (size > 1024) { - return String.format("%.1f KB", size / 1024.0); - } - return size + " B"; - } + private String formatSize(long size) { return com.cozooo.dlc_manager.core.DlcFileSize.format(size); } private boolean controlsReady; private int pageRevision; diff --git a/src/main/java/com/cozooo/dlc_manager/client/screen/DlcServerPushScreen.java b/src/main/java/com/cozooo/dlc_manager/client/screen/DlcServerPushScreen.java index 828449a4..61ec7a55 100644 --- a/src/main/java/com/cozooo/dlc_manager/client/screen/DlcServerPushScreen.java +++ b/src/main/java/com/cozooo/dlc_manager/client/screen/DlcServerPushScreen.java @@ -1,401 +1,227 @@ package com.cozooo.dlc_manager.client.screen; -import com.cozooo.dlc_manager.client.screen.DlcScreenCompat; - +import com.cozooo.dlc_manager.client.server.*; +import com.cozooo.dlc_manager.client.sync.DlcSyncAdapter; +import com.cozooo.dlc_manager.sync.*; +import com.cozooo.dlc_manager.sync.SyncModel.*; import com.cozooo.dlc_manager.platform.DlcTextCompat; - -import com.cozooo.dlc_manager.platform.DlcPlatformServices; -import com.cozooo.dlc_manager.client.server.DlcServerEntry; -import com.cozooo.dlc_manager.client.server.DlcServerConnection; -import com.cozooo.dlc_manager.core.DlcPaths; import net.minecraft.client.Minecraft; -import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.screens.Screen; -import net.minecraft.network.chat.Component; -import java.nio.file.DirectoryStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; +import java.util.*; import java.util.concurrent.CompletableFuture; public class DlcServerPushScreen extends DlcFilePageScreen { - - - - - private final Screen parent; private final DlcServerEntry entry; - private final Path rootDir; - private Path currentDir; - - private Button modeButton; - private Button pushButton; - private Button backButton; - private Button upButton; - private Button clearButton; - - private boolean overwriteMode = true; - private boolean pushing; - private String statusText; - private String errorText; - private final Set selectedFiles = new LinkedHashSet<>(); - private final Map> dirFilesCache = new HashMap<>(); - private List entries = List.of(); - - private int scrollIndex; - private boolean draggingScrollBar; + private List local = List.of(), remote = List.of(); + private List rows = List.of(), filteredRows = List.of(); + private final Set selected = new HashSet<>(); + private Mode mode = Mode.CONFIG; + private Category category = Category.ALL; + private Direction direction = Direction.PUSH; + private boolean states = true, loaded, settingsSelected; + private volatile boolean busy; + private volatile String message = ""; + private volatile double fraction = -1; + private volatile SyncClient active; + private volatile AutoCloseable connecting; + private volatile boolean cancelRequested; + private volatile DlcServerSession previewSession; + private volatile SyncClient rollbackClient; + private volatile String failureReason; + private boolean syncing; + private DlcUiButton executeButton; + private com.sighs.apricityui.init.Element results; + private Category renderedCategory = Category.ALL; + private final Map scrolls = new EnumMap<>(Category.class); public DlcServerPushScreen(Screen parent, DlcServerEntry entry) { - super(DlcUiPage.SERVER_PUSH, parent, DlcTextCompat.translatable("dlc_manager.server.push_title")); - this.parent = parent; + super(DlcUiPage.SERVER_PUSH, parent, DlcTextCompat.translatable("dlc_manager.sync.title")); this.entry = entry; - this.rootDir = DlcPaths.gameDir().toAbsolutePath().normalize(); - this.currentDir = rootDir; } - - private void initControls() { - int buttonHeight = 20; - int bottomY = this.height - 28; - int modeWidth = 96; - int pushWidth = 96; - int backWidth = 96; - int gap = 6; - - Component modeLabel = DlcTextCompat.translatable("dlc_manager.server.push_mode"); - int modeLabelWidth = this.font.width(modeLabel); - int modeX = 20 + modeLabelWidth + 6; - modeButton = DlcScreenCompat.buttonBuilder(DlcTextCompat.empty(), btn -> toggleMode()) - .bounds(modeX, bottomY, modeWidth, buttonHeight) - .build(); - - int rightMargin = 10; - int backX = this.width - rightMargin - backWidth; - int pushX = backX - gap - pushWidth; - pushButton = DlcScreenCompat.buttonBuilder(DlcTextCompat.translatable("dlc_manager.server.push_start"), btn -> confirmPush()) - .bounds(pushX, bottomY, pushWidth, buttonHeight) - .build(); - backButton = DlcScreenCompat.buttonBuilder(DlcTextCompat.translatable("gui.back"), btn -> Minecraft.getInstance().setScreen(parent)) - .bounds(backX, bottomY, backWidth, buttonHeight) - .build(); - - int topButtonY = 56; - upButton = DlcScreenCompat.buttonBuilder(DlcTextCompat.translatable("dlc_manager.server.push_up"), btn -> goUp()) - .bounds(20, topButtonY, 70, buttonHeight) - .build(); - clearButton = DlcScreenCompat.buttonBuilder(DlcTextCompat.translatable("dlc_manager.server.push_clear_selection", 0), btn -> clearSelection()) - .bounds(this.width - 20 - 140, topButtonY, 140, buttonHeight) - .build(); - - addRenderableWidget(modeButton); - addRenderableWidget(pushButton); - addRenderableWidget(backButton); - addRenderableWidget(upButton); - addRenderableWidget(clearButton); - refreshEntries(); - updateLabels(); - } - - private void toggleMode() { - if (pushing) { - return; - } - overwriteMode = !overwriteMode; - updateLabels(); - } - - private void updateLabels() { - if (modeButton != null) { - Component modeLabel = overwriteMode - ? DlcTextCompat.translatable("dlc_manager.server.push_mode_overwrite") - : DlcTextCompat.translatable("dlc_manager.server.push_mode_diff"); - modeButton.setMessage(modeLabel); - modeButton.active = !pushing; - } - if (pushButton != null) { - pushButton.active = !pushing; - } - if (backButton != null) { - backButton.active = !pushing; - } - if (clearButton != null) { - clearButton.setMessage(DlcTextCompat.translatable("dlc_manager.server.push_clear_selection", selectedFiles.size())); - clearButton.active = !pushing && !selectedFiles.isEmpty(); - } - if (upButton != null) { - upButton.active = !pushing && !currentDir.equals(rootDir); - } - } - - private void confirmPush() { - if (selectedFiles.isEmpty()) { - DlcScreenCompat.addOrUpdatePeriodicNotification(DlcScreenCompat.toastManager(Minecraft.getInstance()), - DlcTextCompat.translatable("dlc_manager.server.push_no_selection"), DlcTextCompat.empty()); - return; - } - Component title = DlcTextCompat.translatable("dlc_manager.server.push_title"); - Component message = DlcTextCompat.translatable("dlc_manager.server.push_confirm", buildTargetLabel(), getModeLabel()); - Minecraft.getInstance().setScreen(new DlcConfirmScreen(accepted -> { - if (accepted) { - confirmPushAgain(); - } else { - Minecraft.getInstance().setScreen(this); - } - }, title, message, DlcConfirmUtil.confirmYes(), DlcConfirmUtil.confirmNo())); - } - - private void confirmPushAgain() { - Component title = DlcTextCompat.translatable("dlc_manager.server.push_title"); - Component message = DlcTextCompat.translatable("dlc_manager.server.push_confirm_again"); - Minecraft.getInstance().setScreen(new DlcConfirmScreen(accepted -> { - if (accepted) { - startPush(); - } else { - Minecraft.getInstance().setScreen(this); - } - }, title, message, DlcConfirmUtil.confirmYes(), DlcConfirmUtil.confirmNo())); - } - - private void startPush() { - if (pushing) { - return; - } - pushing = true; - statusText = DlcTextCompat.translatable("dlc_manager.server.pushing").getString(); - updateLabels(); - CompletableFuture - .supplyAsync(this::doPush) - .whenComplete((ok, throwable) -> Minecraft.getInstance().execute(() -> { - pushing = false; - if (ok != null && ok) { - DlcScreenCompat.addOrUpdatePeriodicNotification(DlcScreenCompat.toastManager(Minecraft.getInstance()), - DlcTextCompat.translatable("dlc_manager.server.push_success"), DlcTextCompat.empty()); - } else { - DlcScreenCompat.addOrUpdatePeriodicNotification(DlcScreenCompat.toastManager(Minecraft.getInstance()), - DlcTextCompat.translatable("dlc_manager.server.push_failed"), DlcTextCompat.empty()); - } - updateLabels(); - })); - } - - private boolean doPush() { - try { - List files = new ArrayList<>(selectedFiles); - if (files.isEmpty()) { - return false; - } - return DlcServerConnection.push(entry, 20000, List.of(), files, overwriteMode ? "overwrite" : "diff", - DlcPlatformServices.playerName(), DlcPlatformServices.playerUuid()); - } catch (Exception ignored) { - return false; - } - } - - private String buildTargetLabel() { - return DlcTextCompat.translatable("dlc_manager.server.push_selected_count", selectedFiles.size()).getString(); - } - - private String getModeLabel() { - return overwriteMode - ? DlcTextCompat.translatable("dlc_manager.server.push_mode_overwrite").getString() - : DlcTextCompat.translatable("dlc_manager.server.push_mode_diff").getString(); - } - - private void refreshEntries() { - entries = listEntries(currentDir); - scrollIndex = 0; - updateLabels(); - } - - private List listEntries(Path dir) { - List result = new ArrayList<>(); - if (dir == null || !Files.isDirectory(dir)) { - return result; - } - try (DirectoryStream stream = Files.newDirectoryStream(dir)) { - for (Path path : stream) { - if (path == null) { - continue; - } - boolean isDir = Files.isDirectory(path); - String name = path.getFileName() == null ? path.toString() : path.getFileName().toString(); - result.add(new FileEntry(path, name, isDir)); - } - } catch (Exception ignored) { - } - result.sort(Comparator.comparing((FileEntry entry) -> !entry.isDir).thenComparing(entry -> entry.name.toLowerCase())); - return result; - } - - private void openDirectory(Path dir) { - if (dir == null) { - return; - } - Path normalized = dir.toAbsolutePath().normalize(); - if (!normalized.startsWith(rootDir) || !Files.isDirectory(normalized)) { - return; - } - currentDir = normalized; - refreshEntries(); - } - - private void goUp() { - if (currentDir == null || currentDir.equals(rootDir)) { - return; - } - Path parentDir = currentDir.getParent(); - if (parentDir == null || !parentDir.startsWith(rootDir)) { - return; - } - currentDir = parentDir; - refreshEntries(); - } - - private SelectionState getEntryState(FileEntry entry) { - if (entry == null) { - return SelectionState.NONE; - } - if (!entry.isDir) { - String rel = toRelative(entry.path); - return rel != null && selectedFiles.contains(rel) ? SelectionState.ALL : SelectionState.NONE; - } - List files = getDirFiles(entry.path); - if (files.isEmpty()) { - return SelectionState.NONE; - } - long count = files.stream().filter(selectedFiles::contains).count(); - return count == 0 ? SelectionState.NONE : count == files.size() ? SelectionState.ALL : SelectionState.PARTIAL; - } - - private void toggleEntry(FileEntry entry) { - if (entry == null) { - return; - } - if (!entry.isDir) { - String rel = toRelative(entry.path); - if (rel == null || rel.isBlank()) { - return; + @Override protected void buildPage() { + footerLeading.setAttribute("style", "display:flex"); + action(footerLeading, tr("dlc_manager.sync.mode_" + mode.name().toLowerCase(Locale.ROOT)), () -> { mode = mode == Mode.FILES ? Mode.CONFIG : Mode.FILES; replan(); }).setEnabled(!busy); + action(toolbar, tr("dlc_manager.sync." + direction.name().toLowerCase(Locale.ROOT)), () -> { direction = direction == Direction.PUSH ? Direction.PULL : Direction.PUSH; replan(); }).setEnabled(!busy); + if (SyncModel.hasStateDifferences(local, remote)) toggle(footerLeading, tr("dlc_manager.sync.states"), states, value -> { states = value; replan(); }).setEnabled(!busy); + if (settingsRow() != null) toggle(footerLeading, tr("dlc_manager.sync.manager_config"), settingsSelected, value -> { settingsSelected = value; refreshPage(); }).setEnabled(!busy); + action(toolbar, tr("dlc_manager.sync.compare"), this::compare).setEnabled(!busy); + content.parentElement.setAttribute("class", "page-shell sync-page"); + var layout = node(content, "div", "sync-layout"); + var types = node(layout, "aside", "sync-types"); + for (Category type : Category.values()) { + if (type == Category.MANAGER_CONFIG) continue; + var button = action(types, tr("dlc_manager.sync.type_" + type.name().toLowerCase(Locale.ROOT)), () -> { category = type; refilter(); refreshPage(); }); + button.getElement().setAttribute("class", "button filter" + (category == type ? " active" : "")); + button.setEnabled(!busy); + } + if (results != null) scrolls.put(renderedCategory, DlcUiScroll.capture(results)); + results = node(layout, "div", "sync-results"); + renderedCategory = category; + DlcUiScroll.State savedScroll = scrolls.get(category); + if (savedScroll != null) DlcUiScroll.restore(results, savedScroll); + List shown = visibleRows(); + var list = fileList(loaded && shown.isEmpty() ? tr("dlc_manager.sync.empty") : tr("dlc_manager.sync.differences")); + results.appendChild(list); + showWhen(list, () -> !busy || syncing); + if (!shown.isEmpty()) selectionRow(list, tr("dlc_manager.sync.select_all"), this::selectionState, this::selectVisible); + for (Row row : shown) { + String language = com.cozooo.dlc_manager.platform.DlcPlatformServices.languageCode(); + String name = SyncModel.displayName(row.source(), language); + var line = selectionRow(list, name, () -> Boolean.toString(selected.contains(row.key())), () -> { + if (!selected.add(row.key())) selected.remove(row.key()); + }); + tip(line, () -> new DlcUiTooltip.Content(name, SyncModel.tooltip(row.source(), language), List.of(), tr("dlc_manager.sync." + SyncModel.status(row, mode)))); + text(line, tr("dlc_manager.sync." + SyncModel.status(row, mode)), "row-description"); + } + live(results, () -> message, "page-description"); + showWhen(progress(results, () -> fraction), () -> busy); + if (rollbackClient != null && rollbackClient.canRollback()) { + var undo = action(footer, tr("dlc_manager.sync.rollback"), this::rollback); + undo.getElement().setAttribute("class", "button danger"); undo.setEnabled(!busy); + } + executeButton = primary(tr(syncing ? "dlc_manager.sync.cancel" : "dlc_manager.sync.execute"), () -> { + if (syncing) cancel(); else confirm(); + }); + executeButton.getElement().setAttribute("class", syncing ? "button danger" : "button primary"); + updateExecuteButton(); + } + private com.sighs.apricityui.init.Element selectionRow(com.sighs.apricityui.init.Element list, String name, java.util.function.Supplier checked, Runnable change) { + var line = fileRowLine(list, name, null, checked, () -> !busy, change, null); + var checkbox = line.getChildren().get(0); + var document = pageDocument; + long generation = document.getRefreshGeneration(); + line.addEventListener("mousedown", event -> { + if (event instanceof com.sighs.apricityui.event.MouseEvent mouse && mouse.button == 0 && !busy && Minecraft.getInstance().screen == this + && document == getLinkedDocument() && document.isCurrentGeneration(generation) + && !(event.target instanceof com.sighs.apricityui.init.Element target && checkbox.contains(target))) { + event.preventDefault(); change.run(); updatePage(); } - if (!selectedFiles.add(rel)) { - selectedFiles.remove(rel); + }); + return line; + } + private Row settingsRow() { return rows.stream().filter(row -> SyncModel.category(row.source()) == Category.MANAGER_CONFIG).findFirst().orElse(null); } + private void refilter() { filteredRows = rows.stream().filter(row -> SyncModel.category(row.source()) != Category.MANAGER_CONFIG && (category == Category.ALL || SyncModel.category(row.source()) == category)).toList(); } + private List visibleRows() { return filteredRows; } + private List selectedRows() { + List chosen = new ArrayList<>(visibleRows().stream().filter(row -> selected.contains(row.key())).toList()); + if (settingsSelected && settingsRow() != null) chosen.add(settingsRow()); + return chosen; + } + private String selectionState() { + List shown = visibleRows(); long count = shown.stream().filter(row -> selected.contains(row.key())).count(); + return count == 0 ? "false" : count == shown.size() ? "true" : "mixed"; + } + private void selectVisible() { + List shown = visibleRows(); boolean all = shown.stream().allMatch(row -> selected.contains(row.key())); + for (Row row : shown) { if (all) selected.remove(row.key()); else selected.add(row.key()); } + } + private void updateExecuteButton() { + if (executeButton != null) executeButton.setEnabled(busy ? syncing && !cancelRequested : settingsSelected && settingsRow() != null || visibleRows().stream().anyMatch(row -> selected.contains(row.key()))); + } + private SyncModel.Peer peer(DlcServerSession session) { + return new SyncModel.Peer() { + public com.google.gson.JsonObject call(com.google.gson.JsonObject request) throws java.io.IOException { + try { return session.syncRequest(request); } catch (java.io.IOException error) { session.close(); throw error; } } - updateLabels(); - return; - } - List files = getDirFiles(entry.path); - if (files.isEmpty()) { - return; - } - boolean allSelected = true; - for (String file : files) { - if (!selectedFiles.contains(file)) { - allSelected = false; - break; + public void close() throws java.io.IOException { session.close(); } + }; + } + private SyncModel.Peer control() { + return request -> { try (DlcServerSession session = DlcServerSession.connect(entry, 10000)) { return peer(session).call(request); } }; + } + private void compare() { + if (busy) return; + loaded = false; local = List.of(); remote = List.of(); rows = List.of(); filteredRows = List.of(); settingsSelected = false; selected.clear(); + run(() -> { + List left, right; + try (SyncEndpoint endpoint = new SyncEndpoint(new DlcSyncAdapter()); DlcServerSession session = connect(entry)) { + previewSession = session; + left = SyncClient.manifest(endpoint, () -> cancelRequested); + right = SyncClient.manifest(peer(session), () -> cancelRequested); + } finally { previewSession = null; } + Minecraft.getInstance().execute(() -> { local = left; remote = right; loaded = true; message = ""; replan(); }); + }); + } + private void replan() { + rows = SyncModel.plan(local, remote, direction, states, mode); + selected.clear(); settingsSelected = false; refilter(); + refreshPage(); + } + private void confirm() { + if (busy) return; + List chosen = selectedRows(); + if (chosen.isEmpty()) return; + Minecraft.getInstance().setScreen(new DlcConfirmScreen(accepted -> { + Minecraft.getInstance().setScreen(this); + if (accepted) execute(chosen); + }, DlcTextCompat.translatable("dlc_manager.sync.title"), DlcTextCompat.translatable("dlc_manager.sync.confirm", chosen.size()), DlcConfirmUtil.confirmYes(), DlcConfirmUtil.confirmNo())); + } + private void execute(List chosen) { + run(() -> { + rollbackClient = null; + try (SyncEndpoint endpoint = new SyncEndpoint(new DlcSyncAdapter()); DlcServerSession session = connect(entry)) { + SyncClient client = new SyncClient(endpoint, peer(session), control()); active = client; rollbackClient = client; + if (cancelRequested) client.cancel(); + Result result = client.execute(chosen, mode, states, (phase, resource, done, total) -> { + fraction = total > 0 && !phase.equals("restoring") ? done / (double) total : -1; + String amount = phase.equals("transferring") || phase.equals("downloading") ? com.cozooo.dlc_manager.core.DlcFileSize.formatProgress(done, total) : done + (total >= 0 ? "/" + total : ""); + message = DlcTextCompat.translatable("dlc_manager.sync." + phase).getString() + " " + resource.substring(resource.lastIndexOf('/') + 1) + " " + amount; + }); + message = DlcTextCompat.translatable(result.canceled() ? "dlc_manager.sync.canceled" : "dlc_manager.sync.finished", result.completed(), result.errors().size()).getString() + + (result.requiresRestart() ? " " + DlcTextCompat.translatable("dlc_manager.sync.restart").getString() : "") + "\n" + String.join("\n", result.succeeded().stream().map(key -> tr("dlc_manager.sync.succeeded_item", key)).toList()) + "\n" + String.join("\n", result.errors()); + if (chosen.stream().anyMatch(row -> SyncModel.category(row.source()) == Category.UPDATE && result.succeeded().contains(row.key()))) message += "\n" + tr("dlc_manager.sync.updates_ready"); + if (result.canceled()) return; + if (!result.errors().isEmpty()) failureReason = String.join("\n", result.errors()); + List left = SyncClient.manifest(endpoint, () -> cancelRequested), right = SyncClient.manifest(peer(session), () -> cancelRequested); + Minecraft.getInstance().execute(() -> { local = left; remote = right; replan(); }); + } finally { active = null; } + }, true); + } + private void rollback() { + SyncClient client = rollbackClient; + if (busy || client == null || !client.canRollback()) return; + run(() -> { + message = tr("dlc_manager.sync.restoring"); + try (SyncEndpoint endpoint = new SyncEndpoint(new DlcSyncAdapter()); DlcServerSession session = connect(entry)) { + client.rollback(endpoint, peer(session)); + List left = SyncClient.manifest(endpoint), right = SyncClient.manifest(peer(session)); + Minecraft.getInstance().execute(() -> { local = left; remote = right; replan(); }); } - } - if (allSelected) { - selectedFiles.removeAll(files); - } else { - selectedFiles.addAll(files); - } - updateLabels(); - } - - private List getDirFiles(Path dir) { - if (dir == null) { - return List.of(); - } - List cached = dirFilesCache.get(dir); - if (cached != null) { - return cached; - } - List files = new ArrayList<>(); - try (var stream = Files.walk(dir)) { - stream.filter(Files::isRegularFile).forEach(path -> { - String rel = toRelative(path); - if (rel != null && !rel.isBlank()) { - files.add(rel); - } - }); - } catch (Exception ignored) { - } - dirFilesCache.put(dir, files); - return files; - } - - private String toRelative(Path path) { - if (path == null) { - return null; - } - Path normalized = path.toAbsolutePath().normalize(); - if (!normalized.startsWith(rootDir)) { - return null; - } - Path relative = rootDir.relativize(normalized); - String rel = relative.toString(); - if (rel.isBlank()) { - return null; - } - return rel.replace('\\', '/'); - } - - private void clearSelection() { - selectedFiles.clear(); - updateLabels(); - } - - private String getCurrentDirLabel() { - if (currentDir == null || currentDir.equals(rootDir)) { - return "/"; - } - String rel = rootDir.relativize(currentDir).toString().replace('\\', '/'); - return "/" + rel; - } - - private static final class FileEntry { - private final Path path; - private final String name; - private final boolean isDir; - - private FileEntry(Path path, String name, boolean isDir) { - this.path = path; - this.name = name; - this.isDir = isDir; - } - } - - private enum SelectionState { - NONE, - PARTIAL, - ALL - } - - private boolean controlsReady; - private int pageRevision; - @Override protected void buildPage() { - if (!controlsReady) { controlsReady = true; initControls(); } - pageRevision = java.util.Objects.hash(entries, pushing); - - mount(toolbar, upButton); mount(toolbar, clearButton); - var list = fileList(getCurrentDirLabel()); - for (var file : entries) { - fileRow(list, file.name + (file.isDir ? "/" : ""), null, - () -> switch (getEntryState(file)) { case ALL -> "true"; case PARTIAL -> "mixed"; case NONE -> "false"; }, - () -> !pushing, () -> toggleEntry(file), file.isDir ? () -> openDirectory(file.path) : null); - } - live(content, () -> statusText, "page-description"); live(content, () -> errorText, "page-error"); mount(footer, modeButton); mount(footer, pushButton).getElement().setAttribute("class", "button primary"); - - } - @Override protected void updatePage() { - updateLabels(); - if (pageRevision != java.util.Objects.hash(entries, pushing)) refreshPage(); - super.updatePage(); - } - - @Override public boolean shouldCloseOnEsc() { return !pushing; } + message = tr("dlc_manager.sync.restored"); + }); + } + private void showFailure(String reason) { + Minecraft.getInstance().setScreen(new DlcSyncFailureScreen(restore -> { + Minecraft.getInstance().setScreen(this); + if (restore) rollback(); + }, reason)); + } + private DlcServerSession connect(DlcServerEntry entry) throws java.io.IOException { + return DlcServerSession.connect(entry, 120000, socket -> { + connecting = socket; + if (cancelRequested) try { socket.close(); } catch (Exception ignored) {} + }); + } + private void cancel() { + if (!busy || cancelRequested) return; + cancelRequested = true; + if (active != null) { if (!active.cancel()) { cancelRequested = false; return; } message = tr("dlc_manager.sync.restoring"); fraction = -1; } + else try { if (connecting != null) connecting.close(); } catch (Exception ignored) {} + refreshPage(); + } + + @FunctionalInterface private interface Work { void run() throws Exception; } + private void run(Work work) { run(work, false); } + private void run(Work work, boolean synchronizing) { + if (busy) return; + failureReason = null; cancelRequested = false; syncing = synchronizing; busy = true; fraction = -1; + message = tr("dlc_manager.sync.working"); refreshPage(); + CompletableFuture.runAsync(() -> { + try { work.run(); } + catch (Exception error) { message = cancelRequested && (error instanceof java.net.SocketException || SyncFailure.is(error, "canceled")) ? DlcTextCompat.translatable("dlc_manager.sync.canceled").getString() : error.getMessage() == null ? error.getClass().getSimpleName() : error.getMessage(); if (synchronizing && !cancelRequested) failureReason = failureReason == null ? message : failureReason + "\n" + message; } + finally { Minecraft.getInstance().execute(() -> { busy = false; syncing = false; connecting = null; refreshPage(); if (failureReason != null) showFailure(failureReason); }); } + }); + } + @Override protected void updatePage() { updateExecuteButton(); super.updatePage(); } + @Override public boolean shouldCloseOnEsc() { return !busy; } } diff --git a/src/main/java/com/cozooo/dlc_manager/client/screen/DlcSyncFailureScreen.java b/src/main/java/com/cozooo/dlc_manager/client/screen/DlcSyncFailureScreen.java new file mode 100644 index 00000000..23a97c2b --- /dev/null +++ b/src/main/java/com/cozooo/dlc_manager/client/screen/DlcSyncFailureScreen.java @@ -0,0 +1,29 @@ +package com.cozooo.dlc_manager.client.screen; + +import com.cozooo.dlc_manager.platform.DlcTextCompat; +import it.unimi.dsi.fastutil.booleans.BooleanConsumer; +import net.minecraft.client.Minecraft; + +public final class DlcSyncFailureScreen extends DlcPageScreen { + private final BooleanConsumer callback; + private final String reason; + private boolean answered; + + public DlcSyncFailureScreen(BooleanConsumer callback, String reason) { + super(DlcUiPage.CONFIRM, Minecraft.getInstance().screen, DlcTextCompat.translatable("dlc_manager.sync.failed")); + this.callback = callback; + this.reason = reason; + } + @Override protected boolean showHeaderBackButton() { return false; } + @Override protected void buildPage() { + rich(node(content, "div", "page-dialog"), DlcTextCompat.translatable("dlc_manager.sync.failure_choice", reason)); + action(footer, tr("dlc_manager.sync.rollback"), () -> answer(true)).getElement().setAttribute("class", "button danger"); + primary(tr("dlc_manager.sync.keep"), () -> answer(false)); + } + private void answer(boolean restore) { + if (answered) return; + answered = true; + callback.accept(restore); + } + @Override public void onClose() { answer(false); } +} diff --git a/src/main/java/com/cozooo/dlc_manager/client/screen/DlcUiProgress.java b/src/main/java/com/cozooo/dlc_manager/client/screen/DlcUiProgress.java new file mode 100644 index 00000000..ff27c7f4 --- /dev/null +++ b/src/main/java/com/cozooo/dlc_manager/client/screen/DlcUiProgress.java @@ -0,0 +1,24 @@ +package com.cozooo.dlc_manager.client.screen; + +import com.sighs.apricityui.init.Element; +import java.util.function.DoubleSupplier; + +final class DlcUiProgress { + private DlcUiProgress() {} + static Runnable binding(Element track, Element fill, DoubleSupplier value) { + int[] previous = {-1}; + return () -> { + if ("display:none".equals(track.getAttribute("style"))) return; + double fraction = value.getAsDouble(); + if (fraction < 0) { + double phase = (System.nanoTime() % 1200000000L) / 1200000000.0; + fill.setAttribute("style", "width:25%;transform:translateX(" + (track.getOffsetWidth() * 0.75 * phase) + "px)"); + if (track.hasAttribute("aria-valuenow")) track.removeAttribute("aria-valuenow"); + if (!track.hasAttribute("aria-busy")) track.setAttribute("aria-busy", "true"); previous[0] = -1; return; + } + if (track.hasAttribute("aria-busy")) track.removeAttribute("aria-busy"); + int percent = (int) Math.round(Math.max(0, Math.min(1, fraction)) * 100); + if (percent != previous[0]) { previous[0] = percent; fill.setAttribute("style", "width:" + percent + "%"); track.setAttribute("aria-valuenow", String.valueOf(percent)); } + }; + } +} diff --git a/src/main/java/com/cozooo/dlc_manager/client/screen/DlcUiScroll.java b/src/main/java/com/cozooo/dlc_manager/client/screen/DlcUiScroll.java index 2f696b8f..e288e101 100644 --- a/src/main/java/com/cozooo/dlc_manager/client/screen/DlcUiScroll.java +++ b/src/main/java/com/cozooo/dlc_manager/client/screen/DlcUiScroll.java @@ -6,6 +6,14 @@ final class DlcUiScroll { private DlcUiScroll() {} + record State(double top, double target, double height) {} + static State capture(Element element) { return new State(element.getScrollTop(), element.getTargetScrollTop(), element.scrollHeight); } + static void restore(Element element, State state) { + element.scrollHeight = state.height(); + element.scrollTop = state.top(); + element.setScrollTop(state.target()); + } + static void scrollToEnd(Element element) { element.setScrollTop(limit(element)); } diff --git a/src/main/java/com/cozooo/dlc_manager/client/server/DlcServerSession.java b/src/main/java/com/cozooo/dlc_manager/client/server/DlcServerSession.java index d811c2d7..3e7a1f42 100644 --- a/src/main/java/com/cozooo/dlc_manager/client/server/DlcServerSession.java +++ b/src/main/java/com/cozooo/dlc_manager/client/server/DlcServerSession.java @@ -40,28 +40,38 @@ private DlcServerSession(Socket socket) throws IOException { } public static DlcServerSession connect(DlcServerEntry entry, int timeoutMs) throws IOException { + return connect(entry, timeoutMs, connection -> {}); + } + + public static DlcServerSession connect(DlcServerEntry entry, int timeoutMs, java.util.function.Consumer opened) throws IOException { if (entry == null) { throw new IOException("missing server entry"); } return connect(entry.host, entry.port, entry.password, timeoutMs, - DlcPlatformServices.languageCode(), DlcPlatformServices.playerName(), DlcPlatformServices.playerUuid()); + DlcPlatformServices.languageCode(), DlcPlatformServices.playerName(), DlcPlatformServices.playerUuid(), opened); } public static DlcServerSession connect(String host, int port, String password, int timeoutMs, String lang, String player, String uuid) throws IOException { + return connect(host, port, password, timeoutMs, lang, player, uuid, connection -> {}); + } + + private static DlcServerSession connect(String host, int port, String password, int timeoutMs, + String lang, String player, String uuid, java.util.function.Consumer opened) throws IOException { Socket socket = new Socket(); - String actualHost = host == null ? "" : host; - socket.connect(new InetSocketAddress(actualHost, port), timeoutMs); - socket.setSoTimeout(timeoutMs); - DlcServerSession session = new DlcServerSession(socket); try { + opened.accept(socket); + String actualHost = host == null ? "" : host; + socket.connect(new InetSocketAddress(actualHost, port), timeoutMs); + socket.setSoTimeout(timeoutMs); + DlcServerSession session = new DlcServerSession(socket); socket.getOutputStream().write(PORT_MULTIPLEXER_PREFACE); socket.getOutputStream().flush(); session.hello(password, lang, player, uuid); return session; } catch (IOException | RuntimeException error) { try { - session.close(); + socket.close(); } catch (IOException ignored) { } throw error; @@ -429,6 +439,18 @@ public boolean pushZip(String id, String mode, List targets, List configuredRoots = DlcConfig.getDlcRoots().stream().map(p -> p.toAbsolutePath().normalize()).toList(); + private final DlcSyncContent content = new DlcSyncContent(configuredRoots); + private final Path game = DlcPaths.gameDir().toAbsolutePath().normalize(); + private final Map aliases = new HashMap<>(); + private final Map previous = new HashMap<>(); + public DlcSyncAdapter() { this(canceled -> {}); } + public DlcSyncAdapter(Quiesce quiesce) { this.quiesce = quiesce; } + @Override public void quiesce(BooleanSupplier canceled) throws Exception { quiesce.await(canceled); } + @Override public Path workDirectory() { return configuredRoots.get(0).resolve("tmp/sync"); } + private List roots() { return configuredRoots; } + private String id(Path root, Path path) throws IOException { + Path normalized = path.toAbsolutePath().normalize(); + if (!normalized.startsWith(root) || normalized.equals(root)) throw new SyncFailure("outside_root"); + return roots().indexOf(root) + ":" + root.relativize(normalized).toString().replace('\\', '/'); + } + @Override public Path resolve(String id) throws IOException { + if (id != null && id.startsWith("manager:")) return content.managerPath(id); + int colon = id.indexOf(':'); + if (colon <= 0) throw new SyncFailure("invalid_resource"); + int index; + try { index = Integer.parseInt(id.substring(0, colon)); } + catch (NumberFormatException error) { throw new SyncFailure("invalid_root").causedBy(error); } + if (index < 0 || index >= roots().size()) throw new SyncFailure("root_missing"); + String relative = id.substring(colon + 1); + if (relative.isBlank() || relative.contains("\\") || relative.contains(":") || Arrays.stream(relative.split("/", -1)).anyMatch(part -> part.isEmpty() || part.equals(".") || part.equals(".."))) throw new SyncFailure("invalid_path"); + Path root = roots().get(index), path = aliases.getOrDefault(id, root.resolve(relative).normalize()); + if (!path.startsWith(root) || path.equals(root)) throw new SyncFailure("outside_root"); + for (Path part = path; part != null && part.startsWith(root); part = part.getParent()) if (Files.isSymbolicLink(part)) throw new SyncFailure("symbolic_path"); + return path; + } + private File file(Path root, Path path, boolean config) throws IOException { return file(root, path, path, config); } + private File file(Path root, Path path, Path canonical, boolean config) throws IOException { + if (path == null) throw new SyncFailure("filename_missing"); + String id = id(root, canonical); + if (!config) aliases.put(id, path.toAbsolutePath().normalize()); + resolve(id); + return new File(id, Files.exists(path) ? com.cozooo.dlc_manager.sync.SyncArchive.size(path) : -1, Files.exists(path) ? DlcFileMatch.hash(path) : null); + } + private List items() { return new DlcScanner().scan(false).values().stream().flatMap(List::stream).toList(); } + private Item remoteItem(Path root, DlcRemoteEntry entry, List scanned) throws IOException { + Path data = entry.getTargetPath(); + Boolean enabled = entry.getType() == DlcType.DLC_PACK ? null : false; + for (DlcItem item : scanned) if (data != null && item.getSourcePath().toAbsolutePath().normalize().equals(data.toAbsolutePath().normalize())) enabled = item.isEnabled(); + List resources = new ArrayList<>(); + if (data != null) resources.add(file(root, data, entry.resolveTargetPath(data.getFileName().toString()), false)); + return new Item(id(root, entry.getConfigPath()), entry.getDlcIdentifier(), file(root, entry.getConfigPath(), true), resources, enabled, entry.getDisplayNames(), entry.getTips()); + } + private Item requiredItem(Path root, DlcRequiredEntry entry) throws IOException { + String name = DlcRequiredService.resolveExistingFileName(entry); + if (name == null || name.isBlank()) name = entry.getRequiredFileName(); + List resources = new ArrayList<>(); + if (name != null && !name.isBlank()) resources.add(file(root, entry.resolveRequiredPath(name), entry.resolveDownloadPath(name), false)); + return new Item(id(root, entry.getConfigPath()), entry.getIdentifier(), file(root, entry.getConfigPath(), true), resources, null, localized(entry.getConfigPath(), "DisplayName"), localized(entry.getConfigPath(), "tip")); + } + private Map localized(Path config, String category) throws IOException { + if (config == null) return Map.of(); + var object = DlcDcConfigIO.getObjectIgnoreCase(DlcDcConfigIO.read(config, DlcDcConfigIO.Layout.GENERIC), category); + Map result = new LinkedHashMap<>(); + if (object != null) for (var entry : object.entrySet()) { + String language = DlcDcConfigIO.normalizeLangKey(entry.getKey()); + if (language != null && entry.getValue().isJsonPrimitive() && entry.getValue().getAsJsonPrimitive().isString()) + result.put(language, entry.getValue().getAsString()); + } + return result; + } + private boolean synchronizable(Path path) { + String name = path.getFileName().toString(); + return DlcDcFormatUtil.isDcName(name) && !name.equalsIgnoreCase("notice.dc") && !name.equalsIgnoreCase("_metadata.dc"); + } + private void validateConfigurations(Path root, String category, int depth) throws IOException { + Path directory = root.resolve(category); + if (!Files.exists(directory)) return; + resolve(id(root, directory)); + try (var paths = Files.walk(directory, depth)) { + for (Path path : paths.filter(Files::isRegularFile).filter(this::synchronizable).filter(p -> !p.startsWith(root.resolve("remote/redirect"))).toList()) { + if (Thread.currentThread().isInterrupted()) throw new SyncFailure("comparison_canceled"); + resolve(id(root, path)); com.cozooo.dclanguage.DcDocuments.read(path); + } + } + } + @Override public List manifest() throws IOException { + List result = new ArrayList<>(); + List scanned = items(); + for (Path root : roots()) { + validateConfigurations(root, "remote", Integer.MAX_VALUE); validateConfigurations(root, "required", 1); + for (DlcRemoteEntry entry : DlcRemoteLoader.load(root)) { if (!synchronizable(entry.getConfigPath())) continue; if (Thread.currentThread().isInterrupted()) throw new SyncFailure("comparison_canceled"); result.add(remoteItem(root, entry, scanned)); } + for (DlcRequiredEntry entry : DlcRequiredLoader.load(root)) { if (!synchronizable(entry.getConfigPath())) continue; if (Thread.currentThread().isInterrupted()) throw new SyncFailure("comparison_canceled"); result.add(requiredItem(root, entry)); } + } + result.addAll(content.manifest()); + return result; + } + private boolean auxiliary(String key) { return key.startsWith("manager:") || key.substring(key.indexOf(':') + 1).startsWith("preset/") || key.substring(key.indexOf(':') + 1).startsWith("update/"); } + @Override public Item item(String key) throws IOException { + validateKey(key); + if (auxiliary(key)) return content.item(key, resolve(key)); + Path root = roots().get(Integer.parseInt(key.substring(0, key.indexOf(':')))); + if (key.substring(key.indexOf(':') + 1).startsWith("remote/")) { + DlcRemoteEntry entry = DlcRemoteLoader.loadSingle(resolve(key), root); + if (entry == null) throw new SyncFailure("invalid_remote"); + return remoteItem(root, entry, items()); + } + return requiredItem(root, required(key)); + } + @Override public void validateKey(String key) throws IOException { + Path path = resolve(key); + String relative = key.substring(key.indexOf(':') + 1); + if (key.startsWith("manager:")) return; + if (relative.startsWith("preset/") && DlcDcFormatUtil.isDcName(relative)) return; + if (relative.startsWith("update/") && relative.indexOf('/', 7) < 0 && relative.toLowerCase(Locale.ROOT).endsWith(".zip")) return; + if (!(relative.startsWith("remote/") || relative.startsWith("required/")) || relative.startsWith("remote/redirect/") + || !DlcDcFormatUtil.isDcName(relative) || path.getFileName().toString().equalsIgnoreCase("notice.dc") || path.getFileName().toString().equalsIgnoreCase("_metadata.dc")) throw new SyncFailure("not_config"); + if (!previous.containsKey(key) && relative.startsWith("remote/") && Files.isRegularFile(path)) { + DlcRemoteEntry entry = DlcRemoteLoader.loadSingle(path, roots().get(Integer.parseInt(key.substring(0, key.indexOf(':'))))); + if (entry != null && entry.getTargetPath() != null) for (DlcItem item : items()) { + if (item.getSourcePath().toAbsolutePath().normalize().equals(entry.getTargetPath().toAbsolutePath().normalize())) previous.put(key, item); + } + } + } + @Override public void validateIncoming(Item incoming) throws IOException { + if (SyncModel.category(incoming) == Category.UPDATE && !SyncModel.eligibleUpdates(List.of(incoming), content.manifest()).contains(incoming.key())) + throw new SyncFailure("update_version"); + } + @Override public void writeSettings(String key, com.google.gson.JsonObject settings, Journal journal) throws IOException { + if (!key.equals("manager:config.dc")) throw new SyncFailure("not_settings"); + DlcSyncSettings.write(content.managerPath(key), settings, journal); + } + @Override public Journal journal(Path directory) throws IOException { + var presetPaths = com.cozooo.dlc_manager.client.preset.DlcPresetRuntimeState.getAppliedDisabledRelativePathsByPreset(); + DlcUpdateJournal journal = DlcUpdateJournal.openSync(game, directory); + try { + mark(directory, "pending"); + journal.capture(com.cozooo.dlc_manager.client.preset.DlcPresetStateStore.getStatePath()); + for (Path root : roots()) { + journal.capture(root.resolve("tmp/state.dc")); + journal.capture(root.resolve("tmp/dlc/history.dc")); + journal.capture(root.resolve("tmp/required/history.dc")); + } + } catch (IOException | RuntimeException error) { journal.close(); throw error; } + return new Journal() { + public void capture(Path path) throws IOException { journal.capture(path); } + public void rollback() throws IOException { journal.rollback(); reload(); com.cozooo.dlc_manager.client.preset.DlcPresetRuntimeState.setAppliedDisabledRelativePathsByPreset(presetPaths); mark(directory, "restored"); } + public void commit() throws IOException { mark(directory, "committed"); } + public void close() { journal.close(); } + }; + } + @Override public void prepare(String key, boolean force, BooleanSupplier canceled, Progress progress) throws Exception { + validateKey(key); + if (auxiliary(key)) return; + Path config = resolve(key); + Path root = roots().get(Integer.parseInt(key.substring(0, key.indexOf(':')))); + DlcUpdateJournal.beforeWrite(config); + if (key.substring(key.indexOf(':') + 1).startsWith("remote/")) { + DlcRemoteEntry entry = DlcRemoteLoader.loadSingle(config, root); + if (entry == null) throw new SyncFailure("invalid_remote"); + if (force && entry.getTargetPath() != null && Files.exists(entry.getTargetPath())) { + DlcUpdateJournal.beforeWrite(entry.getTargetPath()); DlcResourceHistory.delete(entry.getTargetPath()); + } + if (entry.getTargetPath() == null || !Files.exists(entry.getTargetPath())) DlcRemoteService.downloadOne(entry, (done, total) -> progress.update("downloading", key, done, total), canceled::getAsBoolean); + } else { + DlcRequiredEntry entry = required(key); + String existing = DlcRequiredService.resolveExistingFileName(entry); + if (force && existing != null) { Path path = entry.resolveRequiredPath(existing); DlcUpdateJournal.beforeWrite(path); DlcResourceHistory.delete(path); } + String available = DlcRequiredService.resolveExistingFileName(entry); + if (available == null || entry.resolveRequiredPath(available) == null || !Files.isRegularFile(entry.resolveRequiredPath(available))) DlcRequiredService.downloadOne(entry, (done, total) -> progress.update("downloading", key, done, total), canceled::getAsBoolean); + } + } + private DlcRequiredEntry required(String key) throws IOException { + Path config = resolve(key); + for (Path root : roots()) for (DlcRequiredEntry entry : DlcRequiredLoader.load(root)) if (entry.getConfigPath().toAbsolutePath().normalize().equals(config)) return entry; + throw new SyncFailure("invalid_required"); + } + @Override public boolean apply(String key, Boolean enabled, Journal journal, BooleanSupplier canceled) throws Exception { + validateKey(key); + if (canceled.getAsBoolean()) throw new SyncFailure("canceled"); + if (auxiliary(key)) { + Item value = content.item(key, resolve(key)); + if (SyncModel.category(value) == Category.UPDATE) { validateIncoming(value); return false; } + if (SyncModel.category(value) == Category.PRESET) return enabled == null || DlcSyncPresets.apply(value.identifier(), enabled, canceled); + return true; + } + if (key.substring(key.indexOf(':') + 1).startsWith("required/")) { + DlcRequiredEntry entry = required(key); + DlcRequiredInstaller.install(entry, path -> { if (canceled.getAsBoolean()) throw new SyncFailure("canceled"); journal.capture(path); }); + return !entry.resolveInstallationTargets(DlcRequiredService.resolveExistingFileName(entry)).isEmpty(); + } + Path root = roots().get(Integer.parseInt(key.substring(0, key.indexOf(':')))); + DlcRemoteEntry remote = DlcRemoteLoader.loadSingle(resolve(key), root); + if (remote == null || remote.getTargetPath() == null || !Files.exists(remote.getTargetPath())) throw new SyncFailure("remote_missing"); + if (remote.getType() == DlcType.DLC_PACK) return false; + for (DlcItem item : items()) if (item.getSourcePath().toAbsolutePath().normalize().equals(remote.getTargetPath().toAbsolutePath().normalize())) { + DlcItem old = previous.get(key); + boolean restart = item.isEnabled() || Boolean.TRUE.equals(enabled) || old != null && old.isEnabled(); + if (old != null && !old.getId().equals(item.getId()) && old.isEnabled()) { old.setEnabled(false); DlcApplier.applySynced(old); } + if (enabled != null) item.setEnabled(enabled); + DlcApplier.applySynced(item); + return restart; + } + throw new SyncFailure("apply"); + } + private void mark(Path directory, String status) throws IOException { + com.google.gson.JsonObject data = new com.google.gson.JsonObject(); data.addProperty("status", status); + Path temp = directory.resolve("sync.pending.dc"); + Files.createDirectories(directory); + if (!DlcDcConfigIO.write(temp, data, DlcDcConfigIO.Layout.GENERIC)) throw new SyncFailure("recovery_save"); + try { Files.move(temp, directory.resolve("sync.dc"), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } + catch (AtomicMoveNotSupportedException error) { Files.move(temp, directory.resolve("sync.dc"), StandardCopyOption.REPLACE_EXISTING); } + } + @Override public void recoverOperation(String operation) throws IOException { + UUID.fromString(operation); + restore(workDirectory().resolve(operation), true); + } + @Override public void recover() throws IOException { restore(workDirectory(), false); } + private void restore(Path directory, boolean committed) throws IOException { + if (!Files.isDirectory(directory)) return; + boolean restored = false; + try (var paths = Files.walk(directory)) { + for (Path path : paths.filter(p -> p.getFileName().toString().equals("sync.dc")).sorted(Comparator.reverseOrder()).toList()) { + com.google.gson.JsonObject data = DlcDcConfigIO.read(path, DlcDcConfigIO.Layout.GENERIC); + if (data == null) throw new SyncFailure("invalid_recovery", path); + String status = DlcDcConfigIO.getStringIgnoreCase(data, "status"); + if (!"pending".equals(status) && !(committed && "committed".equals(status))) continue; + try (DlcUpdateJournal journal = DlcUpdateJournal.open(game, path.getParent())) { journal.rollback(); } + mark(path.getParent(), "restored"); + restored = true; + } + } + reload(); + if (restored) com.cozooo.dlc_manager.client.preset.DlcPresetRuntimeState.setAppliedDisabledRelativePathsByPreset(com.cozooo.dlc_manager.client.preset.DlcPresetRuntimeState.getDesiredDisabledRelativePathsByPreset()); + } + @Override public void reload() { DlcStateConfig.reloadFromDisk(); DlcRuntimeState.updateItemIndex(items()); DlcSyncPresets.reload(); } +} diff --git a/src/main/java/com/cozooo/dlc_manager/client/sync/DlcSyncContent.java b/src/main/java/com/cozooo/dlc_manager/client/sync/DlcSyncContent.java new file mode 100644 index 00000000..944a8825 --- /dev/null +++ b/src/main/java/com/cozooo/dlc_manager/client/sync/DlcSyncContent.java @@ -0,0 +1,89 @@ +package com.cozooo.dlc_manager.client.sync; + +import com.cozooo.dlc_manager.core.*; +import com.cozooo.dlc_manager.platform.DlcPlatformServices; +import com.cozooo.dlc_manager.client.update.DlcUpdatePackage; +import com.cozooo.dlc_manager.sync.SyncFailure; +import com.cozooo.dlc_manager.sync.SyncModel; +import com.cozooo.dlc_manager.sync.SyncModel.*; +import com.google.gson.JsonObject; +import java.io.IOException; +import java.nio.file.*; +import java.util.*; + +final class DlcSyncContent { + private final List roots; + private final Path manager = DlcPlatformServices.configDir().resolve("dlc_manager").toAbsolutePath().normalize(); + DlcSyncContent(List roots) { this.roots = List.copyOf(roots); } + Path managerPath(String key) throws IOException { + if (!key.equals("manager:config.dc")) throw new SyncFailure("not_settings"); + Path path = manager.resolve(key.substring("manager:".length())); + for (Path part = path; part != null && part.startsWith(manager); part = part.getParent()) if (Files.isSymbolicLink(part)) throw new SyncFailure("symbolic_settings"); + return path; + } + private boolean configKey(String key) { return key.startsWith("manager:") || key.substring(key.indexOf(':') + 1).startsWith("preset/"); } + List manifest() throws IOException { + List result = new ArrayList<>(); + result.add(item("manager:config.dc", managerPath("manager:config.dc"))); + for (int index = 0; index < roots.size(); index++) { + Path root = roots.get(index), presets = root.resolve("preset"), updates = root.resolve("update"); + if (Files.isDirectory(presets)) try (var paths = Files.walk(presets)) { + for (Path path : paths.filter(Files::isRegularFile).filter(p -> DlcDcFormatUtil.isDcName(p.getFileName().toString())).sorted().toList()) { + result.add(item(index + ":" + root.relativize(path).toString().replace('\\', '/'), path)); + } + } + Path version = DlcDcFormatUtil.resolveConfigPath(root.resolve("info"), "version"); + if (Files.isRegularFile(version)) { + JsonObject value = DlcDcConfigIO.read(version, DlcDcConfigIO.Layout.GENERIC); + String base = DlcDcConfigIO.getStringIgnoreCase(value, "base"), dlc = DlcDcConfigIO.getStringIgnoreCase(value, "dlc"); + if (base != null && !base.isBlank() && dlc != null && !dlc.isBlank()) { + String key = index + ":info/version.dc"; + result.add(new Item(key, "version", file(key, version), List.of(), null, Map.of(), Map.of(), new Update(base, dlc, base, dlc, DlcPlatformServices.isDedicatedServer() ? "server" : "client"))); + } + } + if (Files.isDirectory(updates)) try (var paths = Files.list(updates)) { + for (Path path : paths.filter(Files::isRegularFile).filter(p -> p.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".zip")).sorted().toList()) { + if (DlcUpdatePackage.fromZip(path) != null) try { result.add(item(index + ":update/" + path.getFileName(), path)); } catch (IOException ignored) {} + } + } + } + return result; + } + private File file(String key, Path path) throws IOException { + Path anchor = path.startsWith(manager) ? manager : roots.stream().filter(path::startsWith).findFirst().orElseThrow(() -> new SyncFailure("outside_roots")); + for (Path part = path; part != null && part.startsWith(anchor); part = part.getParent()) if (Files.isSymbolicLink(part)) throw new SyncFailure("symbolic_file"); + return new File(key, Files.size(path), DlcFileMatch.hash(path)); + } + Item item(String key, Path path) throws IOException { + if (key.startsWith("manager:")) return DlcSyncSettings.item(key, path); + if (!Files.isRegularFile(path)) throw new SyncFailure("file_missing", key); + if (configKey(key)) { + com.cozooo.dclanguage.DcDocuments.read(path); + JsonObject object = DlcDcConfigIO.read(path, DlcDcConfigIO.Layout.GENERIC); + String identifier = DlcDcConfigIO.getStringIgnoreCase(DlcDcConfigIO.getObjectIgnoreCase(object, "basic"), "identifier"); + if (identifier == null || identifier.isBlank()) identifier = DlcDcConfigIO.getStringIgnoreCase(object, "identifier"); + if (identifier == null || identifier.isBlank()) identifier = DlcDcFormatUtil.stripConfigExtension(path.getFileName().toString()); + identifier = identifier.trim(); + Map names = localized(object, "DisplayName"); + Boolean enabled = com.cozooo.dlc_manager.client.preset.DlcPresetStateStore.loadPresetStatus().getOrDefault(identifier, com.cozooo.dlc_manager.client.preset.DlcPresetStatus.OFF).isEnabled(); + return new Item(key, identifier, file(key, path), List.of(), enabled, names, localized(object, "tip")); + } + DlcUpdatePackage pkg = DlcUpdatePackage.fromZip(path); + if (pkg == null || pkg.getManifest().getVersion() == null) throw new SyncFailure("invalid_update", key); + var version = pkg.getManifest().getVersion(); + String base = version.getBase(), next = version.getBaseNew(), old = version.getDlcOld(), updated = version.getDlcNew(); + if (base == null || base.isBlank() || old == null || old.isBlank()) throw new SyncFailure("update_versions_missing", key); + if (next == null || next.isBlank()) next = base; + if (updated == null || updated.isBlank()) updated = old; + return new Item(key, pkg.getName(), file(key, path), List.of(), null, Map.of(), pkg.getManifest().getChangelog(), new Update(base, old, next, updated, version.getPlatform())); + } + private Map localized(JsonObject object, String category) { + JsonObject values = DlcDcConfigIO.getObjectIgnoreCase(object, category); + Map result = new LinkedHashMap<>(); + if (values != null) for (var value : values.entrySet()) { + String language = DlcDcConfigIO.normalizeLangKey(value.getKey()); + if (language != null && value.getValue().isJsonPrimitive() && value.getValue().getAsJsonPrimitive().isString()) result.put(language, value.getValue().getAsString()); + } + return result; + } +} diff --git a/src/main/java/com/cozooo/dlc_manager/client/sync/DlcSyncPresets.java b/src/main/java/com/cozooo/dlc_manager/client/sync/DlcSyncPresets.java new file mode 100644 index 00000000..e18350ad --- /dev/null +++ b/src/main/java/com/cozooo/dlc_manager/client/sync/DlcSyncPresets.java @@ -0,0 +1,42 @@ +package com.cozooo.dlc_manager.client.sync; + +import com.cozooo.dlc_manager.sync.SyncFailure; + +import com.cozooo.dlc_manager.client.preset.*; +import com.cozooo.dlc_manager.core.*; +import java.io.IOException; +import java.util.*; +import java.util.function.BooleanSupplier; + +final class DlcSyncPresets { + private DlcSyncPresets() {} + static boolean apply(String identifier, Boolean enabled, BooleanSupplier canceled) throws IOException { + if (!DlcConfig.isPresetFuncOn()) throw new SyncFailure("presets_disabled"); + List items = new DlcScanner().scan(false).values().stream().flatMap(List::stream).toList(); + DlcRuntimeState.updateItemIndex(items); + for (DlcItem item : items) DlcRuntimeState.ensureBaseline(item.getId(), item.isEnabled()); + DlcPresetRuntimeState.reload(false); + if (!DlcPresetRuntimeState.hasPreset(identifier)) throw new SyncFailure("preset_missing", identifier); + boolean desired = enabled == null ? DlcPresetRuntimeState.isPresetEnabled(identifier) : enabled; + if (!DlcPresetRuntimeState.setPresetEnabled(identifier, desired)) throw new SyncFailure("preset_blocked", identifier); + boolean restart = false; + for (DlcItem item : items) { + if (canceled.getAsBoolean()) throw new SyncFailure("canceled"); + Boolean override = DlcRuntimeState.getOverride(item.getId()); + if (override == null || override == item.isEnabled()) continue; + item.setEnabled(override); DlcApplier.applySynced(item); restart = true; + } + if (canceled.getAsBoolean()) throw new SyncFailure("canceled"); + restart |= DlcApplier.applyPresetConflictsSynced(); + DlcRuntimeState.updateItemIndex(new DlcScanner().scan(false).values().stream().flatMap(List::stream).toList()); + DlcPresetRuntimeState.syncStatusFromRuntime(); + if (DlcPresetStateStore.loadPresetStatus().getOrDefault(identifier, DlcPresetStatus.OFF).isEnabled() != desired) throw new SyncFailure("preset_state_save", identifier); + return restart; + } + static void reload() { + Set enabled = new LinkedHashSet<>(); + DlcPresetStateStore.loadPresetStatus().forEach((id, state) -> { if (state.isEnabled()) enabled.add(id); }); + DlcPresetRuntimeState.restoreEnabledPresets(enabled); + DlcPresetRuntimeState.reload(false); + } +} diff --git a/src/main/java/com/cozooo/dlc_manager/client/sync/DlcSyncSettings.java b/src/main/java/com/cozooo/dlc_manager/client/sync/DlcSyncSettings.java new file mode 100644 index 00000000..b7d5e1e9 --- /dev/null +++ b/src/main/java/com/cozooo/dlc_manager/client/sync/DlcSyncSettings.java @@ -0,0 +1,45 @@ +package com.cozooo.dlc_manager.client.sync; + +import com.cozooo.dlc_manager.core.*; +import com.cozooo.dlc_manager.sync.SyncFailure; +import com.cozooo.dlc_manager.sync.SyncModel.*; +import com.google.gson.JsonObject; +import java.io.IOException; +import java.nio.file.*; +import java.nio.charset.StandardCharsets; +import java.util.*; + +final class DlcSyncSettings { + private DlcSyncSettings() {} + static Item item(String key, Path path) throws IOException { + JsonObject values = DlcConfig.buildSyncConfigJson(read(path)); + byte[] bytes = values.toString().getBytes(StandardCharsets.UTF_8); + return new Item(key, "config", new File(key, bytes.length, DlcFileMatch.hash(new java.io.ByteArrayInputStream(bytes))), List.of(), null, + Map.of(), Map.of(), null, values); + } + private static JsonObject read(Path path) throws IOException { + if (!Files.exists(path)) return new JsonObject(); + return com.cozooo.dclanguage.DcDocuments.read(path).toJson(); + } + static void write(Path path, JsonObject incoming, Journal journal) throws IOException { + JsonObject values = DlcConfig.buildSyncConfigJson(incoming), original = read(path), previous = DlcConfig.buildSyncConfigJson(original); + for (var section : values.entrySet()) { + String category = existingKey(original, section.getKey()); + JsonObject target = DlcDcConfigIO.getObjectIgnoreCase(original, category); + for (var entry : section.getValue().getAsJsonObject().entrySet()) { + if (DlcDcConfigIO.getIgnoreCase(DlcDcConfigIO.getObjectIgnoreCase(incoming, section.getKey()), entry.getKey()) == null || entry.getValue().equals(previous.getAsJsonObject(section.getKey()).get(entry.getKey()))) continue; + if (target == null) { target = new JsonObject(); original.add(category, target); } + String key = existingKey(target, entry.getKey()); + if (section.getKey().equals("modpack") && entry.getKey().equals("updateGitRepo") && DlcDcConfigIO.getIgnoreCase(target, "updateGithubRepo") != null) target.add(existingKey(target, "updateGithubRepo"), entry.getValue().deepCopy()); + target.add(key, entry.getValue().deepCopy()); + } + } + journal.capture(path); + Files.createDirectories(path.getParent()); + if (!DlcDcConfigIO.write(path, original, DlcDcConfigIO.Layout.GENERIC)) throw new SyncFailure("settings_save"); + } + private static String existingKey(JsonObject object, String key) { + for (String existing : object.keySet()) if (existing.equalsIgnoreCase(key)) return existing; + return key; + } +} diff --git a/src/main/java/com/cozooo/dlc_manager/client/update/DlcUpdateJournal.java b/src/main/java/com/cozooo/dlc_manager/client/update/DlcUpdateJournal.java index 44df5e47..75e3edac 100644 --- a/src/main/java/com/cozooo/dlc_manager/client/update/DlcUpdateJournal.java +++ b/src/main/java/com/cozooo/dlc_manager/client/update/DlcUpdateJournal.java @@ -14,6 +14,7 @@ public final class DlcUpdateJournal implements AutoCloseable { private static final ThreadLocal ACTIVE = new ThreadLocal<>(); + private static final ThreadLocal SYNC = new ThreadLocal<>(); private final Path game; private final Path directory; private final JsonArray entries; @@ -26,7 +27,14 @@ private DlcUpdateJournal(Path game, Path directory, JsonArray entries, List items, DlcApplyMode mode) { return new ApplyResult(messages, requiresRestart, attemptedHotReload, context.helperScheduled); } + public static void applySynced(DlcItem item) throws IOException { + ApplyContext context = new ApplyContext(new ArrayList<>()); + CURRENT_APPLY_CONTEXT.set(context); + try { + List all = new DlcScanner().scan(false).values().stream().flatMap(List::stream).map(candidate -> candidate.getId().equals(item.getId()) ? item : candidate).toList(); + DlcRuntimeState.updateItemIndex(all); + applyDlcConflictPaths(all); + DlcStateConfig.DlcState previous = DlcStateConfig.getState(item.getId()); + List targets; + if (item.isEnabled()) targets = applyItem(item); + else { RemovalResult removed = removeItem(item, previous); if (!removed.messages().isEmpty()) throw new IOException(String.join("; ", removed.messages())); targets = List.of(); } + if (!context.messages.isEmpty()) throw new IOException(String.join("; ", context.messages)); + DlcStateConfig.DlcState state = new DlcStateConfig.DlcState(); + state.root = DlcPathUtil.toGameRelativePath(item.getDlcRoot()); + state.mode = item.getMatchMode(); + state.enabled = item.isEnabled(); + state.appliedTargets = targets.stream().map(DlcPathUtil::toGameRelativePath).toList(); + DlcStateConfig.setState(item.getId(), state); + DlcStateConfig.save(); + } finally { CURRENT_APPLY_CONTEXT.remove(); } + } + + public static boolean applyPresetConflictsSynced() throws IOException { + ApplyContext context = new ApplyContext(new ArrayList<>()); + CURRENT_APPLY_CONTEXT.set(context); + try { + PresetConflictPathResult result = applyPresetConflictPaths(); + if (!result.messages().isEmpty()) throw new IOException(String.join("; ", result.messages())); + if (!context.messages.isEmpty()) throw new IOException(String.join("; ", context.messages)); + return result.changed(); + } finally { CURRENT_APPLY_CONTEXT.remove(); } + } + private static DlcConflictPathResult applyDlcConflictPaths(Collection items) { List messages = new ArrayList<>(); Set desiredPatterns = collectEnabledDlcConflictPathPatterns(items); @@ -663,6 +696,7 @@ public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOEx } private static void copyWithHelperFallback(Path source, Path target) throws IOException { + com.cozooo.dlc_manager.client.update.DlcUpdateJournal.beforeSyncWrite(target); if (source == null || target == null) { return; } @@ -689,6 +723,8 @@ private static void copyWithHelperFallback(Path source, Path target) throws IOEx } private static void moveWithHelperFallback(Path source, Path target, boolean replaceExisting) throws IOException { + com.cozooo.dlc_manager.client.update.DlcUpdateJournal.beforeSyncWrite(target); + com.cozooo.dlc_manager.client.update.DlcUpdateJournal.beforeSyncWrite(source); if (source == null || target == null) { return; } @@ -720,6 +756,7 @@ private static void moveWithHelperFallback(Path source, Path target, boolean rep } private static void deleteWithHelperFallback(Path path) throws IOException { + com.cozooo.dlc_manager.client.update.DlcUpdateJournal.beforeSyncWrite(path); if (path == null) { return; } diff --git a/src/main/java/com/cozooo/dlc_manager/core/DlcConfig.java b/src/main/java/com/cozooo/dlc_manager/core/DlcConfig.java index 95cd1756..085f0378 100644 --- a/src/main/java/com/cozooo/dlc_manager/core/DlcConfig.java +++ b/src/main/java/com/cozooo/dlc_manager/core/DlcConfig.java @@ -186,6 +186,55 @@ public static JsonObject buildServerConfigJson() { return root; } + public static JsonObject buildSyncConfigJson(JsonObject source) { + JsonObject defaults = new JsonObject(); + JsonObject paths = new JsonObject(); + paths.addProperty("dlcRoot", DEFAULT_DLC_ROOT); + paths.addProperty("taczRoot", DEFAULT_TACZ_ROOT); + paths.addProperty("resourcepackRoot", DEFAULT_RESOURCEPACK_ROOT); + paths.addProperty("datapackRoot", DEFAULT_DATAPACK_ROOT); + defaults.add("paths", paths); + JsonObject ui = new JsonObject(); + ui.addProperty("isEnableAllButtonShown", DEFAULT_ENABLE_ALL_SHOWN); + ui.addProperty("isUpdateManagerOn", DEFAULT_UPDATE_MANAGER_ON); + ui.addProperty("isSelfUpdateAutoCheckOn", DEFAULT_SELF_UPDATE_AUTO_CHECK_ON); + ui.addProperty("isPresetFuncOn", DEFAULT_PRESET_FUNC_ON); + ui.addProperty("WaitingTime", DEFAULT_ALARM_WAITING_TIME); + defaults.add("ui", ui); + JsonObject downloads = new JsonObject(); + downloads.addProperty("maxConcurrentDownloads", DEFAULT_MAX_CONCURRENT_DOWNLOADS); + downloads.addProperty("maxSegmentsPerFile", DEFAULT_MAX_SEGMENTS_PER_FILE); + downloads.addProperty("minSegmentMB", DEFAULT_MIN_SEGMENT_MB); + downloads.addProperty("maxRetryCount", DEFAULT_MAX_RETRY); + downloads.addProperty("connectTimeoutSeconds", DEFAULT_CONNECT_TIMEOUT_SECONDS); + downloads.addProperty("taskTimeoutSeconds", DEFAULT_TASK_TIMEOUT_SECONDS); + downloads.addProperty("downloadMirrorMode", DEFAULT_DOWNLOAD_MIRROR_MODE); + downloads.addProperty("downloadMirrorProbeCount", DEFAULT_DOWNLOAD_MIRROR_PROBE_COUNT); + downloads.addProperty("downloadMirrorProbeStrategy", DEFAULT_DOWNLOAD_MIRROR_PROBE_STRATEGY); + defaults.add("downloads", downloads); + JsonObject modpack = new JsonObject(); + modpack.addProperty("modpack", DEFAULT_MODPACK); + modpack.addProperty("modpackUrl", DEFAULT_MODPACK_URL); + modpack.addProperty("updateGitRepo", DEFAULT_UPDATE_GIT_REPO_URL); + modpack.addProperty("autoUpdate", DEFAULT_MODPACK_AUTO_UPDATE); + modpack.addProperty("forceUpdate", DEFAULT_MODPACK_FORCE_UPDATE); + defaults.add("modpack", modpack); + for (var section : defaults.entrySet()) { + JsonObject incoming = DlcDcConfigIO.getObjectIgnoreCase(source, section.getKey()); + JsonObject values = section.getValue().getAsJsonObject(); + for (String key : new java.util.ArrayList<>(values.keySet())) { + JsonElement value = DlcDcConfigIO.getIgnoreCase(incoming, key); + if (value == null && section.getKey().equals("modpack") && key.equals("updateGitRepo")) value = DlcDcConfigIO.getIgnoreCase(incoming, "updateGithubRepo"); + if (value == null || !value.isJsonPrimitive()) continue; + var expected = values.getAsJsonPrimitive(key); + var actual = value.getAsJsonPrimitive(); + if (expected.isNumber() && actual.isNumber()) values.addProperty(key, value.getAsInt()); + else if (expected.isBoolean() && actual.isBoolean() || expected.isString() && actual.isString()) values.add(key, value.deepCopy()); + } + } + return defaults; + } + public static JsonObject buildSharedConfigJson() { JsonObject root = new JsonObject(); writeShared(root); @@ -557,7 +606,7 @@ public static long getMinSegmentBytes() { if (value < 1) { value = DEFAULT_MIN_SEGMENT_MB; } - return (long) value * 1024L * 1024L; + return (long) value * DlcFileSize.MIB; } public static int getMaxRetryCount() { @@ -796,7 +845,7 @@ public static void setMaxSegmentsPerFile(int value) { } public static void setMinSegmentBytes(long value) { - long mb = Math.max(1L, (value + (1024L * 1024L - 1)) / (1024L * 1024L)); + long mb = Math.max(1L, DlcFileSize.ceilMiB(value)); long clamped = Math.min(Integer.MAX_VALUE, mb); minSegmentMb = (int) clamped; } diff --git a/src/main/java/com/cozooo/dlc_manager/core/DlcFileMatch.java b/src/main/java/com/cozooo/dlc_manager/core/DlcFileMatch.java index 3be09c40..46fdd0cf 100644 --- a/src/main/java/com/cozooo/dlc_manager/core/DlcFileMatch.java +++ b/src/main/java/com/cozooo/dlc_manager/core/DlcFileMatch.java @@ -1,18 +1,27 @@ package com.cozooo.dlc_manager.core; +import java.io.EOFException; import java.io.IOException; +import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.nio.file.LinkOption; import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.Comparator; import java.util.HashMap; import java.util.HexFormat; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; public final class DlcFileMatch { - private static final ThreadLocal> SCAN_HASHES = new ThreadLocal<>(); + private static final long LIGHT_LIMIT = 128 * DlcFileSize.KIB; + private static final int SAMPLE_BYTES = (int)(64 * DlcFileSize.KIB); + private record CachedHash(String stamp, String hash) {} + private static final Map HASHES = new ConcurrentHashMap<>(); + private static final ThreadLocal> SCAN_HASHES = new ThreadLocal<>(); private DlcFileMatch() {} public static String mode(String value) { return "name".equalsIgnoreCase(value) ? "name" : "hash"; } @@ -21,47 +30,123 @@ private DlcFileMatch() {} public static String hash(Path path) throws IOException { Path normalized = path.toAbsolutePath().normalize(); - Map cache = SCAN_HASHES.get(); - if (cache != null && cache.containsKey(normalized)) return cache.get(normalized); - MessageDigest digest; - try { digest = MessageDigest.getInstance("SHA-256"); } - catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); } - if (Files.isDirectory(normalized)) { + String stamp = stamp(normalized); + Map scan = SCAN_HASHES.get(); + CachedHash cached = scan == null ? null : scan.get(normalized); + if (cached == null || !cached.stamp.equals(stamp)) cached = HASHES.get(normalized); + if (cached != null && cached.stamp.equals(stamp)) return cached.hash; + String result = fullHash(normalized); + if (!stamp.equals(stamp(normalized))) throw new IOException("Resource changed while hashing: " + path); + CachedHash updated = new CachedHash(stamp, result); + if (HASHES.size() >= 8192) HASHES.clear(); + HASHES.put(normalized, updated); + if (scan != null) scan.put(normalized, updated); + return result; + } + + private static String stamp(Path path) throws IOException { + MessageDigest digest = digest(); + BasicFileAttributes attributes = attributes(path); + if (attributes.isDirectory()) { + try (var paths = Files.walk(path)) { + for (Path child : paths.sorted(Comparator.comparing(p -> path.relativize(p).toString())).toList()) { + checkInterrupted(); + addStamp(digest, path.relativize(child).toString().replace('\\', '/'), child); + } + } + } else addStamp(digest, "", path); + return HexFormat.of().formatHex(digest.digest()); + } + + private static void addStamp(MessageDigest digest, String relative, Path path) throws IOException { + BasicFileAttributes attributes = attributes(path); + String value = relative + "\u0000" + (attributes.isDirectory() ? "directory" : "file") + "\u0000" + + attributes.size() + "\u0000" + attributes.lastModifiedTime() + "\u0000" + attributes.fileKey() + "\u0000"; + digest.update(value.getBytes(StandardCharsets.UTF_8)); + if (attributes.isRegularFile()) { digest.update(lightHash(path).getBytes(StandardCharsets.US_ASCII)); unchanged(path, attributes); } + } + + public static String lightHash(Path path) throws IOException { + BasicFileAttributes before = attributes(path); + if (!before.isRegularFile()) throw new IOException("Not a regular resource: " + path); + if (before.size() <= LIGHT_LIMIT) return fullHash(path); + MessageDigest digest = digest(); + digest.update(ByteBuffer.allocate(Long.BYTES).putLong(before.size()).array()); + try (var channel = Files.newByteChannel(path)) { + ByteBuffer buffer = ByteBuffer.allocate(SAMPLE_BYTES); + for (long offset : new long[]{0, before.size() - buffer.capacity()}) { + checkInterrupted(); buffer.clear(); channel.position(offset); + while (buffer.hasRemaining()) { + checkInterrupted(); + if (channel.read(buffer) < 0) throw new EOFException("Resource changed while hashing: " + path); + } + digest.update(buffer.array()); + } + } + unchanged(path, before); + return "light-" + HexFormat.of().formatHex(digest.digest()); + } + + public static String fullHash(Path path) throws IOException { + Path normalized = path.toAbsolutePath().normalize(); + BasicFileAttributes before = attributes(normalized); + MessageDigest digest = digest(); + if (before.isDirectory()) { try (var paths = Files.walk(normalized)) { for (Path child : paths.sorted(Comparator.comparing(p -> normalized.relativize(p).toString())).toList()) { - if (Files.isSymbolicLink(child)) throw new IOException("Cannot hash symbolic link: " + child); - digest.update((normalized.relativize(child).toString().replace('\\', '/') + "\u0000" + (Files.isDirectory(child) ? "directory" : "file") + "\u0000").getBytes(StandardCharsets.UTF_8)); - if (Files.isRegularFile(child)) { - digest.update((Files.size(child) + "\u0000").getBytes(StandardCharsets.UTF_8)); + checkInterrupted(); + BasicFileAttributes childAttributes = attributes(child); + digest.update((normalized.relativize(child).toString().replace('\\', '/') + "\u0000" + (childAttributes.isDirectory() ? "directory" : "file") + "\u0000").getBytes(StandardCharsets.UTF_8)); + if (childAttributes.isRegularFile()) { + digest.update((childAttributes.size() + "\u0000").getBytes(StandardCharsets.UTF_8)); update(digest, child); } } } - } else { - if (!Files.isRegularFile(normalized) || Files.isSymbolicLink(normalized)) throw new IOException("Not a regular resource: " + normalized); - update(digest, normalized); - } - String result = HexFormat.of().formatHex(digest.digest()); - if (cache != null) cache.put(normalized, result); - return result; + } else update(digest, normalized); + unchanged(normalized, before); + return HexFormat.of().formatHex(digest.digest()); } public static String hash(java.io.InputStream input) throws IOException { - MessageDigest digest; - try { digest = MessageDigest.getInstance("SHA-256"); } - catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); } - byte[] buffer = new byte[65536]; + checkInterrupted(); + MessageDigest digest = digest(); + byte[] buffer = new byte[SAMPLE_BYTES]; int count; - while ((count = input.read(buffer)) != -1) digest.update(buffer, 0, count); + while ((count = input.read(buffer)) != -1) { checkInterrupted(); digest.update(buffer, 0, count); } return HexFormat.of().formatHex(digest.digest()); } private static void update(MessageDigest digest, Path file) throws IOException { + BasicFileAttributes before = attributes(file); try (var input = Files.newInputStream(file)) { - byte[] buffer = new byte[65536]; + byte[] buffer = new byte[SAMPLE_BYTES]; int count; - while ((count = input.read(buffer)) != -1) digest.update(buffer, 0, count); + while ((count = input.read(buffer)) != -1) { checkInterrupted(); digest.update(buffer, 0, count); } } + unchanged(file, before); + } + + private static BasicFileAttributes attributes(Path path) throws IOException { + checkInterrupted(); + BasicFileAttributes attributes = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + if (!attributes.isRegularFile() && !attributes.isDirectory()) throw new IOException("Not a regular resource or directory: " + path); + return attributes; + } + + private static void unchanged(Path path, BasicFileAttributes before) throws IOException { + BasicFileAttributes after = attributes(path); + if (before.isDirectory() != after.isDirectory() || before.size() != after.size() + || !before.lastModifiedTime().equals(after.lastModifiedTime()) || !java.util.Objects.equals(before.fileKey(), after.fileKey())) throw new IOException("Resource changed while hashing: " + path); + } + + private static MessageDigest digest() { + try { return MessageDigest.getInstance("SHA-256"); } + catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); } + } + + private static void checkInterrupted() throws IOException { + if (Thread.currentThread().isInterrupted()) throw new IOException("Resource validation canceled"); } public static boolean matches(Path source, Path target, String mode) throws IOException { diff --git a/src/main/java/com/cozooo/dlc_manager/core/DlcFileSize.java b/src/main/java/com/cozooo/dlc_manager/core/DlcFileSize.java new file mode 100644 index 00000000..7735c360 --- /dev/null +++ b/src/main/java/com/cozooo/dlc_manager/core/DlcFileSize.java @@ -0,0 +1,30 @@ +package com.cozooo.dlc_manager.core; + +import java.util.Locale; + +public final class DlcFileSize { + public static final long KIB = 1024L; + public static final long MIB = KIB * 1024L; + public static final long GIB = MIB * 1024L; + private static final String[] UNITS = {"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"}; + + private DlcFileSize() {} + + public static long ceilMiB(long bytes) { + if (bytes <= 0) return 0; + return bytes / MIB + (bytes % MIB == 0 ? 0 : 1); + } + + public static String formatProgress(long done, long total) { + return format(done) + (total < 0 ? "" : "/" + format(total)); + } + + public static String format(long bytes) { + if (bytes < 0) return "--"; + if (bytes < KIB) return bytes + " B"; + double value = bytes; + int unit = 0; + while (value >= KIB && unit < UNITS.length - 1) { value /= KIB; unit++; } + return String.format(Locale.ROOT, "%.1f %s", value, UNITS[unit]); + } +} diff --git a/src/main/java/com/cozooo/dlc_manager/core/DlcResourceHistory.java b/src/main/java/com/cozooo/dlc_manager/core/DlcResourceHistory.java index a15f07f4..1d895b88 100644 --- a/src/main/java/com/cozooo/dlc_manager/core/DlcResourceHistory.java +++ b/src/main/java/com/cozooo/dlc_manager/core/DlcResourceHistory.java @@ -288,6 +288,7 @@ public static void replace(Path source, Path target) throws IOException { } public static void copy(Path source, Path target) throws IOException { + com.cozooo.dlc_manager.client.update.DlcUpdateJournal.beforeSyncWrite(target); if (Files.isSymbolicLink(source)) throw new IOException("Cannot copy symbolic link: " + source); if (Files.isDirectory(source)) { Files.createDirectories(target); diff --git a/src/main/java/com/cozooo/dlc_manager/server/DlcServerDownloadService.java b/src/main/java/com/cozooo/dlc_manager/server/DlcServerDownloadService.java index 7472adca..4f5b63a5 100644 --- a/src/main/java/com/cozooo/dlc_manager/server/DlcServerDownloadService.java +++ b/src/main/java/com/cozooo/dlc_manager/server/DlcServerDownloadService.java @@ -553,6 +553,9 @@ public static DlcServerRemoteStatus startRemoteDownload(Path dlcRoot, List keys, String lang) { synchronized (REMOTE_LOCK) { + if (com.cozooo.dlc_manager.sync.SyncEndpoint.busy(DlcConfig.getDlcRoot().resolve("tmp/sync"))) { + DlcServerRemoteStatus blocked = new DlcServerRemoteStatus(); blocked.finished = true; blocked.error = "DLC synchronization is in progress"; return blocked; + } if (remoteSession != null && remoteSession.running.get()) { return remoteSession.toStatus(lang); } @@ -599,6 +602,9 @@ public static DlcServerRequiredStatus startRequiredDownload(Path dlcRoot, List ids, String lang) { synchronized (REQUIRED_LOCK) { + if (com.cozooo.dlc_manager.sync.SyncEndpoint.busy(DlcConfig.getDlcRoot().resolve("tmp/sync"))) { + DlcServerRequiredStatus blocked = new DlcServerRequiredStatus(); blocked.finished = true; blocked.error = "DLC synchronization is in progress"; return blocked; + } if (requiredSession != null && requiredSession.running.get()) { return requiredSession.toStatus(lang); } diff --git a/src/main/java/com/cozooo/dlc_manager/server/DlcServerService.java b/src/main/java/com/cozooo/dlc_manager/server/DlcServerService.java index 0f850a8f..73662344 100644 --- a/src/main/java/com/cozooo/dlc_manager/server/DlcServerService.java +++ b/src/main/java/com/cozooo/dlc_manager/server/DlcServerService.java @@ -79,6 +79,9 @@ public class DlcServerService { private static final Object PACK_APPLY_LOCK = new Object(); private static volatile PackApplyJob packApplyJob; private static volatile MinecraftServer currentServer; + private static final java.util.concurrent.ScheduledExecutorService SYNC_HEARTBEATS = java.util.concurrent.Executors.newSingleThreadScheduledExecutor(task -> { + Thread thread = new Thread(task, "dlc-sync-heartbeat"); thread.setDaemon(true); return thread; + }); public static void updateServerStatus(MinecraftServer server) { if (!DlcConfig.isServerSupportOnServer()) { @@ -122,7 +125,15 @@ public static String getServerVersionLabel() { } public static void handleClient(Socket socket) { - try (Socket client = socket; + try (com.cozooo.dlc_manager.sync.SyncEndpoint sync = new com.cozooo.dlc_manager.sync.SyncEndpoint(new com.cozooo.dlc_manager.client.sync.DlcSyncAdapter(canceled -> { + DlcServerDownloadService.cancelRemoteDownload(); DlcServerDownloadService.cancelRequiredDownload(); + long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(30); + while (DlcServerDownloadService.getRemoteDownloadStatus().running || DlcServerDownloadService.getRequiredDownloadStatus().running) { + if (canceled.getAsBoolean() || System.nanoTime() > deadline) throw new com.cozooo.dlc_manager.sync.SyncFailure("server_downloads_stop"); + Thread.sleep(50); + } + })); + Socket client = socket; BufferedReader reader = new BufferedReader(new InputStreamReader(client.getInputStream(), StandardCharsets.UTF_8)); BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(client.getOutputStream(), StandardCharsets.UTF_8))) { @@ -157,113 +168,142 @@ public static void handleClient(Socket socket) { writeError(writer, "unauthorized"); continue; } - if ("list".equals(type)) { - writeList(writer, clientLang); - } else if ("remote_list".equals(type)) { - handleRemoteList(writer, clientLang); - } else if ("remote_download_start".equals(type)) { - handleRemoteDownloadStart(request, writer, clientLang); - } else if ("remote_download_status".equals(type)) { - handleRemoteDownloadStatus(writer, clientLang); - } else if ("remote_download_cancel".equals(type)) { - handleRemoteDownloadCancel(writer); - } else if ("remote_download".equals(type)) { - handleRemoteDownload(writer); - } else if ("required_list".equals(type)) { - handleRequiredList(writer, clientLang); - } else if ("required_download_start".equals(type)) { - handleRequiredDownloadStart(request, writer, clientLang); - } else if ("required_download_status".equals(type)) { - handleRequiredDownloadStatus(writer, clientLang); - } else if ("required_download_cancel".equals(type)) { - handleRequiredDownloadCancel(writer); - } else if ("required_uninstall".equals(type)) { - handleRequiredUninstall(request, writer); - } else if ("required_apply".equals(type)) { - handleRequiredApply(request, writer); - } else if ("update_state".equals(type)) { - writeUpdateState(writer); - } else if ("update_list".equals(type)) { - writeUpdateList(writer); - } else if ("update_apply".equals(type)) { - handleUpdateApply(request, writer); - } else if ("update_rollback".equals(type)) { - handleUpdateRollback(request, writer); - } else if ("update_import".equals(type)) { - handleUpdateImport(request, writer); - } else if ("update_import_url".equals(type)) { - handleUpdateImportUrl(request, writer); - } else if ("pack_list".equals(type)) { - handlePackList(writer, clientLang); - } else if ("pack_install".equals(type)) { - handlePackInstall(request, writer); - } else if ("pack_uninstall".equals(type)) { - handlePackUninstall(request, writer); - } else if ("pack_delete".equals(type)) { - handlePackDelete(request, writer); - } else if ("pack_import".equals(type)) { - handlePackImport(request, writer); - } else if ("pack_import_url".equals(type)) { - handlePackImportUrl(request, writer); - } else if ("pack_order".equals(type)) { - handlePackOrder(request, writer); - } else if ("pack_apply_start".equals(type)) { - handlePackApplyStart(request, writer); - } else if ("pack_apply_status".equals(type)) { - handlePackApplyStatus(writer); - } else if ("pack_apply_pause".equals(type)) { - handlePackApplyPause(writer); - } else if ("pack_apply_resume".equals(type)) { - handlePackApplyResume(writer); - } else if ("pack_apply_cancel".equals(type)) { - handlePackApplyCancel(writer); - } else if ("set".equals(type)) { - handleSet(request, overrides, writer); - } else if ("apply".equals(type)) { - handleApply(overrides, writer); - overrides.clear(); - } else if ("push_init".equals(type)) { - pushState = handlePushInit(request, writer); - } else if ("push_chunk".equals(type)) { - handlePushChunk(request, pushState, writer); - } else if ("push_finish".equals(type)) { - handlePushFinish(pushState, writer); - pushState = null; - } else if ("file_list".equals(type)) { - handleFileList(request, writer); - } else if ("file_delete".equals(type)) { - handleFileDelete(request, writer); - } else if ("file_upload_init".equals(type)) { - uploadState = handleFileUploadInit(request, writer); - } else if ("file_upload_chunk".equals(type)) { - handleFileUploadChunk(request, uploadState, writer); - } else if ("file_upload_finish".equals(type)) { - handleFileUploadFinish(uploadState, writer); - uploadState = null; - } else if ("file_download_init".equals(type)) { - handleFileDownload(request, writer); - } else if ("config_get".equals(type)) { - handleConfigGet(writer); - } else if ("config_set".equals(type)) { - handleConfigSet(request, writer); - } else if ("rcon_info".equals(type)) { - handleRconInfo(writer); - } else if ("rcon_get".equals(type)) { - handleRconGet(writer); - } else if ("rcon_set".equals(type)) { - handleRconSet(request, writer); - } else if ("rcon_exec".equals(type)) { - handleRconExec(request, writer); - } else if ("server_restart".equals(type)) { - handleServerRestart(writer); - } else { - writeError(writer, "unknown request"); + if ("dlc_sync".equals(type)) { + handleSync(sync, request, writer); + continue; + } + com.cozooo.dlc_manager.sync.SyncEndpoint.OperationGuard guard; + try { guard = com.cozooo.dlc_manager.sync.SyncEndpoint.guard(DlcConfig.getDlcRoot().resolve("tmp/sync")); } + catch (IOException busy) { writeError(writer, busy.getMessage()); continue; } + try (guard) { + if ("list".equals(type)) { + writeList(writer, clientLang); + } else if ("remote_list".equals(type)) { + handleRemoteList(writer, clientLang); + } else if ("remote_download_start".equals(type)) { + handleRemoteDownloadStart(request, writer, clientLang); + } else if ("remote_download_status".equals(type)) { + handleRemoteDownloadStatus(writer, clientLang); + } else if ("remote_download_cancel".equals(type)) { + handleRemoteDownloadCancel(writer); + } else if ("remote_download".equals(type)) { + handleRemoteDownload(writer); + } else if ("required_list".equals(type)) { + handleRequiredList(writer, clientLang); + } else if ("required_download_start".equals(type)) { + handleRequiredDownloadStart(request, writer, clientLang); + } else if ("required_download_status".equals(type)) { + handleRequiredDownloadStatus(writer, clientLang); + } else if ("required_download_cancel".equals(type)) { + handleRequiredDownloadCancel(writer); + } else if ("required_uninstall".equals(type)) { + handleRequiredUninstall(request, writer); + } else if ("required_apply".equals(type)) { + handleRequiredApply(request, writer); + } else if ("update_state".equals(type)) { + writeUpdateState(writer); + } else if ("update_list".equals(type)) { + writeUpdateList(writer); + } else if ("update_apply".equals(type)) { + handleUpdateApply(request, writer); + } else if ("update_rollback".equals(type)) { + handleUpdateRollback(request, writer); + } else if ("update_import".equals(type)) { + handleUpdateImport(request, writer); + } else if ("update_import_url".equals(type)) { + handleUpdateImportUrl(request, writer); + } else if ("pack_list".equals(type)) { + handlePackList(writer, clientLang); + } else if ("pack_install".equals(type)) { + handlePackInstall(request, writer); + } else if ("pack_uninstall".equals(type)) { + handlePackUninstall(request, writer); + } else if ("pack_delete".equals(type)) { + handlePackDelete(request, writer); + } else if ("pack_import".equals(type)) { + handlePackImport(request, writer); + } else if ("pack_import_url".equals(type)) { + handlePackImportUrl(request, writer); + } else if ("pack_order".equals(type)) { + handlePackOrder(request, writer); + } else if ("pack_apply_start".equals(type)) { + handlePackApplyStart(request, writer); + } else if ("pack_apply_status".equals(type)) { + handlePackApplyStatus(writer); + } else if ("pack_apply_pause".equals(type)) { + handlePackApplyPause(writer); + } else if ("pack_apply_resume".equals(type)) { + handlePackApplyResume(writer); + } else if ("pack_apply_cancel".equals(type)) { + handlePackApplyCancel(writer); + } else if ("set".equals(type)) { + handleSet(request, overrides, writer); + } else if ("apply".equals(type)) { + handleApply(overrides, writer); + overrides.clear(); + } else if ("push_init".equals(type)) { + pushState = handlePushInit(request, writer); + } else if ("push_chunk".equals(type)) { + handlePushChunk(request, pushState, writer); + } else if ("push_finish".equals(type)) { + handlePushFinish(pushState, writer); + pushState = null; + } else if ("file_list".equals(type)) { + handleFileList(request, writer); + } else if ("file_delete".equals(type)) { + handleFileDelete(request, writer); + } else if ("file_upload_init".equals(type)) { + uploadState = handleFileUploadInit(request, writer); + } else if ("file_upload_chunk".equals(type)) { + handleFileUploadChunk(request, uploadState, writer); + } else if ("file_upload_finish".equals(type)) { + handleFileUploadFinish(uploadState, writer); + uploadState = null; + } else if ("file_download_init".equals(type)) { + handleFileDownload(request, writer); + } else if ("config_get".equals(type)) { + handleConfigGet(writer); + } else if ("config_set".equals(type)) { + handleConfigSet(request, writer); + } else if ("rcon_info".equals(type)) { + handleRconInfo(writer); + } else if ("rcon_get".equals(type)) { + handleRconGet(writer); + } else if ("rcon_set".equals(type)) { + handleRconSet(request, writer); + } else if ("rcon_exec".equals(type)) { + handleRconExec(request, writer); + } else if ("server_restart".equals(type)) { + handleServerRestart(writer); + } else { + writeError(writer, "unknown request"); + } } } } catch (IOException ignored) { } } + private static void handleSync(com.cozooo.dlc_manager.sync.SyncEndpoint sync, JsonObject request, BufferedWriter writer) throws IOException { + Thread actor = Thread.currentThread(); + Object gate = new Object(); + boolean[] finished = {false}; + java.util.concurrent.ScheduledFuture heartbeat = null; + String action = request.has("action") ? request.get("action").getAsString() : ""; + if (Set.of("prepare", "read", "read_end", "write_end", "apply", "rollback", "recover", "recover_operation").contains(action)) { + heartbeat = SYNC_HEARTBEATS.scheduleWithFixedDelay(() -> { + JsonObject progress = new JsonObject(); progress.addProperty("type", "dlc_sync_progress"); + if (request.has("requestId")) progress.add("requestId", request.get("requestId")); + try { synchronized (writer) { writeLine(writer, progress); } } + catch (IOException disconnected) { synchronized (gate) { if (!finished[0]) { sync.disconnectCurrent(); actor.interrupt(); } } } + }, 1, 1, java.util.concurrent.TimeUnit.SECONDS); + } + JsonObject response; + try { response = sync.call(request); } + finally { synchronized (gate) { finished[0] = true; } if (heartbeat != null) heartbeat.cancel(false); } + synchronized (writer) { writeLine(writer, response); } + } + private static boolean handleHello(JsonObject request, BufferedWriter writer, String remoteAddress) throws IOException { String expected = DlcConfig.getServerPassword(); String provided = request.has("password") ? request.get("password").getAsString() : ""; diff --git a/src/main/java/com/cozooo/dlc_manager/sync/SyncArchive.java b/src/main/java/com/cozooo/dlc_manager/sync/SyncArchive.java new file mode 100644 index 00000000..4a1db865 --- /dev/null +++ b/src/main/java/com/cozooo/dlc_manager/sync/SyncArchive.java @@ -0,0 +1,120 @@ +package com.cozooo.dlc_manager.sync; + +import com.cozooo.dlc_manager.core.DlcFileMatch; +import com.cozooo.dlc_manager.core.DlcResourceHistory; +import java.io.*; +import java.nio.file.*; +import java.util.function.BooleanSupplier; +import java.util.zip.*; + +public final class SyncArchive { + private SyncArchive() {} + public static long size(Path path) throws IOException { + if (!Files.isDirectory(path)) return Files.size(path); + long size = 0; + try (var paths = Files.walk(path)) { + for (Path child : paths.toList()) { + check(() -> false); + if (Files.isSymbolicLink(child)) throw new SyncFailure("symbolic_resource"); + if (Files.isRegularFile(child)) size = Math.addExact(size, Files.size(child)); + } + } + return size; + } + public static java.util.List inventory(Path root, BooleanSupplier canceled) throws IOException { + java.util.List result = new java.util.ArrayList<>(); + if (!Files.isDirectory(root)) return result; + try (var paths = Files.walk(root)) { + for (Path path : paths.sorted().toList()) { + check(canceled); + if (Files.isSymbolicLink(path)) throw new SyncFailure("symbolic_resource"); + if (Files.isRegularFile(path)) result.add(new SyncModel.File(root.relativize(path).toString().replace('\\', '/'), Files.size(path), DlcFileMatch.hash(path))); + } + } + return result; + } + public static java.util.List reusable(Path root, java.util.List existing, BooleanSupplier canceled) throws IOException { + java.util.Map files = new java.util.HashMap<>(); + for (SyncModel.File file : existing) files.put(file.id(), file); + return inventory(root, canceled).stream().filter(file -> SyncModel.same(file, files.get(file.id()))).toList(); + } + private static Path child(Path root, String name) throws IOException { + if (name.isBlank() || name.contains("\\") || name.contains(":") || name.startsWith("/") + || java.util.Arrays.stream(name.split("/", -1)).anyMatch(part -> part.isEmpty() || part.equals(".") || part.equals(".."))) throw new SyncFailure("invalid_reuse_path"); + Path path = root.resolve(name).normalize(); + if (!path.startsWith(root) || path.equals(root)) throw new SyncFailure("resource_escape"); + for (Path part = path; part != null && part.startsWith(root); part = part.getParent()) if (Files.isSymbolicLink(part)) throw new SyncFailure("symbolic_resource"); + return path; + } + public static void pack(Path source, Path archive, BooleanSupplier canceled) throws IOException { pack(source, archive, canceled, java.util.List.of()); } + public static void pack(Path source, Path archive, BooleanSupplier canceled, java.util.List reused) throws IOException { + java.util.Set omitted = new java.util.HashSet<>(); + for (SyncModel.File file : reused) omitted.add(file.id()); + try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(archive)); var paths = Files.walk(source)) { + output.setLevel(0); + byte[] buffer = new byte[65536]; + for (Path child : paths.sorted().toList()) { + check(canceled); + if (child.equals(source)) continue; + if (Files.isSymbolicLink(child)) throw new SyncFailure("symbolic_resource"); + String relative = source.relativize(child).toString().replace('\\', '/'); + if (Files.isRegularFile(child) && omitted.contains(relative)) continue; + output.putNextEntry(new ZipEntry(relative + (Files.isDirectory(child) ? "/" : ""))); + if (Files.isRegularFile(child)) try (InputStream input = Files.newInputStream(child)) { + int count; + while ((count = input.read(buffer)) != -1) { check(canceled); output.write(buffer, 0, count); } + } + output.closeEntry(); + } + } + } + public static void install(Path archive, Path target, long size, String hash, SyncModel.Journal journal, BooleanSupplier canceled) throws IOException { + install(archive, target, size, hash, journal, canceled, java.util.List.of()); + } + public static void install(Path archive, Path target, long size, String hash, SyncModel.Journal journal, BooleanSupplier canceled, java.util.List reused) throws IOException { + if (!Files.exists(target.getParent())) journal.capture(target); + Files.createDirectories(target.getParent()); + Path staging = Files.createTempDirectory(target.getParent(), ".dlc-sync-"); + try { + long received = 0; + for (SyncModel.File file : reused) { + check(canceled); + Path source = child(target, file.id()), destination = child(staging, file.id()); + if (!Files.isRegularFile(source) || Files.size(source) != file.size() || !DlcFileMatch.fullHash(source).equals(file.hash())) throw new SyncFailure("reused_changed", file.id()); + Files.createDirectories(destination.getParent()); Files.copy(source, destination); + received = Math.addExact(received, file.size()); + if (received > size) throw new SyncFailure("archive_size"); + } + try (ZipInputStream input = new ZipInputStream(Files.newInputStream(archive))) { + ZipEntry entry; + byte[] buffer = new byte[65536]; + while ((entry = input.getNextEntry()) != null) { + check(canceled); + String name = entry.getName(); + if (name.contains("\\") || name.contains(":") || name.startsWith("/")) throw new SyncFailure("invalid_archive_path"); + Path path = staging.resolve(name).normalize(); + if (!path.startsWith(staging) || path.equals(staging)) throw new SyncFailure("archive_escape"); + if (entry.isDirectory()) Files.createDirectories(path); + else { + Files.createDirectories(path.getParent()); + try (OutputStream output = Files.newOutputStream(path, StandardOpenOption.CREATE_NEW)) { + int count; + while ((count = input.read(buffer)) != -1) { + check(canceled); received = Math.addExact(received, count); + if (received > size) throw new SyncFailure("archive_size"); + output.write(buffer, 0, count); + } + } + } + } + } + if (received != size || !DlcFileMatch.fullHash(staging).equals(hash)) throw new SyncFailure("directory_integrity"); + check(canceled); journal.capture(target); DlcResourceHistory.delete(target); + try { Files.move(staging, target, StandardCopyOption.ATOMIC_MOVE); } + catch (AtomicMoveNotSupportedException error) { Files.move(staging, target); } + } finally { DlcResourceHistory.delete(staging); } + } + private static void check(BooleanSupplier canceled) throws IOException { + if (canceled.getAsBoolean() || Thread.currentThread().isInterrupted()) throw new SyncFailure("canceled"); + } +} diff --git a/src/main/java/com/cozooo/dlc_manager/sync/SyncClient.java b/src/main/java/com/cozooo/dlc_manager/sync/SyncClient.java new file mode 100644 index 00000000..fd7dfbe7 --- /dev/null +++ b/src/main/java/com/cozooo/dlc_manager/sync/SyncClient.java @@ -0,0 +1,209 @@ +package com.cozooo.dlc_manager.sync; + +import com.google.gson.*; +import java.io.IOException; +import java.util.*; +import java.util.function.BooleanSupplier; +import static com.cozooo.dlc_manager.sync.SyncModel.*; + +public final class SyncClient { + private final Peer local, remote, control; + private final String localId = UUID.randomUUID().toString(), remoteId = UUID.randomUUID().toString(); + private volatile boolean canceled, sealed, recoveryPending, preparing, rollbackAvailable; + private void prepare(Peer peer, boolean force) throws IOException { + preparing = true; + try { JsonObject r = request("prepare"); r.addProperty("force", force); checked(peer, r); } + finally { preparing = false; } + } + private void poll(Peer peer, String operation, Progress progress) { + try { + JsonObject r = request("status"); r.addProperty("operation", operation); + JsonObject response = checked(peer, r); + if (preparing && !canceled && response.has("progress")) { + JsonObject value = response.getAsJsonObject("progress"); + if (value.has("phase") && "downloading".equals(value.get("phase").getAsString())) progress.update("downloading", value.get("resource").getAsString(), value.get("done").getAsLong(), value.get("total").getAsLong()); + } + } catch (IOException ignored) {} + } + public boolean canRollback() { return rollbackAvailable; } + public void rollback(Peer local, Peer remote) throws IOException { + recoverPeer(remote, remoteId); recoverPeer(local, localId); rollbackAvailable = false; recoveryPending = false; + } + public boolean needsRecovery() { return recoveryPending; } + public void recover() throws IOException { + rollback(local, control); + } + private void recoverPeer(Peer peer, String operation) throws IOException { + IOException failure = null; + for (int attempt = 0; attempt < 20; attempt++) { + try { JsonObject request = request("recover_operation"); request.addProperty("operation", operation); checked(peer, request); return; } + catch (IOException error) { if (!SyncFailure.is(error, "active")) throw error; failure = error; } + try { Thread.sleep(100); } catch (InterruptedException error) { Thread.currentThread().interrupt(); throw new SyncFailure("recovery_interrupted").causedBy(error); } + } + throw failure; + } + public SyncClient(Peer local, Peer remote, Peer control) { this.local = local; this.remote = remote; this.control = control; } + public boolean cancel() { + synchronized (this) { if (sealed) return false; canceled = true; } + SyncEndpoint.cancel(localId); + Thread thread = new Thread(() -> { + try { JsonObject request = request("cancel"); request.addProperty("operation", remoteId); checked(control, request); } + catch (IOException failure) { try { remote.close(); } catch (IOException ignored) {} } + }, "dlc-sync-cancel"); + thread.setDaemon(true); thread.start(); + return true; + } + public static List manifest(Peer peer) throws IOException { return manifest(peer, () -> false); } + public static List manifest(Peer peer, BooleanSupplier canceled) throws IOException { + checked(peer, request("manifest_start")); + while (true) { + if (canceled.getAsBoolean()) throw new SyncFailure("canceled"); + JsonObject response = checked(peer, request("manifest_poll")); + if (response.get("ready").getAsBoolean()) return Arrays.asList(SyncEndpoint.JSON.fromJson(response.get("items"), Item[].class)); + try { Thread.sleep(50); } catch (InterruptedException error) { Thread.currentThread().interrupt(); throw new SyncFailure("comparison_interrupted").causedBy(error); } + } + } + private List readManifest(Peer peer) throws IOException { return manifest(peer, () -> canceled); } + public Result execute(List rows, Mode mode, boolean states, Progress progress) throws IOException { + java.util.concurrent.ScheduledExecutorService monitor = java.util.concurrent.Executors.newSingleThreadScheduledExecutor(task -> { Thread thread = new Thread(task, "dlc-sync-progress"); thread.setDaemon(true); return thread; }); + monitor.scheduleWithFixedDelay(() -> { if (preparing) { poll(local, localId, progress); poll(control, remoteId, progress); } }, 200, 400, java.util.concurrent.TimeUnit.MILLISECONDS); + List errors = new ArrayList<>(), succeeded = new ArrayList<>(); + int count = 0; boolean restart = false, localStarted = false, remoteStarted = false; + try { + check(); + rollbackAvailable = true; localStarted = true; begin(local, localId); + remoteStarted = true; begin(remote, remoteId); + Map freshLocal = index(readManifest(local)), freshRemote = index(readManifest(remote)); + for (Row row : rows) { + Item expectedLocal = row.direction() == Direction.PUSH ? row.source() : row.target(); + Item expectedRemote = row.direction() == Direction.PUSH ? row.target() : row.source(); + if (!Objects.equals(expectedLocal, freshLocal.get(row.key())) || !Objects.equals(expectedRemote, freshRemote.get(row.key()))) throw new SyncFailure("changed", row.key()); + } + for (Row row : ordered(rows)) { + check(); + Peer source = row.direction() == Direction.PUSH ? local : remote; + Peer target = row.direction() == Direction.PUSH ? remote : local; + Item current = inspect(source, row.key()); + if (current == null || !same(row.source().config(), current.config())) throw new SyncFailure("config_changed", row.key()); + boolean contentNeeded = row.target() == null || !same(row.source().config(), row.target().config()) + || mode == Mode.FILES && usesResources(row.source()) && !resourcesMatch(row.source().resources(), row.target().resources(), mode); + boolean applied = false, itemRestart = false; + if (contentNeeded) { + try { + start(local, row.key()); start(remote, row.key()); + progress.update("preparing", row.key(), count, rows.size()); + if (mode == Mode.FILES) prepare(source, false); + check(); + current = inspect(source, row.key()); + if (current == null) throw new SyncFailure("config_missing"); + if (category(current) == Category.UPDATE && mode != Mode.FILES) throw new SyncFailure("update_mode"); + JsonObject validation = request("validate"); validation.add("item", SyncEndpoint.JSON.toJsonTree(current)); checked(target, validation); + Item destinationBeforeWrite = row.target() == null ? null : inspect(target, row.key()); + boolean configChanged = destinationBeforeWrite == null || !same(current.config(), destinationBeforeWrite.config()); + if (category(current) == Category.MANAGER_CONFIG) { + if (current.settings() == null) throw new SyncFailure("settings_missing"); + JsonObject settings = request("settings"); settings.add("settings", current.settings()); checked(target, settings); + } else { + if (configChanged) transfer(source, target, current.config(), progress); + if (mode == Mode.FILES) { + Item destination = inspect(target, row.key()); + Map files = new HashMap<>(); + if (destination != null) for (File file : destination.resources()) files.put(file.id(), file); + for (File file : current.resources()) { check(); if (!same(file, files.get(file.id()))) transfer(source, target, file, progress); } + } else { prepare(target, configChanged); current = inspect(target, row.key()); } + } + check(); progress.update("applying", row.key(), count, rows.size()); + JsonObject apply = request("apply"); + if (category(current) != Category.PRESET && row.target() != null && row.target().enabled() != null) apply.addProperty("enabled", row.target().enabled()); + itemRestart = checked(target, apply).get("requiresRestart").getAsBoolean(); + check(); Item result = inspect(target, row.key()); + if (result == null || !same(current.config(), result.config()) || mode == Mode.FILES && usesResources(current) && !sameResources(current.resources(), result.resources())) throw new SyncFailure("verification", row.key()); + applied = true; + } catch (IOException | RuntimeException error) { + if (canceled) throw new SyncFailure("canceled").causedBy(error); + checked(target, request("item_keep")); checked(source, request("item_keep")); + errors.add(row.key() + ": " + error.getMessage()); + } + if (!applied) continue; + checked(target, request("item_done")); checked(source, request("item_done")); + } + if (states && sharedState(row.source(), row.target()) && !Objects.equals(row.source().enabled(), row.target().enabled())) { + boolean stateApplied = false; + try { + check(); start(target, row.key()); + progress.update("applying", row.key(), count, rows.size()); + JsonObject apply = request("apply"); apply.addProperty("enabled", row.source().enabled()); + boolean stateRestart = checked(target, apply).get("requiresRestart").getAsBoolean(); + check(); stateApplied = true; itemRestart |= stateRestart; + } catch (IOException | RuntimeException error) { + if (canceled) throw new SyncFailure("canceled").causedBy(error); + checked(target, request("item_keep")); + } + if (stateApplied) { checked(target, request("item_done")); applied = true; } + } + if (applied) { count++; succeeded.add(row.key()); restart |= itemRestart; } + } + synchronized (this) { check(); sealed = true; } + checked(remote, request("commit")); + checked(local, request("commit")); remoteStarted = false; localStarted = false; + return new Result(count, false, restart, errors, List.copyOf(succeeded)); + } catch (IOException | RuntimeException executionFailure) { + IOException error = executionFailure instanceof IOException io ? io : new SyncFailure("invalid_response").causedBy(executionFailure); + if (!canceled) { + if (remoteStarted) try { checked(remote, request("retain")); } catch (IOException failure) { error.addSuppressed(failure); } + if (localStarted) try { checked(local, request("retain")); } catch (IOException failure) { error.addSuppressed(failure); } + throw error; + } + progress.update("restoring", "", 0, rows.size()); + IOException recovery = null; + if (remoteStarted) try { checked(remote, request("rollback")); } catch (IOException failure) { try { recoverPeer(control, remoteId); } catch (IOException unresolved) { recovery = unresolved; } } + if (localStarted) try { checked(local, request("rollback")); } catch (IOException failure) { if (recovery == null) recovery = failure; else recovery.addSuppressed(failure); } + if (recovery != null) { recoveryPending = true; error.addSuppressed(recovery); throw new SyncFailure("recovery_incomplete", recovery.getMessage()).causedBy(error); } + if (canceled) { rollbackAvailable = false; return new Result(0, true, false, errors, List.of()); } + throw error; + } finally { sealed = true; preparing = false; monitor.shutdownNow(); } + } + private void transfer(Peer source, Peer target, File file, Progress progress) throws IOException { + check(); + JsonObject inventory = request("inventory"); inventory.addProperty("id", file.id()); + JsonObject read = request("read"); read.addProperty("id", file.id()); read.add("existing", checked(target, inventory).get("files")); + JsonObject opened = checked(source, read); + long size = opened.get("size").getAsLong(); + long resourceSize = opened.get("resourceSize").getAsLong(); + String contentHash = opened.get("contentHash").getAsString(); + if (resourceSize != file.size() || !Objects.equals(contentHash, file.hash())) throw new SyncFailure("source_changed", file.id()); + JsonObject write = request("write"); + write.add("file", SyncEndpoint.JSON.toJsonTree(new File(file.id(), size, file.hash()))); + write.addProperty("hash", opened.get("hash").getAsString()); + if (opened.has("reused")) write.add("reused", opened.get("reused")); + write.add("directory", opened.get("directory")); write.addProperty("resourceSize", resourceSize); checked(target, write); + for (long offset = 0; offset < size;) { + check(); + JsonObject chunk = request("chunk"); chunk.addProperty("id", file.id()); chunk.addProperty("offset", offset); + String data = checked(source, chunk).get("data").getAsString(); + int bytes = Base64.getDecoder().decode(data).length; + if (bytes == 0) throw new SyncFailure("truncated", file.id()); + JsonObject upload = request("write_chunk"); upload.addProperty("data", data); checked(target, upload); + offset += bytes; progress.update("transferring", file.id(), offset, size); + } + JsonObject finish = request("read_end"); finish.addProperty("id", file.id()); checked(source, finish); + check(); checked(target, request("write_end")); + } + private Item inspect(Peer peer, String key) throws IOException { + JsonObject request = request("inspect"); request.addProperty("key", key); + Item value = SyncEndpoint.JSON.fromJson(checked(peer, request).get("item"), Item.class); + if (value == null) throw new SyncFailure("config_missing"); + return value; + } + private void check() throws IOException { if (canceled) throw new SyncFailure("canceled"); } + private static void begin(Peer peer, String id) throws IOException { JsonObject r = request("begin"); r.addProperty("operation", id); checked(peer, r); } + private static void start(Peer peer, String key) throws IOException { JsonObject r = request("item"); r.addProperty("key", key); checked(peer, r); } + public static JsonObject request(String action) { JsonObject r = new JsonObject(); r.addProperty("action", action); return r; } + public static JsonObject checked(Peer peer, JsonObject request) throws IOException { + try { + JsonObject result = peer.call(request); + if (result == null || !result.has("ok") || !result.get("ok").getAsBoolean()) throw SyncFailure.read(result); + return result; + } catch (RuntimeException error) { throw new SyncFailure("invalid_response").causedBy(error); } + } +} diff --git a/src/main/java/com/cozooo/dlc_manager/sync/SyncEndpoint.java b/src/main/java/com/cozooo/dlc_manager/sync/SyncEndpoint.java new file mode 100644 index 00000000..afbba87a --- /dev/null +++ b/src/main/java/com/cozooo/dlc_manager/sync/SyncEndpoint.java @@ -0,0 +1,298 @@ +package com.cozooo.dlc_manager.sync; + +import com.cozooo.dlc_manager.core.DlcFileMatch; +import com.google.gson.*; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import static com.cozooo.dlc_manager.sync.SyncModel.*; + +public final class SyncEndpoint implements Peer, AutoCloseable { + public static final Gson JSON = new Gson(); + private static final Map OWNERS = new ConcurrentHashMap<>(); + private static final Map LOCKS = new ConcurrentHashMap<>(); + private java.util.concurrent.locks.ReentrantReadWriteLock.WriteLock writeLock; + private static final Map OPERATIONS = new ConcurrentHashMap<>(); + private final Adapter adapter; + private final List completed = new ArrayList<>(); + private final Map reads = new HashMap<>(); + private final Map readSources = new HashMap<>(); + private final Map readHashes = new HashMap<>(); + private Journal current; + private String key, operation; + private Path upload; + private File uploading; + private String uploadHash; + private boolean uploadDirectory; + private List uploadReused = List.of(); + private long resourceSize; + private long received; + private volatile boolean canceled, disconnected; + private Thread validation; + private volatile boolean validationDone; + private volatile List validated; + private volatile Exception validationFailure; + private void startValidation() throws IOException { + stopValidation(); validationDone = false; validated = null; validationFailure = null; + validation = new Thread(() -> { + try { + if (operation != null) validated = adapter.manifest(); + else try (OperationGuard ignored = guard(adapter.workDirectory())) { validated = adapter.manifest(); } + } catch (Exception error) { validationFailure = error; } + finally { validationDone = true; } + }, "dlc-sync-validation"); + validation.setDaemon(true); validation.start(); + } + private void stopValidation() throws IOException { + if (validation == null) return; + if (validation.isAlive()) { + validation.interrupt(); + try { validation.join(5000); } catch (InterruptedException error) { Thread.currentThread().interrupt(); throw new SyncFailure("validation_interrupted").causedBy(error); } + if (validation.isAlive()) throw new SyncFailure("validation_stop"); + } + validation = null; + } + private volatile JsonObject progress = new JsonObject(); + private void report(String phase, String resource, long done, long total) { + JsonObject value = new JsonObject(); value.addProperty("phase", phase); value.addProperty("resource", resource); + value.addProperty("done", done); value.addProperty("total", total); progress = value; + } + + public SyncEndpoint(Adapter adapter) { this.adapter = adapter; } + public static final class OperationGuard implements AutoCloseable { + private final java.util.concurrent.locks.Lock lock; + private OperationGuard(java.util.concurrent.locks.Lock lock) { this.lock = lock; } + @Override public void close() { lock.unlock(); } + } + public static OperationGuard guard(Path directory) throws IOException { + Path key = directory.toAbsolutePath().normalize(); + java.util.concurrent.locks.Lock lock = LOCKS.computeIfAbsent(key, ignored -> new java.util.concurrent.locks.ReentrantReadWriteLock(true)).readLock(); + if (busy(key) || !lock.tryLock()) throw new SyncFailure("busy"); + if (busy(key)) { lock.unlock(); throw new SyncFailure("busy"); } + return new OperationGuard(lock); + } + public static boolean busy(Path directory) { return OWNERS.containsKey(directory.toAbsolutePath().normalize()); } + public void disconnectCurrent() { disconnected = true; } + public static void cancel(String id) { + SyncEndpoint endpoint = OPERATIONS.get(id); + if (endpoint != null) endpoint.canceled = true; + } + public JsonObject call(JsonObject request) throws IOException { + JsonObject result = new JsonObject(); + result.addProperty("type", "dlc_sync"); + if (request.has("requestId")) result.add("requestId", request.get("requestId")); + try { + String action = request.get("action").getAsString(); + if (action.equals("status")) { + SyncEndpoint endpoint = OPERATIONS.get(request.get("operation").getAsString()); + if (endpoint != null) result.add("progress", endpoint.progress.deepCopy()); + } + else if (action.equals("cancel")) { cancel(request.get("operation").getAsString()); } + else if (action.equals("recover_operation")) { exclusiveRecovery(request.get("operation").getAsString()); } + else if (action.equals("recover")) { exclusiveRecovery(null); } + else if (action.equals("inspect")) { String selected = request.get("key").getAsString(); adapter.validateKey(selected); result.add("item", JSON.toJsonTree(adapter.item(selected))); } + else if (action.equals("manifest_start")) startValidation(); + else if (action.equals("manifest_poll")) { + if (validation == null) throw new SyncFailure("comparison_not_started"); + result.addProperty("ready", validationDone); + if (validationDone) { + if (validationFailure != null) throw validationFailure; + result.add("items", JSON.toJsonTree(validated)); + } + } + else if (action.equals("begin")) begin(request.get("operation").getAsString()); + else if (action.equals("rollback")) rollback(); + else if (action.equals("retain")) retain(); + else if (action.equals("commit")) { check(); if (current != null) throw new SyncFailure("unfinished_item"); retain(); } + else { + check(); + switch (action) { + case "validate" -> { + requireItem(); Item incoming = JSON.fromJson(request.get("item"), Item.class); + if (incoming == null || !key.equals(incoming.key())) throw new SyncFailure("invalid_item"); + adapter.validateIncoming(incoming); + } + case "settings" -> { requireItem(); adapter.writeSettings(key, request.getAsJsonObject("settings"), current); } + case "item" -> start(request.get("key").getAsString()); + case "prepare" -> { requireItem(); report("downloading", key, 0, -1); adapter.prepare(key, request.has("force") && request.get("force").getAsBoolean(), () -> canceled || disconnected, this::report); report("preparing", key, 0, -1); check(); result.add("item", JSON.toJsonTree(item())); } + case "inventory" -> { result.add("files", JSON.toJsonTree(SyncArchive.inventory(allowed(request.get("id").getAsString()), () -> canceled || disconnected))); } + case "read" -> { + requireItem(); + String id = request.get("id").getAsString(); + Path path = allowed(id); + if (!Files.isRegularFile(path) && !Files.isDirectory(path)) throw new SyncFailure("resource_missing", id); + if (reads.containsKey(id)) throw new SyncFailure("read_open"); + String hash = DlcFileMatch.hash(path); + readSources.put(id, path); readHashes.put(id, hash); + Path wire = path; + boolean directory = Files.isDirectory(path); + if (directory) { + wire = Files.createTempFile(adapter.workDirectory(), "outgoing-", ".zip"); + List existing = request.has("existing") ? Arrays.asList(JSON.fromJson(request.get("existing"), File[].class)) : List.of(); + List reused = SyncArchive.reusable(path, existing, () -> canceled || disconnected); + result.add("reused", JSON.toJsonTree(reused)); + reads.put(id, wire); SyncArchive.pack(path, wire, () -> canceled || disconnected, reused); + } else reads.put(id, wire); + result.addProperty("resourceSize", SyncArchive.size(path)); result.addProperty("contentHash", hash); + result.addProperty("directory", directory); + result.addProperty("size", Files.size(wire)); result.addProperty("hash", directory ? DlcFileMatch.fullHash(wire) : hash); + } + case "chunk" -> { + String id = request.get("id").getAsString(); + Path path = reads.get(id); + if (path == null) throw new SyncFailure("read_not_open"); + long offset = request.get("offset").getAsLong(); + if (offset < 0 || offset > Files.size(path)) throw new SyncFailure("invalid_offset"); + byte[] bytes = new byte[(int)Math.min(1048576L, Files.size(path) - offset)]; + try (RandomAccessFile file = new RandomAccessFile(path.toFile(), "r")) { file.seek(offset); file.readFully(bytes); } + result.addProperty("data", Base64.getEncoder().encodeToString(bytes)); + } + case "read_end" -> { + String id = request.get("id").getAsString(); + Path source = readSources.remove(id), path = reads.remove(id); + try { if (source == null || !DlcFileMatch.fullHash(source).equals(readHashes.remove(id))) throw new SyncFailure("source_changed_transfer", id); } + finally { if (path != null && !path.equals(source)) Files.deleteIfExists(path); } + } + case "write" -> { + requireItem(); + if (upload != null) throw new SyncFailure("unfinished_upload"); + uploading = JSON.fromJson(request.get("file"), File.class); + allowed(uploading.id()); + if (uploading.size() < 0) throw new SyncFailure("invalid_size"); + uploadHash = request.get("hash").getAsString(); + uploadDirectory = request.has("directory") && request.get("directory").getAsBoolean(); + uploadReused = uploadDirectory && request.has("reused") ? Arrays.asList(JSON.fromJson(request.get("reused"), File[].class)) : List.of(); + resourceSize = request.has("resourceSize") ? request.get("resourceSize").getAsLong() : uploading.size(); + if (resourceSize < 0 || uploadDirectory && uploading.id().equals(key)) throw new SyncFailure("invalid_directory"); + upload = Files.createTempFile(adapter.workDirectory(), "incoming-", ".part"); received = 0; + } + case "write_chunk" -> { + if (upload == null) throw new SyncFailure("no_upload"); + byte[] bytes = Base64.getDecoder().decode(request.get("data").getAsString()); + if (bytes.length > 1048576 || received + bytes.length > uploading.size()) throw new SyncFailure("resource_size"); + Files.write(upload, bytes, StandardOpenOption.APPEND); received += bytes.length; + } + case "write_end" -> { + if (upload == null || received != uploading.size() || !DlcFileMatch.fullHash(upload).equals(uploadHash)) throw new SyncFailure("resource_integrity"); + Path target = allowed(uploading.id()); + if (uploadDirectory) { + SyncArchive.install(upload, target, resourceSize, uploading.hash(), current, () -> canceled || disconnected, uploadReused); + Files.deleteIfExists(upload); upload = null; adapter.reload(); break; + } + current.capture(target); + Files.createDirectories(target.getParent()); + try { Files.move(upload, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } + catch (AtomicMoveNotSupportedException error) { Files.move(upload, target, StandardCopyOption.REPLACE_EXISTING); } + upload = null; adapter.reload(); + } + case "apply" -> { + requireItem(); + Boolean enabled = request.has("enabled") ? request.get("enabled").getAsBoolean() : null; + boolean restart = adapter.apply(key, enabled, current, () -> canceled || disconnected); check(); + result.addProperty("requiresRestart", restart); + } + case "item_done" -> { requireItem(); completed.add(current); current.close(); current = null; key = null; } + case "item_rollback" -> rollbackItem(); + case "item_keep" -> keepItem(); + default -> throw new SyncFailure("unsupported_action"); + } + } + result.addProperty("ok", true); + } catch (Exception error) { + if (error instanceof InterruptedException) Thread.currentThread().interrupt(); + result.addProperty("ok", false); + result.addProperty("reason", error.getMessage() == null ? error.getClass().getSimpleName() : error.getMessage()); + if (error instanceof SyncFailure failure) failure.write(result); + } + return result; + } + private void begin(String id) throws Exception { + if (operation != null) throw new SyncFailure("already_active"); + UUID.fromString(id); + Path directory = adapter.workDirectory().toAbsolutePath().normalize(); + if (OWNERS.putIfAbsent(directory, this) != null) throw new SyncFailure("other_active"); + operation = id; OPERATIONS.put(id, this); + try { acquire(directory); Files.createDirectories(directory); adapter.quiesce(() -> canceled || disconnected); } + catch (Exception error) { release(); throw error; } + } + private void acquire(Path directory) throws Exception { + writeLock = LOCKS.computeIfAbsent(directory, ignored -> new java.util.concurrent.locks.ReentrantReadWriteLock(true)).writeLock(); + long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(30); + while (!writeLock.tryLock(100, java.util.concurrent.TimeUnit.MILLISECONDS)) { + if (canceled || disconnected || System.nanoTime() > deadline) { writeLock = null; throw new SyncFailure("other_operation"); } + } + } + private void exclusiveRecovery(String operation) throws Exception { + Path directory = adapter.workDirectory().toAbsolutePath().normalize(); + if (OWNERS.putIfAbsent(directory, this) != null) throw new SyncFailure("active"); + try { acquire(directory); adapter.quiesce(() -> false); if (operation == null) adapter.recover(); else adapter.recoverOperation(operation); } + finally { release(); } + } + private void start(String id) throws IOException { + if (current != null) throw new SyncFailure("previous_unfinished"); + adapter.validateKey(id); + key = id; + current = adapter.journal(adapter.workDirectory().resolve(operation).resolve(String.format(java.util.Locale.ROOT, "%08d-", completed.size()) + UUID.randomUUID()).resolve("journal")); + current.capture(adapter.resolve(id)); + } + private Item item() throws IOException { return adapter.item(key); } + private Path allowed(String id) throws IOException { + requireItem(); + if (key.startsWith("manager:")) throw new SyncFailure("settings_fields"); + if (id.equals(key)) return adapter.resolve(id); + for (File file : item().resources()) if (file.id().equals(id)) return adapter.resolve(id); + throw new SyncFailure("outside_item", id); + } + private void requireItem() throws IOException { if (current == null) throw new SyncFailure("no_item"); } + private void check() throws IOException { + if (operation == null) throw new SyncFailure("not_started"); + if (canceled) throw new SyncFailure("canceled"); + if (disconnected) throw new SyncFailure("connection_lost"); + } + private void cleanupTransfers() throws IOException { + stopValidation(); + if (upload != null) { Files.deleteIfExists(upload); upload = null; } + for (var read : reads.entrySet()) if (!read.getValue().equals(readSources.get(read.getKey()))) Files.deleteIfExists(read.getValue()); + reads.clear(); readHashes.clear(); readSources.clear(); + } + private void keepItem() throws IOException { + cleanupTransfers(); + if (current != null) { current.close(); completed.add(current); current = null; key = null; } + } + private void retain() throws IOException { + keepItem(); + for (Journal journal : completed) journal.commit(); + completed.clear(); release(); + } + private void rollbackItem() throws IOException { + cleanupTransfers(); + if (current != null) { + current.close(); current.rollback(); current = null; key = null; + } + adapter.reload(); + } + private void rollback() throws IOException { + IOException failure = null; + try { rollbackItem(); } catch (IOException error) { failure = error; } + for (int index = completed.size() - 1; index >= 0; index--) { + try { completed.get(index).rollback(); } + catch (IOException error) { if (failure == null) failure = error; else failure.addSuppressed(error); } + } + try { adapter.reload(); } catch (IOException error) { if (failure == null) failure = error; else failure.addSuppressed(error); } + if (failure != null) throw failure; + completed.clear(); release(); + } + private void release() { + if (operation != null) OPERATIONS.remove(operation, this); + OWNERS.remove(adapter.workDirectory().toAbsolutePath().normalize(), this); operation = null; + if (writeLock != null && LOCKS.get(adapter.workDirectory().toAbsolutePath().normalize()).isWriteLockedByCurrentThread()) writeLock.unlock(); writeLock = null; + } + @Override public void close() throws IOException { + boolean interrupted = Thread.interrupted(); + try { stopValidation(); if (operation != null) { if (canceled) rollback(); else retain(); } } + catch (IOException failure) { System.getLogger("DLC Sync").log(System.Logger.Level.ERROR, "DLC sync cleanup failed; recovery records retained", failure); throw failure; } + finally { release(); if (interrupted) Thread.currentThread().interrupt(); } + } +} diff --git a/src/main/java/com/cozooo/dlc_manager/sync/SyncFailure.java b/src/main/java/com/cozooo/dlc_manager/sync/SyncFailure.java new file mode 100644 index 00000000..36dc91c7 --- /dev/null +++ b/src/main/java/com/cozooo/dlc_manager/sync/SyncFailure.java @@ -0,0 +1,60 @@ +package com.cozooo.dlc_manager.sync; + +import com.cozooo.dlc_manager.platform.DlcPlatformServices; +import com.google.gson.*; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +public final class SyncFailure extends IOException { + private static final long serialVersionUID = 1L; + private static final String PREFIX = "dlc_manager.sync.error."; + private static final Map LANGUAGES = new ConcurrentHashMap<>(); + private final String code; + private final String[] arguments; + + public SyncFailure(String code, Object... arguments) { + super(PREFIX + code); + this.code = code; + this.arguments = Arrays.stream(arguments).map(String::valueOf).toArray(String[]::new); + } + public SyncFailure causedBy(Throwable cause) { initCause(cause); return this; } + public static boolean is(Throwable error, String code) { return error instanceof SyncFailure failure && failure.code.equals(code); } + @Override public String getMessage() { + String language = DlcPlatformServices.languageCode(); + language = language == null || language.isBlank() ? "en_us" : language.toLowerCase(Locale.ROOT).replace('-', '_'); + if (!language.matches("[a-z]{2,3}_[a-z]{2,3}")) language = "en_us"; + String key = PREFIX + code; + JsonElement value = load(language).get(key); + if (value == null) value = load("en_us").get(key); + String pattern = value == null ? key : value.getAsString(); + try { return String.format(Locale.ROOT, pattern, (Object[]) arguments); } + catch (IllegalFormatException ignored) { return pattern; } + } + public void write(JsonObject response) { + response.addProperty("reasonKey", PREFIX + code); + JsonArray values = new JsonArray(); + for (String argument : arguments) values.add(argument); + response.add("reasonArgs", values); + } + public static IOException read(JsonObject response) { + if (response != null && response.has("reasonKey") && response.get("reasonKey").isJsonPrimitive()) { + String key = response.get("reasonKey").getAsString(); + if (key.startsWith(PREFIX) && key.substring(PREFIX.length()).matches("[a-z_]+")) { + List arguments = new ArrayList<>(); + if (response.has("reasonArgs")) for (JsonElement value : response.getAsJsonArray("reasonArgs")) arguments.add(value.getAsString()); + return new SyncFailure(key.substring(PREFIX.length()), arguments.toArray()); + } + } + return response != null && response.has("reason") ? new IOException(response.get("reason").getAsString()) : new SyncFailure("unsupported"); + } + private static JsonObject load(String language) { + return LANGUAGES.computeIfAbsent(language, name -> { + try (var input = SyncFailure.class.getResourceAsStream("/assets/dlc_manager/lang/" + name + ".json")) { + return input == null ? new JsonObject() : JsonParser.parseReader(new InputStreamReader(input, StandardCharsets.UTF_8)).getAsJsonObject(); + } catch (IOException | RuntimeException ignored) { return new JsonObject(); } + }); + } +} diff --git a/src/main/java/com/cozooo/dlc_manager/sync/SyncModel.java b/src/main/java/com/cozooo/dlc_manager/sync/SyncModel.java new file mode 100644 index 00000000..2ef0fa82 --- /dev/null +++ b/src/main/java/com/cozooo/dlc_manager/sync/SyncModel.java @@ -0,0 +1,204 @@ +package com.cozooo.dlc_manager.sync; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.*; +import java.util.function.BooleanSupplier; +import com.google.gson.JsonObject; + +public final class SyncModel { + private SyncModel() {} + public enum Mode { CONFIG, FILES } + public enum Direction { PUSH, PULL } + public enum Category { ALL, DLC, REQUIRED, UPDATE, MANAGER_CONFIG, PRESET } + public record Update(String baseOld, String dlcOld, String baseNew, String dlcNew, String platform) {} + public record File(String id, long size, String hash) {} + public record Item(String key, String identifier, File config, List resources, Boolean enabled, Map displayNames, Map tooltips, Update update, JsonObject settings) { + public Item(String key, String identifier, File config, List resources, Boolean enabled, Map displayNames, Map tooltips, Update update) { + this(key, identifier, config, resources, enabled, displayNames, tooltips, update, null); + } + public Item(String key, String identifier, File config, List resources, Boolean enabled, Map displayNames, Map tooltips) { + this(key, identifier, config, resources, enabled, displayNames, tooltips, null); + } + public Item(String key, String identifier, File config, List resources, Boolean enabled, Map displayNames) { + this(key, identifier, config, resources, enabled, displayNames, Map.of()); + } + public Item(String key, String identifier, File config, List resources, Boolean enabled) { + this(key, identifier, config, resources, enabled, Map.of(), Map.of()); + } + } + public record Row(String key, Item source, Item target, Direction direction, boolean replacement, boolean stateChanged) {} + public record Result(int completed, boolean canceled, boolean requiresRestart, List errors, List succeeded) {} + @FunctionalInterface public interface Peer extends AutoCloseable { JsonObject call(JsonObject request) throws IOException; @Override default void close() throws IOException {} } + @FunctionalInterface public interface Progress { void update(String phase, String resource, long done, long total); } + public interface Journal extends AutoCloseable { + void capture(Path path) throws IOException; + void rollback() throws IOException; + void commit() throws IOException; + @Override void close(); + } + @FunctionalInterface public interface Quiesce { void await(BooleanSupplier canceled) throws Exception; } + public interface Adapter { + Path workDirectory(); + List manifest() throws IOException; + default Item item(String key) throws IOException { + for (Item item : manifest()) if (item.key().equals(key)) return item; + throw new SyncFailure("config_not_found", key); + } + Path resolve(String id) throws IOException; + void validateKey(String key) throws IOException; + default void validateIncoming(Item item) throws IOException {} + default void writeSettings(String key, JsonObject settings, Journal journal) throws IOException { throw new SyncFailure("settings_unavailable"); } + Journal journal(Path directory) throws IOException; + void prepare(String key, boolean force, BooleanSupplier canceled, Progress progress) throws Exception; + boolean apply(String key, Boolean enabled, Journal journal, BooleanSupplier canceled) throws Exception; + void reload() throws IOException; + void recover() throws IOException; + void recoverOperation(String operation) throws IOException; + default void quiesce(BooleanSupplier canceled) throws Exception {} + } + public static boolean sharedState(Item source, Item target) { + return source != null && target != null && source.enabled() != null && target.enabled() != null + && source.identifier() != null && !source.identifier().isBlank() && source.identifier().equals(target.identifier()) && category(source) == category(target); + } + public static boolean hasStateDifferences(List local, List remote) { + Map target = index(remote); + return local.stream().anyMatch(item -> sharedState(item, target.get(item.key())) && !Objects.equals(item.enabled(), target.get(item.key()).enabled())); + } + public static List plan(List local, List server, Direction direction, boolean states) { return plan(local, server, direction, states, Mode.FILES); } + public static List plan(List local, List server, Direction direction, boolean states, Mode mode) { + List sourceItems = direction == Direction.PUSH ? local : server, targetItems = direction == Direction.PUSH ? server : local; + Map left = index(sourceItems), right = index(targetItems); + Set eligible = eligibleUpdates(sourceItems, targetItems), reversedEligible = eligibleUpdates(targetItems, sourceItems); + List rows = new ArrayList<>(); + for (Item source : left.values()) { + if (versionState(source) || category(source) == Category.UPDATE && (mode != Mode.FILES || !eligible.contains(source.key()))) continue; + Item target = right.get(source.key()); + boolean state = states && sharedState(source, target) && !Objects.equals(source.enabled(), target.enabled()); + if (target != null && same(source.config(), target.config()) && (!usesResources(source) || resourcesMatch(source.resources(), target.resources(), mode)) && !state) continue; + boolean replacement = target != null && (!same(source.config(), target.config()) || state + || usesResources(source) && target.resources().stream().anyMatch(file -> file.size() >= 0) && !resourcesMatch(source.resources(), target.resources(), mode)); + rows.add(new Row(source.key(), source, target, direction, replacement, state)); + } + for (Item target : right.values()) if (!left.containsKey(target.key()) && !versionState(target) + && (category(target) != Category.UPDATE || mode == Mode.FILES && reversedEligible.contains(target.key()))) rows.add(new Row(target.key(), target, null, + direction == Direction.PUSH ? Direction.PULL : Direction.PUSH, false, false)); + rows.sort(Comparator.comparing(Row::key)); + return rows; + } + public static boolean same(File a, File b) { + return a != null && b != null && a.size() >= 0 && a.size() == b.size() && a.id().equals(b.id()) && a.hash() != null && a.hash().equals(b.hash()); + } + public static boolean sameResources(List a, List b) { + if (a.isEmpty() || b.isEmpty() || a.size() != b.size()) return false; + Map indexed = new HashMap<>(); + for (File file : b) indexed.put(file.id(), file); + for (File file : a) if (!same(file, indexed.get(file.id()))) return false; + return true; + } + public static boolean resourcesMatch(List source, List target, Mode mode) { + if (mode == Mode.FILES) return sameResources(source, target); + return true; + } + public static String displayName(Item item, String language) { + String localized = localized(item.displayNames(), language); + if (!localized.isBlank()) return localized; + if (item.identifier() != null && !item.identifier().isBlank()) return item.identifier(); + String name = item.key().substring(item.key().lastIndexOf('/') + 1); + int extension = name.lastIndexOf('.'); + return extension > 0 ? name.substring(0, extension) : name; + } + public static String tooltip(Item item, String language) { return localized(item.tooltips(), language); } + private static String localized(Map names, String language) { + if (names != null) { + String localized = names.get(language == null ? "" : language.toLowerCase(Locale.ROOT).replace('-', '_')); + if (localized != null && !localized.isBlank()) return localized; + String english = names.get("en_us"); + if (english != null && !english.isBlank()) return english; + for (String value : names.values()) if (value != null && !value.isBlank()) return value; + } + return ""; + } + public static String status(Row row, Mode mode) { + if (row.target() == null) return row.direction() == Direction.PUSH ? "server_missing" : "client_missing"; + if (mode == Mode.FILES && usesResources(row.source()) && (row.source().resources().isEmpty() || row.target().resources().isEmpty() + || row.source().resources().stream().anyMatch(file -> file.size() < 0) + || row.target().resources().stream().anyMatch(file -> file.size() < 0) + || row.source().resources().stream().anyMatch(file -> row.target().resources().stream().noneMatch(other -> other.id().equals(file.id()))))) return "resources_missing"; + if (same(row.source().config(), row.target().config()) && (!usesResources(row.source()) || resourcesMatch(row.source().resources(), row.target().resources(), mode))) return "state_changed"; + return "content_changed"; + } + public static List differences(Row row, Mode mode) { + List result = new ArrayList<>(); + if (row.target() == null) result.add("configuration_missing"); + else if (!same(row.source().config(), row.target().config())) result.add("configuration_changed"); + if (mode == Mode.FILES && usesResources(row.source()) && (row.target() == null || !resourcesMatch(row.source().resources(), row.target().resources(), mode))) { + boolean missing = row.target() == null || row.target().resources().isEmpty() || row.target().resources().stream().anyMatch(file -> file.size() < 0) || row.source().resources().isEmpty() || row.source().resources().stream().anyMatch(file -> file.size() < 0); + result.add(missing ? "resources_missing" : "resources_changed"); + } + if (row.stateChanged()) result.add(Boolean.TRUE.equals(row.source().enabled()) ? "enable" : "disable"); + return result; + } + private static String relative(String key) { return key.substring(key.indexOf(':') + 1); } + public static Category category(Item item) { + if (item.key().startsWith("manager:")) return Category.MANAGER_CONFIG; + String path = relative(item.key()); + if (path.startsWith("required/")) return Category.REQUIRED; + if (path.startsWith("preset/")) return Category.PRESET; + if (path.startsWith("update/") || path.equals("info/version.dc")) return Category.UPDATE; + return Category.DLC; + } + public static boolean usesResources(Item item) { return category(item) == Category.DLC || category(item) == Category.REQUIRED; } + public static boolean versionState(Item item) { return relative(item.key()).equals("info/version.dc"); } + private static String root(Item item) { int colon = item.key().indexOf(':'); return colon < 0 ? "" : item.key().substring(0, colon); } + private static String pair(String base, String dlc) { return base + "\n" + dlc; } + private static boolean advances(Update update) { + if (update == null || update.baseOld() == null || update.dlcOld() == null || update.baseNew() == null || update.dlcNew() == null) return false; + int base = com.cozooo.dlc_manager.client.update.DlcUpdateService.compareVersions(update.baseNew(), update.baseOld()); + return base > 0 || base == 0 && com.cozooo.dlc_manager.client.update.DlcUpdateService.compareVersions(update.dlcNew(), update.dlcOld()) > 0; + } + public static Set eligibleUpdates(List source, List target) { + Map versions = new HashMap<>(); + Map> reachable = new HashMap<>(); + for (Item item : target) if (versionState(item) && item.update() != null) { + versions.put(root(item), item.update()); + reachable.put(root(item), new HashSet<>(Set.of(pair(item.update().baseOld(), item.update().dlcOld())))); + } + Set sourceKeys = new HashSet<>(); + List available = new ArrayList<>(); + for (Item item : source) if (category(item) == Category.UPDATE && !versionState(item)) { sourceKeys.add(item.key()); available.add(item); } + for (Item item : target) if (category(item) == Category.UPDATE && !versionState(item) && !sourceKeys.contains(item.key())) available.add(item); + Set eligible = new HashSet<>(); + boolean changed; + do { + changed = false; + for (Item item : available) { + Update current = versions.get(root(item)), update = item.update(); + if (current == null || !advances(update) || !("universal".equals(update.platform()) || Objects.equals(current.platform(), update.platform()))) continue; + Set reached = reachable.get(root(item)); + if (!reached.contains(pair(update.baseOld(), update.dlcOld()))) continue; + if (sourceKeys.contains(item.key())) eligible.add(item.key()); + if (reached.add(pair(update.baseNew(), update.dlcNew()))) changed = true; + } + } while (changed); + return eligible; + } + private static int applicationOrder(Item item) { return category(item) == Category.UPDATE ? 2 : category(item) == Category.PRESET ? 1 : 0; } + public static List ordered(List rows) { + List result = new ArrayList<>(rows); + result.sort((a, b) -> { + int left = applicationOrder(a.source()), right = applicationOrder(b.source()); + if (left != right) return Integer.compare(left, right); + if (left == 1) return Boolean.compare(Boolean.TRUE.equals(a.source().enabled()), Boolean.TRUE.equals(b.source().enabled())); + if (left != 2 || a.source().update() == null || b.source().update() == null) return 0; + int base = com.cozooo.dlc_manager.client.update.DlcUpdateService.compareVersions(a.source().update().baseOld(), b.source().update().baseOld()); + return base != 0 ? base : com.cozooo.dlc_manager.client.update.DlcUpdateService.compareVersions(a.source().update().dlcOld(), b.source().update().dlcOld()); + }); + return result; + } + public static Map index(List items) { + Map result = new TreeMap<>(); + for (Item item : items) if (result.put(item.key(), item) != null) throw new IllegalArgumentException("Duplicate DLC configuration: " + item.key()); + return result; + } +} diff --git a/src/main/kotlin/com/cozooo/dlc_manager/client/config/DlcConfigClient.kt b/src/main/kotlin/com/cozooo/dlc_manager/client/config/DlcConfigClient.kt index 171b361d..26219bef 100644 --- a/src/main/kotlin/com/cozooo/dlc_manager/client/config/DlcConfigClient.kt +++ b/src/main/kotlin/com/cozooo/dlc_manager/client/config/DlcConfigClient.kt @@ -1,5 +1,6 @@ package com.cozooo.dlc_manager.client.config +import com.cozooo.dlc_manager.core.DlcFileSize import com.cozooo.dlc_manager.core.DlcConfig object DlcConfigClient { @@ -226,7 +227,7 @@ object DlcConfigClient { DlcConfig.setMaxConcurrentDownloads(MAX_CONCURRENT_DOWNLOADS.integerValue) DlcConfig.setMaxSegmentsPerFile(MAX_SEGMENTS_PER_FILE.integerValue) - DlcConfig.setMinSegmentBytes(kotlin.math.max(1, MIN_SEGMENT_MB.integerValue).toLong() * 1024L * 1024L) + DlcConfig.setMinSegmentBytes(kotlin.math.max(1, MIN_SEGMENT_MB.integerValue).toLong() * DlcFileSize.MIB) DlcConfig.setMaxRetryCount(MAX_RETRY.integerValue) DlcConfig.setConnectTimeoutMs(CONNECT_TIMEOUT_SECONDS.integerValue * 1000) DlcConfig.setTaskTimeoutMs(TASK_TIMEOUT_SECONDS.integerValue * 1000) @@ -236,7 +237,7 @@ object DlcConfigClient { } private fun toMb(bytes: Long): Int { - val mb = (bytes + 1024L * 1024L - 1) / (1024L * 1024L) + val mb = DlcFileSize.ceilMiB(bytes) if (mb < 1) { return 1 } diff --git a/src/main/kotlin/com/cozooo/dlc_manager/client/download/DlcDownloadService.kt b/src/main/kotlin/com/cozooo/dlc_manager/client/download/DlcDownloadService.kt index 51b1d8a4..5b3fae73 100644 --- a/src/main/kotlin/com/cozooo/dlc_manager/client/download/DlcDownloadService.kt +++ b/src/main/kotlin/com/cozooo/dlc_manager/client/download/DlcDownloadService.kt @@ -1,5 +1,6 @@ package com.cozooo.dlc_manager.client.download +import com.cozooo.dlc_manager.core.DlcFileSize import com.cozooo.dlc_manager.core.DlcConfig import java.io.BufferedInputStream import java.io.IOException @@ -28,7 +29,7 @@ object DlcDownloadService { private const val MIRROR_COMPARE_WINDOW_MS = 3000L private const val MIRROR_DISABLE_FAILURES = 2 private const val SPEED_WINDOW_MS = 1000L - private const val BYTES_PER_MIB = 1024L * 1024L + private const val BYTES_PER_MIB = DlcFileSize.MIB private const val MIN_MIRROR_SPEED_CHECK_BYTES = BYTES_PER_MIB private const val SEGMENT_SPEED_DROP_RETRY_RATIO = 0.25 private const val CURSEFORGE_PROBE_PROJECT_ID = "678295" @@ -94,6 +95,8 @@ object DlcDownloadService { } val target = outputDir.resolve(fileName) val temp = outputDir.resolve("$fileName.part") + com.cozooo.dlc_manager.client.update.DlcUpdateJournal.beforeSyncWrite(target) + com.cozooo.dlc_manager.client.update.DlcUpdateJournal.beforeSyncWrite(temp) listener?.onProgress(0L, probe.totalBytes) if (probe.supportsRanges @@ -151,6 +154,8 @@ object DlcDownloadService { } val target = outputDir.resolve(fileName) val temp = outputDir.resolve("$fileName.part") + com.cozooo.dlc_manager.client.update.DlcUpdateJournal.beforeSyncWrite(target) + com.cozooo.dlc_manager.client.update.DlcUpdateJournal.beforeSyncWrite(temp) val total = if (expectedSize > 0) expectedSize else -1L val mode = DownloadMirrorMode.fromConfig(DlcConfig.getDownloadMirrorMode()) val mirrorPlan = mirrorPlan(url, fileName, total) diff --git a/src/main/kotlin/com/cozooo/dlc_manager/client/preset/DlcPresetLoader.kt b/src/main/kotlin/com/cozooo/dlc_manager/client/preset/DlcPresetLoader.kt index 228f13a2..7a099494 100644 --- a/src/main/kotlin/com/cozooo/dlc_manager/client/preset/DlcPresetLoader.kt +++ b/src/main/kotlin/com/cozooo/dlc_manager/client/preset/DlcPresetLoader.kt @@ -17,7 +17,10 @@ object DlcPresetLoader { } @JvmStatic - fun loadAll(roots: List?): List { + fun loadAll(roots: List?): List = loadAll(roots, true) + + @JvmStatic + fun loadAll(roots: List?, migrate: Boolean): List { val entries = ArrayList() if (roots.isNullOrEmpty()) { return entries @@ -36,7 +39,7 @@ object DlcPresetLoader { Files.walk(presetRoot).use { stream -> stream.filter { path -> path.fileName != null && DlcDcConfigIO.isDc(path) } .forEach { path -> - val entry = readEntry(path) + val entry = readEntry(path, migrate) if (entry != null) { entries.add(entry) } @@ -64,7 +67,7 @@ object DlcPresetLoader { return presetRoot } - private fun readEntry(path: Path): DlcPresetEntry? { + private fun readEntry(path: Path, migrate: Boolean): DlcPresetEntry? { val obj = DlcDcConfigIO.read(path, DlcDcConfigIO.Layout.GENERIC) ?: return null val basic = DlcDcConfigIO.getObjectIgnoreCase(obj, "basic") val displayName = readLangMap(DlcDcConfigIO.getObjectIgnoreCase(obj, "DisplayName")) @@ -77,7 +80,7 @@ object DlcPresetLoader { val fileName = stripExtension(path.fileName.toString()) val id = readIdentifier(basic, obj, fileName) - migrateIfNeeded(path, obj, id, alarm, enable, conflicts) + if (migrate) migrateIfNeeded(path, obj, id, alarm, enable, conflicts) return DlcPresetEntry(id, fileName, path, displayName, tip, alarmTip, enable, conflicts, alarm, DlcPresetStatus.OFF) } diff --git a/src/main/kotlin/com/cozooo/dlc_manager/client/preset/DlcPresetRuntimeState.kt b/src/main/kotlin/com/cozooo/dlc_manager/client/preset/DlcPresetRuntimeState.kt index 6156e8bb..ccc59401 100644 --- a/src/main/kotlin/com/cozooo/dlc_manager/client/preset/DlcPresetRuntimeState.kt +++ b/src/main/kotlin/com/cozooo/dlc_manager/client/preset/DlcPresetRuntimeState.kt @@ -17,9 +17,12 @@ object DlcPresetRuntimeState { private var baselineDisabledRelativePathsInitialized = false @JvmStatic - fun reload() { + fun reload() = reload(true) + + @JvmStatic + fun reload(migrate: Boolean) { val previousEnabled = if (presetDirty) LinkedHashSet(ENABLED_PRESETS) else null - val entries = DlcPresetLoader.loadAll() + val entries = DlcPresetLoader.loadAll(com.cozooo.dlc_manager.core.DlcConfig.getDlcRoots(), migrate) PRESETS_BY_ID.clear() for (entry in entries) { val id = entry.getId() diff --git a/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcFilePageScreen.kt b/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcFilePageScreen.kt index db941204..b877f234 100644 --- a/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcFilePageScreen.kt +++ b/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcFilePageScreen.kt @@ -19,6 +19,11 @@ abstract class DlcFilePageScreen(page: DlcUiPage, parent: Screen?, title: Compon protected fun fileRow(list: Element, name: String, description: String?, checked: Supplier, enabled: BooleanSupplier, select: Runnable, open: Runnable?) { + fileRowLine(list, name, description, checked, enabled, select, open) + } + + protected fun fileRowLine(list: Element, name: String, description: String?, checked: Supplier, + enabled: BooleanSupplier, select: Runnable, open: Runnable?): Element { val line = node(list, "div", "page-row file-row") val button = action(line, "") { if (enabled.asBoolean) { select.run(); syncSelections() } @@ -56,6 +61,7 @@ abstract class DlcFilePageScreen(page: DlcUiPage, parent: Screen?, title: Compon } selections.add(sync) sync.run() + return line } private fun syncSelections() { selections.forEach { it.run() } } diff --git a/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcPageScreen.kt b/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcPageScreen.kt index f7892a9a..b155f5e4 100644 --- a/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcPageScreen.kt +++ b/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcPageScreen.kt @@ -198,19 +198,7 @@ abstract class DlcPageScreen( val track = node(parent, "div", "progress-track") track.setAttribute("role", "progressbar"); track.setAttribute("aria-valuemin", "0"); track.setAttribute("aria-valuemax", "100") val fill = node(track, "div", "progress-fill") - var previous = -1 - val binding = Runnable { - val fraction = value.asDouble - if (fraction < 0) { - val phase = (System.nanoTime() % 1200000000L) / 1200000000.0 - fill.setAttribute("style", "width:25%;transform:translateX(${track.offsetWidth * 0.75 * phase}px)") - track.removeAttribute("aria-valuenow"); track.setAttribute("aria-busy", "true"); previous = -1 - } else { - track.removeAttribute("aria-busy") - val percent = (fraction.coerceIn(0.0, 1.0) * 100).toInt() - if (percent != previous) { previous = percent; fill.setAttribute("style", "width:$percent%"); track.setAttribute("aria-valuenow", percent.toString()) } - } - } + val binding = DlcUiProgress.binding(track, fill, value) bindings.add(binding); binding.run(); return track } diff --git a/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcServerFileManagerScreen.kt b/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcServerFileManagerScreen.kt index a4a63388..34b223b2 100644 --- a/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcServerFileManagerScreen.kt +++ b/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcServerFileManagerScreen.kt @@ -341,18 +341,7 @@ class DlcServerFileManagerScreen( private val selectedEntry: DlcServerFileEntry? get() = if (selectedIndex >= 0 && selectedIndex < entries.size) entries[selectedIndex] else null - private fun formatSize(size: Long): String { - if (size <= 0) { - return "" - } - if (size > 1024 * 1024) { - return String.format("%.1f MB", size / 1024.0 / 1024.0) - } - if (size > 1024) { - return String.format("%.1f KB", size / 1024.0) - } - return "$size B" - } + private fun formatSize(size: Long): String = com.cozooo.dlc_manager.core.DlcFileSize.format(size) companion object { diff --git a/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcServerPushScreen.kt b/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcServerPushScreen.kt index 1b8ab93d..c686330c 100644 --- a/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcServerPushScreen.kt +++ b/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcServerPushScreen.kt @@ -1,410 +1,215 @@ package com.cozooo.dlc_manager.client.screen -import com.cozooo.dlc_manager.client.screen.DlcScreenCompat - +import com.cozooo.dlc_manager.client.server.* +import com.cozooo.dlc_manager.client.sync.DlcSyncAdapter +import com.cozooo.dlc_manager.sync.* +import com.cozooo.dlc_manager.sync.SyncModel.* import com.cozooo.dlc_manager.platform.DlcTextCompat - -import com.cozooo.dlc_manager.client.server.DlcServerConnection -import com.cozooo.dlc_manager.client.server.DlcServerEntry -import com.cozooo.dlc_manager.core.DlcPaths -import com.cozooo.dlc_manager.platform.DlcPlatformServices import net.minecraft.client.Minecraft -import net.minecraft.client.gui.components.Button import net.minecraft.client.gui.screens.Screen -import net.minecraft.network.chat.Component -import java.nio.file.DirectoryStream -import java.nio.file.Files -import java.nio.file.Path +import java.util.Locale import java.util.concurrent.CompletableFuture -class DlcServerPushScreen( - private val parent: Screen?, - private val entry: DlcServerEntry?, -) : DlcFilePageScreen(DlcUiPage.SERVER_PUSH, parent, DlcTextCompat.translatable("dlc_manager.server.push_title")) { - private val rootDir: Path = DlcPaths.gameDir().toAbsolutePath().normalize() - private var currentDir: Path = rootDir - - private var modeButton: Button? = null - private var pushButton: Button? = null - private var backButton: Button? = null - private var upButton: Button? = null - private var clearButton: Button? = null - - private var overwriteMode = true - private var pushing = false - private var statusText: String? = null - private var errorText: String? = null - private val selectedFiles: MutableSet = LinkedHashSet() - private val dirFilesCache: MutableMap> = HashMap() - private var entries: List = emptyList() - - private var scrollIndex = 0 - private var draggingScrollBar = false - - private fun initControls() { - val buttonHeight = 20 - val bottomY = height - 28 - val modeWidth = 96 - val pushWidth = 96 - val backWidth = 96 - val gap = 6 - - val modeLabel = DlcTextCompat.translatable("dlc_manager.server.push_mode") - val modeLabelWidth = font.width(modeLabel) - val modeX = 20 + modeLabelWidth + 6 - val mode = DlcScreenCompat.buttonBuilder(DlcTextCompat.empty()) { toggleMode() } - .bounds(modeX, bottomY, modeWidth, buttonHeight) - .build() - - val rightMargin = 10 - val backX = width - rightMargin - backWidth - val pushX = backX - gap - pushWidth - val push = DlcScreenCompat.buttonBuilder(DlcTextCompat.translatable("dlc_manager.server.push_start")) { confirmPush() } - .bounds(pushX, bottomY, pushWidth, buttonHeight) - .build() - val back = DlcScreenCompat.buttonBuilder(DlcTextCompat.translatable("gui.back")) { Minecraft.getInstance().setScreen(parent) } - .bounds(backX, bottomY, backWidth, buttonHeight) - .build() - - val topButtonY = 56 - val up = DlcScreenCompat.buttonBuilder(DlcTextCompat.translatable("dlc_manager.server.push_up")) { goUp() } - .bounds(20, topButtonY, 70, buttonHeight) - .build() - val clear = DlcScreenCompat.buttonBuilder(DlcTextCompat.translatable("dlc_manager.server.push_clear_selection", 0)) { clearSelection() } - .bounds(width - 20 - 140, topButtonY, 140, buttonHeight) - .build() - - modeButton = mode - pushButton = push - backButton = back - upButton = up - clearButton = clear - - addRenderableWidget(mode) - addRenderableWidget(push) - addRenderableWidget(back) - addRenderableWidget(up) - addRenderableWidget(clear) - refreshEntries() - updateLabels() - } - - private fun toggleMode() { - if (pushing) { - return - } - overwriteMode = !overwriteMode - updateLabels() - } - - private fun updateLabels() { - modeButton?.let { - val modeLabel = if (overwriteMode) { - DlcTextCompat.translatable("dlc_manager.server.push_mode_overwrite") - } else { - DlcTextCompat.translatable("dlc_manager.server.push_mode_diff") - } - it.message = modeLabel - it.active = !pushing - } - pushButton?.active = !pushing - backButton?.active = !pushing - clearButton?.let { - it.message = DlcTextCompat.translatable("dlc_manager.server.push_clear_selection", selectedFiles.size) - it.active = !pushing && selectedFiles.isNotEmpty() - } - upButton?.active = !pushing && currentDir != rootDir - } - - private fun confirmPush() { - if (selectedFiles.isEmpty()) { - DlcScreenCompat.addOrUpdatePeriodicNotification( - DlcScreenCompat.toastManager(Minecraft.getInstance()), - DlcTextCompat.translatable("dlc_manager.server.push_no_selection"), - DlcTextCompat.empty(), - ) - return - } - val title = DlcTextCompat.translatable("dlc_manager.server.push_title") - val message = DlcTextCompat.translatable("dlc_manager.server.push_confirm", buildTargetLabel(), modeLabel) - Minecraft.getInstance().setScreen( - DlcConfirmScreen({ accepted -> - if (accepted) { - confirmPushAgain() - } else { - Minecraft.getInstance().setScreen(this) - } - }, title, message, DlcConfirmUtil.confirmYes(), DlcConfirmUtil.confirmNo()), - ) - } - - private fun confirmPushAgain() { - val title = DlcTextCompat.translatable("dlc_manager.server.push_title") - val message = DlcTextCompat.translatable("dlc_manager.server.push_confirm_again") - Minecraft.getInstance().setScreen( - DlcConfirmScreen({ accepted -> - if (accepted) { - startPush() - } else { - Minecraft.getInstance().setScreen(this) - } - }, title, message, DlcConfirmUtil.confirmYes(), DlcConfirmUtil.confirmNo()), - ) - } - - private fun startPush() { - if (pushing) { - return - } - pushing = true - statusText = DlcTextCompat.translatable("dlc_manager.server.pushing").string - updateLabels() - CompletableFuture - .supplyAsync { doPush() } - .whenComplete { ok, _ -> - Minecraft.getInstance().execute { - pushing = false - if (ok == true) { - DlcScreenCompat.addOrUpdatePeriodicNotification( - DlcScreenCompat.toastManager(Minecraft.getInstance()), - DlcTextCompat.translatable("dlc_manager.server.push_success"), - DlcTextCompat.empty(), - ) - } else { - DlcScreenCompat.addOrUpdatePeriodicNotification( - DlcScreenCompat.toastManager(Minecraft.getInstance()), - DlcTextCompat.translatable("dlc_manager.server.push_failed"), - DlcTextCompat.empty(), - ) - } - updateLabels() - } - } - } - - private fun doPush(): Boolean { - return try { - val files = ArrayList(selectedFiles) - if (files.isEmpty()) { - return false - } - DlcServerConnection.push( - entry, - 20000, - emptyList(), - files, - if (overwriteMode) "overwrite" else "diff", - DlcPlatformServices.playerName(), - DlcPlatformServices.playerUuid(), - ) - } catch (_: Exception) { - false - } - } - - private fun buildTargetLabel(): String { - return DlcTextCompat.translatable("dlc_manager.server.push_selected_count", selectedFiles.size).string - } - - private val modeLabel: String - get() = if (overwriteMode) { - DlcTextCompat.translatable("dlc_manager.server.push_mode_overwrite").string - } else { - DlcTextCompat.translatable("dlc_manager.server.push_mode_diff").string - } - - private fun refreshEntries() { - entries = listEntries(currentDir) - scrollIndex = 0 - updateLabels() - } - - private fun listEntries(dir: Path?): List { - val result: MutableList = ArrayList() - if (dir == null || !Files.isDirectory(dir)) { - return result - } - try { - Files.newDirectoryStream(dir).use { stream: DirectoryStream -> - for (path in stream) { - val isDir = Files.isDirectory(path) - val name = path.fileName?.toString() ?: path.toString() - result.add(FileEntry(path, name, isDir)) - } - } - } catch (_: Exception) { - } - result.sortWith(compareBy { !it.isDir }.thenBy { it.name.lowercase() }) - return result - } - - private fun openDirectory(dir: Path?) { - if (dir == null) { - return - } - val normalized = dir.toAbsolutePath().normalize() - if (!normalized.startsWith(rootDir) || !Files.isDirectory(normalized)) { - return - } - currentDir = normalized - refreshEntries() - } - - private fun goUp() { - if (currentDir == rootDir) { - return - } - val parentDir = currentDir.parent - if (parentDir == null || !parentDir.startsWith(rootDir)) { - return - } - currentDir = parentDir - refreshEntries() - } +class DlcServerPushScreen(parent: Screen, private val entry: DlcServerEntry?) : + DlcFilePageScreen(DlcUiPage.SERVER_PUSH, parent, DlcTextCompat.translatable("dlc_manager.sync.title")) { + private var local = emptyList() + private var remote = emptyList() + private var rows = emptyList() + private var filteredRows = emptyList() + private val selected = mutableSetOf() + private var mode = Mode.CONFIG + private var category = Category.ALL + private var direction = Direction.PUSH + private var settingsSelected = false + private var states = true + private var loaded = false + @Volatile private var busy = false + @Volatile private var message = "" + @Volatile private var fraction = -1.0 + @Volatile private var connecting: AutoCloseable? = null + @Volatile private var cancelRequested = false + @Volatile private var active: SyncClient? = null + @Volatile private var previewSession: DlcServerSession? = null + @Volatile private var rollbackClient: SyncClient? = null + @Volatile private var failureReason: String? = null + private var syncing = false + private var results: com.sighs.apricityui.init.Element? = null + private var renderedCategory = Category.ALL + private val scrolls = java.util.EnumMap(Category::class.java) + private var executeButton: DlcUiButton? = null - private fun getEntryState(entry: FileEntry?): SelectionState { - if (entry == null) { - return SelectionState.NONE - } - if (!entry.isDir) { - val rel = toRelative(entry.path) - return if (rel != null && selectedFiles.contains(rel)) SelectionState.ALL else SelectionState.NONE - } - val files = getDirFiles(entry.path) - if (files.isEmpty()) { - return SelectionState.NONE - } - val count = files.count { it in selectedFiles } - return if (count == 0) SelectionState.NONE else if (count == files.size) SelectionState.ALL else SelectionState.PARTIAL - } - - private fun toggleEntry(entry: FileEntry?) { - if (entry == null) { - return - } - if (!entry.isDir) { - val rel = toRelative(entry.path) - if (rel.isNullOrBlank()) { - return - } - if (!selectedFiles.add(rel)) { - selectedFiles.remove(rel) - } - updateLabels() - return - } - val files = getDirFiles(entry.path) - if (files.isEmpty()) { - return - } - var allSelected = true - for (file in files) { - if (!selectedFiles.contains(file)) { - allSelected = false - break + override fun buildPage() { + footerLeading.setAttribute("style", "display:flex") + action(footerLeading, tr("dlc_manager.sync.mode_" + mode.name.lowercase(Locale.ROOT))) { mode = if (mode == Mode.FILES) Mode.CONFIG else Mode.FILES; replan() }.setEnabled(!busy) + action(toolbar, tr("dlc_manager.sync." + direction.name.lowercase(Locale.ROOT))) { direction = if (direction == Direction.PUSH) Direction.PULL else Direction.PUSH; replan() }.setEnabled(!busy) + if (SyncModel.hasStateDifferences(local, remote)) toggle(footerLeading, tr("dlc_manager.sync.states"), states) { states = it; replan() }.setEnabled(!busy) + if (settingsRow() != null) toggle(footerLeading, tr("dlc_manager.sync.manager_config"), settingsSelected) { settingsSelected = it; refreshPage() }.setEnabled(!busy) + action(toolbar, tr("dlc_manager.sync.compare"), ::compare).setEnabled(!busy) + content.parentElement.setAttribute("class", "page-shell sync-page") + val layout = node(content, "div", "sync-layout") + val types = node(layout, "aside", "sync-types") + for (type in Category.entries) { + if (type == Category.MANAGER_CONFIG) continue + val button = action(types, tr("dlc_manager.sync.type_" + type.name.lowercase(Locale.ROOT))) { category = type; refilter(); refreshPage() } + button.getElement().setAttribute("class", "button filter" + if (category == type) " active" else "") + button.setEnabled(!busy) + } + results?.let { scrolls[renderedCategory] = DlcUiScroll.capture(it) } + val results = node(layout, "div", "sync-results") + this.results = results + renderedCategory = category + scrolls[category]?.let { DlcUiScroll.restore(results, it) } + val shown = visibleRows() + val list = fileList(if (loaded && shown.isEmpty()) tr("dlc_manager.sync.empty") else tr("dlc_manager.sync.differences")) + results.appendChild(list) + showWhen(list) { !busy || syncing } + if (shown.isNotEmpty()) selectionRow(list, tr("dlc_manager.sync.select_all"), ::selectionState, ::selectVisible) + for (row in shown) { + val language = com.cozooo.dlc_manager.platform.DlcPlatformServices.languageCode() + val name = SyncModel.displayName(row.source, language) + val line = selectionRow(list, name, { selected.contains(row.key).toString() }) { if (!selected.add(row.key)) selected.remove(row.key) } + tip(line) { DlcUiTooltip.Content(name, SyncModel.tooltip(row.source, language), emptyList(), tr("dlc_manager.sync." + SyncModel.status(row, mode))) } + text(line, tr("dlc_manager.sync." + SyncModel.status(row, mode)), "row-description") + } + live(results, { message }, "page-description") + showWhen(progress(results) { fraction }) { busy } + if (rollbackClient?.canRollback() == true) { + val undo = action(footer, tr("dlc_manager.sync.rollback"), ::rollback) + undo.getElement().setAttribute("class", "button danger"); undo.setEnabled(!busy) + } + executeButton = primary(tr(if (syncing) "dlc_manager.sync.cancel" else "dlc_manager.sync.execute")) { + if (syncing) cancel() else confirm() + } + executeButton?.getElement()?.setAttribute("class", if (syncing) "button danger" else "button primary") + updateExecuteButton() + } + private fun selectionRow(list: com.sighs.apricityui.init.Element, name: String, checked: java.util.function.Supplier, change: Runnable): com.sighs.apricityui.init.Element { + val line = fileRowLine(list, name, null, checked, { !busy }, change, null) + val checkbox = line.getChildren()[0] + val document = pageDocument + val generation = document.getRefreshGeneration() + line.addEventListener("mousedown") { event -> + val target = event.target + if (event is com.sighs.apricityui.event.MouseEvent && event.button == 0 && !busy && Minecraft.getInstance().screen === this && document === getLinkedDocument() && document.isCurrentGeneration(generation) && !(target is com.sighs.apricityui.init.Element && checkbox.contains(target))) { + event.preventDefault(); change.run(); updatePage() } } - if (allSelected) { - selectedFiles.removeAll(files.toSet()) - } else { - selectedFiles.addAll(files) - } - updateLabels() - } - - private fun getDirFiles(dir: Path?): List { - if (dir == null) { - return emptyList() - } - val cached = dirFilesCache[dir] - if (cached != null) { - return cached - } - val files: MutableList = ArrayList() + return line + } + private fun settingsRow() = rows.firstOrNull { SyncModel.category(it.source) == Category.MANAGER_CONFIG } + private fun refilter() { filteredRows = rows.filter { SyncModel.category(it.source) != Category.MANAGER_CONFIG && (category == Category.ALL || SyncModel.category(it.source) == category) } } + private fun visibleRows(): List = filteredRows + private fun selectedRows(): List = visibleRows().filter { it.key in selected } + if (settingsSelected) listOfNotNull(settingsRow()) else emptyList() + private fun selectionState(): String { + val shown = visibleRows() + val count = shown.count { selected.contains(it.key) } + return if (count == 0) "false" else if (count == shown.size) "true" else "mixed" + } + private fun selectVisible() { + val shown = visibleRows() + val all = shown.all { selected.contains(it.key) } + shown.forEach { if (all) selected.remove(it.key) else selected.add(it.key) } + } + private fun updateExecuteButton() { + executeButton?.setEnabled(if (busy) syncing && !cancelRequested else settingsSelected && settingsRow() != null || visibleRows().any { it.key in selected }) + } + private fun peer(session: DlcServerSession) = object : Peer { + override fun call(request: com.google.gson.JsonObject): com.google.gson.JsonObject = try { session.syncRequest(request) } + catch (error: java.io.IOException) { session.close(); throw error } + override fun close() = session.close() + } + private fun control() = Peer { request -> DlcServerSession.connect(entry, 10000).use { peer(it).call(request) } } + private fun compare() { + if (busy) return + loaded = false; local = emptyList(); remote = emptyList(); rows = emptyList(); filteredRows = emptyList(); settingsSelected = false; selected.clear() + run { + val manifests = try { + SyncEndpoint(DlcSyncAdapter()).use { endpoint -> connect().use { session -> + previewSession = session + val left = SyncClient.manifest(endpoint) { cancelRequested } + val right = SyncClient.manifest(peer(session)) { cancelRequested } + left to right + } } + } finally { previewSession = null } + Minecraft.getInstance().execute { local = manifests.first; remote = manifests.second; loaded = true; message = ""; replan() } + } + } + private fun replan() { + rows = SyncModel.plan(local, remote, direction, states, mode) + selected.clear(); settingsSelected = false; refilter() + refreshPage() + } + private fun confirm() { + if (busy) return + val chosen = selectedRows() + if (chosen.isEmpty()) return + Minecraft.getInstance().setScreen(DlcConfirmScreen({ accepted -> + Minecraft.getInstance().setScreen(this) + if (accepted) execute(chosen) + }, DlcTextCompat.translatable("dlc_manager.sync.title"), DlcTextCompat.translatable("dlc_manager.sync.confirm", chosen.size), DlcConfirmUtil.confirmYes(), DlcConfirmUtil.confirmNo())) + } + private fun execute(chosen: List) = run(synchronizing = true) { + rollbackClient = null try { - Files.walk(dir).use { stream -> - stream.filter(Files::isRegularFile).forEach { path -> - val rel = toRelative(path) - if (!rel.isNullOrBlank()) { - files.add(rel) - } + SyncEndpoint(DlcSyncAdapter()).use { endpoint -> connect().use { session -> + val client = SyncClient(endpoint, peer(session), control()); active = client; rollbackClient = client + if (cancelRequested) client.cancel() + val result = client.execute(chosen, mode, states) { phase, resource, done, total -> + fraction = if (total > 0 && phase != "restoring") done.toDouble() / total else -1.0 + val amount = if (phase == "transferring" || phase == "downloading") com.cozooo.dlc_manager.core.DlcFileSize.formatProgress(done, total) else "$done" + if (total >= 0) "/$total" else "" + message = DlcTextCompat.translatable("dlc_manager.sync.$phase").string + " ${resource.substringAfterLast('/')} $amount" } - } - } catch (_: Exception) { - } - dirFilesCache[dir] = files - return files - } - - private fun toRelative(path: Path?): String? { - if (path == null) { - return null - } - val normalized = path.toAbsolutePath().normalize() - if (!normalized.startsWith(rootDir)) { - return null - } - val relative = rootDir.relativize(normalized) - val rel = relative.toString() - if (rel.isBlank()) { - return null - } - return rel.replace('\\', '/') - } - - private fun clearSelection() { - selectedFiles.clear() - updateLabels() - } - - private val currentDirLabel: String - get() { - if (currentDir == rootDir) { - return "/" - } - val rel = rootDir.relativize(currentDir).toString().replace('\\', '/') - return "/$rel" - } - - private class FileEntry( - val path: Path, - val name: String, - val isDir: Boolean, - ) - - private enum class SelectionState { - NONE, - PARTIAL, - ALL, - } - - companion object { - - - - } - - private var controlsReady = false - private var pageRevision = 0 - override fun buildPage() { - if (!controlsReady) { controlsReady = true; initControls() } - pageRevision = 31 * entries.hashCode() + pushing.hashCode() - - upButton?.let { mount(toolbar, it) }; clearButton?.let { mount(toolbar, it) } - val list = fileList(currentDirLabel) - for (file in entries) { - fileRow(list, file.name + if (file.isDir) "/" else "", null, - { when (getEntryState(file)) { SelectionState.ALL -> "true"; SelectionState.PARTIAL -> "mixed"; SelectionState.NONE -> "false" } }, - { !pushing }, { toggleEntry(file) }, if (file.isDir) Runnable { openDirectory(file.path) } else null) - } - live(content, { statusText }, "page-description"); live(content, { errorText }, "page-error"); modeButton?.let { mount(footer, it) }; pushButton?.let { mount(footer, it).getElement().setAttribute("class", "button primary") } - - } - override fun updatePage() { - updateLabels() - if (pageRevision != 31 * entries.hashCode() + pushing.hashCode()) refreshPage() - super.updatePage() - } - - override fun shouldCloseOnEsc() = !pushing + message = DlcTextCompat.translatable(if (result.canceled) "dlc_manager.sync.canceled" else "dlc_manager.sync.finished", result.completed, result.errors.size).string + + (if (result.requiresRestart) " " + DlcTextCompat.translatable("dlc_manager.sync.restart").string else "") + "\n" + result.succeeded.joinToString("\n") { tr("dlc_manager.sync.succeeded_item", it) } + "\n" + result.errors.joinToString("\n") + if (chosen.any { SyncModel.category(it.source) == Category.UPDATE && it.key in result.succeeded }) message += "\n" + tr("dlc_manager.sync.updates_ready") + if (result.canceled) return@run + if (result.errors.isNotEmpty()) failureReason = result.errors.joinToString("\n") + val left = SyncClient.manifest(endpoint) { cancelRequested } + val right = SyncClient.manifest(peer(session)) { cancelRequested } + Minecraft.getInstance().execute { local = left; remote = right; replan() } + } } + } finally { active = null } + } + private fun rollback() { + val client = rollbackClient ?: return + if (busy || !client.canRollback()) return + run { + message = tr("dlc_manager.sync.restoring") + SyncEndpoint(DlcSyncAdapter()).use { endpoint -> connect().use { session -> + client.rollback(endpoint, peer(session)) + val left = SyncClient.manifest(endpoint) + val right = SyncClient.manifest(peer(session)) + Minecraft.getInstance().execute { local = left; remote = right; replan() } + } } + message = tr("dlc_manager.sync.restored") + } + } + private fun showFailure(reason: String) { + Minecraft.getInstance().setScreen(DlcSyncFailureScreen({ restore -> + Minecraft.getInstance().setScreen(this) + if (restore) rollback() + }, reason)) + } + private fun connect(): DlcServerSession = DlcServerSession.connect(entry, 120000) { socket -> + connecting = socket + if (cancelRequested) try { socket.close() } catch (_: Exception) {} + } + private fun cancel() { + if (!busy || cancelRequested) return + cancelRequested = true + if (active != null) { if (active?.cancel() != true) { cancelRequested = false; return }; message = tr("dlc_manager.sync.restoring"); fraction = -1.0 } + else try { connecting?.close() } catch (_: Exception) {} + refreshPage() + } + private fun run(synchronizing: Boolean = false, work: () -> Unit) { + if (busy) return + failureReason = null; cancelRequested = false; syncing = synchronizing; busy = true; fraction = -1.0 + message = tr("dlc_manager.sync.working"); refreshPage() + CompletableFuture.runAsync { + try { work() } catch (error: Exception) { message = if (cancelRequested && (error is java.net.SocketException || SyncFailure.`is`(error, "canceled"))) DlcTextCompat.translatable("dlc_manager.sync.canceled").string else error.message ?: error.javaClass.simpleName; if (synchronizing && !cancelRequested) failureReason = failureReason?.let { "$it\n$message" } ?: message } + finally { Minecraft.getInstance().execute { busy = false; syncing = false; connecting = null; refreshPage(); failureReason?.let(::showFailure) } } + } + } + override fun updatePage() { updateExecuteButton(); super.updatePage() } + override fun shouldCloseOnEsc() = !busy } diff --git a/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcSyncFailureScreen.kt b/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcSyncFailureScreen.kt new file mode 100644 index 00000000..56305d9c --- /dev/null +++ b/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcSyncFailureScreen.kt @@ -0,0 +1,22 @@ +package com.cozooo.dlc_manager.client.screen + +import com.cozooo.dlc_manager.platform.DlcTextCompat +import it.unimi.dsi.fastutil.booleans.BooleanConsumer +import net.minecraft.client.Minecraft + +class DlcSyncFailureScreen(private val callback: BooleanConsumer, private val reason: String) : + DlcPageScreen(DlcUiPage.CONFIRM, Minecraft.getInstance().screen, DlcTextCompat.translatable("dlc_manager.sync.failed")) { + private var answered = false + override fun showHeaderBackButton() = false + override fun buildPage() { + rich(node(content, "div", "page-dialog"), DlcTextCompat.translatable("dlc_manager.sync.failure_choice", reason)) + action(footer, tr("dlc_manager.sync.rollback")) { answer(true) }.getElement().setAttribute("class", "button danger") + primary(tr("dlc_manager.sync.keep")) { answer(false) } + } + private fun answer(restore: Boolean) { + if (answered) return + answered = true + callback.accept(restore) + } + override fun onClose() { answer(false) } +} diff --git a/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcUiProgress.kt b/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcUiProgress.kt new file mode 100644 index 00000000..74d6dfc5 --- /dev/null +++ b/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcUiProgress.kt @@ -0,0 +1,24 @@ +package com.cozooo.dlc_manager.client.screen + +import com.sighs.apricityui.init.Element +import java.util.function.DoubleSupplier + +internal object DlcUiProgress { + @JvmStatic fun binding(track: Element, fill: Element, value: DoubleSupplier): Runnable { + var previous = -1 + return Runnable { + if (track.getAttribute("style") == "display:none") return@Runnable + val fraction = value.asDouble + if (fraction < 0) { + val phase = (System.nanoTime() % 1200000000L) / 1200000000.0 + fill.setAttribute("style", "width:25%;transform:translateX(${track.offsetWidth * 0.75 * phase}px)") + if (track.hasAttribute("aria-valuenow")) track.removeAttribute("aria-valuenow") + if (!track.hasAttribute("aria-busy")) track.setAttribute("aria-busy", "true"); previous = -1 + } else { + if (track.hasAttribute("aria-busy")) track.removeAttribute("aria-busy") + val percent = Math.round(fraction.coerceIn(0.0, 1.0) * 100).toInt() + if (percent != previous) { previous = percent; fill.setAttribute("style", "width:$percent%"); track.setAttribute("aria-valuenow", percent.toString()) } + } + } + } +} diff --git a/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcUiScroll.kt b/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcUiScroll.kt index fe6c1369..4370e65c 100644 --- a/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcUiScroll.kt +++ b/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcUiScroll.kt @@ -4,6 +4,14 @@ import com.sighs.apricityui.init.Element import com.sighs.apricityui.layout.Box internal object DlcUiScroll { + data class State(val top: Double, val target: Double, val height: Double) + @JvmStatic fun capture(element: Element) = State(element.scrollTop, element.targetScrollTop, element.scrollHeight) + @JvmStatic fun restore(element: Element, state: State) { + element.scrollHeight = state.height + element.scrollTop = state.top + element.setScrollTop(state.target) + } + @JvmStatic fun scrollToEnd(element: Element) { element.setScrollTop(limit(element)) } diff --git a/src/main/kotlin/com/cozooo/dlc_manager/client/server/DlcServerSession.kt b/src/main/kotlin/com/cozooo/dlc_manager/client/server/DlcServerSession.kt index ea607dc8..4df7d731 100644 --- a/src/main/kotlin/com/cozooo/dlc_manager/client/server/DlcServerSession.kt +++ b/src/main/kotlin/com/cozooo/dlc_manager/client/server/DlcServerSession.kt @@ -345,6 +345,19 @@ class DlcServerSession private constructor(private val socket: Socket) : AutoClo return bool(finishResponse, "ok") } + @Synchronized @Throws(IOException::class) + fun syncRequest(payload: JsonObject): JsonObject { + val id = UUID.randomUUID().toString() + payload.addProperty("requestId", id) + send(withType("dlc_sync", payload)) + while (true) { + val response = readObject() + if (string(response, "type") == "dlc_sync_progress") continue + if (string(response, "requestId") != id) throw com.cozooo.dlc_manager.sync.SyncFailure("unsupported") + return response + } + } + @Throws(IOException::class) fun rawRequest(type: String, payload: JsonObject?): JsonObject { send(withType(type, payload)) @@ -422,7 +435,10 @@ class DlcServerSession private constructor(private val socket: Socket) : AutoClo @JvmStatic @Throws(IOException::class) - fun connect(entry: DlcServerEntry?, timeoutMs: Int): DlcServerSession { + fun connect(entry: DlcServerEntry?, timeoutMs: Int): DlcServerSession = connect(entry, timeoutMs) {} + + @JvmStatic @Throws(IOException::class) + fun connect(entry: DlcServerEntry?, timeoutMs: Int, opened: java.util.function.Consumer): DlcServerSession { if (entry == null) throw IOException("missing server entry") return connect( entry.host, @@ -432,25 +448,31 @@ class DlcServerSession private constructor(private val socket: Socket) : AutoClo DlcPlatformServices.languageCode(), DlcPlatformServices.playerName(), DlcPlatformServices.playerUuid(), + opened, ) } @JvmStatic @Throws(IOException::class) fun connect(host: String?, port: Int, password: String?, timeoutMs: Int, lang: String?, player: String?, uuid: String?): DlcServerSession { + return connect(host, port, password, timeoutMs, lang, player, uuid) {} + } + + private fun connect(host: String?, port: Int, password: String?, timeoutMs: Int, lang: String?, player: String?, uuid: String?, opened: java.util.function.Consumer): DlcServerSession { val socket = Socket() - val actualHost = host ?: "" - socket.connect(InetSocketAddress(actualHost, port), timeoutMs) - socket.soTimeout = timeoutMs - val session = DlcServerSession(socket) try { + opened.accept(socket) + val actualHost = host ?: "" + socket.connect(InetSocketAddress(actualHost, port), timeoutMs) + socket.soTimeout = timeoutMs + val session = DlcServerSession(socket) socket.getOutputStream().write(PORT_MULTIPLEXER_PREFACE) socket.getOutputStream().flush() session.hello(password, lang, player, uuid) return session } catch (error: Throwable) { try { - session.close() + socket.close() } catch (_: IOException) { } throw error diff --git a/src/main/kotlin/com/cozooo/dlc_manager/client/sync/DlcSyncAdapter.kt b/src/main/kotlin/com/cozooo/dlc_manager/client/sync/DlcSyncAdapter.kt new file mode 100644 index 00000000..e205382a --- /dev/null +++ b/src/main/kotlin/com/cozooo/dlc_manager/client/sync/DlcSyncAdapter.kt @@ -0,0 +1,244 @@ +package com.cozooo.dlc_manager.client.sync + +import com.cozooo.dlc_manager.sync.SyncFailure +import com.cozooo.dlc_manager.sync.SyncModel.* +import com.cozooo.dlc_manager.core.* +import com.cozooo.dlc_manager.client.remote.* +import com.cozooo.dlc_manager.client.required.* +import com.cozooo.dlc_manager.client.update.DlcUpdateJournal +import java.io.IOException +import java.nio.file.* +import java.util.function.BooleanSupplier + +class DlcSyncAdapter @JvmOverloads constructor(private val quiesce: Quiesce = Quiesce {}) : Adapter { + private val configuredRoots = DlcConfig.getDlcRoots().map { it.toAbsolutePath().normalize() } + private val content = DlcSyncContent(configuredRoots) + private val game = DlcPaths.gameDir().toAbsolutePath().normalize() + private val aliases = mutableMapOf() + private val previous = mutableMapOf() + override fun quiesce(canceled: BooleanSupplier) = quiesce.await(canceled) + override fun workDirectory(): Path = configuredRoots.first().resolve("tmp/sync") + private fun roots() = configuredRoots + private fun id(root: Path, path: Path?): String { + val normalized = path?.toAbsolutePath()?.normalize() ?: throw SyncFailure("invalid_path") + if (!normalized.startsWith(root) || normalized == root) throw SyncFailure("outside_root") + return "${roots().indexOf(root)}:" + root.relativize(normalized).toString().replace('\\', '/') + } + override fun resolve(id: String): Path { + if (id.startsWith("manager:")) return content.managerPath(id) + val colon = id.indexOf(':') + if (colon <= 0) throw SyncFailure("invalid_resource") + val index = id.substring(0, colon).toIntOrNull() ?: throw SyncFailure("invalid_root") + if (index !in roots().indices) throw SyncFailure("root_missing") + val relative = id.substring(colon + 1) + if (relative.isBlank() || relative.contains('\\') || relative.contains(':') || relative.split('/').any { it.isEmpty() || it == "." || it == ".." }) throw SyncFailure("invalid_path") + val root = roots()[index] + val path = aliases[id] ?: root.resolve(relative).normalize() + if (!path.startsWith(root) || path == root) throw SyncFailure("outside_root") + var part: Path? = path + while (part != null && part.startsWith(root)) { if (Files.isSymbolicLink(part)) throw SyncFailure("symbolic_path"); part = part.parent } + return path + } + private fun file(root: Path, path: Path?, config: Boolean): File = file(root, path, path, config) + private fun file(root: Path, path: Path?, canonical: Path?, config: Boolean): File { + if (path == null) throw SyncFailure("filename_missing") + val id = id(root, canonical) + if (!config) aliases[id] = path.toAbsolutePath().normalize() + resolve(id) + return File(id, if (Files.exists(path)) com.cozooo.dlc_manager.sync.SyncArchive.size(path) else -1, + if (Files.exists(path)) DlcFileMatch.hash(path) else null) + } + private fun items() = DlcScanner().scan(false).values.filterNotNull().flatMap { it.filterNotNull() } + private fun remoteItem(root: Path, entry: DlcRemoteEntry, scanned: List): Item { + val data = entry.getTargetPath() + var enabled: Boolean? = if (entry.getType() == DlcType.DLC_PACK) null else false + for (item in scanned) if (data != null && item.getSourcePath()?.toAbsolutePath()?.normalize() == data.toAbsolutePath().normalize()) enabled = item.isEnabled() + val resources = mutableListOf() + if (data != null) resources.add(file(root, data, entry.resolveTargetPath(data.fileName.toString()), false)) + return Item(id(root, entry.getConfigPath()), entry.getDlcIdentifier(), file(root, entry.getConfigPath(), true), resources, enabled, entry.getDisplayNames(), entry.getTips()) + } + private fun requiredItem(root: Path, entry: DlcRequiredEntry): Item { + var name = DlcRequiredService.resolveExistingFileName(entry) + if (name.isNullOrBlank()) name = entry.getRequiredFileName() + val resources = mutableListOf() + if (!name.isNullOrBlank()) resources.add(file(root, entry.resolveRequiredPath(name), entry.resolveDownloadPath(name), false)) + return Item(id(root, entry.getConfigPath()), entry.getIdentifier(), file(root, entry.getConfigPath(), true), resources, null, localized(entry.getConfigPath(), "DisplayName"), localized(entry.getConfigPath(), "tip")) + } + private fun localized(config: Path?, category: String): Map { + if (config == null) return emptyMap() + val obj = DlcDcConfigIO.getObjectIgnoreCase(DlcDcConfigIO.read(config, DlcDcConfigIO.Layout.GENERIC), category) + val result = linkedMapOf() + if (obj != null) for ((key, value) in obj.entrySet()) { + val language = DlcDcConfigIO.normalizeLangKey(key) ?: continue + if (value.isJsonPrimitive && value.asJsonPrimitive.isString) + result[language] = value.asString + } + return result + } + private fun synchronizable(path: Path?): Boolean { + if (path == null) return false + val name = path.fileName.toString() + return DlcDcFormatUtil.isDcName(name) && !name.equals("notice.dc", true) && !name.equals("_metadata.dc", true) + } + private fun validateConfigurations(root: Path, category: String, depth: Int) { + val directory = root.resolve(category) + if (!Files.exists(directory)) return + resolve(id(root, directory)) + Files.walk(directory, depth).use { paths -> + for (path in paths.filter { Files.isRegularFile(it) }.filter(::synchronizable).filter { !it.startsWith(root.resolve("remote/redirect")) }.toList()) { + if (Thread.currentThread().isInterrupted) throw SyncFailure("comparison_canceled") + resolve(id(root, path)); com.cozooo.dclanguage.DcDocuments.read(path) + } + } + } + override fun manifest(): List { + val result = mutableListOf() + val scanned = items() + for (root in roots()) { + validateConfigurations(root, "remote", Int.MAX_VALUE); validateConfigurations(root, "required", 1) + for (entry in DlcRemoteLoader.load(root)) { if (!synchronizable(entry.getConfigPath())) continue; if (Thread.currentThread().isInterrupted) throw SyncFailure("comparison_canceled"); result.add(remoteItem(root, entry, scanned)) } + for (entry in DlcRequiredLoader.load(root)) { if (!synchronizable(entry.getConfigPath())) continue; if (Thread.currentThread().isInterrupted) throw SyncFailure("comparison_canceled"); result.add(requiredItem(root, entry)) } + } + result.addAll(content.manifest()) + return result + } + private fun auxiliary(key: String) = key.startsWith("manager:") || key.substringAfter(':').startsWith("preset/") || key.substringAfter(':').startsWith("update/") + override fun item(key: String): Item { + validateKey(key) + if (auxiliary(key)) return content.item(key, resolve(key)) + val root = roots()[key.substringBefore(':').toInt()] + if (key.substringAfter(':').startsWith("remote/")) { + val entry = DlcRemoteLoader.loadSingle(resolve(key), root) ?: throw SyncFailure("invalid_remote") + return remoteItem(root, entry, items()) + } + return requiredItem(root, required(key)) + } + override fun validateKey(key: String) { + val path = resolve(key) + val relative = key.substringAfter(':') + if (key.startsWith("manager:")) return + if (relative.startsWith("preset/") && DlcDcFormatUtil.isDcName(relative)) return + if (relative.startsWith("update/") && relative.indexOf('/', 7) < 0 && relative.lowercase(java.util.Locale.ROOT).endsWith(".zip")) return + if ((!relative.startsWith("remote/") && !relative.startsWith("required/")) || relative.startsWith("remote/redirect/") || + !DlcDcFormatUtil.isDcName(relative) || path.fileName.toString().equals("notice.dc", true) || path.fileName.toString().equals("_metadata.dc", true)) throw SyncFailure("not_config") + if (!previous.containsKey(key) && relative.startsWith("remote/") && Files.isRegularFile(path)) { + val entry = DlcRemoteLoader.loadSingle(path, roots()[key.substringBefore(':').toInt()]) + if (entry?.getTargetPath() != null) for (item in items()) { + if (item.getSourcePath()?.toAbsolutePath()?.normalize() == entry.getTargetPath()?.toAbsolutePath()?.normalize()) previous[key] = item + } + } + } + override fun validateIncoming(item: Item) { + if (com.cozooo.dlc_manager.sync.SyncModel.category(item) == Category.UPDATE && item.key !in com.cozooo.dlc_manager.sync.SyncModel.eligibleUpdates(listOf(item), content.manifest())) + throw SyncFailure("update_version") + } + override fun writeSettings(key: String, settings: com.google.gson.JsonObject, journal: Journal) { + if (key != "manager:config.dc") throw SyncFailure("not_settings") + DlcSyncSettings.write(content.managerPath(key), settings, journal) + } + override fun journal(directory: Path): Journal { + val presetPaths = com.cozooo.dlc_manager.client.preset.DlcPresetRuntimeState.getAppliedDisabledRelativePathsByPreset() + val journal = DlcUpdateJournal.openSync(game, directory) + try { + mark(directory, "pending") + com.cozooo.dlc_manager.client.preset.DlcPresetStateStore.getStatePath()?.let(journal::capture) + for (root in roots()) { + journal.capture(root.resolve("tmp/state.dc")) + journal.capture(root.resolve("tmp/dlc/history.dc")) + journal.capture(root.resolve("tmp/required/history.dc")) + } + } catch (error: Exception) { journal.close(); throw error } + return object : Journal { + override fun capture(path: Path) = journal.capture(path) + override fun rollback() { journal.rollback(); reload(); com.cozooo.dlc_manager.client.preset.DlcPresetRuntimeState.setAppliedDisabledRelativePathsByPreset(presetPaths); mark(directory, "restored") } + override fun commit() = mark(directory, "committed") + override fun close() = journal.close() + } + } + override fun prepare(key: String, force: Boolean, canceled: BooleanSupplier, progress: Progress) { + validateKey(key) + if (auxiliary(key)) return + val config = resolve(key) + val root = roots()[key.substringBefore(':').toInt()] + DlcUpdateJournal.beforeWrite(config) + if (key.substringAfter(':').startsWith("remote/")) { + val entry = DlcRemoteLoader.loadSingle(config, root) ?: throw SyncFailure("invalid_remote") + if (force && entry.getTargetPath() != null && Files.exists(entry.getTargetPath())) { + DlcUpdateJournal.beforeWrite(entry.getTargetPath()); DlcResourceHistory.delete(entry.getTargetPath() ?: throw SyncFailure("remote_missing")) + } + if (entry.getTargetPath() == null || !Files.exists(entry.getTargetPath())) DlcRemoteService.downloadOne(entry, + { done, total -> progress.update("downloading", key, done, total) }, { canceled.asBoolean }) + } else { + val entry = required(key) + val existing = DlcRequiredService.resolveExistingFileName(entry) + if (force && existing != null) { val path = entry.resolveRequiredPath(existing) ?: throw SyncFailure("resource_missing", key); DlcUpdateJournal.beforeWrite(path); DlcResourceHistory.delete(path) } + val available = DlcRequiredService.resolveExistingFileName(entry) + if (available == null || entry.resolveRequiredPath(available) == null || !Files.isRegularFile(entry.resolveRequiredPath(available))) DlcRequiredService.downloadOne(entry, + { done, total -> progress.update("downloading", key, done, total) }, { canceled.asBoolean }) + } + } + private fun required(key: String): DlcRequiredEntry { + val config = resolve(key) + for (root in roots()) for (entry in DlcRequiredLoader.load(root)) if (entry.getConfigPath()?.toAbsolutePath()?.normalize() == config) return entry + throw SyncFailure("invalid_required") + } + override fun apply(key: String, enabled: Boolean?, journal: Journal, canceled: BooleanSupplier): Boolean { + validateKey(key) + if (canceled.asBoolean) throw SyncFailure("canceled") + if (auxiliary(key)) { + val value = content.item(key, resolve(key)) + if (com.cozooo.dlc_manager.sync.SyncModel.category(value) == Category.UPDATE) { validateIncoming(value); return false } + if (com.cozooo.dlc_manager.sync.SyncModel.category(value) == Category.PRESET) return enabled == null || DlcSyncPresets.apply(value.identifier ?: throw SyncFailure("preset_missing", key), enabled, canceled) + return true + } + if (key.substringAfter(':').startsWith("required/")) { + val entry = required(key) + DlcRequiredInstaller.install(entry) { path -> if (canceled.asBoolean) throw SyncFailure("canceled"); journal.capture(path) } + return entry.resolveInstallationTargets(DlcRequiredService.resolveExistingFileName(entry)).isNotEmpty() + } + val root = roots()[key.substringBefore(':').toInt()] + val remote = DlcRemoteLoader.loadSingle(resolve(key), root) ?: throw SyncFailure("remote_missing") + val target = remote.getTargetPath() ?: throw SyncFailure("remote_missing") + if (!Files.exists(target)) throw SyncFailure("remote_missing") + if (remote.getType() == DlcType.DLC_PACK) return false + for (item in items()) if (item.getSourcePath()?.toAbsolutePath()?.normalize() == target.toAbsolutePath().normalize()) { + val old = previous[key] + val restart = item.isEnabled() || enabled == true || old?.isEnabled() == true + if (old != null && old.getId() != item.getId() && old.isEnabled()) { old.setEnabled(false); DlcApplier.applySynced(old) } + if (enabled != null) item.setEnabled(enabled) + DlcApplier.applySynced(item) + return restart + } + throw SyncFailure("apply") + } + private fun mark(directory: Path, status: String) { + val data = com.google.gson.JsonObject(); data.addProperty("status", status) + val temp = directory.resolve("sync.pending.dc") + Files.createDirectories(directory) + if (!DlcDcConfigIO.write(temp, data, DlcDcConfigIO.Layout.GENERIC)) throw SyncFailure("recovery_save") + try { Files.move(temp, directory.resolve("sync.dc"), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) } + catch (_: AtomicMoveNotSupportedException) { Files.move(temp, directory.resolve("sync.dc"), StandardCopyOption.REPLACE_EXISTING) } + } + override fun recoverOperation(operation: String) { + java.util.UUID.fromString(operation) + restore(workDirectory().resolve(operation), true) + } + override fun recover() = restore(workDirectory(), false) + private fun restore(directory: Path, committed: Boolean) { + if (!Files.isDirectory(directory)) return + var restored = false + Files.walk(directory).use { paths -> + for (path in paths.filter { it.fileName.toString() == "sync.dc" }.sorted(Comparator.reverseOrder()).toList()) { + val data = DlcDcConfigIO.read(path, DlcDcConfigIO.Layout.GENERIC) ?: throw SyncFailure("invalid_recovery", "$path") + val status = DlcDcConfigIO.getStringIgnoreCase(data, "status") + if (status != "pending" && !(committed && status == "committed")) continue + DlcUpdateJournal.open(game, path.parent).use { it.rollback() } + mark(path.parent, "restored") + restored = true + } + } + reload() + if (restored) com.cozooo.dlc_manager.client.preset.DlcPresetRuntimeState.setAppliedDisabledRelativePathsByPreset(com.cozooo.dlc_manager.client.preset.DlcPresetRuntimeState.getDesiredDisabledRelativePathsByPreset()) + } + override fun reload() { DlcStateConfig.reloadFromDisk(); DlcRuntimeState.updateItemIndex(items()); DlcSyncPresets.reload() } +} diff --git a/src/main/kotlin/com/cozooo/dlc_manager/client/sync/DlcSyncContent.kt b/src/main/kotlin/com/cozooo/dlc_manager/client/sync/DlcSyncContent.kt new file mode 100644 index 00000000..48d5ae27 --- /dev/null +++ b/src/main/kotlin/com/cozooo/dlc_manager/client/sync/DlcSyncContent.kt @@ -0,0 +1,96 @@ +package com.cozooo.dlc_manager.client.sync + +import com.cozooo.dlc_manager.core.* +import com.cozooo.dlc_manager.platform.DlcPlatformServices +import com.cozooo.dlc_manager.client.update.DlcUpdatePackage +import com.cozooo.dlc_manager.sync.SyncFailure +import com.cozooo.dlc_manager.sync.SyncModel.* +import com.google.gson.JsonObject +import java.io.IOException +import java.nio.file.* +import java.util.Locale + +class DlcSyncContent(private val roots: List) { + private val manager = DlcPlatformServices.configDir().resolve("dlc_manager").toAbsolutePath().normalize() + fun managerPath(key: String): Path { + if (key != "manager:config.dc") throw SyncFailure("not_settings") + val path = manager.resolve(key.removePrefix("manager:")) + var part: Path? = path + while (part != null && part.startsWith(manager)) { + if (Files.isSymbolicLink(part)) throw SyncFailure("symbolic_settings") + part = part.parent + } + return path + } + private fun configKey(key: String) = key.startsWith("manager:") || key.substringAfter(':').startsWith("preset/") + fun manifest(): List { + val result = mutableListOf() + result.add(item("manager:config.dc", managerPath("manager:config.dc"))) + for ((index, root) in roots.withIndex()) { + val presets = root.resolve("preset") + val updates = root.resolve("update") + if (Files.isDirectory(presets)) Files.walk(presets).use { paths -> + for (path in paths.filter { Files.isRegularFile(it) }.filter { DlcDcFormatUtil.isDcName(it.fileName.toString()) }.sorted().toList()) { + result.add(item("$index:" + root.relativize(path).toString().replace('\\', '/'), path)) + } + } + val version = DlcDcFormatUtil.resolveConfigPath(root.resolve("info"), "version") + if (version != null && Files.isRegularFile(version)) { + val value = DlcDcConfigIO.read(version, DlcDcConfigIO.Layout.GENERIC) + val base = DlcDcConfigIO.getStringIgnoreCase(value, "base") + val dlc = DlcDcConfigIO.getStringIgnoreCase(value, "dlc") + if (!base.isNullOrBlank() && !dlc.isNullOrBlank()) { + val key = "$index:info/version.dc" + result.add(Item(key, "version", file(key, version), emptyList(), null, emptyMap(), emptyMap(), Update(base, dlc, base, dlc, if (DlcPlatformServices.isDedicatedServer()) "server" else "client"))) + } + } + if (Files.isDirectory(updates)) Files.list(updates).use { paths -> + for (path in paths.filter { Files.isRegularFile(it) }.filter { it.fileName.toString().lowercase(Locale.ROOT).endsWith(".zip") }.sorted().toList()) { + if (DlcUpdatePackage.fromZip(path) != null) try { result.add(item("$index:update/${path.fileName}", path)) } catch (_: IOException) {} + } + } + } + return result + } + private fun file(key: String, path: Path): File { + val anchor = if (path.startsWith(manager)) manager else roots.firstOrNull { path.startsWith(it) } ?: throw SyncFailure("outside_roots") + var part: Path? = path + while (part != null && part.startsWith(anchor)) { + if (Files.isSymbolicLink(part)) throw SyncFailure("symbolic_file") + part = part.parent + } + return File(key, Files.size(path), DlcFileMatch.hash(path)) + } + fun item(key: String, path: Path): Item { + if (key.startsWith("manager:")) return DlcSyncSettings.item(key, path) + if (!Files.isRegularFile(path)) throw SyncFailure("file_missing", "$key") + if (configKey(key)) { + com.cozooo.dclanguage.DcDocuments.read(path) + val obj = DlcDcConfigIO.read(path, DlcDcConfigIO.Layout.GENERIC) + val identifier = DlcDcConfigIO.getStringIgnoreCase(DlcDcConfigIO.getObjectIgnoreCase(obj, "basic"), "identifier")?.takeIf { it.isNotBlank() } + ?: DlcDcConfigIO.getStringIgnoreCase(obj, "identifier") + val fallback = identifier?.trim()?.takeIf { it.isNotBlank() } ?: DlcDcFormatUtil.stripConfigExtension(path.fileName.toString()) + val names = localized(obj, "DisplayName") + val enabled = com.cozooo.dlc_manager.client.preset.DlcPresetStateStore.loadPresetStatus().getOrDefault(fallback, com.cozooo.dlc_manager.client.preset.DlcPresetStatus.OFF).isEnabled() + return Item(key, fallback, file(key, path), emptyList(), enabled, names, localized(obj, "tip")) + } + val pkg = DlcUpdatePackage.fromZip(path) ?: throw SyncFailure("invalid_update", "$key") + val manifest = pkg.getManifest() + val version = manifest.getVersion() ?: throw SyncFailure("invalid_update", "$key") + val base = version.getBase() + val old = version.getDlcOld() + if (base.isNullOrBlank() || old.isNullOrBlank()) throw SyncFailure("update_versions_missing", "$key") + val updated = version.getDlcNew()?.takeIf { it.isNotBlank() } ?: old + val next = version.getBaseNew()?.takeIf { it.isNotBlank() } ?: base + return Item(key, pkg.getName(), file(key, path), emptyList(), null, emptyMap(), manifest.getChangelog(), Update(base, old, next, updated, version.getPlatform())) + } + private fun localized(obj: JsonObject?, category: String): Map { + val values = DlcDcConfigIO.getObjectIgnoreCase(obj, category) + val result = linkedMapOf() + if (values != null) for ((key, value) in values.entrySet()) { + val language = DlcDcConfigIO.normalizeLangKey(key) ?: continue + if (value.isJsonPrimitive && value.asJsonPrimitive.isString) result[language] = value.asString + } + return result + } +} diff --git a/src/main/kotlin/com/cozooo/dlc_manager/client/sync/DlcSyncPresets.kt b/src/main/kotlin/com/cozooo/dlc_manager/client/sync/DlcSyncPresets.kt new file mode 100644 index 00000000..68183566 --- /dev/null +++ b/src/main/kotlin/com/cozooo/dlc_manager/client/sync/DlcSyncPresets.kt @@ -0,0 +1,39 @@ +package com.cozooo.dlc_manager.client.sync + +import com.cozooo.dlc_manager.sync.SyncFailure + +import com.cozooo.dlc_manager.client.preset.* +import com.cozooo.dlc_manager.core.* +import java.io.IOException +import java.util.function.BooleanSupplier + +internal object DlcSyncPresets { + fun apply(identifier: String, enabled: Boolean?, canceled: BooleanSupplier): Boolean { + if (!DlcConfig.isPresetFuncOn()) throw SyncFailure("presets_disabled") + val items = DlcScanner().scan(false).values.filterNotNull().flatMap { it.filterNotNull() } + DlcRuntimeState.updateItemIndex(items) + for (item in items) DlcRuntimeState.ensureBaseline(item.getId(), item.isEnabled()) + DlcPresetRuntimeState.reload(false) + if (!DlcPresetRuntimeState.hasPreset(identifier)) throw SyncFailure("preset_missing", "$identifier") + val desired = enabled ?: DlcPresetRuntimeState.isPresetEnabled(identifier) + if (!DlcPresetRuntimeState.setPresetEnabled(identifier, desired)) throw SyncFailure("preset_blocked", "$identifier") + var restart = false + for (item in items) { + if (canceled.asBoolean) throw SyncFailure("canceled") + val override = DlcRuntimeState.getOverride(item.getId()) ?: continue + if (override == item.isEnabled()) continue + item.setEnabled(override); DlcApplier.applySynced(item); restart = true + } + if (canceled.asBoolean) throw SyncFailure("canceled") + restart = DlcApplier.applyPresetConflictsSynced() || restart + DlcRuntimeState.updateItemIndex(DlcScanner().scan(false).values.filterNotNull().flatMap { it.filterNotNull() }) + DlcPresetRuntimeState.syncStatusFromRuntime() + if (DlcPresetStateStore.loadPresetStatus().getOrDefault(identifier, DlcPresetStatus.OFF).isEnabled() != desired) throw SyncFailure("preset_state_save", "$identifier") + return restart + } + fun reload() { + val enabled = DlcPresetStateStore.loadPresetStatus().filterValues { it.isEnabled() }.keys + DlcPresetRuntimeState.restoreEnabledPresets(enabled) + DlcPresetRuntimeState.reload(false) + } +} diff --git a/src/main/kotlin/com/cozooo/dlc_manager/client/sync/DlcSyncSettings.kt b/src/main/kotlin/com/cozooo/dlc_manager/client/sync/DlcSyncSettings.kt new file mode 100644 index 00000000..0a1eca57 --- /dev/null +++ b/src/main/kotlin/com/cozooo/dlc_manager/client/sync/DlcSyncSettings.kt @@ -0,0 +1,38 @@ +package com.cozooo.dlc_manager.client.sync + +import com.cozooo.dlc_manager.core.* +import com.cozooo.dlc_manager.sync.SyncFailure +import com.cozooo.dlc_manager.sync.SyncModel.* +import com.google.gson.JsonObject +import java.io.IOException +import java.nio.file.* +import java.nio.charset.StandardCharsets + +internal object DlcSyncSettings { + @JvmStatic fun item(key: String, path: Path): Item { + val values = DlcConfig.buildSyncConfigJson(read(path)) + val bytes = values.toString().toByteArray(StandardCharsets.UTF_8) + return Item(key, "config", File(key, bytes.size.toLong(), DlcFileMatch.hash(java.io.ByteArrayInputStream(bytes))), emptyList(), null, + emptyMap(), emptyMap(), null, values) + } + private fun read(path: Path): JsonObject = if (Files.exists(path)) com.cozooo.dclanguage.DcDocuments.read(path).toJson() else JsonObject() + @JvmStatic fun write(path: Path, incoming: JsonObject, journal: Journal) { + val values = DlcConfig.buildSyncConfigJson(incoming) + val original = read(path) + val previous = DlcConfig.buildSyncConfigJson(original) + for ((section, entries) in values.entrySet()) { + val category = existingKey(original, section) + var target = DlcDcConfigIO.getObjectIgnoreCase(original, category) + for ((key, value) in entries.asJsonObject.entrySet()) { + if (DlcDcConfigIO.getIgnoreCase(DlcDcConfigIO.getObjectIgnoreCase(incoming, section), key) == null || value == previous.getAsJsonObject(section).get(key)) continue + val destination = target ?: JsonObject().also { target = it; original.add(category, it) } + if (section == "modpack" && key == "updateGitRepo" && DlcDcConfigIO.getIgnoreCase(destination, "updateGithubRepo") != null) destination.add(existingKey(destination, "updateGithubRepo"), value.deepCopy()) + destination.add(existingKey(destination, key), value.deepCopy()) + } + } + journal.capture(path) + Files.createDirectories(path.parent) + if (!DlcDcConfigIO.write(path, original, DlcDcConfigIO.Layout.GENERIC)) throw SyncFailure("settings_save") + } + private fun existingKey(obj: JsonObject, key: String) = obj.keySet().firstOrNull { it.equals(key, true) } ?: key +} diff --git a/src/main/kotlin/com/cozooo/dlc_manager/client/update/DlcUpdateJournal.kt b/src/main/kotlin/com/cozooo/dlc_manager/client/update/DlcUpdateJournal.kt index 3e733b24..d8e47a3e 100644 --- a/src/main/kotlin/com/cozooo/dlc_manager/client/update/DlcUpdateJournal.kt +++ b/src/main/kotlin/com/cozooo/dlc_manager/client/update/DlcUpdateJournal.kt @@ -80,15 +80,23 @@ class DlcUpdateJournal private constructor(game: Path, directory: Path, private } override fun close() { + SYNC.remove() ACTIVE.remove() com.cozooo.dlc_manager.client.helper.DlcFileOpsHelperClient.requireImmediateOperations(false) } companion object { private val ACTIVE = ThreadLocal() + private val SYNC = ThreadLocal() + + @JvmStatic @Throws(IOException::class) + fun openSync(game: Path, directory: Path): DlcUpdateJournal = open(game, directory).also { SYNC.set(true) } + + @JvmStatic fun beforeSyncWrite(target: Path?) { if (SYNC.get() == true) beforeWrite(target) } @JvmStatic fun open(game: Path, directory: Path): DlcUpdateJournal { + SYNC.remove() val log = directory.resolve("recovery.dc") val saved = if (Files.exists(log)) DcDocuments.read(log).toJson() else JsonObject() val entries = if (saved.has("entries")) saved.getAsJsonArray("entries") else JsonArray() diff --git a/src/main/kotlin/com/cozooo/dlc_manager/core/DlcApplier.kt b/src/main/kotlin/com/cozooo/dlc_manager/core/DlcApplier.kt index bb88a71b..b17d5f21 100644 --- a/src/main/kotlin/com/cozooo/dlc_manager/core/DlcApplier.kt +++ b/src/main/kotlin/com/cozooo/dlc_manager/core/DlcApplier.kt @@ -89,6 +89,39 @@ object DlcApplier { return ApplyResult(messages, requiresRestart, attemptedHotReload, context.helperScheduled) } + @JvmStatic @Throws(IOException::class) + fun applySynced(item: DlcItem) { + val context = ApplyContext(mutableListOf()) + CURRENT_APPLY_CONTEXT.set(context) + try { + val all = DlcScanner().scan(false).values.filterNotNull().flatMap { it.filterNotNull() }.map { if (it.getId() == item.getId()) item else it } + DlcRuntimeState.updateItemIndex(all) + applyDlcConflictPaths(all) + val previous = DlcStateConfig.getState(item.getId()) + val targets = if (item.isEnabled()) applyItem(item) else { val removed = removeItem(item, previous); if (removed.messages.isNotEmpty()) throw IOException(removed.messages.joinToString("; ")); emptyList() } + if (context.messages.isNotEmpty()) throw IOException(context.messages.joinToString("; ")) + val state = DlcStateConfig.DlcState() + state.root = DlcPathUtil.toGameRelativePath(item.getDlcRoot()) + state.mode = item.getMatchMode() + state.enabled = item.isEnabled() + state.appliedTargets = targets.map { DlcPathUtil.toGameRelativePath(it) } + DlcStateConfig.setState(item.getId(), state) + DlcStateConfig.save() + } finally { CURRENT_APPLY_CONTEXT.remove() } + } + + @JvmStatic @Throws(IOException::class) + fun applyPresetConflictsSynced(): Boolean { + val context = ApplyContext(mutableListOf()) + CURRENT_APPLY_CONTEXT.set(context) + try { + val result = applyPresetConflictPaths() + if (result.messages.isNotEmpty()) throw IOException(result.messages.joinToString("; ")) + if (context.messages.isNotEmpty()) throw IOException(context.messages.joinToString("; ")) + return result.changed + } finally { CURRENT_APPLY_CONTEXT.remove() } + } + private fun applyDlcConflictPaths(items: Collection?): DlcConflictPathResult { val messages = ArrayList() val desiredPatterns = collectEnabledDlcConflictPathPatterns(items) @@ -654,6 +687,7 @@ object DlcApplier { @Throws(IOException::class) private fun copyWithHelperFallback(source: Path?, target: Path?) { + com.cozooo.dlc_manager.client.update.DlcUpdateJournal.beforeSyncWrite(target) if (source == null || target == null) { return } @@ -681,6 +715,8 @@ object DlcApplier { @Throws(IOException::class) private fun moveWithHelperFallback(source: Path?, target: Path?, replaceExisting: Boolean) { + com.cozooo.dlc_manager.client.update.DlcUpdateJournal.beforeSyncWrite(target) + com.cozooo.dlc_manager.client.update.DlcUpdateJournal.beforeSyncWrite(source) if (source == null || target == null) { return } @@ -713,6 +749,7 @@ object DlcApplier { @Throws(IOException::class) private fun deleteWithHelperFallback(path: Path?) { + com.cozooo.dlc_manager.client.update.DlcUpdateJournal.beforeSyncWrite(path) if (path == null) { return } diff --git a/src/main/kotlin/com/cozooo/dlc_manager/core/DlcConfig.kt b/src/main/kotlin/com/cozooo/dlc_manager/core/DlcConfig.kt index e62c4d5a..3293ac46 100644 --- a/src/main/kotlin/com/cozooo/dlc_manager/core/DlcConfig.kt +++ b/src/main/kotlin/com/cozooo/dlc_manager/core/DlcConfig.kt @@ -160,6 +160,55 @@ object DlcConfig { return root } + @JvmStatic fun buildSyncConfigJson(source: JsonObject?): JsonObject { + val defaults = JsonObject() + val paths = JsonObject() + paths.addProperty("dlcRoot", DEFAULT_DLC_ROOT) + paths.addProperty("taczRoot", DEFAULT_TACZ_ROOT) + paths.addProperty("resourcepackRoot", DEFAULT_RESOURCEPACK_ROOT) + paths.addProperty("datapackRoot", DEFAULT_DATAPACK_ROOT) + defaults.add("paths", paths) + val ui = JsonObject() + ui.addProperty("isEnableAllButtonShown", DEFAULT_ENABLE_ALL_SHOWN) + ui.addProperty("isUpdateManagerOn", DEFAULT_UPDATE_MANAGER_ON) + ui.addProperty("isSelfUpdateAutoCheckOn", DEFAULT_SELF_UPDATE_AUTO_CHECK_ON) + ui.addProperty("isPresetFuncOn", DEFAULT_PRESET_FUNC_ON) + ui.addProperty("WaitingTime", DEFAULT_ALARM_WAITING_TIME) + defaults.add("ui", ui) + val downloads = JsonObject() + downloads.addProperty("maxConcurrentDownloads", DEFAULT_MAX_CONCURRENT_DOWNLOADS) + downloads.addProperty("maxSegmentsPerFile", DEFAULT_MAX_SEGMENTS_PER_FILE) + downloads.addProperty("minSegmentMB", DEFAULT_MIN_SEGMENT_MB) + downloads.addProperty("maxRetryCount", DEFAULT_MAX_RETRY) + downloads.addProperty("connectTimeoutSeconds", DEFAULT_CONNECT_TIMEOUT_SECONDS) + downloads.addProperty("taskTimeoutSeconds", DEFAULT_TASK_TIMEOUT_SECONDS) + downloads.addProperty("downloadMirrorMode", DEFAULT_DOWNLOAD_MIRROR_MODE) + downloads.addProperty("downloadMirrorProbeCount", DEFAULT_DOWNLOAD_MIRROR_PROBE_COUNT) + downloads.addProperty("downloadMirrorProbeStrategy", DEFAULT_DOWNLOAD_MIRROR_PROBE_STRATEGY) + defaults.add("downloads", downloads) + val modpack = JsonObject() + modpack.addProperty("modpack", DEFAULT_MODPACK) + modpack.addProperty("modpackUrl", DEFAULT_MODPACK_URL) + modpack.addProperty("updateGitRepo", DEFAULT_UPDATE_GIT_REPO_URL) + modpack.addProperty("autoUpdate", DEFAULT_MODPACK_AUTO_UPDATE) + modpack.addProperty("forceUpdate", DEFAULT_MODPACK_FORCE_UPDATE) + defaults.add("modpack", modpack) + for ((section, entries) in defaults.entrySet()) { + val incoming = DlcDcConfigIO.getObjectIgnoreCase(source, section) + val values = entries.asJsonObject + for (key in values.keySet().toList()) { + val value = DlcDcConfigIO.getIgnoreCase(incoming, key) + ?: (if (section == "modpack" && key == "updateGitRepo") DlcDcConfigIO.getIgnoreCase(incoming, "updateGithubRepo") else null) ?: continue + if (!value.isJsonPrimitive) continue + val expected = values.getAsJsonPrimitive(key) + val actual = value.asJsonPrimitive + if (expected.isNumber && actual.isNumber) values.addProperty(key, value.asInt) + else if (expected.isBoolean && actual.isBoolean || expected.isString && actual.isString) values.add(key, value.deepCopy()) + } + } + return defaults + } + @JvmStatic fun buildSharedConfigJson(): JsonObject { val root = JsonObject() @@ -290,7 +339,7 @@ object DlcConfig { @JvmStatic fun getMaxConcurrentDownloads(): Int = if (maxConcurrentDownloads < 1) DEFAULT_MAX_CONCURRENT_DOWNLOADS else maxConcurrentDownloads @JvmStatic fun getMaxSegmentsPerFile(): Int = if (maxSegmentsPerFile < 1) DEFAULT_MAX_SEGMENTS_PER_FILE else maxSegmentsPerFile - @JvmStatic fun getMinSegmentBytes(): Long = (if (minSegmentMb < 1) DEFAULT_MIN_SEGMENT_MB else minSegmentMb).toLong() * 1024L * 1024L + @JvmStatic fun getMinSegmentBytes(): Long = (if (minSegmentMb < 1) DEFAULT_MIN_SEGMENT_MB else minSegmentMb).toLong() * DlcFileSize.MIB @JvmStatic fun getMaxRetryCount(): Int = if (maxRetryCount < 1) DEFAULT_MAX_RETRY else maxRetryCount @JvmStatic fun getConnectTimeoutMs(): Int = (if (connectTimeoutSeconds < 1) DEFAULT_CONNECT_TIMEOUT_SECONDS else connectTimeoutSeconds) * 1000 @JvmStatic fun getTaskTimeoutMs(): Int = (if (taskTimeoutSeconds < 1) DEFAULT_TASK_TIMEOUT_SECONDS else taskTimeoutSeconds) * 1000 @@ -347,7 +396,7 @@ object DlcConfig { @JvmStatic fun setMaxConcurrentDownloads(value: Int) { maxConcurrentDownloads = maxOf(1, value) } @JvmStatic fun setMaxSegmentsPerFile(value: Int) { maxSegmentsPerFile = maxOf(1, value) } @JvmStatic fun setMinSegmentBytes(value: Long) { - val mb = maxOf(1L, (value + (1024L * 1024L - 1)) / (1024L * 1024L)) + val mb = maxOf(1L, DlcFileSize.ceilMiB(value)) minSegmentMb = minOf(Int.MAX_VALUE.toLong(), mb).toInt() } @JvmStatic fun setMaxRetryCount(value: Int) { maxRetryCount = maxOf(1, value) } diff --git a/src/main/kotlin/com/cozooo/dlc_manager/core/DlcFileMatch.kt b/src/main/kotlin/com/cozooo/dlc_manager/core/DlcFileMatch.kt index 6e790e61..6695b335 100644 --- a/src/main/kotlin/com/cozooo/dlc_manager/core/DlcFileMatch.kt +++ b/src/main/kotlin/com/cozooo/dlc_manager/core/DlcFileMatch.kt @@ -1,15 +1,24 @@ package com.cozooo.dlc_manager.core +import java.io.EOFException import java.io.IOException +import java.nio.ByteBuffer import java.nio.charset.StandardCharsets import java.nio.file.Files +import java.nio.file.LinkOption import java.nio.file.Path +import java.nio.file.attribute.BasicFileAttributes import java.security.MessageDigest import java.util.Comparator import java.util.HexFormat +import java.util.concurrent.ConcurrentHashMap object DlcFileMatch { - private val scanHashes = ThreadLocal>() + private const val LIGHT_LIMIT = 128 * DlcFileSize.KIB + private val SAMPLE_BYTES = (64 * DlcFileSize.KIB).toInt() + private data class CachedHash(val stamp: String, val hash: String) + private val hashes = ConcurrentHashMap() + private val scanHashes = ThreadLocal>() @JvmStatic fun mode(value: String?): String = if (value.equals("name", true)) "name" else "hash" @JvmStatic fun beginScan() { scanHashes.set(HashMap()) } @JvmStatic fun endScan() { scanHashes.remove() } @@ -17,35 +26,90 @@ object DlcFileMatch { @JvmStatic @Throws(IOException::class) fun hash(path: Path): String { val normalized = path.toAbsolutePath().normalize() - val cache = scanHashes.get() - cache?.get(normalized)?.let { return it } + val stamp = stamp(normalized) + val scan = scanHashes.get() + var cached = scan?.get(normalized) + if (cached == null || cached.stamp != stamp) cached = hashes[normalized] + if (cached != null && cached.stamp == stamp) return cached.hash + val result = fullHash(normalized) + if (stamp != stamp(normalized)) throw IOException("Resource changed while hashing: $path") + val updated = CachedHash(stamp, result) + if (hashes.size >= 8192) hashes.clear() + hashes[normalized] = updated + scan?.put(normalized, updated) + return result + } + + private fun stamp(path: Path): String { val digest = MessageDigest.getInstance("SHA-256") - if (Files.isDirectory(normalized)) { - Files.walk(normalized).use { paths -> - for (child in paths.sorted(Comparator.comparing { p: Path -> normalized.relativize(p).toString() }).toList()) { - if (Files.isSymbolicLink(child)) throw IOException("Cannot hash symbolic link: $child") - digest.update((normalized.relativize(child).toString().replace('\\', '/') + "\u0000" + (if (Files.isDirectory(child)) "directory" else "file") + "\u0000").toByteArray(StandardCharsets.UTF_8)) - if (Files.isRegularFile(child)) { - digest.update((Files.size(child).toString() + "\u0000").toByteArray(StandardCharsets.UTF_8)) - update(digest, child) - } + val attributes = attributes(path) + if (attributes.isDirectory) Files.walk(path).use { paths -> + for (child in paths.sorted(Comparator.comparing { p: Path -> path.relativize(p).toString() }).toList()) { + checkInterrupted() + addStamp(digest, path.relativize(child).toString().replace('\\', '/'), child) + } + } else addStamp(digest, "", path) + return HexFormat.of().formatHex(digest.digest()) + } + + private fun addStamp(digest: MessageDigest, relative: String, path: Path) { + val attributes = attributes(path) + val value = relative + "\u0000" + (if (attributes.isDirectory) "directory" else "file") + "\u0000" + + attributes.size() + "\u0000" + attributes.lastModifiedTime() + "\u0000" + attributes.fileKey() + "\u0000" + digest.update(value.toByteArray(StandardCharsets.UTF_8)) + if (attributes.isRegularFile) { digest.update(lightHash(path).toByteArray(StandardCharsets.US_ASCII)); unchanged(path, attributes) } + } + + @JvmStatic @Throws(IOException::class) + fun lightHash(path: Path): String { + val before = attributes(path) + if (!before.isRegularFile) throw IOException("Not a regular resource: $path") + if (before.size() <= LIGHT_LIMIT) return fullHash(path) + val digest = MessageDigest.getInstance("SHA-256") + digest.update(ByteBuffer.allocate(java.lang.Long.BYTES).putLong(before.size()).array()) + Files.newByteChannel(path).use { channel -> + val buffer = ByteBuffer.allocate(SAMPLE_BYTES) + for (offset in longArrayOf(0, before.size() - buffer.capacity())) { + checkInterrupted(); buffer.clear(); channel.position(offset) + while (buffer.hasRemaining()) { + checkInterrupted() + if (channel.read(buffer) < 0) throw EOFException("Resource changed while hashing: $path") } + digest.update(buffer.array()) } - } else { - if (!Files.isRegularFile(normalized) || Files.isSymbolicLink(normalized)) throw IOException("Not a regular resource: $normalized") - update(digest, normalized) } - val result = HexFormat.of().formatHex(digest.digest()) - cache?.put(normalized, result) - return result + unchanged(path, before) + return "light-" + HexFormat.of().formatHex(digest.digest()) + } + + @JvmStatic @Throws(IOException::class) + fun fullHash(path: Path): String { + val normalized = path.toAbsolutePath().normalize() + val before = attributes(normalized) + val digest = MessageDigest.getInstance("SHA-256") + if (before.isDirectory) Files.walk(normalized).use { paths -> + for (child in paths.sorted(Comparator.comparing { p: Path -> normalized.relativize(p).toString() }).toList()) { + checkInterrupted() + val childAttributes = attributes(child) + digest.update((normalized.relativize(child).toString().replace('\\', '/') + "\u0000" + (if (childAttributes.isDirectory) "directory" else "file") + "\u0000").toByteArray(StandardCharsets.UTF_8)) + if (childAttributes.isRegularFile) { + digest.update((childAttributes.size().toString() + "\u0000").toByteArray(StandardCharsets.UTF_8)) + update(digest, child) + } + } + } else update(digest, normalized) + unchanged(normalized, before) + return HexFormat.of().formatHex(digest.digest()) } @JvmStatic @Throws(IOException::class) fun hash(input: java.io.InputStream): String { + checkInterrupted() val digest = MessageDigest.getInstance("SHA-256") - val buffer = ByteArray(65536) + val buffer = ByteArray(SAMPLE_BYTES) while (true) { val count = input.read(buffer) + checkInterrupted() if (count == -1) break digest.update(buffer, 0, count) } @@ -53,14 +117,34 @@ object DlcFileMatch { } private fun update(digest: MessageDigest, file: Path) { + val before = attributes(file) Files.newInputStream(file).use { input -> - val buffer = ByteArray(65536) + val buffer = ByteArray(SAMPLE_BYTES) while (true) { val count = input.read(buffer) + checkInterrupted() if (count == -1) break digest.update(buffer, 0, count) } } + unchanged(file, before) + } + + private fun attributes(path: Path): BasicFileAttributes { + checkInterrupted() + val attributes = Files.readAttributes(path, BasicFileAttributes::class.java, LinkOption.NOFOLLOW_LINKS) + if (!attributes.isRegularFile && !attributes.isDirectory) throw IOException("Not a regular resource or directory: $path") + return attributes + } + + private fun unchanged(path: Path, before: BasicFileAttributes) { + val after = attributes(path) + if (before.isDirectory != after.isDirectory || before.size() != after.size() || + before.lastModifiedTime() != after.lastModifiedTime() || before.fileKey() != after.fileKey()) throw IOException("Resource changed while hashing: $path") + } + + private fun checkInterrupted() { + if (Thread.currentThread().isInterrupted) throw IOException("Resource validation canceled") } @JvmStatic @Throws(IOException::class) diff --git a/src/main/kotlin/com/cozooo/dlc_manager/core/DlcFileSize.kt b/src/main/kotlin/com/cozooo/dlc_manager/core/DlcFileSize.kt new file mode 100644 index 00000000..8659a793 --- /dev/null +++ b/src/main/kotlin/com/cozooo/dlc_manager/core/DlcFileSize.kt @@ -0,0 +1,26 @@ +package com.cozooo.dlc_manager.core + +import java.util.Locale + +object DlcFileSize { + const val KIB = 1024L + const val MIB = KIB * 1024L + const val GIB = MIB * 1024L + private val units = arrayOf("B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB") + + @JvmStatic fun ceilMiB(bytes: Long): Long { + if (bytes <= 0) return 0 + return bytes / MIB + if (bytes % MIB == 0L) 0 else 1 + } + + @JvmStatic fun formatProgress(done: Long, total: Long): String = format(done) + if (total < 0) "" else "/" + format(total) + + @JvmStatic fun format(bytes: Long): String { + if (bytes < 0) return "--" + if (bytes < KIB) return "$bytes B" + var value = bytes.toDouble() + var unit = 0 + while (value >= KIB && unit < units.size - 1) { value /= KIB; unit++ } + return String.format(Locale.ROOT, "%.1f %s", value, units[unit]) + } +} diff --git a/src/main/kotlin/com/cozooo/dlc_manager/core/DlcResourceHistory.kt b/src/main/kotlin/com/cozooo/dlc_manager/core/DlcResourceHistory.kt index 7fe4408a..b07e32b3 100644 --- a/src/main/kotlin/com/cozooo/dlc_manager/core/DlcResourceHistory.kt +++ b/src/main/kotlin/com/cozooo/dlc_manager/core/DlcResourceHistory.kt @@ -285,6 +285,7 @@ object DlcResourceHistory { @JvmStatic @Throws(IOException::class) fun copy(source: Path, target: Path) { + com.cozooo.dlc_manager.client.update.DlcUpdateJournal.beforeSyncWrite(target) if (Files.isSymbolicLink(source)) throw IOException("Cannot copy symbolic link: $source") if (Files.isDirectory(source)) { Files.createDirectories(target) diff --git a/src/main/kotlin/com/cozooo/dlc_manager/server/DlcServerDownloadService.kt b/src/main/kotlin/com/cozooo/dlc_manager/server/DlcServerDownloadService.kt index 993f8470..18df2a10 100644 --- a/src/main/kotlin/com/cozooo/dlc_manager/server/DlcServerDownloadService.kt +++ b/src/main/kotlin/com/cozooo/dlc_manager/server/DlcServerDownloadService.kt @@ -504,6 +504,9 @@ object DlcServerDownloadService { @JvmStatic fun startRemoteDownload(dlcRoot: Path?, keys: List?, lang: String?): DlcServerRemoteStatus { synchronized(REMOTE_LOCK) { + if (com.cozooo.dlc_manager.sync.SyncEndpoint.busy(DlcConfig.getDlcRoot().resolve("tmp/sync"))) { + return DlcServerRemoteStatus().also { it.finished = true; it.error = "DLC synchronization is in progress" } + } val current = remoteSession if (current != null && current.running.get()) { return current.toStatus(lang) @@ -544,6 +547,9 @@ object DlcServerDownloadService { @JvmStatic fun startRequiredDownload(dlcRoot: Path?, ids: List?, lang: String?): DlcServerRequiredStatus { synchronized(REQUIRED_LOCK) { + if (com.cozooo.dlc_manager.sync.SyncEndpoint.busy(DlcConfig.getDlcRoot().resolve("tmp/sync"))) { + return DlcServerRequiredStatus().also { it.finished = true; it.error = "DLC synchronization is in progress" } + } val current = requiredSession if (current != null && current.running.get()) { return current.toStatus(lang) diff --git a/src/main/kotlin/com/cozooo/dlc_manager/server/DlcServerService.kt b/src/main/kotlin/com/cozooo/dlc_manager/server/DlcServerService.kt index a170f387..84dcdbe5 100644 --- a/src/main/kotlin/com/cozooo/dlc_manager/server/DlcServerService.kt +++ b/src/main/kotlin/com/cozooo/dlc_manager/server/DlcServerService.kt @@ -117,9 +117,19 @@ object DlcServerService { return "$base+$dlc" } + private val SYNC_HEARTBEATS = java.util.concurrent.Executors.newSingleThreadScheduledExecutor { task -> Thread(task, "dlc-sync-heartbeat").also { it.isDaemon = true } } + @JvmStatic fun handleClient(socket: Socket) { try { + com.cozooo.dlc_manager.sync.SyncEndpoint(com.cozooo.dlc_manager.client.sync.DlcSyncAdapter { canceled -> + DlcServerDownloadService.cancelRemoteDownload(); DlcServerDownloadService.cancelRequiredDownload() + val deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(30) + while (DlcServerDownloadService.getRemoteDownloadStatus().running || DlcServerDownloadService.getRequiredDownloadStatus().running) { + if (canceled.asBoolean || System.nanoTime() > deadline) throw com.cozooo.dlc_manager.sync.SyncFailure("server_downloads_stop") + Thread.sleep(50) + } + }).use { sync -> socket.use { client -> BufferedReader(InputStreamReader(client.getInputStream(), StandardCharsets.UTF_8)).use { reader -> BufferedWriter(OutputStreamWriter(client.getOutputStream(), StandardCharsets.UTF_8)).use { writer -> @@ -153,75 +163,98 @@ object DlcServerService { writeError(writer, "unauthorized") continue } - when (type) { - "list" -> writeList(writer, clientLang) - "remote_list" -> handleRemoteList(writer, clientLang) - "remote_download_start" -> handleRemoteDownloadStart(request, writer, clientLang) - "remote_download_status" -> handleRemoteDownloadStatus(writer, clientLang) - "remote_download_cancel" -> handleRemoteDownloadCancel(writer) - "remote_download" -> handleRemoteDownload(writer) - "required_list" -> handleRequiredList(writer, clientLang) - "required_download_start" -> handleRequiredDownloadStart(request, writer, clientLang) - "required_download_status" -> handleRequiredDownloadStatus(writer, clientLang) - "required_download_cancel" -> handleRequiredDownloadCancel(writer) - "required_uninstall" -> handleRequiredUninstall(request, writer) - "required_apply" -> handleRequiredApply(request, writer) - "update_state" -> writeUpdateState(writer) - "update_list" -> writeUpdateList(writer) - "update_apply" -> handleUpdateApply(request, writer) - "update_rollback" -> handleUpdateRollback(request, writer) - "update_import" -> handleUpdateImport(request, writer) - "update_import_url" -> handleUpdateImportUrl(request, writer) - "pack_list" -> handlePackList(writer, clientLang) - "pack_install" -> handlePackInstall(request, writer) - "pack_uninstall" -> handlePackUninstall(request, writer) - "pack_delete" -> handlePackDelete(request, writer) - "pack_import" -> handlePackImport(request, writer) - "pack_import_url" -> handlePackImportUrl(request, writer) - "pack_order" -> handlePackOrder(request, writer) - "pack_apply_start" -> handlePackApplyStart(request, writer) - "pack_apply_status" -> handlePackApplyStatus(writer) - "pack_apply_pause" -> handlePackApplyPause(writer) - "pack_apply_resume" -> handlePackApplyResume(writer) - "pack_apply_cancel" -> handlePackApplyCancel(writer) - "set" -> handleSet(request, overrides, writer) - "apply" -> { - handleApply(overrides, writer) - overrides.clear() - } - "push_init" -> pushState = handlePushInit(request, writer) - "push_chunk" -> handlePushChunk(request, pushState, writer) - "push_finish" -> { - handlePushFinish(pushState, writer) - pushState = null - } - "file_list" -> handleFileList(request, writer) - "file_delete" -> handleFileDelete(request, writer) - "file_upload_init" -> uploadState = handleFileUploadInit(request, writer) - "file_upload_chunk" -> handleFileUploadChunk(request, uploadState, writer) - "file_upload_finish" -> { - handleFileUploadFinish(uploadState, writer) - uploadState = null + if (type == "dlc_sync") { handleSync(sync, request, writer); continue } + val guard = try { com.cozooo.dlc_manager.sync.SyncEndpoint.guard(DlcConfig.getDlcRoot().resolve("tmp/sync")) } + catch (busy: IOException) { writeError(writer, busy.message ?: "DLC synchronization is in progress"); continue } + guard.use { + when (type) { + "list" -> writeList(writer, clientLang) + "remote_list" -> handleRemoteList(writer, clientLang) + "remote_download_start" -> handleRemoteDownloadStart(request, writer, clientLang) + "remote_download_status" -> handleRemoteDownloadStatus(writer, clientLang) + "remote_download_cancel" -> handleRemoteDownloadCancel(writer) + "remote_download" -> handleRemoteDownload(writer) + "required_list" -> handleRequiredList(writer, clientLang) + "required_download_start" -> handleRequiredDownloadStart(request, writer, clientLang) + "required_download_status" -> handleRequiredDownloadStatus(writer, clientLang) + "required_download_cancel" -> handleRequiredDownloadCancel(writer) + "required_uninstall" -> handleRequiredUninstall(request, writer) + "required_apply" -> handleRequiredApply(request, writer) + "update_state" -> writeUpdateState(writer) + "update_list" -> writeUpdateList(writer) + "update_apply" -> handleUpdateApply(request, writer) + "update_rollback" -> handleUpdateRollback(request, writer) + "update_import" -> handleUpdateImport(request, writer) + "update_import_url" -> handleUpdateImportUrl(request, writer) + "pack_list" -> handlePackList(writer, clientLang) + "pack_install" -> handlePackInstall(request, writer) + "pack_uninstall" -> handlePackUninstall(request, writer) + "pack_delete" -> handlePackDelete(request, writer) + "pack_import" -> handlePackImport(request, writer) + "pack_import_url" -> handlePackImportUrl(request, writer) + "pack_order" -> handlePackOrder(request, writer) + "pack_apply_start" -> handlePackApplyStart(request, writer) + "pack_apply_status" -> handlePackApplyStatus(writer) + "pack_apply_pause" -> handlePackApplyPause(writer) + "pack_apply_resume" -> handlePackApplyResume(writer) + "pack_apply_cancel" -> handlePackApplyCancel(writer) + "set" -> handleSet(request, overrides, writer) + "apply" -> { + handleApply(overrides, writer) + overrides.clear() + } + "push_init" -> pushState = handlePushInit(request, writer) + "push_chunk" -> handlePushChunk(request, pushState, writer) + "push_finish" -> { + handlePushFinish(pushState, writer) + pushState = null + } + "file_list" -> handleFileList(request, writer) + "file_delete" -> handleFileDelete(request, writer) + "file_upload_init" -> uploadState = handleFileUploadInit(request, writer) + "file_upload_chunk" -> handleFileUploadChunk(request, uploadState, writer) + "file_upload_finish" -> { + handleFileUploadFinish(uploadState, writer) + uploadState = null + } + "file_download_init" -> handleFileDownload(request, writer) + "config_get" -> handleConfigGet(writer) + "config_set" -> handleConfigSet(request, writer) + "rcon_info" -> handleRconInfo(writer) + "rcon_get" -> handleRconGet(writer) + "rcon_set" -> handleRconSet(request, writer) + "rcon_exec" -> handleRconExec(request, writer) + "server_restart" -> handleServerRestart(writer) + else -> writeError(writer, "unknown request") } - "file_download_init" -> handleFileDownload(request, writer) - "config_get" -> handleConfigGet(writer) - "config_set" -> handleConfigSet(request, writer) - "rcon_info" -> handleRconInfo(writer) - "rcon_get" -> handleRconGet(writer) - "rcon_set" -> handleRconSet(request, writer) - "rcon_exec" -> handleRconExec(request, writer) - "server_restart" -> handleServerRestart(writer) - else -> writeError(writer, "unknown request") } } } } } + } } catch (_: IOException) { } } @Throws(IOException::class) + private fun handleSync(sync: com.cozooo.dlc_manager.sync.SyncEndpoint, request: JsonObject, writer: BufferedWriter) { + val actor = Thread.currentThread() + val gate = Any() + var finished = false + val action = if (request.has("action")) request.get("action").asString else "" + val heartbeat = if (action in setOf("prepare", "read", "read_end", "write_end", "apply", "rollback", "recover", "recover_operation")) { + SYNC_HEARTBEATS.scheduleWithFixedDelay({ + val progress = JsonObject(); progress.addProperty("type", "dlc_sync_progress") + if (request.has("requestId")) progress.add("requestId", request.get("requestId")) + try { synchronized(writer) { writeLine(writer, progress) } } + catch (_: IOException) { synchronized(gate) { if (!finished) { sync.disconnectCurrent(); actor.interrupt() } } } + }, 1, 1, java.util.concurrent.TimeUnit.SECONDS) + } else null + val response = try { sync.call(request) } finally { synchronized(gate) { finished = true }; heartbeat?.cancel(false) } + synchronized(writer) { writeLine(writer, response) } + } + private fun handleHello(request: JsonObject, writer: BufferedWriter, remoteAddress: String): Boolean { val expected = DlcConfig.getServerPassword() val provided = if (request.has("password")) request.get("password").asString else "" diff --git a/src/main/kotlin/com/cozooo/dlc_manager/sync/SyncArchive.kt b/src/main/kotlin/com/cozooo/dlc_manager/sync/SyncArchive.kt new file mode 100644 index 00000000..e3ca5376 --- /dev/null +++ b/src/main/kotlin/com/cozooo/dlc_manager/sync/SyncArchive.kt @@ -0,0 +1,122 @@ +package com.cozooo.dlc_manager.sync + +import com.cozooo.dlc_manager.core.DlcFileMatch +import com.cozooo.dlc_manager.core.DlcResourceHistory +import java.io.IOException +import java.nio.file.* +import java.util.function.BooleanSupplier +import java.util.zip.* + +object SyncArchive { + @JvmStatic @Throws(IOException::class) fun size(path: Path): Long { + if (!Files.isDirectory(path)) return Files.size(path) + var size = 0L + Files.walk(path).use { paths -> + for (child in paths.toList()) { + check { false } + if (Files.isSymbolicLink(child)) throw SyncFailure("symbolic_resource") + if (Files.isRegularFile(child)) size = Math.addExact(size, Files.size(child)) + } + } + return size + } + @JvmStatic @Throws(IOException::class) fun inventory(root: Path, canceled: BooleanSupplier): List { + val result = mutableListOf() + if (!Files.isDirectory(root)) return result + Files.walk(root).use { paths -> + for (path in paths.sorted().toList()) { + check(canceled) + if (Files.isSymbolicLink(path)) throw SyncFailure("symbolic_resource") + if (Files.isRegularFile(path)) result.add(SyncModel.File(root.relativize(path).toString().replace('\\', '/'), Files.size(path), DlcFileMatch.hash(path))) + } + } + return result + } + @JvmStatic @Throws(IOException::class) fun reusable(root: Path, existing: List, canceled: BooleanSupplier): List { + val files = existing.associateBy { it.id } + return inventory(root, canceled).filter { SyncModel.same(it, files[it.id]) } + } + private fun child(root: Path, name: String): Path { + if (name.isBlank() || name.contains('\\') || name.contains(':') || name.startsWith('/') || name.split('/').any { it.isEmpty() || it == "." || it == ".." }) throw SyncFailure("invalid_reuse_path") + val path = root.resolve(name).normalize() + if (!path.startsWith(root) || path == root) throw SyncFailure("resource_escape") + var part: Path? = path + while (part != null && part.startsWith(root)) { + if (Files.isSymbolicLink(part)) throw SyncFailure("symbolic_resource") + part = part.parent + } + return path + } + @JvmStatic @Throws(IOException::class) fun pack(source: Path, archive: Path, canceled: BooleanSupplier) = pack(source, archive, canceled, emptyList()) + @JvmStatic @Throws(IOException::class) fun pack(source: Path, archive: Path, canceled: BooleanSupplier, reused: List) { + val omitted = reused.map { it.id }.toSet() + ZipOutputStream(Files.newOutputStream(archive)).use { output -> + Files.walk(source).use { paths -> + output.setLevel(0) + val buffer = ByteArray(65536) + for (child in paths.sorted().toList()) { + check(canceled) + if (child == source) continue + if (Files.isSymbolicLink(child)) throw SyncFailure("symbolic_resource") + val relative = source.relativize(child).toString().replace('\\', '/') + if (Files.isRegularFile(child) && relative in omitted) continue + output.putNextEntry(ZipEntry(relative + if (Files.isDirectory(child)) "/" else "")) + if (Files.isRegularFile(child)) Files.newInputStream(child).use { input -> + while (true) { val count = input.read(buffer); if (count == -1) break; check(canceled); output.write(buffer, 0, count) } + } + output.closeEntry() + } + } + } + } + @JvmStatic @Throws(IOException::class) fun install(archive: Path, target: Path, size: Long, hash: String, journal: SyncModel.Journal, canceled: BooleanSupplier) { + install(archive, target, size, hash, journal, canceled, emptyList()) + } + @JvmStatic @Throws(IOException::class) fun install(archive: Path, target: Path, size: Long, hash: String, journal: SyncModel.Journal, canceled: BooleanSupplier, reused: List) { + if (!Files.exists(target.parent)) journal.capture(target) + Files.createDirectories(target.parent) + val staging = Files.createTempDirectory(target.parent, ".dlc-sync-") + try { + var received = 0L + for (file in reused) { + check(canceled) + val source = child(target, file.id) + val destination = child(staging, file.id) + if (!Files.isRegularFile(source) || Files.size(source) != file.size || DlcFileMatch.fullHash(source) != file.hash) throw SyncFailure("reused_changed", "${file.id}") + Files.createDirectories(destination.parent); Files.copy(source, destination) + received = Math.addExact(received, file.size) + if (received > size) throw SyncFailure("archive_size") + } + ZipInputStream(Files.newInputStream(archive)).use { input -> + val buffer = ByteArray(65536) + while (true) { + val entry = input.nextEntry ?: break + check(canceled) + val name = entry.name + if (name.contains('\\') || name.contains(':') || name.startsWith('/')) throw SyncFailure("invalid_archive_path") + val path = staging.resolve(name).normalize() + if (!path.startsWith(staging) || path == staging) throw SyncFailure("archive_escape") + if (entry.isDirectory) Files.createDirectories(path) + else { + Files.createDirectories(path.parent) + Files.newOutputStream(path, StandardOpenOption.CREATE_NEW).use { output -> + while (true) { + val count = input.read(buffer); if (count == -1) break + check(canceled); received = Math.addExact(received, count.toLong()) + if (received > size) throw SyncFailure("archive_size") + output.write(buffer, 0, count) + } + } + } + } + } + if (received != size || DlcFileMatch.fullHash(staging) != hash) throw SyncFailure("directory_integrity") + check(canceled); journal.capture(target); DlcResourceHistory.delete(target) + try { Files.move(staging, target, StandardCopyOption.ATOMIC_MOVE) } + catch (_: AtomicMoveNotSupportedException) { Files.move(staging, target) } + } finally { DlcResourceHistory.delete(staging) } + } + private fun check(canceled: BooleanSupplier) { + if (canceled.asBoolean || Thread.currentThread().isInterrupted) throw SyncFailure("canceled") + } +} diff --git a/src/main/kotlin/com/cozooo/dlc_manager/sync/SyncClient.kt b/src/main/kotlin/com/cozooo/dlc_manager/sync/SyncClient.kt new file mode 100644 index 00000000..f09275fa --- /dev/null +++ b/src/main/kotlin/com/cozooo/dlc_manager/sync/SyncClient.kt @@ -0,0 +1,213 @@ +package com.cozooo.dlc_manager.sync + +import com.cozooo.dlc_manager.sync.SyncModel.* +import com.google.gson.JsonObject +import java.io.IOException +import java.util.* + +class SyncClient(private val local: Peer, private val remote: Peer, private val control: Peer) { + private val localId = UUID.randomUUID().toString() + private val remoteId = UUID.randomUUID().toString() + @Volatile private var canceled = false + @Volatile private var sealed = false + @Volatile private var recoveryPending = false + @Volatile private var rollbackAvailable = false + @Volatile private var preparing = false + private fun prepare(peer: Peer, force: Boolean) { + preparing = true + try { val r = request("prepare"); r.addProperty("force", force); checked(peer, r) } + finally { preparing = false } + } + private fun poll(peer: Peer, operation: String, progress: Progress) { + try { + val r = request("status"); r.addProperty("operation", operation) + val response = checked(peer, r) + if (preparing && !canceled && response.has("progress")) { + val value = response.getAsJsonObject("progress") + if (value.has("phase") && value.get("phase").asString == "downloading") progress.update("downloading", value.get("resource").asString, value.get("done").asLong, value.get("total").asLong) + } + } catch (_: IOException) {} + } + fun canRollback() = rollbackAvailable + @Throws(IOException::class) fun rollback(local: Peer, remote: Peer) { + recoverPeer(remote, remoteId); recoverPeer(local, localId); rollbackAvailable = false; recoveryPending = false + } + fun needsRecovery() = recoveryPending + @Throws(IOException::class) fun recover() = rollback(local, control) + private fun recoverPeer(peer: Peer, operation: String) { + var failure: IOException? = null + repeat(20) { + try { val request = request("recover_operation"); request.addProperty("operation", operation); checked(peer, request); return } + catch (error: IOException) { if (!SyncFailure.`is`(error, "active")) throw error; failure = error } + try { Thread.sleep(100) } catch (error: InterruptedException) { Thread.currentThread().interrupt(); throw SyncFailure("recovery_interrupted").causedBy(error) } + } + throw failure!! + } + fun cancel(): Boolean { + synchronized(this) { if (sealed) return false; canceled = true } + SyncEndpoint.cancel(localId) + Thread({ + try { val r = request("cancel"); r.addProperty("operation", remoteId); checked(control, r) } + catch (_: IOException) { try { remote.close() } catch (_: IOException) {} } + }, "dlc-sync-cancel").also { it.isDaemon = true; it.start() } + return true + } + private fun readManifest(peer: Peer) = manifest(peer) { canceled } + @Throws(IOException::class) + fun execute(rows: List, mode: Mode, states: Boolean, progress: Progress): Result { + val monitor = java.util.concurrent.Executors.newSingleThreadScheduledExecutor { task -> Thread(task, "dlc-sync-progress").also { it.isDaemon = true } } + monitor.scheduleWithFixedDelay({ if (preparing) { poll(local, localId, progress); poll(control, remoteId, progress) } }, 200, 400, java.util.concurrent.TimeUnit.MILLISECONDS) + val errors = mutableListOf() + val succeeded = mutableListOf() + var count = 0 + var restart = false + var localStarted = false + var remoteStarted = false + try { + check() + rollbackAvailable = true; localStarted = true; begin(local, localId) + remoteStarted = true; begin(remote, remoteId) + val freshLocal = SyncModel.index(readManifest(local)) + val freshRemote = SyncModel.index(readManifest(remote)) + for (row in rows) { + val expectedLocal = if (row.direction == Direction.PUSH) row.source else row.target + val expectedRemote = if (row.direction == Direction.PUSH) row.target else row.source + if (expectedLocal != freshLocal[row.key] || expectedRemote != freshRemote[row.key]) throw SyncFailure("changed", "${row.key}") + } + for (row in SyncModel.ordered(rows)) { + check() + val source = if (row.direction == Direction.PUSH) local else remote + val target = if (row.direction == Direction.PUSH) remote else local + var current = inspect(source, row.key) + if (!SyncModel.same(row.source.config, current.config)) throw SyncFailure("config_changed", "${row.key}") + val contentNeeded = row.target == null || !SyncModel.same(row.source.config, row.target.config) || mode == Mode.FILES && SyncModel.usesResources(row.source) && !SyncModel.resourcesMatch(row.source.resources, row.target.resources, mode) + var applied = false + var itemRestart = false + if (contentNeeded) { + try { + start(local, row.key); start(remote, row.key) + progress.update("preparing", row.key, count.toLong(), rows.size.toLong()) + if (mode == Mode.FILES) prepare(source, false) + check(); current = inspect(source, row.key) + if (SyncModel.category(current) == Category.UPDATE && mode != Mode.FILES) throw SyncFailure("update_mode") + val validation = request("validate"); validation.add("item", SyncEndpoint.JSON.toJsonTree(current)); checked(target, validation) + val destinationBeforeWrite = if (row.target == null) null else inspect(target, row.key) + val configChanged = destinationBeforeWrite == null || !SyncModel.same(current.config, destinationBeforeWrite.config) + if (SyncModel.category(current) == Category.MANAGER_CONFIG) { + val values = current.settings ?: throw SyncFailure("settings_missing") + val settings = request("settings"); settings.add("settings", values); checked(target, settings) + } else { + if (configChanged) transfer(source, target, current.config, progress) + if (mode == Mode.FILES) { + val destination = inspect(target, row.key) + val files = destination.resources.associateBy { it.id } + for (file in current.resources) { check(); if (!SyncModel.same(file, files[file.id])) transfer(source, target, file, progress) } + } else { prepare(target, configChanged); current = inspect(target, row.key) } + } + check(); progress.update("applying", row.key, count.toLong(), rows.size.toLong()) + val apply = request("apply") + if (SyncModel.category(current) != Category.PRESET) row.target?.enabled?.let { apply.addProperty("enabled", it) } + itemRestart = checked(target, apply).get("requiresRestart").asBoolean + check(); val result = inspect(target, row.key) + if (!SyncModel.same(current.config, result.config) || mode == Mode.FILES && SyncModel.usesResources(current) && !SyncModel.sameResources(current.resources, result.resources)) throw SyncFailure("verification", "${row.key}") + applied = true + } catch (error: Exception) { + if (canceled) throw SyncFailure("canceled").causedBy(error) + checked(target, request("item_keep")); checked(source, request("item_keep")) + errors.add("${row.key}: ${error.message}") + } + if (!applied) continue + checked(target, request("item_done")); checked(source, request("item_done")) + } + if (states && SyncModel.sharedState(row.source, row.target) && row.source.enabled != row.target?.enabled) { + var stateApplied = false + try { + check(); start(target, row.key) + progress.update("applying", row.key, count.toLong(), rows.size.toLong()) + val apply = request("apply"); apply.addProperty("enabled", row.source.enabled) + val stateRestart = checked(target, apply).get("requiresRestart").asBoolean + check(); stateApplied = true; itemRestart = itemRestart || stateRestart + } catch (error: Exception) { + if (canceled) throw SyncFailure("canceled").causedBy(error) + checked(target, request("item_keep")) + } + if (stateApplied) { checked(target, request("item_done")); applied = true } + } + if (applied) { count++; succeeded.add(row.key); restart = restart || itemRestart } + } + synchronized(this) { check(); sealed = true } + checked(remote, request("commit")) + checked(local, request("commit")); remoteStarted = false; localStarted = false + return Result(count, false, restart, errors, succeeded.toList()) + } catch (failure: Exception) { + val error = if (failure is IOException) failure else SyncFailure("invalid_response").causedBy(failure) + if (!canceled) { + if (remoteStarted) try { checked(remote, request("retain")) } catch (failure: IOException) { error.addSuppressed(failure) } + if (localStarted) try { checked(local, request("retain")) } catch (failure: IOException) { error.addSuppressed(failure) } + throw error + } + progress.update("restoring", "", 0, rows.size.toLong()) + var recovery: IOException? = null + if (remoteStarted) try { checked(remote, request("rollback")) } catch (failure: IOException) { try { recoverPeer(control, remoteId) } catch (unresolved: IOException) { recovery = unresolved } } + if (localStarted) try { checked(local, request("rollback")) } catch (failure: IOException) { if (recovery == null) recovery = failure else recovery.addSuppressed(failure) } + recovery?.let { recoveryPending = true; error.addSuppressed(it); throw SyncFailure("recovery_incomplete", "${it.message}").causedBy(error) } + if (canceled) { rollbackAvailable = false; return Result(0, true, false, errors, emptyList()) } + throw error + } finally { sealed = true; preparing = false; monitor.shutdownNow() } + } + private fun transfer(source: Peer, target: Peer, file: File, progress: Progress) { + check() + val inventory = request("inventory"); inventory.addProperty("id", file.id) + val read = request("read"); read.addProperty("id", file.id); read.add("existing", checked(target, inventory).get("files")) + val opened = checked(source, read) + val size = opened.get("size").asLong + val resourceSize = opened.get("resourceSize").asLong + val contentHash = opened.get("contentHash").asString + if (resourceSize != file.size || contentHash != file.hash) throw SyncFailure("source_changed", "${file.id}") + val write = request("write") + write.add("file", SyncEndpoint.JSON.toJsonTree(File(file.id, size, file.hash))) + write.addProperty("hash", opened.get("hash").asString) + if (opened.has("reused")) write.add("reused", opened.get("reused")) + write.add("directory", opened.get("directory")); write.addProperty("resourceSize", resourceSize); checked(target, write) + var offset = 0L + while (offset < size) { + check() + val chunk = request("chunk"); chunk.addProperty("id", file.id); chunk.addProperty("offset", offset) + val data = checked(source, chunk).get("data").asString + val bytes = Base64.getDecoder().decode(data).size + if (bytes == 0) throw SyncFailure("truncated", "${file.id}") + val upload = request("write_chunk"); upload.addProperty("data", data); checked(target, upload) + offset += bytes; progress.update("transferring", file.id, offset, size) + } + val finish = request("read_end"); finish.addProperty("id", file.id); checked(source, finish) + check(); checked(target, request("write_end")) + } + private fun inspect(peer: Peer, key: String): Item { + val request = request("inspect"); request.addProperty("key", key) + val value = SyncEndpoint.JSON.fromJson(checked(peer, request).get("item"), Item::class.java) + return value ?: throw SyncFailure("config_missing") + } + private fun check() { if (canceled) throw SyncFailure("canceled") } + companion object { + @JvmStatic @Throws(IOException::class) fun manifest(peer: Peer): List = manifest(peer) { false } + @JvmStatic @Throws(IOException::class) fun manifest(peer: Peer, canceled: java.util.function.BooleanSupplier): List { + checked(peer, request("manifest_start")) + while (true) { + if (canceled.asBoolean) throw SyncFailure("canceled") + val response = checked(peer, request("manifest_poll")) + if (response.get("ready").asBoolean) return SyncEndpoint.JSON.fromJson(response.get("items"), Array::class.java).toList() + try { Thread.sleep(50) } catch (error: InterruptedException) { Thread.currentThread().interrupt(); throw SyncFailure("comparison_interrupted").causedBy(error) } + } + } + private fun begin(peer: Peer, id: String) { val r = request("begin"); r.addProperty("operation", id); checked(peer, r) } + private fun start(peer: Peer, key: String) { val r = request("item"); r.addProperty("key", key); checked(peer, r) } + @JvmStatic fun request(action: String): JsonObject = JsonObject().also { it.addProperty("action", action) } + @JvmStatic @Throws(IOException::class) fun checked(peer: Peer, request: JsonObject): JsonObject { + try { + val result = peer.call(request) + if (!result.has("ok") || !result.get("ok").asBoolean) throw SyncFailure.read(result) + return result + } catch (error: RuntimeException) { throw SyncFailure("invalid_response").causedBy(error) } + } + } +} diff --git a/src/main/kotlin/com/cozooo/dlc_manager/sync/SyncEndpoint.kt b/src/main/kotlin/com/cozooo/dlc_manager/sync/SyncEndpoint.kt new file mode 100644 index 00000000..51a35d60 --- /dev/null +++ b/src/main/kotlin/com/cozooo/dlc_manager/sync/SyncEndpoint.kt @@ -0,0 +1,287 @@ +package com.cozooo.dlc_manager.sync + +import com.cozooo.dlc_manager.core.DlcFileMatch +import com.cozooo.dlc_manager.sync.SyncModel.* +import com.google.gson.Gson +import com.google.gson.JsonObject +import java.io.IOException +import java.io.RandomAccessFile +import java.nio.file.* +import java.util.* +import java.util.concurrent.ConcurrentHashMap + +class SyncEndpoint(private val adapter: Adapter) : Peer, AutoCloseable { + private val completed = mutableListOf() + private val reads = mutableMapOf() + private val readSources = mutableMapOf() + private val readHashes = mutableMapOf() + private var current: Journal? = null + private var key: String? = null + private var operation: String? = null + private var upload: Path? = null + private var uploading: SyncModel.File? = null + private var uploadHash: String? = null + private var uploadDirectory = false + private var uploadReused = emptyList() + private var resourceSize = 0L + private var received = 0L + @Volatile private var canceled = false + @Volatile private var disconnected = false + private var validation: Thread? = null + @Volatile private var validationDone = false + @Volatile private var validated: List? = null + @Volatile private var validationFailure: Exception? = null + private fun startValidation() { + stopValidation(); validationDone = false; validated = null; validationFailure = null + validation = Thread({ + try { + if (operation != null) validated = adapter.manifest() + else guard(adapter.workDirectory()).use { validated = adapter.manifest() } + } catch (error: Exception) { validationFailure = error } + finally { validationDone = true } + }, "dlc-sync-validation").also { it.isDaemon = true; it.start() } + } + private fun stopValidation() { + val thread = validation ?: return + if (thread.isAlive) { + thread.interrupt() + try { thread.join(5000) } catch (error: InterruptedException) { Thread.currentThread().interrupt(); throw SyncFailure("validation_interrupted").causedBy(error) } + if (thread.isAlive) throw SyncFailure("validation_stop") + } + validation = null + } + @Volatile private var progress = JsonObject() + private fun report(phase: String, resource: String, done: Long, total: Long) { + val value = JsonObject(); value.addProperty("phase", phase); value.addProperty("resource", resource) + value.addProperty("done", done); value.addProperty("total", total); progress = value + } + private var writeLock: java.util.concurrent.locks.ReentrantReadWriteLock.WriteLock? = null + + override fun call(request: JsonObject): JsonObject { + val result = JsonObject() + result.addProperty("type", "dlc_sync") + if (request.has("requestId")) result.add("requestId", request.get("requestId")) + try { + when (request.get("action").asString) { + "status" -> OPERATIONS[request.get("operation").asString]?.let { result.add("progress", it.progress.deepCopy()) } + "cancel" -> cancel(request.get("operation").asString) + "recover_operation" -> exclusiveRecovery(request.get("operation").asString) + "recover" -> exclusiveRecovery(null) + "inspect" -> { val selected = request.get("key").asString; adapter.validateKey(selected); result.add("item", JSON.toJsonTree(adapter.item(selected))) } + "manifest_start" -> startValidation() + "manifest_poll" -> { + if (validation == null) throw SyncFailure("comparison_not_started") + result.addProperty("ready", validationDone) + if (validationDone) { validationFailure?.let { throw it }; result.add("items", JSON.toJsonTree(validated)) } + } + "begin" -> begin(request.get("operation").asString) + "rollback" -> rollback() + "retain" -> retain() + "commit" -> { check(); if (current != null) throw SyncFailure("unfinished_item"); retain() } + else -> { + check() + when (request.get("action").asString) { + "validate" -> { + requireItem() + val incoming = JSON.fromJson(request.get("item"), Item::class.java) + if (incoming == null || key != incoming.key) throw SyncFailure("invalid_item") + adapter.validateIncoming(incoming) + } + "settings" -> { requireItem(); adapter.writeSettings(key!!, request.getAsJsonObject("settings"), current!!) } + "item" -> start(request.get("key").asString) + "prepare" -> { requireItem(); report("downloading", key!!, 0, -1); adapter.prepare(key!!, request.has("force") && request.get("force").asBoolean, { canceled || disconnected }, ::report); report("preparing", key!!, 0, -1); check(); result.add("item", JSON.toJsonTree(item())) } + "inventory" -> result.add("files", JSON.toJsonTree(SyncArchive.inventory(allowed(request.get("id").asString)) { canceled || disconnected })) + "read" -> { + requireItem() + val id = request.get("id").asString + val path = allowed(id) + if (!Files.isRegularFile(path) && !Files.isDirectory(path)) throw SyncFailure("resource_missing", "$id") + if (reads.containsKey(id)) throw SyncFailure("read_open") + val hash = DlcFileMatch.hash(path) + readSources[id] = path; readHashes[id] = hash + val directory = Files.isDirectory(path) + val wire = if (directory) Files.createTempFile(adapter.workDirectory(), "outgoing-", ".zip") else path + reads[id] = wire + if (directory) { + val existing = if (request.has("existing")) JSON.fromJson(request.get("existing"), Array::class.java).toList() else emptyList() + val reused = SyncArchive.reusable(path, existing) { canceled || disconnected } + result.add("reused", JSON.toJsonTree(reused)) + SyncArchive.pack(path, wire, { canceled || disconnected }, reused) + } + result.addProperty("resourceSize", SyncArchive.size(path)); result.addProperty("contentHash", hash) + result.addProperty("directory", directory) + result.addProperty("size", Files.size(wire)); result.addProperty("hash", if (directory) DlcFileMatch.fullHash(wire) else hash) + } + "chunk" -> { + val id = request.get("id").asString + val path = reads[id] ?: throw SyncFailure("read_not_open") + val offset = request.get("offset").asLong + if (offset < 0 || offset > Files.size(path)) throw SyncFailure("invalid_offset") + val bytes = ByteArray(minOf(1048576L, Files.size(path) - offset).toInt()) + RandomAccessFile(path.toFile(), "r").use { it.seek(offset); it.readFully(bytes) } + result.addProperty("data", Base64.getEncoder().encodeToString(bytes)) + } + "read_end" -> { + val id = request.get("id").asString + val source = readSources.remove(id) + val path = reads.remove(id) + try { if (source == null || DlcFileMatch.fullHash(source) != readHashes.remove(id)) throw SyncFailure("source_changed_transfer", "$id") } + finally { if (path != null && path != source) Files.deleteIfExists(path) } + } + "write" -> { + requireItem() + if (upload != null) throw SyncFailure("unfinished_upload") + uploading = JSON.fromJson(request.get("file"), SyncModel.File::class.java) + allowed(uploading!!.id) + if (uploading!!.size < 0) throw SyncFailure("invalid_size") + uploadHash = request.get("hash").asString + uploadDirectory = request.has("directory") && request.get("directory").asBoolean + uploadReused = if (uploadDirectory && request.has("reused")) JSON.fromJson(request.get("reused"), Array::class.java).toList() else emptyList() + resourceSize = if (request.has("resourceSize")) request.get("resourceSize").asLong else uploading!!.size + if (resourceSize < 0 || uploadDirectory && uploading!!.id == key) throw SyncFailure("invalid_directory") + upload = Files.createTempFile(adapter.workDirectory(), "incoming-", ".part"); received = 0 + } + "write_chunk" -> { + val path = upload ?: throw SyncFailure("no_upload") + val bytes = Base64.getDecoder().decode(request.get("data").asString) + if (bytes.size > 1048576 || received + bytes.size > uploading!!.size) throw SyncFailure("resource_size") + Files.write(path, bytes, StandardOpenOption.APPEND); received += bytes.size + } + "write_end" -> { + val path = upload + if (path == null || received != uploading!!.size || DlcFileMatch.fullHash(path) != uploadHash) throw SyncFailure("resource_integrity") + val target = allowed(uploading!!.id) + if (uploadDirectory) { + SyncArchive.install(path, target, resourceSize, uploading!!.hash!!, current!!, { canceled || disconnected }, uploadReused) + Files.deleteIfExists(path) + } else { + current!!.capture(target); Files.createDirectories(target.parent) + try { Files.move(path, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) } + catch (_: AtomicMoveNotSupportedException) { Files.move(path, target, StandardCopyOption.REPLACE_EXISTING) } + } + upload = null; adapter.reload() + } + "apply" -> { + requireItem() + val enabled = if (request.has("enabled")) request.get("enabled").asBoolean else null + val restart = adapter.apply(key!!, enabled, current!!, { canceled || disconnected }); check() + result.addProperty("requiresRestart", restart) + } + "item_done" -> { requireItem(); completed.add(current!!); current!!.close(); current = null; key = null } + "item_rollback" -> rollbackItem() + "item_keep" -> keepItem() + else -> throw SyncFailure("unsupported_action") + } + } + } + result.addProperty("ok", true) + } catch (error: Exception) { + if (error is InterruptedException) Thread.currentThread().interrupt() + result.addProperty("ok", false) + result.addProperty("reason", error.message ?: error.javaClass.simpleName) + if (error is SyncFailure) error.write(result) + } + return result + } + private fun begin(id: String) { + if (operation != null) throw SyncFailure("already_active") + UUID.fromString(id) + val directory = adapter.workDirectory().toAbsolutePath().normalize() + if (OWNERS.putIfAbsent(directory, this) != null) throw SyncFailure("other_active") + operation = id; OPERATIONS[id] = this + try { acquire(directory); Files.createDirectories(directory); adapter.quiesce { canceled || disconnected } } + catch (error: Exception) { release(); throw error } + } + private fun acquire(directory: Path) { + writeLock = LOCKS.computeIfAbsent(directory) { java.util.concurrent.locks.ReentrantReadWriteLock(true) }.writeLock() + val deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(30) + while (writeLock?.tryLock(100, java.util.concurrent.TimeUnit.MILLISECONDS) != true) { + if (canceled || disconnected || System.nanoTime() > deadline) { writeLock = null; throw SyncFailure("other_operation") } + } + } + private fun exclusiveRecovery(operation: String?) { + val directory = adapter.workDirectory().toAbsolutePath().normalize() + if (OWNERS.putIfAbsent(directory, this) != null) throw SyncFailure("active") + try { acquire(directory); adapter.quiesce { false }; if (operation == null) adapter.recover() else adapter.recoverOperation(operation) } + finally { release() } + } + private fun start(id: String) { + if (current != null) throw SyncFailure("previous_unfinished") + adapter.validateKey(id); key = id + current = adapter.journal(adapter.workDirectory().resolve(operation!!).resolve(String.format(Locale.ROOT, "%08d-", completed.size) + UUID.randomUUID()).resolve("journal")) + current!!.capture(adapter.resolve(id)) + } + private fun item(): Item = adapter.item(key!!) + private fun allowed(id: String): Path { + requireItem() + if (key!!.startsWith("manager:")) throw SyncFailure("settings_fields") + if (id == key || item().resources.any { it.id == id }) return adapter.resolve(id) + throw SyncFailure("outside_item", "$id") + } + private fun requireItem() { if (current == null) throw SyncFailure("no_item") } + private fun check() { + if (operation == null) throw SyncFailure("not_started") + if (canceled) throw SyncFailure("canceled") + if (disconnected) throw SyncFailure("connection_lost") + } + private fun cleanupTransfers() { + stopValidation() + upload?.let { Files.deleteIfExists(it); upload = null } + for ((id, path) in reads) if (path != readSources[id]) Files.deleteIfExists(path) + reads.clear(); readHashes.clear(); readSources.clear() + } + private fun keepItem() { + cleanupTransfers() + current?.let { it.close(); completed.add(it); current = null; key = null } + } + private fun retain() { + keepItem() + completed.forEach { it.commit() } + completed.clear(); release() + } + private fun rollbackItem() { + cleanupTransfers() + current?.let { it.close(); it.rollback(); current = null; key = null } + adapter.reload() + } + private fun rollback() { + var failure: IOException? = null + try { rollbackItem() } catch (error: IOException) { failure = error } + for (journal in completed.asReversed()) try { journal.rollback() } + catch (error: IOException) { if (failure == null) failure = error else failure.addSuppressed(error) } + try { adapter.reload() } catch (error: IOException) { if (failure == null) failure = error else failure.addSuppressed(error) } + failure?.let { throw it } + completed.clear(); release() + } + private fun release() { + operation?.let { OPERATIONS.remove(it, this) } + OWNERS.remove(adapter.workDirectory().toAbsolutePath().normalize(), this); operation = null + if (writeLock != null && LOCKS[adapter.workDirectory().toAbsolutePath().normalize()]?.isWriteLockedByCurrentThread == true) writeLock?.unlock() + writeLock = null + } + @Throws(IOException::class) override fun close() { + val interrupted = Thread.interrupted() + try { stopValidation(); if (operation != null) { if (canceled) rollback() else retain() } } + catch (failure: IOException) { System.getLogger("DLC Sync").log(System.Logger.Level.ERROR, "DLC sync cleanup failed; recovery records retained", failure); throw failure } + finally { release(); if (interrupted) Thread.currentThread().interrupt() } + } + class OperationGuard internal constructor(private val lock: java.util.concurrent.locks.Lock) : AutoCloseable { + override fun close() = lock.unlock() + } + fun disconnectCurrent() { disconnected = true } + companion object { + @JvmField val JSON = Gson() + private val LOCKS = ConcurrentHashMap() + private val OWNERS = ConcurrentHashMap() + private val OPERATIONS = ConcurrentHashMap() + @JvmStatic @Throws(IOException::class) fun guard(directory: Path): OperationGuard { + val key = directory.toAbsolutePath().normalize() + val lock = LOCKS.computeIfAbsent(key) { java.util.concurrent.locks.ReentrantReadWriteLock(true) }.readLock() + if (busy(key) || !lock.tryLock()) throw SyncFailure("busy") + if (busy(key)) { lock.unlock(); throw SyncFailure("busy") } + return OperationGuard(lock) + } + @JvmStatic fun busy(directory: Path) = OWNERS.containsKey(directory.toAbsolutePath().normalize()) + @JvmStatic fun cancel(id: String) { OPERATIONS[id]?.canceled = true } + } +} diff --git a/src/main/kotlin/com/cozooo/dlc_manager/sync/SyncFailure.kt b/src/main/kotlin/com/cozooo/dlc_manager/sync/SyncFailure.kt new file mode 100644 index 00000000..e1d4cc95 --- /dev/null +++ b/src/main/kotlin/com/cozooo/dlc_manager/sync/SyncFailure.kt @@ -0,0 +1,50 @@ +package com.cozooo.dlc_manager.sync + +import com.cozooo.dlc_manager.platform.DlcPlatformServices +import com.google.gson.* +import java.io.IOException +import java.io.InputStreamReader +import java.nio.charset.StandardCharsets +import java.util.* +import java.util.concurrent.ConcurrentHashMap + +class SyncFailure(private val code: String, vararg arguments: Any?) : IOException(PREFIX + code) { + private val arguments = arguments.map { it.toString() }.toTypedArray() + fun causedBy(cause: Throwable): SyncFailure { initCause(cause); return this } + override val message: String + get() { + var language = DlcPlatformServices.languageCode().ifBlank { "en_us" }.lowercase(Locale.ROOT).replace('-', '_') + if (!language.matches(Regex("[a-z]{2,3}_[a-z]{2,3}"))) language = "en_us" + val key = PREFIX + code + val value = load(language).get(key) ?: load("en_us").get(key) + val pattern = value?.asString ?: key + return try { String.format(Locale.ROOT, pattern, *arguments) } catch (_: IllegalFormatException) { pattern } + } + fun write(response: JsonObject) { + response.addProperty("reasonKey", PREFIX + code) + val values = JsonArray(); arguments.forEach { values.add(it) }; response.add("reasonArgs", values) + } + companion object { + private const val serialVersionUID = 1L + private const val PREFIX = "dlc_manager.sync.error." + private val LANGUAGES = ConcurrentHashMap() + @JvmStatic fun `is`(error: Throwable?, code: String) = error is SyncFailure && error.code == code + @JvmStatic fun read(response: JsonObject?): IOException { + if (response?.has("reasonKey") == true && response.get("reasonKey").isJsonPrimitive) { + val key = response.get("reasonKey").asString + if (key.startsWith(PREFIX) && key.removePrefix(PREFIX).matches(Regex("[a-z_]+"))) { + val arguments = if (response.has("reasonArgs")) response.getAsJsonArray("reasonArgs").map { it.asString }.toTypedArray() else emptyArray() + return SyncFailure(key.removePrefix(PREFIX), *arguments) + } + } + return if (response?.has("reason") == true) IOException(response.get("reason").asString) else SyncFailure("unsupported") + } + private fun load(language: String): JsonObject = LANGUAGES.computeIfAbsent(language) { name -> + try { + SyncFailure::class.java.getResourceAsStream("/assets/dlc_manager/lang/$name.json").use { input -> + if (input == null) JsonObject() else JsonParser.parseReader(InputStreamReader(input, StandardCharsets.UTF_8)).asJsonObject + } + } catch (_: IOException) { JsonObject() } catch (_: RuntimeException) { JsonObject() } + } + } +} diff --git a/src/main/kotlin/com/cozooo/dlc_manager/sync/SyncModel.kt b/src/main/kotlin/com/cozooo/dlc_manager/sync/SyncModel.kt new file mode 100644 index 00000000..5764173b --- /dev/null +++ b/src/main/kotlin/com/cozooo/dlc_manager/sync/SyncModel.kt @@ -0,0 +1,200 @@ +package com.cozooo.dlc_manager.sync + +import com.google.gson.JsonObject +import java.io.IOException +import java.nio.file.Path +import java.util.function.BooleanSupplier + +class SyncModel private constructor() { + enum class Mode { CONFIG, FILES } + enum class Direction { PUSH, PULL } + enum class Category { ALL, DLC, REQUIRED, UPDATE, MANAGER_CONFIG, PRESET } + data class Update(val baseOld: String, val dlcOld: String, val baseNew: String, val dlcNew: String, val platform: String) { + fun baseOld() = baseOld + fun dlcOld() = dlcOld + fun baseNew() = baseNew + fun dlcNew() = dlcNew + fun platform() = platform + } + data class File(val id: String, val size: Long, val hash: String?) { + fun id() = id + fun size() = size + fun hash() = hash + } + data class Item @JvmOverloads constructor(val key: String, val identifier: String?, val config: File, val resources: List, val enabled: Boolean?, val displayNames: Map = emptyMap(), val tooltips: Map = emptyMap(), val update: Update? = null, val settings: JsonObject? = null) { + fun key() = key + fun identifier() = identifier + fun config() = config + fun resources() = resources + fun enabled() = enabled + fun displayNames() = displayNames + fun tooltips() = tooltips + fun settings() = settings + fun update() = update + } + data class Row(val key: String, val source: Item, val target: Item?, val direction: Direction, val replacement: Boolean, val stateChanged: Boolean) { + fun key() = key + fun source() = source + fun target() = target + fun direction() = direction + fun replacement() = replacement + fun stateChanged() = stateChanged + } + data class Result(val completed: Int, val canceled: Boolean, val requiresRestart: Boolean, val errors: List, val succeeded: List) { + fun completed() = completed + fun canceled() = canceled + fun requiresRestart() = requiresRestart + fun errors() = errors + fun succeeded() = succeeded + } + fun interface Peer : AutoCloseable { @Throws(IOException::class) fun call(request: JsonObject): JsonObject; @Throws(IOException::class) override fun close() {} } + fun interface Progress { fun update(phase: String, resource: String, done: Long, total: Long) } + interface Journal : AutoCloseable { + @Throws(IOException::class) fun capture(path: Path) + @Throws(IOException::class) fun rollback() + @Throws(IOException::class) fun commit() + override fun close() + } + fun interface Quiesce { @Throws(Exception::class) fun await(canceled: BooleanSupplier) } + interface Adapter { + fun workDirectory(): Path + @Throws(IOException::class) fun manifest(): List + @Throws(IOException::class) fun item(key: String): Item = manifest().firstOrNull { it.key == key } ?: throw SyncFailure("config_not_found", "$key") + @Throws(IOException::class) fun resolve(id: String): Path + @Throws(IOException::class) fun validateKey(key: String) + @Throws(IOException::class) fun validateIncoming(item: Item) {} + @Throws(IOException::class) fun writeSettings(key: String, settings: JsonObject, journal: Journal) { throw SyncFailure("settings_unavailable") } + @Throws(IOException::class) fun journal(directory: Path): Journal + @Throws(Exception::class) fun prepare(key: String, force: Boolean, canceled: BooleanSupplier, progress: Progress) + @Throws(Exception::class) fun apply(key: String, enabled: Boolean?, journal: Journal, canceled: BooleanSupplier): Boolean + @Throws(IOException::class) fun reload() + @Throws(IOException::class) fun recover() + @Throws(IOException::class) fun recoverOperation(operation: String) + @Throws(Exception::class) fun quiesce(canceled: BooleanSupplier) {} + } + companion object { + @JvmStatic fun sharedState(source: Item?, target: Item?): Boolean = source != null && target != null && source.enabled != null && target.enabled != null && !source.identifier.isNullOrBlank() && source.identifier == target.identifier && category(source) == category(target) + @JvmStatic fun hasStateDifferences(local: List, remote: List): Boolean { + val target = index(remote) + return local.any { sharedState(it, target[it.key]) && it.enabled != target[it.key]?.enabled } + } + @JvmStatic fun plan(local: List, server: List, direction: Direction, states: Boolean): List = plan(local, server, direction, states, Mode.FILES) + @JvmStatic fun plan(local: List, server: List, direction: Direction, states: Boolean, mode: Mode): List { + val sourceItems = if (direction == Direction.PUSH) local else server + val targetItems = if (direction == Direction.PUSH) server else local + val left = index(sourceItems) + val right = index(targetItems) + val eligible = eligibleUpdates(sourceItems, targetItems) + val reversedEligible = eligibleUpdates(targetItems, sourceItems) + val rows = left.values.mapNotNull { source -> + if (versionState(source) || category(source) == Category.UPDATE && (mode != Mode.FILES || source.key !in eligible)) return@mapNotNull null + val target = right[source.key] + val state = states && sharedState(source, target) && source.enabled != target?.enabled + if (target != null && same(source.config, target.config) && (!usesResources(source) || resourcesMatch(source.resources, target.resources, mode)) && !state) null + else { + val replacement = target != null && (!same(source.config, target.config) || state || usesResources(source) && target.resources.any { it.size >= 0 } && !resourcesMatch(source.resources, target.resources, mode)) + Row(source.key, source, target, direction, replacement, state) + } + }.toMutableList() + for (target in right.values) if (!left.containsKey(target.key) && !versionState(target) && (category(target) != Category.UPDATE || mode == Mode.FILES && target.key in reversedEligible)) rows.add(Row(target.key, target, null, + if (direction == Direction.PUSH) Direction.PULL else Direction.PUSH, false, false)) + return rows.sortedBy { it.key } + } + @JvmStatic fun same(a: File?, b: File?): Boolean = a != null && b != null && a.size >= 0 && a.size == b.size && a.id == b.id && a.hash != null && a.hash == b.hash + @JvmStatic fun sameResources(a: List, b: List): Boolean = a.isNotEmpty() && b.isNotEmpty() && a.size == b.size && a.all { same(it, b.associateBy { it.id }[it.id]) } + @JvmStatic fun resourcesMatch(source: List, target: List, mode: Mode): Boolean { + if (mode == Mode.FILES) return sameResources(source, target) + return true + } + @JvmStatic fun displayName(item: Item, language: String?): String { + val name = localized(item.displayNames, language) + if (name.isNotBlank()) return name + if (!item.identifier.isNullOrBlank()) return item.identifier + val fallback = item.key.substringAfterLast('/') + val extension = fallback.lastIndexOf('.') + return if (extension > 0) fallback.substring(0, extension) else fallback + } + @JvmStatic fun tooltip(item: Item, language: String?) = localized(item.tooltips, language) + private fun localized(values: Map?, language: String?): String { + val names = values.orEmpty() + val localized = names[language.orEmpty().lowercase(java.util.Locale.ROOT).replace('-', '_')] + if (!localized.isNullOrBlank()) return localized + val english = names["en_us"] + if (!english.isNullOrBlank()) return english + names.values.firstOrNull { !it.isNullOrBlank() }?.let { return it } + return "" + } + @JvmStatic fun status(row: Row, mode: Mode): String { + val target = row.target ?: return if (row.direction == Direction.PUSH) "server_missing" else "client_missing" + if (mode == Mode.FILES && usesResources(row.source) && (row.source.resources.isEmpty() || target.resources.isEmpty() || row.source.resources.any { it.size < 0 } || target.resources.any { it.size < 0 } || row.source.resources.any { file -> target.resources.none { it.id == file.id } })) return "resources_missing" + if (same(row.source.config, target.config) && (!usesResources(row.source) || resourcesMatch(row.source.resources, target.resources, mode))) return "state_changed" + return "content_changed" + } + @JvmStatic fun differences(row: Row, mode: Mode): List { + val result = mutableListOf() + if (row.target == null) result.add("configuration_missing") + else if (!same(row.source.config, row.target.config)) result.add("configuration_changed") + if (mode == Mode.FILES && usesResources(row.source) && (row.target == null || !resourcesMatch(row.source.resources, row.target.resources, mode))) { + val missing = row.target == null || row.target.resources.isEmpty() || row.target.resources.any { it.size < 0 } || row.source.resources.isEmpty() || row.source.resources.any { it.size < 0 } + result.add(if (missing) "resources_missing" else "resources_changed") + } + if (row.stateChanged) result.add(if (row.source.enabled == true) "enable" else "disable") + return result + } + private fun relative(key: String) = key.substringAfter(':') + @JvmStatic fun category(item: Item): Category = when { + item.key.startsWith("manager:") -> Category.MANAGER_CONFIG + relative(item.key).startsWith("required/") -> Category.REQUIRED + relative(item.key).startsWith("preset/") -> Category.PRESET + relative(item.key).startsWith("update/") || relative(item.key) == "info/version.dc" -> Category.UPDATE + else -> Category.DLC + } + @JvmStatic fun usesResources(item: Item) = category(item) == Category.DLC || category(item) == Category.REQUIRED + @JvmStatic fun versionState(item: Item) = relative(item.key) == "info/version.dc" + private fun root(item: Item) = item.key.substringBefore(':', "") + private fun pair(base: String, dlc: String) = "$base\n$dlc" + private fun advances(update: Update?): Boolean { + if (update == null) return false + val base = com.cozooo.dlc_manager.client.update.DlcUpdateService.compareVersions(update.baseNew, update.baseOld) + return base > 0 || base == 0 && com.cozooo.dlc_manager.client.update.DlcUpdateService.compareVersions(update.dlcNew, update.dlcOld) > 0 + } + @JvmStatic fun eligibleUpdates(source: List, target: List): Set { + val versions = target.filter { versionState(it) && it.update != null }.associate { root(it) to it.update!! } + val reachable = versions.mapValues { (_, v) -> mutableSetOf(pair(v.baseOld, v.dlcOld)) } + val candidates = source.filter { category(it) == Category.UPDATE && !versionState(it) } + val sourceKeys = candidates.map { it.key }.toSet() + val available = candidates + target.filter { category(it) == Category.UPDATE && !versionState(it) && it.key !in sourceKeys } + val eligible = mutableSetOf() + do { + var changed = false + for (item in available) { + val current = versions[root(item)] ?: continue + val update = item.update ?: continue + if (!advances(update) || update.platform != "universal" && update.platform != current.platform) continue + val reached = reachable.getValue(root(item)) + if (pair(update.baseOld, update.dlcOld) !in reached) continue + if (item.key in sourceKeys) eligible.add(item.key) + if (reached.add(pair(update.baseNew, update.dlcNew))) changed = true + } + } while (changed) + return eligible + } + private fun applicationOrder(item: Item) = when (category(item)) { Category.UPDATE -> 2; Category.PRESET -> 1; else -> 0 } + @JvmStatic fun ordered(rows: List): List = rows.sortedWith { a, b -> + val left = applicationOrder(a.source) + val right = applicationOrder(b.source) + if (left != right) return@sortedWith left.compareTo(right) + if (left == 1) return@sortedWith (a.source.enabled == true).compareTo(b.source.enabled == true) + val first = a.source.update + val second = b.source.update + if (left != 2 || first == null || second == null) return@sortedWith 0 + val base = com.cozooo.dlc_manager.client.update.DlcUpdateService.compareVersions(first.baseOld, second.baseOld) + if (base != 0) base else com.cozooo.dlc_manager.client.update.DlcUpdateService.compareVersions(first.dlcOld, second.dlcOld) + } + @JvmStatic fun index(items: List): Map { + val result = sortedMapOf() + for (item in items) require(result.put(item.key, item) == null) { "Duplicate DLC configuration: ${item.key}" } + return result + } + } +} diff --git a/src/main/resources/META-INF/mods.toml b/src/main/resources/META-INF/mods.toml index 8e2d061c..db7964b6 100644 --- a/src/main/resources/META-INF/mods.toml +++ b/src/main/resources/META-INF/mods.toml @@ -2,9 +2,6 @@ modLoader="javafml" loaderVersion="${forge_loader_version_range}" license="LicenseRef-DLC-Manager-Source-1.0" -[[mixins]] -config="dlc_manager.mixins.json" - [[mods]] modId="${mod_id}" version="${mod_version}" diff --git a/src/main/resources/assets/apricityui/apricity/dlc_manager/ui/page.css b/src/main/resources/assets/apricityui/apricity/dlc_manager/ui/page.css index ba4b987a..b5b18fdf 100644 --- a/src/main/resources/assets/apricityui/apricity/dlc_manager/ui/page.css +++ b/src/main/resources/assets/apricityui/apricity/dlc_manager/ui/page.css @@ -207,3 +207,8 @@ .file-checkbox[aria-checked="true"] .file-checkmark { display: block; } .file-checkbox[aria-checked="mixed"] .file-mixed { display: block; } .file-checkbox:disabled { opacity: 0.5; } + +.sync-page #page-content { overflow: hidden; padding: 16px 0; } +.sync-layout { display: flex; gap: 20px; height: 100%; min-height: 0; } +.sync-types { display: flex; flex-direction: column; gap: 5px; width: 172px; min-width: 172px; padding-right: 14px; border-right: 1px solid var(--line); overflow-y: auto; } +.sync-results { display: block; flex: 1; min-width: 0; overflow-y: auto; padding: 8px 12px 12px 0; } diff --git a/src/main/resources/assets/dlc_manager/lang/en_us.json b/src/main/resources/assets/dlc_manager/lang/en_us.json index 0e614ce4..5c06f6cd 100644 --- a/src/main/resources/assets/dlc_manager/lang/en_us.json +++ b/src/main/resources/assets/dlc_manager/lang/en_us.json @@ -207,7 +207,7 @@ "dlc_manager.config.download.task_timeout": "Single download timeout (s)", "dlc_manager.config.download.maxConcurrentDownloads": "Max concurrent downloads", "dlc_manager.config.download.maxSegmentsPerFile": "Max segments per file", - "dlc_manager.config.download.minSegmentMB": "Min segment size (MB)", + "dlc_manager.config.download.minSegmentMB": "Min segment size (MiB)", "dlc_manager.config.download.maxRetryCount": "Retry attempts on failure", "dlc_manager.config.download.connectTimeoutSeconds": "Connect timeout (s)", "dlc_manager.config.download.taskTimeoutSeconds": "Single download timeout (s)", @@ -225,7 +225,7 @@ "dlc_manager.config.download.downloadMirrorProbeStrategy.majority": "Majority decision", "dlc_manager.config.comment.download.maxConcurrentDownloads": "Count. Range: 1 to 64", "dlc_manager.config.comment.download.maxSegmentsPerFile": "Segments. Range: 1 to 64", - "dlc_manager.config.comment.download.minSegmentMB": "Unit: MB. Range: 1 to 1024", + "dlc_manager.config.comment.download.minSegmentMB": "Unit: MiB. Range: 1 to 1024", "dlc_manager.config.comment.download.maxRetryCount": "Times. Range: 1 to 100", "dlc_manager.config.comment.download.connectTimeoutSeconds": "Unit: seconds. Range: 1 to 300", "dlc_manager.config.comment.download.taskTimeoutSeconds": "Unit: seconds. Range: 10 to 600", @@ -458,10 +458,10 @@ "dlc_manager.server.command_update_list": "Updates available: %s", "dlc_manager.server.command_push_unavailable": "Server push is not available yet.", "dlc_manager.server.self_update_available": "DLC Manager update available: %s -> %s", - "dlc_manager.server.push": "Push", + "dlc_manager.server.push": "DLC Sync", "dlc_manager.server.rcon": "RCON", "dlc_manager.server.files": "Files", - "dlc_manager.server.push_title": "Server Push", + "dlc_manager.server.push_title": "DLC Sync", "dlc_manager.server.push_current_dir": "Current dir: %s", "dlc_manager.server.push_up": "Up", "dlc_manager.server.push_clear_selection": "Clear Selection (%s)", @@ -691,7 +691,7 @@ "dlc_manager.ui.empty_packs": "No expansion packs yet. Import a pack or download one from a URL.", "dlc_manager.ui.empty_updates": "No update history yet. Imported updates will appear here.", "dlc_manager.ui.empty_files": "This folder is empty.", - "dlc_manager.ui.tool_push": "Send local DLC files to this server.", + "dlc_manager.ui.tool_push": "Compare and sync DLC configurations, resources and enabled states.", "dlc_manager.ui.tool_rcon": "Run commands and manage players through RCON.", "dlc_manager.ui.tool_files": "Browse folders and transfer or remove server files.", "dlc_manager.ui.tool_updates": "Import updates and review version history and rollback actions.", @@ -762,5 +762,134 @@ "dlc_manager.bootstrap.failure.dialog_unavailable": "Could not display the error dialog: %s", "dlc_manager.bootstrap.failure.close": "Close", "dlc_manager.bootstrap.early_incomplete": "Early installation did not complete", - "dlc_manager.bootstrap.verification_failed": "Required resource verification failed" + "dlc_manager.bootstrap.verification_failed": "Required resource verification failed", + "dlc_manager.sync.title": "DLC Sync", + "dlc_manager.sync.mode_config": "Configuration only", + "dlc_manager.sync.mode_files": "Configuration and files", + "dlc_manager.sync.states": "Sync DLC and preset enabled states", + "dlc_manager.sync.push": "Client → Server", + "dlc_manager.sync.pull": "Server → Client", + "dlc_manager.sync.compare": "Compare", + "dlc_manager.sync.execute": "Synchronize", + "dlc_manager.sync.cancel": "Cancel and undo", + "dlc_manager.sync.server_missing": "Missing on server", + "dlc_manager.sync.client_missing": "Missing on client", + "dlc_manager.sync.content_changed": "Content differs", + "dlc_manager.sync.state_changed": "Enabled state differs", + "dlc_manager.sync.type_all": "All", + "dlc_manager.sync.type_dlc": "DLC", + "dlc_manager.sync.type_required": "Required resources", + "dlc_manager.sync.type_update": "Hot updates", + "dlc_manager.sync.manager_config": "Sync manager configuration", + "dlc_manager.sync.type_preset": "Presets", + "dlc_manager.sync.select_all": "Select all", + "dlc_manager.sync.empty": "No differences to synchronize", + "dlc_manager.sync.differences": "Differences", + "dlc_manager.sync.replacement": "Replacement — select to confirm", + "dlc_manager.sync.addition": "New content", + "dlc_manager.sync.enable": "Enable DLC", + "dlc_manager.sync.disable": "Disable DLC", + "dlc_manager.sync.confirm": "Synchronize %s selected DLC entries? Existing content will be backed up before replacement.", + "dlc_manager.sync.working": "Reading DLC information…", + "dlc_manager.sync.preparing": "Preparing resources…", + "dlc_manager.sync.downloading": "Downloading…", + "dlc_manager.sync.transferring": "Transferring…", + "dlc_manager.sync.applying": "Applying…", + "dlc_manager.sync.restoring": "Undoing synchronization…", + "dlc_manager.sync.canceled": "Synchronization canceled and all changes restored.", + "dlc_manager.sync.rollback": "Undo sync", + "dlc_manager.sync.keep": "Keep changes", + "dlc_manager.sync.error.server_downloads_stop": "Could not stop server downloads. Please try again later.", + "dlc_manager.sync.error.busy": "DLC synchronization is in progress", + "dlc_manager.sync.error.active": "Synchronization is active", + "dlc_manager.sync.error.already_active": "Synchronization is already active", + "dlc_manager.sync.error.other_active": "Another synchronization is active", + "dlc_manager.sync.error.other_operation": "Another DLC operation is in progress", + "dlc_manager.sync.error.settings_save": "Cannot save manager configuration", + "dlc_manager.sync.error.preset_state_save": "Cannot save preset state: %s", + "dlc_manager.sync.error.recovery_save": "Cannot save synchronization recovery state", + "dlc_manager.sync.error.comparison_canceled": "Comparison canceled", + "dlc_manager.sync.error.comparison_not_started": "Comparison has not started", + "dlc_manager.sync.error.comparison_interrupted": "Comparison interrupted", + "dlc_manager.sync.error.validation_stop": "Could not stop resource validation; retry recovery", + "dlc_manager.sync.error.changed": "DLC changed after comparison: %s", + "dlc_manager.sync.error.config_changed": "DLC configuration changed after comparison: %s", + "dlc_manager.sync.error.config_missing": "DLC configuration is missing", + "dlc_manager.sync.error.config_not_found": "DLC configuration not found: %s", + "dlc_manager.sync.error.apply": "DLC resource cannot be applied", + "dlc_manager.sync.error.read_not_open": "DLC resource has not been opened", + "dlc_manager.sync.error.read_open": "DLC resource is already open", + "dlc_manager.sync.error.root_missing": "DLC root is unavailable", + "dlc_manager.sync.error.verification": "DLC verification failed: %s", + "dlc_manager.sync.error.outside_roots": "File is outside synchronization roots", + "dlc_manager.sync.error.outside_item": "File is outside the selected DLC: %s", + "dlc_manager.sync.error.update_version": "Hot update package cannot be applied from the target version", + "dlc_manager.sync.error.update_versions_missing": "Hot update package is missing versions: %s", + "dlc_manager.sync.error.update_mode": "Hot updates require configuration and files mode", + "dlc_manager.sync.error.invalid_path": "Invalid DLC path", + "dlc_manager.sync.error.invalid_resource": "Invalid DLC resource", + "dlc_manager.sync.error.invalid_root": "Invalid DLC root", + "dlc_manager.sync.error.invalid_update": "Invalid hot update package: %s", + "dlc_manager.sync.error.invalid_remote": "Invalid remote DLC configuration", + "dlc_manager.sync.error.invalid_required": "Invalid required resource configuration", + "dlc_manager.sync.error.invalid_archive_path": "Invalid resource archive path", + "dlc_manager.sync.error.invalid_directory": "Invalid resource directory", + "dlc_manager.sync.error.invalid_offset": "Invalid resource offset", + "dlc_manager.sync.error.invalid_size": "Invalid resource size", + "dlc_manager.sync.error.invalid_reuse_path": "Invalid reusable resource path", + "dlc_manager.sync.error.invalid_item": "Invalid synchronization item", + "dlc_manager.sync.error.invalid_recovery": "Invalid synchronization recovery state: %s", + "dlc_manager.sync.error.invalid_response": "Invalid synchronization response", + "dlc_manager.sync.error.settings_fields": "Manager configuration requires field synchronization", + "dlc_manager.sync.error.settings_unavailable": "Manager configuration synchronization is unavailable", + "dlc_manager.sync.error.settings_missing": "Manager configuration values are missing", + "dlc_manager.sync.error.resource_missing": "Missing DLC resource: %s", + "dlc_manager.sync.error.no_item": "No DLC item selected", + "dlc_manager.sync.error.no_upload": "No resource upload", + "dlc_manager.sync.error.not_settings": "Not a manager configuration", + "dlc_manager.sync.error.not_config": "Not a synchronizable DLC configuration", + "dlc_manager.sync.error.preset_missing": "Preset configuration is unavailable: %s", + "dlc_manager.sync.error.preset_blocked": "Preset is blocked: %s", + "dlc_manager.sync.error.presets_disabled": "Presets are disabled on the destination", + "dlc_manager.sync.error.previous_unfinished": "Previous DLC item is unfinished", + "dlc_manager.sync.error.recovery_incomplete": "Recovery incomplete; backups retained: %s", + "dlc_manager.sync.error.recovery_interrupted": "Recovery interrupted", + "dlc_manager.sync.error.remote_missing": "Remote DLC resource is missing", + "dlc_manager.sync.error.archive_escape": "Resource archive escapes DLC directory", + "dlc_manager.sync.error.archive_size": "Resource archive size mismatch", + "dlc_manager.sync.error.directory_integrity": "Resource directory integrity check failed", + "dlc_manager.sync.error.resource_escape": "Resource escapes DLC directory", + "dlc_manager.sync.error.filename_missing": "Resource filename is unavailable", + "dlc_manager.sync.error.resource_integrity": "Resource integrity check failed", + "dlc_manager.sync.error.outside_root": "Resource is outside DLC root", + "dlc_manager.sync.error.resource_size": "Resource size mismatch", + "dlc_manager.sync.error.reused_changed": "Reusable resource changed: %s", + "dlc_manager.sync.error.source_changed": "Source changed after comparison: %s", + "dlc_manager.sync.error.source_changed_transfer": "Source changed during transfer: %s", + "dlc_manager.sync.error.symbolic_path": "Symbolic DLC paths are not supported", + "dlc_manager.sync.error.symbolic_resource": "Symbolic DLC resources are not supported", + "dlc_manager.sync.error.symbolic_settings": "Symbolic manager configuration paths are not supported", + "dlc_manager.sync.error.symbolic_file": "Symbolic synchronization files are not supported", + "dlc_manager.sync.error.canceled": "Synchronization canceled", + "dlc_manager.sync.error.connection_lost": "Synchronization connection lost", + "dlc_manager.sync.error.file_missing": "Synchronization file is missing: %s", + "dlc_manager.sync.error.not_started": "Synchronization has not started", + "dlc_manager.sync.error.truncated": "Truncated DLC resource: %s", + "dlc_manager.sync.error.unfinished_item": "Unfinished DLC item", + "dlc_manager.sync.error.unfinished_upload": "Unfinished resource upload", + "dlc_manager.sync.error.unsupported_action": "Unsupported DLC sync action", + "dlc_manager.sync.error.validation_interrupted": "Validation interrupted", + "dlc_manager.sync.error.unsupported": "Server does not support DLC sync", + "dlc_manager.sync.failed": "Synchronization failed", + "dlc_manager.sync.failure_choice": "%s\n\nChanges have been kept. Undo this synchronization or keep the current state?", + "dlc_manager.sync.restored": "Synchronization undone.", + "dlc_manager.sync.finished": "Synchronization completed: %s succeeded, %s failed.", + "dlc_manager.sync.updates_ready": "Hot update packages transferred. Open the hot update manager on the destination to complete the updates.", + "dlc_manager.sync.restart": "Restart to finish applying these changes.", + "dlc_manager.sync.busy": "Finish or cancel DLC synchronization before switching game directories.", + "dlc_manager.sync.configuration_missing": "Configuration missing", + "dlc_manager.sync.configuration_changed": "Configuration differs", + "dlc_manager.sync.resources_missing": "Missing resources", + "dlc_manager.sync.resources_changed": "Resources differ", + "dlc_manager.sync.succeeded_item": "Synced: %s" } diff --git a/src/main/resources/assets/dlc_manager/lang/zh_cn.json b/src/main/resources/assets/dlc_manager/lang/zh_cn.json index ebdfe18e..0f212ece 100644 --- a/src/main/resources/assets/dlc_manager/lang/zh_cn.json +++ b/src/main/resources/assets/dlc_manager/lang/zh_cn.json @@ -207,7 +207,7 @@ "dlc_manager.config.download.task_timeout": "单次下载超时(秒)", "dlc_manager.config.download.maxConcurrentDownloads": "最大同时下载数", "dlc_manager.config.download.maxSegmentsPerFile": "单文件最大分段数", - "dlc_manager.config.download.minSegmentMB": "分段最小大小(MB)", + "dlc_manager.config.download.minSegmentMB": "分段最小大小(MiB)", "dlc_manager.config.download.maxRetryCount": "下载失败重试次数", "dlc_manager.config.download.connectTimeoutSeconds": "连接超时(秒)", "dlc_manager.config.download.taskTimeoutSeconds": "单次下载超时(秒)", @@ -225,7 +225,7 @@ "dlc_manager.config.download.downloadMirrorProbeStrategy.majority": "多数优选", "dlc_manager.config.comment.download.maxConcurrentDownloads": "数量,范围 1~64", "dlc_manager.config.comment.download.maxSegmentsPerFile": "分段数,范围 1~64", - "dlc_manager.config.comment.download.minSegmentMB": "单位:MB,范围 1~1024", + "dlc_manager.config.comment.download.minSegmentMB": "单位:MiB,范围 1~1024", "dlc_manager.config.comment.download.maxRetryCount": "次数,范围 1~100", "dlc_manager.config.comment.download.connectTimeoutSeconds": "单位:秒,范围 1~300", "dlc_manager.config.comment.download.taskTimeoutSeconds": "单位:秒,范围 10~600", @@ -458,10 +458,10 @@ "dlc_manager.server.command_update_list": "可用更新:%s", "dlc_manager.server.command_push_unavailable": "服务端推送暂不可用。", "dlc_manager.server.self_update_available": "DLC 管理器可更新:%s -> %s", - "dlc_manager.server.push": "推送", + "dlc_manager.server.push": "DLC 同步", "dlc_manager.server.rcon": "RCON", "dlc_manager.server.files": "文件", - "dlc_manager.server.push_title": "服务器推送", + "dlc_manager.server.push_title": "DLC 同步", "dlc_manager.server.push_current_dir": "当前目录:%s", "dlc_manager.server.push_up": "上级", "dlc_manager.server.push_clear_selection": "清空选择(%s)", @@ -691,7 +691,7 @@ "dlc_manager.ui.empty_packs": "暂无拓展包,可导入文件或从 URL 下载。", "dlc_manager.ui.empty_updates": "暂无更新记录,导入的更新将在这里显示。", "dlc_manager.ui.empty_files": "此文件夹为空。", - "dlc_manager.ui.tool_push": "将本地 DLC 文件推送到此服务器。", + "dlc_manager.ui.tool_push": "对比并同步 DLC 配置、资源和开启状态。", "dlc_manager.ui.tool_rcon": "通过 RCON 执行命令与管理在线玩家。", "dlc_manager.ui.tool_files": "浏览目录、传输文件和管理服务器文件。", "dlc_manager.ui.tool_updates": "导入更新,查看版本记录与回滚操作。", @@ -762,5 +762,134 @@ "dlc_manager.bootstrap.failure.dialog_unavailable": "无法显示错误提示窗口:%s", "dlc_manager.bootstrap.failure.close": "关闭", "dlc_manager.bootstrap.early_incomplete": "早期资源安装尚未完成", - "dlc_manager.bootstrap.verification_failed": "必要资源校验失败" + "dlc_manager.bootstrap.verification_failed": "必要资源校验失败", + "dlc_manager.sync.title": "DLC 同步", + "dlc_manager.sync.mode_config": "仅同步配置", + "dlc_manager.sync.mode_files": "同步配置和文件", + "dlc_manager.sync.states": "同步 DLC 和预设启用状态", + "dlc_manager.sync.push": "客户端 → 服务端", + "dlc_manager.sync.pull": "服务端 → 客户端", + "dlc_manager.sync.compare": "对比", + "dlc_manager.sync.execute": "开始同步", + "dlc_manager.sync.cancel": "取消并撤回", + "dlc_manager.sync.server_missing": "服务端缺失", + "dlc_manager.sync.client_missing": "客户端缺失", + "dlc_manager.sync.content_changed": "内容不同", + "dlc_manager.sync.state_changed": "开启状态不同", + "dlc_manager.sync.type_all": "全部", + "dlc_manager.sync.type_dlc": "DLC", + "dlc_manager.sync.type_required": "必要资源", + "dlc_manager.sync.type_update": "热更新", + "dlc_manager.sync.manager_config": "同步管理器配置", + "dlc_manager.sync.type_preset": "预设", + "dlc_manager.sync.select_all": "全选", + "dlc_manager.sync.empty": "没有需要同步的内容", + "dlc_manager.sync.differences": "差异内容", + "dlc_manager.sync.replacement": "替换已有内容,需勾选确认", + "dlc_manager.sync.addition": "新增内容", + "dlc_manager.sync.enable": "开启 DLC", + "dlc_manager.sync.disable": "关闭 DLC", + "dlc_manager.sync.confirm": "同步选中的 %s 个 DLC 条目?替换前会备份已有内容。", + "dlc_manager.sync.working": "正在读取 DLC 信息…", + "dlc_manager.sync.preparing": "正在准备资源…", + "dlc_manager.sync.downloading": "正在下载…", + "dlc_manager.sync.transferring": "正在传输…", + "dlc_manager.sync.applying": "正在应用…", + "dlc_manager.sync.restoring": "正在撤回同步…", + "dlc_manager.sync.canceled": "已取消同步并撤回全部改动。", + "dlc_manager.sync.rollback": "回退", + "dlc_manager.sync.keep": "保留", + "dlc_manager.sync.error.server_downloads_stop": "无法停止服务端正在进行的下载,请稍后重试。", + "dlc_manager.sync.error.busy": "正在同步 DLC,请等待同步结束。", + "dlc_manager.sync.error.active": "同步仍在进行,请稍后重试。", + "dlc_manager.sync.error.already_active": "已经有同步任务正在运行。", + "dlc_manager.sync.error.other_active": "另一个同步任务正在运行。", + "dlc_manager.sync.error.other_operation": "另一个 DLC 操作正在进行,请稍后重试。", + "dlc_manager.sync.error.settings_save": "无法保存管理器配置。", + "dlc_manager.sync.error.preset_state_save": "无法保存预设状态:%s", + "dlc_manager.sync.error.recovery_save": "无法保存同步备份记录。", + "dlc_manager.sync.error.comparison_canceled": "已取消读取 DLC 信息。", + "dlc_manager.sync.error.comparison_not_started": "尚未开始读取 DLC 信息。", + "dlc_manager.sync.error.comparison_interrupted": "读取 DLC 信息已中断。", + "dlc_manager.sync.error.validation_stop": "无法停止资源校验,请稍后重试回退。", + "dlc_manager.sync.error.changed": "比较后 DLC 已发生变化,请重新比较:%s", + "dlc_manager.sync.error.config_changed": "比较后 DLC 配置已发生变化,请重新比较:%s", + "dlc_manager.sync.error.config_missing": "DLC 配置缺失。", + "dlc_manager.sync.error.config_not_found": "未找到 DLC 配置:%s", + "dlc_manager.sync.error.apply": "无法应用 DLC 资源。", + "dlc_manager.sync.error.read_not_open": "尚未开始读取 DLC 资源。", + "dlc_manager.sync.error.read_open": "DLC 资源正在读取中。", + "dlc_manager.sync.error.root_missing": "DLC 根目录不可用。", + "dlc_manager.sync.error.verification": "DLC 校验失败:%s", + "dlc_manager.sync.error.outside_roots": "文件位于允许同步的目录之外。", + "dlc_manager.sync.error.outside_item": "文件不属于所选 DLC:%s", + "dlc_manager.sync.error.update_version": "热更新包不适用于目标端的当前版本。", + "dlc_manager.sync.error.update_versions_missing": "热更新包缺少版本信息:%s", + "dlc_manager.sync.error.update_mode": "热更新包只能在“同步配置和文件”模式下同步。", + "dlc_manager.sync.error.invalid_path": "DLC 路径无效。", + "dlc_manager.sync.error.invalid_resource": "DLC 资源无效。", + "dlc_manager.sync.error.invalid_root": "DLC 根目录无效。", + "dlc_manager.sync.error.invalid_update": "热更新包无效:%s", + "dlc_manager.sync.error.invalid_remote": "远程 DLC 配置无效。", + "dlc_manager.sync.error.invalid_required": "必要资源配置无效。", + "dlc_manager.sync.error.invalid_archive_path": "资源压缩包内的路径无效。", + "dlc_manager.sync.error.invalid_directory": "资源目录无效。", + "dlc_manager.sync.error.invalid_offset": "资源传输位置无效。", + "dlc_manager.sync.error.invalid_size": "资源大小无效。", + "dlc_manager.sync.error.invalid_reuse_path": "可复用资源的路径无效。", + "dlc_manager.sync.error.invalid_item": "同步条目无效。", + "dlc_manager.sync.error.invalid_recovery": "同步备份记录无效:%s", + "dlc_manager.sync.error.invalid_response": "收到的同步响应无效。", + "dlc_manager.sync.error.settings_fields": "管理器配置需要按配置项同步。", + "dlc_manager.sync.error.settings_unavailable": "当前不支持同步管理器配置。", + "dlc_manager.sync.error.settings_missing": "管理器配置内容缺失。", + "dlc_manager.sync.error.resource_missing": "DLC 资源缺失:%s", + "dlc_manager.sync.error.no_item": "尚未选择要同步的 DLC。", + "dlc_manager.sync.error.no_upload": "尚未开始上传资源。", + "dlc_manager.sync.error.not_settings": "不是有效的管理器配置。", + "dlc_manager.sync.error.not_config": "不是可同步的 DLC 配置。", + "dlc_manager.sync.error.preset_missing": "预设配置不可用:%s", + "dlc_manager.sync.error.preset_blocked": "预设无法启用:%s", + "dlc_manager.sync.error.presets_disabled": "目标端未开启预设功能。", + "dlc_manager.sync.error.previous_unfinished": "上一个 DLC 条目尚未处理完成。", + "dlc_manager.sync.error.recovery_incomplete": "回退尚未完成,备份已保留:%s", + "dlc_manager.sync.error.recovery_interrupted": "回退操作已中断。", + "dlc_manager.sync.error.remote_missing": "远程 DLC 的资源文件缺失。", + "dlc_manager.sync.error.archive_escape": "资源压缩包包含超出 DLC 目录的路径。", + "dlc_manager.sync.error.archive_size": "资源压缩包的大小不匹配。", + "dlc_manager.sync.error.directory_integrity": "资源目录完整性校验失败。", + "dlc_manager.sync.error.resource_escape": "资源路径超出了 DLC 目录。", + "dlc_manager.sync.error.filename_missing": "无法确定资源文件名。", + "dlc_manager.sync.error.resource_integrity": "资源完整性校验失败。", + "dlc_manager.sync.error.outside_root": "资源位于 DLC 根目录之外。", + "dlc_manager.sync.error.resource_size": "资源大小不匹配。", + "dlc_manager.sync.error.reused_changed": "可复用资源已发生变化:%s", + "dlc_manager.sync.error.source_changed": "比较后源文件已发生变化:%s", + "dlc_manager.sync.error.source_changed_transfer": "传输时源文件已发生变化:%s", + "dlc_manager.sync.error.symbolic_path": "不支持同步符号链接路径。", + "dlc_manager.sync.error.symbolic_resource": "不支持同步符号链接资源。", + "dlc_manager.sync.error.symbolic_settings": "管理器配置路径不能使用符号链接。", + "dlc_manager.sync.error.symbolic_file": "不支持同步符号链接文件。", + "dlc_manager.sync.error.canceled": "已取消同步。", + "dlc_manager.sync.error.connection_lost": "同步连接已断开。", + "dlc_manager.sync.error.file_missing": "要同步的文件缺失:%s", + "dlc_manager.sync.error.not_started": "尚未开始同步。", + "dlc_manager.sync.error.truncated": "DLC 资源传输不完整:%s", + "dlc_manager.sync.error.unfinished_item": "DLC 条目尚未处理完成。", + "dlc_manager.sync.error.unfinished_upload": "资源上传尚未完成。", + "dlc_manager.sync.error.unsupported_action": "不支持此同步操作。", + "dlc_manager.sync.error.validation_interrupted": "资源校验已中断。", + "dlc_manager.sync.error.unsupported": "服务端不支持 DLC 同步。", + "dlc_manager.sync.failed": "同步失败", + "dlc_manager.sync.failure_choice": "%s\n\n已保留当前改动。是否回退本次同步?", + "dlc_manager.sync.restored": "已回退本次同步。", + "dlc_manager.sync.finished": "同步完成:%s 项成功,%s 项失败。", + "dlc_manager.sync.updates_ready": "热更新包已同步,请前往目标端的热更新管理器完成更新。", + "dlc_manager.sync.restart": "请重启以完成应用。", + "dlc_manager.sync.busy": "请先完成或取消 DLC 同步,再切换游戏目录。", + "dlc_manager.sync.configuration_missing": "缺少配置", + "dlc_manager.sync.configuration_changed": "配置不同", + "dlc_manager.sync.resources_missing": "缺失资源", + "dlc_manager.sync.resources_changed": "资源不同", + "dlc_manager.sync.succeeded_item": "已同步:%s" } diff --git a/src/test/java/com/cozooo/dlc_manager/client/screen/DlcUiProgressTest.java b/src/test/java/com/cozooo/dlc_manager/client/screen/DlcUiProgressTest.java new file mode 100644 index 00000000..68280d51 --- /dev/null +++ b/src/test/java/com/cozooo/dlc_manager/client/screen/DlcUiProgressTest.java @@ -0,0 +1,32 @@ +package com.cozooo.dlc_manager.client.screen; + +import com.sighs.apricityui.init.Document; +import com.sighs.apricityui.init.Element; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class DlcUiProgressTest { + @Test void hiddenProgressDoesNotInvalidateTheScrollingListOnEveryTick() { + var invalidations = new AtomicInteger(); + var document = new Document("dlc_manager/ui/page.html", false) { + @Override public void markDirty(Element element, int flags) { invalidations.incrementAndGet(); } + }; + Document.runWithContext(document, () -> { + document.documentElement = new com.sighs.apricityui.element.Html(document); + document.body = new com.sighs.apricityui.element.Body(document); + document.documentElement.appendChild(document.body); + var results = document.body.appendChild(document.createElement("div")); + var track = results.appendChild(document.createElement("div")); + var fill = track.appendChild(document.createElement("div")); + var binding = DlcUiProgress.binding(track, fill, () -> -1); + binding.run(); + track.setAttribute("style", "display:none"); + invalidations.set(0); + for (int tick = 0; tick < 100; tick++) binding.run(); + assertEquals(0, invalidations.get(), "Hidden progress must not continually invalidate a long scrolling list"); + track.setAttribute("style", ""); invalidations.set(0); binding.run(); + assertTrue(invalidations.get() > 0, "Visible indeterminate progress must resume"); + }); + } +} diff --git a/src/test/java/com/cozooo/dlc_manager/client/screen/DlcUiScrollTest.java b/src/test/java/com/cozooo/dlc_manager/client/screen/DlcUiScrollTest.java index 8a2eb8d9..36c59ab8 100644 --- a/src/test/java/com/cozooo/dlc_manager/client/screen/DlcUiScrollTest.java +++ b/src/test/java/com/cozooo/dlc_manager/client/screen/DlcUiScrollTest.java @@ -30,6 +30,18 @@ private Element output(double height) { assertEquals(440, element.getTargetScrollTop()); } + @Test void rebuildingAnInnerListPreservesItsCurrentAndPendingScrollBeforeLayout() { + Element previous = output(900); + previous.scrollTop = 200; + previous.setScrollTop(350); + var state = DlcUiScroll.capture(previous); + Element rebuilt = output(0); + DlcUiScroll.restore(rebuilt, state); + assertEquals(200, rebuilt.getScrollTop()); + assertEquals(350, rebuilt.getTargetScrollTop()); + assertEquals(900, rebuilt.scrollHeight); + } + @Test void shortOutputRemainsVisibleAndUserScrollingDisablesFollowing() { Element element = output(40); DlcUiScroll.scrollToEnd(element); diff --git a/src/test/java/com/cozooo/dlc_manager/client/sync/DlcSyncAdapterTest.java b/src/test/java/com/cozooo/dlc_manager/client/sync/DlcSyncAdapterTest.java new file mode 100644 index 00000000..feb45027 --- /dev/null +++ b/src/test/java/com/cozooo/dlc_manager/client/sync/DlcSyncAdapterTest.java @@ -0,0 +1,454 @@ +package com.cozooo.dlc_manager.client.sync; + +import com.cozooo.dlc_manager.sync.*; +import com.cozooo.dlc_manager.sync.SyncModel.*; +import com.cozooo.dlc_manager.core.*; +import com.cozooo.dlc_manager.platform.PlatformServices; +import com.cozooo.dlc_manager.test.TestPlatformProvider; +import com.google.gson.JsonObject; +import java.nio.file.*; +import java.util.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.*; + +class DlcSyncAdapterTest { + @TempDir Path game; + Path root; + @BeforeEach void setup() throws Exception { + root = game.resolve("DLC"); Files.createDirectories(root.resolve("required")); Files.createDirectories(root.resolve("downloaded/required")); + PlatformServices.setProvider(new TestPlatformProvider(game, game.resolve("config"))); DlcConfig.setDlcRoot("DLC"); DlcStateConfig.reloadFromDisk(); + Files.writeString(root.resolve("required/main.dc"), "identifier='main'\nfile_name='main.txt'\nappliedTarget='config/main.txt'\n"); + Files.writeString(root.resolve("downloaded/required/main.txt"), "old resource"); + Files.createDirectories(game.resolve("config")); Files.writeString(game.resolve("config/main.txt"), "original user file"); + Files.writeString(game.resolve("config/unrelated.txt"), "keep"); + } + @AfterEach void reset() { com.cozooo.dlc_manager.client.preset.DlcPresetRuntimeState.clearAll(); DlcRuntimeState.clearOverrides(); PlatformServices.setProvider(null); DlcConfig.setDlcRoot("DLC"); } + private void start(SyncEndpoint endpoint) throws Exception { + JsonObject begin = SyncClient.request("begin"); begin.addProperty("operation", UUID.randomUUID().toString()); SyncClient.checked(endpoint, begin); + JsonObject row = SyncClient.request("item"); row.addProperty("key", "0:required/main.dc"); SyncClient.checked(endpoint, row); + } + private void upload(SyncEndpoint endpoint, String value) throws Exception { upload(endpoint, "0:downloaded/required/main.txt", value); } + private void upload(SyncEndpoint endpoint, String id, String value) throws Exception { + Path bytes = Files.writeString(game.resolve("incoming.txt"), value); + JsonObject write = SyncClient.request("write"); write.add("file", SyncEndpoint.JSON.toJsonTree(new File(id, Files.size(bytes), DlcFileMatch.fullHash(bytes)))); + write.addProperty("hash", DlcFileMatch.fullHash(bytes)); SyncClient.checked(endpoint, write); + JsonObject chunk = SyncClient.request("write_chunk"); chunk.addProperty("data", Base64.getEncoder().encodeToString(Files.readAllBytes(bytes))); SyncClient.checked(endpoint, chunk); + SyncClient.checked(endpoint, SyncClient.request("write_end")); + } + @Test void manifestIncludesLocalizedNamesForRemoteAndRequiredDlc() throws Exception { + Files.writeString(root.resolve("required/main.dc"), "identifier='main'\nfile_name='main.txt'\nappliedTarget='config/main.txt'\n[DisplayName]\nzh_CN='必要资源'\nen_US='Required resource'\n[tip]\nzh_CN='必要资源说明'\n"); + Files.createDirectories(root.resolve("remote")); + Files.writeString(root.resolve("remote/main.dc"), "identifier='remote-main'\ntype='mod'\nfile_name='main.jar'\n[DisplayName]\nzh_CN='远程内容'\nen_US='Remote content'\n[tip]\nen_US='Remote description'\n"); + try (SyncEndpoint endpoint = new SyncEndpoint(new DlcSyncAdapter())) { + Map items = SyncModel.index(SyncClient.manifest(endpoint)); + assertEquals("必要资源", SyncModel.displayName(items.get("0:required/main.dc"), "zh_cn")); + assertEquals("Remote content", SyncModel.displayName(items.get("0:remote/main.dc"), "en_us")); + assertEquals("远程内容", items.get("0:remote/main.dc").displayNames().get("zh_cn")); + assertEquals("必要资源说明", SyncModel.tooltip(items.get("0:required/main.dc"), "zh_cn")); + assertEquals("Remote description", SyncModel.tooltip(items.get("0:remote/main.dc"), "en_us")); + } + } + @Test void usesCurrentResourceDirectoryAndRestoresAppliedFilesAfterCompletedItem() throws Exception { + DlcSyncAdapter adapter = new DlcSyncAdapter(); + assertEquals("0:downloaded/required/main.txt", adapter.manifest().get(0).resources().get(0).id()); + try (SyncEndpoint endpoint = new SyncEndpoint(adapter)) { + start(endpoint); upload(endpoint, "new resource"); SyncClient.checked(endpoint, SyncClient.request("apply")); + assertEquals("new resource", Files.readString(game.resolve("config/main.txt"))); + SyncClient.checked(endpoint, SyncClient.request("item_done")); SyncClient.checked(endpoint, SyncClient.request("rollback")); + assertEquals("original user file", Files.readString(game.resolve("config/main.txt"))); + assertEquals("old resource", Files.readString(root.resolve("downloaded/required/main.txt"))); + assertEquals("keep", Files.readString(game.resolve("config/unrelated.txt"))); + } + try (var paths = Files.walk(root.resolve("tmp/sync"))) { + for (Path path : paths.filter(p -> p.getFileName().toString().equals("recovery.dc")).toList()) { + JsonObject log = DlcDcConfigIO.read(path, DlcDcConfigIO.Layout.GENERIC); + for (var value : log.getAsJsonArray("entries")) assertFalse(Path.of(value.getAsJsonObject().get("path").getAsString()).isAbsolute()); + } + } + } + @Test void disconnectKeepsChangesAndSuccessfulCommitIsNotReplayedByRecovery() throws Exception { + try (SyncEndpoint endpoint = new SyncEndpoint(new DlcSyncAdapter())) { start(endpoint); upload(endpoint, "uncommitted"); } + new DlcSyncAdapter().recover(); + assertEquals("uncommitted", Files.readString(root.resolve("downloaded/required/main.txt"))); + try (SyncEndpoint endpoint = new SyncEndpoint(new DlcSyncAdapter())) { + start(endpoint); upload(endpoint, "committed"); SyncClient.checked(endpoint, SyncClient.request("apply")); + SyncClient.checked(endpoint, SyncClient.request("item_done")); SyncClient.checked(endpoint, SyncClient.request("commit")); + } + new DlcSyncAdapter().recover(); + assertEquals("committed", Files.readString(root.resolve("downloaded/required/main.txt"))); + assertEquals("committed", Files.readString(game.resolve("config/main.txt"))); + } + @Test void retainedAppliedFilesCanBeRestoredAfterClosingAndReopeningTheEndpoint() throws Exception { + String operation = UUID.randomUUID().toString(); + try (SyncEndpoint endpoint = new SyncEndpoint(new DlcSyncAdapter())) { + JsonObject begin = SyncClient.request("begin"); begin.addProperty("operation", operation); SyncClient.checked(endpoint, begin); + JsonObject row = SyncClient.request("item"); row.addProperty("key", "0:required/main.dc"); SyncClient.checked(endpoint, row); + upload(endpoint, "retained"); SyncClient.checked(endpoint, SyncClient.request("apply")); + endpoint.disconnectCurrent(); + assertThrows(java.io.IOException.class, () -> SyncClient.checked(endpoint, SyncClient.request("apply"))); + } + new DlcSyncAdapter().recover(); + assertEquals("retained", Files.readString(root.resolve("downloaded/required/main.txt"))); + assertEquals("retained", Files.readString(game.resolve("config/main.txt"))); + try (SyncEndpoint endpoint = new SyncEndpoint(new DlcSyncAdapter())) { + JsonObject restore = SyncClient.request("recover_operation"); restore.addProperty("operation", operation); SyncClient.checked(endpoint, restore); + SyncClient.checked(endpoint, restore); + } + assertEquals("old resource", Files.readString(root.resolve("downloaded/required/main.txt"))); + assertEquals("original user file", Files.readString(game.resolve("config/main.txt"))); + assertEquals("keep", Files.readString(game.resolve("config/unrelated.txt"))); + } + @Test void startingAnotherSyncDoesNotAutomaticallyRestoreAnInterruptedOperation() throws Exception { + DlcSyncAdapter adapter = new DlcSyncAdapter(); + String interrupted = UUID.randomUUID().toString(); + Path resource = root.resolve("downloaded/required/main.txt"); + try (Journal journal = adapter.journal(adapter.workDirectory().resolve(interrupted).resolve("00000000-item/journal"))) { + journal.capture(resource); Files.writeString(resource, "partial result"); + } + try (SyncEndpoint endpoint = new SyncEndpoint(new DlcSyncAdapter())) { + JsonObject begin = SyncClient.request("begin"); begin.addProperty("operation", UUID.randomUUID().toString()); SyncClient.checked(endpoint, begin); + assertEquals("partial result", Files.readString(resource)); + } + new DlcSyncAdapter().recoverOperation(interrupted); + assertEquals("old resource", Files.readString(resource)); + } + @Test void remoteReapplicationAndFilenameChangeUseApplicationFlowAndRollbackConflicts() throws Exception { + Path config = root.resolve("remote/main.dc"); Files.createDirectories(config.getParent()); + String original = "identifier='remote-main'\ntype='mod'\nfile_name='old.jar'\n"; + Files.writeString(config, original); + var remote = com.cozooo.dlc_manager.client.remote.DlcRemoteLoader.loadSingle(config, root); + Path old = remote.resolveTargetPath("old.jar"); Files.createDirectories(old.getParent()); Files.writeString(old, "old mod"); + DlcItem initial = new DlcScanner().scan(false).values().stream().flatMap(List::stream).filter(item -> old.equals(item.getSourcePath())).findFirst().orElseThrow(); + initial.setEnabled(true); DlcApplier.applySynced(initial); + Files.writeString(game.resolve("mods/conflict.jar"), "unrelated conflicting mod"); + DlcSyncAdapter adapter = new DlcSyncAdapter(); + try (SyncEndpoint endpoint = new SyncEndpoint(adapter)) { + JsonObject begin = SyncClient.request("begin"); begin.addProperty("operation", UUID.randomUUID().toString()); SyncClient.checked(endpoint, begin); + JsonObject row = SyncClient.request("item"); row.addProperty("key", "0:remote/main.dc"); SyncClient.checked(endpoint, row); + upload(endpoint, "0:remote/main.dc", "identifier='remote-main'\ntype='mod'\nfile_name='new.jar'\nconflicts=['mods/conflict.jar']\n"); + Path next = com.cozooo.dlc_manager.client.remote.DlcRemoteLoader.loadSingle(config, root).resolveTargetPath("new.jar"); + String id = "0:" + root.relativize(next).toString().replace('\\', '/'); + upload(endpoint, id, "new mod payload"); + JsonObject apply = SyncClient.request("apply"); apply.addProperty("enabled", true); SyncClient.checked(endpoint, apply); + assertFalse(Files.exists(game.resolve("mods/old.jar"))); + assertEquals("new mod payload", Files.readString(game.resolve("mods/new.jar"))); + assertFalse(Files.exists(game.resolve("mods/conflict.jar"))); + SyncClient.checked(endpoint, SyncClient.request("item_done")); SyncClient.checked(endpoint, SyncClient.request("rollback")); + assertEquals(original, Files.readString(config)); + assertEquals("old mod", Files.readString(game.resolve("mods/old.jar"))); + assertEquals("unrelated conflicting mod", Files.readString(game.resolve("mods/conflict.jar"))); + assertFalse(Files.exists(game.resolve("mods/new.jar"))); + assertFalse(Files.exists(next)); + assertTrue(DlcStateConfig.getState(initial.getId()).enabled); + assertEquals(old, DlcRuntimeState.getItemByIdentifier("remote-main").getSourcePath()); + assertTrue(DlcRuntimeState.getItemByIdentifier("remote-main").isEnabled()); + } + } + @Test void explicitRecoveryCanUndoAnAcknowledgmentLostAfterCommit() throws Exception { + String operation = UUID.randomUUID().toString(); + try (SyncEndpoint endpoint = new SyncEndpoint(new DlcSyncAdapter())) { + JsonObject begin = SyncClient.request("begin"); begin.addProperty("operation", operation); SyncClient.checked(endpoint, begin); + JsonObject row = SyncClient.request("item"); row.addProperty("key", "0:required/main.dc"); SyncClient.checked(endpoint, row); + upload(endpoint, "new resource"); SyncClient.checked(endpoint, SyncClient.request("apply")); + SyncClient.checked(endpoint, SyncClient.request("item_done")); SyncClient.checked(endpoint, SyncClient.request("commit")); + } + new DlcSyncAdapter().recoverOperation(operation); + assertEquals("old resource", Files.readString(root.resolve("downloaded/required/main.txt"))); + assertEquals("original user file", Files.readString(game.resolve("config/main.txt"))); + } + @Test void serverDownloadPreparationAndAppliedResourcesAreRevertedTogether() throws Exception { + var server = com.sun.net.httpserver.HttpServer.create(new java.net.InetSocketAddress("127.0.0.1", 0), 0); + byte[] content = "downloaded resource".getBytes(java.nio.charset.StandardCharsets.UTF_8); + server.createContext("/main.txt", exchange -> { + exchange.getResponseHeaders().set("Content-Length", Integer.toString(content.length)); + if (exchange.getRequestMethod().equals("HEAD")) exchange.sendResponseHeaders(200, -1); + else { exchange.sendResponseHeaders(200, content.length); exchange.getResponseBody().write(content); } + exchange.close(); + }); + server.start(); + String mode = DlcConfig.getDownloadMirrorMode(); int retries = DlcConfig.getMaxRetryCount(); + try { + DlcConfig.setDownloadMirrorMode("direct"); DlcConfig.setMaxRetryCount(1); + String config = "# keep comment\nidentifier='main'\nfile_name='main.txt'\nappliedTarget='config/main.txt'\nunknown='keep'\ndownloadPriority='directurl'\ndirectUrl='http://127.0.0.1:" + server.getAddress().getPort() + "/main.txt'\n"; + Files.writeString(root.resolve("required/main.dc"), config); + try (SyncEndpoint endpoint = new SyncEndpoint(new DlcSyncAdapter())) { + start(endpoint); + JsonObject prepare = SyncClient.request("prepare"); prepare.addProperty("force", true); SyncClient.checked(endpoint, prepare); + assertEquals("downloaded resource", Files.readString(root.resolve("downloaded/required/main.txt"))); + SyncClient.checked(endpoint, SyncClient.request("apply")); + assertEquals("downloaded resource", Files.readString(game.resolve("config/main.txt"))); + SyncClient.checked(endpoint, SyncClient.request("item_done")); SyncClient.checked(endpoint, SyncClient.request("rollback")); + } + assertEquals(config, Files.readString(root.resolve("required/main.dc"))); + assertEquals("old resource", Files.readString(root.resolve("downloaded/required/main.txt"))); + assertEquals("original user file", Files.readString(game.resolve("config/main.txt"))); + assertFalse(Files.exists(root.resolve("downloaded/required/main.txt.part"))); + } finally { server.stop(0); DlcConfig.setDownloadMirrorMode(mode); DlcConfig.setMaxRetryCount(retries); } + } + @Test void directoryTransferVerifiesBeforeReplacingAndRestoresWholeTree() throws Exception { + Path config = root.resolve("remote/folder.dc"); Files.createDirectories(config.getParent()); + Files.writeString(config, "identifier='folder'\ntype='resourcepack'\nfile_name='folder'\n"); + var remote = com.cozooo.dlc_manager.client.remote.DlcRemoteLoader.loadSingle(config, root); + Path target = remote.resolveTargetPath("folder"); Files.createDirectories(target); + Files.writeString(target.resolve("old.txt"), "original"); + Path source = game.resolve("source-folder"), archive = game.resolve("folder.zip"); + Files.createDirectories(source.resolve("nested/empty")); Files.writeString(source.resolve("nested/new.txt"), "new resource"); + SyncArchive.pack(source, archive, () -> false); + DlcSyncAdapter adapter = new DlcSyncAdapter(); + String key = "0:remote/folder.dc", id = "0:" + root.relativize(target).toString().replace('\\', '/'); + assertEquals(DlcFileMatch.fullHash(target), adapter.item(key).resources().get(0).hash()); + try (SyncEndpoint endpoint = new SyncEndpoint(adapter)) { + JsonObject begin = SyncClient.request("begin"); begin.addProperty("operation", UUID.randomUUID().toString()); SyncClient.checked(endpoint, begin); + JsonObject row = SyncClient.request("item"); row.addProperty("key", key); SyncClient.checked(endpoint, row); + JsonObject write = SyncClient.request("write"); write.add("file", SyncEndpoint.JSON.toJsonTree(new File(id, Files.size(archive), DlcFileMatch.fullHash(source)))); + write.addProperty("hash", DlcFileMatch.fullHash(archive)); write.addProperty("directory", true); write.addProperty("resourceSize", SyncArchive.size(source)); SyncClient.checked(endpoint, write); + JsonObject chunk = SyncClient.request("write_chunk"); chunk.addProperty("data", Base64.getEncoder().encodeToString(Files.readAllBytes(archive))); SyncClient.checked(endpoint, chunk); + SyncClient.checked(endpoint, SyncClient.request("write_end")); + assertEquals(DlcFileMatch.fullHash(source), DlcFileMatch.fullHash(target)); assertFalse(Files.exists(target.resolve("old.txt"))); + SyncClient.checked(endpoint, SyncClient.request("item_done")); SyncClient.checked(endpoint, SyncClient.request("rollback")); + assertEquals("original", Files.readString(target.resolve("old.txt"))); assertFalse(Files.exists(target.resolve("nested"))); + } + Path malicious = game.resolve("escape.zip"); + try (var output = new java.util.zip.ZipOutputStream(Files.newOutputStream(malicious))) { + output.putNextEntry(new java.util.zip.ZipEntry("../escape.txt")); output.write(1); output.closeEntry(); + } + Journal journal = adapter.journal(root.resolve("tmp/test-directory-recovery")); + try { assertThrows(java.io.IOException.class, () -> SyncArchive.install(malicious, target, 1, "invalid", journal, () -> false)); } + finally { journal.close(); } + assertEquals("original", Files.readString(target.resolve("old.txt"))); assertFalse(Files.exists(target.getParent().resolve("escape.txt"))); + } + @Test void invalidConfigurationsAreReportedAndLegacyCacheUsesCanonicalResourceId() throws Exception { + Files.move(root.resolve("downloaded/required/main.txt"), root.resolve("required/main.txt")); + DlcSyncAdapter adapter = new DlcSyncAdapter(); + File resource = adapter.manifest().get(0).resources().get(0); + assertEquals("0:downloaded/required/main.txt", resource.id()); + assertEquals(root.resolve("required/main.txt"), adapter.resolve(resource.id())); + Files.writeString(root.resolve("required/broken.dc"), "identifier=[\n"); + assertThrows(java.io.IOException.class, adapter::manifest); + } + @Test void requiredZipInstallationAndHistoryAreRestoredOnRollback() throws Exception { + Path zip = root.resolve("downloaded/required/main.zip"); + try (var output = new java.util.zip.ZipOutputStream(Files.newOutputStream(zip))) { + output.putNextEntry(new java.util.zip.ZipEntry("main.txt")); output.write("new extracted resource".getBytes(java.nio.charset.StandardCharsets.UTF_8)); output.closeEntry(); + output.putNextEntry(new java.util.zip.ZipEntry("extra.txt")); output.write("extra".getBytes(java.nio.charset.StandardCharsets.UTF_8)); output.closeEntry(); + } + Files.writeString(root.resolve("required/main.dc"), "identifier='main'\nfile_name='main.zip'\nunzip=true\nappliedTarget='config/'\n"); + try (SyncEndpoint endpoint = new SyncEndpoint(new DlcSyncAdapter())) { + start(endpoint); SyncClient.checked(endpoint, SyncClient.request("apply")); + assertEquals("new extracted resource", Files.readString(game.resolve("config/main.txt"))); assertTrue(Files.exists(game.resolve("config/extra.txt"))); + SyncClient.checked(endpoint, SyncClient.request("item_done")); SyncClient.checked(endpoint, SyncClient.request("rollback")); + } + assertEquals("original user file", Files.readString(game.resolve("config/main.txt"))); assertFalse(Files.exists(game.resolve("config/extra.txt"))); + assertFalse(Files.exists(root.resolve("tmp/required/history.dc"))); + } + private Path updateZip(String name, String platform, String old, String next, String baseNew) throws Exception { + Path zip = root.resolve("update").resolve(name); Files.createDirectories(zip.getParent()); + try (var output = new java.util.zip.ZipOutputStream(Files.newOutputStream(zip))) { + output.putNextEntry(new java.util.zip.ZipEntry("update.dc")); + output.write(("[version]\nplatform='" + platform + "'\nbase='1.0.0'\ndlc='" + old + "'\n" + (next == null ? "" : "dlc_new='" + next + "'\n") + (baseNew == null ? "" : "base_new='" + baseNew + "'\n") + "[file]\nadd=['mods/new.jar']\n").getBytes(java.nio.charset.StandardCharsets.UTF_8)); + output.closeEntry(); output.putNextEntry(new java.util.zip.ZipEntry("mods/new.jar")); output.write("new mod".getBytes(java.nio.charset.StandardCharsets.UTF_8)); output.closeEntry(); + } + return zip; + } + private void installedVersion() throws Exception { + Files.createDirectories(root.resolve("info")); Files.writeString(root.resolve("info/version.dc"), "base='1.0.0'\ndlc='1.0.0'\n"); + } + private void beginItem(SyncEndpoint endpoint, String key) throws Exception { + JsonObject begin = SyncClient.request("begin"); begin.addProperty("operation", UUID.randomUUID().toString()); SyncClient.checked(endpoint, begin); + JsonObject row = SyncClient.request("item"); row.addProperty("key", key); SyncClient.checked(endpoint, row); + } + private void validate(SyncEndpoint endpoint, Item item) throws Exception { + JsonObject request = SyncClient.request("validate"); request.add("item", SyncEndpoint.JSON.toJsonTree(item)); SyncClient.checked(endpoint, request); + } + private void uploadBytes(SyncEndpoint endpoint, String key, byte[] bytes) throws Exception { + Path incoming = Files.write(game.resolve("incoming.zip"), bytes); + JsonObject write = SyncClient.request("write"); write.add("file", SyncEndpoint.JSON.toJsonTree(new File(key, bytes.length, DlcFileMatch.fullHash(incoming)))); + write.addProperty("hash", DlcFileMatch.fullHash(incoming)); SyncClient.checked(endpoint, write); + JsonObject chunk = SyncClient.request("write_chunk"); chunk.addProperty("data", Base64.getEncoder().encodeToString(bytes)); SyncClient.checked(endpoint, chunk); + SyncClient.checked(endpoint, SyncClient.request("write_end")); + } + @Test void discoversPresetsManagerConfigurationsAndUpdateMetadataWithoutExtractingOrCreatingVersion() throws Exception { + Path manager = game.resolve("config/dlc_manager"); Files.createDirectories(manager); + Files.writeString(manager.resolve("config.dc"), "# keep\n[ui]\nshowDependencyReminder=true\nunknown='keep'\n"); + Files.writeString(manager.resolve("config-server.dc"), "password='example'\n"); + Files.createDirectories(root.resolve("preset/nested")); + Files.writeString(root.resolve("preset/nested/main.dc"), "identifier='preset-main'\n[DisplayName]\nzh_CN='测试预设'\n"); + Path zip = updateZip("next.zip", "universal", "1.0.0", "1.1.0", null); + updateZip("base.zip", "universal", "1.0.0", null, "2.0.0"); + DlcSyncAdapter adapter = new DlcSyncAdapter(); + Map before = SyncModel.index(adapter.manifest()); + assertEquals("测试预设", SyncModel.displayName(before.get("0:preset/nested/main.dc"), "zh_cn")); + assertTrue(before.get("manager:config.dc").displayNames().isEmpty()); + assertFalse(before.containsKey("manager:config-server.dc")); + assertEquals("1.0.0", before.get("0:update/base.zip").update().dlcNew()); + assertEquals("2.0.0", before.get("0:update/base.zip").update().baseNew()); + assertEquals(Files.size(zip), before.get("0:update/next.zip").config().size()); + assertFalse(Files.exists(root.resolve("info/version.dc"))); + assertFalse(Files.exists(root.resolve("tmp/_staging"))); + installedVersion(); + assertTrue(SyncModel.versionState(SyncModel.index(adapter.manifest()).get("0:info/version.dc"))); + assertThrows(java.io.IOException.class, () -> adapter.validateKey("0:info/version.dc")); + assertThrows(java.io.IOException.class, () -> adapter.resolve("manager:../other.dc")); + assertThrows(java.io.IOException.class, () -> adapter.resolve("manager:state.dc")); + assertThrows(java.io.IOException.class, () -> adapter.validateKey("0:update/nested/next.zip")); + } + @Test void synchronizingHotUpdateOnlyStoresArchiveAndCanRollBackOverwrite() throws Exception { + installedVersion(); + String key = "0:update/next.zip", version = Files.readString(root.resolve("info/version.dc")); + Path zip = updateZip("next.zip", "universal", "1.0.0", "1.1.0", null); + byte[] original = Files.readAllBytes(zip); + DlcSyncAdapter adapter = new DlcSyncAdapter(); Item expected = adapter.item(key); + byte[] replacement = Files.readAllBytes(updateZip("payload.zip", "universal", "1.0.0", "1.2.0", null)); + try (SyncEndpoint endpoint = new SyncEndpoint(adapter)) { + beginItem(endpoint, key); validate(endpoint, expected); uploadBytes(endpoint, key, replacement); + assertFalse(SyncClient.checked(endpoint, SyncClient.request("apply")).get("requiresRestart").getAsBoolean()); + SyncClient.checked(endpoint, SyncClient.request("item_done")); + assertArrayEquals(replacement, Files.readAllBytes(zip)); + assertEquals(version, Files.readString(root.resolve("info/version.dc"))); + assertFalse(Files.exists(game.resolve("mods/new.jar"))); + assertFalse(Files.exists(root.resolve("tmp/_staging"))); + SyncClient.checked(endpoint, SyncClient.request("rollback")); + } + assertArrayEquals(original, Files.readAllBytes(zip)); + try (SyncEndpoint endpoint = new SyncEndpoint(new DlcSyncAdapter())) { + beginItem(endpoint, key); validate(endpoint, expected); uploadBytes(endpoint, key, replacement); + SyncClient.checked(endpoint, SyncClient.request("apply")); SyncClient.checked(endpoint, SyncClient.request("item_done")); SyncClient.checked(endpoint, SyncClient.request("commit")); + } + new DlcSyncAdapter().recover(); + assertArrayEquals(replacement, Files.readAllBytes(zip)); assertEquals(version, Files.readString(root.resolve("info/version.dc"))); assertFalse(Files.exists(game.resolve("mods/new.jar"))); + } + @Test void rejectsUpdateWithoutItsPrerequisiteAndAcceptsExistingTargetPrerequisite() throws Exception { + installedVersion(); + Path incoming = updateZip("later.zip", "universal", "1.1.0", "1.2.0", null); + DlcSyncAdapter adapter = new DlcSyncAdapter(); Item item = adapter.item("0:update/later.zip"); + try (SyncEndpoint endpoint = new SyncEndpoint(adapter)) { + beginItem(endpoint, item.key()); + assertThrows(java.io.IOException.class, () -> validate(endpoint, item)); + updateZip("first.zip", "universal", "1.0.0", "1.1.0", null); + validate(endpoint, item); + assertEquals(Files.size(incoming), item.config().size()); + SyncClient.checked(endpoint, SyncClient.request("rollback")); + } + } + @Test void preservesManagerAndPresetTextAndRollsBackTheirOverwrites() throws Exception { + for (String key : List.of("0:preset/main.dc")) { + DlcSyncAdapter adapter = new DlcSyncAdapter(); Path path = adapter.resolve(key); Files.createDirectories(path.getParent()); + String original = "# original\nidentifier='before'\nunknown='retain'\n", replacement = "# incoming\nidentifier='after'\nunknown='preserve'\n"; + Files.writeString(path, original); + try (SyncEndpoint endpoint = new SyncEndpoint(adapter)) { + beginItem(endpoint, key); upload(endpoint, key, replacement); + assertTrue(SyncClient.checked(endpoint, SyncClient.request("apply")).get("requiresRestart").getAsBoolean()); + SyncClient.checked(endpoint, SyncClient.request("item_done")); + assertEquals(replacement, Files.readString(path)); + SyncClient.checked(endpoint, SyncClient.request("rollback")); + } + assertEquals(original, Files.readString(path)); + } + } + private Path presetWithMod() throws Exception { + Files.createDirectories(root.resolve("remote")); Files.writeString(root.resolve("remote/mod.dc"), "identifier='preset-mod'\ntype='mod'\nfile_name='preset.jar'\n"); + var remote = com.cozooo.dlc_manager.client.remote.DlcRemoteLoader.loadSingle(root.resolve("remote/mod.dc"), root); + Path payload = remote.resolveTargetPath("preset.jar"); Files.createDirectories(payload.getParent()); Files.writeString(payload, "preset mod"); + Files.createDirectories(root.resolve("preset")); + Files.writeString(root.resolve("preset/main.dc"), "identifier='preset-main'\nenable=['preset-mod']\nconflicts=['config/unrelated.txt']\n"); + com.cozooo.dlc_manager.client.preset.DlcPresetRuntimeState.clearAll(); DlcRuntimeState.clearOverrides(); + return payload; + } + @Test void presetManifestIncludesEnabledStateForStateOnlyComparison() throws Exception { + presetWithMod(); + var adapter = new DlcSyncAdapter(); + Item disabled = adapter.item("0:preset/main.dc"); + com.cozooo.dlc_manager.client.preset.DlcPresetStateStore.savePresetStatus(Map.of("preset-main", com.cozooo.dlc_manager.client.preset.DlcPresetStatus.ON)); + Item enabled = adapter.item("0:preset/main.dc"); + assertEquals(false, disabled.enabled()); assertEquals(true, enabled.enabled()); + assertTrue(SyncModel.plan(List.of(enabled), List.of(disabled), Direction.PUSH, false, Mode.CONFIG).isEmpty()); + Row row = SyncModel.plan(List.of(enabled), List.of(disabled), Direction.PUSH, true, Mode.CONFIG).get(0); + assertEquals("state_changed", SyncModel.status(row, Mode.CONFIG)); + } + @Test void presetMetadataUsesTheSameIdentifierPrecedenceAsThePresetLoader() throws Exception { + presetWithMod(); + Files.writeString(root.resolve("preset/main.dc"), "identifier='legacy'\n[basic]\nidentifier=' preset-main '\n"); + com.cozooo.dlc_manager.client.preset.DlcPresetStateStore.savePresetStatus(Map.of("preset-main", com.cozooo.dlc_manager.client.preset.DlcPresetStatus.ON)); + Item item = new DlcSyncAdapter().item("0:preset/main.dc"); + assertEquals("preset-main", item.identifier()); assertEquals(true, item.enabled()); + } + @Test void presetSyncRunsEnableAndConflictOperationsAndRollbackRestoresEverything() throws Exception { + presetWithMod(); + Path state = com.cozooo.dlc_manager.client.preset.DlcPresetStateStore.getStatePath(); + try (SyncEndpoint endpoint = new SyncEndpoint(new DlcSyncAdapter())) { + beginItem(endpoint, "0:preset/main.dc"); + JsonObject apply = SyncClient.request("apply"); apply.addProperty("enabled", true); SyncClient.checked(endpoint, apply); + assertEquals("preset mod", Files.readString(game.resolve("mods/preset.jar"))); + assertFalse(Files.exists(game.resolve("config/unrelated.txt"))); + assertTrue(com.cozooo.dlc_manager.client.preset.DlcPresetStateStore.loadPresetStatus().get("preset-main").isEnabled()); + SyncClient.checked(endpoint, SyncClient.request("item_done")); SyncClient.checked(endpoint, SyncClient.request("rollback")); + } + assertFalse(Files.exists(game.resolve("mods/preset.jar"))); + assertEquals("keep", Files.readString(game.resolve("config/unrelated.txt"))); + assertFalse(Files.exists(state)); + assertFalse(com.cozooo.dlc_manager.client.preset.DlcPresetRuntimeState.isPresetEnabled("preset-main")); + } + @Test void presetRecoveryAfterLostCommitAcknowledgmentRestoresFilesAndRuntime() throws Exception { + presetWithMod(); String operation = UUID.randomUUID().toString(); + try (SyncEndpoint endpoint = new SyncEndpoint(new DlcSyncAdapter())) { + JsonObject begin = SyncClient.request("begin"); begin.addProperty("operation", operation); SyncClient.checked(endpoint, begin); + JsonObject item = SyncClient.request("item"); item.addProperty("key", "0:preset/main.dc"); SyncClient.checked(endpoint, item); + JsonObject apply = SyncClient.request("apply"); apply.addProperty("enabled", true); SyncClient.checked(endpoint, apply); + SyncClient.checked(endpoint, SyncClient.request("item_done")); SyncClient.checked(endpoint, SyncClient.request("commit")); + } + new DlcSyncAdapter().recoverOperation(operation); + assertFalse(Files.exists(game.resolve("mods/preset.jar"))); + assertEquals("keep", Files.readString(game.resolve("config/unrelated.txt"))); + assertFalse(com.cozooo.dlc_manager.client.preset.DlcPresetRuntimeState.isPresetEnabled("preset-main")); + assertFalse(com.cozooo.dlc_manager.client.preset.DlcPresetRuntimeState.hasPendingFileConflictChanges()); + } + @Test void enabledPresetMayBePartialOnTheDestinationWithoutFailingSynchronization() throws Exception { + presetWithMod(); Files.writeString(root.resolve("preset/main.dc"), "identifier='preset-main'\nenable=['preset-mod','missing-dlc']\n"); + try (SyncEndpoint endpoint = new SyncEndpoint(new DlcSyncAdapter())) { + beginItem(endpoint, "0:preset/main.dc"); + JsonObject apply = SyncClient.request("apply"); apply.addProperty("enabled", true); + SyncClient.checked(endpoint, apply); + assertEquals(com.cozooo.dlc_manager.client.preset.DlcPresetStatus.PART, com.cozooo.dlc_manager.client.preset.DlcPresetStateStore.loadPresetStatus().get("preset-main")); + assertEquals("preset mod", Files.readString(game.resolve("mods/preset.jar"))); + Item partial = new DlcSyncAdapter().item("0:preset/main.dc"); + assertEquals(true, partial.enabled()); + com.cozooo.dlc_manager.client.preset.DlcPresetStateStore.savePresetStatus(Map.of("preset-main", com.cozooo.dlc_manager.client.preset.DlcPresetStatus.ON)); + Item full = new DlcSyncAdapter().item("0:preset/main.dc"); + assertTrue(SyncModel.plan(List.of(full), List.of(partial), Direction.PUSH, true, Mode.CONFIG).isEmpty()); + SyncClient.checked(endpoint, SyncClient.request("rollback")); + } + assertFalse(com.cozooo.dlc_manager.client.preset.DlcPresetRuntimeState.isPresetEnabled("preset-main")); + assertTrue(com.cozooo.dlc_manager.client.preset.DlcPresetStateStore.loadPresetStatus().isEmpty()); + assertFalse(Files.exists(game.resolve("mods/preset.jar"))); + } + @Test void managerSynchronizationMergesOnlySharedFieldsAndRestoresTheOriginalFile() throws Exception { + Path path = game.resolve("config/dlc_manager/config.dc"); Files.createDirectories(path.getParent()); + String original = "# local comment\n[downloads]\nmaxConcurrentDownloads=7\n[api]\napiToken='secret'\n[server]\nserverPassword='private'\n[ui]\nshowDependencyReminder=true\n"; + Files.writeString(path, original); + DlcSyncAdapter adapter = new DlcSyncAdapter(); + assertThrows(java.io.IOException.class, () -> adapter.resolve("manager:config-server.dc")); + assertFalse(SyncEndpoint.JSON.toJson(adapter.item("manager:config.dc")).contains("secret")); + try (SyncEndpoint endpoint = new SyncEndpoint(adapter)) { + beginItem(endpoint, "manager:config.dc"); + JsonObject values = DlcConfig.buildSyncConfigJson(null); values.getAsJsonObject("downloads").addProperty("maxConcurrentDownloads", 3); + JsonObject settings = SyncClient.request("settings"); settings.add("settings", values); SyncClient.checked(endpoint, settings); + SyncClient.checked(endpoint, SyncClient.request("apply")); SyncClient.checked(endpoint, SyncClient.request("item_done")); + JsonObject after = DlcDcConfigIO.read(path, DlcDcConfigIO.Layout.GENERIC); + assertEquals(3, after.getAsJsonObject("downloads").get("maxConcurrentDownloads").getAsInt()); + assertEquals("secret", after.getAsJsonObject("api").get("apiToken").getAsString()); + assertEquals("private", after.getAsJsonObject("server").get("serverPassword").getAsString()); + assertTrue(after.getAsJsonObject("ui").get("showDependencyReminder").getAsBoolean()); + SyncClient.checked(endpoint, SyncClient.request("rollback")); + assertEquals(original, Files.readString(path)); + } + } + @Test void rejectsUnmanagedPathsAndSymbolicLinks() throws Exception { + DlcSyncAdapter adapter = new DlcSyncAdapter(); + assertThrows(java.io.IOException.class, () -> adapter.validateKey("0:mods/other.jar")); + assertThrows(java.io.IOException.class, () -> adapter.resolve("0:../mods/other.jar")); + assertThrows(java.io.IOException.class, () -> adapter.validateKey("0:remote/../tmp/state.dc")); + Files.createSymbolicLink(root.resolve("required/link.dc"), game.resolve("config/unrelated.txt")); + assertThrows(java.io.IOException.class, () -> adapter.validateKey("0:required/link.dc")); + } +} diff --git a/src/test/java/com/cozooo/dlc_manager/client/sync/DlcSyncSettingsTest.java b/src/test/java/com/cozooo/dlc_manager/client/sync/DlcSyncSettingsTest.java new file mode 100644 index 00000000..5dcb9fa4 --- /dev/null +++ b/src/test/java/com/cozooo/dlc_manager/client/sync/DlcSyncSettingsTest.java @@ -0,0 +1,110 @@ +package com.cozooo.dlc_manager.client.sync; + +import com.cozooo.dlc_manager.core.DlcDcConfigIO; +import com.cozooo.dlc_manager.sync.*; +import com.cozooo.dlc_manager.sync.SyncModel.*; +import com.google.gson.JsonObject; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.IOException; +import java.nio.file.*; +import java.util.*; +import java.util.function.BooleanSupplier; +import static org.junit.jupiter.api.Assertions.*; + +class DlcSyncSettingsTest { + @TempDir Path temp; + private static final String KEY = "manager:config.dc"; + private static final String CLIENT = "# client comment\n[downloads]\nmaxConcurrentDownloads=3\n[ui]\nshowDependencyReminder=true\ntitleAnchor='hidden'\n[api]\napiToken='client-secret'\n[server]\nserverPassword='client-password'\n"; + private static final String SERVER = "# server comment\n[Downloads]\nmaxConcurrentDownloads = 7 # keep download comment\nunknown = 'keep me'\n[ui]\nshowDependencyReminder=false\ntitleAnchor='left'\n[api]\napiToken='server-secret'\n[server]\nserverPassword='server-password'\n[custom]\nuntouched = 'unchanged'\n"; + + @Test void comparesOnlySharedValuesAndIgnoresFormattingDefaultsAndPrivateFields() throws Exception { + Fixture client = new Fixture(temp.resolve("client"), CLIENT), server = new Fixture(temp.resolve("server"), SERVER); + Item source = client.item(KEY), target = server.item(KEY); + assertFalse(source.settings().has("api")); assertFalse(source.settings().has("server")); + assertFalse(source.settings().getAsJsonObject("ui").has("showDependencyReminder")); + assertFalse(source.settings().getAsJsonObject("ui").has("titleAnchor")); + assertTrue(source.displayNames().isEmpty(), "Manager labels belong to the UI language resources"); + assertFalse(SyncEndpoint.JSON.toJson(source).contains("client-secret")); + assertFalse(SyncEndpoint.JSON.toJson(source).contains("client-password")); + assertEquals(1, SyncModel.plan(List.of(source), List.of(target), Direction.PUSH, true, Mode.CONFIG).size()); + Files.writeString(server.config, SERVER.replace("maxConcurrentDownloads = 7", "maxConcurrentDownloads = 3")); + assertEquals(source.settings(), server.item(KEY).settings()); + assertEquals(source.config(), server.item(KEY).config()); + assertTrue(SyncModel.plan(client.manifest(), server.manifest(), Direction.PUSH, true, Mode.CONFIG).isEmpty()); + Fixture absent = new Fixture(temp.resolve("absent"), null); + Fixture defaults = new Fixture(temp.resolve("defaults"), "[paths]\ndlcRoot='DLC'\n[ui]\nshowDependencyReminder=true\n"); + assertEquals(absent.item(KEY).config(), defaults.item(KEY).config()); + assertFalse(Files.exists(absent.config)); + } + @Test void bothModesSynchronizeFieldsWithoutTransferringTheWholeConfiguration() throws Exception { + for (Mode mode : Mode.values()) { + Fixture client = new Fixture(temp.resolve("client-" + mode), CLIENT), server = new Fixture(temp.resolve("server-" + mode), SERVER); + List actions = new ArrayList<>(); + try (SyncEndpoint left = new SyncEndpoint(client); SyncEndpoint right = new SyncEndpoint(server)) { + Peer tracked = request -> { actions.add(request.get("action").getAsString()); return right.call(request); }; + var rows = SyncModel.plan(SyncClient.manifest(left), SyncClient.manifest(right), Direction.PUSH, true, mode); + Result result = new SyncClient(left, tracked, right).execute(rows, mode, true, (phase, name, done, total) -> {}); + assertEquals(1, result.completed()); assertTrue(result.errors().isEmpty()); assertTrue(result.requiresRestart()); + assertTrue(actions.contains("settings")); assertFalse(actions.contains("write")); assertFalse(actions.contains("read")); + } + JsonObject updated = DlcDcConfigIO.read(server.config, DlcDcConfigIO.Layout.GENERIC); + assertEquals(3, DlcDcConfigIO.getObjectIgnoreCase(updated, "downloads").get("maxConcurrentDownloads").getAsInt()); + assertEquals("server-secret", updated.getAsJsonObject("api").get("apiToken").getAsString()); + assertEquals("server-password", updated.getAsJsonObject("server").get("serverPassword").getAsString()); + assertFalse(updated.getAsJsonObject("ui").get("showDependencyReminder").getAsBoolean()); + assertEquals("left", updated.getAsJsonObject("ui").get("titleAnchor").getAsString()); + String text = Files.readString(server.config); + assertTrue(text.contains("# server comment")); assertTrue(text.contains("# keep download comment")); + assertTrue(text.contains("unknown = 'keep me'")); assertTrue(text.contains("untouched = 'unchanged'")); + assertEquals(CLIENT, Files.readString(client.config)); + } + } + @Test void existingRepositoryAliasIsReadAndUpdatedAlongWithTheCanonicalField() throws Exception { + Fixture source = new Fixture(temp.resolve("source"), "[modpack]\nupdateGitRepo='new/repository'\n"); + Fixture target = new Fixture(temp.resolve("target"), "[modpack]\nupdateGithubRepo='old/repository'\n"); + assertEquals("old/repository", target.item(KEY).settings().getAsJsonObject("modpack").get("updateGitRepo").getAsString()); + try (SyncEndpoint left = new SyncEndpoint(source); SyncEndpoint right = new SyncEndpoint(target)) { + var rows = SyncModel.plan(SyncClient.manifest(left), SyncClient.manifest(right), Direction.PUSH, false, Mode.CONFIG); + assertTrue(new SyncClient(left, right, right).execute(rows, Mode.CONFIG, false, (phase, name, done, total) -> {}).errors().isEmpty()); + } + assertEquals("new/repository", DlcDcConfigIO.read(target.config, DlcDcConfigIO.Layout.GENERIC).getAsJsonObject("modpack").get("updateGithubRepo").getAsString()); + } + @Test void fieldUpdatesAreBackedUpAndRestoredAndRawManagerFileAccessIsRejected() throws Exception { + Fixture target = new Fixture(temp.resolve("target"), SERVER), source = new Fixture(temp.resolve("source"), CLIENT); + try (SyncEndpoint endpoint = new SyncEndpoint(target)) { + JsonObject begin = SyncClient.request("begin"); begin.addProperty("operation", UUID.randomUUID().toString()); SyncClient.checked(endpoint, begin); + JsonObject selected = SyncClient.request("item"); selected.addProperty("key", KEY); SyncClient.checked(endpoint, selected); + JsonObject read = SyncClient.request("read"); read.addProperty("id", KEY); + assertThrows(IOException.class, () -> SyncClient.checked(endpoint, read)); + JsonObject fields = SyncClient.request("settings"); fields.add("settings", source.item(KEY).settings()); SyncClient.checked(endpoint, fields); + SyncClient.checked(endpoint, SyncClient.request("apply")); SyncClient.checked(endpoint, SyncClient.request("item_done")); + assertNotEquals(SERVER, Files.readString(target.config)); + SyncClient.checked(endpoint, SyncClient.request("rollback")); + assertEquals(SERVER, Files.readString(target.config)); + } + } + private static final class Fixture implements Adapter { + final Path root, config; + Fixture(Path root, String text) throws IOException { this.root = root; config = root.resolve("config.dc"); Files.createDirectories(root); if (text != null) Files.writeString(config, text); } + public Path workDirectory() { return root.resolve("work"); } + public List manifest() throws IOException { return List.of(DlcSyncSettings.item(KEY, config)); } + public Path resolve(String key) throws IOException { validateKey(key); return config; } + public void validateKey(String key) throws IOException { if (!KEY.equals(key)) throw new IOException("Not a manager configuration"); } + public void writeSettings(String key, JsonObject settings, Journal journal) throws IOException { validateKey(key); DlcSyncSettings.write(config, settings, journal); } + public Journal journal(Path directory) { + return new Journal() { + final Map originals = new LinkedHashMap<>(); + public void capture(Path path) throws IOException { if (!originals.containsKey(path)) originals.put(path, Files.exists(path) ? Files.readAllBytes(path) : null); } + public void rollback() throws IOException { for (var entry : originals.entrySet()) { if (entry.getValue() == null) Files.deleteIfExists(entry.getKey()); else Files.write(entry.getKey(), entry.getValue()); } } + public void commit() {} + public void close() {} + }; + } + public void prepare(String key, boolean force, BooleanSupplier canceled, Progress progress) {} + public boolean apply(String key, Boolean enabled, Journal journal, BooleanSupplier canceled) { return true; } + public void reload() {} + public void recover() {} + public void recoverOperation(String operation) {} + } +} diff --git a/src/test/java/com/cozooo/dlc_manager/client/sync/DlcSyncTransportTest.java b/src/test/java/com/cozooo/dlc_manager/client/sync/DlcSyncTransportTest.java new file mode 100644 index 00000000..921d5ecf --- /dev/null +++ b/src/test/java/com/cozooo/dlc_manager/client/sync/DlcSyncTransportTest.java @@ -0,0 +1,111 @@ +package com.cozooo.dlc_manager.client.sync; + +import com.cozooo.dlc_manager.client.server.*; +import com.cozooo.dlc_manager.core.DlcConfig; +import com.cozooo.dlc_manager.platform.PlatformServices; +import com.cozooo.dlc_manager.test.TestPlatformProvider; +import com.cozooo.dlc_manager.server.DlcServerService; +import com.cozooo.dlc_manager.sync.*; +import java.io.IOException; +import java.net.*; +import java.nio.file.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.*; + +class DlcSyncTransportTest { + @TempDir Path game; + String password; + @BeforeEach void setup() throws Exception { + Files.createDirectories(game.resolve("DLC")); + PlatformServices.setProvider(new TestPlatformProvider(game, game.resolve("config"))); + DlcConfig.setDlcRoot("DLC"); password = DlcConfig.getServerPassword(); DlcConfig.setServerPassword("sync-test"); + } + @AfterEach void reset() { DlcConfig.setServerPassword(password); PlatformServices.setProvider(null); DlcConfig.setDlcRoot("DLC"); } + @Test void keepsMinecraftPortPrefaceAndUsesAuthenticatedSyncHandler() throws Exception { + ExecutorService executor = Executors.newSingleThreadExecutor(); + try (ServerSocket listener = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))) { + Future accepted = executor.submit(() -> { + try (Socket socket = listener.accept()) { + assertArrayEquals(new byte[]{'D','L','C','M',1}, socket.getInputStream().readNBytes(5)); + DlcServerService.handleClient(socket); + } catch (IOException error) { throw new java.io.UncheckedIOException(error); } + }); + DlcServerEntry entry = new DlcServerEntry(); entry.host = "127.0.0.1"; entry.port = listener.getLocalPort(); entry.password = "sync-test"; + try (DlcServerSession session = DlcServerSession.connect(entry, 5000)) { + var items = SyncClient.manifest(request -> session.syncRequest(request)); + assertEquals(java.util.List.of("manager:config.dc"), items.stream().map(SyncModel.Item::key).toList()); + assertFalse(SyncEndpoint.JSON.toJson(items).contains("sync-test")); + } + accepted.get(5, TimeUnit.SECONDS); + } finally { executor.shutdownNow(); } + } + @Test void authenticatedUploadRejectsCorruptionAndDisconnectDiscardsOnlyIncompleteBytes() throws Exception { + Path root = game.resolve("DLC"); Files.createDirectories(root.resolve("required")); Files.createDirectories(root.resolve("downloaded/required")); + Files.writeString(root.resolve("required/main.dc"), "identifier='main'\nfile_name='main.txt'\n"); + Path resource = Files.writeString(root.resolve("downloaded/required/main.txt"), "old resource"); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try (ServerSocket listener = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))) { + Future accepted = executor.submit(() -> { + try (Socket socket = listener.accept()) { socket.getInputStream().readNBytes(5); DlcServerService.handleClient(socket); } + catch (IOException error) { throw new java.io.UncheckedIOException(error); } + }); + DlcServerEntry entry = new DlcServerEntry(); entry.host = "127.0.0.1"; entry.port = listener.getLocalPort(); entry.password = "sync-test"; + try (DlcServerSession session = DlcServerSession.connect(entry, 5000)) { + SyncModel.Peer peer = session::syncRequest; + var begin = SyncClient.request("begin"); begin.addProperty("operation", java.util.UUID.randomUUID().toString()); SyncClient.checked(peer, begin); + var item = SyncClient.request("item"); item.addProperty("key", "0:required/main.dc"); SyncClient.checked(peer, item); + var write = SyncClient.request("write"); write.add("file", SyncEndpoint.JSON.toJsonTree(new SyncModel.File("0:downloaded/required/main.txt", 3, "wrong"))); write.addProperty("hash", "wrong"); SyncClient.checked(peer, write); + var chunk = SyncClient.request("write_chunk"); chunk.addProperty("data", "YmFk"); SyncClient.checked(peer, chunk); + assertThrows(IOException.class, () -> SyncClient.checked(peer, SyncClient.request("write_end"))); + assertEquals("old resource", Files.readString(resource)); + } + accepted.get(5, TimeUnit.SECONDS); assertEquals("old resource", Files.readString(resource)); + try (var files = Files.list(root.resolve("tmp/sync"))) { assertFalse(files.anyMatch(path -> path.getFileName().toString().startsWith("incoming-"))); } + } finally { executor.shutdownNow(); } + } + @Test void disconnectKeepsUploadedAndAppliedFilesUntilAnAuthenticatedRollback() throws Exception { + Path root = game.resolve("DLC"); Files.createDirectories(root.resolve("required")); Files.createDirectories(root.resolve("downloaded/required")); + Files.writeString(root.resolve("required/main.dc"), "identifier='main'\nfile_name='main.txt'\nappliedTarget='config/main.txt'\n"); + Path resource = Files.writeString(root.resolve("downloaded/required/main.txt"), "old"); + Files.createDirectories(game.resolve("config")); Path applied = Files.writeString(game.resolve("config/main.txt"), "original"); + String operation = java.util.UUID.randomUUID().toString(); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try (ServerSocket listener = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))) { + Future accepted = executor.submit(() -> { + for (int i = 0; i < 2; i++) try (Socket socket = listener.accept()) { socket.getInputStream().readNBytes(5); DlcServerService.handleClient(socket); } + catch (IOException error) { throw new java.io.UncheckedIOException(error); } + }); + DlcServerEntry entry = new DlcServerEntry(); entry.host = "127.0.0.1"; entry.port = listener.getLocalPort(); entry.password = "sync-test"; + try (DlcServerSession session = DlcServerSession.connect(entry, 5000)) { + SyncModel.Peer peer = session::syncRequest; + var begin = SyncClient.request("begin"); begin.addProperty("operation", operation); SyncClient.checked(peer, begin); + var item = SyncClient.request("item"); item.addProperty("key", "0:required/main.dc"); SyncClient.checked(peer, item); + Path payload = Files.writeString(game.resolve("payload.txt"), "new"); + String hash = com.cozooo.dlc_manager.core.DlcFileMatch.hash(payload); + var write = SyncClient.request("write"); write.add("file", SyncEndpoint.JSON.toJsonTree(new SyncModel.File("0:downloaded/required/main.txt", 3, hash))); write.addProperty("hash", hash); SyncClient.checked(peer, write); + var chunk = SyncClient.request("write_chunk"); chunk.addProperty("data", "bmV3"); SyncClient.checked(peer, chunk); + SyncClient.checked(peer, SyncClient.request("write_end")); SyncClient.checked(peer, SyncClient.request("apply")); + assertEquals("new", Files.readString(resource)); assertEquals("new", Files.readString(applied)); + } + try (DlcServerSession session = DlcServerSession.connect(entry, 5000)) { + SyncModel.Peer peer = session::syncRequest; + SyncClient.manifest(peer); + assertEquals("new", Files.readString(resource)); assertEquals("new", Files.readString(applied)); + var restore = SyncClient.request("recover_operation"); restore.addProperty("operation", operation); SyncClient.checked(peer, restore); + } + accepted.get(5, TimeUnit.SECONDS); + assertEquals("old", Files.readString(resource)); assertEquals("original", Files.readString(applied)); + } finally { executor.shutdownNow(); } + } + @Test void cancellationCanCloseSocketBeforeConnecting() { + DlcServerEntry entry = new DlcServerEntry(); entry.host = "127.0.0.1"; entry.port = 1; + AtomicReference opened = new AtomicReference<>(); + assertThrows(IOException.class, () -> DlcServerSession.connect(entry, 5000, connection -> { + opened.set(connection); try { connection.close(); } catch (Exception error) { throw new IllegalStateException(error); } + })); + assertTrue(((Socket)opened.get()).isClosed()); + } +} diff --git a/src/test/java/com/cozooo/dlc_manager/core/DlcFileMatchTest.java b/src/test/java/com/cozooo/dlc_manager/core/DlcFileMatchTest.java new file mode 100644 index 00000000..245b9356 --- /dev/null +++ b/src/test/java/com/cozooo/dlc_manager/core/DlcFileMatchTest.java @@ -0,0 +1,113 @@ +package com.cozooo.dlc_manager.core; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.security.MessageDigest; +import java.util.HexFormat; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.*; + +class DlcFileMatchTest { + @TempDir Path directory; + private String sha256(byte[] bytes) throws Exception { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } + + @Test void preservesFullSha256ValuesForMatchingAndStoredHashes() throws Exception { + for (int size : new int[]{0, 17, 131072, 131073, 300000}) { + byte[] bytes = new byte[size]; + for (int index = 0; index < size; index++) bytes[index] = (byte)index; + Path file = Files.write(directory.resolve(size + ".jar"), bytes); + assertEquals(sha256(bytes), DlcFileMatch.hash(file)); + assertEquals(sha256(bytes), DlcFileMatch.hash(file)); + assertEquals(sha256(bytes), DlcFileMatch.fullHash(file)); + assertEquals(sha256(bytes), DlcFileMatch.hash(new ByteArrayInputStream(bytes))); + } + } + + @Test void lightweightFingerprintUsesSizeAndFirstAndLast64KiBOnlyForLargeFiles() throws Exception { + byte[] small = new byte[131072]; + Path file = Files.write(directory.resolve("main.jar"), small); + assertEquals(sha256(small), DlcFileMatch.lightHash(file)); + byte[] large = new byte[200000]; large[0] = 1; large[large.length - 1] = 2; + Files.write(file, large); + MessageDigest expected = MessageDigest.getInstance("SHA-256"); + expected.update(ByteBuffer.allocate(8).putLong(large.length).array()); + expected.update(large, 0, 65536); expected.update(large, large.length - 65536, 65536); + assertEquals("light-" + HexFormat.of().formatHex(expected.digest()), DlcFileMatch.lightHash(file)); + } + + @Test void matchingRehashesMiddleChangesWhenModificationTimeChanges() throws Exception { + byte[] bytes = new byte[300000]; + Path source = Files.write(directory.resolve("main.jar"), bytes); + Path installed = Files.createDirectories(directory.resolve("installed")); + Path target = Files.write(installed.resolve("renamed.jar"), bytes); + assertTrue(DlcFileMatch.matches(source, target, "hash")); + String light = DlcFileMatch.lightHash(target); + FileTime before = Files.getLastModifiedTime(target); + bytes[150000] = 1; Files.write(target, bytes); Files.setLastModifiedTime(target, FileTime.fromMillis(before.toMillis() + 2000)); + assertEquals(light, DlcFileMatch.lightHash(target)); + assertFalse(DlcFileMatch.matches(source, target, "hash")); + assertEquals(sha256(bytes), DlcFileMatch.hash(target)); + assertNull(DlcFileMatch.findByHash(DlcFileMatch.hash(source), installed)); + assertNull(DlcFileMatch.find(source, installed, "hash")); + } + + @Test void fingerprintInvalidatesCacheWhenSizeAndTimeAreUnchanged() throws Exception { + byte[] bytes = new byte[300000]; + Path file = Files.write(directory.resolve("main.jar"), bytes); + String before = DlcFileMatch.hash(file); + FileTime modified = Files.getLastModifiedTime(file); + bytes[0] = 1; Files.write(file, bytes); Files.setLastModifiedTime(file, modified); + assertNotEquals(before, DlcFileMatch.hash(file)); + assertEquals(sha256(bytes), DlcFileMatch.hash(file)); + } + + @Test void finalIntegrityCheckAlwaysReadsFullContentEvenWhenFingerprintAndTimeMatch() throws Exception { + byte[] bytes = new byte[300000]; + Path file = Files.write(directory.resolve("main.jar"), bytes); + String before = DlcFileMatch.hash(file), light = DlcFileMatch.lightHash(file); + FileTime modified = Files.getLastModifiedTime(file); + bytes[150000] = 1; Files.write(file, bytes); Files.setLastModifiedTime(file, modified); + assertEquals(light, DlcFileMatch.lightHash(file)); + assertNotEquals(before, DlcFileMatch.fullHash(file)); + assertEquals(sha256(bytes), DlcFileMatch.fullHash(file)); + } + + @Test void scanAndDirectoryCachesObserveNestedFileAndStructureChanges() throws Exception { + Path root = Files.createDirectories(directory.resolve("tree/nested")); + Path child = Files.writeString(root.resolve("main.txt"), "before"); + Path tree = root.getParent(); + FileTime modified = Files.getLastModifiedTime(tree); + DlcFileMatch.beginScan(); + try { + String before = DlcFileMatch.hash(tree); + Files.writeString(child, "after!"); + assertEquals(modified, Files.getLastModifiedTime(tree)); + assertNotEquals(before, DlcFileMatch.hash(tree)); + String changed = DlcFileMatch.hash(tree); + Files.createDirectory(root.resolve("empty")); + assertNotEquals(changed, DlcFileMatch.hash(tree)); + assertEquals(DlcFileMatch.fullHash(tree), DlcFileMatch.hash(tree)); + } finally { DlcFileMatch.endScan(); } + } + + @Test void cachedEntriesCannotHideSymbolicLinksOrInterruptions() throws Exception { + Path file = Files.writeString(directory.resolve("main.txt"), "resource"); + DlcFileMatch.hash(file); + Thread.currentThread().interrupt(); + try { + assertThrows(IOException.class, () -> DlcFileMatch.hash(file)); + assertThrows(IOException.class, () -> DlcFileMatch.fullHash(file)); + } finally { Thread.interrupted(); } + Path other = Files.writeString(directory.resolve("other.txt"), "resource"); + Files.delete(file); Files.createSymbolicLink(file, other); + assertThrows(IOException.class, () -> DlcFileMatch.hash(file)); + assertThrows(IOException.class, () -> DlcFileMatch.fullHash(file)); + } +} diff --git a/src/test/java/com/cozooo/dlc_manager/core/DlcFileSizeTest.java b/src/test/java/com/cozooo/dlc_manager/core/DlcFileSizeTest.java new file mode 100644 index 00000000..6e822a00 --- /dev/null +++ b/src/test/java/com/cozooo/dlc_manager/core/DlcFileSizeTest.java @@ -0,0 +1,41 @@ +package com.cozooo.dlc_manager.core; + +import java.util.Locale; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class DlcFileSizeTest { + @Test void usesBinaryUnitsAtExactBoundariesAndPreservesUnknownSizes() { + assertEquals(1024L, DlcFileSize.KIB); + assertEquals(1048576L, DlcFileSize.MIB); + assertEquals(1073741824L, DlcFileSize.GIB); + assertEquals("--", DlcFileSize.format(-1)); + assertEquals("0 B", DlcFileSize.format(0)); + assertEquals("1.5 KiB", DlcFileSize.formatProgress(1536, -1)); + assertEquals("1.5 KiB/2.0 KiB", DlcFileSize.formatProgress(1536, 2048)); + assertEquals("1023 B", DlcFileSize.format(1023)); + assertEquals("1.0 KiB", DlcFileSize.format(1024)); + assertEquals("1.5 MiB", DlcFileSize.format(1572864)); + assertEquals("1.0 GiB", DlcFileSize.format(DlcFileSize.GIB)); + assertEquals("1.0 TiB", DlcFileSize.format(DlcFileSize.GIB * 1024)); + } + + @Test void formattingDoesNotDependOnSystemLocale() { + Locale previous = Locale.getDefault(); + try { + for (Locale locale : new Locale[]{Locale.CHINA, Locale.US, Locale.GERMANY, Locale.FRANCE}) { + Locale.setDefault(locale); + assertEquals("1.5 KiB", DlcFileSize.format(1536)); + } + } finally { Locale.setDefault(previous); } + } + + @Test void configurationRoundingUsesBytesWithoutOverflow() { + assertEquals(0, DlcFileSize.ceilMiB(-1)); + assertEquals(0, DlcFileSize.ceilMiB(0)); + assertEquals(1, DlcFileSize.ceilMiB(1)); + assertEquals(1, DlcFileSize.ceilMiB(DlcFileSize.MIB)); + assertEquals(2, DlcFileSize.ceilMiB(DlcFileSize.MIB + 1)); + assertEquals(8796093022208L, DlcFileSize.ceilMiB(Long.MAX_VALUE)); + } +} diff --git a/src/test/java/com/cozooo/dlc_manager/server/DlcMixinRegistrationTest.java b/src/test/java/com/cozooo/dlc_manager/server/DlcMixinRegistrationTest.java new file mode 100644 index 00000000..5f268717 --- /dev/null +++ b/src/test/java/com/cozooo/dlc_manager/server/DlcMixinRegistrationTest.java @@ -0,0 +1,105 @@ +package com.cozooo.dlc_manager.server; + +import com.google.gson.JsonParser; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.objectweb.asm.*; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.jar.JarFile; +import static org.junit.jupiter.api.Assertions.*; + +class DlcMixinRegistrationTest { + private static final String CONFIG = "dlc_manager.mixins.json"; + @TempDir Path temporary; + private JarFile artifact() throws Exception { + JarFile jar = new JarFile(System.getProperty("dlc_manager.test.jar")); + var payload = jar.getJarEntry("META-INF/dlc-manager/mod.jar"); + if (payload == null) return jar; + Path extracted = Files.createTempFile(temporary, "mod-", ".jar"); + try (jar; InputStream input = jar.getInputStream(payload)) { Files.copy(input, extracted, java.nio.file.StandardCopyOption.REPLACE_EXISTING); } + return new JarFile(extracted.toFile()); + } + private String read(JarFile jar, String path) throws Exception { + var entry = jar.getJarEntry(path); + assertNotNull(entry, "Missing archive entry: " + path); + try (InputStream input = jar.getInputStream(entry)) { return new String(input.readAllBytes(), StandardCharsets.UTF_8); } + } + + @Test void packagedMixinIsRegisteredUsingTheLoadersSupportedMetadata() throws Exception { + try (JarFile jar = artifact()) { + String loader = System.getProperty("dlc_manager.test.loader"); + if ("forge".equals(loader)) { + String registered = jar.getManifest().getMainAttributes().getValue("MixinConfigs"); + assertNotNull(registered, "Forge requires MixinConfigs in the jar manifest"); + assertTrue(Arrays.stream(registered.split(",")).map(String::trim).anyMatch(CONFIG::equals)); + } else if ("neoforge".equals(loader)) { + String toml = read(jar, "META-INF/neoforge.mods.toml"); + assertTrue(toml.contains("[[mixins]]\nconfig=\"" + CONFIG + "\"")); + } else { + var json = JsonParser.parseString(read(jar, "fabric.mod.json")).getAsJsonObject(); + boolean registered = false; + for (var value : json.getAsJsonArray("mixins")) registered |= CONFIG.equals(value.getAsString()); + assertTrue(registered); + } + var config = JsonParser.parseString(read(jar, CONFIG)).getAsJsonObject(); + for (var name : config.getAsJsonArray("mixins")) { + String path = (config.get("package").getAsString() + "." + name.getAsString()).replace('.', '/') + ".class"; + assertNotNull(jar.getJarEntry(path), "Registered Mixin class must be packaged"); + } + } + } + + @Test void forgeDevelopmentLaunchAlsoRegistersTheMixin() throws Exception { + if (!"forge".equals(System.getProperty("dlc_manager.test.loader"))) return; + List arguments = Files.readAllLines(Path.of(System.getProperty("dlc_manager.test.launchConfig"))).stream().map(String::trim).toList(); + int argument = arguments.indexOf("--mixin.config"); + assertTrue(argument >= 0 && argument + 1 < arguments.size(), "Forge development launch is missing --mixin.config"); + assertEquals(CONFIG, arguments.get(argument + 1)); + } + + @Test void injectionSelectsAnInitializerMethodThatExistsInThisMinecraftVersion() throws Exception { + Set targets = new LinkedHashSet<>(), selectors = new LinkedHashSet<>(); + try (JarFile jar = artifact(); InputStream input = jar.getInputStream(jar.getJarEntry("com/cozooo/dlc_manager/mixin/DlcServerConnectionListenerMixin.class"))) { + new ClassReader(input).accept(new ClassVisitor(Opcodes.ASM9) { + @Override public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) { + return "Lorg/spongepowered/asm/mixin/Mixin;".equals(descriptor) ? strings("targets", targets) : null; + } + @Override public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) { + return new MethodVisitor(Opcodes.ASM9) { + @Override public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) { + return "Lorg/spongepowered/asm/mixin/injection/Inject;".equals(descriptor) ? strings("method", selectors) : null; + } + }; + } + }, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); + } + assertFalse(targets.isEmpty()); assertFalse(selectors.isEmpty()); + for (String target : targets) { + Set methods = new HashSet<>(); + try (InputStream input = getClass().getClassLoader().getResourceAsStream(target.replace('.', '/') + ".class")) { + assertNotNull(input, "Mixin target does not exist: " + target); + new ClassReader(input).accept(new ClassVisitor(Opcodes.ASM9) { + @Override public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) { + if (descriptor.equals("(Lio/netty/channel/Channel;)V")) { methods.add(name); methods.add(name + descriptor); } + return null; + } + }, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); + } + for (String selector : selectors) assertTrue(methods.contains(selector), "Mixin selector does not match the Netty initializer: " + target + "." + selector); + } + } + + private AnnotationVisitor strings(String field, Set values) { + return new AnnotationVisitor(Opcodes.ASM9) { + @Override public AnnotationVisitor visitArray(String name) { + return field.equals(name) ? new AnnotationVisitor(Opcodes.ASM9) { + @Override public void visit(String name, Object value) { values.add((String)value); } + } : null; + } + }; + } +} diff --git a/src/test/java/com/cozooo/dlc_manager/sync/DlcSyncArchiveTest.java b/src/test/java/com/cozooo/dlc_manager/sync/DlcSyncArchiveTest.java new file mode 100644 index 00000000..3e512eb6 --- /dev/null +++ b/src/test/java/com/cozooo/dlc_manager/sync/DlcSyncArchiveTest.java @@ -0,0 +1,151 @@ +package com.cozooo.dlc_manager.sync; + +import com.cozooo.dlc_manager.core.DlcFileMatch; +import com.cozooo.dlc_manager.core.DlcResourceHistory; +import java.io.IOException; +import java.nio.file.*; +import java.util.*; +import java.util.zip.ZipFile; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.*; + +class DlcSyncArchiveTest { + @TempDir Path temp; + private Path source, target, archive; + private List reused; + + private void prepare() throws Exception { + source = Files.createDirectories(temp.resolve("source")); target = Files.createDirectories(temp.resolve("target")); + Files.writeString(source.resolve("same.txt"), "unchanged".repeat(1000)); + Files.copy(source.resolve("same.txt"), target.resolve("same.txt")); + Files.writeString(source.resolve("changed.txt"), "new content"); Files.writeString(target.resolve("changed.txt"), "old content"); + Files.writeString(target.resolve("obsolete.txt"), "old extra content"); Files.createDirectory(source.resolve("empty")); + reused = SyncArchive.reusable(source, SyncArchive.inventory(target, () -> false), () -> false); + archive = temp.resolve("delta.zip"); SyncArchive.pack(source, archive, () -> false, reused); + } + private SyncModel.Journal journal(boolean failBackup) { + return new SyncModel.Journal() { + public void capture(Path path) throws IOException { + assertEquals(target, path); + assertEquals("old content", Files.readString(path.resolve("changed.txt"))); + if (failBackup) throw new IOException("Backup failed"); + copy(path, temp.resolve("backup")); + } + public void rollback() throws IOException { DlcResourceHistory.delete(target); copy(temp.resolve("backup"), target); } + public void commit() {} + public void close() {} + }; + } + private void copy(Path source, Path destination) throws IOException { + try (var paths = Files.walk(source)) { + for (Path path : paths.toList()) { + Path to = destination.resolve(source.relativize(path)); + if (Files.isDirectory(path)) Files.createDirectories(to); else { Files.createDirectories(to.getParent()); Files.copy(path, to); } + } + } + } + @Test void transmitsOnlyChangedDirectoryFilesAndBacksUpBeforeReplacement() throws Exception { + prepare(); assertEquals(List.of("same.txt"), reused.stream().map(SyncModel.File::id).toList()); + try (ZipFile zip = new ZipFile(archive.toFile())) { assertNull(zip.getEntry("same.txt")); assertNotNull(zip.getEntry("changed.txt")); } + SyncModel.Journal journal = journal(false); + SyncArchive.install(archive, target, SyncArchive.size(source), DlcFileMatch.fullHash(source), journal, () -> false, reused); + assertEquals(DlcFileMatch.fullHash(source), DlcFileMatch.fullHash(target)); + assertEquals("old content", Files.readString(temp.resolve("backup/changed.txt"))); + assertFalse(Files.exists(target.resolve("obsolete.txt"))); assertTrue(Files.isDirectory(target.resolve("empty"))); + journal.rollback(); assertEquals("old content", Files.readString(target.resolve("changed.txt"))); + assertTrue(Files.exists(target.resolve("obsolete.txt"))); + } + @Test void directoryDeltaTravelsThroughSyncEndpointsAndRetainsItsBackupAfterCommit() throws Exception { + prepare(); + DirectoryAdapter left = new DirectoryAdapter(temp.resolve("left"), source), right = new DirectoryAdapter(temp.resolve("right"), target); + List reusedNames = new ArrayList<>(); + try (SyncEndpoint sourceEndpoint = new SyncEndpoint(left); SyncEndpoint targetEndpoint = new SyncEndpoint(right)) { + SyncModel.Peer tracked = request -> { + var response = sourceEndpoint.call(request); + if (request.get("action").getAsString().equals("read") && response.has("reused")) { + for (var file : response.getAsJsonArray("reused")) reusedNames.add(file.getAsJsonObject().get("id").getAsString()); + } + return response; + }; + var rows = SyncModel.plan(SyncClient.manifest(sourceEndpoint), SyncClient.manifest(targetEndpoint), SyncModel.Direction.PUSH, false, SyncModel.Mode.FILES); + var result = new SyncClient(tracked, targetEndpoint, targetEndpoint).execute(rows, SyncModel.Mode.FILES, false, (phase, resource, done, total) -> {}); + assertTrue(result.errors().isEmpty(), result.errors().toString()); assertEquals(1, result.completed()); + assertEquals(List.of("same.txt"), reusedNames); + assertEquals(DlcFileMatch.fullHash(left.root.resolve("data/a")), DlcFileMatch.fullHash(right.root.resolve("data/a"))); + assertEquals("old content", Files.readString(right.snapshots.get(right.root.resolve("data/a")).resolve("changed.txt"))); + } + } + private final class DirectoryAdapter implements SyncModel.Adapter { + private final Path root; + private final Map snapshots = new LinkedHashMap<>(); + private DirectoryAdapter(Path root, Path data) throws IOException { + this.root = root; Files.createDirectories(root.resolve("remote")); Files.writeString(root.resolve("remote/a.dc"), "same config"); copy(data, root.resolve("data/a")); + } + public Path workDirectory() { return root.resolve("work"); } + public List manifest() throws IOException { + Path config = root.resolve("remote/a.dc"), data = root.resolve("data/a"); + return List.of(new SyncModel.Item("remote/a.dc", "a", new SyncModel.File("remote/a.dc", Files.size(config), DlcFileMatch.hash(config)), + List.of(new SyncModel.File("data/a", SyncArchive.size(data), DlcFileMatch.hash(data))), false)); + } + public Path resolve(String id) { return root.resolve(id); } + public void validateKey(String key) { if (!key.equals("remote/a.dc")) throw new IllegalArgumentException(); } + public SyncModel.Journal journal(Path directory) { + return new SyncModel.Journal() { + private final Map saved = new LinkedHashMap<>(); + public void capture(Path path) throws IOException { + if (saved.containsKey(path)) return; + Path backup = directory.resolve("backups/" + saved.size()); + if (Files.isDirectory(path)) copy(path, backup); else { Files.createDirectories(backup.getParent()); Files.copy(path, backup); } + saved.put(path, backup); snapshots.put(path, backup); + } + public void rollback() throws IOException { + for (var entry : saved.entrySet()) { + DlcResourceHistory.delete(entry.getKey()); + if (Files.isDirectory(entry.getValue())) copy(entry.getValue(), entry.getKey()); else Files.copy(entry.getValue(), entry.getKey()); + } + } + public void commit() {} + public void close() {} + }; + } + public void prepare(String key, boolean force, java.util.function.BooleanSupplier canceled, SyncModel.Progress progress) {} + public boolean apply(String key, Boolean enabled, SyncModel.Journal journal, java.util.function.BooleanSupplier canceled) { return false; } + public void reload() {} + public void recover() {} + public void recoverOperation(String operation) {} + } + @Test void rollbackAlsoRemovesParentsCreatedForANewResourceDirectory() throws Exception { + prepare(); target = temp.resolve("new/nested/resource"); SyncArchive.pack(source, archive, () -> false); + com.cozooo.dlc_manager.platform.PlatformServices.setProvider(new com.cozooo.dlc_manager.test.TestPlatformProvider(temp, temp.resolve("config"))); + com.cozooo.dlc_manager.core.DlcConfig.setDlcRoot("DLC"); + Path directory = temp.resolve("DLC/tmp/sync/" + UUID.randomUUID() + "/item/journal"); + try (var backup = com.cozooo.dlc_manager.client.update.DlcUpdateJournal.openSync(temp, directory)) { + SyncModel.Journal journal = new SyncModel.Journal() { + public void capture(Path path) throws IOException { backup.capture(path); } + public void rollback() throws IOException { backup.rollback(); } + public void commit() {} + public void close() {} + }; + SyncArchive.install(archive, target, SyncArchive.size(source), DlcFileMatch.fullHash(source), journal, () -> false); + assertTrue(Files.isDirectory(target)); journal.rollback(); + assertFalse(Files.exists(temp.resolve("new")), "Rollback must remove all newly created parent directories"); + } finally { + com.cozooo.dlc_manager.platform.PlatformServices.setProvider(null); + com.cozooo.dlc_manager.core.DlcConfig.setDlcRoot("DLC"); + } + } + @Test void backupFailureLeavesTheOriginalDirectoryUntouched() throws Exception { + prepare(); String original = DlcFileMatch.fullHash(target); + assertThrows(IOException.class, () -> SyncArchive.install(archive, target, SyncArchive.size(source), DlcFileMatch.fullHash(source), journal(true), () -> false, reused)); + assertEquals(original, DlcFileMatch.fullHash(target)); + } + @Test void changedReusableFileOrEscapingPathCannotReplaceTheOriginalDirectory() throws Exception { + prepare(); Files.writeString(target.resolve("same.txt"), "changed after comparison"); + String original = DlcFileMatch.fullHash(target); + assertThrows(IOException.class, () -> SyncArchive.install(archive, target, SyncArchive.size(source), DlcFileMatch.fullHash(source), journal(false), () -> false, reused)); + assertEquals(original, DlcFileMatch.fullHash(target)); + assertThrows(IOException.class, () -> SyncArchive.install(archive, target, 0, "invalid", journal(false), () -> false, List.of(new SyncModel.File("../outside.txt", 0, "invalid")))); + assertEquals(original, DlcFileMatch.fullHash(target)); + } +} diff --git a/src/test/java/com/cozooo/dlc_manager/sync/DlcSyncTest.java b/src/test/java/com/cozooo/dlc_manager/sync/DlcSyncTest.java new file mode 100644 index 00000000..2cfd0c09 --- /dev/null +++ b/src/test/java/com/cozooo/dlc_manager/sync/DlcSyncTest.java @@ -0,0 +1,489 @@ +package com.cozooo.dlc_manager.sync; + +import com.cozooo.dlc_manager.core.DlcFileMatch; +import com.cozooo.dlc_manager.sync.SyncModel.*; +import com.google.gson.JsonObject; +import java.io.IOException; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BooleanSupplier; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.*; + +class DlcSyncTest { + @TempDir Path temp; + private final Progress quiet = (phase, file, done, total) -> {}; + private File file(String id, String hash) { return new File(id, 3, hash); } + private Item item(String key, String hash, boolean state) { return new Item(key, key, file(key, hash), List.of(file("data/" + key, "data")), state); } + + @Test void resolvesLocalizedNamesAndShowsOneStatusForEachDifference() { + Item named = new Item("remote/a.dc", "internal-id", file("remote/a.dc", "same"), List.of(file("data/a", "data")), true, + Map.of("zh_cn", "中文名称", "en_us", "English name")); + Item transported = SyncEndpoint.JSON.fromJson(SyncEndpoint.JSON.toJson(named), Item.class); + assertEquals("中文名称", SyncModel.displayName(transported, "zh_CN")); + assertEquals("English name", SyncModel.displayName(transported, "fr_fr")); + assertEquals("internal-id", SyncModel.displayName(new Item(named.key(), named.identifier(), named.config(), named.resources(), true), "zh_cn")); + Item unnamed = new Item("0:remote/folder/sample.dc", null, named.config(), named.resources(), true); + assertEquals("sample", SyncModel.displayName(unnamed, "zh_cn")); + assertEquals("server_missing", SyncModel.status(SyncModel.plan(List.of(named), List.of(), Direction.PUSH, true).get(0), Mode.CONFIG)); + assertEquals("client_missing", SyncModel.status(SyncModel.plan(List.of(), List.of(named), Direction.PUSH, true).get(0), Mode.CONFIG)); + Item disabled = new Item(named.key(), named.identifier(), named.config(), named.resources(), false); + assertEquals("state_changed", SyncModel.status(SyncModel.plan(List.of(named), List.of(disabled), Direction.PUSH, true).get(0), Mode.CONFIG)); + Item different = new Item(named.key(), named.identifier(), file(named.key(), "other"), named.resources(), true); + assertEquals("content_changed", SyncModel.status(SyncModel.plan(List.of(named), List.of(different), Direction.PUSH, true).get(0), Mode.CONFIG)); + } + @Test void hidesIdenticalItemsAndIncludesStateOnlyDifferencesOnlyWhenRequested() { + Item a = item("remote/a.dc", "same", true), b = item("remote/a.dc", "same", false); + assertTrue(SyncModel.plan(List.of(a), List.of(a), Direction.PUSH, true).isEmpty()); + assertTrue(SyncModel.plan(List.of(a), List.of(b), Direction.PUSH, false).isEmpty()); + Row difference = SyncModel.plan(List.of(a), List.of(b), Direction.PUSH, true).get(0); + assertTrue(difference.stateChanged()); assertTrue(difference.replacement()); + assertEquals(Direction.PULL, SyncModel.plan(List.of(), List.of(a), Direction.PUSH, true).get(0).direction()); + assertEquals(a, SyncModel.plan(List.of(), List.of(a), Direction.PULL, true).get(0).source()); + Item unknown = new Item(a.key(), a.identifier(), a.config(), List.of(new File(a.resources().get(0).id(), -1, null)), a.enabled()); + assertTrue(SyncModel.plan(List.of(unknown), List.of(a), Direction.PUSH, false, Mode.FILES).get(0).replacement()); + assertTrue(SyncModel.plan(List.of(unknown), List.of(a), Direction.PUSH, false, Mode.CONFIG).isEmpty()); + } + private Item update(String key, String from, String to, String platform) { + return new Item(key, key, file(key, key), List.of(), null, Map.of(), Map.of(), new Update("1.0.0", from, "1.0.0", to, platform)); + } + @Test void classifiesNewTypesAndHidesInstalledVersionRecords() { + Item manager = new Item("manager:config.dc", "config", file("manager:config.dc", "same"), List.of(), null); + Item preset = new Item("0:preset/main.dc", "preset", file("0:preset/main.dc", "same"), List.of(), null); + Item required = item("0:required/main.dc", "same", false); + Item version = update("0:info/version.dc", "1.0.0", "1.0.0", "server"); + assertEquals(Category.MANAGER_CONFIG, SyncModel.category(manager)); + assertEquals(Category.PRESET, SyncModel.category(preset)); + assertEquals(Category.REQUIRED, SyncModel.category(required)); + assertEquals(Category.UPDATE, SyncModel.category(version)); + assertEquals(Category.DLC, SyncModel.category(item("0:remote/main.dc", "same", false))); + assertEquals(2, SyncModel.plan(List.of(manager, preset, version), List.of(), Direction.PUSH, true, Mode.CONFIG).size()); + assertTrue(SyncModel.plan(List.of(manager, preset, version), List.of(manager, preset), Direction.PUSH, true, Mode.FILES).isEmpty()); + } + @Test void appliesDlcBeforePresetsAndDisablesOldPresetsBeforeEnablingNewOnes() { + Item off = new Item("0:preset/z-old.dc", "old", file("0:preset/z-old.dc", "off"), List.of(), false); + Item on = new Item("0:preset/a-new.dc", "new", file("0:preset/a-new.dc", "on"), List.of(), true); + Item dlc = item("0:remote/main.dc", "main", true); + List rows = List.of(new Row(on.key(), on, null, Direction.PUSH, false, true), new Row(off.key(), off, null, Direction.PUSH, false, true), new Row(dlc.key(), dlc, null, Direction.PUSH, false, true)); + assertEquals(List.of(dlc.key(), off.key(), on.key()), SyncModel.ordered(rows).stream().map(Row::key).toList()); + } + @Test void offersOnlyReachablePlatformCompatibleUpdatesInFilesMode() { + Item installed = update("0:info/version.dc", "1.0.0", "1.0.0", "server"); + Item first = update("0:update/z-first.zip", "1.0.0", "1.1.0", "server"); + Item second = update("0:update/a-second.zip", "1.1.0", "1.2.0", "universal"); + List packages = List.of(second, first, + update("0:update/unreachable.zip", "2.0.0", "2.1.0", "server"), + update("0:update/client.zip", "1.0.0", "1.9.0", "client"), + update("0:update/downgrade.zip", "1.0.0", "0.9.0", "server"), + update("1:update/other-root.zip", "1.0.0", "1.1.0", "server")); + List rows = SyncModel.plan(packages, List.of(installed), Direction.PUSH, true, Mode.FILES); + assertEquals(Set.of(first.key(), second.key()), rows.stream().map(Row::key).collect(java.util.stream.Collectors.toSet())); + assertEquals(List.of(first.key(), second.key()), SyncModel.ordered(rows).stream().map(Row::key).toList()); + assertTrue(SyncModel.plan(packages, List.of(installed), Direction.PUSH, true, Mode.CONFIG).isEmpty()); + assertTrue(SyncModel.plan(packages, List.of(), Direction.PUSH, true, Mode.FILES).isEmpty()); + assertEquals(Set.of(second.key()), SyncModel.eligibleUpdates(List.of(second), List.of(installed, first))); + assertTrue(SyncModel.eligibleUpdates(List.of(second), List.of(installed)).isEmpty()); + assertTrue(SyncModel.plan(List.of(first), List.of(installed, first), Direction.PUSH, true, Mode.FILES).isEmpty()); + assertEquals(Direction.PULL, SyncModel.plan(List.of(installed), List.of(first), Direction.PUSH, true, Mode.FILES).get(0).direction()); + Item baseOnly = new Item("0:update/base.zip", "base", file("0:update/base.zip", "base"), List.of(), null, Map.of(), Map.of(), new Update("1.0.0", "1.0.0", "2.0.0", "1.0.0", "server")); + assertEquals(Set.of(baseOnly.key()), SyncModel.eligibleUpdates(List.of(baseOnly), List.of(installed))); + } + @Test void showsTheStateOptionOnlyForDifferentStatesOfSharedItems() { + Item enabled = item("remote/a.dc", "same", true), disabled = item("remote/a.dc", "same", false); + assertFalse(SyncModel.hasStateDifferences(List.of(enabled), List.of(enabled))); + assertTrue(SyncModel.hasStateDifferences(List.of(enabled), List.of(disabled))); + assertFalse(SyncModel.hasStateDifferences(List.of(enabled), List.of())); + Item renamed = new Item(disabled.key(), "different-dlc", disabled.config(), disabled.resources(), false); + assertFalse(SyncModel.hasStateDifferences(List.of(enabled), List.of(renamed))); + assertFalse(SyncModel.plan(List.of(enabled), List.of(), Direction.PUSH, true).get(0).stateChanged()); + assertTrue(SyncModel.hasStateDifferences(List.of(enabled), List.of(disabled)), "Visibility must not depend on whether the state option is currently selected"); + } + @Test void anUnappliedStateIsSkippedWithoutFailingOrUndoingTransferredContent() throws Exception { + for (boolean contentChanged : List.of(false, true)) { + Fixture left = new Fixture(temp.resolve("local-skip-" + contentChanged)), right = new Fixture(temp.resolve("server-skip-" + contentChanged)); + left.add("a", contentChanged ? "new config" : "same", "same data", true); + right.add("a", contentChanged ? "old config" : "same", "same data", false); right.failEnable = true; + try (SyncEndpoint source = new SyncEndpoint(left); SyncEndpoint target = new SyncEndpoint(right)) { + Result result = new SyncClient(source, target, target).execute(plan(source, target, true), Mode.CONFIG, true, quiet); + assertTrue(result.errors().isEmpty(), result.errors().toString()); + assertEquals(contentChanged ? 1 : 0, result.completed()); + assertEquals(contentChanged ? "new config" : "same", Files.readString(right.root.resolve("remote/a.dc"))); + assertEquals("true", Files.readString(right.root.resolve("state/a"))); + assertEquals(Files.readString(right.root.resolve("data/a")), Files.readString(right.root.resolve("applied/a")), "Partial state changes remain until the user requests rollback"); + } + } + } + @Test void acceptingOnlyPartOfAStateDoesNotRequireAnIdenticalFinalFlag() throws Exception { + Fixture left = new Fixture(temp.resolve("local-part")), right = new Fixture(temp.resolve("server-part")); + left.add("a", "same", "same data", true); right.add("a", "same", "same data", false); + try (SyncEndpoint source = new SyncEndpoint(left); SyncEndpoint endpoint = new SyncEndpoint(right)) { + Peer target = request -> { + if (request.get("action").getAsString().equals("apply")) { request = request.deepCopy(); request.remove("enabled"); } + return endpoint.call(request); + }; + Result result = new SyncClient(source, target, endpoint).execute(plan(source, endpoint, true), Mode.CONFIG, true, quiet); + assertTrue(result.errors().isEmpty()); assertEquals("false", Files.readString(right.root.resolve("state/a"))); + } + } + @Test void configurationModeIgnoresResourcesWhileKeepingTheirMetadataAvailable() throws Exception { + Fixture left = new Fixture(temp.resolve("local")), right = new Fixture(temp.resolve("server")); + left.add("a", "same", null, true); right.add("a", "same", "server data", true); + try (SyncEndpoint source = new SyncEndpoint(left); SyncEndpoint target = new SyncEndpoint(right)) { + List local = SyncClient.manifest(source), remote = SyncClient.manifest(target); + assertEquals(-1, local.get(0).resources().get(0).size()); + assertTrue(remote.get(0).resources().get(0).size() > 0); + assertTrue(SyncModel.plan(local, remote, Direction.PUSH, true, Mode.CONFIG).isEmpty()); + Row missing = SyncModel.plan(local, remote, Direction.PUSH, true, Mode.FILES).get(0); + assertEquals("resources_missing", SyncModel.status(missing, Mode.FILES)); + assertFalse(SyncModel.differences(missing, Mode.CONFIG).contains("resources_missing")); + } + } + @Test void sendsOnlyChangedFilesAndDoesNotTransferForStateOnlyChanges() throws Exception { + for (int variant = 0; variant < 3; variant++) { + Fixture left = new Fixture(temp.resolve("local" + variant)), right = new Fixture(temp.resolve("server" + variant)); + left.add("a", "new config", "new data", true); + right.add("a", variant == 1 ? "old config" : "new config", variant == 0 ? "old data" : "new data", variant != 2); + List reads = new ArrayList<>(), writes = new ArrayList<>(); + try (SyncEndpoint source = new SyncEndpoint(left); SyncEndpoint target = new SyncEndpoint(right)) { + Peer trackedSource = request -> { if (request.get("action").getAsString().equals("read")) reads.add(request.get("id").getAsString()); return source.call(request); }; + Peer trackedTarget = request -> { if (request.get("action").getAsString().equals("write")) writes.add(request.getAsJsonObject("file").get("id").getAsString()); return target.call(request); }; + Result result = new SyncClient(trackedSource, trackedTarget, target).execute(plan(source, target, true), Mode.FILES, true, quiet); + assertTrue(result.errors().isEmpty(), result.errors().toString()); assertEquals(1, result.completed()); + List expected = variant == 0 ? List.of("data/a") : variant == 1 ? List.of("remote/a.dc") : List.of(); + assertEquals(expected, reads); assertEquals(expected, writes); + } + } + } + @Test void failedBackupPreventsOverwritingTheExistingResource() throws Exception { + Fixture left = new Fixture(temp.resolve("local")), right = new Fixture(temp.resolve("server")); + left.add("a", "same", "new data", true); right.add("a", "same", "old data", true); + right.captureFailure = right.root.resolve("data/a"); + try (SyncEndpoint source = new SyncEndpoint(left); SyncEndpoint target = new SyncEndpoint(right)) { + Result result = new SyncClient(source, target, target).execute(plan(source, target, true), Mode.FILES, true, quiet); + assertEquals(0, result.completed()); assertEquals(1, result.errors().size()); + assertEquals("old data", Files.readString(right.root.resolve("data/a"))); + } + } + @Test void filesModePreparesOnSourceAndAppliesOnTargetWithoutDownloadingThere() throws Exception { + Fixture left = new Fixture(temp.resolve("local")), right = new Fixture(temp.resolve("server")); + left.add("a", "config", null, true); + try (SyncEndpoint source = new SyncEndpoint(left); SyncEndpoint target = new SyncEndpoint(right)) { + Result result = new SyncClient(source, target, target).execute(plan(source, target, true), Mode.FILES, true, quiet); + assertEquals(1, result.completed()); assertTrue(result.errors().isEmpty(), result.errors().toString()); + assertEquals(1, left.downloads); assertEquals(0, right.downloads); + assertEquals("downloaded", Files.readString(right.root.resolve("data/a"))); + assertFalse(Files.exists(right.root.resolve("applied/a"))); + assertEquals("false", Files.readString(right.root.resolve("state/a"))); + } + } + @Test void configurationModeDownloadsOnlyOnTargetAndCanKeepTargetState() throws Exception { + Fixture left = new Fixture(temp.resolve("local")), right = new Fixture(temp.resolve("server")); + left.add("a", "new config", null, true); right.add("a", "old config", "old", false); + try (SyncEndpoint source = new SyncEndpoint(left); SyncEndpoint target = new SyncEndpoint(right)) { + Result result = new SyncClient(source, target, target).execute(plan(source, target, false), Mode.CONFIG, false, quiet); + assertEquals(1, result.completed()); assertEquals(0, left.downloads); + assertEquals("false", Files.readString(right.root.resolve("state/a"))); + assertFalse(Files.exists(right.root.resolve("applied/a"))); + } + } + @Test void cancelAfterOneCompletedItemUndoesEveryFileAndEnabledState() throws Exception { + Fixture left = new Fixture(temp.resolve("local")), right = new Fixture(temp.resolve("server")); + left.add("a", "new", null, true); left.add("b", "new", "new data", true); + right.add("a", "old", "old data", false); + try (SyncEndpoint source = new SyncEndpoint(left); SyncEndpoint target = new SyncEndpoint(right)) { + SyncClient client = new SyncClient(source, target, target); + Result result = client.execute(plan(source, target, true), Mode.FILES, true, (phase, resource, done, total) -> { + if (phase.equals("preparing") && resource.equals("remote/b.dc")) client.cancel(); + }); + assertTrue(result.canceled()); assertEquals(0, result.completed()); + assertEquals("old", Files.readString(right.root.resolve("remote/a.dc"))); + assertEquals("old data", Files.readString(right.root.resolve("data/a"))); + assertEquals("false", Files.readString(right.root.resolve("state/a"))); + assertFalse(Files.exists(right.root.resolve("applied/a"))); + assertFalse(Files.exists(right.root.resolve("remote/b.dc"))); + assertFalse(Files.exists(left.root.resolve("data/a"))); + } + } + @Test void corruptedUploadDoesNotReplaceOriginalAndOtherItemsCanContinue() throws Exception { + Fixture left = new Fixture(temp.resolve("local")), right = new Fixture(temp.resolve("server")); + left.add("a", "new", "new data", true); left.add("b", "new", "new data", true); + right.add("a", "old", "old data", false); + AtomicBoolean corrupt = new AtomicBoolean(true); + try (SyncEndpoint source = new SyncEndpoint(left); SyncEndpoint endpoint = new SyncEndpoint(right)) { + Peer target = request -> { + if (request.get("action").getAsString().equals("write_chunk") && corrupt.getAndSet(false)) { + request = request.deepCopy(); request.addProperty("data", Base64.getEncoder().encodeToString("bad".getBytes(java.nio.charset.StandardCharsets.UTF_8))); + } + return endpoint.call(request); + }; + Result result = new SyncClient(source, target, endpoint).execute(plan(source, target, true), Mode.FILES, true, quiet); + assertEquals(1, result.completed()); assertEquals(1, result.errors().size()); + assertEquals("old", Files.readString(right.root.resolve("remote/a.dc"))); + assertEquals("old data", Files.readString(right.root.resolve("data/a"))); + assertEquals("new", Files.readString(right.root.resolve("remote/b.dc"))); + } + } + @Test void applicationFailureKeepsChangesUntilExplicitRollback() throws Exception { + Fixture left = new Fixture(temp.resolve("local")), right = new Fixture(temp.resolve("server")); + left.add("a", "new", "new data", true); right.add("a", "old", "old data", false); right.failApply = true; + try (SyncEndpoint source = new SyncEndpoint(left); SyncEndpoint target = new SyncEndpoint(right)) { + SyncClient client = new SyncClient(source, target, target); + Result result = client.execute(plan(source, target, true), Mode.FILES, true, quiet); + assertEquals(0, result.completed()); assertEquals(1, result.errors().size()); + assertEquals("new", Files.readString(right.root.resolve("remote/a.dc"))); + assertEquals("new data", Files.readString(right.root.resolve("data/a"))); + assertTrue(client.canRollback()); + client.rollback(source, target); + assertFalse(client.canRollback()); + assertEquals("old", Files.readString(right.root.resolve("remote/a.dc"))); + assertEquals("old data", Files.readString(right.root.resolve("data/a"))); + assertFalse(Files.exists(right.root.resolve("applied/a"))); + assertEquals("false", Files.readString(right.root.resolve("state/a"))); + } + } + @Test void refusesUnscopedPathsAndDoesNotOverwriteOnTruncation() throws Exception { + Fixture right = new Fixture(temp.resolve("server")); right.add("a", "config", "old", false); + try (SyncEndpoint target = new SyncEndpoint(right)) { + JsonObject begin = SyncClient.request("begin"); begin.addProperty("operation", UUID.randomUUID().toString()); SyncClient.checked(target, begin); + JsonObject choose = SyncClient.request("item"); choose.addProperty("key", "mods/other.jar"); assertFalse(target.call(choose).get("ok").getAsBoolean()); + choose.addProperty("key", "remote/a.dc"); SyncClient.checked(target, choose); + JsonObject write = SyncClient.request("write"); write.add("file", SyncEndpoint.JSON.toJsonTree(new File("data/a", 99, "unused"))); write.addProperty("hash", "incorrect"); SyncClient.checked(target, write); + assertFalse(target.call(SyncClient.request("write_end")).get("ok").getAsBoolean()); + assertEquals("old", Files.readString(right.root.resolve("data/a"))); + SyncClient.checked(target, SyncClient.request("rollback")); + } + } + @Test void rejectsTargetChangesMadeAfterComparison() throws Exception { + Fixture left = new Fixture(temp.resolve("local")), right = new Fixture(temp.resolve("server")); + left.add("a", "new", "new data", true); right.add("a", "old", "old data", false); + try (SyncEndpoint source = new SyncEndpoint(left); SyncEndpoint target = new SyncEndpoint(right)) { + List rows = plan(source, target, true); + right.write("remote/a.dc", "edited after comparison"); + assertThrows(IOException.class, () -> new SyncClient(source, target, target).execute(rows, Mode.FILES, true, quiet)); + assertEquals("edited after comparison", Files.readString(right.root.resolve("remote/a.dc"))); + assertEquals("old data", Files.readString(right.root.resolve("data/a"))); + } + } + @Test void supportsStateOnlyUpdatesWithoutChangingDirectionOrDownloading() throws Exception { + Fixture left = new Fixture(temp.resolve("local")), right = new Fixture(temp.resolve("server")); + left.add("a", "same", "same data", true); right.add("a", "same", "same data", false); + try (SyncEndpoint source = new SyncEndpoint(left); SyncEndpoint target = new SyncEndpoint(right)) { + Result result = new SyncClient(source, target, target).execute(plan(source, target, true), Mode.CONFIG, true, quiet); + assertEquals(1, result.completed()); assertEquals(0, left.downloads + right.downloads); + assertEquals("true", Files.readString(right.root.resolve("state/a"))); + assertEquals("same data", Files.readString(right.root.resolve("applied/a"))); + } + } + @Test void pullsResourcesAndPreservesStateUnlessStateSyncIsSelected() throws Exception { + Fixture left = new Fixture(temp.resolve("local")), right = new Fixture(temp.resolve("server")); + left.add("a", "old", "old data", false); right.add("a", "new", "new data", true); + try (SyncEndpoint source = new SyncEndpoint(left); SyncEndpoint target = new SyncEndpoint(right)) { + List rows = SyncModel.plan(SyncClient.manifest(source), SyncClient.manifest(target), Direction.PULL, false); + Result result = new SyncClient(source, target, target).execute(rows, Mode.FILES, false, quiet); + assertEquals(List.of("remote/a.dc"), result.succeeded()); + assertEquals("new data", Files.readString(left.root.resolve("data/a"))); + assertEquals("false", Files.readString(left.root.resolve("state/a"))); + assertEquals("true", Files.readString(right.root.resolve("state/a"))); + } + } + @Test void lostItemAcknowledgmentKeepsEarlierChangesAndAllowsRollback() throws Exception { + Fixture left = new Fixture(temp.resolve("local")), right = new Fixture(temp.resolve("server")); + left.add("a", "new", "new data", true); right.add("a", "old", "old data", false); + try (SyncEndpoint source = new SyncEndpoint(left); SyncEndpoint target = new SyncEndpoint(right)) { + Peer lost = request -> { + JsonObject response = source.call(request); + if (request.get("action").getAsString().equals("item_done")) throw new IOException("Connection lost after item acknowledgment"); + return response; + }; + SyncClient client = new SyncClient(lost, target, target); + assertThrows(IOException.class, () -> client.execute(plan(source, target, true), Mode.FILES, true, quiet)); + assertEquals("new", Files.readString(right.root.resolve("remote/a.dc"))); + assertEquals("new data", Files.readString(right.root.resolve("data/a"))); + client.rollback(source, target); + assertEquals("old", Files.readString(right.root.resolve("remote/a.dc"))); + assertEquals("old data", Files.readString(right.root.resolve("data/a"))); + assertEquals("false", Files.readString(right.root.resolve("state/a"))); + assertFalse(Files.exists(right.root.resolve("applied/a"))); + } + } + @Test void successfulSyncCanBeUndoneAfterConnectionsClose() throws Exception { + Fixture left = new Fixture(temp.resolve("local")), right = new Fixture(temp.resolve("server")); + left.add("a", "new", "new data", true); right.add("a", "old", "old data", false); + SyncClient client; + try (SyncEndpoint source = new SyncEndpoint(left); SyncEndpoint target = new SyncEndpoint(right)) { + client = new SyncClient(source, target, target); + assertEquals(1, client.execute(plan(source, target, true), Mode.FILES, true, quiet).completed()); + } + assertEquals("new data", Files.readString(right.root.resolve("data/a"))); + try (SyncEndpoint source = new SyncEndpoint(left); SyncEndpoint target = new SyncEndpoint(right)) { + client.rollback(source, target); + } + assertEquals("old data", Files.readString(right.root.resolve("data/a"))); + assertEquals("false", Files.readString(right.root.resolve("state/a"))); + assertFalse(client.canRollback()); + } + @Test void cancelingALaterOperationDoesNotUndoAnEarlierKeptSynchronization() throws Exception { + Fixture left = new Fixture(temp.resolve("local")), right = new Fixture(temp.resolve("server")); + left.add("a", "first", "first data", false); right.add("a", "old", "old data", false); + try (SyncEndpoint source = new SyncEndpoint(left); SyncEndpoint target = new SyncEndpoint(right)) { + new SyncClient(source, target, target).execute(plan(source, target, true), Mode.FILES, true, quiet); + left.add("a", "second", "second data", false); + SyncClient client = new SyncClient(source, target, target); + Result result = client.execute(plan(source, target, true), Mode.FILES, true, (phase, name, done, total) -> { + if (phase.equals("applying")) client.cancel(); + }); + assertTrue(result.canceled()); + assertEquals("first", Files.readString(right.root.resolve("remote/a.dc"))); + assertEquals("first data", Files.readString(right.root.resolve("data/a"))); + } + } + @Test void changedSourceAndBusyLegacyOperationsCannotSilentlyOverwriteTarget() throws Exception { + Fixture left = new Fixture(temp.resolve("local")), right = new Fixture(temp.resolve("server")); + left.add("a", "new", "new data", true); right.add("a", "old", "old data", false); + try (SyncEndpoint source = new SyncEndpoint(left); SyncEndpoint target = new SyncEndpoint(right)) { + Peer changing = request -> { + if (request.get("action").getAsString().equals("read") && request.get("id").getAsString().equals("data/a")) left.write("data/a", "changed after comparison"); + return source.call(request); + }; + Result result = new SyncClient(changing, target, target).execute(plan(source, target, true), Mode.FILES, true, quiet); + assertEquals(0, result.completed()); assertEquals(1, result.errors().size()); + assertEquals("old data", Files.readString(right.root.resolve("data/a"))); + JsonObject begin = SyncClient.request("begin"); begin.addProperty("operation", UUID.randomUUID().toString()); SyncClient.checked(target, begin); + assertThrows(IOException.class, () -> SyncEndpoint.guard(right.workDirectory())); + SyncClient.checked(target, SyncClient.request("rollback")); + try (var guard = SyncEndpoint.guard(right.workDirectory())) { assertFalse(SyncEndpoint.busy(right.workDirectory())); } + } + } + @Test void transfersMultipleChunksWithFullIntegrityVerification() throws Exception { + Fixture left = new Fixture(temp.resolve("local")), right = new Fixture(temp.resolve("server")); + String payload = "0123456789".repeat(250000); + left.add("a", "config", payload, true); + java.util.concurrent.atomic.AtomicInteger chunks = new java.util.concurrent.atomic.AtomicInteger(); + try (SyncEndpoint source = new SyncEndpoint(left); SyncEndpoint endpoint = new SyncEndpoint(right)) { + Peer target = request -> { if (request.get("action").getAsString().equals("write_chunk")) chunks.incrementAndGet(); return endpoint.call(request); }; + Result result = new SyncClient(source, target, endpoint).execute(plan(source, endpoint, true), Mode.FILES, true, quiet); + assertEquals(1, result.completed()); assertTrue(result.errors().isEmpty()); assertTrue(chunks.get() >= 4); + assertEquals(DlcFileMatch.fullHash(left.root.resolve("data/a")), DlcFileMatch.fullHash(right.root.resolve("data/a"))); + assertEquals(payload, Files.readString(right.root.resolve("data/a"))); + assertFalse(Files.exists(right.root.resolve("applied/a"))); + } + } + @Test void lightFingerprintDoesNotReplaceFullContentVerification() throws Exception { + Path path = temp.resolve("large.jar"); byte[] bytes = new byte[200000]; Files.write(path, bytes); + String light = DlcFileMatch.lightHash(path), full = DlcFileMatch.hash(path); + long modified = Files.getLastModifiedTime(path).toMillis(); + bytes[100000] = 1; Files.write(path, bytes); + Files.setLastModifiedTime(path, java.nio.file.attribute.FileTime.fromMillis(modified + 1000)); + assertEquals(light, DlcFileMatch.lightHash(path)); + assertNotEquals(full, DlcFileMatch.hash(path)); + } + + @Test void transfersUpdateChainInOrderWithoutInstallingAndRejectsMissingPrerequisiteBeforeSending() throws Exception { + Map definitions = Map.of( + "info/version.dc", new Update("1.0.0", "1.0.0", "1.0.0", "1.0.0", "server"), + "update/z-first.zip", new Update("1.0.0", "1.0.0", "1.0.0", "1.1.0", "server"), + "update/a-second.zip", new Update("1.0.0", "1.1.0", "1.0.0", "1.2.0", "universal")); + UpdateFixture left = new UpdateFixture(temp.resolve("local-updates"), definitions), right = new UpdateFixture(temp.resolve("server-updates"), definitions); + left.write("info/version.dc", "installed 1.0.0"); right.write("info/version.dc", "installed 1.0.0"); + left.write("update/z-first.zip", "first update payload"); left.write("update/a-second.zip", "second update payload"); + List written = new ArrayList<>(); + try (SyncEndpoint source = new SyncEndpoint(left); SyncEndpoint endpoint = new SyncEndpoint(right)) { + Peer target = request -> { if (request.get("action").getAsString().equals("write")) written.add(request.getAsJsonObject("file").get("id").getAsString()); return endpoint.call(request); }; + List rows = plan(source, endpoint, false); + assertEquals(2, rows.size()); + Row later = rows.stream().filter(row -> row.key().endsWith("a-second.zip")).findFirst().orElseThrow(); + Result rejected = new SyncClient(source, target, endpoint).execute(List.of(later), Mode.FILES, false, quiet); + assertEquals(0, rejected.completed()); assertEquals(1, rejected.errors().size()); assertTrue(written.isEmpty()); + assertFalse(Files.exists(right.resolve(later.key()))); + Result wrongMode = new SyncClient(source, target, endpoint).execute(rows, Mode.CONFIG, false, quiet); + assertEquals(0, wrongMode.completed()); assertEquals(2, wrongMode.errors().size()); assertTrue(written.isEmpty()); + Result result = new SyncClient(source, target, endpoint).execute(rows, Mode.FILES, false, quiet); + assertEquals(2, result.completed()); assertTrue(result.errors().isEmpty()); assertFalse(result.requiresRestart()); + assertEquals(List.of("update/z-first.zip", "update/a-second.zip"), written); + assertEquals("first update payload", Files.readString(right.resolve("update/z-first.zip"))); + assertEquals("second update payload", Files.readString(right.resolve("update/a-second.zip"))); + assertEquals("installed 1.0.0", Files.readString(right.resolve("info/version.dc"))); + assertEquals(0, right.downloads); assertFalse(Files.exists(right.root.resolve("applied"))); + } + } + private static final class UpdateFixture extends Fixture { + private final Map definitions; + UpdateFixture(Path root, Map definitions) throws IOException { super(root); this.definitions = definitions; } + @Override public void validateKey(String key) throws IOException { + if (!key.startsWith("update/") || !definitions.containsKey(key)) throw new IOException("Invalid update key"); resolve(key); + } + @Override public List manifest() throws IOException { + List result = new ArrayList<>(); + for (var entry : definitions.entrySet()) { + Path path = resolve(entry.getKey()); + if (Files.isRegularFile(path)) result.add(new Item(entry.getKey(), entry.getKey(), new File(entry.getKey(), Files.size(path), DlcFileMatch.hash(path)), List.of(), null, Map.of(), Map.of(), entry.getValue())); + } + return result; + } + @Override public void validateIncoming(Item item) throws IOException { + if (!SyncModel.eligibleUpdates(List.of(item), manifest()).contains(item.key())) throw new IOException("Missing update prerequisite"); + } + @Override public void prepare(String key, boolean force, BooleanSupplier canceled, Progress progress) {} + @Override public boolean apply(String key, Boolean enabled, Journal journal, BooleanSupplier canceled) throws IOException { validateIncoming(item(key)); return false; } + } + + private List plan(Peer a, Peer b, boolean states) throws IOException { return SyncModel.plan(SyncClient.manifest(a), SyncClient.manifest(b), Direction.PUSH, states); } + + private static class Fixture implements Adapter { + final Map journals = new LinkedHashMap<>(); + final Set restored = new HashSet<>(); + final Path root; int downloads; boolean failApply, failEnable; Journal active; Path captureFailure; + Fixture(Path root) throws IOException { this.root = root; Files.createDirectories(root.resolve("remote")); } + void write(String path, String text) throws IOException { Path file = root.resolve(path); Files.createDirectories(file.getParent()); Files.writeString(file, text); } + void add(String name, String config, String data, boolean enabled) throws IOException { + write("remote/" + name + ".dc", config); if (data != null) write("data/" + name, data); write("state/" + name, Boolean.toString(enabled)); + } + public Path workDirectory() { return root.resolve("work"); } + public Path resolve(String id) throws IOException { + Path path = root.resolve(id).normalize(); if (!path.startsWith(root) || path.equals(root)) throw new IOException("Outside root"); return path; + } + public void validateKey(String key) throws IOException { if (!key.startsWith("remote/") || !key.endsWith(".dc")) throw new IOException("Invalid DLC key"); resolve(key); } + public List manifest() throws IOException { + List result = new ArrayList<>(); + try (var files = Files.list(root.resolve("remote"))) { + for (Path config : files.sorted().toList()) { + String name = config.getFileName().toString().replace(".dc", ""); Path data = root.resolve("data/" + name), state = root.resolve("state/" + name); + result.add(new Item("remote/" + name + ".dc", name, new File("remote/" + name + ".dc", Files.size(config), DlcFileMatch.fullHash(config)), + List.of(new File("data/" + name, Files.exists(data) ? Files.size(data) : -1, Files.exists(data) ? DlcFileMatch.hash(data) : null)), Files.exists(state) && Boolean.parseBoolean(Files.readString(state)))); + } + } + return result; + } + public Journal journal(Path directory) { + active = new Journal() { + final Map originals = new LinkedHashMap<>(); + public void capture(Path path) throws IOException { if (path.equals(captureFailure)) throw new IOException("Backup failed"); if (!originals.containsKey(path)) originals.put(path, Files.exists(path) ? Files.readAllBytes(path) : null); } + public void rollback() throws IOException { if (!restored.add(directory)) return; for (var value : originals.entrySet()) { if (value.getValue() == null) Files.deleteIfExists(value.getKey()); else { Files.createDirectories(value.getKey().getParent()); Files.write(value.getKey(), value.getValue()); } } } + public void commit() {} + public void close() {} + }; + journals.put(directory, active); return active; + } + public void prepare(String key, boolean force, BooleanSupplier canceled, Progress progress) throws IOException { + String name = key.substring(7).replace(".dc", ""); + if (force || !Files.exists(root.resolve("data/" + name))) { active.capture(root.resolve("data/" + name)); downloads++; write("data/" + name, "downloaded"); } + } + public boolean apply(String key, Boolean enabled, Journal journal, BooleanSupplier canceled) throws IOException { + String name = key.substring(7).replace(".dc", ""); Path state = root.resolve("state/" + name), target = root.resolve("applied/" + name); + journal.capture(state); journal.capture(target); + boolean desired = enabled == null ? Files.exists(state) && Boolean.parseBoolean(Files.readString(state)) : enabled; + write("state/" + name, Boolean.toString(desired)); + if (desired) write("applied/" + name, Files.readString(root.resolve("data/" + name))); else Files.deleteIfExists(target); + if (failApply || failEnable && Boolean.TRUE.equals(enabled)) throw new IOException("Target is locked"); + return desired; + } + public void reload() {} + public void recover() {} + public void recoverOperation(String operation) throws IOException { + List paths = new ArrayList<>(journals.keySet()); Collections.reverse(paths); + for (Path path : paths) if (path.startsWith(workDirectory().resolve(operation))) journals.get(path).rollback(); + } + } +} diff --git a/src/test/java/com/cozooo/dlc_manager/sync/SyncFailureTest.java b/src/test/java/com/cozooo/dlc_manager/sync/SyncFailureTest.java new file mode 100644 index 00000000..7c814438 --- /dev/null +++ b/src/test/java/com/cozooo/dlc_manager/sync/SyncFailureTest.java @@ -0,0 +1,55 @@ +package com.cozooo.dlc_manager.sync; + +import com.cozooo.dlc_manager.platform.DlcPlatformServices; +import com.cozooo.dlc_manager.platform.PlatformServices; +import com.cozooo.dlc_manager.test.TestPlatformProvider; +import com.google.gson.JsonObject; +import java.io.IOException; +import java.lang.reflect.Proxy; +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.*; + +class SyncFailureTest { + @TempDir Path game; + private final AtomicReference language = new AtomicReference<>("en_us"); + @BeforeEach void setup() { + var delegate = new TestPlatformProvider(game, game.resolve("config")); + PlatformServices.setProvider((DlcPlatformServices.Provider) Proxy.newProxyInstance(getClass().getClassLoader(), new Class[]{DlcPlatformServices.Provider.class}, + (proxy, method, arguments) -> method.getName().equals("languageCode") ? language.get() : method.invoke(delegate, arguments))); + } + @AfterEach void reset() { PlatformServices.setProvider(null); } + @Test void messagesUseTheCurrentLanguageAndKeepResourceNamesAsArguments() { + SyncFailure failure = new SyncFailure("source_changed", "remote/example.dc"); + assertEquals("Source changed after comparison: remote/example.dc", failure.getMessage()); + language.set("zh_CN"); + assertEquals("比较后源文件已发生变化:remote/example.dc", failure.getMessage()); + language.set("fr_fr"); + assertEquals("Source changed after comparison: remote/example.dc", failure.getMessage()); + } + @Test void receivedErrorsAreTranslatedInTheClientsLanguageInsteadOfTheServers() { + JsonObject response = new JsonObject(); response.addProperty("ok", false); + SyncFailure source = new SyncFailure("resource_missing", "resource.zip"); + response.addProperty("reason", source.getMessage()); source.write(response); + language.set("zh_cn"); + IOException received = assertThrows(IOException.class, () -> SyncClient.checked(request -> response, SyncClient.request("inspect"))); + assertEquals("DLC 资源缺失:resource.zip", received.getMessage()); + assertTrue(SyncFailure.is(received, "resource_missing")); + } + @Test void cancellationAndBusyDetectionDoNotDependOnTranslatedMessages() { + language.set("zh_cn"); + SyncFailure failure = new SyncFailure("active").causedBy(new IOException("details")); + assertTrue(SyncFailure.is(failure, "active")); assertFalse(SyncFailure.is(failure, "canceled")); + assertEquals("details", failure.getCause().getMessage()); + } + @Test void unknownSystemErrorsKeepTheirOriginalDetailsAndInvalidRepliesAreLocalized() { + JsonObject response = new JsonObject(); response.addProperty("ok", false); response.addProperty("reason", "permission denied: example.jar"); + IOException error = assertThrows(IOException.class, () -> SyncClient.checked(request -> response, SyncClient.request("inspect"))); + assertEquals("permission denied: example.jar", error.getMessage()); + response.addProperty("reasonKey", "dlc_manager.sync.error.resource_missing"); response.addProperty("reasonArgs", "invalid"); language.set("zh_cn"); + error = assertThrows(IOException.class, () -> SyncClient.checked(request -> response, SyncClient.request("inspect"))); + assertEquals("收到的同步响应无效。", error.getMessage()); + } +} diff --git a/tools/dlc-bootstrap/build.gradle.kts b/tools/dlc-bootstrap/build.gradle.kts index 8344681b..72ee58c4 100644 --- a/tools/dlc-bootstrap/build.gradle.kts +++ b/tools/dlc-bootstrap/build.gradle.kts @@ -130,6 +130,7 @@ val sharedClasses = listOf( "core/DlcDcFormatUtil", "core/DlcDcDefinition", "core/DlcFileMatch", + "core/DlcFileSize", "core/DlcItem", "core/DlcItemFiles", "core/DlcPathUtil", diff --git a/tools/dlc-javafx-manager/build.gradle.kts b/tools/dlc-javafx-manager/build.gradle.kts index d759d074..fbd1bb32 100644 --- a/tools/dlc-javafx-manager/build.gradle.kts +++ b/tools/dlc-javafx-manager/build.gradle.kts @@ -52,7 +52,10 @@ java { } val reusedCommonJavaFiles = listOf( + "com/cozooo/dlc_manager/sync/**", + "com/cozooo/dlc_manager/client/sync/**", "com/cozooo/dlc_manager/core/DlcFileMatch.java", + "com/cozooo/dlc_manager/core/DlcFileSize.java", "com/cozooo/dlc_manager/core/DlcItemFiles.java", "com/cozooo/dlc_manager/core/DlcResourceHistory.java", "com/cozooo/dlc_manager/bootstrap/DlcEarlyInstaller.java", diff --git a/tools/dlc-javafx-manager/src/main/java/com/cozooo/dlc_manager/app/DlcFxManagerApp.java b/tools/dlc-javafx-manager/src/main/java/com/cozooo/dlc_manager/app/DlcFxManagerApp.java index cd49c145..07c075c8 100644 --- a/tools/dlc-javafx-manager/src/main/java/com/cozooo/dlc_manager/app/DlcFxManagerApp.java +++ b/tools/dlc-javafx-manager/src/main/java/com/cozooo/dlc_manager/app/DlcFxManagerApp.java @@ -460,13 +460,21 @@ private Node buildRemoteWorkspace() { return root; } + private boolean syncBusy() { + if (remoteFileManagerTab == null || !remoteFileManagerTab.isSyncBusy()) return false; + DlcFxStyle.setStatus(DlcFxI18n.tr("dlc_manager.sync.busy")); + return true; + } + private void showHome() { + if (syncBusy()) return; currentView = ViewMode.HOME; shellContent.setCenter(homeView); refreshStatusForCurrentView(); } private void showLocalWorkspace() { + if (syncBusy()) return; currentView = ViewMode.LOCAL; shellContent.setCenter(localView); refreshStatusForCurrentView(); @@ -827,6 +835,7 @@ private Node localDirectoryBar() { } private void showLocalConfigOverlay() { + if (syncBusy()) return; if (!localGameDirSelected) { refreshStatusForCurrentView(); return; @@ -1112,6 +1121,10 @@ private boolean isGameDirectory(Path path) { } private void setLocalGameDirectory(Path gameDir, boolean checkRequired, boolean showInvalidDialog) { + if (remoteFileManagerTab != null && remoteFileManagerTab.isSyncBusy()) { + DlcFxStyle.setStatus(DlcFxI18n.tr("dlc_manager.sync.busy")); + return; + } if (gameDir == null) { return; } @@ -1163,7 +1176,7 @@ private Button managerSettingsButton() { button.setMinSize(36, 36); button.setPrefSize(36, 36); button.setMaxSize(36, 36); - button.setOnAction(event -> new DlcFxManagerSettingsOverlay(scene(), PROVIDER, this::rebuildForLanguageChange).show()); + button.setOnAction(event -> { if (!syncBusy()) new DlcFxManagerSettingsOverlay(scene(), PROVIDER, this::rebuildForLanguageChange).show(); }); DlcFxStyle.add(button, "dlc-manager-settings-button"); return button; } @@ -1191,6 +1204,7 @@ private void refreshStatusForCurrentView() { } private void rebuildForLanguageChange() { + if (syncBusy()) return; ViewMode target = currentView; primaryStage.setTitle(DlcFxI18n.tr("dlc_manager.window_title")); if (remoteConnection != null) { diff --git a/tools/dlc-javafx-manager/src/main/java/com/cozooo/dlc_manager/server/DlcFxServerFileManagerTab.java b/tools/dlc-javafx-manager/src/main/java/com/cozooo/dlc_manager/server/DlcFxServerFileManagerTab.java index 8427bee7..69c8e429 100644 --- a/tools/dlc-javafx-manager/src/main/java/com/cozooo/dlc_manager/server/DlcFxServerFileManagerTab.java +++ b/tools/dlc-javafx-manager/src/main/java/com/cozooo/dlc_manager/server/DlcFxServerFileManagerTab.java @@ -186,7 +186,7 @@ public Tab buildSyncTab(java.util.function.Supplier gameDirTextSupplier, java.util.function.BooleanSupplier gameDirSelectedSupplier, Runnable chooseGameDirAction, Runnable readLocalFilesAction) { - syncTab = new DlcFxServerSyncTab(gameDirTextSupplier, gameDirSelectedSupplier, chooseGameDirAction, readLocalFilesAction); + syncTab = new DlcFxServerSyncTab(conn, gameDirTextSupplier, gameDirSelectedSupplier, chooseGameDirAction, readLocalFilesAction); return syncTab.build(); } @@ -211,6 +211,8 @@ private void clearConnectionState(boolean connected) { : DlcFxI18n.tr("dlc_manager.status.disconnected")); } + public boolean isSyncBusy() { return syncTab != null && syncTab.isBusy(); } + public void onStorageContextChanged() { if (syncTab != null) { syncTab.onStorageContextChanged(); @@ -456,12 +458,7 @@ private static String parentPath(String path) { return normalized.substring(0, slash); } - private static String formatSize(long bytes) { - if (bytes < 1024) return bytes + " B"; - if (bytes < 1024 * 1024) return String.format("%.1f KB", bytes / 1024.0); - if (bytes < 1024 * 1024 * 1024) return String.format("%.1f MB", bytes / (1024.0 * 1024)); - return String.format("%.2f GB", bytes / (1024.0 * 1024 * 1024)); - } + private static String formatSize(long bytes) { return com.cozooo.dlc_manager.core.DlcFileSize.format(bytes); } private void showDeleteConfirm(List remotePaths) { DlcFxModalOverlay.show( diff --git a/tools/dlc-javafx-manager/src/main/java/com/cozooo/dlc_manager/server/DlcFxServerSyncTab.java b/tools/dlc-javafx-manager/src/main/java/com/cozooo/dlc_manager/server/DlcFxServerSyncTab.java index bb2e13bd..ebfd4f97 100644 --- a/tools/dlc-javafx-manager/src/main/java/com/cozooo/dlc_manager/server/DlcFxServerSyncTab.java +++ b/tools/dlc-javafx-manager/src/main/java/com/cozooo/dlc_manager/server/DlcFxServerSyncTab.java @@ -1,82 +1,249 @@ package com.cozooo.dlc_manager.server; -import com.cozooo.dlc_manager.ui.common.DlcFxI18n; -import com.cozooo.dlc_manager.ui.common.DlcFxStyle; -import com.cozooo.dlc_manager.ui.common.DlcFxTabFactory; -import com.cozooo.dlc_manager.ui.common.DlcFxTabPage; -import com.cozooo.dlc_manager.ui.common.DlcFxToolbar; +import com.cozooo.dlc_manager.client.server.*; +import com.cozooo.dlc_manager.client.sync.DlcSyncAdapter; +import com.cozooo.dlc_manager.sync.*; +import com.cozooo.dlc_manager.sync.SyncModel.*; +import com.cozooo.dlc_manager.ui.common.*; +import javafx.application.Platform; import javafx.geometry.Pos; -import javafx.scene.Node; -import javafx.scene.control.Button; -import javafx.scene.control.Label; -import javafx.scene.control.Tab; -import javafx.scene.layout.VBox; - -import java.util.function.BooleanSupplier; -import java.util.function.Supplier; +import javafx.scene.control.*; +import javafx.scene.layout.*; +import java.util.*; +import java.util.function.*; public final class DlcFxServerSyncTab { + private final DlcFxServerConnection connection; + private final Supplier gameDir; + private final BooleanSupplier selectedGame; + private final Runnable chooseDirectory, readLocal; + private final Label directory = new Label(), status = new Label(); + private final ComboBox mode = new ComboBox<>(), direction = new ComboBox<>(); + private final CheckBox settings = new CheckBox(tr("manager_config")); + private final CheckBox states = new CheckBox(tr("states")), selectAll = new CheckBox(tr("select_all")); + private final HBox selectionHeader = DlcFxActionPanel.row(selectAll); + private final Button compare = new Button(tr("compare")), execute = new Button(tr("execute")); + private final Button rollback = new Button(tr("rollback")); + private volatile SyncClient rollbackClient; + private DlcServerEntry rollbackEntry; + private volatile String failureReason; + private final ProgressBar progress = new ProgressBar(-1); + private final ListView list = new ListView<>(); + private final Set selected = new HashSet<>(); + private List local = List.of(), remote = List.of(); + private List rows = List.of(); + private volatile SyncClient active; + private volatile AutoCloseable connecting; + private volatile boolean cancelRequested; + private volatile DlcServerSession previewSession; + private boolean busy, syncing, loaded; + private Category category = Category.ALL; + private final VBox types = new VBox(5); - private final Supplier gameDirTextSupplier; - private final BooleanSupplier gameDirSelectedSupplier; - private final Runnable chooseGameDirAction; - private final Runnable readLocalFilesAction; - private final Label gameDirLabel = new Label(); - private final Button readLocalButton = new Button(DlcFxI18n.tr("dlc_manager.server.read_local_files")); - private final Button chooseDirButton = new Button(DlcFxI18n.tr("dlc_manager.local.choose_game_dir")); - - public DlcFxServerSyncTab(Supplier gameDirTextSupplier, - BooleanSupplier gameDirSelectedSupplier, - Runnable chooseGameDirAction, - Runnable readLocalFilesAction) { - this.gameDirTextSupplier = gameDirTextSupplier == null ? () -> "" : gameDirTextSupplier; - this.gameDirSelectedSupplier = gameDirSelectedSupplier == null ? () -> false : gameDirSelectedSupplier; - this.chooseGameDirAction = chooseGameDirAction == null ? () -> {} : chooseGameDirAction; - this.readLocalFilesAction = readLocalFilesAction == null ? () -> {} : readLocalFilesAction; + public DlcFxServerSyncTab(DlcFxServerConnection connection, Supplier gameDir, BooleanSupplier selectedGame, Runnable chooseDirectory, Runnable readLocal) { + this.connection = connection; + this.gameDir = gameDir; + this.selectedGame = selectedGame; + this.chooseDirectory = chooseDirectory; + this.readLocal = readLocal; } - + private static String tr(String key, Object... args) { return DlcFxI18n.tr("dlc_manager.sync." + key, args); } public Tab build() { DlcFxTabPage page = new DlcFxTabPage(""); - - chooseDirButton.setOnAction(e -> chooseGameDirAction.run()); - DlcFxStyle.primary(chooseDirButton); - readLocalButton.setOnAction(e -> readLocalFilesAction.run()); - - refreshState(); - - Node top = DlcFxToolbar.create( - new Label(DlcFxI18n.tr("dlc_manager.server.local_source")), - gameDirLabel, - DlcFxToolbar.spacer(), - chooseDirButton, - readLocalButton - ); - page.setTop(top); - - Label hint = new Label(DlcFxI18n.tr("dlc_manager.server.sync_hint")); - DlcFxStyle.muted(hint); - VBox center = new VBox(10, hint); - center.setAlignment(Pos.TOP_LEFT); - page.setCenter(page.section(center)); - - return DlcFxTabFactory.create(DlcFxI18n.tr("dlc_manager.tab.sync"), page.root()); + mode.getItems().setAll(tr("mode_config"), tr("mode_files")); mode.getSelectionModel().select(0); + states.setSelected(true); + direction.getItems().setAll(tr("push"), tr("pull")); direction.getSelectionModel().select(0); + mode.setOnAction(e -> replan()); direction.setOnAction(e -> replan()); states.setOnAction(e -> replan()); + Button choose = new Button(DlcFxI18n.tr("dlc_manager.local.choose_game_dir")); choose.setOnAction(e -> { if (!busy) chooseDirectory.run(); }); + Button read = new Button(DlcFxI18n.tr("dlc_manager.server.read_local_files")); read.setOnAction(e -> { if (!busy) readLocal.run(); }); + page.setTop(new VBox(10, DlcFxToolbar.create(directory, DlcFxToolbar.spacer(), choose, read), DlcFxToolbar.create(direction, DlcFxToolbar.spacer(), compare))); + types.getStyleClass().add("dlc-config-tabs"); types.setPrefWidth(172); + for (Category type : Category.values()) { + if (type == Category.MANAGER_CONFIG) continue; + Button button = new Button(tr("type_" + type.name().toLowerCase(Locale.ROOT))); + button.setMaxWidth(Double.MAX_VALUE); button.setUserData(type); button.getStyleClass().add("dlc-config-tab-button"); + button.setOnAction(event -> { if (!busy) { category = type; list.getItems().setAll(visibleRows()); refreshState(); } }); + types.getChildren().add(button); + } + page.root().setLeft(types); + list.setPlaceholder(new Label(tr("differences"))); + list.setCellFactory(view -> new ListCell<>() { + @Override protected void updateItem(Row row, boolean empty) { + super.updateItem(row, empty); + if (empty || row == null) { setGraphic(null); return; } + CheckBox check = new CheckBox(SyncModel.displayName(row.source(), com.cozooo.dlc_manager.platform.DlcPlatformServices.languageCode())); + check.setSelected(selected.contains(row.key())); check.setDisable(busy); + Runnable change = () -> { if (!selected.add(row.key())) selected.remove(row.key()); refreshState(); }; + check.setOnAction(e -> change.run()); + HBox.setHgrow(check, Priority.ALWAYS); check.setMaxWidth(Double.MAX_VALUE); + Label difference = new Label(tr(SyncModel.status(row, mode.getSelectionModel().getSelectedIndex() == 0 ? Mode.CONFIG : Mode.FILES))); + DlcFxStyle.muted(difference); + HBox line = new HBox(12, check, difference); line.setAlignment(Pos.CENTER_LEFT); + selectOnRow(line, check, change); + String language = com.cozooo.dlc_manager.platform.DlcPlatformServices.languageCode(); + DlcFxTooltips.install(line, DlcFxTooltips.formatted(SyncModel.displayName(row.source(), language) + "\n" + SyncModel.tooltip(row.source(), language) + "\n" + difference.getText())); + setGraphic(line); + } + }); + status.setWrapText(true); + progress.setMaxWidth(Double.MAX_VALUE); + Runnable selectEverything = () -> { + List shown = visibleRows(); boolean all = shown.stream().allMatch(row -> selected.contains(row.key())); + for (Row row : shown) { if (all) selected.remove(row.key()); else selected.add(row.key()); } + refreshState(); + }; + selectAll.setOnAction(e -> selectEverything.run()); selectAll.setMaxWidth(Double.MAX_VALUE); HBox.setHgrow(selectAll, Priority.ALWAYS); + selectOnRow(selectionHeader, selectAll, selectEverything); + VBox body = new VBox(12, selectionHeader, list, status, progress); VBox.setVgrow(list, Priority.ALWAYS); page.setCenter(page.section(body)); + page.setBottom(DlcFxActionPanel.panel(DlcFxActionPanel.row(mode, states, settings, DlcFxActionPanel.spacer(), rollback, execute))); + settings.setOnAction(e -> refreshState()); + compare.setOnAction(e -> compare()); execute.setOnAction(e -> { if (syncing) cancel(); else confirm(); }); + rollback.setOnAction(e -> rollback()); DlcFxStyle.danger(rollback); + DlcFxStyle.primary(execute); refreshState(); + return DlcFxTabFactory.create(tr("title"), page.root()); } - - public void onStorageContextChanged() { - refreshState(); + private void selectOnRow(HBox line, CheckBox checkbox, Runnable change) { + line.setOnMousePressed(event -> { + if (busy || event.getButton() != javafx.scene.input.MouseButton.PRIMARY) return; + if (event.getTarget() instanceof javafx.scene.Node node) { + for (javafx.scene.Node parent = node; parent != null; parent = parent.getParent()) if (parent == checkbox) return; + } + change.run(); event.consume(); + }); + } + private Peer peer(DlcServerSession session) { + return new Peer() { + public com.google.gson.JsonObject call(com.google.gson.JsonObject request) throws java.io.IOException { + try { return session.syncRequest(request); } catch (java.io.IOException error) { session.close(); throw error; } + } + public void close() throws java.io.IOException { session.close(); } + }; + } + private Peer control(DlcServerEntry entry) { return request -> { try (DlcServerSession session = DlcServerSession.connect(entry, 10000)) { return peer(session).call(request); } }; } + private void compare() { + if (busy) return; + loaded = false; local = List.of(); remote = List.of(); rows = List.of(); selected.clear(); list.getItems().clear(); + DlcServerEntry entry = connection.currentEntry(); + run(() -> { + List left, right; + try (SyncEndpoint endpoint = new SyncEndpoint(new DlcSyncAdapter()); DlcServerSession session = connect(entry)) { + previewSession = session; + left = SyncClient.manifest(endpoint, () -> cancelRequested); + right = SyncClient.manifest(peer(session), () -> cancelRequested); + } finally { previewSession = null; } + Platform.runLater(() -> { local = left; remote = right; loaded = true; status.setText(""); replan(); }); + }); + } + private void replan() { + rows = SyncModel.plan(local, remote, direction.getSelectionModel().getSelectedIndex() == 0 ? Direction.PUSH : Direction.PULL, states.isSelected(), mode.getSelectionModel().getSelectedIndex() == 0 ? Mode.CONFIG : Mode.FILES); + selected.clear(); settings.setSelected(false); + list.getItems().setAll(visibleRows()); refreshState(); + } + private void confirm() { + if (busy) return; + List chosen = selectedRows(); + if (chosen.isEmpty()) return; + DlcFxModalOverlay.show(execute.getScene(), tr("title"), tr("confirm", chosen.size()), () -> execute(chosen), DlcFxI18n.tr("dlc_manager.confirm_yes"), () -> {}, DlcFxI18n.tr("dlc_manager.confirm_no")); + } + private void execute(List chosen) { + DlcServerEntry entry = connection.currentEntry(); + Mode selectedMode = mode.getSelectionModel().getSelectedIndex() == 0 ? Mode.CONFIG : Mode.FILES; + boolean syncStates = states.isSelected(); + run(() -> { + rollbackClient = null; + try (SyncEndpoint endpoint = new SyncEndpoint(new DlcSyncAdapter()); DlcServerSession session = connect(entry)) { + SyncClient client = new SyncClient(endpoint, peer(session), control(entry)); active = client; rollbackClient = client; rollbackEntry = entry; + if (cancelRequested) client.cancel(); + Result result = client.execute(chosen, selectedMode, syncStates, (phase, resource, done, total) -> Platform.runLater(() -> { progress.setProgress(total > 0 && !phase.equals("restoring") ? done / (double) total : -1); String amount = phase.equals("transferring") || phase.equals("downloading") ? com.cozooo.dlc_manager.core.DlcFileSize.formatProgress(done, total) : done + (total >= 0 ? "/" + total : ""); status.setText(tr(phase) + " " + resource.substring(resource.lastIndexOf('/') + 1) + " " + amount); })); + String message = tr(result.canceled() ? "canceled" : "finished", result.completed(), result.errors().size()) + (result.requiresRestart() ? " " + tr("restart") : "") + "\n" + String.join("\n", result.succeeded().stream().map(key -> tr("succeeded_item", key)).toList()) + "\n" + String.join("\n", result.errors()); + String updates = chosen.stream().anyMatch(row -> SyncModel.category(row.source()) == Category.UPDATE && result.succeeded().contains(row.key())) ? "\n" + tr("updates_ready") : ""; + if (!result.errors().isEmpty() && !result.canceled()) failureReason = String.join("\n", result.errors()); + if (result.canceled()) { Platform.runLater(() -> status.setText(message)); return; } + List left = SyncClient.manifest(endpoint, () -> cancelRequested), right = SyncClient.manifest(peer(session), () -> cancelRequested); + Platform.runLater(() -> { status.setText(message + updates); local = left; remote = right; replan(); }); + } finally { active = null; } + }, true); } - public void onTabSelected() { + private void rollback() { + SyncClient client = rollbackClient; + if (busy || client == null || !client.canRollback()) return; + DlcServerEntry entry = rollbackEntry; + run(() -> { + Platform.runLater(() -> status.setText(tr("restoring"))); + try (SyncEndpoint endpoint = new SyncEndpoint(new DlcSyncAdapter()); DlcServerSession session = connect(entry)) { + client.rollback(endpoint, peer(session)); + List left = SyncClient.manifest(endpoint), right = SyncClient.manifest(peer(session)); + Platform.runLater(() -> { local = left; remote = right; status.setText(tr("restored")); replan(); }); + } + }); + } + private void showFailure(String reason) { + Label message = new Label(tr("failure_choice", reason)); message.setWrapText(true); message.setPrefWidth(520); + StackPane overlay = DlcFxModalOverlay.showAndGetOverlay(execute.getScene(), tr("failed"), message, () -> {}, tr("keep"), this::rollback, tr("rollback"), null); + if (overlay != null) for (javafx.scene.Node node : overlay.lookupAll(".button")) { + if (node instanceof Button button && button.getText().equals(tr("rollback"))) DlcFxStyle.danger(button); + } + } + private DlcServerSession connect(DlcServerEntry entry) throws java.io.IOException { + return DlcServerSession.connect(entry, 120000, socket -> { + connecting = socket; + if (cancelRequested) try { socket.close(); } catch (Exception ignored) {} + }); + } + private void cancel() { + if (!busy || cancelRequested) return; + cancelRequested = true; + if (active != null) { if (!active.cancel()) { cancelRequested = false; return; } status.setText(tr("restoring")); progress.setProgress(-1); } + else try { if (connecting != null) connecting.close(); } catch (Exception ignored) {} refreshState(); } - + private Row settingsRow() { return rows.stream().filter(row -> SyncModel.category(row.source()) == Category.MANAGER_CONFIG).findFirst().orElse(null); } + private List visibleRows() { return rows.stream().filter(row -> SyncModel.category(row.source()) != Category.MANAGER_CONFIG && (category == Category.ALL || SyncModel.category(row.source()) == category)).toList(); } + private List selectedRows() { + List chosen = new ArrayList<>(visibleRows().stream().filter(row -> selected.contains(row.key())).toList()); + if (settings.isSelected() && settingsRow() != null) chosen.add(settingsRow()); + return chosen; + } private void refreshState() { - boolean selected = gameDirSelectedSupplier.getAsBoolean(); - gameDirLabel.setText(selected ? safeText(gameDirTextSupplier.get()) : DlcFxI18n.tr("dlc_manager.local.no_game_dir")); - readLocalButton.setDisable(!selected); + directory.setText(gameDir.get()); + list.setVisible(!busy || syncing); list.setManaged(!busy || syncing); + list.setPlaceholder(new Label(tr(loaded ? "empty" : "differences"))); + List shown = visibleRows(); long selectedCount = shown.stream().filter(row -> selected.contains(row.key())).count(); + for (javafx.scene.Node node : types.getChildren()) { + node.setDisable(busy); node.getStyleClass().remove("selected"); + if (node.getUserData() == category) node.getStyleClass().add("selected"); + } + boolean showSelection = (!busy || syncing) && !shown.isEmpty(); + selectionHeader.setVisible(showSelection); selectionHeader.setManaged(showSelection); + selectAll.setDisable(busy || shown.isEmpty()); selectAll.setSelected(!shown.isEmpty() && selectedCount == shown.size()); + selectAll.setIndeterminate(selectedCount > 0 && selectedCount != shown.size()); + progress.setVisible(busy); progress.setManaged(busy); + boolean ready = selectedGame.getAsBoolean() && connection.isConnected(); + compare.setDisable(busy || !ready); + boolean canRollback = rollbackClient != null && rollbackClient.canRollback(); + rollback.setVisible(canRollback); rollback.setManaged(canRollback); rollback.setDisable(busy); + execute.setText(tr(syncing ? "cancel" : "execute")); + execute.getStyleClass().removeAll(DlcFxStyle.PRIMARY, DlcFxStyle.DANGER); + if (syncing) DlcFxStyle.danger(execute); else DlcFxStyle.primary(execute); + execute.setDisable(busy ? !syncing || cancelRequested : !ready || selectedRows().isEmpty()); + boolean stateDifferences = SyncModel.hasStateDifferences(local, remote); states.setVisible(stateDifferences); states.setManaged(stateDifferences); + settings.setVisible(settingsRow() != null); settings.setManaged(settingsRow() != null); settings.setDisable(busy); + mode.setDisable(busy); direction.setDisable(busy); states.setDisable(busy); list.refresh(); } - - private static String safeText(String text) { - return text == null ? "" : text; + public boolean isBusy() { return busy; } + public void onStorageContextChanged() { if (busy) cancel(); else { rollbackClient = null; rollbackEntry = null; loaded = false; local = List.of(); remote = List.of(); replan(); } } + public void onTabSelected() { refreshState(); } + @FunctionalInterface private interface Work { void run() throws Exception; } + private void run(Work work) { run(work, false); } + private void run(Work work, boolean synchronizing) { + if (busy) return; + failureReason = null; cancelRequested = false; syncing = synchronizing; busy = true; progress.setProgress(-1); + status.setText(tr("working")); refreshState(); + Thread thread = new Thread(() -> { + try { work.run(); } catch (Exception error) { String reason = error.getMessage() == null ? error.getClass().getSimpleName() : error.getMessage(); if (synchronizing && !cancelRequested) failureReason = failureReason == null ? reason : failureReason + "\n" + reason; Platform.runLater(() -> status.setText(cancelRequested && (error instanceof java.net.SocketException || SyncFailure.is(error, "canceled")) ? tr("canceled") : error.getMessage() == null ? error.getClass().getSimpleName() : error.getMessage())); } + finally { Platform.runLater(() -> { busy = false; syncing = false; connecting = null; refreshState(); if (failureReason != null) showFailure(failureReason); }); } + }, "dlc-sync"); + thread.setDaemon(true); thread.start(); } } diff --git a/tools/dlc-javafx-manager/src/main/resources/assets/dlc_manager/lang/en_us.json b/tools/dlc-javafx-manager/src/main/resources/assets/dlc_manager/lang/en_us.json index d9ea6ed5..e6ec146b 100644 --- a/tools/dlc-javafx-manager/src/main/resources/assets/dlc_manager/lang/en_us.json +++ b/tools/dlc-javafx-manager/src/main/resources/assets/dlc_manager/lang/en_us.json @@ -257,7 +257,7 @@ "dlc_manager.tab.pack": "DLC Pack", "dlc_manager.tab.preset": "Presets", "dlc_manager.tab.remote": "DLC Completion", - "dlc_manager.tab.sync": "Sync", + "dlc_manager.tab.sync": "DLC Sync", "dlc_manager.tab.update": "Hot Update", "dlc_manager.tooltip.conflicts": "Conflicts: ", "dlc_manager.tooltip.curseforge": "CurseForge: ", @@ -553,7 +553,7 @@ "dlc_manager.config.comment.download.maxConcurrentDownloads": "Count. Range: 1 to 64", "dlc_manager.config.comment.download.maxRetryCount": "Times. Range: 1 to 100", "dlc_manager.config.comment.download.maxSegmentsPerFile": "Segments. Range: 1 to 64", - "dlc_manager.config.comment.download.minSegmentMB": "Unit: MB. Range: 1 to 1024", + "dlc_manager.config.comment.download.minSegmentMB": "Unit: MiB. Range: 1 to 1024", "dlc_manager.config.comment.download.taskTimeoutSeconds": "Unit: seconds. Range: 10 to 600", "dlc_manager.config.comment.modpack.modpack": "Current modpack identifier used by DLC Pack modpack compatibility checks.", "dlc_manager.config.comment.modpack.modpackUrl": "Modpack home page URL (used by the update page jump button).", @@ -619,7 +619,7 @@ "dlc_manager.config.download.max_concurrent": "Max concurrent downloads", "dlc_manager.config.download.max_retry": "Retry attempts on failure", "dlc_manager.config.download.max_segments": "Max segments per file", - "dlc_manager.config.download.minSegmentMB": "Min segment size (MB)", + "dlc_manager.config.download.minSegmentMB": "Min segment size (MiB)", "dlc_manager.config.download.min_segment_bytes": "Min segment size (bytes)", "dlc_manager.config.download.taskTimeoutSeconds": "Single download timeout (s)", "dlc_manager.config.download.task_timeout": "Single download timeout (s)", @@ -745,5 +745,135 @@ "dlc_manager.update.ignore_unknown": "Ignore and force update", "dlc_manager.update.update_manager_retry": "Update DLC Manager and retry", "dlc_manager.update.pending_restart": "DLC Manager is ready. Restart the game to continue the modpack update.", - "dlc_manager.update.organizing_resources": "Organizing hot update resources..." + "dlc_manager.update.organizing_resources": "Organizing hot update resources...", + "dlc_manager.sync.title": "DLC Sync", + "dlc_manager.sync.mode_config": "Configuration only", + "dlc_manager.sync.mode_files": "Configuration and files", + "dlc_manager.sync.states": "Sync DLC and preset enabled states", + "dlc_manager.sync.push": "Client → Server", + "dlc_manager.sync.pull": "Server → Client", + "dlc_manager.sync.compare": "Compare", + "dlc_manager.sync.execute": "Synchronize", + "dlc_manager.sync.cancel": "Cancel and undo", + "dlc_manager.sync.server_missing": "Missing on server", + "dlc_manager.sync.client_missing": "Missing on client", + "dlc_manager.sync.content_changed": "Content differs", + "dlc_manager.sync.state_changed": "Enabled state differs", + "dlc_manager.sync.type_all": "All", + "dlc_manager.sync.type_dlc": "DLC", + "dlc_manager.sync.type_required": "Required resources", + "dlc_manager.sync.type_update": "Hot updates", + "dlc_manager.sync.manager_config": "Sync manager configuration", + "dlc_manager.sync.type_preset": "Presets", + "dlc_manager.sync.select_all": "Select all", + "dlc_manager.sync.empty": "No differences to synchronize", + "dlc_manager.sync.differences": "Differences", + "dlc_manager.sync.replacement": "Replacement — select to confirm", + "dlc_manager.sync.addition": "New content", + "dlc_manager.sync.enable": "Enable DLC", + "dlc_manager.sync.disable": "Disable DLC", + "dlc_manager.sync.confirm": "Synchronize %s selected DLC entries? Existing content will be backed up before replacement.", + "dlc_manager.sync.working": "Reading DLC information…", + "dlc_manager.sync.preparing": "Preparing resources…", + "dlc_manager.sync.downloading": "Downloading…", + "dlc_manager.sync.transferring": "Transferring…", + "dlc_manager.sync.applying": "Applying…", + "dlc_manager.sync.restoring": "Undoing synchronization…", + "dlc_manager.sync.canceled": "Synchronization canceled and all changes restored.", + "dlc_manager.sync.rollback": "Undo sync", + "dlc_manager.sync.keep": "Keep changes", + "dlc_manager.sync.error.server_downloads_stop": "Could not stop server downloads. Please try again later.", + "dlc_manager.sync.error.busy": "DLC synchronization is in progress", + "dlc_manager.sync.error.active": "Synchronization is active", + "dlc_manager.sync.error.already_active": "Synchronization is already active", + "dlc_manager.sync.error.other_active": "Another synchronization is active", + "dlc_manager.sync.error.other_operation": "Another DLC operation is in progress", + "dlc_manager.sync.error.settings_save": "Cannot save manager configuration", + "dlc_manager.sync.error.preset_state_save": "Cannot save preset state: %s", + "dlc_manager.sync.error.recovery_save": "Cannot save synchronization recovery state", + "dlc_manager.sync.error.comparison_canceled": "Comparison canceled", + "dlc_manager.sync.error.comparison_not_started": "Comparison has not started", + "dlc_manager.sync.error.comparison_interrupted": "Comparison interrupted", + "dlc_manager.sync.error.validation_stop": "Could not stop resource validation; retry recovery", + "dlc_manager.sync.error.changed": "DLC changed after comparison: %s", + "dlc_manager.sync.error.config_changed": "DLC configuration changed after comparison: %s", + "dlc_manager.sync.error.config_missing": "DLC configuration is missing", + "dlc_manager.sync.error.config_not_found": "DLC configuration not found: %s", + "dlc_manager.sync.error.apply": "DLC resource cannot be applied", + "dlc_manager.sync.error.read_not_open": "DLC resource has not been opened", + "dlc_manager.sync.error.read_open": "DLC resource is already open", + "dlc_manager.sync.error.root_missing": "DLC root is unavailable", + "dlc_manager.sync.error.verification": "DLC verification failed: %s", + "dlc_manager.sync.error.outside_roots": "File is outside synchronization roots", + "dlc_manager.sync.error.outside_item": "File is outside the selected DLC: %s", + "dlc_manager.sync.error.update_version": "Hot update package cannot be applied from the target version", + "dlc_manager.sync.error.update_versions_missing": "Hot update package is missing versions: %s", + "dlc_manager.sync.error.update_mode": "Hot updates require configuration and files mode", + "dlc_manager.sync.error.invalid_path": "Invalid DLC path", + "dlc_manager.sync.error.invalid_resource": "Invalid DLC resource", + "dlc_manager.sync.error.invalid_root": "Invalid DLC root", + "dlc_manager.sync.error.invalid_update": "Invalid hot update package: %s", + "dlc_manager.sync.error.invalid_remote": "Invalid remote DLC configuration", + "dlc_manager.sync.error.invalid_required": "Invalid required resource configuration", + "dlc_manager.sync.error.invalid_archive_path": "Invalid resource archive path", + "dlc_manager.sync.error.invalid_directory": "Invalid resource directory", + "dlc_manager.sync.error.invalid_offset": "Invalid resource offset", + "dlc_manager.sync.error.invalid_size": "Invalid resource size", + "dlc_manager.sync.error.invalid_reuse_path": "Invalid reusable resource path", + "dlc_manager.sync.error.invalid_item": "Invalid synchronization item", + "dlc_manager.sync.error.invalid_recovery": "Invalid synchronization recovery state: %s", + "dlc_manager.sync.error.invalid_response": "Invalid synchronization response", + "dlc_manager.sync.error.settings_fields": "Manager configuration requires field synchronization", + "dlc_manager.sync.error.settings_unavailable": "Manager configuration synchronization is unavailable", + "dlc_manager.sync.error.settings_missing": "Manager configuration values are missing", + "dlc_manager.sync.error.resource_missing": "Missing DLC resource: %s", + "dlc_manager.sync.error.no_item": "No DLC item selected", + "dlc_manager.sync.error.no_upload": "No resource upload", + "dlc_manager.sync.error.not_settings": "Not a manager configuration", + "dlc_manager.sync.error.not_config": "Not a synchronizable DLC configuration", + "dlc_manager.sync.error.preset_missing": "Preset configuration is unavailable: %s", + "dlc_manager.sync.error.preset_blocked": "Preset is blocked: %s", + "dlc_manager.sync.error.presets_disabled": "Presets are disabled on the destination", + "dlc_manager.sync.error.previous_unfinished": "Previous DLC item is unfinished", + "dlc_manager.sync.error.recovery_incomplete": "Recovery incomplete; backups retained: %s", + "dlc_manager.sync.error.recovery_interrupted": "Recovery interrupted", + "dlc_manager.sync.error.remote_missing": "Remote DLC resource is missing", + "dlc_manager.sync.error.archive_escape": "Resource archive escapes DLC directory", + "dlc_manager.sync.error.archive_size": "Resource archive size mismatch", + "dlc_manager.sync.error.directory_integrity": "Resource directory integrity check failed", + "dlc_manager.sync.error.resource_escape": "Resource escapes DLC directory", + "dlc_manager.sync.error.filename_missing": "Resource filename is unavailable", + "dlc_manager.sync.error.resource_integrity": "Resource integrity check failed", + "dlc_manager.sync.error.outside_root": "Resource is outside DLC root", + "dlc_manager.sync.error.resource_size": "Resource size mismatch", + "dlc_manager.sync.error.reused_changed": "Reusable resource changed: %s", + "dlc_manager.sync.error.source_changed": "Source changed after comparison: %s", + "dlc_manager.sync.error.source_changed_transfer": "Source changed during transfer: %s", + "dlc_manager.sync.error.symbolic_path": "Symbolic DLC paths are not supported", + "dlc_manager.sync.error.symbolic_resource": "Symbolic DLC resources are not supported", + "dlc_manager.sync.error.symbolic_settings": "Symbolic manager configuration paths are not supported", + "dlc_manager.sync.error.symbolic_file": "Symbolic synchronization files are not supported", + "dlc_manager.sync.error.canceled": "Synchronization canceled", + "dlc_manager.sync.error.connection_lost": "Synchronization connection lost", + "dlc_manager.sync.error.file_missing": "Synchronization file is missing: %s", + "dlc_manager.sync.error.not_started": "Synchronization has not started", + "dlc_manager.sync.error.truncated": "Truncated DLC resource: %s", + "dlc_manager.sync.error.unfinished_item": "Unfinished DLC item", + "dlc_manager.sync.error.unfinished_upload": "Unfinished resource upload", + "dlc_manager.sync.error.unsupported_action": "Unsupported DLC sync action", + "dlc_manager.sync.error.validation_interrupted": "Validation interrupted", + "dlc_manager.sync.error.unsupported": "Server does not support DLC sync", + "dlc_manager.sync.failed": "Synchronization failed", + "dlc_manager.sync.failure_choice": "%s\n\nChanges have been kept. Undo this synchronization or keep the current state?", + "dlc_manager.sync.restored": "Synchronization undone.", + "dlc_manager.sync.finished": "Synchronization completed: %s succeeded, %s failed.", + "dlc_manager.sync.updates_ready": "Hot update packages transferred. Open the hot update manager on the destination to complete the updates.", + "dlc_manager.sync.restart": "Restart to finish applying these changes.", + "dlc_manager.server.push_title": "DLC Sync", + "dlc_manager.sync.busy": "Finish or cancel DLC synchronization before switching game directories.", + "dlc_manager.sync.configuration_missing": "Configuration missing", + "dlc_manager.sync.configuration_changed": "Configuration differs", + "dlc_manager.sync.resources_missing": "Missing resources", + "dlc_manager.sync.resources_changed": "Resources differ", + "dlc_manager.sync.succeeded_item": "Synced: %s" } diff --git a/tools/dlc-javafx-manager/src/main/resources/assets/dlc_manager/lang/zh_cn.json b/tools/dlc-javafx-manager/src/main/resources/assets/dlc_manager/lang/zh_cn.json index e35e7a75..8f3dca51 100644 --- a/tools/dlc-javafx-manager/src/main/resources/assets/dlc_manager/lang/zh_cn.json +++ b/tools/dlc-javafx-manager/src/main/resources/assets/dlc_manager/lang/zh_cn.json @@ -257,7 +257,7 @@ "dlc_manager.tab.pack": "拓展包", "dlc_manager.tab.preset": "预设", "dlc_manager.tab.remote": "补全DLC", - "dlc_manager.tab.sync": "同步", + "dlc_manager.tab.sync": "DLC 同步", "dlc_manager.tab.update": "热更新", "dlc_manager.tooltip.conflicts": "冲突:", "dlc_manager.tooltip.curseforge": "CurseForge:", @@ -553,7 +553,7 @@ "dlc_manager.config.comment.download.maxConcurrentDownloads": "数量,范围 1~64", "dlc_manager.config.comment.download.maxRetryCount": "次数,范围 1~100", "dlc_manager.config.comment.download.maxSegmentsPerFile": "分段数,范围 1~64", - "dlc_manager.config.comment.download.minSegmentMB": "单位:MB,范围 1~1024", + "dlc_manager.config.comment.download.minSegmentMB": "单位:MiB,范围 1~1024", "dlc_manager.config.comment.download.taskTimeoutSeconds": "单位:秒,范围 10~600", "dlc_manager.config.comment.modpack.modpack": "当前整合包标识,用于拓展包的 modpack 条件检测", "dlc_manager.config.comment.modpack.modpackUrl": "整合包主页链接(用于更新页跳转)", @@ -619,7 +619,7 @@ "dlc_manager.config.download.max_concurrent": "最大同时下载数", "dlc_manager.config.download.max_retry": "下载失败重试次数", "dlc_manager.config.download.max_segments": "单文件最大分段数", - "dlc_manager.config.download.minSegmentMB": "分段最小大小(MB)", + "dlc_manager.config.download.minSegmentMB": "分段最小大小(MiB)", "dlc_manager.config.download.min_segment_bytes": "最小分段大小(字节)", "dlc_manager.config.download.taskTimeoutSeconds": "单次下载超时(秒)", "dlc_manager.config.download.task_timeout": "单次下载超时(秒)", @@ -745,5 +745,135 @@ "dlc_manager.update.ignore_unknown": "忽略并强制更新", "dlc_manager.update.update_manager_retry": "更新 DLC 管理器并尝试更新", "dlc_manager.update.pending_restart": "DLC 管理器已准备就绪。请重启游戏以继续完成整合包更新。", - "dlc_manager.update.organizing_resources": "正在整理热更新资源..." + "dlc_manager.update.organizing_resources": "正在整理热更新资源...", + "dlc_manager.sync.title": "DLC 同步", + "dlc_manager.sync.mode_config": "仅同步配置", + "dlc_manager.sync.mode_files": "同步配置和文件", + "dlc_manager.sync.states": "同步 DLC 和预设启用状态", + "dlc_manager.sync.push": "客户端 → 服务端", + "dlc_manager.sync.pull": "服务端 → 客户端", + "dlc_manager.sync.compare": "对比", + "dlc_manager.sync.execute": "开始同步", + "dlc_manager.sync.cancel": "取消并撤回", + "dlc_manager.sync.server_missing": "服务端缺失", + "dlc_manager.sync.client_missing": "客户端缺失", + "dlc_manager.sync.content_changed": "内容不同", + "dlc_manager.sync.state_changed": "开启状态不同", + "dlc_manager.sync.type_all": "全部", + "dlc_manager.sync.type_dlc": "DLC", + "dlc_manager.sync.type_required": "必要资源", + "dlc_manager.sync.type_update": "热更新", + "dlc_manager.sync.manager_config": "同步管理器配置", + "dlc_manager.sync.type_preset": "预设", + "dlc_manager.sync.select_all": "全选", + "dlc_manager.sync.empty": "没有需要同步的内容", + "dlc_manager.sync.differences": "差异内容", + "dlc_manager.sync.replacement": "替换已有内容,需勾选确认", + "dlc_manager.sync.addition": "新增内容", + "dlc_manager.sync.enable": "开启 DLC", + "dlc_manager.sync.disable": "关闭 DLC", + "dlc_manager.sync.confirm": "同步选中的 %s 个 DLC 条目?替换前会备份已有内容。", + "dlc_manager.sync.working": "正在读取 DLC 信息…", + "dlc_manager.sync.preparing": "正在准备资源…", + "dlc_manager.sync.downloading": "正在下载…", + "dlc_manager.sync.transferring": "正在传输…", + "dlc_manager.sync.applying": "正在应用…", + "dlc_manager.sync.restoring": "正在撤回同步…", + "dlc_manager.sync.canceled": "已取消同步并撤回全部改动。", + "dlc_manager.sync.rollback": "回退", + "dlc_manager.sync.keep": "保留", + "dlc_manager.sync.error.server_downloads_stop": "无法停止服务端正在进行的下载,请稍后重试。", + "dlc_manager.sync.error.busy": "正在同步 DLC,请等待同步结束。", + "dlc_manager.sync.error.active": "同步仍在进行,请稍后重试。", + "dlc_manager.sync.error.already_active": "已经有同步任务正在运行。", + "dlc_manager.sync.error.other_active": "另一个同步任务正在运行。", + "dlc_manager.sync.error.other_operation": "另一个 DLC 操作正在进行,请稍后重试。", + "dlc_manager.sync.error.settings_save": "无法保存管理器配置。", + "dlc_manager.sync.error.preset_state_save": "无法保存预设状态:%s", + "dlc_manager.sync.error.recovery_save": "无法保存同步备份记录。", + "dlc_manager.sync.error.comparison_canceled": "已取消读取 DLC 信息。", + "dlc_manager.sync.error.comparison_not_started": "尚未开始读取 DLC 信息。", + "dlc_manager.sync.error.comparison_interrupted": "读取 DLC 信息已中断。", + "dlc_manager.sync.error.validation_stop": "无法停止资源校验,请稍后重试回退。", + "dlc_manager.sync.error.changed": "比较后 DLC 已发生变化,请重新比较:%s", + "dlc_manager.sync.error.config_changed": "比较后 DLC 配置已发生变化,请重新比较:%s", + "dlc_manager.sync.error.config_missing": "DLC 配置缺失。", + "dlc_manager.sync.error.config_not_found": "未找到 DLC 配置:%s", + "dlc_manager.sync.error.apply": "无法应用 DLC 资源。", + "dlc_manager.sync.error.read_not_open": "尚未开始读取 DLC 资源。", + "dlc_manager.sync.error.read_open": "DLC 资源正在读取中。", + "dlc_manager.sync.error.root_missing": "DLC 根目录不可用。", + "dlc_manager.sync.error.verification": "DLC 校验失败:%s", + "dlc_manager.sync.error.outside_roots": "文件位于允许同步的目录之外。", + "dlc_manager.sync.error.outside_item": "文件不属于所选 DLC:%s", + "dlc_manager.sync.error.update_version": "热更新包不适用于目标端的当前版本。", + "dlc_manager.sync.error.update_versions_missing": "热更新包缺少版本信息:%s", + "dlc_manager.sync.error.update_mode": "热更新包只能在“同步配置和文件”模式下同步。", + "dlc_manager.sync.error.invalid_path": "DLC 路径无效。", + "dlc_manager.sync.error.invalid_resource": "DLC 资源无效。", + "dlc_manager.sync.error.invalid_root": "DLC 根目录无效。", + "dlc_manager.sync.error.invalid_update": "热更新包无效:%s", + "dlc_manager.sync.error.invalid_remote": "远程 DLC 配置无效。", + "dlc_manager.sync.error.invalid_required": "必要资源配置无效。", + "dlc_manager.sync.error.invalid_archive_path": "资源压缩包内的路径无效。", + "dlc_manager.sync.error.invalid_directory": "资源目录无效。", + "dlc_manager.sync.error.invalid_offset": "资源传输位置无效。", + "dlc_manager.sync.error.invalid_size": "资源大小无效。", + "dlc_manager.sync.error.invalid_reuse_path": "可复用资源的路径无效。", + "dlc_manager.sync.error.invalid_item": "同步条目无效。", + "dlc_manager.sync.error.invalid_recovery": "同步备份记录无效:%s", + "dlc_manager.sync.error.invalid_response": "收到的同步响应无效。", + "dlc_manager.sync.error.settings_fields": "管理器配置需要按配置项同步。", + "dlc_manager.sync.error.settings_unavailable": "当前不支持同步管理器配置。", + "dlc_manager.sync.error.settings_missing": "管理器配置内容缺失。", + "dlc_manager.sync.error.resource_missing": "DLC 资源缺失:%s", + "dlc_manager.sync.error.no_item": "尚未选择要同步的 DLC。", + "dlc_manager.sync.error.no_upload": "尚未开始上传资源。", + "dlc_manager.sync.error.not_settings": "不是有效的管理器配置。", + "dlc_manager.sync.error.not_config": "不是可同步的 DLC 配置。", + "dlc_manager.sync.error.preset_missing": "预设配置不可用:%s", + "dlc_manager.sync.error.preset_blocked": "预设无法启用:%s", + "dlc_manager.sync.error.presets_disabled": "目标端未开启预设功能。", + "dlc_manager.sync.error.previous_unfinished": "上一个 DLC 条目尚未处理完成。", + "dlc_manager.sync.error.recovery_incomplete": "回退尚未完成,备份已保留:%s", + "dlc_manager.sync.error.recovery_interrupted": "回退操作已中断。", + "dlc_manager.sync.error.remote_missing": "远程 DLC 的资源文件缺失。", + "dlc_manager.sync.error.archive_escape": "资源压缩包包含超出 DLC 目录的路径。", + "dlc_manager.sync.error.archive_size": "资源压缩包的大小不匹配。", + "dlc_manager.sync.error.directory_integrity": "资源目录完整性校验失败。", + "dlc_manager.sync.error.resource_escape": "资源路径超出了 DLC 目录。", + "dlc_manager.sync.error.filename_missing": "无法确定资源文件名。", + "dlc_manager.sync.error.resource_integrity": "资源完整性校验失败。", + "dlc_manager.sync.error.outside_root": "资源位于 DLC 根目录之外。", + "dlc_manager.sync.error.resource_size": "资源大小不匹配。", + "dlc_manager.sync.error.reused_changed": "可复用资源已发生变化:%s", + "dlc_manager.sync.error.source_changed": "比较后源文件已发生变化:%s", + "dlc_manager.sync.error.source_changed_transfer": "传输时源文件已发生变化:%s", + "dlc_manager.sync.error.symbolic_path": "不支持同步符号链接路径。", + "dlc_manager.sync.error.symbolic_resource": "不支持同步符号链接资源。", + "dlc_manager.sync.error.symbolic_settings": "管理器配置路径不能使用符号链接。", + "dlc_manager.sync.error.symbolic_file": "不支持同步符号链接文件。", + "dlc_manager.sync.error.canceled": "已取消同步。", + "dlc_manager.sync.error.connection_lost": "同步连接已断开。", + "dlc_manager.sync.error.file_missing": "要同步的文件缺失:%s", + "dlc_manager.sync.error.not_started": "尚未开始同步。", + "dlc_manager.sync.error.truncated": "DLC 资源传输不完整:%s", + "dlc_manager.sync.error.unfinished_item": "DLC 条目尚未处理完成。", + "dlc_manager.sync.error.unfinished_upload": "资源上传尚未完成。", + "dlc_manager.sync.error.unsupported_action": "不支持此同步操作。", + "dlc_manager.sync.error.validation_interrupted": "资源校验已中断。", + "dlc_manager.sync.error.unsupported": "服务端不支持 DLC 同步。", + "dlc_manager.sync.failed": "同步失败", + "dlc_manager.sync.failure_choice": "%s\n\n已保留当前改动。是否回退本次同步?", + "dlc_manager.sync.restored": "已回退本次同步。", + "dlc_manager.sync.finished": "同步完成:%s 项成功,%s 项失败。", + "dlc_manager.sync.updates_ready": "热更新包已同步,请前往目标端的热更新管理器完成更新。", + "dlc_manager.sync.restart": "请重启以完成应用。", + "dlc_manager.server.push_title": "DLC 同步", + "dlc_manager.sync.busy": "请先完成或取消 DLC 同步,再切换游戏目录。", + "dlc_manager.sync.configuration_missing": "缺少配置", + "dlc_manager.sync.configuration_changed": "配置不同", + "dlc_manager.sync.resources_missing": "缺失资源", + "dlc_manager.sync.resources_changed": "资源不同", + "dlc_manager.sync.succeeded_item": "已同步:%s" } From 8b3878df7c56a7451a16723d33715787c22b6a82 Mon Sep 17 00:00:00 2001 From: CoZooo <2606767372@qq.com> Date: Mon, 19 Oct 2026 01:36:58 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E8=B0=83=E6=95=B4=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client/screen/DlcFilePageScreen.java | 23 +- .../client/screen/DlcManagerPageScreen.java | 22 +- .../client/screen/DlcNoticeScreen.java | 6 +- .../client/screen/DlcPageScreen.java | 1 + .../client/screen/DlcServerPushScreen.java | 21 +- .../client/screen/DlcServerScreen.java | 5 +- .../dlc_manager/client/screen/DlcUiRows.java | 21 ++ .../client/screen/DlcUiTooltip.java | 60 ++-- .../cozooo/dlc_manager/sync/SyncModel.java | 9 + .../client/screen/DlcFilePageScreen.kt | 23 +- .../client/screen/DlcManagerPageScreen.kt | 24 +- .../client/screen/DlcNoticeScreen.kt | 5 +- .../client/screen/DlcPageScreen.kt | 1 + .../client/screen/DlcServerPushScreen.kt | 21 +- .../client/screen/DlcServerScreen.kt | 5 +- .../dlc_manager/client/screen/DlcUiRows.kt | 19 ++ .../dlc_manager/client/screen/DlcUiTooltip.kt | 61 ++-- .../com/cozooo/dlc_manager/sync/SyncModel.kt | 15 +- .../apricity/dlc_manager/ui/manager.css | 17 +- .../apricity/dlc_manager/ui/manager.html | 7 +- .../apricity/dlc_manager/ui/page.css | 22 +- .../client/screen/DlcUiScrollTest.java | 89 ++++++ .../client/screen/DlcUiTooltipTest.java | 272 ++++++++++++++++++ .../cozooo/dlc_manager/sync/DlcSyncTest.java | 36 +++ 24 files changed, 669 insertions(+), 116 deletions(-) create mode 100644 src/main/java/com/cozooo/dlc_manager/client/screen/DlcUiRows.java create mode 100644 src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcUiRows.kt create mode 100644 src/test/java/com/cozooo/dlc_manager/client/screen/DlcUiTooltipTest.java diff --git a/src/main/java/com/cozooo/dlc_manager/client/screen/DlcFilePageScreen.java b/src/main/java/com/cozooo/dlc_manager/client/screen/DlcFilePageScreen.java index cdb02bb5..1ed3fc2e 100644 --- a/src/main/java/com/cozooo/dlc_manager/client/screen/DlcFilePageScreen.java +++ b/src/main/java/com/cozooo/dlc_manager/client/screen/DlcFilePageScreen.java @@ -28,7 +28,16 @@ protected final void fileRow(Element list, String name, String description, Supp protected final Element fileRowLine(Element list, String name, String description, Supplier checked, BooleanSupplier enabled, Runnable select, Runnable open) { - Element line = node(list, "div", "page-row file-row"); + return fileRowLine(list, name, description, checked, enabled, select, open, false); + } + + protected final Element managerRowLine(Element list, String name, Supplier checked, BooleanSupplier enabled, Runnable select) { + return fileRowLine(list, name, null, checked, enabled, select, null, true); + } + + private Element fileRowLine(Element list, String name, String description, Supplier checked, + BooleanSupplier enabled, Runnable select, Runnable open, boolean managerStyle) { + Element line = managerStyle ? list.appendChild(pageDocument.createElement("div")) : node(list, "div", "page-row file-row"); var button = action(line, "", () -> { if (enabled.getAsBoolean()) { select.run(); syncSelections(); } }); @@ -37,9 +46,13 @@ protected final Element fileRowLine(Element list, String name, String descriptio box.setAttribute("role", "checkbox"); box.setAttribute("aria-label", tr("dlc_manager.ui.select") + " " + name); box.setInnerHTML(""); - Element copy = node(line, "div", "row-copy"); - text(copy, name, "row-title"); - if (description != null && !description.isBlank()) text(copy, description, "row-description"); + if (managerStyle) { + DlcUiRows.managerRow(pageDocument, line, name, false, false); + } else { + Element copy = node(line, "div", "row-copy"); + text(copy, name, "row-title"); + if (description != null && !description.isBlank()) text(copy, description, "row-description"); + } if (open != null) { Document document = pageDocument; long generation = document.getRefreshGeneration(); @@ -67,6 +80,6 @@ protected final Element fileRowLine(Element list, String name, String descriptio return line; } - private void syncSelections() { selections.forEach(Runnable::run); } + protected final void syncSelections() { selections.forEach(Runnable::run); } @Override protected void updatePage() { syncSelections(); } } diff --git a/src/main/java/com/cozooo/dlc_manager/client/screen/DlcManagerPageScreen.java b/src/main/java/com/cozooo/dlc_manager/client/screen/DlcManagerPageScreen.java index 138d2512..7bc5b677 100644 --- a/src/main/java/com/cozooo/dlc_manager/client/screen/DlcManagerPageScreen.java +++ b/src/main/java/com/cozooo/dlc_manager/client/screen/DlcManagerPageScreen.java @@ -23,6 +23,7 @@ import java.util.Map; public abstract class DlcManagerPageScreen extends DlcApricityScreen { + protected DlcUiButton headerBackButton; protected DlcManagerPageScreen() { this(DlcUiPage.MANAGER); } protected DlcManagerPageScreen(DlcUiPage page) { super(page); } protected DlcUiButton searchClearButton; @@ -31,7 +32,7 @@ public abstract class DlcManagerPageScreen extends DlcApricityScreen { private Document layoutDocument; private long layoutGeneration = -1; private boolean sidebarCollapsed; - private double sidebarWidth = 172; + private double sidebarWidth = 240; private double rowHeight = 40; private final DlcUiMotion sidebarMotion = new DlcUiMotion(1); private double lastSidebarProgress = Double.NaN; @@ -55,11 +56,19 @@ private static final class GroupView { protected void bindManagerLayout(Document document, String title) { Element manager = document.getElementById("manager"); - sidebarWidth = layoutMetric(manager, "data-sidebar-width", 172); + sidebarWidth = layoutMetric(manager, "data-sidebar-width", 240); rowHeight = layoutMetric(manager, "data-row-height", 40); document.getElementById("manager-header").setAttribute("class", "workspace-header"); restoreUiClasses(document.getElementById("manager-header")); DlcUiText.set(document.getElementById("manager-title"), title); + Element back = document.getElementById("manager-back"); + headerBackButton = null; + if (back != null) { + clearUiContent(back); + headerBackButton = button(document, back, "‹", this::onClose); + headerBackButton.getElement().setAttribute("class", "button manager-back"); + headerBackButton.getElement().setAttribute("aria-label", layoutTranslate("gui.back")); + } document.getElementById("category-label").setTextContent(layoutTranslate("dlc_manager.ui.categories")); document.getElementById("tools-label").setTextContent(layoutTranslate("dlc_manager.ui.tools")); if (layoutDocument != document || layoutGeneration != document.getRefreshGeneration()) { @@ -94,9 +103,7 @@ protected Element addManagerGroup(Document document, Element list, Element headi } protected Element styleManagerRow(Document document, Element entry, String name, boolean enabled, boolean pending) { - entry.setAttribute("class", "dlc-row" + (enabled ? " enabled" : "") + (pending ? " pending" : "")); - if (pending || enabled) managerText(document, entry, "span", "", "item-status-dot " + (pending ? "pending" : "enabled")).setAttribute("aria-hidden", "true"); - return managerText(document, entry, "span", name, "item-name"); + return DlcUiRows.managerRow(document, entry, name, enabled, pending); } protected DlcUiButton managerToggle(Document document, Element entry, boolean enabled, Runnable action) { @@ -187,10 +194,7 @@ protected void updateAnimations(Document document, long now) { } protected static Element managerText(Document document, Element parent, String tag, String value, String className) { - Element element = document.createElement(tag); - element.setAttribute("class", className); - DlcUiText.set(element, value); - return parent.appendChild(element); + return DlcUiRows.text(document, parent, tag, value, className); } diff --git a/src/main/java/com/cozooo/dlc_manager/client/screen/DlcNoticeScreen.java b/src/main/java/com/cozooo/dlc_manager/client/screen/DlcNoticeScreen.java index ce616186..5b2d1f83 100644 --- a/src/main/java/com/cozooo/dlc_manager/client/screen/DlcNoticeScreen.java +++ b/src/main/java/com/cozooo/dlc_manager/client/screen/DlcNoticeScreen.java @@ -29,8 +29,10 @@ public DlcNoticeScreen(Screen parent, DlcNoticeConfig config) { } var body = node(content, "article", "page-prose"); markdown(body, resolvedContents); - if (config.getShowFrequency() != DlcNoticeConfig.ShowFrequency.ONCE) - toggle(footer, tr("dlc_manager.notice.never_show"), neverShow, value -> { neverShow = value; refreshPage(); }); + if (config.getShowFrequency() != DlcNoticeConfig.ShowFrequency.ONCE) { + footerLeading.setAttribute("style", "display:flex"); + toggle(footerLeading, tr("dlc_manager.notice.never_show"), neverShow, value -> { neverShow = value; refreshPage(); }); + } primary(tr("dlc_manager.notice.read"), this::onClose); status(future == null ? "" : tr("dlc_manager.notice.loading")); } diff --git a/src/main/java/com/cozooo/dlc_manager/client/screen/DlcPageScreen.java b/src/main/java/com/cozooo/dlc_manager/client/screen/DlcPageScreen.java index 46059e3d..82eb42fc 100644 --- a/src/main/java/com/cozooo/dlc_manager/client/screen/DlcPageScreen.java +++ b/src/main/java/com/cozooo/dlc_manager/client/screen/DlcPageScreen.java @@ -58,6 +58,7 @@ protected DlcPageScreen(DlcUiPage page, Screen parent, Component title) { clearUiContent(back); backAction = null; back.setAttribute("style", showHeaderBackButton() ? "" : "display:none"); + back.parentElement.setAttribute("data-has-back", Boolean.toString(showHeaderBackButton())); if (showHeaderBackButton()) { backAction = action(back, "‹", this::onClose); backAction.getElement().setAttribute("aria-label", tr("gui.back")); diff --git a/src/main/java/com/cozooo/dlc_manager/client/screen/DlcServerPushScreen.java b/src/main/java/com/cozooo/dlc_manager/client/screen/DlcServerPushScreen.java index 61ec7a55..8ad9c18e 100644 --- a/src/main/java/com/cozooo/dlc_manager/client/screen/DlcServerPushScreen.java +++ b/src/main/java/com/cozooo/dlc_manager/client/screen/DlcServerPushScreen.java @@ -14,6 +14,7 @@ public class DlcServerPushScreen extends DlcFilePageScreen { private final DlcServerEntry entry; private List local = List.of(), remote = List.of(); private List rows = List.of(), filteredRows = List.of(); + private Comparison comparison = new Comparison(List.of(), Map.of()); private final Set selected = new HashSet<>(); private Mode mode = Mode.CONFIG; private Category category = Category.ALL; @@ -64,14 +65,16 @@ public DlcServerPushScreen(Screen parent, DlcServerEntry entry) { results.appendChild(list); showWhen(list, () -> !busy || syncing); if (!shown.isEmpty()) selectionRow(list, tr("dlc_manager.sync.select_all"), this::selectionState, this::selectVisible); + String language = com.cozooo.dlc_manager.platform.DlcPlatformServices.languageCode(); for (Row row : shown) { - String language = com.cozooo.dlc_manager.platform.DlcPlatformServices.languageCode(); String name = SyncModel.displayName(row.source(), language); + String status = tr("dlc_manager.sync." + comparison.status(row)); + var tooltip = new DlcUiTooltip.Content(name, SyncModel.tooltip(row.source(), language), List.of(), status); var line = selectionRow(list, name, () -> Boolean.toString(selected.contains(row.key())), () -> { if (!selected.add(row.key())) selected.remove(row.key()); }); - tip(line, () -> new DlcUiTooltip.Content(name, SyncModel.tooltip(row.source(), language), List.of(), tr("dlc_manager.sync." + SyncModel.status(row, mode)))); - text(line, tr("dlc_manager.sync." + SyncModel.status(row, mode)), "row-description"); + tip(line, () -> tooltip); + text(line, status, "row-description"); } live(results, () -> message, "page-description"); showWhen(progress(results, () -> fraction), () -> busy); @@ -86,7 +89,8 @@ public DlcServerPushScreen(Screen parent, DlcServerEntry entry) { updateExecuteButton(); } private com.sighs.apricityui.init.Element selectionRow(com.sighs.apricityui.init.Element list, String name, java.util.function.Supplier checked, Runnable change) { - var line = fileRowLine(list, name, null, checked, () -> !busy, change, null); + var line = managerRowLine(list, name, checked, () -> !busy, change); + line.setAttribute("class", "dlc-row sync-row"); var checkbox = line.getChildren().get(0); var document = pageDocument; long generation = document.getRefreshGeneration(); @@ -94,7 +98,7 @@ private com.sighs.apricityui.init.Element selectionRow(com.sighs.apricityui.init if (event instanceof com.sighs.apricityui.event.MouseEvent mouse && mouse.button == 0 && !busy && Minecraft.getInstance().screen == this && document == getLinkedDocument() && document.isCurrentGeneration(generation) && !(event.target instanceof com.sighs.apricityui.init.Element target && checkbox.contains(target))) { - event.preventDefault(); change.run(); updatePage(); + event.preventDefault(); change.run(); syncSelections(); updatePage(); } }); return line; @@ -116,7 +120,7 @@ private void selectVisible() { for (Row row : shown) { if (all) selected.remove(row.key()); else selected.add(row.key()); } } private void updateExecuteButton() { - if (executeButton != null) executeButton.setEnabled(busy ? syncing && !cancelRequested : settingsSelected && settingsRow() != null || visibleRows().stream().anyMatch(row -> selected.contains(row.key()))); + if (executeButton != null) executeButton.setEnabled(busy ? syncing && !cancelRequested : settingsSelected && settingsRow() != null || !selected.isEmpty() && visibleRows().stream().anyMatch(row -> selected.contains(row.key()))); } private SyncModel.Peer peer(DlcServerSession session) { return new SyncModel.Peer() { @@ -143,7 +147,8 @@ private void compare() { }); } private void replan() { - rows = SyncModel.plan(local, remote, direction, states, mode); + comparison = SyncModel.compare(local, remote, direction, states, mode); + rows = comparison.rows(); selected.clear(); settingsSelected = false; refilter(); refreshPage(); } @@ -222,6 +227,6 @@ private void run(Work work, boolean synchronizing) { finally { Minecraft.getInstance().execute(() -> { busy = false; syncing = false; connecting = null; refreshPage(); if (failureReason != null) showFailure(failureReason); }); } }); } - @Override protected void updatePage() { updateExecuteButton(); super.updatePage(); } + @Override protected void updatePage() { updateExecuteButton(); } @Override public boolean shouldCloseOnEsc() { return !busy; } } diff --git a/src/main/java/com/cozooo/dlc_manager/client/screen/DlcServerScreen.java b/src/main/java/com/cozooo/dlc_manager/client/screen/DlcServerScreen.java index 66dc9479..2f44edee 100644 --- a/src/main/java/com/cozooo/dlc_manager/client/screen/DlcServerScreen.java +++ b/src/main/java/com/cozooo/dlc_manager/client/screen/DlcServerScreen.java @@ -435,6 +435,7 @@ void updateApplyState() { if (backButton != null) { backButton.setEnabled(!applying && requiredMissingCount <= 0); } + if (headerBackButton != null) headerBackButton.setEnabled(!applying && requiredMissingCount <= 0); } boolean isDirty(DlcServerItem item) { @@ -1328,10 +1329,10 @@ private static class ConflictCheck { managerText(document, connection, "span", String.join(" · ", java.util.stream.Stream.of(serverBaseVersion, serverDlcVersion).filter(v -> !v.isBlank()).toList()), "connection-version"); Element context = document.getElementById("manager-context"); clearUiContent(context); - if (entry != null) button(document, context, tr("dlc_manager.server.config"), this::openConfig); Element tools = document.getElementById("manager-tools"); clearUiContent(tools); if (DlcConfig.isPresetFuncOn()) button(document, tools, tr("dlc_manager.preset.manager"), this::openPreset); + if (entry != null) button(document, tools, tr("dlc_manager.server.config"), this::openConfig); button(document, tools, tr("dlc_manager.server.tab.servers"), this::openServerTools); for (Element tool : tools.children) tool.setAttribute("class", "button nav-button"); Element batch = document.getElementById("batch-actions"); @@ -1488,7 +1489,7 @@ private List itemState() { private void updateManagerControls(Document document) { if (!java.util.Objects.equals(controlsLocked, applying)) { controlsLocked = applying; - var managed = java.util.Arrays.asList(applyButton, backButton, enableAllButton, disableAllButton, searchClearButton).stream().filter(java.util.Objects::nonNull).map(DlcUiButton::getElement).toList(); + var managed = java.util.Arrays.asList(applyButton, backButton, headerBackButton, enableAllButton, disableAllButton, searchClearButton).stream().filter(java.util.Objects::nonNull).map(DlcUiButton::getElement).toList(); for (Element element : document.querySelectorAll("#manager button, #manager input")) { if (managed.contains(element)) continue; if (applying) element.setAttribute("disabled", ""); else element.removeAttribute("disabled"); diff --git a/src/main/java/com/cozooo/dlc_manager/client/screen/DlcUiRows.java b/src/main/java/com/cozooo/dlc_manager/client/screen/DlcUiRows.java new file mode 100644 index 00000000..561bae50 --- /dev/null +++ b/src/main/java/com/cozooo/dlc_manager/client/screen/DlcUiRows.java @@ -0,0 +1,21 @@ +package com.cozooo.dlc_manager.client.screen; + +import com.sighs.apricityui.init.Document; +import com.sighs.apricityui.init.Element; + +final class DlcUiRows { + private DlcUiRows() {} + + static Element managerRow(Document document, Element entry, String name, boolean enabled, boolean pending) { + entry.setAttribute("class", "dlc-row" + (enabled ? " enabled" : "") + (pending ? " pending" : "")); + if (pending || enabled) text(document, entry, "span", "", "item-status-dot " + (pending ? "pending" : "enabled")).setAttribute("aria-hidden", "true"); + return text(document, entry, "span", name, "item-name"); + } + + static Element text(Document document, Element parent, String tag, String value, String className) { + Element element = document.createElement(tag); + element.setAttribute("class", className); + DlcUiText.set(element, value); + return parent.appendChild(element); + } +} diff --git a/src/main/java/com/cozooo/dlc_manager/client/screen/DlcUiTooltip.java b/src/main/java/com/cozooo/dlc_manager/client/screen/DlcUiTooltip.java index 974a93df..4c7617a8 100644 --- a/src/main/java/com/cozooo/dlc_manager/client/screen/DlcUiTooltip.java +++ b/src/main/java/com/cozooo/dlc_manager/client/screen/DlcUiTooltip.java @@ -4,6 +4,7 @@ import com.sighs.apricityui.init.Document; import com.sighs.apricityui.init.Element; import com.sighs.apricityui.layout.Box; +import com.sighs.apricityui.render.Drawer; import java.util.List; import java.util.function.Supplier; @@ -16,8 +17,8 @@ record Content(String title, String description, List details, String hi private final Element panel; private Element owner; private Supplier pending; - private double pointerX, pointerY, placedWidth = -1, placedHeight = -1, viewportWidth = -1, viewportHeight = -1; - private double placedPointerX = Double.NaN, placedPointerY = Double.NaN; + private Content renderedContent; + private double pointerX, pointerY, placedPointerX = Double.NaN, placedPointerY = Double.NaN, placedWidth = -1, placedHeight = -1, viewportWidth = -1, viewportHeight = -1; private boolean visible; DlcUiTooltip(Document document) { @@ -28,7 +29,7 @@ record Content(String title, String description, List details, String hi panel.setAttribute("class", "dlc-tooltip"); panel.setAttribute("role", "tooltip"); panel.setAttribute("aria-hidden", "true"); - panel.setAttribute("style", "display:none"); + panel.setAttribute("style", "display:block;visibility:hidden;left:0;top:0;transform:translate(0px,0px)"); panel.setTopLayer(true); document.addEventListener("wheel", event -> { if (visible && event instanceof MouseEvent wheel && event.target instanceof Element target @@ -70,50 +71,63 @@ void hide() { owner = null; if (!visible) return; visible = false; - panel.setAttribute("style", "display:none"); + panel.setInlineStyleProperty("visibility", "hidden"); panel.setAttribute("aria-hidden", "true"); } void update() { - if (!document.isCurrentGeneration(generation)) return; + if (!document.isCurrentGeneration(generation) || !visible && pending == null) return; double vw = document.getViewport().layoutWidth(), vh = document.getViewport().layoutHeight(); - String limits = "max-width:" + Math.max(1, vw - 24) + "px;max-height:" + Math.max(1, vh - 24) + "px"; if (pending != null && !visible) { Content content = pending.get(); pending = null; - panel.setInnerHTML(""); - append(panel, content.title(), "tooltip-title"); - append(panel, content.description(), "tooltip-description"); - for (Detail detail : content.details()) { - if (detail.text() == null || detail.text().isBlank()) continue; - Element section = append(panel, "", "tooltip-detail" + (detail.warning() ? " warning" : "")); - append(section, detail.label(), "tooltip-label"); - append(section, detail.text(), "tooltip-value"); + if (!content.equals(renderedContent)) { + panel.setInnerHTML(""); + append(panel, content.title(), "tooltip-title"); + append(panel, content.description(), "tooltip-description"); + for (Detail detail : content.details()) { + if (detail.text() == null || detail.text().isBlank()) continue; + Element section = append(panel, "", "tooltip-detail" + (detail.warning() ? " warning" : "")); + append(section, detail.label(), "tooltip-label"); + append(section, detail.text(), "tooltip-value"); + } + append(panel, content.hint(), "tooltip-hint"); + renderedContent = new Content(content.title(), content.description(), List.copyOf(content.details()), content.hint()); + document.markDirty(document.documentElement, Drawer.REORDER); } - append(panel, content.hint(), "tooltip-hint"); visible = true; - placedWidth = placedHeight = viewportWidth = viewportHeight = -1; - panel.setAttribute("style", "display:block;visibility:hidden;" + limits); + placedWidth = placedHeight = -1; panel.setScrollTop(0); } if (!visible) return; + boolean resized = vw != viewportWidth || vh != viewportHeight; + if (resized) { + panel.setInlineStyleProperty("max-width", Math.max(1, vw - 24) + "px"); + panel.setInlineStyleProperty("max-height", Math.max(1, vh - 24) + "px"); + } + if (placedWidth < 0 || resized) { + document.commitPendingStyleRecalcForRender(); + document.commitRenderState(); + } double width = panel.getOffsetWidth(), height = panel.getOffsetHeight(); if (width <= 0 || height <= 0) return; - if (width == placedWidth && height == placedHeight && vw == viewportWidth && vh == viewportHeight - && pointerX == placedPointerX && pointerY == placedPointerY) return; + if (width == placedWidth && height == placedHeight && !resized && pointerX == placedPointerX && pointerY == placedPointerY) return; double left = pointerX + 16, top = pointerY + 20; if (left + width > vw - 12) left = pointerX - width - 16; if (top + height > vh - 12) top = pointerY - height - 12; left = Math.max(12, Math.min(left, Math.max(12, vw - width - 12))); top = Math.max(12, Math.min(top, Math.max(12, vh - height - 12))); - panel.setAttribute("style", "display:block;visibility:visible;left:" + left + "px;top:" + top + "px;" + limits); - panel.setAttribute("aria-hidden", "false"); + panel.setInlineStyleProperty("transform", "translate(" + left + "px," + top + "px)"); + if (placedWidth < 0) { + panel.setInlineStyleProperty("visibility", "visible"); + panel.setAttribute("aria-hidden", "false"); + } + placedPointerX = pointerX; + placedPointerY = pointerY; placedWidth = width; placedHeight = height; viewportWidth = vw; viewportHeight = vh; - placedPointerX = pointerX; - placedPointerY = pointerY; } private Element append(Element parent, String value, String className) { diff --git a/src/main/java/com/cozooo/dlc_manager/sync/SyncModel.java b/src/main/java/com/cozooo/dlc_manager/sync/SyncModel.java index 2ef0fa82..1b8d525f 100644 --- a/src/main/java/com/cozooo/dlc_manager/sync/SyncModel.java +++ b/src/main/java/com/cozooo/dlc_manager/sync/SyncModel.java @@ -28,6 +28,9 @@ public Item(String key, String identifier, File config, List resources, Bo } } public record Row(String key, Item source, Item target, Direction direction, boolean replacement, boolean stateChanged) {} + public record Comparison(List rows, Map statuses) { + public String status(Row row) { return statuses.get(row.key()); } + } public record Result(int completed, boolean canceled, boolean requiresRestart, List errors, List succeeded) {} @FunctionalInterface public interface Peer extends AutoCloseable { JsonObject call(JsonObject request) throws IOException; @Override default void close() throws IOException {} } @FunctionalInterface public interface Progress { void update(String phase, String resource, long done, long total); } @@ -86,6 +89,12 @@ public static List plan(List local, List server, Direction dire rows.sort(Comparator.comparing(Row::key)); return rows; } + public static Comparison compare(List local, List server, Direction direction, boolean states, Mode mode) { + List rows = plan(local, server, direction, states, mode); + Map statuses = new HashMap<>(); + for (Row row : rows) statuses.put(row.key(), status(row, mode)); + return new Comparison(List.copyOf(rows), Map.copyOf(statuses)); + } public static boolean same(File a, File b) { return a != null && b != null && a.size() >= 0 && a.size() == b.size() && a.id().equals(b.id()) && a.hash() != null && a.hash().equals(b.hash()); } diff --git a/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcFilePageScreen.kt b/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcFilePageScreen.kt index b877f234..2bc42e63 100644 --- a/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcFilePageScreen.kt +++ b/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcFilePageScreen.kt @@ -24,7 +24,16 @@ abstract class DlcFilePageScreen(page: DlcUiPage, parent: Screen?, title: Compon protected fun fileRowLine(list: Element, name: String, description: String?, checked: Supplier, enabled: BooleanSupplier, select: Runnable, open: Runnable?): Element { - val line = node(list, "div", "page-row file-row") + return fileRowLine(list, name, description, checked, enabled, select, open, false) + } + + protected fun managerRowLine(list: Element, name: String, checked: Supplier, enabled: BooleanSupplier, select: Runnable): Element { + return fileRowLine(list, name, null, checked, enabled, select, null, true) + } + + private fun fileRowLine(list: Element, name: String, description: String?, checked: Supplier, + enabled: BooleanSupplier, select: Runnable, open: Runnable?, managerStyle: Boolean): Element { + val line = if (managerStyle) list.appendChild(pageDocument.createElement("div")) else node(list, "div", "page-row file-row") val button = action(line, "") { if (enabled.asBoolean) { select.run(); syncSelections() } } @@ -33,9 +42,13 @@ abstract class DlcFilePageScreen(page: DlcUiPage, parent: Screen?, title: Compon box.setAttribute("role", "checkbox") box.setAttribute("aria-label", tr("dlc_manager.ui.select") + " " + name) box.setInnerHTML("") - val copy = node(line, "div", "row-copy") - text(copy, name, "row-title") - if (!description.isNullOrBlank()) text(copy, description, "row-description") + if (managerStyle) { + DlcUiRows.managerRow(pageDocument, line, name, false, false) + } else { + val copy = node(line, "div", "row-copy") + text(copy, name, "row-title") + if (!description.isNullOrBlank()) text(copy, description, "row-description") + } if (open != null) { val document = pageDocument val generation = document.refreshGeneration @@ -64,6 +77,6 @@ abstract class DlcFilePageScreen(page: DlcUiPage, parent: Screen?, title: Compon return line } - private fun syncSelections() { selections.forEach { it.run() } } + protected fun syncSelections() { selections.forEach { it.run() } } override fun updatePage() { syncSelections() } } diff --git a/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcManagerPageScreen.kt b/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcManagerPageScreen.kt index b923a895..a44e8738 100644 --- a/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcManagerPageScreen.kt +++ b/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcManagerPageScreen.kt @@ -25,7 +25,7 @@ abstract class DlcManagerPageScreen(page: DlcUiPage = DlcUiPage.MANAGER) : DlcAp private var layoutDocument: Document? = null private var layoutGeneration = -1L private var sidebarCollapsed = false - private var sidebarWidth = 172.0 + private var sidebarWidth = 240.0 private var rowHeight = 40.0 private val sidebarMotion = DlcUiMotion(1.0) private var lastSidebarProgress = Double.NaN @@ -38,13 +38,24 @@ abstract class DlcManagerPageScreen(page: DlcUiPage = DlcUiPage.MANAGER) : DlcAp var lastProgress = Double.NaN } + protected var headerBackButton: DlcUiButton? = null + protected fun bindManagerLayout(document: Document, title: String) { val manager = document.getElementById("manager") - sidebarWidth = layoutMetric(manager, "data-sidebar-width", 172.0) + sidebarWidth = layoutMetric(manager, "data-sidebar-width", 240.0) rowHeight = layoutMetric(manager, "data-row-height", 40.0) document.getElementById("manager-header").setAttribute("class", "workspace-header") restoreUiClasses(document.getElementById("manager-header")) DlcUiText.set(document.getElementById("manager-title"), title) + val back = document.getElementById("manager-back") + headerBackButton = null + if (back != null) { + clearUiContent(back) + headerBackButton = button(document, back, "‹", ::onClose).also { + it.getElement().setAttribute("class", "button manager-back") + it.getElement().setAttribute("aria-label", layoutTranslate("gui.back")) + } + } document.getElementById("category-label").setTextContent(layoutTranslate("dlc_manager.ui.categories")) document.getElementById("tools-label").setTextContent(layoutTranslate("dlc_manager.ui.tools")) if (layoutDocument !== document || layoutGeneration != document.refreshGeneration) { @@ -80,9 +91,7 @@ abstract class DlcManagerPageScreen(page: DlcUiPage = DlcUiPage.MANAGER) : DlcAp } protected fun styleManagerRow(document: Document, entry: Element, name: String, enabled: Boolean, pending: Boolean): Element { - entry.setAttribute("class", "dlc-row" + (if (enabled) " enabled" else "") + (if (pending) " pending" else "")) - if (pending || enabled) managerText(document, entry, "span", "", "item-status-dot " + if (pending) "pending" else "enabled").setAttribute("aria-hidden", "true") - return managerText(document, entry, "span", name, "item-name") + return DlcUiRows.managerRow(document, entry, name, enabled, pending) } protected fun managerToggle(document: Document, entry: Element, enabled: Boolean, action: Runnable): DlcUiButton { @@ -174,10 +183,7 @@ abstract class DlcManagerPageScreen(page: DlcUiPage = DlcUiPage.MANAGER) : DlcAp } protected fun managerText(document: Document, parent: Element, tag: String, value: String?, className: String): Element { - val element = document.createElement(tag) - element.setAttribute("class", className) - DlcUiText.set(element, value) - return parent.appendChild(element) + return DlcUiRows.text(document, parent, tag, value, className) } diff --git a/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcNoticeScreen.kt b/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcNoticeScreen.kt index f72106b8..43658c22 100644 --- a/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcNoticeScreen.kt +++ b/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcNoticeScreen.kt @@ -25,7 +25,10 @@ class DlcNoticeScreen(parent: Screen?, private val config: DlcNoticeConfig) : Dl } val body = node(content, "article", "page-prose") markdown(body, resolvedContents) - if (config.getShowFrequency() != DlcNoticeConfig.ShowFrequency.ONCE) toggle(footer, tr("dlc_manager.notice.never_show"), neverShow) { neverShow = it; refreshPage() } + if (config.getShowFrequency() != DlcNoticeConfig.ShowFrequency.ONCE) { + footerLeading.setAttribute("style", "display:flex") + toggle(footerLeading, tr("dlc_manager.notice.never_show"), neverShow) { neverShow = it; refreshPage() } + } primary(tr("dlc_manager.notice.read")) { onClose() } status(if (future == null) "" else tr("dlc_manager.notice.loading")) } diff --git a/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcPageScreen.kt b/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcPageScreen.kt index b155f5e4..e8852860 100644 --- a/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcPageScreen.kt +++ b/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcPageScreen.kt @@ -51,6 +51,7 @@ abstract class DlcPageScreen( clearUiContent(back) backAction = null back.setAttribute("style", if (showHeaderBackButton()) "" else "display:none") + back.parentElement.setAttribute("data-has-back", showHeaderBackButton().toString()) if (showHeaderBackButton()) { backAction = action(back, "‹") { onClose() } backAction!!.getElement().setAttribute("aria-label", tr("gui.back")) diff --git a/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcServerPushScreen.kt b/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcServerPushScreen.kt index c686330c..d3a22d1e 100644 --- a/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcServerPushScreen.kt +++ b/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcServerPushScreen.kt @@ -16,6 +16,7 @@ class DlcServerPushScreen(parent: Screen, private val entry: DlcServerEntry?) : private var remote = emptyList() private var rows = emptyList() private var filteredRows = emptyList() + private var comparison = Comparison(emptyList(), emptyMap()) private val selected = mutableSetOf() private var mode = Mode.CONFIG private var category = Category.ALL @@ -64,12 +65,14 @@ class DlcServerPushScreen(parent: Screen, private val entry: DlcServerEntry?) : results.appendChild(list) showWhen(list) { !busy || syncing } if (shown.isNotEmpty()) selectionRow(list, tr("dlc_manager.sync.select_all"), ::selectionState, ::selectVisible) + val language = com.cozooo.dlc_manager.platform.DlcPlatformServices.languageCode() for (row in shown) { - val language = com.cozooo.dlc_manager.platform.DlcPlatformServices.languageCode() val name = SyncModel.displayName(row.source, language) + val status = tr("dlc_manager.sync." + comparison.status(row)) + val tooltip = DlcUiTooltip.Content(name, SyncModel.tooltip(row.source, language), emptyList(), status) val line = selectionRow(list, name, { selected.contains(row.key).toString() }) { if (!selected.add(row.key)) selected.remove(row.key) } - tip(line) { DlcUiTooltip.Content(name, SyncModel.tooltip(row.source, language), emptyList(), tr("dlc_manager.sync." + SyncModel.status(row, mode))) } - text(line, tr("dlc_manager.sync." + SyncModel.status(row, mode)), "row-description") + tip(line) { tooltip } + text(line, status, "row-description") } live(results, { message }, "page-description") showWhen(progress(results) { fraction }) { busy } @@ -84,14 +87,15 @@ class DlcServerPushScreen(parent: Screen, private val entry: DlcServerEntry?) : updateExecuteButton() } private fun selectionRow(list: com.sighs.apricityui.init.Element, name: String, checked: java.util.function.Supplier, change: Runnable): com.sighs.apricityui.init.Element { - val line = fileRowLine(list, name, null, checked, { !busy }, change, null) + val line = managerRowLine(list, name, checked, { !busy }, change) + line.setAttribute("class", "dlc-row sync-row") val checkbox = line.getChildren()[0] val document = pageDocument val generation = document.getRefreshGeneration() line.addEventListener("mousedown") { event -> val target = event.target if (event is com.sighs.apricityui.event.MouseEvent && event.button == 0 && !busy && Minecraft.getInstance().screen === this && document === getLinkedDocument() && document.isCurrentGeneration(generation) && !(target is com.sighs.apricityui.init.Element && checkbox.contains(target))) { - event.preventDefault(); change.run(); updatePage() + event.preventDefault(); change.run(); syncSelections(); updatePage() } } return line @@ -111,7 +115,7 @@ class DlcServerPushScreen(parent: Screen, private val entry: DlcServerEntry?) : shown.forEach { if (all) selected.remove(it.key) else selected.add(it.key) } } private fun updateExecuteButton() { - executeButton?.setEnabled(if (busy) syncing && !cancelRequested else settingsSelected && settingsRow() != null || visibleRows().any { it.key in selected }) + executeButton?.setEnabled(if (busy) syncing && !cancelRequested else settingsSelected && settingsRow() != null || selected.isNotEmpty() && visibleRows().any { it.key in selected }) } private fun peer(session: DlcServerSession) = object : Peer { override fun call(request: com.google.gson.JsonObject): com.google.gson.JsonObject = try { session.syncRequest(request) } @@ -135,7 +139,8 @@ class DlcServerPushScreen(parent: Screen, private val entry: DlcServerEntry?) : } } private fun replan() { - rows = SyncModel.plan(local, remote, direction, states, mode) + comparison = SyncModel.compare(local, remote, direction, states, mode) + rows = comparison.rows selected.clear(); settingsSelected = false; refilter() refreshPage() } @@ -210,6 +215,6 @@ class DlcServerPushScreen(parent: Screen, private val entry: DlcServerEntry?) : finally { Minecraft.getInstance().execute { busy = false; syncing = false; connecting = null; refreshPage(); failureReason?.let(::showFailure) } } } } - override fun updatePage() { updateExecuteButton(); super.updatePage() } + override fun updatePage() { updateExecuteButton() } override fun shouldCloseOnEsc() = !busy } diff --git a/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcServerScreen.kt b/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcServerScreen.kt index 649f4ab1..b661d4ef 100644 --- a/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcServerScreen.kt +++ b/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcServerScreen.kt @@ -410,6 +410,7 @@ class DlcServerScreen( } apply.setEnabled(dirty && !applying) backButton?.setEnabled(!applying && requiredMissingCount <= 0) + headerBackButton?.setEnabled(!applying && requiredMissingCount <= 0) } fun isDirty(item: DlcServerItem?): Boolean { @@ -1156,10 +1157,10 @@ class DlcServerScreen( managerText(document, connection, "span", listOf(serverBaseVersion, serverDlcVersion).filter { it.isNotBlank() }.joinToString(" · "), "connection-version") val context = document.getElementById("manager-context") clearUiContent(context) - if (entry != null) button(document, context, tr("dlc_manager.server.config")) { openConfig() } val tools = document.getElementById("manager-tools") clearUiContent(tools) if (DlcConfig.isPresetFuncOn()) button(document, tools, tr("dlc_manager.preset.manager")) { openPreset() } + if (entry != null) button(document, tools, tr("dlc_manager.server.config")) { openConfig() } button(document, tools, tr("dlc_manager.server.tab.servers")) { openServerTools() } for (tool in tools.children) tool.setAttribute("class", "button nav-button") val batch = document.getElementById("batch-actions") @@ -1307,7 +1308,7 @@ class DlcServerScreen( private fun updateManagerControls(document: Document) { if (controlsLocked != applying) { controlsLocked = applying - val managed = listOfNotNull(applyButton, backButton, enableAllButton, disableAllButton, searchClearButton).map { it.getElement() } + val managed = listOfNotNull(applyButton, backButton, headerBackButton, enableAllButton, disableAllButton, searchClearButton).map { it.getElement() } for (element in document.querySelectorAll("#manager button, #manager input")) { if (element in managed) continue if (applying) element.setAttribute("disabled", "") else element.removeAttribute("disabled") diff --git a/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcUiRows.kt b/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcUiRows.kt new file mode 100644 index 00000000..d953c1b6 --- /dev/null +++ b/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcUiRows.kt @@ -0,0 +1,19 @@ +package com.cozooo.dlc_manager.client.screen + +import com.sighs.apricityui.init.Document +import com.sighs.apricityui.init.Element + +internal object DlcUiRows { + @JvmStatic fun managerRow(document: Document, entry: Element, name: String, enabled: Boolean, pending: Boolean): Element { + entry.setAttribute("class", "dlc-row" + (if (enabled) " enabled" else "") + (if (pending) " pending" else "")) + if (pending || enabled) text(document, entry, "span", "", "item-status-dot " + if (pending) "pending" else "enabled").setAttribute("aria-hidden", "true") + return text(document, entry, "span", name, "item-name") + } + + @JvmStatic fun text(document: Document, parent: Element, tag: String, value: String?, className: String): Element { + val element = document.createElement(tag) + element.setAttribute("class", className) + DlcUiText.set(element, value) + return parent.appendChild(element) + } +} diff --git a/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcUiTooltip.kt b/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcUiTooltip.kt index d3f43fe6..bad7d83b 100644 --- a/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcUiTooltip.kt +++ b/src/main/kotlin/com/cozooo/dlc_manager/client/screen/DlcUiTooltip.kt @@ -4,6 +4,7 @@ import com.sighs.apricityui.event.MouseEvent import com.sighs.apricityui.init.Document import com.sighs.apricityui.init.Element import com.sighs.apricityui.layout.Box +import com.sighs.apricityui.render.Drawer import java.util.function.Supplier internal class DlcUiTooltip(private val document: Document) { @@ -14,14 +15,15 @@ internal class DlcUiTooltip(private val document: Document) { private val panel = document.body.appendChild(document.createElement("div")) private var owner: Element? = null private var pending: Supplier? = null + private var renderedContent: Content? = null private var pointerX = 0.0 private var pointerY = 0.0 + private var placedPointerX = Double.NaN + private var placedPointerY = Double.NaN private var placedWidth = -1.0 private var placedHeight = -1.0 private var viewportWidth = -1.0 private var viewportHeight = -1.0 - private var placedPointerX = Double.NaN - private var placedPointerY = Double.NaN private var visible = false init { @@ -29,7 +31,7 @@ internal class DlcUiTooltip(private val document: Document) { panel.setAttribute("class", "dlc-tooltip") panel.setAttribute("role", "tooltip") panel.setAttribute("aria-hidden", "true") - panel.setAttribute("style", "display:none") + panel.setAttribute("style", "display:block;visibility:hidden;left:0;top:0;transform:translate(0px,0px)") panel.setTopLayer(true) document.addEventListener("wheel") { event -> val target = event.target as? Element @@ -71,56 +73,67 @@ internal class DlcUiTooltip(private val document: Document) { owner = null if (!visible) return visible = false - panel.setAttribute("style", "display:none") + panel.setInlineStyleProperty("visibility", "hidden") panel.setAttribute("aria-hidden", "true") } fun update() { - if (!document.isCurrentGeneration(generation)) return + if (!document.isCurrentGeneration(generation) || !visible && pending == null) return val vw = document.viewport.layoutWidth().toDouble() val vh = document.viewport.layoutHeight().toDouble() - val limits = "max-width:${(vw - 24).coerceAtLeast(1.0)}px;max-height:${(vh - 24).coerceAtLeast(1.0)}px" if (pending != null && !visible) { val content = pending!!.get() pending = null - panel.setInnerHTML("") - append(panel, content.title, "tooltip-title") - append(panel, content.description, "tooltip-description") - for (detail in content.details) { - if (detail.text.isBlank()) continue - val section = append(panel, "", "tooltip-detail" + if (detail.warning) " warning" else "") - append(section, detail.label, "tooltip-label") - append(section, detail.text, "tooltip-value") + if (content != renderedContent) { + panel.setInnerHTML("") + append(panel, content.title, "tooltip-title") + append(panel, content.description, "tooltip-description") + for (detail in content.details) { + if (detail.text.isBlank()) continue + val section = append(panel, "", "tooltip-detail" + if (detail.warning) " warning" else "") + append(section, detail.label, "tooltip-label") + append(section, detail.text, "tooltip-value") + } + append(panel, content.hint, "tooltip-hint") + renderedContent = content.copy(details = content.details.toList()) + document.markDirty(document.documentElement, Drawer.REORDER) } - append(panel, content.hint, "tooltip-hint") visible = true placedWidth = -1.0 placedHeight = -1.0 - viewportWidth = -1.0 - viewportHeight = -1.0 - panel.setAttribute("style", "display:block;visibility:hidden;$limits") panel.setScrollTop(0.0) } if (!visible) return + val resized = vw != viewportWidth || vh != viewportHeight + if (resized) { + panel.setInlineStyleProperty("max-width", "${(vw - 24).coerceAtLeast(1.0)}px") + panel.setInlineStyleProperty("max-height", "${(vh - 24).coerceAtLeast(1.0)}px") + } + if (placedWidth < 0 || resized) { + document.commitPendingStyleRecalcForRender() + document.commitRenderState() + } val width = panel.offsetWidth val height = panel.offsetHeight if (width <= 0 || height <= 0) return - if (width == placedWidth && height == placedHeight && vw == viewportWidth && vh == viewportHeight - && pointerX == placedPointerX && pointerY == placedPointerY) return + if (width == placedWidth && height == placedHeight && !resized && pointerX == placedPointerX && pointerY == placedPointerY) return var left = pointerX + 16 var top = pointerY + 20 if (left + width > vw - 12) left = pointerX - width - 16 if (top + height > vh - 12) top = pointerY - height - 12 left = left.coerceIn(12.0, (vw - width - 12).coerceAtLeast(12.0)) top = top.coerceIn(12.0, (vh - height - 12).coerceAtLeast(12.0)) - panel.setAttribute("style", "display:block;visibility:visible;left:${left}px;top:${top}px;$limits") - panel.setAttribute("aria-hidden", "false") + panel.setInlineStyleProperty("transform", "translate(${left}px,${top}px)") + if (placedWidth < 0) { + panel.setInlineStyleProperty("visibility", "visible") + panel.setAttribute("aria-hidden", "false") + } + placedPointerX = pointerX + placedPointerY = pointerY placedWidth = width placedHeight = height viewportWidth = vw viewportHeight = vh - placedPointerX = pointerX - placedPointerY = pointerY } private fun append(parent: Element, value: String?, className: String): Element { diff --git a/src/main/kotlin/com/cozooo/dlc_manager/sync/SyncModel.kt b/src/main/kotlin/com/cozooo/dlc_manager/sync/SyncModel.kt index 5764173b..b00c3a68 100644 --- a/src/main/kotlin/com/cozooo/dlc_manager/sync/SyncModel.kt +++ b/src/main/kotlin/com/cozooo/dlc_manager/sync/SyncModel.kt @@ -40,6 +40,11 @@ class SyncModel private constructor() { fun replacement() = replacement fun stateChanged() = stateChanged } + data class Comparison(val rows: List, val statuses: Map) { + fun rows() = rows + fun statuses() = statuses + fun status(row: Row) = statuses[row.key] + } data class Result(val completed: Int, val canceled: Boolean, val requiresRestart: Boolean, val errors: List, val succeeded: List) { fun completed() = completed fun canceled() = canceled @@ -100,8 +105,16 @@ class SyncModel private constructor() { if (direction == Direction.PUSH) Direction.PULL else Direction.PUSH, false, false)) return rows.sortedBy { it.key } } + @JvmStatic fun compare(local: List, server: List, direction: Direction, states: Boolean, mode: Mode): Comparison { + val rows = plan(local, server, direction, states, mode) + return Comparison(java.util.List.copyOf(rows), java.util.Map.copyOf(rows.associate { it.key to status(it, mode) })) + } @JvmStatic fun same(a: File?, b: File?): Boolean = a != null && b != null && a.size >= 0 && a.size == b.size && a.id == b.id && a.hash != null && a.hash == b.hash - @JvmStatic fun sameResources(a: List, b: List): Boolean = a.isNotEmpty() && b.isNotEmpty() && a.size == b.size && a.all { same(it, b.associateBy { it.id }[it.id]) } + @JvmStatic fun sameResources(a: List, b: List): Boolean { + if (a.isEmpty() || b.isEmpty() || a.size != b.size) return false + val indexed = b.associateBy { it.id } + return a.all { same(it, indexed[it.id]) } + } @JvmStatic fun resourcesMatch(source: List, target: List, mode: Mode): Boolean { if (mode == Mode.FILES) return sameResources(source, target) return true diff --git a/src/main/resources/assets/apricityui/apricity/dlc_manager/ui/manager.css b/src/main/resources/assets/apricityui/apricity/dlc_manager/ui/manager.css index aaf84f69..7741bf3e 100644 --- a/src/main/resources/assets/apricityui/apricity/dlc_manager/ui/manager.css +++ b/src/main/resources/assets/apricityui/apricity/dlc_manager/ui/manager.css @@ -2,15 +2,21 @@ html, body { display: block; width: 100%; height: 100%; margin: 0; overflow: hidden; } .dlc-manager-ui { --canvas: #11151c; --surface: #181e28; --hover: #242e3b; --line: #2b3542; + --back-size: 40px; --back-left: 24px; --back-top: 20px; --header-height: 80px; --ink: #e9edf3; --muted: #98a5b7; --accent: #a5e7d1; --accent-ink: #14352b; background: var(--canvas); color: var(--ink); font-family: "DlcInter", "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", "DejaVu Sans", sans-serif; font-size: 14px; line-height: 1.4; user-select: none; } .dlc-manager-ui * { box-sizing: border-box; } .manager { position: absolute; inset: 0; width: 100%; height: 100%; display: flex; overflow: hidden; } -.sidebar { width: 172px; flex-shrink: 0; border-right: 1px solid var(--line); overflow: hidden; opacity: 1; } -.sidebar-content { display: flex; flex-direction: column; width: 171px; min-width: 171px; height: 100%; padding: 26px 14px 18px; overflow-y: auto; overflow-x: hidden; scrollbar-color: #465469 #11151c; } +.sidebar { width: 240px; flex-shrink: 0; border-right: 1px solid var(--line); overflow: hidden; opacity: 1; } +.sidebar-content { display: flex; flex-direction: column; width: 239px; min-width: 239px; height: 100%; padding: var(--back-top) var(--back-left) 18px; overflow-y: auto; overflow-x: hidden; scrollbar-color: #465469 #11151c; } .brand { display: block; flex-shrink: 0; padding: 0 8px 24px; font-size: 18px; font-weight: 600; line-height: 1.2; margin: 0; white-space: nowrap; } +.brand-row { display: flex; align-items: center; gap: 12px; min-height: var(--back-size); flex-shrink: 0; padding-left: 52px; margin-bottom: 24px; } +.brand-row .brand { flex: 1; min-width: 0; padding: 0; white-space: normal; } +#manager-back, #page-back { position: absolute; left: var(--back-left); top: var(--back-top); width: var(--back-size); min-width: var(--back-size); height: var(--back-size); display: flex; align-items: center; justify-content: center; flex-shrink: 0; z-index: 10; } +.button.manager-back, #page-back .button { width: var(--back-size); min-width: var(--back-size); height: var(--back-size); min-height: var(--back-size); padding: 0; font-size: 28px; background: transparent; } +.manager.sidebar-collapsed .list-toolbar, .manager.sidebar-collapsed .workspace-header { padding-left: 52px; } .sidebar-tools { margin-top: auto; padding-top: 24px; flex-shrink: 0; } .manager.sidebar-collapsed .sidebar { pointer-events: none; } #sidebar-toggle-slot { display: flex; flex-shrink: 0; } @@ -83,8 +89,7 @@ html, body { display: block; width: 100%; height: 100%; margin: 0; overflow: hid #pending-status { display: block; flex: 1; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: var(--muted); font-size: 12px; } #pending-status.pending { color: var(--accent); } @media (max-height: 520px) { - .sidebar-content { padding-top: 16px; } - .brand { padding-bottom: 14px; } + .brand-row { margin-bottom: 14px; } .sidebar-divider { margin-top: 12px; margin-bottom: 12px; } .button.nav-button, .button.filter { min-height: 32px; padding-top: 7px; padding-bottom: 7px; } .workspace { padding-top: 12px; } @@ -106,3 +111,7 @@ html, body { display: block; width: 100%; height: 100%; margin: 0; overflow: hid .tooltip-value { display: block; overflow-wrap: anywhere; } .tooltip-detail.warning .tooltip-value { color: #dfc37c; } .tooltip-hint { display: block; margin-top: 12px; padding-top: 10px; border-top: 1px solid #344151; color: var(--accent); font-size: 12px; } + +@media (max-width: 760px), (max-height: 520px) { + .dlc-manager-ui { --back-left: 16px; --back-top: 12px; --header-height: 64px; } +} diff --git a/src/main/resources/assets/apricityui/apricity/dlc_manager/ui/manager.html b/src/main/resources/assets/apricityui/apricity/dlc_manager/ui/manager.html index d734138d..ee604f5f 100644 --- a/src/main/resources/assets/apricityui/apricity/dlc_manager/ui/manager.html +++ b/src/main/resources/assets/apricityui/apricity/dlc_manager/ui/manager.html @@ -8,10 +8,13 @@ DLC Manager -
+
+