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..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 @@ -23,7 +23,21 @@ protected final Element fileList(String title) { protected final void fileRow(Element list, String name, String description, Supplier checked, BooleanSupplier enabled, Runnable select, Runnable open) { - Element line = node(list, "div", "page-row file-row"); + 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) { + 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(); } }); @@ -32,9 +46,13 @@ protected final void fileRow(Element list, String name, String description, Supp 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(); @@ -59,8 +77,9 @@ 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); } + 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 0bd578bb..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")); @@ -242,18 +243,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..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 @@ -1,401 +1,232 @@ 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 Comparison comparison = new Comparison(List.of(), Map.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); + String language = com.cozooo.dlc_manager.platform.DlcPlatformServices.languageCode(); + for (Row row : shown) { + 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, () -> tooltip); + text(line, status, "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 = 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(); + 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(); syncSelections(); 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 || !selected.isEmpty() && 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() { + comparison = SyncModel.compare(local, remote, direction, states, mode); + rows = comparison.rows(); + 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(); } + @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/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/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/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/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/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..1b8d525f --- /dev/null +++ b/src/main/java/com/cozooo/dlc_manager/sync/SyncModel.java @@ -0,0 +1,213 @@ +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 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); } + 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 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()); + } + 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..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 @@ -19,7 +19,21 @@ 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?) { - val line = node(list, "div", "page-row file-row") + 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 { + 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() } } @@ -28,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 @@ -56,8 +74,9 @@ abstract class DlcFilePageScreen(page: DlcUiPage, parent: Screen?, title: Compon } selections.add(sync) sync.run() + 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 f7892a9a..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")) @@ -198,19 +199,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..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 @@ -1,410 +1,220 @@ 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 var comparison = Comparison(emptyList(), emptyMap()) + 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) + val language = com.cozooo.dlc_manager.platform.DlcPlatformServices.languageCode() + for (row in shown) { + 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) { tooltip } + text(line, status, "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 = 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(); syncSelections(); 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 || 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) } + 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() { + comparison = SyncModel.compare(local, remote, direction, states, mode) + rows = comparison.rows + 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() } + 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/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/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/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/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/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..b00c3a68 --- /dev/null +++ b/src/main/kotlin/com/cozooo/dlc_manager/sync/SyncModel.kt @@ -0,0 +1,213 @@ +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 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 + 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 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 { + 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 + } + @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/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 -
+
+