diff --git a/CHANGELOG.md b/CHANGELOG.md index e31fcdd4c..aba0dd60c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,6 @@ -## 1.6.2 -- Fix emoji rendering -- Fix rare Vulkan crash -- Add an API for detecting when HUDs are moved externally -- Fix FPS regression with 3DSkinLayers -- Fix FPS regression with HUDs -- Dropped compatibility with Hypixel SkyBlock mods (Apec, Firmament, Odin, Skyblocker, SkyHanni and other MoulConfig-based mods) on Minecraft versions below 26.1, which can no longer be used to play SkyBlock \ No newline at end of file +## Unreleased changes +- Added integration with the Hotwire mod (0.7.3 and above) for turning mods on and off, updating them and adding new ones without restarting the game + +### For mod developers +- Added `ModInfo.parentId`, `ModInfo.seenMods` and `ModInfo.refreshLoadedMods()`, along with a `ModInfo` constructor that takes the parent mod's ID +- `ConfigEntrypoint`s now also run for mods added or updated via Hotwire while the game is running diff --git a/buildSrc/src/main/kotlin/oneconfig-setup.gradle.kts b/buildSrc/src/main/kotlin/oneconfig-setup.gradle.kts index bacc36a6a..4eb925ac8 100644 --- a/buildSrc/src/main/kotlin/oneconfig-setup.gradle.kts +++ b/buildSrc/src/main/kotlin/oneconfig-setup.gradle.kts @@ -7,6 +7,7 @@ import org.gradle.api.publish.PublishingExtension import org.gradle.authentication.http.BasicAuthentication import org.gradle.plugins.signing.SigningExtension import org.jetbrains.kotlin.gradle.dsl.KotlinJvmExtension +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import gg.essential.gradle.util.RelocationTransform.Companion.registerRelocationAttribute import gg.essential.gradle.util.prebundle import java.lang.Boolean.TRUE @@ -358,6 +359,10 @@ if (enableMoulRelocatorKsp) { tasks.matching { it.name.startsWith("ksp") }.configureEach { enabled = false } + // the ksp plugin still registers its output dir, so stale output from builds where ksp ran would compile + val kspOutput = layout.buildDirectory.dir("generated/ksp").get().asFile + tasks.withType().configureEach { exclude { it.file.startsWith(kspOutput) } } + tasks.withType().configureEach { exclude { it.file.startsWith(kspOutput) } } } group = rootProject.group diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/api/platform/v1/internal/CompatibilityPlatformImpl.java b/minecraft/src/main/java/org/polyfrost/oneconfig/api/platform/v1/internal/CompatibilityPlatformImpl.java index b46118ee3..fc2d08ace 100644 --- a/minecraft/src/main/java/org/polyfrost/oneconfig/api/platform/v1/internal/CompatibilityPlatformImpl.java +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/api/platform/v1/internal/CompatibilityPlatformImpl.java @@ -1,8 +1,12 @@ package org.polyfrost.oneconfig.api.platform.v1.internal; +import java.util.List; import java.util.Set; import java.util.stream.Collectors; import net.fabricmc.loader.api.FabricLoader; +import net.fabricmc.loader.api.ModContainer; +import net.fabricmc.loader.api.metadata.CustomValue; +import net.fabricmc.loader.api.metadata.ModMetadata; import net.kyori.adventure.text.Component; import net.minecraft.client.Minecraft; import org.polyfrost.oneconfig.api.platform.v1.CompatibilityPlatform; @@ -25,6 +29,11 @@ public class CompatibilityPlatformImpl implements CompatibilityPlatform { Options options = new OptionsImpl(); Keys keys = new KeysImpl(); + //? fabric || ornithe { + // compat asks for the mods on every lookup, and building them reads all their metadata + private List modsFrom = List.of(); + private Set mods = Set.of(); + //? } @Override public void displayChatMessage(Component text) { @@ -44,32 +53,62 @@ public void displayChatMessage(Component text) { public Set getMods() { //? fabric || ornithe { - return FabricLoader.getInstance().getAllMods().stream() - .map(mod -> { - var metadata = mod.getMetadata(); - return new ModInfo( - metadata.getId(), - metadata.getName(), - metadata.getVersion().getFriendlyString(), - mod.getRootPaths().getFirst(), - metadata.getIconPath(Integer.MAX_VALUE).orElse(null), - metadata.getAuthors().stream() - .map(person -> person.getName()) - .filter(name -> !name.isBlank()) - .collect(Collectors.joining(", ")), - metadata.getContributors().stream() - .map(person -> person.getName()) - .filter(name -> !name.isBlank()) - .collect(Collectors.joining(", ")), - metadata.getDescription() - ); - }).collect(Collectors.toSet()); + FabricLoader loader = FabricLoader.getInstance(); + synchronized (loader) { + List containers = List.copyOf(loader.getAllMods()); + if (containers.equals(modsFrom)) return mods; + mods = containers.stream() + .map(mod -> { + var metadata = mod.getMetadata(); + return new ModInfo( + metadata.getId(), + metadata.getName(), + metadata.getVersion().getFriendlyString(), + mod.getRootPaths().getFirst(), + metadata.getIconPath(Integer.MAX_VALUE).orElse(null), + metadata.getAuthors().stream() + .map(person -> person.getName()) + .filter(name -> !name.isBlank()) + .collect(Collectors.joining(", ")), + metadata.getContributors().stream() + .map(person -> person.getName()) + .filter(name -> !name.isBlank()) + .collect(Collectors.joining(", ")), + metadata.getDescription(), + mod.getContainingMod().map(parent -> parent.getMetadata().getId()).orElseGet(() -> modMenuParent(metadata)) + ); + }).collect(Collectors.toSet()); + modsFrom = containers; + return mods; + } //? } //? neoforge { //? } } + //? fabric || ornithe { + /** The parent Mod Menu lists a separately shipped mod under: its "parent" custom value, a bare id or an object with an id, or Fabric API for its modules */ + private static String modMenuParent(ModMetadata metadata) { + try { + CustomValue parent = metadata.containsCustomValue("modmenu") + ? metadata.getCustomValue("modmenu").getAsObject().get("parent") + : null; + if (parent != null && parent.getType() == CustomValue.CvType.OBJECT) parent = parent.getAsObject().get("id"); + if (parent != null && parent.getType() == CustomValue.CvType.STRING) return parent.getAsString(); + } catch (ClassCastException ignored) { + // malformed metadata from another mod should not break the mod list + } + // Mod Menu files Fabric API modules under Fabric API itself even when they ship as separate jars + if (metadata.containsCustomValue("fabric-api:module-lifecycle")) { + FabricLoader loader = FabricLoader.getInstance(); + String parent = loader.isModLoaded("fabric-api") || !loader.isModLoaded("fabric") ? "fabric-api" : "fabric"; + return parent.equals(metadata.getId()) ? null : parent; + } + return null; + } + //? } + @Override public boolean isDevelopment() { return FabricLoader.getInstance().isDevelopmentEnvironment(); diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfig.java b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfig.java index 4b2dd2c4a..4056c6088 100644 --- a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfig.java +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfig.java @@ -46,6 +46,7 @@ import org.polyfrost.oneconfig.api.event.v1.events.ResourceFinishedLoading; import org.polyfrost.oneconfig.api.event.v1.events.ScreenOpenEvent; import org.polyfrost.oneconfig.api.event.v1.events.ShutdownEvent; +import org.polyfrost.oneconfig.api.event.v1.events.TickEvent; import org.polyfrost.oneconfig.api.event.v1.events.WorldEvent; import org.polyfrost.oneconfig.api.hud.v1.HudManager; import org.polyfrost.oneconfig.api.hud.v1.events.HudEditorToggleEvent; @@ -349,6 +350,7 @@ private static void registerEventHandlers() { WWaypointsCompat.register(); ThemeRegistry.INSTANCE.loadFromConfig(); }); + EventManager.register(TickEvent.End.class, ConfigEntrypoints::registerAdded); EventManager.register(WorldEvent.Load.class, e -> { showFirstLaunchNotification(); ItemCatalog.INSTANCE.markIconsAvailable(); diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfigMixinInit.java b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfigMixinInit.java index 9c077811c..7aa6cdcbb 100644 --- a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfigMixinInit.java +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfigMixinInit.java @@ -159,6 +159,9 @@ public List getMixins() { mixins.add("compat.skyhanni.Mixin_SkyHanniRenderData"); mixins.add("compat.firmament.Mixin_FirmamentHudMeta"); //?} + // the tick listener can run too late on every version Hotwire supports, as a world can open before the next tick + //? if > 1.8.9 + mixins.add("compat.Mixin_RegisterAddedConfigs"); // Firmament has no stable release for 26.2 yet //? skyblock && < 26.2 //mixins.add("compat.firmament.Mixin_FirmamentContentCapture"); diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/compat/ConfigEntrypoints.java b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/compat/ConfigEntrypoints.java index 4ffa67833..0ec6c6936 100644 --- a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/compat/ConfigEntrypoints.java +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/compat/ConfigEntrypoints.java @@ -40,16 +40,21 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Predicate; import java.util.function.Supplier; import kotlin.Unit; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.Nullable; +import org.polyfrost.oneconfig.api.config.v1.CompatSnapshots; import org.polyfrost.oneconfig.api.config.v1.Config; import org.polyfrost.oneconfig.api.config.v1.ConfigEntrypoint; import org.polyfrost.oneconfig.api.config.v1.ConfigManager; import org.polyfrost.oneconfig.api.config.v1.Tree; +import org.polyfrost.oneconfig.api.config.v1.backend.Backend; import org.polyfrost.oneconfig.api.platform.v1.ModInfo; +import org.polyfrost.oneconfig.internal.ui.api.ConfigRegistry; +import org.polyfrost.oneconfig.internal.ui.api.HotwireBridge; //? if !neoforge { import net.fabricmc.loader.api.FabricLoader; import net.fabricmc.loader.api.ModContainer; @@ -75,10 +80,21 @@ private record Skipped(String modId, Runnable extraction) { // mods whose original config is extracted after all, read without the lock by provides() private static final Set failed = ConcurrentHashMap.newKeySet(); private static final Set succeeded = new HashSet<>(); + // by mod and definition, as an update can add an entrypoint to a mod that is already decided + private static final Set ranEntrypoints = new HashSet<>(); // keyed so a hook that fires more than once before the mod is decided is only retried once private static final Map> skippedExtractions = new HashMap<>(); private static final AtomicBoolean registered = new AtomicBoolean(); - private static final Map treeOwners = new ConcurrentHashMap<>(); + private static final AtomicBoolean listening = new AtomicBoolean(); + private static volatile boolean startupDecided; + //? if !neoforge + private static volatile Map seenMods = Map.of(); + private static volatile Set lateIds = Set.of(); + // mods the loader no longer lists, which Hotwire turned off + private static Set offMods = Set.of(); + // the config ids of each mod, noted while they can be, as a class no longer resolves to its mod once that mod is off + static final Map> modConfigs = new ConcurrentHashMap<>(); + static final Map treeOwners = new ConcurrentHashMap<>(); // configs claimed only because they existed before the entrypoint ran, which do not count when it threw private static final Set earlierConfigs = ConcurrentHashMap.newKeySet(); private static final Map customOpeners = new ConcurrentHashMap<>(); @@ -154,15 +170,30 @@ public static List ownedTrees(String modId) { public static void registerAll() { if (!registered.compareAndSet(false, true)) return; if (providers().isEmpty()) return; - ConfigManager.addTreeRegistrationListener(ConfigEntrypoints::onTreeRegistered); - ConfigManager.addConfigSubmissionListener(ConfigEntrypoints::onConfigSubmitted); - claimEarlierConfigs(); // a mod may name more than one entrypoint, which only decide together whether its original is extracted Map> byMod = new LinkedHashMap<>(); for (Provider provider : providers()) byMod.computeIfAbsent(provider.modId, k -> new ArrayList<>()).add(provider); + prepare(byMod.keySet()); byMod.forEach((modId, entrypoints) -> undecided.put(modId, register(modId, entrypoints))); } + private static void prepare(Set modIds) { + if (listening.compareAndSet(false, true)) { + ConfigManager.addTreeRegistrationListener(ConfigEntrypoints::onTreeRegistered); + ConfigManager.addConfigSubmissionListener(ConfigEntrypoints::onConfigSubmitted); + } + claimEarlierConfigs(modIds); + } + + public static void registerAdded() { + //? if !neoforge { + if (!startupDecided) return; + if (modsChanged()) registerChanged(); + else if (!lateIds.isEmpty()) finishLate(() -> { + }); + //?} + } + /** * Decides for each mod that ran an entrypoint whether to keep its configs or extract its original. Called after * {@link ConfigManager#initialize()}, as a config only counts once its tree was built and registered. @@ -170,21 +201,7 @@ public static void registerAll() { // compat waits for this, so it always ends by releasing it even if deciding a mod threw public static void finishRegistration() { try { - Map pending = new LinkedHashMap<>(undecided); - undecided.clear(); - pending.forEach((modId, broken) -> { - try { - decide(modId, broken); - } catch (Throwable t) { - // a mod already marked as succeeded keeps its configs, extracting its original too would show two cards - if (isDecided(modId)) { - LOGGER.error("Failed to finish deciding the OneConfig config entrypoint of {}", modId, t); - return; - } - LOGGER.error("Failed to decide the OneConfig config entrypoint of {}, extracting its original config instead", modId, t); - fallBackToOriginal(modId); - } - }); + Map pending = decidePending(); // providers registerAll never reached, such as when it threw partway, would otherwise stay skipped forever for (String modId : providerIds()) { if (pending.containsKey(modId) || isDecided(modId)) continue; @@ -196,20 +213,52 @@ public static void finishRegistration() { } } } finally { + //? if !neoforge { + seenMods = loadedMods(); + ConfigManager.setAwaitingLateWindow(ConfigEntrypoints::awaitsLateWindow); + //?} + startupDecided = true; // caught so a broken CompatLoader never hides an exception from ConfigManager.initialize() try { CompatLoader.INSTANCE.onEntrypointsDecided(); } catch (Throwable t) { LOGGER.error("Failed to release the compat waiting for the OneConfig config entrypoints", t); } + //? if !neoforge { + // after the release above, which this must never get in the way of + try { + // a lazily created config can be made at any time + if (HotwireBridge.INSTANCE.getSupportsSwitching()) ConfigManager.addConfigSubmissionListener(ConfigEntrypoints::rememberClassOwner); + rememberConfigOwners(); + } catch (Throwable t) { + LOGGER.error("Failed to note which mods own which configs", t); + } + //?} } } + private static Map decidePending() { + Map pending = new LinkedHashMap<>(undecided); + undecided.clear(); + pending.forEach((modId, broken) -> { + try { + decide(modId, broken); + } catch (Throwable t) { + if (isDecided(modId)) { + LOGGER.error("Failed to finish deciding the OneConfig config entrypoint of {}", modId, t); + return; + } + LOGGER.error("Failed to decide the OneConfig config entrypoint of {}, extracting its original config instead", modId, t); + fallBackToOriginal(modId); + } + }); + return pending; + } + // configs a mod created before its entrypoint ran, such as during its own initializer, belong to it too, // otherwise a mod that forgot Context.register would show them next to its extracted original - private static void claimEarlierConfigs() { - Set ids = providerIds(); - for (Config config : ConfigManager.pendingConfigs()) { + private static void claimEarlierConfigs(Set ids) { + for (Config config : ConfigManager.createdConfigs()) { ModInfo owner = CompatLoader.INSTANCE.findModByClass(config.getClass()); String modId = owner == null ? null : providerOf(owner.getId()); if (modId == null || !ids.contains(modId)) continue; @@ -223,6 +272,7 @@ private static boolean register(String modId, List entrypoints) { registeringModId.set(modId); try { for (Provider provider : entrypoints) { + ranEntrypoints.add(entrypointKey(modId, provider.name)); Context context = new Context(modId); constructing.set(new IdentityHashMap<>()); try { @@ -345,8 +395,12 @@ private static void decide(String modId, boolean broken) { // keep what it registered rather than also extracting the original, which would show two cards if (broken) LOGGER.warn("Keeping the OneConfig configs {} registered before its entrypoint failed", modId); markSucceeded(modId); + for (Tree tree : owned) { + if (tree.getMetadata(CompatLoader.OPEN_ORIGINAL_SCREEN) == null) attachOpener(tree, modId); + } // CompatLoader only marked the providers it saw when it initialized CompatLoader.INSTANCE.getNativeLoadedConfigs().add(modId); + if (startupDecided) dropExtracted(modId); LOGGER.info("Using the OneConfig configs registered by {}", modId); return; } @@ -354,6 +408,25 @@ private static void decide(String modId, boolean broken) { fallBackToOriginal(modId); } + // for any mod decided late, as one gaining its first entrypoint never failed but can still have a card from before + static void dropExtracted(String modId) { + // adapters can extract under an id of the config's own, such as a ResourcefulConfig id + Set ids = new HashSet<>(modConfigs.getOrDefault(modId, Set.of())); + ids.add(modId); + ConfigManager manager = ConfigManager.active(); + for (String id : ids) { + Tree tree = manager.exists(id) ? manager.get(id) : null; + boolean owned = treeOwners.containsKey(id); + // what an entrypoint registered through compat itself is not what this is dropping + if (owned && tree != null && Boolean.TRUE.equals(tree.getMetadata(CompatSnapshots.SNAPSHOT_METADATA))) continue; + // a native config under this id already took the tree over, but the snapshot would still apply on profile changes + CompatSnapshots.untrack(id); + if (owned || tree == null || !Boolean.TRUE.equals(tree.getMetadata(Backend.UI_ONLY_METADATA))) continue; + manager.unregister(id); + ConfigRegistry.INSTANCE.unregister(id); + } + } + private static void fallBackToOriginal(String modId) { // un-marked before failing so a hook firing in between still sees provides() and queues itself try { @@ -367,6 +440,18 @@ private static synchronized boolean isDecided(String modId) { return succeeded.contains(modId) || failed.contains(modId); } + static String entrypointKey(String modId, String name) { + return modId + '=' + name; + } + + static boolean needsRunning(String modId, String name, Set succeeded, Set failed, Set ran) { + return !failed.contains(modId) && (!succeeded.contains(modId) || !ran.contains(entrypointKey(modId, name))); + } + + private static synchronized boolean needsRunning(Provider provider) { + return needsRunning(provider.modId, provider.name, succeeded, failed, ranEntrypoints); + } + private static synchronized void markSucceeded(String modId) { succeeded.add(modId); skippedExtractions.remove(modId); @@ -403,7 +488,7 @@ private static void onTreeRegistered(Tree tree) { if (current != null) { // a tree that another mod registered while this entrypoint called into it, such as through compat, stays its own ModInfo owner = CompatLoader.INSTANCE.findFirstMod(); - if (owner == null || current.equals(providerOf(owner.getId()))) treeOwners.putIfAbsent(id, current); + if (owner == null || current.equals(providerOf(owner.getId()))) claimOwner(current, id); } String modId = treeOwners.get(id); if (modId == null || tree.getMetadata(CompatLoader.OPEN_ORIGINAL_SCREEN) != null) return; @@ -419,7 +504,7 @@ private static void onConfigSubmitted(Config config) { // its class, belongs to that mod, otherwise it would count as this mod's and stop its original being extracted ModInfo owner = CompatLoader.INSTANCE.findModByClass(config.getClass()); if (owner != null && !current.equals(providerOf(owner.getId()))) return; - treeOwners.putIfAbsent(config.id, current); + claimOwner(current, config.id); Map pending = constructing.get(); if (pending == null) return; Construction construction = construction(config.getClass(), new Throwable().getStackTrace()); @@ -429,6 +514,22 @@ private static void onConfigSubmitted(Config config) { pending.put(config, construction); } + // also for compat that extracts a tree under an id other than its mod's, which only it can trace back to that mod + static void rememberConfigOwner(@Nullable String modId, String id) { + if (modId == null) return; + modConfigs.computeIfAbsent(modId, k -> ConcurrentHashMap.newKeySet()).add(id); + //? if !neoforge + handOver(modId, id); + } + + /** Returns who owns the id after {@code modId} claimed it */ + private static String claimOwner(String modId, String id) { + //? if !neoforge + handOver(modId, id); + String owner = treeOwners.putIfAbsent(id, modId); + return owner == null ? modId : owner; + } + private static void attachOpener(Tree tree, String modId) { if (failed.contains(modId)) return; Runnable opener = customOpeners.get(modId); @@ -462,11 +563,12 @@ public T register(T config) { // its original was already extracted, claiming the config now would not remove that card if (failed.contains(modId)) return config; } - String owner = treeOwners.putIfAbsent(config.id, modId); - if (owner != null && !owner.equals(modId)) { + String owner = claimOwner(modId, config.id); + if (!owner.equals(modId)) { LOGGER.warn("Mod {} registered config {} which already belongs to {}", modId, config.id, owner); return config; } + ConfigManager.claimForLateInitialization(config); // registered on purpose, so it counts even if the entrypoint throws afterwards earlierConfigs.remove(config.id); // the tree normally only exists once the config initializes, where onTreeRegistered picks it up @@ -495,10 +597,21 @@ private static List providers() { List cached = providers; if (cached != null) return cached; synchronized (DISCOVERY_LOCK) { - if (providers != null) return providers; - List found = new ArrayList<>(); - try { - //? if !neoforge { + return providers != null ? providers : discover(); + } + } + + public static void rediscover() { + synchronized (DISCOVERY_LOCK) { + if (providers != null) discover(); + } + } + + private static List discover() { + List found = new ArrayList<>(); + try { + //? if !neoforge { + synchronized (FabricLoader.getInstance()) { for (EntrypointContainer container : FabricLoader.getInstance().getEntrypointContainers(ConfigEntrypoint.ENTRYPOINT, ConfigEntrypoint.class)) { found.add(new Provider( container.getProvider().getMetadata().getId(), @@ -506,29 +619,203 @@ private static List providers() { container::getEntrypoint )); } - //?} - } catch (Throwable t) { - // not cached so a discovery attempted too early is retried - if (!discoveryFailureLogged) LOGGER.error("Failed to discover OneConfig config entrypoints", t); - discoveryFailureLogged = true; - return Collections.emptyList(); - } - Set ids = new LinkedHashSet<>(); - for (Provider provider : found) ids.add(provider.modId); - Map nested = new HashMap<>(); - //? if !neoforge { - for (String id : ids) { - FabricLoader.getInstance().getModContainer(id).ifPresent(container -> collectNested(container, id, ids, nested)); } //?} - nestedIds = Collections.unmodifiableMap(nested); - providerIds = Collections.unmodifiableSet(ids); - providers = Collections.unmodifiableList(found); - return providers; + } catch (Throwable t) { + // not cached so a discovery attempted too early is retried + if (!discoveryFailureLogged) LOGGER.error("Failed to discover OneConfig config entrypoints", t); + discoveryFailureLogged = true; + return Collections.emptyList(); } + Set ids = new LinkedHashSet<>(); + for (Provider provider : found) ids.add(provider.modId); + Map nested = new HashMap<>(); + //? if !neoforge { + for (String id : ids) { + FabricLoader.getInstance().getModContainer(id).ifPresent(container -> collectNested(container, id, ids, nested)); + } + //?} + nestedIds = Collections.unmodifiableMap(nested); + providerIds = Collections.unmodifiableSet(ids); + providers = Collections.unmodifiableList(found); + return providers; } //? if !neoforge { + private static void registerChanged() { + Map mods = loadedMods(); + Set changedIds = changedIds(mods, seenMods); + Set newIds = new HashSet<>(changedIds); + newIds.removeAll(seenMods.keySet()); + Set removed = removedIds(mods, seenMods); + // an update can take a mod out of the loader for a moment, and replaces its container + boolean existing = changedIds.size() > newIds.size() || !removed.isEmpty(); + if (HotwireBridge.INSTANCE.isStarting(newIds) || existing && HotwireBridge.INSTANCE.isUpdating()) return; + // a mod can also register a config explicitly after its entrypoint returned + rememberConfigOwners(); + Map seen = seenMods; + seenMods = mods; + offMods = offAfter(offMods, mods, seen); + ConfigRegistry.INSTANCE.setOff(offConfigIds(offMods, modConfigs)); + HotwireBridge.INSTANCE.invalidate(); + rediscover(); + Set retried = retriedIds(providerIds(), failed, changedIds, seen.keySet()); + failed.removeAll(retried); + Map> added = new LinkedHashMap<>(); + for (Provider provider : providers()) { + if (needsRunning(provider)) added.computeIfAbsent(provider.modId, k -> new ArrayList<>()).add(provider); + } + prepare(added.keySet()); + Set late = new HashSet<>(lateIds); + late.addAll(changedIds); + late.addAll(replacedBy(changedIds, offMods, mods)); + lateIds = late; + finishLate(() -> added.forEach((modId, entrypoints) -> { + boolean broken = register(modId, entrypoints); + // a mod that already has working configs keeps them whatever an entrypoint it gained does + if (!isDecided(modId)) undecided.put(modId, broken); + })); + } + + private static void finishLate(Runnable creator) { + if (ConfigManager.initializeLate(lateConfigs(lateIds), creator)) return; + lateIds = Set.of(); + decidePending(); + //? modmenu_compat + if (CompatLoader.INSTANCE.hasMod("modmenu")) ModMenuCompat.refresh(); + rememberConfigOwners(); + // what a live update's copy made in this window is no longer off with the mod it replaces + ConfigRegistry.INSTANCE.setOff(offConfigIds(offMods, modConfigs)); + } + + // Hotwire can run a live update from a relocated copy under a new id that provides the old one, and the configs + // that copy makes again are its own from then on, while any it no longer makes stay off with the old mod + private static void handOver(String modId, String id) { + synchronized (FabricLoader.getInstance()) { + FabricLoader.getInstance().getModContainer(modId) + // the loader also finds a mod under the ids it provides + .filter(mod -> mod.getMetadata().getId().equals(modId)) + .ifPresent(mod -> handOver(mod.getMetadata().getProvides(), modId, id, modConfigs, treeOwners)); + } + } + + static void handOver(Collection replaced, String modId, String id, Map> configs, Map owners) { + for (String old : replaced) { + Set theirs = configs.get(old); + if (theirs != null) theirs.remove(id); + owners.replace(id, old, modId); + } + } + + // the mods a change's copies replace, whose older config instances those copies may take over from + static Set replacedBy(Set changed, Set off, Map mods) { + Set replaced = new HashSet<>(); + for (String id : changed) replaced.addAll(mods.get(id).getMetadata().getProvides()); + replaced.retainAll(off); + return replaced; + } + + // only Hotwire takes mods out of the loader, so everyone else skips looking up every config's mod + private static void rememberConfigOwners() { + if (!HotwireBridge.INSTANCE.getSupportsSwitching()) return; + treeOwners.forEach((id, modId) -> rememberConfigOwner(modId, id)); + ConfigManager.createdConfigs().forEach(ConfigEntrypoints::rememberClassOwner); + } + + private static void rememberClassOwner(Config config) { + ModInfo owner = CompatLoader.INSTANCE.findModByClass(config.getClass()); + if (owner != null) rememberConfigOwner(owner.getId(), config.id); + } + + // their extracted and Mod Menu cards go by the mod's id, unless a mod that's still on has a config under that id + static Set offConfigIds(Set off, Map> configs) { + Set kept = new HashSet<>(); + configs.forEach((modId, owned) -> { + if (!off.contains(modId)) kept.addAll(owned); + }); + Set ids = new HashSet<>(); + for (String modId : off) { + if (!kept.contains(modId)) ids.add(modId); + if (!kept.contains(modId + ".json")) ids.add(modId + ".json"); + ids.addAll(configs.getOrDefault(modId, Set.of())); + } + return ids; + } + + // read up front, as an entrypoint running in the window claiming an id must not make another mod's config its own + static Predicate lateConfigs(Set ids) { + Map owners = new HashMap<>(treeOwners); + return config -> { + ModInfo classOwner = CompatLoader.INSTANCE.findModByClass(config.getClass()); + return isLate(config.id, owners.get(config.id), classOwner == null ? null : classOwner.getId(), ids, modConfigs); + }; + } + + // an entrypoint can register a library's config class as its mod's own, so the id's owner goes before the class's, + // and an update can take the class away, which only leaves what was noted before that to go by + static boolean isLate(String id, @Nullable String owner, @Nullable String classOwner, Set ids, Map> configs) { + if (owner != null) return ids.contains(owner); + if (classOwner != null) return ids.contains(classOwner); + return ids.stream().anyMatch(modId -> configs.getOrDefault(modId, Set.of()).contains(id)); + } + + private static boolean awaitsLateWindow(Config config) { + Set ids = new HashSet<>(lateIds); + if (modsChanged()) ids.addAll(changedIds(loadedMods(), seenMods)); + return !ids.isEmpty() && lateConfigs(ids).test(config); + } + + private static boolean modsChanged() { + synchronized (FabricLoader.getInstance()) { + return modsChanged(FabricLoader.getInstance().getAllMods(), seenMods); + } + } + + static boolean modsChanged(Collection mods, Map seen) { + if (mods.size() != seen.size()) return true; + for (ModContainer mod : mods) { + if (seen.get(mod.getMetadata().getId()) != mod) return true; + } + return false; + } + + static Set changedIds(Map mods, Map seen) { + Set changed = new HashSet<>(); + mods.forEach((id, mod) -> { + if (seen.get(id) != mod) changed.add(id); + }); + return changed; + } + + static Set removedIds(Map mods, Map seen) { + Set removed = new HashSet<>(seen.keySet()); + removed.removeAll(mods.keySet()); + return removed; + } + + static Set offAfter(Set off, Map mods, Map seen) { + Set now = new HashSet<>(off); + now.addAll(removedIds(mods, seen)); + now.removeAll(mods.keySet()); + return now; + } + + static Set retriedIds(Set providers, Set failed, Set changed, Set seen) { + Set retried = new HashSet<>(changed); + retried.retainAll(seen); + retried.retainAll(failed); + retried.retainAll(providers); + return retried; + } + + private static Map loadedMods() { + Map mods = new HashMap<>(); + synchronized (FabricLoader.getInstance()) { + for (ModContainer mod : FabricLoader.getInstance().getAllMods()) mods.put(mod.getMetadata().getId(), mod); + } + return mods; + } + // a nested mod that provides an entrypoint itself is left as its own provider private static void collectNested(ModContainer container, String providerId, Set ids, Map nested) { for (ModContainer child : container.getContainedMods()) { diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/compat/Mixin_RegisterAddedConfigs.java b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/compat/Mixin_RegisterAddedConfigs.java new file mode 100644 index 000000000..542d8a7b8 --- /dev/null +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/compat/Mixin_RegisterAddedConfigs.java @@ -0,0 +1,20 @@ +package org.polyfrost.oneconfig.internal.mixin.compat; + +//? if > 1.8.9 { +import net.minecraft.client.Minecraft; +import org.polyfrost.oneconfig.internal.compat.ConfigEntrypoints; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(Minecraft.class) +public class Mixin_RegisterAddedConfigs { + + @Inject(method = "createWorldOpenFlows", at = @At("HEAD")) + private void registerBeforeWorldOpens(CallbackInfoReturnable cir) { + ConfigEntrypoints.registerAdded(); + } + +} +//?} diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/AxolotlClientConfigCompat.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/AxolotlClientConfigCompat.kt index eeab78429..1e0bb610b 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/AxolotlClientConfigCompat.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/AxolotlClientConfigCompat.kt @@ -65,6 +65,7 @@ internal object AxolotlClientConfigCompat { val tree = Tree.tree() tree.id = uniqueId(usedTreeIds, idPart(rootName, "axolotlclient_config")) + ConfigEntrypoints.rememberConfigOwner(mod?.id, tree.id) tree.title = titleOf(mod, rootName) tree.noCache = true tree.saveFunction = Runnable { diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/CompatLoader.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/CompatLoader.kt index 0c42056a2..87ae7029c 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/CompatLoader.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/CompatLoader.kt @@ -21,8 +21,6 @@ object CompatLoader { private var bypassDelay = false - private val pathFactory: MutableMap String> = mutableMapOf() - // concurrent as compat hooks may fire on other threads while ConfigEntrypoints un-marks a failed mod val nativeLoadedConfigs: MutableSet = ConcurrentHashMap.newKeySet() @@ -56,7 +54,36 @@ object CompatLoader { "org.polyfrost.polyui" ) - private val ownerByClassName = ConcurrentHashMap>() + private class ModIndex(val mods: Set) { + private val roots = mods.mapNotNull { mod -> mod.file?.let { mod to it.toUri().toString() } } + private val ownerByClassName = ConcurrentHashMap>() + + fun ownerOf(className: String): ModInfo? = + ownerByClassName.computeIfAbsent(className) { Optional.ofNullable(resolveOwner(it)) }.orElse(null) + + private fun resolveOwner(className: String): ModInfo? { + val resourcePath = className.replace(".", "/") + ".class" + roots.forEach { (mod, root) -> + runCatching { + URI.create(root + resourcePath).toURL().openStream().use {} // throws if unable to open + return mod + } + } + return null + } + } + + @Volatile + private var index = ModIndex(emptySet()) + + private fun syncedIndex(): ModIndex { + val mods = Platform.compatibility().mods + return index.takeIf { it.mods == mods } ?: ModIndex(mods).also { + ModInfo.refreshLoadedMods() + ConfigEntrypoints.rediscover() + index = it + } + } fun findFirstMod(): ModInfo? { forcedModId.get()?.let { forcedId -> @@ -64,9 +91,7 @@ object CompatLoader { if (forcedMod != null) return forcedMod } val callerClass = callerClassName() ?: return null - return ownerByClassName - .computeIfAbsent(callerClass) { Optional.ofNullable(resolveOwner(it)) } - .orElse(null) + return syncedIndex().ownerOf(callerClass) } private fun callerClassName(): String? = @@ -74,20 +99,7 @@ object CompatLoader { illegalPaths.none { path -> element.className.startsWith(path) } }?.className - private fun resolveOwner(className: String): ModInfo? { - val resourcePath = className.replace(".", "/") + ".class" - pathFactory.entries.forEach { (mod, uri) -> - runCatching { - URI.create(uri(resourcePath)).toURL().openStream().use {} // throws if unable to open - return mod - } - } - return null - } - - fun findModByClass(cls: Class<*>): ModInfo? = ownerByClassName - .computeIfAbsent(cls.name) { Optional.ofNullable(resolveOwner(it)) } - .orElse(null) + fun findModByClass(cls: Class<*>): ModInfo? = syncedIndex().ownerOf(cls.name) fun markFirstModAsSkip() { val mod = findFirstMod() @@ -119,7 +131,7 @@ object CompatLoader { fun skipExtraction(modId: String?, key: Any, retry: Runnable): Boolean = ConfigEntrypoints.skipExtraction(modId, key, retry) || (modId != null && modId in nativeLoadedConfigs) - fun hasMod(id: String): Boolean = ModInfo.loadedMods.any { it.id == id } + fun hasMod(id: String): Boolean = syncedIndex().mods.any { it.id == id } private const val MOD_MENU_CLASS = "com.terraformersmc.modmenu.ModMenu" @@ -170,12 +182,6 @@ object CompatLoader { private var entrypointsDecided = false init { - ModInfo.loadedMods.forEach { mod -> - mod.file?.let { - pathFactory[mod] = it.toUri().toString()::plus - } - } - // filtered so a mod whose entrypoint already failed is never marked again nativeLoadedConfigs.addAll(ConfigEntrypoints.providerModIds().filter(ConfigEntrypoints::provides)) } @@ -223,6 +229,9 @@ object CompatLoader { if (!bypassDelay) list.add(priority to init) !bypassDelay } - if (!queued) init() + if (!queued) { + syncedIndex() + init() + } } } \ No newline at end of file diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/KaleidoCompat.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/KaleidoCompat.kt index 2402e36a6..19a0d4ed8 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/KaleidoCompat.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/KaleidoCompat.kt @@ -78,6 +78,7 @@ object KaleidoCompat { val tree = Tree.tree() tree.id = uniqueTreeId(modId, id) + ConfigEntrypoints.rememberConfigOwner(mod?.id, tree.id) tree.title = mod?.name?.takeIf { it.isNotBlank() } ?: translateOrNull("$modId.title") ?: prettify(id) diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MidnightLibCompat.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MidnightLibCompat.kt index 0a513e1d4..b6df041b4 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MidnightLibCompat.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MidnightLibCompat.kt @@ -67,6 +67,7 @@ object MidnightLibCompat { val tree = Tree.tree() tree.id = modid + ConfigEntrypoints.rememberConfigOwner(CompatLoader.findModByClass(config)?.id, tree.id) tree.title = mod?.name?.takeIf { it.isNotBlank() } ?: translateOrNull("$modid.midnightconfig.title") ?: modid diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/ModMenuCompat.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/ModMenuCompat.kt index db56a7225..85b86d1d7 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/ModMenuCompat.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/ModMenuCompat.kt @@ -26,16 +26,16 @@ object ModMenuCompat { ) fun preLoad() = CompatLoader.requireTranslations(-1000, true) { - ModMenu.ROOT_MODS.forEach { (_, mod) -> - if (mod.id in ownModIds) return@forEach - // never call getConfigScreen here - // it builds widgets that bake font glyphs and preLoad runs off-frame during - // ResourceFinishedLoading where that corrupts the vanilla font atlas - // hasConfigScreen only looks up the factory so the screen is built later in a render frame - if (runCatching { ModMenu.hasConfigScreen(mod.id) }.getOrDefault(false)) { - mods.add(mod) - } - } + mods.addAll(findUnlisted()) + } + + private fun findUnlisted(): List = ModMenu.ROOT_MODS.values.filter { mod -> + // never call getConfigScreen here + // it builds widgets that bake font glyphs and preLoad runs off-frame during + // ResourceFinishedLoading where that corrupts the vanilla font atlas + // hasConfigScreen only looks up the factory so the screen is built later in a render frame + mod.id !in ownModIds && mods.none { it.id == mod.id } && + runCatching { ModMenu.hasConfigScreen(mod.id) }.getOrDefault(false) } @JvmStatic @@ -44,12 +44,31 @@ object ModMenuCompat { postLoad() } - fun postLoad() = CompatLoader.requireTranslations(1000, true) { - val nativeCoveredMods = mods.mapNotNull { mod -> - val nativeChild = mod.findNativeLoadedChild() ?: return@mapNotNull null - mod.aliasNativeChildConfig(nativeChild) - mod.id - }.toSet() + fun postLoad() = CompatLoader.requireTranslations(1000, true) { list(mods) } + + @JvmStatic + fun refresh() = CompatLoader.requireTranslations(1001, true) { + val added = findUnlisted() + coveredByNativeChild(mods).forEach(::removePlaceholder) + mods.addAll(added) + list(added) + } + + private fun coveredByNativeChild(mods: List): Set = mods.mapNotNull { mod -> + val nativeChild = mod.findNativeLoadedChild() ?: return@mapNotNull null + mod.aliasNativeChildConfig(nativeChild) + mod.id + }.toSet() + + private fun removePlaceholder(id: String) { + val manager = ConfigManager.active() + if (!manager.exists(id) || manager.get(id).getMetadata(Backend.UI_PLACEHOLDER_METADATA) != true) return + manager.unregister(id) + ConfigRegistry.unregister(id) + } + + private fun list(mods: List) { + val nativeCoveredMods = coveredByNativeChild(mods) val foundMods = mods.filterNot { CompatLoader.nativeLoadedConfigs.contains(it.id) || diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/OslConfigCompat.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/OslConfigCompat.kt index 92562d010..60a4b4e30 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/OslConfigCompat.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/OslConfigCompat.kt @@ -61,6 +61,7 @@ internal object OslConfigCompat { val namespace = config.namespace?.takeIf { it.isNotBlank() } ?: config.name val tree = Tree.tree() tree.id = uniqueId(usedTreeIds, idPart(namespace, "osl_config")) + ConfigEntrypoints.rememberConfigOwner(mod?.id, tree.id) tree.title = titleOf(config, mod, namespace) tree.noCache = true tree.saveFunction = Runnable { runCatching { ConfigManager.save(config) } } diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/RConfigCompat.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/RConfigCompat.kt index 884e0bd7a..cce0f1db1 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/RConfigCompat.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/RConfigCompat.kt @@ -72,6 +72,7 @@ internal object RConfigCompat : Logger by LogManager.getLogger("OneConfig/Rconfi info("Creating config wrapper for ${config.id()}!") val tree = Tree.tree() tree.id = config.id() + ConfigEntrypoints.rememberConfigOwner(mod?.id, tree.id) //? >= 1.21.8 { tree.title = config.info().title().toComponent() tree.description = config.info().description().toComponent() diff --git a/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json index a8468d322..9def383cc 100644 --- a/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json +++ b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json @@ -228,6 +228,86 @@ "oneconfig.page.profiles.search": "Search Profiles...", "oneconfig.page.keybinds.title": "Keybinds", "oneconfig.page.keybinds.search": "Search Keybinds...", + "oneconfig.page.hotwire.title": "Hotwire", + "oneconfig.page.hotwire.search": "Search Hotwire...", + "oneconfig.page.hotwire.tagline": "Manage your mods in real time.", + "oneconfig.page.hotwire.credit": "Thanks %s!", + "oneconfig.page.hotwire.add_mods": "Add mods", + "oneconfig.page.hotwire.update_available": "Update available", + "oneconfig.page.hotwire.filter.updates": "Has update", + "oneconfig.page.hotwire.report.copied": "Bug report copied. Paste it wherever you're asking for help.", + "oneconfig.page.hotwire.report.copy": "Copy bug report", + "oneconfig.page.hotwire.report.failed": "The bug report couldn't be written: %s", + "oneconfig.page.hotwire.report.path_copied": "Bug report written, and its location copied.", + "oneconfig.page.hotwire.report.written": "Your bug report is at %s.", + "oneconfig.page.hotwire.layout.grid": "Grid", + "oneconfig.page.hotwire.layout.list": "List", + "oneconfig.page.hotwire.add.title": "Add or update a mod", + "oneconfig.page.hotwire.add.hint": "Pick a mod jar. Hotwire shows what it would change before anything happens, and updates the mod if it's already installed.", + "oneconfig.page.hotwire.add.pick": "Choose a mod jar", + "oneconfig.page.hotwire.add.pick_filter": "Mod jars", + "oneconfig.page.hotwire.add.back": "Back", + "oneconfig.page.hotwire.source.file": "From file", + "oneconfig.page.hotwire.source.modrinth": "Modrinth", + "oneconfig.page.hotwire.source.updates": "Updates", + "oneconfig.page.hotwire.modrinth.loading": "Searching Modrinth…", + "oneconfig.page.hotwire.modrinth.failed": "Couldn't reach Modrinth: %s", + "oneconfig.page.hotwire.modrinth.downloads": "%s downloads", + "oneconfig.page.hotwire.modrinth.install": "Install", + "oneconfig.page.hotwire.modrinth.installed": "Installed", + "oneconfig.page.hotwire.modrinth.update": "Update", + "oneconfig.page.hotwire.modrinth.more": "Load more", + "oneconfig.page.hotwire.updates.bar": "Update %s", + "oneconfig.page.hotwire.updates.running": "Updating %s…", + "oneconfig.page.hotwire.updates.checking": "Checking for updates…", + "oneconfig.page.hotwire.updates.failed": "Couldn't check for updates: %s", + "oneconfig.page.hotwire.updates.none": "Everything is up to date.", + "oneconfig.page.hotwire.updates.done": "Updates finished.", + "oneconfig.page.hotwire.updates.done.now": "Updated now: %s.", + "oneconfig.page.hotwire.updates.done.next_start": "Updates at next launch: %s.", + "oneconfig.page.hotwire.updates.downloading": "Downloading %s… %s%%", + "oneconfig.page.hotwire.updates.reopen": "It has to save and reopen your world, so update it on its own.", + "oneconfig.page.hotwire.updates.review": "Review these updates", + "oneconfig.page.hotwire.update.reopen": "Save and update", + "oneconfig.page.hotwire.add.save": "Save to Downloads", + "oneconfig.page.hotwire.downloads_folder": "Downloads folder", + "oneconfig.page.hotwire.modrinth.search": "Search Modrinth...", + "oneconfig.page.hotwire.modrinth.no_version": "No version of this fits this game.", + "oneconfig.page.hotwire.add.waiting": "Waiting for Hotwire…", + "oneconfig.page.hotwire.add.reading": "Reading the jar…", + "oneconfig.page.hotwire.add.downloading": "Downloading… %s%%", + "oneconfig.page.hotwire.add.cancel": "Cancel", + "oneconfig.page.hotwire.add.pause": "Pause", + "oneconfig.page.hotwire.add.resume": "Resume", + "oneconfig.page.hotwire.add.paused": "Paused at %s%%", + "oneconfig.page.hotwire.change.as_planned": "Apply", + "oneconfig.page.hotwire.change.now": "Apply now", + "oneconfig.page.hotwire.change.reopen": "Save and apply", + "oneconfig.page.hotwire.change.next_start": "Apply at next launch", + "oneconfig.page.hotwire.add.now": "Add now", + "oneconfig.page.hotwire.add.reopen": "Save and add", + "oneconfig.page.hotwire.add.next_start": "Add at next launch", + "oneconfig.page.hotwire.add.working": "Hotwire is working on it… %s%%", + "oneconfig.page.hotwire.add.sent": "Sent to Hotwire. It posts the result in chat.", + "oneconfig.page.hotwire.update.as_planned": "Update", + "oneconfig.page.hotwire.update.now": "Update now", + "oneconfig.page.hotwire.update.next_start": "Update at next launch", + "oneconfig.page.hotwire.switch.conflict":"Some of these mods need others you're turning off. Switch them separately.", + "oneconfig.page.hotwire.switch.too_many": "Only %s mods can be switched at once.", + "oneconfig.page.hotwire.switch.checking": "Checking what this changes…", + "oneconfig.page.hotwire.switch.mixed": "Switch %s", + "oneconfig.page.hotwire.switch.off": "Turn off %s", + "oneconfig.page.hotwire.switch.on": "Turn on %s", + "oneconfig.page.hotwire.switch.starting": "Hotwire is still starting, so mods can't be switched yet.", + "oneconfig.page.hotwire.switch.running": "Switching… %s%%", + "oneconfig.page.hotwire.switch.selected": "%s selected", + "oneconfig.page.hotwire.switch.clear": "Clear", + "oneconfig.page.hotwire.result.done": "Done.", + "oneconfig.page.hotwire.result.reopen": "Reopen your world to finish this.", + "oneconfig.page.hotwire.result.failed": "That didn't work.", + "oneconfig.page.hotwire.unavailable": "Hotwire is not available.", + "oneconfig.page.hotwire.update.self": "Hotwire can't update itself while the game runs, so it updates at the next launch.", + "oneconfig.page.hotwire.upgrade.page": "This page needs Hotwire 0.7.3 or newer. Update Hotwire to use it.", "oneconfig.page.themes.title": "Themes", "oneconfig.page.themes.search": "Search Themes...", "oneconfig.page.preferences.title": "Preferences", diff --git a/minecraft/src/main/resources/assets/oneconfig/open-classes.txt b/minecraft/src/main/resources/assets/oneconfig/open-classes.txt index 2b79a910c..2776496e7 100644 --- a/minecraft/src/main/resources/assets/oneconfig/open-classes.txt +++ b/minecraft/src/main/resources/assets/oneconfig/open-classes.txt @@ -2517,3 +2517,176 @@ it.unimi.dsi.fastutil.objects.ReferenceOpenHashSet$1 androidx.compose.foundation.interaction.HoverInteraction$Exit androidx.compose.runtime.composer.gapbuffer.changelist.Operation$UpdateAuxData androidx.compose.runtime.tooling.CompositionObserver +org.polyfrost.oneconfig.internal.ui.api.HotwireBridge +org.polyfrost.oneconfig.internal.ui.api.HotwireBridge$AppliedUpdates +org.polyfrost.oneconfig.internal.ui.api.HotwireBridge$DownloadProgress +org.polyfrost.oneconfig.internal.ui.api.HotwireBridge$JarAction +org.polyfrost.oneconfig.internal.ui.api.HotwireBridge$JarPlan +org.polyfrost.oneconfig.internal.ui.api.HotwireBridge$ModState +org.polyfrost.oneconfig.internal.ui.api.HotwireBridge$OpeningPrompt +org.polyfrost.oneconfig.internal.ui.api.HotwireBridge$Preview +org.polyfrost.oneconfig.internal.ui.api.HotwireBridge$Result +org.polyfrost.oneconfig.internal.ui.api.HotwireBridge$StoreCategory +org.polyfrost.oneconfig.internal.ui.api.HotwireBridge$StoreHit +org.polyfrost.oneconfig.internal.ui.api.HotwireBridge$StorePage +org.polyfrost.oneconfig.internal.ui.api.HotwireBridge$StoreUpdate +org.polyfrost.oneconfig.internal.ui.navigation.graph.ComposableSingletons$HotwireKt +org.polyfrost.oneconfig.internal.ui.navigation.graph.HotwireAddRoute +org.polyfrost.oneconfig.internal.ui.navigation.graph.HotwireGraph +org.polyfrost.oneconfig.internal.ui.navigation.graph.HotwireKt +org.polyfrost.oneconfig.internal.ui.navigation.graph.HotwireRoute +org.polyfrost.oneconfig.internal.ui.screens.ComposableSingletons$HotwireAddKt +org.polyfrost.oneconfig.internal.ui.screens.DownloadWatch +org.polyfrost.oneconfig.internal.ui.screens.FileSelection +org.polyfrost.oneconfig.internal.ui.screens.HotwireAddKt +org.polyfrost.oneconfig.internal.ui.screens.HotwireAddKt$HotwireAdd$2$2$1 +org.polyfrost.oneconfig.internal.ui.screens.HotwireAddKt$HotwireAdd$lambda$24$0$$inlined$onDispose$1 +org.polyfrost.oneconfig.internal.ui.screens.HotwireAddKt$HotwireAdd$review$1 +org.polyfrost.oneconfig.internal.ui.screens.HotwireAddKt$HotwireAdd$review$1$iconPath$1 +org.polyfrost.oneconfig.internal.ui.screens.HotwireAddKt$HotwireAdd$review$2 +org.polyfrost.oneconfig.internal.ui.screens.HotwireAddKt$HotwireAdd$review$2$loaded$1 +org.polyfrost.oneconfig.internal.ui.screens.HotwireAddKt$ModrinthSource$1$1 +org.polyfrost.oneconfig.internal.ui.screens.HotwireAddKt$ModrinthSource$1$1$page$1 +org.polyfrost.oneconfig.internal.ui.screens.HotwireAddKt$ModrinthSource$2$1 +org.polyfrost.oneconfig.internal.ui.screens.HotwireAddKt$PlanCard$1$1 +org.polyfrost.oneconfig.internal.ui.screens.HotwireAddKt$ResultList$lambda$0$0$0$$inlined$items$default$1 +org.polyfrost.oneconfig.internal.ui.screens.HotwireAddKt$ResultList$lambda$0$0$0$$inlined$items$default$2 +org.polyfrost.oneconfig.internal.ui.screens.HotwireAddKt$ResultList$lambda$0$0$0$$inlined$items$default$3 +org.polyfrost.oneconfig.internal.ui.screens.HotwireAddKt$ResultList$lambda$0$0$0$$inlined$items$default$4 +org.polyfrost.oneconfig.internal.ui.screens.HotwireAddKt$RowIcon$path$2$1 +org.polyfrost.oneconfig.internal.ui.screens.HotwireAddKt$RowIcon$path$2$1$1 +org.polyfrost.oneconfig.internal.ui.screens.HotwireAddKt$UpdatesSource$1$1 +org.polyfrost.oneconfig.internal.ui.screens.HotwireAddKt$WhenMappings +org.polyfrost.oneconfig.internal.ui.screens.HotwireKt +org.polyfrost.oneconfig.internal.ui.screens.HotwireKt$Hotwire$4$1 +org.polyfrost.oneconfig.internal.ui.screens.HotwireKt$Hotwire$6$1$2$1$1$1$2$2$1 +org.polyfrost.oneconfig.internal.ui.screens.HotwireKt$Hotwire$lambda$13$$inlined$sortedBy$1 +org.polyfrost.oneconfig.internal.ui.screens.HotwireKt$Hotwire$lambda$15$0$1$0$0$0$$inlined$items$default$1 +org.polyfrost.oneconfig.internal.ui.screens.HotwireKt$Hotwire$lambda$15$0$1$0$0$0$$inlined$items$default$2 +org.polyfrost.oneconfig.internal.ui.screens.HotwireKt$Hotwire$lambda$15$0$1$0$0$0$$inlined$items$default$3 +org.polyfrost.oneconfig.internal.ui.screens.HotwireKt$Hotwire$lambda$15$0$1$0$0$0$$inlined$items$default$4 +org.polyfrost.oneconfig.internal.ui.screens.HotwireKt$Hotwire$lambda$15$0$1$0$0$0$$inlined$items$default$5 +org.polyfrost.oneconfig.internal.ui.screens.HotwireKt$Hotwire$lambda$8$0$$inlined$onDispose$1 +org.polyfrost.oneconfig.internal.ui.screens.HotwireKt$HotwireTile$icon$2$1 +org.polyfrost.oneconfig.internal.ui.screens.HotwireKt$HotwireTile$icon$2$1$path$1 +org.polyfrost.oneconfig.internal.ui.screens.HotwireKt$OpeningPromptCard$1$1 +org.polyfrost.oneconfig.internal.ui.screens.HotwireKt$SwitchBar$1$1 +org.polyfrost.oneconfig.internal.ui.screens.HotwireKt$SwitchBar$1$1$1 +org.polyfrost.oneconfig.internal.ui.screens.HotwireKt$SwitchBar$2$1 +org.polyfrost.oneconfig.internal.ui.screens.HotwireKt$SwitchBar$3$1 +org.polyfrost.oneconfig.internal.ui.screens.HotwireKt$SwitchBar$4$4$1 +org.polyfrost.oneconfig.internal.ui.screens.HotwireKt$UpdateReviewCard$1$1 +org.polyfrost.oneconfig.internal.ui.screens.HotwireKt$modFamilies$lambda$2$$inlined$sortedBy$1 +org.polyfrost.oneconfig.internal.ui.screens.HotwireKt$rememberDownloadWatch$1$1 +org.polyfrost.oneconfig.internal.ui.screens.HotwireUpdates +org.polyfrost.oneconfig.internal.ui.screens.HotwireUpdates$Review +org.polyfrost.oneconfig.internal.ui.screens.HotwireUpdates$acceptReview$1 +org.polyfrost.oneconfig.internal.ui.screens.HotwireUpdates$acceptReview$1$failed$1$1 +org.polyfrost.oneconfig.internal.ui.screens.HotwireUpdates$reviewUpdates$1 +org.polyfrost.oneconfig.internal.ui.screens.HotwireUpdates$reviewUpdates$1$plan$1 +org.polyfrost.oneconfig.internal.ui.screens.HotwireUpdates$reviewUpdates$1$plan$3 +androidx.compose.animation.AnimatedContentScope +androidx.compose.foundation.ScrollKt +androidx.compose.foundation.ScrollState +androidx.compose.foundation.lazy.LazyDslKt +androidx.compose.foundation.lazy.LazyItemScope +androidx.compose.foundation.lazy.LazyListScope +androidx.compose.foundation.lazy.LazyListState +androidx.compose.foundation.lazy.LazyListStateKt +androidx.compose.foundation.lazy.grid.GridCells$Adaptive +androidx.compose.runtime.Composer +androidx.compose.runtime.CompositionLocal +androidx.compose.runtime.IntState +androidx.compose.runtime.MonotonicFrameClockKt +androidx.compose.runtime.MutableIntState +androidx.compose.runtime.MutableState +androidx.compose.runtime.ProduceStateScope +androidx.compose.runtime.ProvidableCompositionLocal +androidx.compose.runtime.SnapshotIntStateKt +androidx.compose.runtime.SnapshotMutationPolicy +androidx.compose.runtime.SnapshotStateKt +androidx.compose.runtime.State +androidx.compose.runtime.internal.ComposableLambda +androidx.compose.runtime.internal.ComposableLambdaKt +androidx.compose.ui.Modifier +androidx.compose.ui.graphics.Color +androidx.compose.ui.graphics.Shape +androidx.compose.ui.text.font.FontWeight +androidx.compose.ui.unit.Dp +androidx.navigation.NavBackStackEntry +kotlin.Lazy +kotlin.LazyKt +kotlin.LazyThreadSafetyMode +kotlin.Metadata +kotlin.NoWhenBranchMatchedException +kotlin.Pair +kotlin.Result +kotlin.Result$Companion +kotlin.ResultKt +kotlin.TuplesKt +kotlin.Unit +kotlin.collections.ArraysKt +kotlin.collections.CollectionsKt +kotlin.collections.MapsKt +kotlin.collections.SetsKt +kotlin.comparisons.ComparisonsKt +kotlin.comparisons.ComparisonsKt__ComparisonsKt +kotlin.coroutines.Continuation +kotlin.coroutines.CoroutineContext +kotlin.coroutines.EmptyCoroutineContext +kotlin.coroutines.intrinsics.IntrinsicsKt +kotlin.coroutines.jvm.internal.ContinuationImpl +kotlin.coroutines.jvm.internal.DebugMetadata +kotlin.coroutines.jvm.internal.SpillingKt +kotlin.coroutines.jvm.internal.SuspendLambda +kotlin.io.CloseableKt +kotlin.jvm.functions.Function0 +kotlin.jvm.functions.Function1 +kotlin.jvm.functions.Function2 +kotlin.jvm.functions.Function3 +kotlin.jvm.functions.Function4 +kotlin.jvm.internal.DefaultConstructorMarker +kotlin.jvm.internal.FunctionReferenceImpl +kotlin.jvm.internal.Intrinsics +kotlin.jvm.internal.Ref +kotlin.jvm.internal.Reflection +kotlin.ranges.RangesKt +kotlin.reflect.KClass +kotlin.reflect.KFunction +kotlin.text.StringsKt +kotlinx.coroutines.BuildersKt +kotlinx.coroutines.CompletableJob +kotlinx.coroutines.CoroutineDispatcher +kotlinx.coroutines.CoroutineScope +kotlinx.coroutines.CoroutineScopeKt +kotlinx.coroutines.CoroutineStart +kotlinx.coroutines.DelayKt +kotlinx.coroutines.Dispatchers +kotlinx.coroutines.ExecutorsKt +kotlinx.coroutines.Job +kotlinx.coroutines.Job$DefaultImpls +kotlinx.coroutines.SupervisorKt +kotlinx.serialization.KSerializer +kotlinx.serialization.Serializable +org.polyfrost.oneconfig.api.notifications.v1.Notification +org.polyfrost.oneconfig.api.notifications.v1.NotificationAction +org.polyfrost.oneconfig.api.notifications.v1.NotificationType +org.polyfrost.oneconfig.api.notifications.v1.Notifications +org.polyfrost.oneconfig.api.notifications.v1.Notifications$Builder +org.polyfrost.oneconfig.api.platform.v1.ModInfo +org.polyfrost.oneconfig.api.platform.v1.ModInfo$Companion +org.polyfrost.oneconfig.api.platform.v1.Platform +org.polyfrost.oneconfig.api.platform.v1.ScreenPlatform +org.polyfrost.oneconfig.api.ui.v1.api.TinyFdApi +org.polyfrost.oneconfig.internal.ui.components.I18nKt +org.polyfrost.oneconfig.internal.ui.components.TextKt +org.polyfrost.oneconfig.internal.ui.components.settings.CheckboxControlKt +org.polyfrost.oneconfig.internal.ui.screens.AddSource +org.polyfrost.oneconfig.internal.ui.screens.IconSegment +org.polyfrost.oneconfig.internal.ui.shell.ShellController +org.polyfrost.oneconfig.internal.ui.shell.ShellController$Companion +org.polyfrost.oneconfig.internal.ui.shell.ShellNavigation +org.polyfrost.oneconfig.internal.ui.shell.ShellState +org.polyfrost.oneconfig.internal.ui.themes.ProviderKt +org.polyfrost.oneconfig.internal.ui.themes.UITheme +org.polyfrost.oneconfig.utils.v1.ClipboardHelper diff --git a/minecraft/src/test/java/org/polyfrost/oneconfig/internal/compat/ConfigEntrypointsTest.java b/minecraft/src/test/java/org/polyfrost/oneconfig/internal/compat/ConfigEntrypointsTest.java index 07891add4..61aabdc0a 100644 --- a/minecraft/src/test/java/org/polyfrost/oneconfig/internal/compat/ConfigEntrypointsTest.java +++ b/minecraft/src/test/java/org/polyfrost/oneconfig/internal/compat/ConfigEntrypointsTest.java @@ -1,7 +1,20 @@ package org.polyfrost.oneconfig.internal.compat; +import java.lang.reflect.Field; +import java.lang.reflect.Proxy; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Predicate; +import net.fabricmc.loader.api.ModContainer; +import net.fabricmc.loader.api.metadata.ModMetadata; import org.junit.jupiter.api.Test; +import org.polyfrost.oneconfig.api.config.v1.CompatSnapshots; import org.polyfrost.oneconfig.api.config.v1.Config; +import org.polyfrost.oneconfig.api.config.v1.ConfigManager; +import org.polyfrost.oneconfig.api.config.v1.Tree; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -154,4 +167,242 @@ static class Base extends Config { static class Derived extends Base { } + + private static ModContainer mod(String id, String... provides) { + ClassLoader loader = ConfigEntrypointsTest.class.getClassLoader(); + ModMetadata metadata = (ModMetadata) Proxy.newProxyInstance(loader, new Class[]{ModMetadata.class}, + (proxy, method, args) -> method.getName().equals("getProvides") ? List.of(provides) : id); + return (ModContainer) Proxy.newProxyInstance(loader, new Class[]{ModContainer.class}, (proxy, method, args) -> metadata); + } + + @Test + void theSameContainersAreNoChange() { + ModContainer lib = mod("lib"); + assertFalse(ConfigEntrypoints.modsChanged(List.of(lib), Map.of("lib", lib))); + } + + @Test + void anAddedModIsAChange() { + ModContainer lib = mod("lib"); + assertTrue(ConfigEntrypoints.modsChanged(List.of(lib, mod("added")), Map.of("lib", lib))); + } + + @Test + void addedAndReplacedModsAreChangedIds() { + ModContainer kept = mod("kept"); + assertEquals( + Set.of("added", "replaced"), + ConfigEntrypoints.changedIds(Map.of("kept", kept, "added", mod("added"), "replaced", mod("replaced")), Map.of("kept", kept, "replaced", mod("replaced"))) + ); + } + + @Test + void aModStaysOffUntilTheLoaderListsItAgain() { + ModContainer kept = mod("kept"); + ModContainer lib = mod("lib"); + Map without = Map.of("kept", kept); + Map with = Map.of("kept", kept, "lib", lib); + + Set off = ConfigEntrypoints.offAfter(Set.of(), without, with); + assertEquals(Set.of("lib"), off); + assertEquals(Set.of("lib"), ConfigEntrypoints.offAfter(off, without, without)); + + Set back = ConfigEntrypoints.offAfter(off, with, without); + assertEquals(Set.of(), back); + assertEquals(Set.of("lib"), ConfigEntrypoints.offAfter(back, without, with)); + } + + @Test + void anUpdateRunsOnlyTheEntrypointsItAddedToADecidedMod() { + Set ran = Set.of(ConfigEntrypoints.entrypointKey("mod", "old")); + assertTrue(ConfigEntrypoints.needsRunning("mod", "new", Set.of("mod"), Set.of(), ran)); + assertFalse(ConfigEntrypoints.needsRunning("mod", "old", Set.of("mod"), Set.of(), ran)); + } + + @Test + void aModThatFellBackToItsOriginalWaitsForAChangeToRetry() { + assertFalse(ConfigEntrypoints.needsRunning("mod", "old", Set.of(), Set.of("mod"), Set.of())); + assertTrue(ConfigEntrypoints.needsRunning("mod", "old", Set.of(), Set.of(), Set.of(ConfigEntrypoints.entrypointKey("mod", "old")))); + } + + @Test + void anOffModHidesItsCardsAndConfigs() { + assertEquals( + Set.of("lib", "lib.json", "custom.json"), + ConfigEntrypoints.offConfigIds(Set.of("lib"), Map.of("lib", Set.of("custom.json"), "kept", Set.of("kept.json"))) + ); + } + + @Test + void anOffModHidesATreeExtractedUnderAnotherId() { + ConfigEntrypoints.rememberConfigOwner("extracted-mod", "extracted-tree"); + assertTrue(ConfigEntrypoints.offConfigIds(Set.of("extracted-mod"), ConfigEntrypoints.modConfigs).contains("extracted-tree")); + assertFalse(ConfigEntrypoints.offConfigIds(Set.of("other-mod"), ConfigEntrypoints.modConfigs).contains("extracted-tree")); + } + + @Test + void aLiveCopyTakesOverOnlyTheConfigsItMakesAgain() { + Map> configs = new HashMap<>(); + configs.put("example", new HashSet<>(Set.of("example.json", "dropped.json"))); + configs.put("example_live1", new HashSet<>(Set.of("example.json"))); + Map owners = new HashMap<>(Map.of("example.json", "example", "dropped.json", "example", "other.json", "other")); + + ConfigEntrypoints.handOver(List.of("example"), "example_live1", "example.json", configs, owners); + ConfigEntrypoints.handOver(List.of("example"), "example_live1", "other.json", configs, owners); + + assertEquals("example_live1", owners.get("example.json")); + assertEquals("example", owners.get("dropped.json")); + assertEquals("other", owners.get("other.json")); + assertEquals(Set.of("example", "dropped.json"), ConfigEntrypoints.offConfigIds(Set.of("example"), configs)); + } + + @Test + void anOffModsConfigStaysOffWhenALibraryOwnsItsClass() { + Map> configs = Map.of("mod", Set.of("mod.json"), "library", Set.of("mod.json")); + assertEquals(Set.of("mod", "mod.json"), ConfigEntrypoints.offConfigIds(Set.of("mod"), configs)); + } + + @Test + void aChangesCopyReplacesOnlyTheOffModItProvides() { + Map mods = Map.of( + "example_live1", mod("example_live1", "example", "alias"), + "added", mod("added", "kept") + ); + assertEquals(Set.of("example"), ConfigEntrypoints.replacedBy(Set.of("example_live1", "added"), Set.of("example", "other"), mods)); + } + + @Test + void aNativeConfigKeepingTheIdStillUntracksTheSnapshot() throws ReflectiveOperationException { + String id = "drop-extracted-test"; + Field known = CompatSnapshots.class.getDeclaredField("known"); + known.setAccessible(true); + Map tracked = (Map) known.get(CompatSnapshots.INSTANCE); + CompatSnapshots.track(Tree.tree(id)); + ConfigEntrypoints.treeOwners.put(id, id); + try { + assertTrue(tracked.containsKey(id)); + ConfigEntrypoints.dropExtracted(id); + assertFalse(tracked.containsKey(id)); + } finally { + ConfigEntrypoints.treeOwners.remove(id); + CompatSnapshots.untrack(id); + } + } + + @Test + void aTreeExtractedUnderAnotherIdIsDroppedWithItsSnapshot() throws ReflectiveOperationException { + String modId = "drop-extracted-other-mod"; + String id = "drop-extracted-other-tree"; + Field known = CompatSnapshots.class.getDeclaredField("known"); + known.setAccessible(true); + Map tracked = (Map) known.get(CompatSnapshots.INSTANCE); + CompatSnapshots.register(Tree.tree(id)); + ConfigEntrypoints.rememberConfigOwner(modId, id); + try { + ConfigEntrypoints.dropExtracted(modId); + assertFalse(ConfigManager.active().exists(id)); + assertFalse(tracked.containsKey(id)); + } finally { + ConfigEntrypoints.modConfigs.remove(modId); + CompatSnapshots.untrack(id); + ConfigManager.active().unregister(id); + } + } + + @Test + void aCompatTreeAnEntrypointRegisteredItselfStays() throws ReflectiveOperationException { + String modId = "drop-extracted-owned-mod"; + String id = "drop-extracted-owned-tree"; + Field known = CompatSnapshots.class.getDeclaredField("known"); + known.setAccessible(true); + Map tracked = (Map) known.get(CompatSnapshots.INSTANCE); + CompatSnapshots.register(Tree.tree(id)); + ConfigEntrypoints.rememberConfigOwner(modId, id); + ConfigEntrypoints.treeOwners.put(id, modId); + try { + ConfigEntrypoints.dropExtracted(modId); + assertTrue(ConfigManager.active().exists(id)); + assertTrue(tracked.containsKey(id)); + } finally { + ConfigEntrypoints.treeOwners.remove(id); + ConfigEntrypoints.modConfigs.remove(modId); + CompatSnapshots.untrack(id); + ConfigManager.active().unregister(id); + } + } + + @Test + void aModTheLoaderDroppedIsRemoved() { + ModContainer kept = mod("kept"); + assertEquals(Set.of("off"), ConfigEntrypoints.removedIds(Map.of("kept", kept), Map.of("kept", kept, "off", mod("off")))); + } + + @Test + void onlyReplacedModsThatFailedRunAgain() { + assertEquals( + Set.of("fixed"), + ConfigEntrypoints.retriedIds( + Set.of("fixed", "fine", "returned", "unchanged"), + Set.of("fixed", "returned", "unchanged", "dropped"), + Set.of("fixed", "fine", "returned", "dropped"), + Set.of("fixed", "fine", "unchanged", "dropped") + ) + ); + } + + static class LibraryConfig extends Config { + LibraryConfig(String id) { + super(id, "Library", Category.OTHER); + } + } + + @Test + void aLibraryConfigAnEntrypointRegisteredBelongsToThatMod() { + String id = "late-config-claimed-test"; + Config library = new LibraryConfig(id); + ConfigEntrypoints.treeOwners.put(id, "mod"); + try { + assertTrue(ConfigEntrypoints.lateConfigs(Set.of("mod")).test(library)); + assertFalse(ConfigEntrypoints.lateConfigs(Set.of("other")).test(library)); + } finally { + ConfigEntrypoints.treeOwners.remove(id); + ConfigManager.cancelInitialization(library); + } + } + + @Test + void claimingAnIdInTheWindowDoesNotTakeAConfigFromAnotherMod() { + String id = "late-config-unclaimed-test"; + Config theirs = new LibraryConfig(id); + Predicate late = ConfigEntrypoints.lateConfigs(Set.of("mod")); + ConfigEntrypoints.treeOwners.put(id, "mod"); + try { + assertFalse(late.test(theirs)); + } finally { + ConfigEntrypoints.treeOwners.remove(id); + ConfigManager.cancelInitialization(theirs); + } + } + + @Test + void aLibraryChangingLeavesAConfigAnotherModRegisteredAlone() { + assertFalse(ConfigEntrypoints.isLate("id", "mod", "library", Set.of("library"), Map.of())); + assertTrue(ConfigEntrypoints.isLate("id", "mod", "library", Set.of("mod"), Map.of())); + assertTrue(ConfigEntrypoints.isLate("id", null, "library", Set.of("library"), Map.of())); + } + + @Test + void aConfigWhoseClassAnUpdateRemovedStaysWithItsMod() { + Map> configs = Map.of("mod", Set.of("id"), "library", Set.of("id")); + assertTrue(ConfigEntrypoints.isLate("id", null, null, Set.of("mod"), configs)); + assertFalse(ConfigEntrypoints.isLate("id", null, null, Set.of("other"), configs)); + // what was noted is only for when nothing better can tell + assertFalse(ConfigEntrypoints.isLate("id", "other", null, Set.of("mod"), configs)); + assertFalse(ConfigEntrypoints.isLate("id", null, "other", Set.of("mod"), configs)); + } + + @Test + void aContainerReplacedUnderTheSameIdIsAChange() { + assertTrue(ConfigEntrypoints.modsChanged(List.of(mod("lib")), Map.of("lib", mod("lib")))); + } } diff --git a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/CompatSnapshots.java b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/CompatSnapshots.java index 45c228cc2..2a2431fef 100644 --- a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/CompatSnapshots.java +++ b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/CompatSnapshots.java @@ -65,6 +65,9 @@ public final class CompatSnapshots implements ConfigManager.ProfileChangeListene private final Map> defaults = new ConcurrentHashMap<>(); private static final String WIRED_METADATA = "oc_compat_snapshot_wired"; private final Set> applying = Collections.synchronizedSet(Collections.newSetFromMap(new IdentityHashMap<>())); + // trees of mods turned off while the game runs, whose options and callbacks are that mod's code, with the profile + // they hold; by instance as the mod's replacement registering the same id isn't off + private final Map off = Collections.synchronizedMap(new IdentityHashMap<>()); private static final ThreadLocal APPLYING_HERE = ThreadLocal.withInitial(() -> Boolean.FALSE); private volatile Consumer dispatcher = Runnable::run; private volatile String currentProfile; @@ -132,14 +135,43 @@ private Tree track0(Tree reg) { if (currentProfile == null) currentProfile = profile; captureDefaults(reg); wire(reg); + dispatchApply(reg, profile); + return reg; + } + + private void dispatchApply(Tree tree, String profile) { dispatcher.accept(() -> { try { - applyProfile(reg, profile); + applyProfile(tree, profile); } catch (Throwable t) { - ConfigManager.LOGGER.error("Failed to apply compat snapshot for '{}'", reg.getID(), t); + ConfigManager.LOGGER.error("Failed to apply compat snapshot for '{}'", tree.getID(), t); } }); - return reg; + } + + /** + * Leaves the trees of {@code ids} alone until their mod is back, when they catch up with the profile they missed + */ + void setOff(Set ids) { + String profile = ConfigManager.activeProfile(); + for (Tree tree : known.values()) { + if (!ids.contains(tree.getID())) continue; + off.putIfAbsent(tree, profile); + tree.addMetadata(Backend.CUSTOM_SAVE_HELD_METADATA, Boolean.TRUE); + } + for (Tree tree : new ArrayList<>(off.keySet())) { + if (ids.contains(tree.getID())) continue; + String held = off.remove(tree); + tree.removeMetadata(Backend.CUSTOM_SAVE_HELD_METADATA); + if (!profile.equals(held) && known.get(tree.getID()) == tree) dispatchApply(tree, profile); + } + } + + // shortcut: a mod's own reset hook can't run while it's off so the captured defaults stand in for it, run the hook + // once it's back if those ever prove wrong + private void storeDefaults(Tree tree, String profile) { + Map snapshot = defaults.get(tree.getID()); + if (snapshot != null) snapshot.forEach((key, value) -> store.putValue(profile, tree.getID(), key, value)); } private void dropStaleRegistration(String id) { @@ -198,6 +230,10 @@ public void onProfileCreated(String profile) { dispatchAndWait(() -> { for (Tree tree : known.values()) { try { + if (off.containsKey(tree)) { + storeDefaults(tree, profile); + continue; + } restoreDefaults(tree); captureAll(tree, profile); } catch (Throwable t) { @@ -546,6 +582,7 @@ private static Object deserialize(Object stored) { private static final Pattern LEGACY_KEY = Pattern.compile("^(\\d+)\\|"); private static boolean gateClosed(Tree tree) { + if (INSTANCE.off.containsKey(tree)) return true; Object gate = tree.getMetadata(GATE_METADATA); return gate instanceof BooleanSupplier && !((BooleanSupplier) gate).getAsBoolean(); diff --git a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/Config.java b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/Config.java index c649d11a9..34ae1b1a6 100644 --- a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/Config.java +++ b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/Config.java @@ -38,6 +38,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; import java.util.function.BooleanSupplier; import java.util.function.Predicate; import java.util.function.Supplier; @@ -52,10 +53,18 @@ public abstract class Config { protected Tree tree; + // what a config waiting for a late window reads from until then, unregistered as the id isn't its own yet + private Tree preview; /** code-defined defaults captured at first initialization keyed by dot-separated property path */ private Map defaultSnapshot; + private static final AtomicLong CREATED = new AtomicLong(); private List deferredSetup; private boolean initializing; + final Thread creator = Thread.currentThread(); + // the configs waiting for a late window have no order of their own, and the first instance of an id has to win + final long createdOrder = CREATED.incrementAndGet(); + // on the instance, as a replacement sharing this id after a mod update can still succeed + volatile boolean initializationFailed; public final String id, title, iconPath; public final Category category; @@ -84,7 +93,7 @@ public final void addAliases(String option, String... aliases) { } private void whenInitialized(Runnable action) { - if (tree == null && ConfigManager.didInitializationFail(this)) { + if (tree == null && initializationFailed) { ConfigManager.LOGGER.warn("Skipping deferred setup for config {}: its initialization failed", id); return; } @@ -125,7 +134,9 @@ protected void initialize(boolean byConfigManager) { ConfigManager.LOGGER.warn("Config {} is already initialized, skipping initialization", id); return; } - if ((tree = makeTree()) != null) { + Tree previewed = preview; + preview = null; + if ((tree = previewed != null ? previewed : makeTree()) != null) { tree.setTitle(title); if (iconPath != null) { tree.addMetadata("icon", iconPath); @@ -133,16 +144,12 @@ protected void initialize(boolean byConfigManager) { } tree.addMetadata("category", category); + // it holds the saved values, which must not end up in the backup of the defaults + if (previewed != null) restoreCapturedDefaults(tree, true); if (!ConfigManager.isRebindingProfiles()) { saveDefaultsBackup(tree); } - // capture code defaults before register() loads stored values over them so the UI can offer a reset action - if (defaultSnapshot == null) { - defaultSnapshot = new HashMap<>(); - captureDefaults(tree, "", defaultSnapshot); - } else { - applyDefaultSnapshot(tree, "", defaultSnapshot); - } + captureCodeDefaults(tree); Tree.beginFailureCollection(); List resetOptions; try { @@ -171,7 +178,21 @@ protected void initialize(boolean byConfigManager) { protected void onInitialized(Tree tree) { } + /** before register() loads stored values over them so the UI can offer a reset action */ + private void captureCodeDefaults(Tree tree) { + if (defaultSnapshot == null) { + defaultSnapshot = new HashMap<>(); + captureDefaults(tree, "", defaultSnapshot); + } else { + applyDefaultSnapshot(tree, "", defaultSnapshot); + } + } + final void runInitialize(boolean byConfigManager) { + if (!byConfigManager && ConfigManager.waitsForLateWindow(this)) { + buildPreview(); + return; + } boolean was = initializing; initializing = true; try { @@ -181,6 +202,18 @@ final void runInitialize(boolean byConfigManager) { } } + // the mod reads its config as soon as it preloads it, which can be well before its window decides anything + private void buildPreview() { + if (preview != null || (preview = makeTree()) == null) return; + captureCodeDefaults(preview); + ConfigManager manager = ConfigManager.active(); + // the instance holding the id can be newer than its file, which it also gets written to before a replacement + if (manager.exists(id)) manager.save(id); + Tree saved = manager.load(id); + // a value that doesn't fit is reported once the window has registered this for real + if (saved != null) Tree.collectFailures(() -> preview.overwrite(saved, false)); + } + private void runDeferredSetup() { if (deferredSetup == null) return; for (Runnable action : deferredSetup) { @@ -527,7 +560,7 @@ protected void loadFrom(Path p) { protected Property getProperty(String option) { if (tree == null) runInitialize(false); - return getProperty(tree, option); + return getProperty(tree != null ? tree : preview, option); } protected static Property getProperty(Tree tree, String option) { diff --git a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/ConfigEntrypoint.java b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/ConfigEntrypoint.java index 7ae27dd3f..10d44bf20 100644 --- a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/ConfigEntrypoint.java +++ b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/ConfigEntrypoint.java @@ -43,7 +43,8 @@ public interface ConfigEntrypoint { String ENTRYPOINT = "oneconfig:config"; /** - * Called once after every mod has initialized. Create your {@link Config} instances here. Configs whose class is + * Called once after every mod has initialized, or after your mod has if it or this entrypoint was added while the game runs. + * Create your {@link Config} instances here. Configs whose class is * in your mod (or a mod nested in its jar) belong to it automatically if they were created before this returns. Any other * config, such as one whose class is in a library, must be passed to {@link Context#register(Config)}. *
diff --git a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/ConfigManager.java b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/ConfigManager.java index 5b985d86a..cf641b4f3 100644 --- a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/ConfigManager.java +++ b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/ConfigManager.java @@ -39,7 +39,7 @@ import java.nio.file.StandardCopyOption; import java.util.*; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; @@ -47,6 +47,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Consumer; +import java.util.function.Predicate; import java.util.stream.Stream; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; @@ -79,14 +80,32 @@ public final class ConfigManager { private static final ConfigManager backup = new ConfigManager(Paths.get("oneconfig", "backup"), NightConfigSerializer.ALL); private static ConfigManager active; private static boolean initialized = false; + // unlike initialized, which Hotwire clears while it starts a mod it added and sets again through initialize() + private static volatile boolean startedUp = false; private static boolean isFirstRun = false; - private static final Queue pendingInitialization = new ArrayDeque<>(); + private static final Queue pendingInitialization = new ConcurrentLinkedQueue<>(); + // weak so configs no late window claims, such as a mod's temporary ones, aren't kept for the whole session + private static final Set createdAfterStartup = Collections.synchronizedSet(Collections.newSetFromMap(new WeakHashMap<>())); + // the ones a coming window will claim, as their mod may keep no reference to them, the same as at startup + private static final Set heldForLateWindow = Collections.synchronizedSet(new HashSet<>()); + // what the running window's creator made or was handed, the only configs that can replace an older instance, + // made on any thread while it runs as it can leave the work to a worker, and the id's owner still has to match + // also what a mod Hotwire started made, for the window after that, which is also what keeps those alive until then + private static final Set fromWindowCreator = Collections.synchronizedSet(Collections.newSetFromMap(new IdentityHashMap<>())); + private static volatile boolean lateWindowOpen = false; private static final Map initializedConfigs = new LinkedHashMap<>(); + private static Set offConfigIds = Set.of(); + // the configs of turned off mods that sat out a profile switch + private static final Set rebindsWhenBack = Collections.newSetFromMap(new IdentityHashMap<>()); + // the same for trees registered directly, as restoring or loading their values would run their callbacks + private static final Set treesWhenBack = Collections.newSetFromMap(new IdentityHashMap<>()); private static final ReentrantLock PROFILE_LIFECYCLE_LOCK = new ReentrantLock(); private static final long PROFILE_OPERATION_BUDGET_NANOS = TimeUnit.SECONDS.toNanos(30L); private static final long MINIMUM_WAIT_NANOS = TimeUnit.MILLISECONDS.toNanos(500L); private static final ThreadLocal PROFILE_OPERATION_DEADLINE = new ThreadLocal<>(); private static final ThreadLocal REBINDING_PROFILES = ThreadLocal.withInitial(() -> Boolean.FALSE); + private static final ThreadLocal LATE_INITIALIZING = ThreadLocal.withInitial(() -> Boolean.FALSE); + private static volatile Predicate awaitingLateWindow = config -> false; private static volatile Consumer profileSwitchDispatcher = Runnable::run; private static final CopyOnWriteArrayList profileListeners = new CopyOnWriteArrayList<>(); private static final CopyOnWriteArrayList treeListeners = new CopyOnWriteArrayList<>(); @@ -216,6 +235,12 @@ public static void initialize() { return; } initialized = true; + if (startedUp) holdBackDuplicates(config -> false); + startedUp = true; + initializePending(); + } + + private static void initializePending() { long t1 = System.nanoTime(); LOGGER.info("Initializing {} configs...", pendingInitialization.size()); while (!pendingInitialization.isEmpty()) { @@ -224,7 +249,7 @@ public static void initialize() { try { config.runInitialize(true); } catch (Throwable t) { - failedInitialization.add(config.id); + config.initializationFailed = true; Tree half = config.tree; config.tree = null; if (half != null && half.getID() != null) { @@ -240,18 +265,134 @@ public static void initialize() { LOGGER.info("Initialized configs in {}ms", (System.nanoTime() - t1) / 1_000_000.0); } - private static final Set failedInitialization = ConcurrentHashMap.newKeySet(); + @ApiStatus.Internal + public static boolean initializeLate(Predicate added, Runnable creator) { + boolean building; + LATE_INITIALIZING.set(Boolean.TRUE); + lateWindowOpen = true; + try { + claimBuilt(added); + creator.run(); + } finally { + LATE_INITIALIZING.remove(); + lateWindowOpen = false; + // again, as a worker the creator started can have finished a config by now + building = claimBuilt(added); + Map replaced = holdBackDuplicates(added); + // one still being built gets its turn in the window this reports it for + fromWindowCreator.removeIf(ConfigManager::isBuilt); + // a replacement writes its own over these before it can fail + Map oldDefaults = new HashMap<>(); + for (Config old : replaced.values()) { + try { + oldDefaults.put(old.id, readDefaultsBackup(old.id)); + } catch (IOException e) { + LOGGER.error("Failed to read the defaults backup of config {}", old.id, e); + } + backup().unregister(old.id); + } + if (!pendingInitialization.isEmpty()) initializePending(); + replaced.forEach((replacement, old) -> { + // a replacement that built nothing must not cost the mod the config it already had + if (replacement.getTree() == null && old.getTree() != null) { + active().register(old.getTree()); + markInitialized(old); + // a backup that could not be read is not a missing one, which this would delete + if (oldDefaults.containsKey(old.id)) writeDefaultsBackup(old.id, oldDefaults.get(old.id)); + } + }); + } + return building; + } + + /** Queues the finished configs [added] accepts, returning whether any it accepts is still being built */ + private static boolean claimBuilt(Predicate added) { + boolean building = false; + List waiting = new ArrayList<>(createdAfterStartup); + waiting.sort(Comparator.comparingLong(config -> config.createdOrder)); + for (Config config : waiting) { + if (!added.test(config)) continue; + if (!isBuilt(config)) building = true; + else claim(config); + } + return building; + } - static boolean didInitializationFail(Config config) { - return failedInitialization.contains(config.id); + private static boolean claim(Config config) { + heldForLateWindow.remove(config); + return createdAfterStartup.remove(config) && pendingInitialization.add(config); + } + + @ApiStatus.Internal + public static void setAwaitingLateWindow(Predicate awaiting) { + awaitingLateWindow = awaiting; + } + + private static boolean isBuilt(Config config) { + if (config.creator == Thread.currentThread()) return true; + Set chain = new HashSet<>(); + for (Class type = config.getClass(); type != Object.class; type = type.getSuperclass()) chain.add(type.getName()); + for (StackTraceElement frame : config.creator.getStackTrace()) { + if ("".equals(frame.getMethodName()) && chain.contains(frame.getClassName())) return false; + } + return true; + } + + /** + * Merging a second instance into a registered tree would overwrite its saved values with that instance's defaults, + * so duplicates wait unless an entrypoint of the mod that owns the older instance made them, as an update can move + * a mod's config there. Returns the older instance each such replacement takes over from. + */ + private static synchronized Map holdBackDuplicates(Predicate added) { + Map replaced = new IdentityHashMap<>(); + Set ids = new HashSet<>(); + ConfigManager manager = active(); + for (Config config : pendingInitialization) { + Config old = initializedConfigs.get(config.id); + boolean replaces = old != null && fromWindowCreator.contains(config) && added.test(config) && added.test(old) + // written first as the replacement loads its values from the file, so it waits if they can't be + && (!manager.exists(config.id) || manager.save(config.id)); + if ((old == null || replaces) && ids.add(config.id)) { + if (replaces) replaced.put(config, old); + } else if (pendingInitialization.remove(config)) { + createdAfterStartup.add(config); + } + } + for (Config replacement : replaced.keySet()) manager.unregister(replacement.id); + return replaced; + } + + private static byte @Nullable [] readDefaultsBackup(String id) throws IOException { + Path file = backup().getFolder().resolve(id); + return Files.exists(file) ? Files.readAllBytes(file) : null; + } + + private static void writeDefaultsBackup(String id, byte @Nullable [] contents) { + Path file = backup().getFolder().resolve(id); + try { + if (contents == null) Files.deleteIfExists(file); + else Files.write(file, contents); + } catch (IOException e) { + LOGGER.error("Failed to restore the defaults backup of config {}", id, e); + } + } + + @ApiStatus.Internal + public static void claimForLateInitialization(Config config) { + if (LATE_INITIALIZING.get() && claim(config)) fromWindowCreator.add(config); } @ApiStatus.Internal public static void submitForInitialization(Config config) { // never initialize synchronously here because this runs from the Config base constructor // before subclass field initializers have run so collect() would read uninitialized fields - if (!initialized) { + if (initialized && !LATE_INITIALIZING.get()) { + createdAfterStartup.add(config); + if (lateWindowOpen) fromWindowCreator.add(config); + if (awaitingLateWindow.test(config)) heldForLateWindow.add(config); + } else { pendingInitialization.add(config); + if (startedUp || LATE_INITIALIZING.get()) fromWindowCreator.add(config); } for (ConfigSubmissionListener listener : submissionListeners) { try { @@ -278,6 +419,8 @@ public static boolean isRebindingProfiles() { static void removePendingInitialization(Config config) { pendingInitialization.remove(config); + createdAfterStartup.remove(config); + heldForLateWindow.remove(config); } /** @@ -296,8 +439,30 @@ public static List pendingConfigs() { return new ArrayList<>(pendingInitialization); } + @ApiStatus.Internal + public static synchronized List createdConfigs() { + List created = new ArrayList<>(initializedConfigs.values()); + created.addAll(pendingInitialization); + created.addAll(createdAfterStartup); + return created; + } + static boolean isPendingInitialization(Config config) { - return pendingInitialization.contains(config); + return pendingInitialization.contains(config) + || createdAfterStartup.contains(config) && (fromWindowCreator.contains(config) || awaitingLateWindow.test(config)); + } + + /** + * Whether a config initializing itself has to leave registering to a late window, as {@link #holdBackDuplicates} + * still has to decide if it may take over from the instance holding its id, and merging would overwrite the saved + * values. It reads those from an unregistered tree in the meantime. + */ + static boolean waitsForLateWindow(Config config) { + Config old; + synchronized (ConfigManager.class) { + old = initializedConfigs.get(config.id); + } + return startedUp && old != null && old != config && isPendingInitialization(config); } static void reportResetOptions(Config config, List options) { @@ -456,6 +621,7 @@ private static void openProfile(String profile, boolean restoreDefaults) { if (Boolean.TRUE.equals(t.getMetadata(PROFILE_LOCAL_METADATA))) continue; externalTrees.add(t); } + externalTrees.addAll(treesWhenBack); active.close(); } internal().get("profiles.json").getProp("activeProfile").setAs(profile); @@ -473,11 +639,14 @@ private static void openProfile(String profile, boolean restoreDefaults) { rebindInitializedConfigs(restoreDefaults); for (Tree t : externalTrees) { try { - if (restoreDefaults - && !Boolean.TRUE.equals(t.getMetadata(CompatSnapshots.SNAPSHOT_METADATA)) - && !Boolean.TRUE.equals(t.getMetadata(Backend.UI_ONLY_METADATA))) { - Config.restoreCapturedDefaults(t); + boolean holdsValues = !Boolean.TRUE.equals(t.getMetadata(CompatSnapshots.SNAPSHOT_METADATA)) + && !Boolean.TRUE.equals(t.getMetadata(Backend.UI_ONLY_METADATA)); + if (holdsValues && offConfigIds.contains(t.getID())) { + treesWhenBack.add(t); + if (restoreDefaults) active.backend.save0(defaultsOf(t)); + continue; } + if (restoreDefaults && holdsValues) Config.restoreCapturedDefaults(t); active.register(t); } catch (Throwable ex) { LOGGER.error("Failed to rebind external tree {} onto profile {}", t.getID(), profile, ex); @@ -1228,12 +1397,84 @@ private static void rebindInitializedConfigs(boolean restoreDefaults) { if (initializedConfigs.isEmpty()) return; ArrayList configs = new ArrayList<>(initializedConfigs.values()); for (Config config : configs) { - try { - config.rebindToActiveProfile(restoreDefaults); - } catch (Throwable ex) { - LOGGER.error("Failed to rebind config {} onto active profile", config.id, ex); + if (offConfigIds.contains(config.id)) { + rebindsWhenBack.add(config); + if (restoreDefaults) copyDefaultsToActiveProfile(config.id); + } else { + rebind(config, restoreDefaults); + } + } + } + + // as the file, since its mod's code can't run to restore them, and whichever profile it comes back in would otherwise pass this one its values + private static void copyDefaultsToActiveProfile(String id) { + try { + copyFileSafely(backup().getFolder().resolve(id), active.getFolder().resolve(id)); + } catch (IOException e) { + LOGGER.error("Failed to give the active profile the defaults of config {}", id, e); + } + } + + // as a copy, since its properties can be its mod's code, for the same reason as above + private static Tree defaultsOf(Tree tree) { + Tree defaults = Tree.tree(tree.getID()); + for (Node node : tree.map.values()) { + if (node instanceof Tree) { + defaults.put(defaultsOf((Tree) node)); + } else { + Object value = node.getMetadata("default"); + if (value != null) defaults.put(Properties.simple(node.getID(), null, null, value, null)); + } + } + return defaults; + } + + private static void rebind(Config config, boolean restoreDefaults) { + try { + config.rebindToActiveProfile(restoreDefaults); + } catch (Throwable ex) { + LOGGER.error("Failed to rebind config {} onto active profile", config.id, ex); + } + } + + /** + * Sets the configs of mods turned off while the game runs, which a profile switch leaves alone as rebuilding them + * would run their mod's code. Those that come back catch up with any switch they missed. + * + * @return whether any did, so their trees were built anew + */ + @ApiStatus.Internal + public static synchronized boolean setOff(Set ids) { + offConfigIds = ids; + CompatSnapshots.INSTANCE.setOff(ids); + boolean caughtUp = false; + boolean wasRebinding = REBINDING_PROFILES.get(); + REBINDING_PROFILES.set(Boolean.TRUE); + try { + for (Config config : new ArrayList<>(rebindsWhenBack)) { + if (ids.contains(config.id)) continue; + rebindsWhenBack.remove(config); + if (initializedConfigs.get(config.id) != config) continue; + rebind(config, false); + caughtUp = true; + } + for (Tree tree : new ArrayList<>(treesWhenBack)) { + if (ids.contains(tree.getID())) continue; + treesWhenBack.remove(tree); + // its mod's replacement can have taken the id by now + if (activeLocked().exists(tree.getID())) continue; + try { + active.register(tree); + caughtUp = true; + } catch (Throwable ex) { + LOGGER.error("Failed to rebind external tree {} onto the active profile", tree.getID(), ex); + } } + } finally { + if (wasRebinding) REBINDING_PROFILES.set(Boolean.TRUE); + else REBINDING_PROFILES.remove(); } + return caughtUp; } private static String normalizeNewProfileName(String profile) { diff --git a/modules/config-impl/src/test/java/org/polyfrost/oneconfig/api/config/v1/CompatSnapshotsTest.java b/modules/config-impl/src/test/java/org/polyfrost/oneconfig/api/config/v1/CompatSnapshotsTest.java index 53c270d02..eb07d864c 100644 --- a/modules/config-impl/src/test/java/org/polyfrost/oneconfig/api/config/v1/CompatSnapshotsTest.java +++ b/modules/config-impl/src/test/java/org/polyfrost/oneconfig/api/config/v1/CompatSnapshotsTest.java @@ -32,6 +32,7 @@ import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.Set; import org.junit.jupiter.api.Test; import org.polyfrost.compose.render.PolyColor; import org.polyfrost.oneconfig.api.config.v1.backend.Backend; @@ -112,6 +113,94 @@ void aModsOwnResetHookWinsOverTheCapturedDefaults() throws Exception { } } + @Test + void aTurnedOffModsCallbacksSitOutANewProfileWhichGivesItItsDefaultsOnceBack() throws Exception { + String id = TREE_ID + "_off"; + String profile = "compat-off-profile"; + Property property = Properties.simple("enabled", "Enabled", "", true); + property.addMetadata("default", Boolean.FALSE); + Tree tree = Tree.tree(id).put(property); + int[] called = {0, 0}; + tree.addMetadata(CompatSnapshots.CUSTOM_RESET_METADATA, (Runnable) () -> called[0]++); + tree.addMetadata("custom_save", (Runnable) () -> called[1]++); + CompatSnapshots.register(tree); + try { + called[1] = 0; + ConfigManager.setOff(Set.of(id)); + ConfigManager.createProfile(profile); + + assertEquals(0, called[0], "the mod's reset hook is its code, which is off"); + assertEquals(0, called[1], "the mod's save hook is its code, which is off"); + assertEquals(Boolean.TRUE, property.get()); + + ConfigManager.setOff(Set.of()); + + assertEquals(Boolean.FALSE, property.get(), "a new profile starts from the defaults"); + assertEquals(1, called[1]); + } finally { + ConfigManager.setOff(Set.of()); + ConfigManager.openProfile(""); + ConfigManager.deleteProfile(profile); + CompatSnapshots.untrack(id); + ConfigManager.active().unregister(id); + } + } + + @Test + void aTurnedOffModsUnsavedChangeWaitsForItToComeBack() { + String id = TREE_ID + "_off_save"; + Property property = Properties.simple("enabled", "Enabled", "", true); + Tree tree = Tree.tree(id).put(property); + int[] saves = {0}; + tree.addMetadata("custom_save", (Runnable) () -> saves[0]++); + CompatSnapshots.register(tree); + try { + saves[0] = 0; + // the snapshot of an earlier run can already have set the other value + property.setAs(!property.get()); + ConfigManager.setOff(Set.of(id)); + + ConfigManager.active().saveAll(); + assertEquals(0, saves[0], "the mod's save hook is its code, which is off"); + + ConfigManager.setOff(Set.of()); + ConfigManager.active().saveAll(); + assertEquals(1, saves[0], "the change was still never written"); + } finally { + ConfigManager.setOff(Set.of()); + CompatSnapshots.untrack(id); + ConfigManager.active().unregister(id); + } + } + + @Test + void aTreeRegisteredUnderAnOffIdIsNotOff() throws Exception { + String id = TREE_ID + "_off_replaced"; + String profile = "compat-off-replaced-profile"; + boolean[] oldReset = {false}; + boolean[] replacementReset = {false}; + Tree old = Tree.tree(id).put(Properties.simple("enabled", "Enabled", "", true)); + old.addMetadata(CompatSnapshots.CUSTOM_RESET_METADATA, (Runnable) () -> oldReset[0] = true); + CompatSnapshots.register(old); + try { + ConfigManager.setOff(Set.of(id)); + Tree replacement = Tree.tree(id).put(Properties.simple("enabled", "Enabled", "", true)); + replacement.addMetadata(CompatSnapshots.CUSTOM_RESET_METADATA, (Runnable) () -> replacementReset[0] = true); + CompatSnapshots.register(replacement); + + ConfigManager.createProfile(profile); + + assertFalse(oldReset[0]); + assertTrue(replacementReset[0], "only the instance that was registered when its mod went off is off"); + } finally { + ConfigManager.setOff(Set.of()); + ConfigManager.openProfile(""); + ConfigManager.deleteProfile(profile); + CompatSnapshots.untrack(id); + ConfigManager.active().unregister(id); + } + } + @Test void isApplyingOnlyReportsTrueWhileTheSnapshotIsWriting() throws Exception { Property property = Properties.simple("enabled", "Enabled", "", true); diff --git a/modules/config-impl/src/test/java/org/polyfrost/oneconfig/api/config/v1/LateInitializationTest.java b/modules/config-impl/src/test/java/org/polyfrost/oneconfig/api/config/v1/LateInitializationTest.java new file mode 100644 index 000000000..bc3fa1891 --- /dev/null +++ b/modules/config-impl/src/test/java/org/polyfrost/oneconfig/api/config/v1/LateInitializationTest.java @@ -0,0 +1,1032 @@ +package org.polyfrost.oneconfig.api.config.v1; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.lang.ref.WeakReference; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.polyfrost.oneconfig.api.config.v1.annotations.Switch; + +class LateInitializationTest { + static class LateConfig extends Config { + LateConfig(String id) { + super(id, "Late", Category.QOL); + } + } + + static class ValueConfig extends LateConfig { + @Switch(title = "Value") + public boolean value = false; + + ValueConfig(String id) { + super(id); + } + } + + static class SetupConfig extends ValueConfig { + SetupConfig(String id) { + super(id); + addAliases("value", new String[]{"alias"}); + } + } + + static class BlockedConfig extends LateConfig { + static final AtomicBoolean initializedEarly = new AtomicBoolean(); + private final boolean built; + + BlockedConfig(String id, CountDownLatch entered, CountDownLatch release) throws InterruptedException { + super(id); + entered.countDown(); + release.await(); + built = true; + } + + @Override + protected void onInitialized(Tree tree) { + if (!built) initializedEarly.set(true); + } + } + + static class FailingConfig extends ValueConfig { + FailingConfig(String id) { + super(id); + } + + @Override + protected Tree makeTree() { + throw new IllegalStateException("this config never builds"); + } + } + + static class FailsOnceRegisteredConfig extends ValueConfig { + FailsOnceRegisteredConfig(String id) { + super(id); + } + + @Override + protected void initialize(boolean byConfigManager) { + super.initialize(byConfigManager); + throw new IllegalStateException("this config fails once registered"); + } + } + + private static Thread buildBlocked(String id, CountDownLatch entered, CountDownLatch release) { + Thread thread = new Thread(() -> { + try { + new BlockedConfig(id, entered, release); + } catch (InterruptedException e) { + throw new IllegalStateException(e); + } + }); + thread.start(); + return thread; + } + + @AfterEach + void reopenStartup() throws ReflectiveOperationException { + ConfigManager.setAwaitingLateWindow(config -> false); + Field initialized = ConfigManager.class.getDeclaredField("initialized"); + initialized.setAccessible(true); + initialized.setBoolean(null, false); + } + + private static void finishStartup() { + ConfigManager.initialize(); + } + + @Test + void configsCreatedLateAreInitializedOnceTheCreatorReturns() { + finishStartup(); + AtomicReference created = new AtomicReference<>(); + ConfigManager.initializeLate(config -> false, () -> { + created.set(new LateConfig("late-initialization-test.json")); + assertNull(created.get().getTree()); + assertTrue(ConfigManager.createdConfigs().contains(created.get())); + }); + assertNotNull(created.get().getTree()); + assertTrue(ConfigManager.createdConfigs().contains(created.get())); + } + + @Test + void addedConfigsCreatedBeforeTheWindowOpensAreKeptForIt() { + finishStartup(); + Config early = new LateConfig("late-initialization-early-test.json"); + assertNull(early.getTree()); + assertTrue(ConfigManager.createdConfigs().contains(early)); + ConfigManager.initializeLate(early::equals, () -> assertNull(early.getTree())); + assertNotNull(early.getTree()); + } + + @Test + void otherConfigsStayOutOfTheWindowAndKeepSavedValues() throws IOException { + String id = "late-initialization-other-test.json"; + try { + ValueConfig saved = new ValueConfig(id); + finishStartup(); + saved.getProperty("value").setAs(true); + ConfigManager.active().save(id); + + Config other = new ValueConfig(id); + ConfigManager.initializeLate(saved::equals, () -> { + }); + assertNull(other.getTree()); + assertEquals(Boolean.TRUE, saved.getProperty("value").get()); + } finally { + ConfigManager.active().unregister(id); + Files.deleteIfExists(ConfigManager.active().getFolder().resolve(id)); + } + } + + @Test + void constructorSetupWaitsForLateInitialization() { + finishStartup(); + ConfigManager.setAwaitingLateWindow(config -> true); + Config late = new SetupConfig("late-initialization-setup-test.json"); + assertTrue(ConfigManager.isPendingInitialization(late)); + ConfigManager.initializeLate(late::equals, () -> { + }); + assertFalse(ConfigManager.isPendingInitialization(late)); + assertEquals(List.of("alias"), late.getProperty("value").getMetadata("aliases")); + } + + @Test + void constructorSetupInitializesConfigsNoWindowIsComingFor() { + finishStartup(); + Config late = new SetupConfig("late-initialization-ordinary-test.json"); + assertNotNull(late.getTree()); + assertEquals(List.of("alias"), late.getProperty("value").getMetadata("aliases")); + } + + @Test + void aConfigStillBeingBuiltIsReportedUntilItCanJoin() throws InterruptedException { + finishStartup(); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicReference built = new AtomicReference<>(); + Thread worker = new Thread(() -> { + try { + built.set(new BlockedConfig("late-initialization-retry-test.json", entered, release)); + } catch (InterruptedException e) { + throw new IllegalStateException(e); + } + }); + worker.start(); + entered.await(); + + assertTrue(ConfigManager.initializeLate(BlockedConfig.class::isInstance, () -> { + })); + release.countDown(); + worker.join(); + + assertFalse(ConfigManager.initializeLate(built.get()::equals, () -> { + })); + assertNotNull(built.get().getTree()); + } + + @Test + void configsThatInitializedThemselvesAreNotKept() { + finishStartup(); + Config early = new LateConfig("late-initialization-preload-test.json"); + early.preload(); + Tree tree = early.getTree(); + ConfigManager.initializeLate(early::equals, () -> { + }); + assertSame(tree, early.getTree()); + assertEquals(1, ConfigManager.createdConfigs().stream().filter(early::equals).count()); + } + + @Test + void configsOtherThreadsAreBuildingStayOutOfTheWindow() throws InterruptedException { + finishStartup(); + BlockedConfig.initializedEarly.set(false); + CountDownLatch release = new CountDownLatch(1); + + CountDownLatch enteredBefore = new CountDownLatch(1); + Thread before = buildBlocked("late-initialization-blocked-before-test.json", enteredBefore, release); + enteredBefore.await(); + + CountDownLatch enteredDuring = new CountDownLatch(1); + AtomicReference during = new AtomicReference<>(); + ConfigManager.initializeLate(BlockedConfig.class::isInstance, () -> { + during.set(buildBlocked("late-initialization-blocked-during-test.json", enteredDuring, release)); + try { + enteredDuring.await(); + } catch (InterruptedException e) { + throw new IllegalStateException(e); + } + }); + + release.countDown(); + before.join(); + during.get().join(); + assertFalse(BlockedConfig.initializedEarly.get()); + } + + @Test + void configsOtherThreadsFinishedBuildingJoinTheWindow() throws InterruptedException { + finishStartup(); + AtomicReference built = new AtomicReference<>(); + CountDownLatch done = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + Thread worker = new Thread(() -> { + built.set(new LateConfig("late-initialization-worker-test.json")); + done.countDown(); + try { + release.await(); + } catch (InterruptedException e) { + throw new IllegalStateException(e); + } + }); + worker.start(); + done.await(); + + ConfigManager.initializeLate(built.get()::equals, () -> { + }); + release.countDown(); + worker.join(); + assertNotNull(built.get().getTree()); + } + + @Test + void aConfigAWorkerFinishesDuringTheWindowIsInitialized() { + finishStartup(); + AtomicReference built = new AtomicReference<>(); + boolean waiting = ConfigManager.initializeLate(config -> config == built.get(), () -> { + Thread worker = new Thread(() -> built.set(new LateConfig("late-initialization-during-test.json"))); + worker.start(); + try { + worker.join(); + } catch (InterruptedException e) { + throw new IllegalStateException(e); + } + }); + assertFalse(waiting); + assertNotNull(built.get().getTree()); + } + + @Test + void aReplacementForAFailedConfigKeepsItsSetup() throws IOException { + String id = "late-initialization-failed-test.json"; + try { + finishStartup(); + ConfigManager.initializeLate(config -> false, () -> new FailingConfig(id)); + AtomicReference replacement = new AtomicReference<>(); + ConfigManager.initializeLate(config -> false, () -> replacement.set(new SetupConfig(id))); + assertEquals(List.of("alias"), replacement.get().getProperty("value").getMetadata("aliases")); + } finally { + ConfigManager.active().unregister(id); + Files.deleteIfExists(ConfigManager.active().getFolder().resolve(id)); + } + } + + @Test + void theFirstInstanceOfAnIdWinsTheWindow() throws IOException { + String id = "late-initialization-order-test.json"; + try { + finishStartup(); + List instances = new ArrayList<>(); + for (int i = 0; i < 20; i++) instances.add(new ValueConfig(id)); + ConfigManager.initializeLate(instances::contains, () -> { + }); + assertNotNull(instances.get(0).getTree()); + assertEquals(1, instances.stream().filter(config -> config.getTree() != null).count()); + } finally { + ConfigManager.active().unregister(id); + Files.deleteIfExists(ConfigManager.active().getFolder().resolve(id)); + } + } + + @Test + void configsNoWindowClaimsAreReleased() throws InterruptedException { + finishStartup(); + WeakReference unclaimed = new WeakReference<>(new LateConfig("late-initialization-released-test.json")); + for (int i = 0; i < 100 && unclaimed.get() != null; i++) { + System.gc(); + Thread.sleep(10); + } + assertNull(unclaimed.get()); + } + + private static void collect(WeakReference reference, int attempts) throws InterruptedException { + for (int i = 0; i < attempts && reference.get() != null; i++) { + System.gc(); + Thread.sleep(10); + } + } + + @Test + void configsAWindowIsComingForNeedNoOtherReference() throws IOException, InterruptedException { + String id = "late-initialization-held-test.json"; + try { + finishStartup(); + ConfigManager.setAwaitingLateWindow(config -> config.id.equals(id)); + WeakReference first = new WeakReference<>(new LateConfig(id)); + WeakReference copy = new WeakReference<>(new LateConfig(id)); + collect(first, 20); + assertNotNull(first.get()); + + ConfigManager.initializeLate(config -> config.id.equals(id), () -> { + }); + assertNotNull(first.get().getTree()); + collect(copy, 100); + assertNull(copy.get()); + } finally { + ConfigManager.active().unregister(id); + Files.deleteIfExists(ConfigManager.active().getFolder().resolve(id)); + } + } + + @Test + void aProfileSwitchWaitsForAnOffModToComeBack() throws IOException { + String id = "late-initialization-off-test.json"; + String profile = "late-initialization-off-profile"; + AtomicInteger built = new AtomicInteger(); + class CountedConfig extends ValueConfig { + CountedConfig(String configId) { + super(configId); + } + + @Override + protected void onInitialized(Tree tree) { + built.incrementAndGet(); + } + } + try { + Config config = new CountedConfig(id); + finishStartup(); + assertEquals(1, built.get()); + + ConfigManager.setOff(Set.of(id)); + ConfigManager.createProfile(profile); + ConfigManager.openProfile(profile); + assertEquals(1, built.get()); + assertFalse(ConfigManager.active().exists(id)); + + assertTrue(ConfigManager.setOff(Set.of())); + assertEquals(2, built.get()); + assertSame(config.getTree(), ConfigManager.active().get(id)); + assertFalse(ConfigManager.setOff(Set.of())); + } finally { + ConfigManager.setOff(Set.of()); + ConfigManager.active().unregister(id); + Files.deleteIfExists(ConfigManager.active().getFolder().resolve(id)); + ConfigManager.openProfile(""); + ConfigManager.deleteProfile(profile); + ConfigManager.active().unregister(id); + Files.deleteIfExists(ConfigManager.active().getFolder().resolve(id)); + } + } + + @Test + void aProfileCreatedWhileAModIsOffStartsFromItsDefaults() throws IOException { + String id = "late-initialization-off-defaults-test.json"; + String profile = "late-initialization-off-defaults-profile"; + try { + ValueConfig config = new ValueConfig(id); + finishStartup(); + config.getProperty("value").setAs(true); + ConfigManager.active().save(id); + + ConfigManager.setOff(Set.of(id)); + ConfigManager.createProfile(profile); + ConfigManager.openProfile(""); + ConfigManager.setOff(Set.of()); + assertEquals(Boolean.TRUE, config.getProperty("value").get()); + + ConfigManager.openProfile(profile); + assertEquals(Boolean.FALSE, config.getProperty("value").get()); + } finally { + ConfigManager.setOff(Set.of()); + ConfigManager.openProfile(""); + ConfigManager.deleteProfile(profile); + ConfigManager.active().unregister(id); + Files.deleteIfExists(ConfigManager.active().getFolder().resolve(id)); + } + } + + @Test + void aProfileCreatedAndKeptWhileAModIsOffGivesItItsDefaultsOnceBack() throws IOException { + String id = "late-initialization-off-kept-test.json"; + String profile = "late-initialization-off-kept-profile"; + try { + ValueConfig config = new ValueConfig(id); + finishStartup(); + config.getProperty("value").setAs(true); + ConfigManager.active().save(id); + + ConfigManager.setOff(Set.of(id)); + ConfigManager.createProfile(profile); + ConfigManager.setOff(Set.of()); + + assertEquals(Boolean.FALSE, config.getProperty("value").get()); + } finally { + ConfigManager.setOff(Set.of()); + ConfigManager.openProfile(""); + ConfigManager.deleteProfile(profile); + ConfigManager.active().unregister(id); + Files.deleteIfExists(ConfigManager.active().getFolder().resolve(id)); + } + } + + @Test + void aTreeOfAnOffModSitsOutANewProfileAndGetsItsDefaultsOnceBack() throws IOException { + String id = "late-initialization-off-tree-test.json"; + String profile = "late-initialization-off-tree-profile"; + Property value = Properties.simple("value", "Value", "", false); + Tree tree = Tree.tree(id).put(value); + AtomicInteger calls = new AtomicInteger(); + try { + ConfigManager.active().register(tree); + value.setAs(true); + ConfigManager.active().save(id); + value.addCallback(set -> { + calls.incrementAndGet(); + return false; + }); + + ConfigManager.setOff(Set.of(id)); + ConfigManager.createProfile(profile); + assertEquals(0, calls.get(), "its callbacks are the mod's code, which is off"); + assertEquals(Boolean.TRUE, value.get()); + assertFalse(ConfigManager.active().exists(id)); + + assertTrue(ConfigManager.setOff(Set.of())); + assertSame(tree, ConfigManager.active().get(id)); + assertEquals(Boolean.FALSE, value.get()); + } finally { + ConfigManager.setOff(Set.of()); + ConfigManager.openProfile(""); + ConfigManager.deleteProfile(profile); + ConfigManager.active().unregister(id); + Files.deleteIfExists(ConfigManager.active().getFolder().resolve(id)); + } + } + + @Test + void aProfileCreatedWhileATreesModIsOffKeepsItsDefaultsWhenItComesBackElsewhere() throws IOException { + String id = "late-initialization-off-tree-elsewhere-test.json"; + String profile = "late-initialization-off-tree-elsewhere-profile"; + Property value = Properties.simple("value", "Value", "", false); + Tree tree = Tree.tree(id).put(value); + try { + ConfigManager.active().register(tree); + value.setAs(true); + ConfigManager.active().save(id); + + ConfigManager.setOff(Set.of(id)); + ConfigManager.createProfile(profile); + ConfigManager.openProfile(""); + assertTrue(ConfigManager.setOff(Set.of())); + assertEquals(Boolean.TRUE, value.get()); + + ConfigManager.openProfile(profile); + assertEquals(Boolean.FALSE, value.get()); + } finally { + ConfigManager.setOff(Set.of()); + ConfigManager.openProfile(""); + ConfigManager.deleteProfile(profile); + ConfigManager.active().unregister(id); + Files.deleteIfExists(ConfigManager.active().getFolder().resolve(id)); + } + } + + @Test + void anEntrypointOfTheUpdatedModReplacesItsOlderInstance() throws IOException { + String id = "late-initialization-replaced-test.json"; + try { + ValueConfig old = new ValueConfig(id); + finishStartup(); + old.getProperty("value").setAs(true); + + AtomicReference replacement = new AtomicReference<>(); + ConfigManager.initializeLate(config -> true, () -> replacement.set(new ValueConfig(id))); + + assertSame(replacement.get().getTree(), ConfigManager.active().get(id)); + assertEquals(Boolean.TRUE, replacement.get().getProperty("value").get()); + assertTrue(ConfigManager.createdConfigs().contains(replacement.get())); + assertFalse(ConfigManager.createdConfigs().contains(old)); + } finally { + ConfigManager.active().unregister(id); + Files.deleteIfExists(ConfigManager.active().getFolder().resolve(id)); + } + } + + @Test + void aReplacementThatPreloadsKeepsTheSavedValues() throws IOException { + String id = "late-initialization-preloaded-replacement-test.json"; + try { + ValueConfig old = new ValueConfig(id); + finishStartup(); + old.getProperty("value").setAs(true); + ConfigManager.active().save(id); + + AtomicReference replacement = new AtomicReference<>(); + ConfigManager.initializeLate(config -> true, () -> { + replacement.set(new ValueConfig(id)); + replacement.get().preload(); + }); + + assertSame(replacement.get().getTree(), ConfigManager.active().get(id)); + assertEquals(Boolean.TRUE, replacement.get().getProperty("value").get()); + } finally { + ConfigManager.active().unregister(id); + Files.deleteIfExists(ConfigManager.active().getFolder().resolve(id)); + } + } + + @Test + void aReplacementAnEntrypointsWorkerBuiltTakesOver() throws IOException { + String id = "late-initialization-worker-replacement-test.json"; + try { + ValueConfig old = new ValueConfig(id); + finishStartup(); + old.getProperty("value").setAs(true); + + AtomicReference replacement = new AtomicReference<>(); + boolean waiting = ConfigManager.initializeLate(config -> true, () -> { + Thread worker = new Thread(() -> replacement.set(new ValueConfig(id))); + worker.start(); + try { + worker.join(); + } catch (InterruptedException e) { + throw new IllegalStateException(e); + } + }); + + assertFalse(waiting); + assertSame(replacement.get().getTree(), ConfigManager.active().get(id)); + assertEquals(Boolean.TRUE, replacement.get().getProperty("value").get()); + } finally { + ConfigManager.active().unregister(id); + Files.deleteIfExists(ConfigManager.active().getFolder().resolve(id)); + } + } + + @Test + void aReplacementAWorkerIsStillBuildingTakesOverInTheNextWindow() throws IOException, InterruptedException { + String id = "late-initialization-worker-unfinished-test.json"; + try { + Config old = new LateConfig(id); + finishStartup(); + + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicReference replacement = new AtomicReference<>(); + AtomicReference worker = new AtomicReference<>(); + assertTrue(ConfigManager.initializeLate(config -> true, () -> { + worker.set(new Thread(() -> { + try { + replacement.set(new BlockedConfig(id, entered, release)); + } catch (InterruptedException e) { + throw new IllegalStateException(e); + } + })); + worker.get().start(); + try { + entered.await(); + } catch (InterruptedException e) { + throw new IllegalStateException(e); + } + })); + assertSame(old.getTree(), ConfigManager.active().get(id)); + + release.countDown(); + worker.get().join(); + assertFalse(ConfigManager.initializeLate(config -> true, () -> { + })); + assertSame(replacement.get().getTree(), ConfigManager.active().get(id)); + } finally { + ConfigManager.active().unregister(id); + Files.deleteIfExists(ConfigManager.active().getFolder().resolve(id)); + } + } + + @Test + void aReplacementReadsTheSavedValuesRightAfterItsPreload() throws IOException { + String id = "late-initialization-preload-reads-test.json"; + try { + ValueConfig old = new ValueConfig(id); + finishStartup(); + old.getProperty("value").setAs(true); + + AtomicReference replacement = new AtomicReference<>(); + AtomicReference> read = new AtomicReference<>(); + ConfigManager.initializeLate(config -> true, () -> { + replacement.set(new ValueConfig(id)); + replacement.get().preload(); + assertTrue(replacement.get().value); + read.set(replacement.get().getProperty("value")); + assertEquals(Boolean.TRUE, read.get().get()); + assertSame(old.getTree(), ConfigManager.active().get(id)); + }); + + assertSame(replacement.get().getTree(), ConfigManager.active().get(id)); + assertSame(read.get(), replacement.get().getProperty("value"), "what a mod kept from its setup must stay live"); + assertEquals(Boolean.TRUE, read.get().get()); + } finally { + ConfigManager.active().unregister(id); + Files.deleteIfExists(ConfigManager.active().getFolder().resolve(id)); + } + } + + @Test + void aReplacementThatReadItsSavedValuesEarlyStillResetsToItsDefaults() throws IOException { + String id = "late-initialization-preload-defaults-test.json"; + try { + ValueConfig old = new ValueConfig(id); + finishStartup(); + old.getProperty("value").setAs(true); + + AtomicReference replacement = new AtomicReference<>(); + ConfigManager.initializeLate(config -> true, () -> { + replacement.set(new ValueConfig(id)); + replacement.get().preload(); + }); + assertEquals(Boolean.TRUE, replacement.get().getProperty("value").get()); + + replacement.get().restoreDefaults(); + assertEquals(Boolean.FALSE, replacement.get().getProperty("value").get()); + } finally { + ConfigManager.active().unregister(id); + ConfigManager.backup().unregister(id); + Files.deleteIfExists(ConfigManager.active().getFolder().resolve(id)); + Files.deleteIfExists(ConfigManager.backup().getFolder().resolve(id)); + } + } + + // what Hotwire does around the entrypoints of a mod it adds + private static void startWithHotwire(Runnable entrypoints) throws ReflectiveOperationException { + Field initialized = ConfigManager.class.getDeclaredField("initialized"); + initialized.setAccessible(true); + initialized.setBoolean(null, false); + entrypoints.run(); + ConfigManager.initialize(); + } + + @Test + void aCopyAnAddedModMakesWaitsForItsWindowToReplaceTheOlderInstance() throws IOException, ReflectiveOperationException { + String id = "late-initialization-reopened-test.json"; + try { + ValueConfig old = new ValueConfig(id); + finishStartup(); + old.getProperty("value").setAs(true); + ConfigManager.active().save(id); + + AtomicReference copy = new AtomicReference<>(); + startWithHotwire(() -> copy.set(new ValueConfig(id))); + + assertNull(copy.get().getTree()); + assertSame(old.getTree(), ConfigManager.active().get(id)); + assertEquals(Boolean.TRUE, ConfigManager.active().load(id).getProp("value").get()); + + ConfigManager.initializeLate(config -> true, () -> { + }); + + assertSame(copy.get().getTree(), ConfigManager.active().get(id)); + assertEquals(Boolean.TRUE, copy.get().getProperty("value").get()); + } finally { + ConfigManager.active().unregister(id); + Files.deleteIfExists(ConfigManager.active().getFolder().resolve(id)); + } + } + + @Test + void aCopyAnAddedModPreloadsLeavesTheSavedValues() throws IOException, ReflectiveOperationException { + String id = "late-initialization-reopened-preload-test.json"; + AtomicReference copy = new AtomicReference<>(); + try { + ValueConfig old = new ValueConfig(id); + finishStartup(); + old.getProperty("value").setAs(true); + ConfigManager.active().save(id); + + startWithHotwire(() -> { + copy.set(new ValueConfig(id)); + copy.get().preload(); + }); + + assertNull(copy.get().getTree()); + assertEquals(Boolean.TRUE, ConfigManager.active().load(id).getProp("value").get()); + } finally { + ConfigManager.cancelInitialization(copy.get()); + ConfigManager.active().unregister(id); + Files.deleteIfExists(ConfigManager.active().getFolder().resolve(id)); + } + } + + @Test + void aCopyAnAddedModStartsWithReadsTheSavedValues() throws IOException, ReflectiveOperationException { + String id = "late-initialization-reopened-reads-test.json"; + AtomicReference copy = new AtomicReference<>(); + try { + ValueConfig old = new ValueConfig(id); + finishStartup(); + old.getProperty("value").setAs(true); + + startWithHotwire(() -> { + copy.set(new ValueConfig(id)); + assertEquals(Boolean.TRUE, copy.get().getProperty("value").get()); + assertTrue(copy.get().value); + }); + + assertSame(old.getTree(), ConfigManager.active().get(id)); + } finally { + ConfigManager.cancelInitialization(copy.get()); + ConfigManager.active().unregister(id); + Files.deleteIfExists(ConfigManager.active().getFolder().resolve(id)); + } + } + + @Test + void anAddedModsNewConfigStillInitializesWhenItStarts() throws ReflectiveOperationException { + AtomicReference created = new AtomicReference<>(); + finishStartup(); + startWithHotwire(() -> created.set(new LateConfig("late-initialization-reopened-new-test.json"))); + assertNotNull(created.get().getTree()); + } + + @Test + void aPreloadedCopyFromAnotherModLeavesTheSavedValues() throws IOException { + String id = "late-initialization-preloaded-copy-test.json"; + AtomicReference copy = new AtomicReference<>(); + try { + ValueConfig theirs = new ValueConfig(id); + finishStartup(); + theirs.getProperty("value").setAs(true); + ConfigManager.active().save(id); + + ConfigManager.initializeLate(config -> config != theirs, () -> { + copy.set(new ValueConfig(id)); + copy.get().preload(); + }); + + assertNull(copy.get().getTree()); + assertSame(theirs.getTree(), ConfigManager.active().get(id)); + assertEquals(Boolean.TRUE, theirs.getProperty("value").get()); + assertEquals(Boolean.TRUE, ConfigManager.active().load(id).getProp("value").get()); + } finally { + ConfigManager.cancelInitialization(copy.get()); + ConfigManager.active().unregister(id); + Files.deleteIfExists(ConfigManager.active().getFolder().resolve(id)); + } + } + + @Test + void aCopyPreloadedBeforeItsWindowLeavesTheSavedValues() throws IOException { + String id = "late-initialization-preloaded-early-test.json"; + Config copy = null; + try { + ValueConfig old = new ValueConfig(id); + finishStartup(); + old.getProperty("value").setAs(true); + ConfigManager.active().save(id); + + ConfigManager.setAwaitingLateWindow(config -> config.id.equals(id)); + copy = new ValueConfig(id); + copy.preload(); + + assertNull(copy.getTree()); + assertEquals(Boolean.TRUE, old.getProperty("value").get()); + } finally { + ConfigManager.cancelInitialization(copy); + ConfigManager.active().unregister(id); + Files.deleteIfExists(ConfigManager.active().getFolder().resolve(id)); + } + } + + @Test + void anEntrypointCannotReplaceAConfigOfAnotherMod() throws IOException { + String id = "late-initialization-other-mod-test.json"; + try { + ValueConfig theirs = new ValueConfig(id); + finishStartup(); + + AtomicReference mine = new AtomicReference<>(); + ConfigManager.initializeLate(config -> config != theirs, () -> mine.set(new ValueConfig(id))); + + assertNull(mine.get().getTree()); + assertSame(theirs.getTree(), ConfigManager.active().get(id)); + } finally { + ConfigManager.active().unregister(id); + Files.deleteIfExists(ConfigManager.active().getFolder().resolve(id)); + } + } + + @Test + void aTemporaryCopyOfTheUpdatedModDoesNotReplaceIt() throws IOException { + String id = "late-initialization-temporary-test.json"; + try { + ValueConfig old = new ValueConfig(id); + finishStartup(); + + Config copy = new ValueConfig(id); + ConfigManager.initializeLate(config -> true, () -> { + }); + + assertNull(copy.getTree()); + assertSame(old.getTree(), ConfigManager.active().get(id)); + } finally { + ConfigManager.active().unregister(id); + Files.deleteIfExists(ConfigManager.active().getFolder().resolve(id)); + } + } + + @Test + void aReplacementThatFailsLeavesTheOlderInstanceRegistered() throws IOException { + String id = "late-initialization-failed-replacement-test.json"; + try { + ValueConfig old = new ValueConfig(id); + finishStartup(); + + ConfigManager.initializeLate(config -> true, () -> new FailingConfig(id)); + + assertSame(old.getTree(), ConfigManager.active().get(id)); + assertTrue(ConfigManager.createdConfigs().contains(old)); + } finally { + ConfigManager.active().unregister(id); + Files.deleteIfExists(ConfigManager.active().getFolder().resolve(id)); + } + } + + @Test + void aReplacementThatFailsOnceRegisteredGivesTheOlderInstanceItsPlaceBack() throws IOException { + String id = "late-initialization-failed-registered-test.json"; + try { + ValueConfig old = new ValueConfig(id); + finishStartup(); + + AtomicReference failed = new AtomicReference<>(); + ConfigManager.initializeLate(config -> true, () -> failed.set(new FailsOnceRegisteredConfig(id))); + + assertSame(old.getTree(), ConfigManager.active().get(id)); + // what a profile switch goes on to rebuild + assertTrue(ConfigManager.createdConfigs().contains(old)); + assertFalse(ConfigManager.createdConfigs().contains(failed.get())); + } finally { + ConfigManager.active().unregister(id); + Files.deleteIfExists(ConfigManager.active().getFolder().resolve(id)); + } + } + + @Test + void aReplacementThatFailsLeavesTheOlderDefaultsToResetTo() throws IOException { + String id = "late-initialization-failed-defaults-test.json"; + try { + ValueConfig old = new ValueConfig(id); + finishStartup(); + old.getProperty("value").setAs(true); + + ConfigManager.initializeLate(config -> true, () -> new FailsOnceRegisteredConfig(id) { + { + value = true; + } + }); + old.restoreDefaults(); + + assertEquals(Boolean.FALSE, old.getProperty("value").get()); + } finally { + ConfigManager.active().unregister(id); + ConfigManager.backup().unregister(id); + Files.deleteIfExists(ConfigManager.active().getFolder().resolve(id)); + Files.deleteIfExists(ConfigManager.backup().getFolder().resolve(id)); + } + } + + @Test + void aReplacementThatFailsLeavesAnUnreadableDefaultsBackupInPlace() throws IOException { + String id = "late-initialization-unreadable-defaults-test.json"; + Path backup = ConfigManager.backup().getFolder().resolve(id); + try { + new ValueConfig(id); + finishStartup(); + // reading a directory fails whatever user runs this, unlike a file without read permission + Files.delete(backup); + Files.createDirectory(backup); + + ConfigManager.initializeLate(config -> true, () -> new FailingConfig(id)); + + assertTrue(Files.exists(backup)); + } finally { + ConfigManager.active().unregister(id); + ConfigManager.backup().unregister(id); + Files.deleteIfExists(ConfigManager.active().getFolder().resolve(id)); + Files.deleteIfExists(backup); + } + } + + @Test + void aReplacementResetsToItsOwnDefaultsAfterTheOlderInstanceWasReset() throws IOException { + String id = "late-initialization-replaced-defaults-test.json"; + try { + ValueConfig old = new ValueConfig(id); + finishStartup(); + old.restoreDefaults(); + + AtomicReference replacement = new AtomicReference<>(); + ConfigManager.initializeLate(config -> true, () -> replacement.set(new ValueConfig(id) { + { + value = true; + } + })); + replacement.get().getProperty("value").setAs(false); + replacement.get().restoreDefaults(); + + assertEquals(Boolean.TRUE, replacement.get().getProperty("value").get()); + } finally { + ConfigManager.active().unregister(id); + ConfigManager.backup().unregister(id); + Files.deleteIfExists(ConfigManager.active().getFolder().resolve(id)); + Files.deleteIfExists(ConfigManager.backup().getFolder().resolve(id)); + } + } + + @Test + void aFailedSaveKeepsTheOlderInstanceAndItsUnsavedValues() throws IOException { + String id = "late-initialization-unsaved-test.json"; + // a directory where the save writes its temporary file + Path blocker = ConfigManager.active().getFolder().resolve(id + ".tmp"); + try { + ValueConfig old = new ValueConfig(id); + finishStartup(); + old.getProperty("value").setAs(true); + Files.createDirectories(blocker); + + AtomicReference replacement = new AtomicReference<>(); + ConfigManager.initializeLate(config -> true, () -> replacement.set(new ValueConfig(id))); + + assertNull(replacement.get().getTree()); + assertSame(old.getTree(), ConfigManager.active().get(id)); + assertEquals(Boolean.TRUE, old.getProperty("value").get()); + } finally { + Files.deleteIfExists(blocker); + ConfigManager.active().unregister(id); + Files.deleteIfExists(ConfigManager.active().getFolder().resolve(id)); + } + } + + @Test + void aSecondInstanceInTheWindowKeepsSavedValues() throws IOException { + String id = "late-initialization-duplicate-test.json"; + Files.write(ConfigManager.active().getFolder().resolve(id), "{\"value\": true}".getBytes(StandardCharsets.UTF_8)); + try { + finishStartup(); + ValueConfig first = new ValueConfig(id); + AtomicReference second = new AtomicReference<>(); + ConfigManager.initializeLate(first::equals, () -> second.set(new ValueConfig(id))); + assertNull(second.get().getTree()); + assertEquals(Boolean.TRUE, first.getProperty("value").get()); + assertTrue(ConfigManager.createdConfigs().contains(second.get())); + } finally { + ConfigManager.active().unregister(id); + Files.deleteIfExists(ConfigManager.active().getFolder().resolve(id)); + } + } + + @Test + void configsHandedOverInTheWindowAreInitialized() { + finishStartup(); + Config library = new LateConfig("late-initialization-claimed-test.json"); + ConfigManager.claimForLateInitialization(library); + assertTrue(ConfigManager.createdConfigs().contains(library)); + assertNull(library.getTree()); + + ConfigManager.initializeLate(config -> false, () -> ConfigManager.claimForLateInitialization(library)); + assertNotNull(library.getTree()); + } + + @Test + void handedOverCopiesOfARegisteredConfigStayOut() throws IOException { + String id = "late-initialization-claimed-copy-test.json"; + try { + ValueConfig saved = new ValueConfig(id); + finishStartup(); + saved.getProperty("value").setAs(true); + ConfigManager.active().save(id); + + Config copy = new ValueConfig(id); + ConfigManager.initializeLate(config -> false, () -> ConfigManager.claimForLateInitialization(copy)); + assertNull(copy.getTree()); + assertEquals(Boolean.TRUE, saved.getProperty("value").get()); + } finally { + ConfigManager.active().unregister(id); + Files.deleteIfExists(ConfigManager.active().getFolder().resolve(id)); + } + } +} diff --git a/modules/config/src/main/java/org/polyfrost/oneconfig/api/config/v1/backend/Backend.java b/modules/config/src/main/java/org/polyfrost/oneconfig/api/config/v1/backend/Backend.java index 22dd5b738..fde46b16b 100644 --- a/modules/config/src/main/java/org/polyfrost/oneconfig/api/config/v1/backend/Backend.java +++ b/modules/config/src/main/java/org/polyfrost/oneconfig/api/config/v1/backend/Backend.java @@ -55,6 +55,11 @@ public abstract class Backend { public static final String UI_PLACEHOLDER_METADATA = "ui_placeholder"; public static final String CUSTOM_SAVE_TRACKED_METADATA = "custom_save_tracked"; public static final String CUSTOM_SAVE_DIRTY_METADATA = "custom_save_dirty"; + /** + * Trees marked with this metadata skip their custom save, as what it runs can't right now, and stay dirty for when it can + */ + @ApiStatus.Internal + public static final String CUSTOM_SAVE_HELD_METADATA = "custom_save_held"; private final Map trees = new ConcurrentHashMap<>(); private final Map saveGenerations = new ConcurrentHashMap<>(); @@ -67,6 +72,7 @@ private static boolean isUiPlaceholder(@NotNull Tree tree) { } private static void runCustomSave(@NotNull Tree tree, @NotNull Runnable customSave) { + if (Boolean.TRUE.equals(tree.getMetadata(CUSTOM_SAVE_HELD_METADATA))) return; boolean tracked = Boolean.TRUE.equals(tree.getMetadata(CUSTOM_SAVE_TRACKED_METADATA)); if (tracked && !Boolean.TRUE.equals(tree.getMetadata(CUSTOM_SAVE_DIRTY_METADATA))) return; customSave.run(); diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/ConfigRegistry.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/ConfigRegistry.kt index f137a4937..e137d2eee 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/ConfigRegistry.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/ConfigRegistry.kt @@ -3,6 +3,7 @@ package org.polyfrost.oneconfig.internal.ui.api import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList import org.apache.logging.log4j.LogManager @@ -74,6 +75,27 @@ object ConfigRegistry { var revision by mutableIntStateOf(0) private set + /** Configs of mods turned off live, kept for when they come back but out of reach as their callbacks are that mod's code */ + private var offIds by mutableStateOf(emptySet()) + + // shortcut: a bind a mod registers itself outside any config has no known owner so keeps firing, Hotwire taking those out would close that + fun setOff(ids: Set) { + if (ids == offIds) return + val before = offIds + offIds = ids + val caughtUp = ConfigManager.setOff(ids) + // read from the manager, as Hotwire takes the cards of a mod it turns off out of this registry itself + ConfigManager.active().trees().forEach { tree -> + val id = tree.id ?: return@forEach + if (id in ids && id !in before) MinecraftKeybindRegistrar.unregister(tree) + else if (id !in ids && id in before) MinecraftKeybindRegistrar.scan(tree) + } + // profile rebinds don't announce their trees, which the listener above leaves to this + if (caughtUp) loadFrom(ConfigManager.active(), ConfigSource.OC) + SearchCorpus.invalidate(ConfigDocumentSource) + revision++ + } + init { ModToggleStore.install() // Index configs as they come in (compat layers etc...) @@ -104,7 +126,7 @@ object ConfigRegistry { } fun shouldShowModCard(config: ConfigData): Boolean = - config.id.lowercase() !in hiddenModCardIds && config.title.asRenderText().lowercase() !in hiddenModCardTitles + config.id !in offIds && config.id.lowercase() !in hiddenModCardIds && config.title.asRenderText().lowercase() !in hiddenModCardTitles fun shouldShowModCardId(id: String): Boolean { val lower = id.lowercase() @@ -112,7 +134,7 @@ object ConfigRegistry { } fun shouldShowInSearch(config: ConfigData): Boolean = - config.id.lowercase() !in hiddenSearchIds && config.title.asRenderText().lowercase() !in hiddenModCardTitles + config.id !in offIds && config.id.lowercase() !in hiddenSearchIds && config.title.asRenderText().lowercase() !in hiddenModCardTitles /** * Loads all trees from the given [ConfigManager] as [source] entries @@ -140,7 +162,7 @@ object ConfigRegistry { onOpen: (() -> Unit)? = null, bumpRevision: Boolean = true ): Boolean { - MinecraftKeybindRegistrar.scan(tree, force = bumpRevision) + if (tree.id !in offIds) MinecraftKeybindRegistrar.scan(tree, force = bumpRevision) if (tree.id == null || tree.title == null) return false if (tree.getMetadata("hidden") != null) return false return upsert(TreeConfigData(tree, source, onOpen), bumpRevision) diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireBridge.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireBridge.kt new file mode 100644 index 000000000..5863c39cb --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireBridge.kt @@ -0,0 +1,524 @@ +package org.polyfrost.oneconfig.internal.ui.api + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.setValue +import java.net.URI +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.util.Optional +import java.util.concurrent.CompletableFuture +import java.util.function.Consumer +import org.polyfrost.oneconfig.api.config.v1.ConfigManager +import org.polyfrost.oneconfig.api.platform.v1.ModInfo +import org.polyfrost.oneconfig.api.platform.v1.Platform +import org.polyfrost.oneconfig.internal.ui.components.translated + +/** + * Hotwire's public API read reflectively, since Hotwire is an optional mod OneConfig does not compile against + * + * Every call falls back to "Hotwire is not there" when the API is missing or one of its calls throws + */ +object HotwireBridge { + class ModState(val id: String, val name: String, val version: String, val on: Boolean, val switchable: Boolean, val offSinceStart: Boolean) + + /** [switched] is every mod the change would move, including the ones Hotwire switches along with those asked for */ + class Preview(val possible: Boolean, val message: String, val switched: List = emptyList()) + + /** + * [needsReopen] means nothing failed but the change only finishes once the world is reopened, which Hotwire's API + * does not do itself, and [refused] that Hotwire chose not to do it, so nothing went wrong that a report would help with + */ + class Result(val ok: Boolean, val message: String, val needsReopen: Boolean = false, val refused: Boolean = false) + + /** Bumped whenever Hotwire finishes a switch, whoever started it, or [invalidate] is called */ + var revision by mutableIntStateOf(0) + private set + + fun invalidate() { + ModInfo.refreshLoadedMods() + revision++ + } + + private val apiClass: Class<*>? by lazy { runCatching { Class.forName("dev.moon.hotwire.api.HotwireApi") }.getOrNull() } + + private val api: Any? by lazy { + val cls = apiClass ?: return@lazy null + runCatching { + cls.getMethod("getInstance").invoke(null).also { api -> + cls.getMethod("addListener", Consumer::class.java).invoke(api, Consumer { + Platform.screen().runOnUiThread(::invalidate) + }) + } + }.getOrNull() + } + + private fun call(name: String, vararg args: Any): Any? { + val api = api ?: return null + val types = args.map { + when (it) { + is Collection<*> -> Collection::class.java + is Int -> Int::class.java + else -> it.javaClass + } + }.toTypedArray() + return runCatching { apiClass!!.getMethod(name, *types).invoke(api, *args) }.getOrNull() + } + + private fun Any.get(accessor: String): Any? = javaClass.getMethod(accessor).invoke(this) + + private fun unavailable() = translated("oneconfig.page.hotwire.unavailable", "Hotwire is not available.") + + /** Whether this is Hotwire 0.7.3 or newer, the oldest whose API has everything the page reads */ + val supportsSwitching: Boolean by lazy { (call("getRevision") as? Int ?: 0) >= MIN_REVISION } + + /** Has Hotwire write a bug report zip, with the game log and what it did and personal details taken out */ + @Suppress("UNCHECKED_CAST") + fun writeReport(reason: String): CompletableFuture = + call("writeReport", reason) as CompletableFuture? ?: CompletableFuture.failedFuture(IllegalStateException(unavailable())) + + /** Every mod Hotwire knows, or null when Hotwire is missing or has not listed its mods yet */ + fun mods(): List? = runCatching { + (call("getMods") as? List<*>)?.takeIf { it.isNotEmpty() }?.map { + ModState( + it!!.get("id") as String, + it.get("name") as String? ?: "", + it.get("version") as String? ?: "", + it.get("on") as Boolean, + it.get("switchable") as Boolean, + it.get("offSinceStart") as Boolean, + ) + } + }.getOrNull() + + fun installedState(): List = mods().orEmpty().map { listOf(it.id, it.version, it.on) } + pendingTasks() + + private fun pendingTasks(): List<*> = call("getPendingTasks") as? List<*> ?: emptyList() + + // only these mean an update is taken care of, not a mod turning off at the next launch or a jar copied for one + private fun queuedUpdates(): List<*> = pendingTasks().filter { (it!!.get("kind") as Enum<*>).name == "UPDATE" } + + /** The name of each mod whose update waits for the next launch, by id */ + fun queuedMods(): Map = runCatching { + queuedUpdates().associate { it!!.get("modId") as String to it.get("name") as String } + }.getOrDefault(emptyMap()) + + class AppliedUpdates(val now: List, val nextLaunch: List) + + /** + * What a batch updated live and what waits for the next launch: a live update joins the queue too, to swap its jar + * when the game closes, so only a mod already running its new version counts as live + */ + fun whenApplied(modsBefore: List, modsAfter: List, queuedBefore: Map, queuedAfter: Map): AppliedUpdates { + val versions = modsBefore.associate { it.id to it.version } + val idsAfter = modsAfter.mapTo(HashSet()) { it.id } + // Hotwire can run an update from a copy under a new id, which takes the old row's place under the same name + // while the jar swap stays queued under the old id, and the mods that copy needs go along without updating + val replaced = modsBefore.filter { it.id !in idsAfter }.associateBy { it.name } + val copies = modsAfter.filter { it.id !in versions && replaced[it.name]?.let { old -> old.version != it.version } == true } + val live = modsAfter.filter { it.id in versions && versions[it.id] != it.version } + copies + val liveIds = live.map { it.id } + copies.map { replaced.getValue(it.name).id } + return AppliedUpdates(live.map { it.name }, (queuedAfter - queuedBefore.keys - liveIds.toSet()).values.toList()) + } + + val isSwitching: Boolean get() = call("isSwitching") as? Boolean ?: false + + /** How far the running switch is, from 0 to 1 */ + fun progress(): Float = runCatching { (call("getProgress")!!.get("fraction") as Double).toFloat() }.getOrDefault(0f) + + /** What turning [ids] on or off would do, including the mods Hotwire switches along with them */ + @Suppress("UNCHECKED_CAST") + fun preview(ids: Collection, on: Boolean): Preview = runCatching { + val preview = call(if (on) "previewOn" else "previewOff", ids)!! + Preview(preview.get("possible") as Boolean, preview.get("message") as String, preview.get("switched") as List) + }.getOrElse { Preview(false, unavailable()) } + + /** What turning [off] off and [on] on together would do; blocks on Hotwire's plan, so call it off the UI thread */ + @Suppress("UNCHECKED_CAST") + fun previewTogether(off: List, on: List): Preview? { + if (off.isEmpty() || on.isEmpty()) return null + return runCatching { + val plan = changePlan(off = off, on = on) + Preview(plan.through("ChangePlan", "canApply") == true, (plan.through("ChangePlan", "getLines") as List).joinToString(" ")) + }.getOrElse { + Preview(false, translated("oneconfig.page.hotwire.switch.conflict", "Some of these mods need others you're turning off. Switch them separately.")) + } + } + + /** + * Hotwire's running downloads, leaving out [others]: Hotwire lists every download it has, its own update among + * them, so whatever was already running before a plan began isn't that plan's to show or stop + */ + private fun runningDownloads(others: Set = emptySet()): List = runCatching { + (call("getStoreDownloads") as List<*>).filterNotNull().filter { it.get("over") == false && it.get("id") !in others } + }.getOrDefault(emptyList()) + + fun runningDownloadIds(): Set = runCatching { runningDownloads().mapTo(HashSet()) { it.get("id") as Int } }.getOrDefault(emptySet()) + + /** [fraction] goes from 0 to 1, [paused] says every running download is paused, and [name] is the one being fetched */ + class DownloadProgress(val fraction: Float, val paused: Boolean, val name: String, private val ids: Set) { + /** What a bar showing [shown] for [before] moves to: a finished jar leaving must not pull it back, but a new jar starts it over */ + fun shownAfter(before: DownloadProgress?, shown: Float): Float = + if (before != null && before.ids.containsAll(ids)) maxOf(fraction, shown) else fraction + } + + /** How far Hotwire's running downloads but [others] are together, or null while none is running */ + @JvmOverloads + fun downloadProgress(others: Set = emptySet()): DownloadProgress? = runCatching { + val running = runningDownloads(others).ifEmpty { return null } + fun sum(field: String) = running.sumOf { it.get(field) as Long } + DownloadProgress( + (sum("done").toFloat() / sum("total").coerceAtLeast(1)).coerceAtMost(1f), + running.all { (it.get("state") as Enum<*>).name == "PAUSED" }, + (running.firstOrNull { (it.get("state") as Enum<*>).name == "GETTING" } ?: running.first()).get("name") as String, + running.mapTo(HashSet()) { it.get("id") as Int }, + ) + }.getOrNull() + + // shortcut: Hotwire doesn't say who started a download, so a plan begun beside [others] can't pause or stop anything, + // as stopping the next jar of one of those would delete its partial file, and a plan begun alone also controls + // whatever started after it; narrow this if its API ever names an owner + private fun controlDownloads(action: String, others: Set) { + if (others.isNotEmpty()) return + runCatching { runningDownloads().forEach { call(action, it.get("id") as Int) } } + } + + @JvmOverloads + fun pauseDownloads(paused: Boolean, others: Set = emptySet()) = controlDownloads(if (paused) "pauseDownload" else "resumeDownload", others) + + /** Ends the running downloads unless [others] were running too, and Hotwire then answers whatever was waiting on them with nothing to add */ + @JvmOverloads + fun stopDownloads(others: Set = emptySet()) = controlDownloads("stopDownload", others) + + /** Whether Hotwire is running a switch or add right now */ + fun isWorking(): Boolean = runCatching { call("getProgress")!!.get("running") as Boolean }.getOrDefault(false) + + fun isStarting(ids: Collection): Boolean = isWorking() && ids.any { call("isAddedThisSession", it) != true } + + fun isUpdating(): Boolean = runCatching { (call("getStatus")!!.get("state") as Enum<*>).name == "BUSY" }.getOrDefault(false) + + fun awaitIdle() { + val start = System.nanoTime() + var sawWorking = false + while (true) { + Thread.sleep(100) + val working = isWorking() + if (working) sawWorking = true + if (sawWorking && !working || !sawWorking && System.nanoTime() - start > 3_000_000_000L) return + } + } + + /** [run] hands back the outcome when Hotwire reports one, and null when Hotwire only posts it to chat */ + class JarAction(val label: String, val primary: Boolean, private val run: () -> CompletableFuture?) { + /** Runs the action from a click, where a failure inside Hotwire comes back as a failed result, not a throw */ + fun attempt(): CompletableFuture? = + runCatching(run).getOrElse { CompletableFuture.completedFuture(failure(it)) }?.exceptionally(::failure) + } + + private fun failure(error: Throwable): Result { + val reason = error.cause ?: error + return Result(false, reason.message ?: reason.toString()) + } + + /** What Hotwire would do with a mod, and the choices it offers, as its own add and update panels show */ + class JarPlan( + val title: String, + val detail: String, + val lines: List, + val actions: List, + val onCancel: (() -> Unit)? = null, + val reopens: Boolean = false, + ) + + // Hotwire implements its API interfaces with private classes, which reflection can only reach through the interface + private fun Any.through(apiInterface: String, method: String): Any? = + Class.forName("dev.moon.hotwire.api.$apiInterface").getMethod(method).invoke(this) + + @Suppress("UNCHECKED_CAST") + private fun Any?.asResults(): CompletableFuture = (this as CompletableFuture).thenApply { result -> + val kind = (result.get("kind") as Enum<*>).name + Result(kind == "DONE", result.get("message") as String? ?: "", needsReopen = kind == "NEEDS_REOPEN", refused = kind == "REFUSED") + } + + class OpeningPrompt( + val title: String, + val lines: List, + val acceptText: String, + val openHotwireText: String, + val waitSeconds: Int, + val accept: () -> Unit, + val acceptAndOpenHotwire: () -> Unit, + ) + + /** Hotwire's warning, which refuses this mod's requests until it has been shown; null once it's been accepted */ + fun openingPrompt(): OpeningPrompt? = runCatching { + val prompt = call("findOpeningPrompt") ?: run { + // accepted in an earlier session, which Hotwire only counts as shown for this mod once it's asked to show it + call("showPrompt") + return null + } + fun text(method: String) = prompt.through("OpeningPrompt", method) as String + fun act(method: String): () -> Unit = { + prompt.through("OpeningPrompt", method) + revision++ + } + @Suppress("UNCHECKED_CAST") + OpeningPrompt( + text("getTitle"), + prompt.through("OpeningPrompt", "getLines") as List, + text("getAcceptText"), + text("getOpenHotwireText"), + prompt.through("OpeningPrompt", "getWaitSeconds") as Int, + act("accept"), + act("acceptAndOpenHotwire"), + ) + }.getOrNull() + + /** Opens the folder Hotwire saves downloads to in the system's file manager */ + fun openDownloads() { + call("openDownloads") + } + + /** Opens Hotwire's own screen */ + fun openScreen() { + call("openScreen") + } + + class StoreHit( + val projectId: String, + val slug: String, + val title: String, + val author: String, + val description: String, + val downloads: Long, + val iconUrl: String?, + val installed: Boolean, + ) + + class StorePage(val hits: List, val total: Int) + + /** + * Hotwire only learns of a project from a jar's hash when its store first opens, so it misses mods added since then + * and any jar Modrinth didn't serve; a project named after one of the [mods] present counts as installed too, though + * only Hotwire's own answer should hide a project, since a different project can share a mod's name + */ + fun isInstalled(hit: StoreHit, mods: Collection): Boolean = + hit.installed || mods.any { it.id == hit.slug || it.name.equals(hit.title, ignoreCase = true) } + + class StoreUpdate( + val modId: String, + val projectId: String, + val name: String, + val installedVersion: String, + val newVersion: String, + ) + + /** Downloads a Modrinth icon once and keeps it on disk for later opens; blocks on the network */ + fun iconFor(url: String): Path { + val dir = Files.createDirectories(Path.of(System.getProperty("java.io.tmpdir"), "oneconfig-hotwire", "icons")) + // named by the url so a remote path can never pick where the file lands + val ext = url.substringAfterLast('.').lowercase().takeIf { it.length in 3..4 && it.all(Char::isLetter) } ?: "png" + val file = dir.resolve("${Integer.toHexString(url.hashCode())}-${url.length}.$ext") + if (!Files.exists(file)) { + val uri = URI(url) + require(uri.scheme == "https") { "Icons only load over https." } + val connection = uri.toURL().openConnection().apply { + connectTimeout = ICON_TIMEOUT_MS + readTimeout = ICON_TIMEOUT_MS + } + val bytes = connection.getInputStream().use { it.readNBytes(MAX_ICON_BYTES) } + // moved into place whole, so an icon fetched twice at once is never read half-written + val temp = Files.createTempFile(dir, file.fileName.toString(), ".tmp") + try { + Files.write(temp, bytes) + Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) + } finally { + Files.deleteIfExists(temp) + } + } + return file + } + + private fun storeQuery(words: String): Any = + Class.forName("dev.moon.hotwire.api.StoreQuery").getMethod("of", String::class.java).invoke(null, words) + + private fun directDownloadQuery(): Any = storeQuery("").let { + it.javaClass.getMethod("withBrowsing", String::class.java, String::class.java).invoke(it, "", MODRINTH_LOADER) + } + + class StoreCategory(val name: String, val parts: List) + + /** Modrinth's mod categories as Hotwire groups them, where [StoreCategory.parts] are what a search can filter by */ + @Suppress("UNCHECKED_CAST") + fun storeCategories(): List = runCatching { + val mod = Class.forName("dev.moon.hotwire.api.StoreKind").enumConstants.first { (it as Enum<*>).name == "MOD" } + (call("getStoreCategories", mod) as List<*>).map { StoreCategory(it!!.get("name") as String, it.get("parts") as List) } + }.getOrDefault(emptyList()) + + /** + * Searches Modrinth for mods matching [words] in [category], or every category when blank, skipping the first + * [offset] results; blocks on the network and on Hotwire's check of what's installed, so call it off the UI thread + */ + fun searchStore(words: String, category: String, offset: Int): StorePage { + val query = storeQuery(words) + .let { it.javaClass.getMethod("withCategory", String::class.java).invoke(it, category) } + .let { it.javaClass.getMethod("withOffset", Int::class.java).invoke(it, offset) } + val page = (call("searchStore", query) as CompletableFuture<*>).get()!! + val hits = (page.get("mods") as List<*>).map { + StoreHit( + it!!.get("id") as String, + it.get("slug") as String? ?: "", + it.get("name") as String, + it.get("author") as String? ?: "", + it.get("description") as String? ?: "", + it.get("downloads") as Long, + (it.get("iconUrl") as String?)?.takeIf { url -> url.isNotBlank() }, + it.get("installed") as Boolean, + ) + } + return StorePage(hits, page.get("total") as Int) + } + + /** The newest version of a project that fits this game; blocks on the network */ + private fun newestVersion(projectId: String): String { + val project = (call("findStoreProject", storeQuery(""), projectId) as CompletableFuture<*>).get() as Optional<*> + val version = (project.orElse(null)?.get("versions") as List<*>?)?.firstOrNull() + ?: error(translated("oneconfig.page.hotwire.modrinth.no_version", "No version of this fits this game.")) + return version.get("id") as String + } + + /** What adding [hit] would do, including what Hotwire adds along with it; blocks on the network */ + @Suppress("UNCHECKED_CAST") + fun storePlan(hit: StoreHit): JarPlan { + val version = newestVersion(hit.projectId) + val add = (call("requestAdd", hit.projectId, version) as CompletableFuture<*>).get()!! + fun ask(method: String) = add.through("StoreAdd", method) + val canAdd = ask("canAdd") == true + var accepted = false + val later = JarAction(translated("oneconfig.page.hotwire.add.next_start", "Add at next launch"), false) { + accepted = true + ask("cancel") + call("queueForNextStart", hit.projectId, version).asResults() + } + val save = JarAction(translated("oneconfig.page.hotwire.add.save", "Save to Downloads"), false) { + accepted = true + ask("cancel") + val query = if (canAdd) storeQuery("") else directDownloadQuery() + call("saveToDownloads", query, hit.slug.ifBlank { hit.projectId }, version).asResults() + } + val now = JarAction( + if (ask("needsReopen") == true) { + translated("oneconfig.page.hotwire.add.reopen", "Save and add") + } else { + translated("oneconfig.page.hotwire.add.now", "Add now") + }, + true, + ) { + accepted = true + ask("accept").asResults() + } + return JarPlan( + ask("getTitle") as String, + "", + ask("getLines") as List, + // queueing skips what made Hotwire refuse, so an installed mod would start next to its own older jar + if (canAdd) listOf(now, later, save) else listOf(save), + onCancel = { if (!accepted) ask("cancel") }, + ) + } + + /** Hotwire's update check against Modrinth for the installed mods; blocks on the network */ + fun findUpdates(): List { + val found = (call("checkUpdates") as CompletableFuture<*>).get()!! + (found.get("failure") as String).takeIf { it.isNotBlank() }?.let { error(it) } + val queued = queuedUpdates().map { it!!.get("modId") } + return (found.get("updates") as List<*>).filterNot { it!!.get("id") in queued }.map { + StoreUpdate(it!!.get("id") as String, it.get("projectId") as String, it.get("name") as String, it.get("installedVersion") as String, it.get("newVersion") as String) + } + } + + private fun changePlan( + off: List = emptyList(), + on: List = emptyList(), + update: List = emptyList(), + jars: List = emptyList(), + ): Any { + val request = Class.forName("dev.moon.hotwire.api.ChangeRequest").constructors.single().newInstance(off, on, update, emptyList(), jars) + return (call("planChange", request) as CompletableFuture<*>).get()!! + } + + /** + * The choices a change plan offers, labelled from the [kind] of change: "add", "update" or "change" + * + * The main one does what each line says, live where it can and at the next launch where it can't, so a plan that + * needs a restart for part of it isn't labelled as all live or all waiting + */ + @Suppress("UNCHECKED_CAST") + private fun planFrom(kind: String, title: String, detail: String, plan: Any): JarPlan { + fun ask(method: String) = plan.through("ChangePlan", method) + val reopens = ask("needsReopen") == true + val restartOnly = (ask("getReason") as Enum<*>).name == "NEEDS_RESTART" + val now = JarAction( + when { + reopens -> translated("oneconfig.page.hotwire.$kind.reopen", "Save and apply") + restartOnly -> translated("oneconfig.page.hotwire.$kind.as_planned", "Apply") + else -> translated("oneconfig.page.hotwire.$kind.now", "Apply now") + }, + true, + ) { + saveBeforeGoing() + ask("accept").asResults() + } + val later = JarAction(translated("oneconfig.page.hotwire.$kind.next_start", "Apply at next launch"), false) { ask("acceptAtNextStart").asResults() } + val actions = when { + ask("canApply") != true -> emptyList() + ask("canWaitForNextStart") == true -> listOf(now, later) + else -> listOf(now) + } + return JarPlan(title, detail, ask("getLines") as List, actions, reopens = reopens) + } + + fun isSelf(update: StoreUpdate) = update.modId == "hotwire" + + /** What applying [updates] together would do, with what they newly need; blocks on the network */ + fun planUpdate(updates: List): JarPlan { + val only = updates.singleOrNull() + val detail = only?.let { "${it.installedVersion} → ${it.newVersion}" }.orEmpty() + if (only != null && isSelf(only)) { + return JarPlan( + only.name, + detail, + listOf(translated("oneconfig.page.hotwire.update.self", "Hotwire can't update itself while the game runs, so it updates at the next launch.")), + listOf( + JarAction(translated("oneconfig.page.hotwire.update.next_start", "Update at next launch"), true) { + call("updateHotwire") + null + }, + ), + ) + } + return planFrom("update", updates.joinToString(", ") { it.name }, detail, changePlan(update = updates.map { it.modId })) + } + + /** What Hotwire would do with [jar], adding it or updating the mod it already is; blocks on Hotwire reading it */ + fun planFor(jar: Path): JarPlan = planFrom("change", jar.fileName.toString(), "", changePlan(jars = listOf(jar))) + + // a mod's own save only runs while it's on, and a mod that stays off until the game closes would lose this change + private fun saveBeforeGoing() { + runCatching { ConfigManager.active().saveAll() } + } + + fun switch(ids: Collection, on: Boolean): CompletableFuture = runCatching { + if (!on) saveBeforeGoing() + call(if (on) "turnOn" else "turnOff", ids.toList()).asResults() + }.getOrElse { CompletableFuture.completedFuture(Result(false, unavailable())) } + + private const val MAX_ICON_BYTES = 8 * 1024 * 1024 + + private const val ICON_TIMEOUT_MS = 10_000 + + private const val MODRINTH_LOADER = "fabric" + + private const val MIN_REVISION = 8 +} diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/TreeConfigData.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/TreeConfigData.kt index 0828d73a7..8b3cc0a90 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/TreeConfigData.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/TreeConfigData.kt @@ -71,13 +71,22 @@ class TreeConfigData( override val toggle: ModToggle? get() = ModToggles.toggleFor(id) ?: tree.findMasterSwitch()?.let(::PropertyModToggle) + private var resolvedFrom: Set? = null + private var resolvedModInfo: ModInfo? = null private val modInfo: ModInfo? - get() = resolvedModInfo ?: findModInfo()?.also { resolvedModInfo = it } + get() { + val loaded = ModInfo.loadedMods + if (loaded !== resolvedFrom) { + resolvedFrom = loaded + resolvedModInfo = findModInfo() + } + return resolvedModInfo + } private fun findModInfo(): ModInfo? { - val mods = ModInfo.loadedMods + val mods = ModInfo.seenMods if (mods.isEmpty()) return null val ids = listOfNotNull( id.nonBlankOrNull(), diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Header.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Header.kt index 8876d4515..19c8e5248 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Header.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/Header.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -65,7 +66,12 @@ import org.polyfrost.oneconfig.internal.ui.api.ConfigRegistry import org.polyfrost.oneconfig.internal.ui.api.Tooltip import org.polyfrost.oneconfig.internal.ui.components.dropdown.DropdownPositionProvider import org.polyfrost.oneconfig.internal.ui.navigation.currentPageTitle +import org.polyfrost.oneconfig.internal.ui.navigation.graph.HotwireAddRoute +import org.polyfrost.oneconfig.internal.ui.navigation.graph.HotwireGraph import org.polyfrost.oneconfig.internal.ui.navigation.graph.ModConfigRoute +import org.polyfrost.oneconfig.internal.ui.navigation.isOn +import org.polyfrost.oneconfig.internal.ui.screens.hotwireTagline +import org.polyfrost.oneconfig.internal.ui.screens.openHotwireScreen import org.polyfrost.oneconfig.internal.ui.navigation.searchPlaceholder import org.polyfrost.oneconfig.internal.ui.shell.LocalShell import org.polyfrost.oneconfig.internal.ui.shell.LocalShellNavController @@ -91,6 +97,8 @@ fun Header(modifier: Modifier = Modifier) { horizontalArrangement = Arrangement.SpaceBetween ) { Row( + // weighted so the search bar and buttons on the right keep their size and the title side gives way + modifier = Modifier.weight(1f).padding(end = 26.dp), verticalAlignment = Alignment.CenterVertically, ) { Row( @@ -104,6 +112,8 @@ fun Header(modifier: Modifier = Modifier) { Row(verticalAlignment = Alignment.CenterVertically) { AnimatedContent( title, + // weighted so a long tagline ellipsizes rather than pushing the button below out of the header + modifier = Modifier.weight(1f, fill = false), transitionSpec = { fadeIn() togetherWith fadeOut() } @@ -121,6 +131,16 @@ fun Header(modifier: Modifier = Modifier) { ) TitleAuthorByline(info.authors) TitleInfoTooltip(info) + info.tagline?.let { + Text( + it, + color = LocalTheme.current.textColorSecondary, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } @@ -176,6 +196,7 @@ private data class HeaderTitle( val credits: String? = null, val version: String? = null, val openOriginalScreen: Runnable? = null, + val tagline: String? = null, ) private fun NavBackStackEntry?.headerTitle(): HeaderTitle? { @@ -195,6 +216,13 @@ private fun NavBackStackEntry?.headerTitle(): HeaderTitle? { ) } + if (destination.hasRoute(HotwireAddRoute::class)) { + return HeaderTitle(translated("oneconfig.page.hotwire.add_mods", "Add mods"), tagline = hotwireTagline(), openOriginalScreen = openHotwireScreen) + } + if (entry.isOn(HotwireGraph)) { + return HeaderTitle(entry.currentPageTitle() ?: "Hotwire", tagline = hotwireTagline(), openOriginalScreen = openHotwireScreen) + } + return entry.currentPageTitle()?.let { HeaderTitle(it) } } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/HudModInfo.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/HudModInfo.kt index 9e08008b4..7bbaf6f96 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/HudModInfo.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/HudModInfo.kt @@ -14,6 +14,6 @@ internal fun configForHud(configId: String): ConfigData? { internal fun modNameFor(configId: String): String? { val modId = configId.removeSuffix(".json").substringBefore('/') - ModInfo.loadedMods.firstOrNull { it.id == modId }?.name?.let { return it } + ModInfo.seenMods.firstOrNull { it.id == modId }?.name?.let { return it } return configForHud(configId)?.title?.asRenderText() } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/keybind/MinecraftKeybindRegistrar.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/keybind/MinecraftKeybindRegistrar.kt index ed9315b0f..34062789c 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/keybind/MinecraftKeybindRegistrar.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/keybind/MinecraftKeybindRegistrar.kt @@ -1,5 +1,8 @@ package org.polyfrost.oneconfig.internal.ui.keybind +import java.lang.ref.WeakReference +import java.util.Collections +import java.util.IdentityHashMap import java.util.WeakHashMap import org.polyfrost.oneconfig.api.config.v1.Property import org.polyfrost.oneconfig.api.config.v1.Tree @@ -21,6 +24,10 @@ object MinecraftKeybindRegistrar { private val wiredProps = WeakHashMap, Boolean>() private val scannedTrees = WeakHashMap() + // weak as a tree holds its config's whole object, such as a deleted HUD's + // shortcut: a hidden tree registered directly can be collected before its replacement is scanned and leave its binds + // registered, as only a config's own properties lead back to its tree; note the binds by id if that ever matters + private val lastScanned = HashMap>() private var listenerInstalled = false @JvmStatic @@ -28,9 +35,32 @@ object MinecraftKeybindRegistrar { fun scan(tree: Tree, force: Boolean = true) { if (!force && scannedTrees.containsKey(tree)) return scannedTrees[tree] = true + // a config replaced after its mod updated leaves binds behind that would fire alongside the new ones + tree.id?.let { lastScanned.put(it, WeakReference(tree)) }?.get()?.takeIf { it !== tree }?.let { unregister(it, kept = tree) } installRebindListener() val category = "${tree.localizedTitle()} (OneConfig)" - walk(tree, category) + walk(tree) { wire(it, category) } + } + + /** Takes the keybinds [scan] registered for [tree] back out, other than any that [kept] holds too */ + @JvmStatic + @JvmOverloads + fun unregister(tree: Tree, kept: Tree? = null) { + val keeping = kept?.let(::mirroredBinds).orEmpty() + val props = Collections.newSetFromMap(IdentityHashMap, Boolean>()) + walk(tree, props::add) + // by what scan saw, as a compat adapter's property can build a new bind on every read + val scanned = propByBind.filterValues { it in props }.keys - keeping + scanned.forEach { KeybindManager.unregister(it) } + propByBind.keys -= scanned + } + + private fun mirroredBinds(tree: Tree): Set { + val binds = HashSet() + walk(tree) { prop -> + if (prop.getMetadata(NO_MIRROR_METADATA) == null) (prop.get() as? OneConfigKeybind)?.let(binds::add) + } + return binds } private fun installRebindListener() { @@ -49,11 +79,11 @@ object MinecraftKeybindRegistrar { } } - private fun walk(tree: Tree, category: String) { + private fun walk(tree: Tree, action: (Property<*>) -> Unit) { for (node in tree.map.values) { when (node) { - is Tree -> walk(node, category) - is Property<*> -> wire(node, category) + is Tree -> walk(node, action) + is Property<*> -> action(node) } } } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/navigation/Navigation.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/navigation/Navigation.kt index 95e281a55..c946b13fe 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/navigation/Navigation.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/navigation/Navigation.kt @@ -3,6 +3,7 @@ package org.polyfrost.oneconfig.internal.ui.navigation import androidx.navigation.NavGraphBuilder import org.polyfrost.oneconfig.internal.ui.navigation.graph.changeLogGraph import org.polyfrost.oneconfig.internal.ui.navigation.graph.creditsGraph +import org.polyfrost.oneconfig.internal.ui.navigation.graph.hotwireGraph import org.polyfrost.oneconfig.internal.ui.navigation.graph.keybindsGraph import org.polyfrost.oneconfig.internal.ui.navigation.graph.modsGraph import org.polyfrost.oneconfig.internal.ui.navigation.graph.preferencesGraph @@ -17,5 +18,6 @@ fun NavGraphBuilder.navigation() { preferencesGraph() changeLogGraph() creditsGraph() + hotwireGraph() pageDestinations() } \ No newline at end of file diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/navigation/Pages.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/navigation/Pages.kt index 8271c23e4..a7e178c1c 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/navigation/Pages.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/navigation/Pages.kt @@ -20,6 +20,8 @@ import org.polyfrost.oneconfig.internal.ui.components.resolve import org.polyfrost.oneconfig.internal.ui.components.translated import org.polyfrost.oneconfig.internal.ui.navigation.graph.ChangeLogGraph import org.polyfrost.oneconfig.internal.ui.navigation.graph.CreditsGraph +import org.polyfrost.oneconfig.internal.ui.navigation.graph.HotwireAddRoute +import org.polyfrost.oneconfig.internal.ui.navigation.graph.HotwireGraph import org.polyfrost.oneconfig.internal.ui.navigation.graph.KeybindsGraph import org.polyfrost.oneconfig.internal.ui.navigation.graph.ModConfigRoute import org.polyfrost.oneconfig.internal.ui.navigation.graph.ModsGraph @@ -28,6 +30,8 @@ import org.polyfrost.oneconfig.internal.ui.navigation.graph.ProfilesGraph import org.polyfrost.oneconfig.internal.ui.navigation.graph.ThemesGraph import org.polyfrost.oneconfig.internal.ui.screens.Changelog import org.polyfrost.oneconfig.internal.ui.screens.Credits +import org.polyfrost.oneconfig.internal.ui.screens.Hotwire +import org.polyfrost.oneconfig.internal.ui.screens.isHotwireInstalled import org.polyfrost.oneconfig.internal.ui.screens.Keybinds import org.polyfrost.oneconfig.internal.ui.screens.Mods import org.polyfrost.oneconfig.internal.ui.screens.Preferences @@ -60,10 +64,13 @@ internal class BuiltinPage( } object BuiltinPages { - internal val all: List = listOf( + internal val all: List = listOfNotNull( BuiltinPage("mods", "Mods", "settings", PageGroup.MODS_AND_OPTIONS, 100, ModsGraph) { Mods() }, BuiltinPage("profiles", "Profiles", "profiles", PageGroup.MODS_AND_OPTIONS, 200, ProfilesGraph) { Profiles() }, BuiltinPage("keybinds", "Keybinds", "keyboard", PageGroup.MODS_AND_OPTIONS, 300, KeybindsGraph) { Keybinds() }, + // the Hotwire page only does anything with Hotwire installed + BuiltinPage("hotwire", "Hotwire", "lightning-01", PageGroup.MODS_AND_OPTIONS, 400, HotwireGraph) { Hotwire() } + .takeIf { isHotwireInstalled }, BuiltinPage("themes", "Themes", "paintbrush", PageGroup.PERSONALIZATION, 100, ThemesGraph) { Themes() }, BuiltinPage("preferences", "Preferences", "cog", PageGroup.PERSONALIZATION, 200, PreferencesGraph) { Preferences() }, BuiltinPage("changelog", "Changelog", "refresh", PageGroup.ONECONFIG, 100, ChangeLogGraph) { Changelog() }, @@ -178,7 +185,13 @@ fun NavBackStackEntry?.currentPageTitle(): String? = currentPage()?.title?.resolve() ?: sidebarSections().asSequence().flatMap { it.entries }.firstOrNull { isOn(it.route) }?.label -fun searchPlaceholder(entry: NavBackStackEntry?): String = searchPlaceholder(entry.currentPage(), entry.currentPageTitle()) +fun searchPlaceholder(entry: NavBackStackEntry?): String { + // the add page's search goes to Modrinth, not the Hotwire page it belongs to + if (entry?.destination?.hasRoute(HotwireAddRoute::class) == true) { + return translated("oneconfig.page.hotwire.modrinth.search", "Search Modrinth...") + } + return searchPlaceholder(entry.currentPage(), entry.currentPageTitle()) +} fun searchPlaceholder(page: OneConfigPage?, title: String?): String { page?.searchPlaceholder?.let { return it.resolve() } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/navigation/graph/Hotwire.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/navigation/graph/Hotwire.kt new file mode 100644 index 000000000..72db067e8 --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/navigation/graph/Hotwire.kt @@ -0,0 +1,28 @@ +package org.polyfrost.oneconfig.internal.ui.navigation.graph + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import androidx.navigation.navigation +import kotlinx.serialization.Serializable +import org.polyfrost.oneconfig.internal.ui.screens.Hotwire +import org.polyfrost.oneconfig.internal.ui.screens.HotwireAdd + +@Serializable +data object HotwireGraph + +@Serializable +data object HotwireRoute + +@Serializable +data object HotwireAddRoute + +fun NavGraphBuilder.hotwireGraph() { + navigation(startDestination = HotwireRoute) { + composable { + Hotwire() + } + composable { + HotwireAdd() + } + } +} diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Hotwire.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Hotwire.kt new file mode 100644 index 000000000..404abcc37 --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Hotwire.kt @@ -0,0 +1,806 @@ +package org.polyfrost.oneconfig.internal.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableStateSetOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import java.awt.datatransfer.DataFlavor +import java.awt.datatransfer.Transferable +import java.awt.datatransfer.UnsupportedFlavorException +import java.nio.file.Path +import java.util.concurrent.CompletableFuture +import java.util.concurrent.Executor +import org.polyfrost.oneconfig.api.notifications.v1.NotificationAction +import org.polyfrost.oneconfig.api.notifications.v1.NotificationType +import org.polyfrost.oneconfig.api.notifications.v1.Notifications +import org.polyfrost.oneconfig.api.platform.v1.ModInfo +import org.polyfrost.oneconfig.api.platform.v1.Platform +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.polyfrost.oneconfig.internal.ui.api.HotwireBridge +import org.polyfrost.oneconfig.internal.ui.api.Tooltip +import org.polyfrost.oneconfig.internal.ui.components.Chip +import org.polyfrost.oneconfig.internal.ui.components.Icon +import org.polyfrost.oneconfig.internal.ui.components.ScrollbarBox +import org.polyfrost.oneconfig.internal.ui.components.Text +import org.polyfrost.oneconfig.internal.ui.components.translated +import org.polyfrost.oneconfig.internal.ui.components.onClick +import org.polyfrost.oneconfig.internal.ui.components.rememberInteractionSource +import org.polyfrost.oneconfig.internal.ui.components.settings.CheckboxIndicator +import org.polyfrost.oneconfig.internal.ui.navigation.graph.HotwireAddRoute +import org.polyfrost.oneconfig.internal.ui.shell.PageGutter +import org.polyfrost.oneconfig.internal.ui.shell.ShellController +import org.polyfrost.oneconfig.internal.ui.shell.ShellState +import org.polyfrost.oneconfig.internal.ui.shell.rememberRestorableLazyGridState +import org.polyfrost.oneconfig.internal.ui.themes.Accent +import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +import org.polyfrost.oneconfig.utils.v1.ClipboardHelper + +/** Whether mods show as full-width rows rather than cards, kept for the session */ +private var listLayout by mutableStateOf(false) + +/** + * The updates Hotwire found on Modrinth, checked once per session and again whenever an update or add made here + * finishes or the mods changed in Hotwire's own screens, so the Hotwire page and the Updates tab share one answer + * instead of each asking Modrinth + */ +object HotwireUpdates { + /** Null until the first check has answered */ + var found by mutableStateOf>?>(null) + private set + + val modIds: Set get() = found?.getOrNull()?.mapTo(HashSet()) { it.modId }.orEmpty() + + /** The name of the update the running batch is on, or null when no batch is running */ + var updating by mutableStateOf(null) + private set + + private var checked = false + + @Volatile + private var checkedState: List? = null + + private val scope = CoroutineScope(SupervisorJob() + Executor { Platform.screen().runOnUiThread(it) }.asCoroutineDispatcher()) + + /** Checks on first use, and again when opened after the mods changed in Hotwire's own screens */ + fun ensure() { + if (!checked) { + refreshAfter {} + } else if (HotwireBridge.installedState() != checkedState) { + HotwireBridge.invalidate() + refreshAfter {} + } + } + + /** + * Re-checks once the change Hotwire was just given has finished: when it stops working after having started, or + * after a few seconds if it never reports progress for it, and has the grid read the changed mods + * + * Runs on its own thread so leaving the page doesn't cancel it + */ + fun refreshWhenDone() = refreshAfter { + HotwireBridge.awaitIdle() + Platform.screen().runOnUiThread(HotwireBridge::invalidate) + } + + private fun refreshAfter(wait: () -> Unit) { + checked = true + Thread({ + wait() + synchronized(this) { + checkedState = HotwireBridge.installedState() + val result = runCatching { HotwireBridge.findUpdates() } + Platform.screen().runOnUiThread { apply(result) } + } + }, "OneConfig Hotwire update check").apply { isDaemon = true }.start() + } + + /** Update plans waiting to be confirmed, each with the name of what it updates */ + class Review(val plans: List>>, val onDone: () -> Unit) + + var review by mutableStateOf(null) + private set + + /** Whether Hotwire is working out the plans for a [review] */ + var planning by mutableStateOf(false) + private set + + private var planningJob: Job? = null + + /** The downloads already running when planning began, which aren't the plan's to show or stop */ + var otherDownloads: Set = emptySet() + private set + + /** Why a plan in a [review] won't be applied, or null when it will */ + fun skipReason(plan: Result): String? = when { + plan.getOrNull()?.reopens == true -> translated("oneconfig.page.hotwire.updates.reopen", "It has to save and reopen your world, so update it on its own.") + plan.getOrNull()?.actions?.any { it.primary } == true -> null + // the refusal can come after lines saying what would have gone fine, so all of them are the reason + else -> (plan.exceptionOrNull()?.let { it.cause ?: it }?.message ?: plan.getOrNull()?.lines?.joinToString(" ")).orEmpty() + } + + /** + * Plans the updates for a [review], since a plan can change more mods than were picked, as one plan so what one + * brings along isn't offered again; Hotwire updates itself apart, since it can't go in a plan + * + * Hotwire refuses a whole plan for one mod it can't update, so a refused batch is planned again one mod at a time, + * which fetches the jars again, so the others can still go + */ + fun reviewUpdates(updates: List, onDone: () -> Unit) { + otherDownloads = HotwireBridge.runningDownloadIds() + planningJob = scope.launch { + planning = true + try { + suspend fun plan(group: List) = + group.joinToString(", ") { it.name } to withContext(Dispatchers.IO) { runCatching { HotwireBridge.planUpdate(group) } } + val (self, mods) = updates.partition(HotwireBridge::isSelf) + val together = mods.takeIf { it.isNotEmpty() }?.let { plan(it) } + val refused = mods.size > 1 && together!!.second.isSuccess && skipReason(together.second) != null + review = Review( + self.map { plan(listOf(it)) } + if (refused) mods.map { plan(listOf(it)) } else listOfNotNull(together), + onDone, + ) + } finally { + planning = false + } + } + } + + fun dismissReview() { + // planning is what downloads the jars, which nothing would use now + if (planning) HotwireBridge.stopDownloads(otherDownloads) + planningJob?.cancel() + review = null + } + + /** Applies the plans in the [review] with whatever Hotwire allows them, live or at the next launch */ + fun acceptReview() { + val accepted = review ?: return + review = null + scope.launch { + val problems = ArrayList() + val modsBefore = HotwireBridge.mods().orEmpty() + val queuedBefore = HotwireBridge.queuedMods() + var sentToChat = false + try { + for ((name, plan) in accepted.plans) { + updating = name + val skipped = skipReason(plan) + if (skipped != null) { + problems += "$name: $skipped" + continue + } + val action = plan.getOrThrow().actions.first { it.primary } + val pending = action.attempt() + if (pending == null) sentToChat = true + val failed = pending?.let { withContext(Dispatchers.IO) { it.get() } }?.takeUnless { it.ok } + if (failed != null) problems += "$name: ${failed.message}" + } + } finally { + updating = null + refreshWhenDone() + } + val outcome = HotwireBridge.whenApplied(modsBefore, HotwireBridge.mods().orEmpty(), queuedBefore, HotwireBridge.queuedMods()) + val summary = listOfNotNull( + outcome.now.takeIf { it.isNotEmpty() }?.let { translated("oneconfig.page.hotwire.updates.done.now", "Updated now: %s.", it.joinToString(", ")) }, + outcome.nextLaunch.takeIf { it.isNotEmpty() }?.let { translated("oneconfig.page.hotwire.updates.done.next_start", "Updates at next launch: %s.", it.joinToString(", ")) }, + // what only reports in chat hasn't finished yet, and may not + translated("oneconfig.page.hotwire.add.sent", "Sent to Hotwire. It posts the result in chat.").takeIf { sentToChat }, + ).joinToString(" ") + if (problems.isEmpty()) { + Notifications.success("Hotwire", summary.ifBlank { translated("oneconfig.page.hotwire.updates.done", "Updates finished.") }) + accepted.onDone() + } else { + if (summary.isNotBlank()) Notifications.success("Hotwire", summary) + Notifications.error("Hotwire", problems.joinToString(" ")) + } + } + } + + private fun apply(result: Result>) { + // a failed re-check keeps the last list shown, and the next use tries again + if (result.isFailure) checked = false + if (result.isSuccess || found == null) found = result + } +} + +/** + * Extracted icon path per mod kept across scrolling so each jar is only read once; by the whole [ModInfo] as a mod kept + * off since launch has no jar until it's turned on + */ +private val iconPaths = HashMap() + +/** + * The mods the grid shows: the loader's live list, the mods seen earlier that Hotwire still knows about, and any + * other mod Hotwire knows, such as one it kept off since the last launch + */ +internal fun gridMods(states: Map?): Collection { + ModInfo.refreshLoadedMods() + val seen = ModInfo.seenMods + val seenIds = seen.mapTo(HashSet()) { it.id } + val liveIds = ModInfo.loadedMods.mapTo(HashSet()) { it.id } + val known = seen.filter { it.id in liveIds || states?.containsKey(it.id) == true } + val unseen = states.orEmpty().values + .filter { it.id !in seenIds } + .map { ModInfo(it.id, it.name.ifBlank { it.id }, it.version, null, null) } + return known + unseen +} + +/** Whether Hotwire is installed, which is the only time its page is offered */ +val isHotwireInstalled: Boolean get() = ModInfo.loadedMods.any { it.id == "hotwire" } + +/** The line beside the page title, crediting Hotwire's authors from its mod metadata */ +fun hotwireTagline(): String { + val author = ModInfo.loadedMods.firstOrNull { it.id == "hotwire" }?.authors?.takeIf { it.isNotBlank() } + return listOfNotNull( + translated("oneconfig.page.hotwire.tagline", "Manage your mods in real time."), + author?.let { translated("oneconfig.page.hotwire.credit", "Made possible by %s", it) }, + ).joinToString(" ") +} + +private val SwitchBarHeight = 56.dp + +val openHotwireScreen = Runnable { HotwireBridge.openScreen() } + +/** Reports what Hotwire says came of a change */ +internal fun notifyHotwireResult(result: HotwireBridge.Result) { + when { + result.ok -> Notifications.success("Hotwire", result.message.ifBlank { translated("oneconfig.page.hotwire.result.done", "Done.") }) + result.needsReopen -> Notifications.info( + "Hotwire", + result.message.ifBlank { translated("oneconfig.page.hotwire.result.reopen", "Reopen your world to finish this.") }, + ) + else -> { + val message = result.message.ifBlank { translated("oneconfig.page.hotwire.result.failed", "That didn't work.") } + Notifications.builder("Hotwire", message).type(NotificationType.ERROR).apply { + // it stays up so there's time to press the button, which dismisses it + if (!result.refused) { + persistent() + action(NotificationAction(translated("oneconfig.page.hotwire.report.copy", "Copy bug report"), true) { copyHotwireReport(message) }) + } + }.send() + } + } +} + +/** A file for the clipboard, so a report pastes as an attachment */ +private class FileSelection(private val file: Path) : Transferable { + override fun getTransferDataFlavors() = arrayOf(DataFlavor.javaFileListFlavor) + + override fun isDataFlavorSupported(flavor: DataFlavor) = flavor == DataFlavor.javaFileListFlavor + + override fun getTransferData(flavor: DataFlavor): Any { + if (!isDataFlavorSupported(flavor)) throw UnsupportedFlavorException(flavor) + return listOf(file.toFile()) + } +} + +/** Has Hotwire write a bug report about [reason] and copies it, or where it is when the clipboard can't hold a file */ +private fun copyHotwireReport(reason: String) { + HotwireBridge.writeReport(reason).whenComplete { report, error -> + Platform.screen().runOnUiThread { + when { + report == null -> Notifications.error( + "Hotwire", + translated("oneconfig.page.hotwire.report.failed", "The bug report couldn't be written: %s", (error?.cause ?: error)?.message ?: ""), + ) + ClipboardHelper.setTransferable(FileSelection(report)) -> + Notifications.success("Hotwire", translated("oneconfig.page.hotwire.report.copied", "Bug report copied. Paste it wherever you're asking for help.")) + ClipboardHelper.setString(report.toString()) -> + Notifications.info("Hotwire", translated("oneconfig.page.hotwire.report.path_copied", "Bug report written, and its location copied.")) + else -> Notifications.info("Hotwire", translated("oneconfig.page.hotwire.report.written", "Your bug report is at %s.", report)) + } + } + } +} + +/** + * Every mod the loader has in a denser grid than the mods page, where picked mods are turned on or off live + * through Hotwire + */ +@Composable +fun Hotwire() { + if (!HotwireBridge.supportsSwitching) { + Box(Modifier.fillMaxSize().padding(horizontal = PageGutter), contentAlignment = Alignment.Center) { + Text( + translated("oneconfig.page.hotwire.upgrade.page", "This page needs Hotwire 0.7.3 or newer. Update Hotwire to use it."), + color = LocalTheme.current.textColorSecondary, + fontSize = 14.sp, + textAlign = TextAlign.Center, + ) + } + return + } + + var prompt by remember { mutableStateOf(HotwireBridge.openingPrompt()) } + prompt?.let { + OpeningPromptCard(it) { prompt = null } + return + } + + val revision = HotwireBridge.revision + val states = remember(revision) { HotwireBridge.mods()?.associateBy { it.id } } + LaunchedEffect(Unit) { HotwireUpdates.ensure() } + // a plan only holds for now, so it doesn't wait for the page to be opened again + DisposableEffect(Unit) { onDispose { HotwireUpdates.dismissReview() } } + val availableUpdates = HotwireUpdates.modIds + val families = remember(states) { + modFamilies(gridMods(states)).filterValues { members -> states == null || members.any { states[it.id]?.switchable == true } } + } + var onlyUpdates by remember { mutableStateOf(false) } + val query = ShellState.searchQuery.trim() + val mods = remember(families, query, onlyUpdates, availableUpdates) { + families + .filter { (_, members) -> + (query.isEmpty() || members.any { it.name.contains(query, true) || it.id.contains(query, true) }) && + (!onlyUpdates || members.any { it.id in availableUpdates }) + } + .keys + .sortedBy { it.name.lowercase() } + } + // picked families by their root id + val selected = remember { mutableStateSetOf() } + val gridState = rememberRestorableLazyGridState("hotwire") + Box(Modifier.fillMaxSize()) { + Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(19.dp)) { + Row( + Modifier.fillMaxWidth().padding(horizontal = PageGutter), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Chip(translated("oneconfig.page.hotwire.filter.updates", "Has update"), onlyUpdates, "refresh") { onlyUpdates = !onlyUpdates } + Spacer(Modifier.weight(1f)) + IconSegments( + listOf( + IconSegment("grid", translated("oneconfig.page.hotwire.layout.grid", "Grid")), + IconSegment("list", translated("oneconfig.page.hotwire.layout.list", "List")), + ), + selected = if (listLayout) 1 else 0, + ) { listLayout = it == 1 } + } + Box(Modifier.weight(1f).fillMaxWidth()) { + ScrollbarBox(gridState, PageGutter, Modifier.fillMaxSize()) { + LazyVerticalGrid( + state = gridState, + columns = if (listLayout) GridCells.Fixed(1) else GridCells.Adaptive(140.dp), + verticalArrangement = Arrangement.spacedBy(if (listLayout) 8.dp else 19.dp), + horizontalArrangement = Arrangement.spacedBy(19.dp), + // the last row can still scroll clear of the bar drawn over the grid + contentPadding = PaddingValues(start = PageGutter, end = PageGutter, bottom = SwitchBarHeight + 38.dp), + modifier = Modifier.fillMaxSize(), + ) { + items(mods, key = { it.id }) { mod -> + HotwireTile( + mod, + families.getValue(mod), + on = familyOn(families.getValue(mod), states), + hasUpdate = families.getValue(mod).any { it.id in availableUpdates }, + list = listLayout, + selected = mod.id in selected, + onSelect = { if (!selected.remove(mod.id)) selected.add(mod.id) }, + ) + } + } + } + Row( + // a fixed height the switch bar fills, so the button stays centred on it and doesn't move when it appears + Modifier.align(Alignment.BottomCenter).fillMaxWidth().padding(start = PageGutter, end = PageGutter, bottom = 19.dp).height(SwitchBarHeight), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End), + ) { + val picked = families.filterKeys { it.id in selected } + if (picked.isNotEmpty()) SwitchBar(picked, states, Modifier.weight(1f), onClear = { selected.clear() }) + Chip(translated("oneconfig.page.hotwire.add_mods", "Add mods"), selected = true, icon = "plus") { + ShellController.shared.navigation.navigate(HotwireAddRoute) + } + } + } + } + // over the whole page rather than the grid, so it's centred on the page + HotwireUpdates.review?.let { UpdateReviewCard(it) } + } +} + +/** Everything the planned updates would change, which needs confirming as it can go beyond the mods that were picked */ +@Composable +private fun UpdateReviewCard(review: HotwireUpdates.Review) { + val theme = LocalTheme.current + // it sits over the grid, so a click on it mustn't pick the tile underneath + Box(Modifier.fillMaxSize().pointerInput(Unit) {}.padding(horizontal = PageGutter), contentAlignment = Alignment.Center) { + Column( + Modifier.widthIn(max = 560.dp) + .background(theme.popupBackground, theme.modCardShape) + .border(1.dp, theme.borderColor, theme.modCardShape) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(translated("oneconfig.page.hotwire.updates.review", "Review these updates"), color = theme.textColor, fontSize = 18.sp, textAlign = TextAlign.Center) + Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(6.dp)) { + review.plans.forEach { (name, plan) -> + Text(name, color = theme.textColorSecondary, fontSize = 13.sp) + (plan.getOrNull()?.lines.orEmpty() + listOfNotNull(HotwireUpdates.skipReason(plan))).distinct().forEach { + Text("• $it", color = theme.textColor, fontSize = 13.sp) + } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (review.plans.any { (_, plan) -> HotwireUpdates.skipReason(plan) == null }) { + Chip(translated("oneconfig.page.hotwire.modrinth.update", "Update"), selected = true) { HotwireUpdates.acceptReview() } + } + Chip(translated("oneconfig.page.hotwire.add.back", "Back"), selected = false) { HotwireUpdates.dismissReview() } + } + } + } +} + +/** Hotwire's warning, which it requires be shown before it takes requests from this mod, with its reading delay */ +@Composable +private fun OpeningPromptCard(prompt: HotwireBridge.OpeningPrompt, onDone: () -> Unit) { + val theme = LocalTheme.current + var wait by remember { mutableIntStateOf(prompt.waitSeconds) } + LaunchedEffect(Unit) { + while (wait > 0) { + delay(1000) + wait-- + } + } + Box(Modifier.fillMaxSize().padding(horizontal = PageGutter), contentAlignment = Alignment.Center) { + Column( + Modifier.widthIn(max = 560.dp) + .background(theme.modCardBackground, theme.modCardShape) + .border(1.dp, theme.borderColor, theme.modCardShape) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(prompt.title, color = theme.textColor, fontSize = 18.sp, textAlign = TextAlign.Center) + Column(verticalArrangement = Arrangement.spacedBy(6.dp), horizontalAlignment = Alignment.CenterHorizontally) { + prompt.lines.forEach { Text(it, color = theme.textColor, fontSize = 13.sp, textAlign = TextAlign.Center) } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Chip(if (wait > 0) "${prompt.acceptText} ($wait)" else prompt.acceptText, selected = wait == 0) { + if (wait == 0) { + prompt.accept() + onDone() + } + } + Chip(prompt.openHotwireText, selected = false) { + if (wait == 0) { + prompt.acceptAndOpenHotwire() + onDone() + } + } + } + } + } +} + +/** + * Each top-level mod with every mod under it, however deep, so the grid shows one entry per family + * + * Members are listed root first, and a parent that is not loaded itself, as Mod Menu allows, still gets an entry + * named after its id + */ +private fun modFamilies(mods: Collection): Map> { + val byId = mods.associateBy { it.id } + fun rootId(mod: ModInfo): String { + var id = mod.id + val seen = HashSet() + while (seen.add(id)) id = byId[id]?.parentId ?: break + return id + } + return byId.values.groupBy(::rootId).entries.associate { (rootId, members) -> + (byId[rootId] ?: ModInfo(rootId, rootId, "", null, null)) to members.sortedBy { it.id != rootId } + } +} + +/** Whether a family counts as on: its first member Hotwire can switch decides, which is the root unless that can't be */ +private fun familyOn(family: List, states: Map?): Boolean? = + family.firstNotNullOfOrNull { mod -> states?.get(mod.id)?.takeIf { it.switchable } }?.on + +/** How far Hotwire's running downloads are, or null [progress] while none is; [fraction] is what a bar should show */ +internal class DownloadWatch { + var progress by mutableStateOf(null) + var fraction by mutableFloatStateOf(0f) +} + +/** Follows Hotwire's downloads but [others] for as long as [active] */ +@Composable +internal fun rememberDownloadWatch(active: Boolean = true, others: Set = emptySet()): DownloadWatch { + val watch = remember { DownloadWatch() } + LaunchedEffect(active, others) { + watch.progress = null + watch.fraction = 0f + while (active) { + val latest = HotwireBridge.downloadProgress(others) + watch.fraction = latest?.shownAfter(watch.progress, watch.fraction) ?: 0f + watch.progress = latest + delay(DOWNLOAD_POLL_MILLIS) + } + } + return watch +} + +@Composable +internal fun HotwireProgressBar(fraction: Float) { + val theme = LocalTheme.current + Box(Modifier.fillMaxWidth().height(3.dp).background(theme.borderColor, theme.modCardShape)) { + Box(Modifier.fillMaxWidth(fraction.coerceIn(0f, 1f)).fillMaxHeight().background(Accent, theme.modCardShape)) + } +} + +/** The picked mods and what switching them does, with the button that does it, as Hotwire's own screen offers */ +@Composable +private fun SwitchBar( + picked: Map>, + states: Map?, + modifier: Modifier = Modifier, + onClear: () -> Unit, +) { + val theme = LocalTheme.current + // a picked entry switches as a whole, so every member Hotwire can switch goes along with its root + fun membersWhere(on: Boolean) = picked.values + .filter { familyOn(it, states) == on } + .flatten() + .filter { mod -> states?.get(mod.id)?.let { it.switchable && it.on == on } == true } + val turnOff = remember(picked, states) { + val members = membersWhere(true) + val ids = members.map { it.id } + // a nested mod another jar still needs stays on, so it's left out; a refused plan keeps them all so it can say why + val plan = HotwireBridge.preview(members.filter { it.parentId !in ids }.map { it.id }, false) + if (plan.possible) ids.filter { it in plan.switched } else ids + } + val turnOn = remember(picked, states) { membersWhere(false).map { it.id } } + val mixed = turnOff.isNotEmpty() && turnOn.isNotEmpty() + var together by remember(turnOff, turnOn) { mutableStateOf(null) } + LaunchedEffect(turnOff, turnOn) { + if (mixed) together = withContext(Dispatchers.IO) { HotwireBridge.previewTogether(turnOff, turnOn) } + } + val previews = remember(turnOff, turnOn, together) { + if (mixed) { + listOfNotNull(together) + } else { + listOfNotNull( + turnOff.takeIf { it.isNotEmpty() }?.let { HotwireBridge.preview(it, false) }, + turnOn.takeIf { it.isNotEmpty() }?.let { HotwireBridge.preview(it, true) }, + ) + } + } + val pickedIds = picked.values.flatten().mapTo(HashSet()) { it.id } + val updates = HotwireUpdates.found?.getOrNull().orEmpty().filter { it.modId in pickedIds } + val updating = HotwireUpdates.updating + var switching by remember { mutableStateOf(false) } + val planning = HotwireUpdates.planning + val running = switching || updating != null || planning + var fraction by remember { mutableFloatStateOf(0f) } + LaunchedEffect(running) { + while (running) withFrameNanos { fraction = HotwireBridge.progress() } + } + // planning is what downloads the jars, so that's what there is to show until a plan is ready + val download = rememberDownloadWatch(planning, HotwireUpdates.otherDownloads) + + val offCount = picked.values.count { familyOn(it, states) == true } + val onCount = picked.values.count { familyOn(it, states) == false } + val tooMany = offCount + onCount > MaxSwitchedMods + val label = when { + offCount > 0 && onCount > 0 -> translated("oneconfig.page.hotwire.switch.mixed", "Switch %s", offCount + onCount) + offCount > 0 -> translated("oneconfig.page.hotwire.switch.off", "Turn off %s", offCount) + else -> translated("oneconfig.page.hotwire.switch.on", "Turn on %s", onCount) + } + val message = when { + states == null -> translated("oneconfig.page.hotwire.switch.starting", "Hotwire is still starting, so mods can't be switched yet.") + updating != null -> translated("oneconfig.page.hotwire.updates.running", "Updating %s…", updating) + // named, since Hotwire fetches one jar at a time and the bar starts over for each + planning -> download.progress?.let { translated("oneconfig.page.hotwire.updates.downloading", "Downloading %s… %s%%", it.name, (download.fraction * 100).toInt()) } + ?: translated("oneconfig.page.hotwire.switch.checking", "Checking what this changes…") + running -> translated("oneconfig.page.hotwire.switch.running", "Switching… %s%%", (fraction * 100).toInt()) + tooMany -> translated("oneconfig.page.hotwire.switch.too_many", "Only %s mods can be switched at once.", MaxSwitchedMods) + mixed && together == null -> translated("oneconfig.page.hotwire.switch.checking", "Checking what this changes…") + else -> previews.joinToString(" ") { it.message } + } + val canSwitch = !running && !tooMany && previews.isNotEmpty() && previews.all { it.possible } && !HotwireBridge.isSwitching + + fun start() { + switching = true + // Hotwire answers for these before it has added them back, so the grid has to look again once it's done + val bringsBack = turnOn.any { states?.get(it)?.offSinceStart == true } + val off = if (turnOff.isEmpty()) done("") else HotwireBridge.switch(turnOff, false) + off.thenCompose { first -> + if (!first.ok || turnOn.isEmpty()) return@thenCompose CompletableFuture.completedFuture(first) + HotwireBridge.switch(turnOn, true).thenApply { second -> + HotwireBridge.Result( + second.ok, + listOf(first.message, second.message).filter { it.isNotBlank() }.joinToString(" "), + second.needsReopen, + second.refused, + ) + } + }.whenComplete { result, error -> + Platform.screen().runOnUiThread { + switching = false + notifyHotwireResult(result ?: HotwireBridge.Result(false, error?.message ?: "")) + if (result?.ok == true) onClear() + if (bringsBack) HotwireUpdates.refreshWhenDone() + } + } + } + + Row( + modifier = modifier + // opaque, since the card background would let the tiles underneath show through the text + .background(theme.popupBackground, theme.modCardShape) + .border(1.dp, theme.borderColor, theme.modCardShape) + .fillMaxHeight() + // it sits over the grid, so a click on the bar mustn't pick the tile underneath + .pointerInput(Unit) {} + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(translated("oneconfig.page.hotwire.switch.selected", "%s selected", picked.size), color = theme.textColor, fontSize = 14.sp) + if (message.isNotBlank()) { + Text(message, color = theme.textColorSecondary, fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + if (running) HotwireProgressBar(if (planning) download.fraction else fraction) + } + Chip(translated("oneconfig.page.hotwire.switch.clear", "Clear"), selected = false) { + // the bar goes with the selection, so nothing would show the plan still coming + if (planning) HotwireUpdates.dismissReview() + onClear() + } + if (!running && updates.isNotEmpty()) { + Chip(translated("oneconfig.page.hotwire.updates.bar", "Update %s", updates.size), selected = true) { HotwireUpdates.reviewUpdates(updates, onClear) } + } + if (canSwitch) Chip(label, selected = true, onClick = ::start) + } +} + +private const val MaxSwitchedMods = 5 + +private fun done(message: String) = CompletableFuture.completedFuture(HotwireBridge.Result(true, message)) + +@Composable +private fun HotwireTile( + mod: ModInfo, + family: List, + on: Boolean?, + hasUpdate: Boolean, + list: Boolean, + selected: Boolean, + onSelect: () -> Unit, +) { + val theme = LocalTheme.current + val icon by produceState(iconPaths[mod], mod) { + if (mod !in iconPaths) iconPaths[mod] = withContext(Dispatchers.IO) { runCatching { mod.extractIconFile() }.getOrNull() } + value = iconPaths[mod] + } + // mods Hotwire has turned off are greyed out but stay selectable so they can be turned back on + val contentAlpha = if (on == false) 0.4f else 1f + val nameColor = if (on == false) theme.textColorSecondary else theme.textColor + + Box( + modifier = Modifier.fillMaxWidth().height(if (list) 56.dp else 124.dp) + .background(theme.modCardBackground, theme.modCardShape) + .then( + if (selected) { + Modifier.border(2.dp, Accent, theme.modCardShape) + } else { + Modifier.border(1.dp, remember(theme.borderColor) { + Brush.verticalGradient(listOf(theme.borderColor, theme.borderColor.copy(0f))) + }, theme.modCardShape) + }, + ) + .onClick(rememberInteractionSource(), onClick = onSelect) + .pointerHoverIcon(PointerIcon.Hand), + ) { + if (list) { + Row( + modifier = Modifier.fillMaxSize().alpha(contentAlpha).padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + CheckboxIndicator(selected, onToggle = onSelect) + // keeps names lined up when a mod has no icon + Box(Modifier.size(36.dp)) { + icon?.let { Icon(it, color = theme.textColor, modifier = Modifier.fillMaxSize()) } + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(mod.name, color = nameColor, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + mod.authors?.takeIf { it.isNotBlank() }?.let { + Text( + translated("oneconfig.header.byline", "by %s", it), + color = theme.textColorSecondary, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (hasUpdate) { + Text(translated("oneconfig.page.hotwire.update_available", "Update available"), color = Accent, fontSize = 12.sp, maxLines = 1) + } + if (mod.version.isNotBlank()) { + Text(mod.version, color = theme.textColorSecondary, fontSize = 12.sp, maxLines = 1) + } + } + } else { + Column( + modifier = Modifier.fillMaxSize().alpha(contentAlpha).padding(10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + icon?.let { Icon(it, color = theme.textColor, modifier = Modifier.size(56.dp)) } + Text( + mod.name, + color = nameColor, + fontSize = 13.sp, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + Box(Modifier.align(Alignment.TopStart).padding(8.dp)) { + CheckboxIndicator(selected, onToggle = onSelect) + } + if (hasUpdate) { + Box(Modifier.align(Alignment.TopEnd).padding(10.dp)) { + Tooltip( + text = { Text(translated("oneconfig.page.hotwire.update_available", "Update available"), color = theme.textColor, fontSize = 12.sp) }, + modifier = Modifier, + anchor = Alignment.TopCenter, + ) { + Box(Modifier.size(8.dp).background(Accent, theme.circleShape)) + } + } + } + } + } +} diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/HotwireAdd.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/HotwireAdd.kt new file mode 100644 index 000000000..8fdccdc70 --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/HotwireAdd.kt @@ -0,0 +1,478 @@ +package org.polyfrost.oneconfig.internal.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import java.nio.file.Path +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.polyfrost.oneconfig.api.platform.v1.ModInfo +import org.polyfrost.oneconfig.api.platform.v1.Platform +import org.polyfrost.oneconfig.api.ui.v1.api.TinyFdApi +import org.polyfrost.oneconfig.internal.ui.api.HotwireBridge +import org.polyfrost.oneconfig.internal.ui.components.Chip +import org.polyfrost.oneconfig.internal.ui.components.Icon +import org.polyfrost.oneconfig.internal.ui.components.ScrollbarBox +import org.polyfrost.oneconfig.internal.ui.components.Text +import org.polyfrost.oneconfig.internal.ui.components.translated +import org.polyfrost.oneconfig.internal.ui.shell.PageGutter +import org.polyfrost.oneconfig.internal.ui.shell.ShellState +import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme + +private enum class AddSource { File, Modrinth, Updates } + +internal const val DOWNLOAD_POLL_MILLIS = 100L + +/** + * Adds or updates a mod through Hotwire from a jar on disk, a Modrinth project, or an update Hotwire found, always + * showing what Hotwire would do before doing it, as Hotwire's own add and update panels offer + */ +@Composable +fun HotwireAdd() { + val scope = rememberCoroutineScope() + var source by remember { mutableStateOf(AddSource.Modrinth) } + var reading by remember { mutableStateOf(false) } + var plan by remember { mutableStateOf(null) } + var planIcon by remember { mutableStateOf(null) } + // bumped whenever the preview is left, so a download still running then can't bring it back + var request by remember { mutableIntStateOf(0) } + // whether a plan is loading, so cancelling the wait for a download that isn't this plan's leaves that download alone + var loading by remember { mutableStateOf(false) } + // the downloads running before this plan began, which aren't its to show or stop, and the ones it stopped + var others by remember { mutableStateOf(emptySet()) } + var stopped by remember { mutableStateOf(emptySet()) } + + fun dropPending() { + if (loading) { + stopped = HotwireBridge.runningDownloadIds() - others + HotwireBridge.stopDownloads(others) + } + loading = false + plan?.onCancel?.invoke() + } + + // leaving the page drops a pending add the same way Back does + DisposableEffect(Unit) { onDispose { dropPending() } } + + fun leavePreview() { + request++ + reading = false + dropPending() + plan = null + } + + /** Loads a plan and its icon off the UI thread, since they can wait on a dialog, a download or Modrinth */ + fun review(name: String, icon: () -> String?, load: () -> HotwireBridge.JarPlan?) { + leavePreview() + val id = request + reading = true + planIcon = null + scope.launch { + val iconPath = withContext(Dispatchers.IO) { runCatching(icon).getOrNull() } + if (id == request) planIcon = iconPath + } + scope.launch { + // a stopped download takes a moment to end, and a second of the same jar beside it would write over it + while (id == request && HotwireBridge.runningDownloadIds().any { it in stopped }) delay(DOWNLOAD_POLL_MILLIS) + if (id != request) return@launch + others = HotwireBridge.runningDownloadIds() + loading = true + val loaded = withContext(Dispatchers.IO) { runCatching(load) } + if (id != request) { + loaded.getOrNull()?.onCancel?.invoke() + return@launch + } + loading = false + reading = false + plan = loaded.getOrElse { + val reason = it.cause ?: it + HotwireBridge.JarPlan(name, "", listOf(reason.message ?: reason.toString()), emptyList()) + } + } + } + + fun show(next: AddSource) { + leavePreview() + source = next + } + + Column(verticalArrangement = Arrangement.spacedBy(19.dp)) { + Row(Modifier.fillMaxWidth().padding(horizontal = PageGutter), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Chip(translated("oneconfig.page.hotwire.source.modrinth", "Modrinth"), source == AddSource.Modrinth, "search") { show(AddSource.Modrinth) } + Chip(translated("oneconfig.page.hotwire.source.updates", "Updates"), source == AddSource.Updates, "refresh") { show(AddSource.Updates) } + Chip(translated("oneconfig.page.hotwire.source.file", "From file"), source == AddSource.File, "folder") { show(AddSource.File) } + Spacer(Modifier.weight(1f)) + Chip(translated("oneconfig.page.hotwire.downloads_folder", "Downloads folder"), selected = false, icon = "external-link") { HotwireBridge.openDownloads() } + } + fun reviewUpdate(update: HotwireBridge.StoreUpdate, icon: () -> String?) = + review(update.name, icon) { HotwireBridge.planUpdate(listOf(update)) } + + if (reading || plan != null) { + PlanCard(reading, plan, planIcon, others, onBack = ::leavePreview) + } else { + when (source) { + AddSource.File -> FileSource { jar -> review("", { null }) { jar()?.let(HotwireBridge::planFor) } } + AddSource.Modrinth -> ModrinthSource( + onInstall = { hit -> review(hit.title, { hit.iconUrl?.let { HotwireBridge.iconFor(it).toString() } }) { HotwireBridge.storePlan(hit) } }, + onUpdate = { update, hit -> reviewUpdate(update) { hit.iconUrl?.let { HotwireBridge.iconFor(it).toString() } } }, + ) + AddSource.Updates -> UpdatesSource { update -> reviewUpdate(update) { installedIcon(update.modId) } } + } + } + } +} + +/** The icon of an installed mod, read out of its jar */ +private fun installedIcon(modId: String): String? = ModInfo.seenMods.firstOrNull { it.id == modId }?.extractIconFile() + +/** A row's icon, loaded off the UI thread so scrolling never waits on a jar or the network */ +@Composable +private fun RowIcon(key: Any, load: () -> String?) { + val path by produceState(null, key) { value = withContext(Dispatchers.IO) { runCatching(load).getOrNull() } } + // keeps names lined up while an icon loads or when a mod has none + Box(Modifier.size(36.dp)) { + path?.let { Icon(it, color = LocalTheme.current.textColor, modifier = Modifier.fillMaxSize()) } + } +} + +@Composable +private fun Card(content: @Composable () -> Unit) { + val theme = LocalTheme.current + Column( + Modifier.padding(horizontal = PageGutter).fillMaxWidth() + .background(theme.modCardBackground, theme.modCardShape) + .border(1.dp, theme.borderColor, theme.modCardShape) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { content() } +} + +/** Picks a jar with the system file dialog, handing back a way to read its path */ +@Composable +private fun FileSource(onPicked: (() -> Path?) -> Unit) { + val theme = LocalTheme.current + Card { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) { + Icon("plus", color = theme.textColor, modifier = Modifier.size(32.dp)) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(translated("oneconfig.page.hotwire.add.title", "Add or update a mod"), color = theme.textColor, fontSize = 18.sp) + Text( + translated( + "oneconfig.page.hotwire.add.hint", + "Pick a mod jar. Hotwire shows what it would change before anything happens, and updates the mod if it's already installed.", + ), + color = theme.textColorSecondary, + fontSize = 13.sp, + ) + } + } + Row { + Chip(translated("oneconfig.page.hotwire.add.pick", "Choose a mod jar"), selected = true, icon = "folder") { + onPicked { + TinyFdApi.getInstance().openFileSelector( + translated("oneconfig.page.hotwire.add.pick", "Choose a mod jar"), + null, + arrayOf("*.jar"), + translated("oneconfig.page.hotwire.add.pick_filter", "Mod jars"), + ) + } + } + } + } +} + +/** A category's name from Hotwire's own translations, or the tag made readable, where a blank tag means every category */ +@Composable +private fun categoryLabel(tag: String): String { + val key = tag.ifBlank { "all" } + return translated("hotwire.store.category.$key", key.replace('-', ' ').replaceFirstChar { it.uppercase() }) +} + +/** Modrinth mods matching the header search, or Modrinth's own listing while it is empty */ +@Composable +private fun ModrinthSource( + onInstall: (HotwireBridge.StoreHit) -> Unit, + onUpdate: (HotwireBridge.StoreUpdate, HotwireBridge.StoreHit) -> Unit, +) { + val theme = LocalTheme.current + val query = ShellState.searchQuery.trim() + val categories = remember { HotwireBridge.storeCategories() } + var group by remember { mutableStateOf(categories.firstOrNull()) } + var category by remember { mutableStateOf("") } + var hits by remember { mutableStateOf>?>(null) } + var total by remember { mutableIntStateOf(0) } + var searched by remember { mutableStateOf?>(null) } + var offset by remember(query, category) { mutableIntStateOf(0) } + LaunchedEffect(query, category, offset) { + // waits out typing so each keystroke doesn't hit Modrinth + if (offset == 0) delay(350) + val earlier = if (offset == 0) emptyList() else hits?.getOrNull().orEmpty() + val page = withContext(Dispatchers.IO) { runCatching { HotwireBridge.searchStore(query, category, offset) } } + total = page.getOrNull()?.total ?: 0 + hits = page.map { earlier + it.hits } + searched = query to category + } + LaunchedEffect(Unit) { HotwireUpdates.ensure() } + + Row(Modifier.padding(horizontal = PageGutter), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + categories.forEach { + Chip(categoryLabel(it.parts.singleOrNull() ?: it.name), group === it) { + group = it + category = it.parts.firstOrNull() ?: it.name + } + } + } + // only a group of several categories has anything to choose between + group?.parts?.takeIf { it.size > 1 }?.let { parts -> + Row(Modifier.padding(horizontal = PageGutter), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + parts.forEach { Chip(categoryLabel(it), category == it) { category = it } } + } + } + + val current = hits + when { + current == null -> Card { Text(translated("oneconfig.page.hotwire.modrinth.loading", "Searching Modrinth…"), color = theme.textColorSecondary, fontSize = 14.sp) } + current.isFailure -> Card { + val reason = current.exceptionOrNull()?.let { it.cause ?: it } + Text( + translated("oneconfig.page.hotwire.modrinth.failed", "Couldn't reach Modrinth: %s", reason?.message ?: ""), + color = theme.textColorSecondary, + fontSize = 14.sp, + ) + } + else -> { + val updates = HotwireUpdates.found?.getOrNull().orEmpty() + val revision = HotwireBridge.revision + val installed = remember(revision) { gridMods(HotwireBridge.mods()?.associateBy { it.id }) } + val shown = remember(current, updates, installed) { + current.getOrThrow().mapNotNull { hit -> + val update = updates.firstOrNull { it.projectId == hit.projectId } + if (update == null && hit.installed) null else hit to update + } + } + val loaded = current.getOrThrow().size + val footer: @Composable () -> Unit = { + if (searched == query to category && offset < loaded && loaded < total) { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Chip(translated("oneconfig.page.hotwire.modrinth.more", "Load more"), selected = false) { offset = loaded } + } + } + } + ResultList(shown, footer) { (hit, update) -> + RowIcon(hit.projectId) { hit.iconUrl?.let { HotwireBridge.iconFor(it).toString() } } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(hit.title, color = theme.textColor, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + listOf(hit.author.takeIf { it.isNotBlank() }?.let { translated("oneconfig.header.byline", "by %s", it) }, hit.description) + .filterNotNull().joinToString(" · "), + color = theme.textColorSecondary, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text(translated("oneconfig.page.hotwire.modrinth.downloads", "%s downloads", "%,d".format(hit.downloads)), color = theme.textColorSecondary, fontSize = 12.sp) + when { + update != null -> Chip(translated("oneconfig.page.hotwire.modrinth.update", "Update"), selected = true) { onUpdate(update, hit) } + // still offered, since a different project can share a mod's name and Hotwire says so when asked + HotwireBridge.isInstalled(hit, installed) -> + Chip(translated("oneconfig.page.hotwire.modrinth.installed", "Installed"), selected = false) { onInstall(hit) } + else -> Chip(translated("oneconfig.page.hotwire.modrinth.install", "Install"), selected = true) { onInstall(hit) } + } + } + } + } +} + +/** Updates Hotwire found on Modrinth for the installed mods, from the session's shared [HotwireUpdates] */ +@Composable +private fun UpdatesSource(onUpdate: (HotwireBridge.StoreUpdate) -> Unit) { + val theme = LocalTheme.current + LaunchedEffect(Unit) { HotwireUpdates.ensure() } + + val current = HotwireUpdates.found + when { + current == null -> Card { Text(translated("oneconfig.page.hotwire.updates.checking", "Checking for updates…"), color = theme.textColorSecondary, fontSize = 14.sp) } + current.isFailure -> Card { + Text( + translated("oneconfig.page.hotwire.updates.failed", "Couldn't check for updates: %s", current.exceptionOrNull()?.message ?: ""), + color = theme.textColorSecondary, + fontSize = 14.sp, + ) + } + current.getOrThrow().isEmpty() -> Card { + Text(translated("oneconfig.page.hotwire.updates.none", "Everything is up to date."), color = theme.textColorSecondary, fontSize = 14.sp) + } + else -> ResultList(current.getOrThrow()) { update -> + RowIcon(update.modId) { installedIcon(update.modId) } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(update.name, color = theme.textColor, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text("${update.installedVersion} → ${update.newVersion}", color = theme.textColorSecondary, fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Chip(translated("oneconfig.page.hotwire.modrinth.update", "Update"), selected = true) { onUpdate(update) } + } + } +} + +/** A scrolling column of rows styled like the Hotwire page's list view */ +@Composable +private fun ResultList(items: List, footer: @Composable () -> Unit = {}, row: @Composable RowScope.(T) -> Unit) { + val theme = LocalTheme.current + val listState = rememberLazyListState() + ScrollbarBox(listState, PageGutter, Modifier.fillMaxSize()) { + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(horizontal = PageGutter), + modifier = Modifier.fillMaxSize(), + ) { + items(items) { item -> + Row( + Modifier.fillMaxWidth().height(56.dp) + .background(theme.modCardBackground, theme.modCardShape) + .border(1.dp, theme.borderColor, theme.modCardShape) + .padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { row(item) } + } + item { footer() } + } + } +} + +/** The wait for a plan, which for a Modrinth mod is Hotwire downloading it and what it needs */ +@Composable +private fun PlanLoading(others: Set, onCancel: () -> Unit) { + val theme = LocalTheme.current + val download = rememberDownloadWatch(others = others) + val current = download.progress + val fraction = download.fraction + val percent = (fraction * 100).toInt() + Text( + when { + current == null -> translated("oneconfig.page.hotwire.add.reading", "Reading the jar…") + current.paused -> translated("oneconfig.page.hotwire.add.paused", "Paused at %s%%", percent) + else -> translated("oneconfig.page.hotwire.add.downloading", "Downloading… %s%%", percent) + }, + color = theme.textColorSecondary, + fontSize = 14.sp, + ) + if (current != null) HotwireProgressBar(fraction) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + // see HotwireBridge.pauseDownloads, which does nothing beside other downloads + if (current != null && others.isEmpty()) { + val label = if (current.paused) { + translated("oneconfig.page.hotwire.add.resume", "Resume") + } else { + translated("oneconfig.page.hotwire.add.pause", "Pause") + } + Chip(label, selected = false) { HotwireBridge.pauseDownloads(!current.paused, others) } + } + Chip(translated("oneconfig.page.hotwire.add.cancel", "Cancel"), selected = false, onClick = onCancel) + } +} + +/** What Hotwire would do with the chosen jar and the choices it offers, then its progress once one is picked */ +@Composable +private fun PlanCard(reading: Boolean, plan: HotwireBridge.JarPlan?, icon: String?, otherDownloads: Set, onBack: () -> Unit) { + val theme = LocalTheme.current + var started by remember(plan) { mutableStateOf(false) } + // the outcome of an action that reports one, and whether one is on its way + var outcome by remember(plan) { mutableStateOf(null) } + var awaiting by remember(plan) { mutableStateOf(false) } + var working by remember { mutableStateOf(false) } + var fraction by remember { mutableFloatStateOf(0f) } + LaunchedEffect(started) { + while (started) withFrameNanos { + working = HotwireBridge.isWorking() + fraction = HotwireBridge.progress() + } + } + + Card { + if (reading || plan == null) { + PlanLoading(otherDownloads, onCancel = onBack) + return@Card + } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) { + if (icon != null) Icon(icon, color = theme.textColor, modifier = Modifier.size(48.dp)) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(plan.title, color = theme.textColor, fontSize = 18.sp) + if (plan.detail.isNotBlank()) Text(plan.detail, color = theme.textColorSecondary, fontSize = 13.sp) + } + } + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + plan.lines.forEach { Text("• $it", color = theme.textColor, fontSize = 13.sp) } + } + if (started) { + Text( + when { + outcome != null -> outcome!!.message + working -> translated("oneconfig.page.hotwire.add.working", "Hotwire is working on it… %s%%", (fraction * 100).toInt()) + awaiting -> translated("oneconfig.page.hotwire.add.waiting", "Waiting for Hotwire…") + else -> translated("oneconfig.page.hotwire.add.sent", "Sent to Hotwire. It posts the result in chat.") + }, + color = theme.textColorSecondary, + fontSize = 13.sp, + ) + if (working) HotwireProgressBar(fraction) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (!started) { + plan.actions.forEach { action -> + Chip(action.label, selected = action.primary) { + started = true + awaiting = true + val pending = action.attempt() + awaiting = pending != null + pending?.whenComplete { result, error -> + Platform.screen().runOnUiThread { + val done = result ?: HotwireBridge.Result(false, (error?.cause ?: error)?.message ?: translated("oneconfig.page.hotwire.result.failed", "That didn't work.")) + outcome = done + awaiting = false + notifyHotwireResult(done) + HotwireUpdates.refreshWhenDone() + } + } + if (pending == null) HotwireUpdates.refreshWhenDone() + } + } + } + Chip(translated("oneconfig.page.hotwire.add.back", "Back"), selected = false, onClick = onBack) + } + } +} diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/ModSortControl.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/ModSortControl.kt index 1cd2f984e..f6547874a 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/ModSortControl.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/ModSortControl.kt @@ -56,20 +56,12 @@ fun ModSortControl(modifier: Modifier = Modifier) { } Box(modifier.padding(end = PageGutter)) { - Row( - Modifier - .height(36.dp) - .liquidGlass(theme.buttonShape) - .background(theme.componentBackground, theme.buttonShape) - .border(1.dp, theme.borderColor, theme.buttonShape) - .padding(3.dp), - horizontalArrangement = Arrangement.spacedBy(2.dp), - ) { - modes.forEach { mode -> - SortSegment(mode, mode.id == selectedId) { - if (mode.confirmation != null) pending = mode else apply(mode) - } - } + IconSegments( + modes.map { IconSegment(it.icon, it.title) }, + selected = modes.indexOfFirst { it.id == selectedId }, + ) { index -> + val mode = modes[index] + if (mode.confirmation != null) pending = mode else apply(mode) } pending?.let { mode -> ShellPopup( @@ -101,12 +93,38 @@ fun ModSortControl(modifier: Modifier = Modifier) { } } +class IconSegment(val icon: String, val title: String) + +/** A row of icon-only choices with the [selected] one highlighted, as the mods page's sort control */ +@Composable +fun IconSegments( + segments: List, + selected: Int, + modifier: Modifier = Modifier, + onSelect: (Int) -> Unit, +) { + val theme = LocalTheme.current + Row( + modifier + .height(36.dp) + .liquidGlass(theme.buttonShape) + .background(theme.componentBackground, theme.buttonShape) + .border(1.dp, theme.borderColor, theme.buttonShape) + .padding(3.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + segments.forEachIndexed { index, segment -> + Segment(segment, index == selected) { onSelect(index) } + } + } +} + @Composable -private fun SortSegment(mode: ModSortMode, selected: Boolean, onSwitch: () -> Unit) { +private fun Segment(segment: IconSegment, selected: Boolean, onSwitch: () -> Unit) { val theme = LocalTheme.current val interactionSource = rememberInteractionSource() Tooltip( - text = { Text(mode.title, color = theme.textColor, fontSize = 12.sp) }, + text = { Text(segment.title, color = theme.textColor, fontSize = 12.sp) }, modifier = Modifier, anchor = Alignment.BottomCenter, ) { @@ -122,7 +140,7 @@ private fun SortSegment(mode: ModSortMode, selected: Boolean, onSwitch: () -> Un .padding(horizontal = 12.dp), contentAlignment = Alignment.Center, ) { - Icon(mode.icon, if (selected) theme.textColor else theme.textColorSecondary, Modifier.size(16.dp)) + Icon(segment.icon, if (selected) theme.textColor else theme.textColorSecondary, Modifier.size(16.dp)) } } } diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Mods.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Mods.kt index 4fe1d5e28..7210d7f66 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Mods.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Mods.kt @@ -79,6 +79,7 @@ import org.polyfrost.oneconfig.api.ui.v1.ModsPageDecorators import org.polyfrost.oneconfig.internal.ui.api.ConfigData import org.polyfrost.oneconfig.internal.ui.api.ConfigRegistry import org.polyfrost.oneconfig.internal.ui.api.ConfigSource +import org.polyfrost.oneconfig.internal.ui.api.HotwireBridge import org.polyfrost.oneconfig.internal.ui.api.ModCardTypeCollapseStore import org.polyfrost.oneconfig.internal.ui.api.ModFavorites import org.polyfrost.oneconfig.internal.ui.api.ModGridEntry @@ -109,6 +110,7 @@ import org.polyfrost.oneconfig.internal.ui.components.rememberModEnabled import org.polyfrost.oneconfig.internal.ui.components.reorderContainer import org.polyfrost.oneconfig.internal.ui.components.reorderOverlay import org.polyfrost.oneconfig.internal.ui.components.reorderableItem +import org.polyfrost.oneconfig.internal.ui.navigation.graph.HotwireGraph import org.polyfrost.oneconfig.internal.ui.navigation.graph.ModConfigRoute import org.polyfrost.oneconfig.internal.ui.shell.LocalShell import org.polyfrost.oneconfig.internal.ui.shell.PageGutter @@ -404,6 +406,8 @@ private fun List.cardData(key: Any): ConfigData? = private fun openModCard(mod: ConfigData, navigation: ShellNavigation) { val onOpen = mod.onOpen when { + // Hotwire's card would open its own screen, but OneConfig has a page for it + mod.id == "hotwire" && HotwireBridge.supportsSwitching -> navigation.navigate(HotwireGraph) onOpen != null -> onOpen() mod.source == ConfigSource.OC -> navigation.navigate(ModConfigRoute(mod.id)) } @@ -487,13 +491,7 @@ fun ModCard( ) val glowStrength by animateFloatAsState(if (enabled) 0.5f else 0f) val navigation = LocalShell.current.navigation - val open = { - val onOpen = mod.onOpen - when { - onOpen != null -> onOpen() - mod.source == ConfigSource.OC -> navigation.navigate(ModConfigRoute(mod.id)) - } - } + val open = { openModCard(mod, navigation) } Box( modifier = modifier.fillMaxWidth().height(140.dp) diff --git a/modules/internal/src/test/java/dev/moon/hotwire/api/ChangePlan.java b/modules/internal/src/test/java/dev/moon/hotwire/api/ChangePlan.java new file mode 100644 index 000000000..99eb66660 --- /dev/null +++ b/modules/internal/src/test/java/dev/moon/hotwire/api/ChangePlan.java @@ -0,0 +1,27 @@ +package dev.moon.hotwire.api; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +public interface ChangePlan { + boolean canApply(); + + Reason getReason(); + + List getLines(); + + CompletableFuture accept(); + + boolean needsReopen(); + + boolean canWaitForNextStart(); + + CompletableFuture acceptAtNextStart(); + + void cancel(); + + enum Reason { + NONE, + NEEDS_RESTART + } +} diff --git a/modules/internal/src/test/java/dev/moon/hotwire/api/ChangeRequest.java b/modules/internal/src/test/java/dev/moon/hotwire/api/ChangeRequest.java new file mode 100644 index 000000000..926cb7b26 --- /dev/null +++ b/modules/internal/src/test/java/dev/moon/hotwire/api/ChangeRequest.java @@ -0,0 +1,7 @@ +package dev.moon.hotwire.api; + +import java.nio.file.Path; +import java.util.List; + +public record ChangeRequest(List off, List on, List update, List add, List jars) { +} diff --git a/modules/internal/src/test/java/dev/moon/hotwire/api/HotwireApi.java b/modules/internal/src/test/java/dev/moon/hotwire/api/HotwireApi.java new file mode 100644 index 000000000..b562e2cfb --- /dev/null +++ b/modules/internal/src/test/java/dev/moon/hotwire/api/HotwireApi.java @@ -0,0 +1,72 @@ +package dev.moon.hotwire.api; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; + +/** Stands in for the optional Hotwire mod, with the signatures of its real API, so the reflective bridge has something to find */ +public final class HotwireApi { + private static final HotwireApi INSTANCE = new HotwireApi(); + + public static final List DOWNLOADS = new ArrayList<>(); + public static final List CONTROLS = new ArrayList<>(); + public static ChangePlan plan; + public static final List PENDING = new ArrayList<>(); + public static final List UPDATES = new ArrayList<>(); + public static StoreAdd storeAdd; + + public static HotwireApi getInstance() { + return INSTANCE; + } + + public CompletableFuture> findStoreProject(StoreQuery query, String idOrSlug) { + return CompletableFuture.completedFuture(Optional.of(new StoreProject(List.of(new StoreProject.Version("version"))))); + } + + public CompletableFuture requestAdd(String idOrSlug, String versionId) { + return CompletableFuture.completedFuture(storeAdd); + } + + public int getRevision() { + return 8; + } + + public List getPendingTasks() { + return PENDING; + } + + public CompletableFuture checkUpdates() { + return CompletableFuture.completedFuture(new ModUpdates(UPDATES, "")); + } + + public void addListener(Consumer listener) { + } + + public CompletableFuture planChange(ChangeRequest request) { + return CompletableFuture.completedFuture(plan); + } + + public List getStoreDownloads() { + return DOWNLOADS; + } + + public CompletableFuture turnOff(Collection ids) { + CONTROLS.add("off " + ids); + return CompletableFuture.completedFuture(new SwitchResult(SwitchResult.Kind.DONE, "")); + } + + public boolean pauseDownload(int id) { + return CONTROLS.add("pause " + id); + } + + public boolean resumeDownload(int id) { + return CONTROLS.add("resume " + id); + } + + public boolean stopDownload(int id) { + return CONTROLS.add("stop " + id); + } +} diff --git a/modules/internal/src/test/java/dev/moon/hotwire/api/ModUpdate.java b/modules/internal/src/test/java/dev/moon/hotwire/api/ModUpdate.java new file mode 100644 index 000000000..8d13eb67c --- /dev/null +++ b/modules/internal/src/test/java/dev/moon/hotwire/api/ModUpdate.java @@ -0,0 +1,4 @@ +package dev.moon.hotwire.api; + +public record ModUpdate(String id, String name, String installedVersion, String newVersion, String projectId) { +} diff --git a/modules/internal/src/test/java/dev/moon/hotwire/api/ModUpdates.java b/modules/internal/src/test/java/dev/moon/hotwire/api/ModUpdates.java new file mode 100644 index 000000000..7c968b3db --- /dev/null +++ b/modules/internal/src/test/java/dev/moon/hotwire/api/ModUpdates.java @@ -0,0 +1,6 @@ +package dev.moon.hotwire.api; + +import java.util.List; + +public record ModUpdates(List updates, String failure) { +} diff --git a/modules/internal/src/test/java/dev/moon/hotwire/api/PendingTask.java b/modules/internal/src/test/java/dev/moon/hotwire/api/PendingTask.java new file mode 100644 index 000000000..0540706e5 --- /dev/null +++ b/modules/internal/src/test/java/dev/moon/hotwire/api/PendingTask.java @@ -0,0 +1,10 @@ +package dev.moon.hotwire.api; + +public record PendingTask(String modId, String name, Kind kind) { + public enum Kind { + UPDATE, + REVERTABLE, + COPIED, + OFF_AT_START + } +} diff --git a/modules/internal/src/test/java/dev/moon/hotwire/api/StoreAdd.java b/modules/internal/src/test/java/dev/moon/hotwire/api/StoreAdd.java new file mode 100644 index 000000000..daa8cf142 --- /dev/null +++ b/modules/internal/src/test/java/dev/moon/hotwire/api/StoreAdd.java @@ -0,0 +1,18 @@ +package dev.moon.hotwire.api; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +public interface StoreAdd { + String getTitle(); + + List getLines(); + + boolean needsReopen(); + + boolean canAdd(); + + CompletableFuture accept(); + + void cancel(); +} diff --git a/modules/internal/src/test/java/dev/moon/hotwire/api/StoreDownload.java b/modules/internal/src/test/java/dev/moon/hotwire/api/StoreDownload.java new file mode 100644 index 000000000..2ec94d11b --- /dev/null +++ b/modules/internal/src/test/java/dev/moon/hotwire/api/StoreDownload.java @@ -0,0 +1,12 @@ +package dev.moon.hotwire.api; + +public record StoreDownload(String name, long done, long total, boolean over, boolean ok, int id, State state) { + public enum State { + WAITING, + GETTING, + PAUSED, + DONE, + FAILED, + STOPPED + } +} diff --git a/modules/internal/src/test/java/dev/moon/hotwire/api/StoreProject.java b/modules/internal/src/test/java/dev/moon/hotwire/api/StoreProject.java new file mode 100644 index 000000000..0d4180d5a --- /dev/null +++ b/modules/internal/src/test/java/dev/moon/hotwire/api/StoreProject.java @@ -0,0 +1,8 @@ +package dev.moon.hotwire.api; + +import java.util.List; + +public record StoreProject(List versions) { + public record Version(String id) { + } +} diff --git a/modules/internal/src/test/java/dev/moon/hotwire/api/StoreQuery.java b/modules/internal/src/test/java/dev/moon/hotwire/api/StoreQuery.java new file mode 100644 index 000000000..fd57119ce --- /dev/null +++ b/modules/internal/src/test/java/dev/moon/hotwire/api/StoreQuery.java @@ -0,0 +1,7 @@ +package dev.moon.hotwire.api; + +public record StoreQuery(String words) { + public static StoreQuery of(String words) { + return new StoreQuery(words); + } +} diff --git a/modules/internal/src/test/java/dev/moon/hotwire/api/SwitchResult.java b/modules/internal/src/test/java/dev/moon/hotwire/api/SwitchResult.java new file mode 100644 index 000000000..bfdbde8eb --- /dev/null +++ b/modules/internal/src/test/java/dev/moon/hotwire/api/SwitchResult.java @@ -0,0 +1,7 @@ +package dev.moon.hotwire.api; + +public record SwitchResult(Kind kind, String message) { + public enum Kind { + DONE + } +} diff --git a/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/TestCompatibilityPlatform.java b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/TestCompatibilityPlatform.java index f4e9ac69f..33b67c1a7 100644 --- a/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/TestCompatibilityPlatform.java +++ b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/TestCompatibilityPlatform.java @@ -39,9 +39,16 @@ public final class TestCompatibilityPlatform implements CompatibilityPlatform { public void displayChatMessage(Component text) { } + public static Set mods = Set.of(); + + public static volatile Runnable afterRead = () -> { + }; + @Override public Set getMods() { - return Set.of(); + Set read = mods; + afterRead.run(); + return read; } @Override diff --git a/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/TestI18nPlatform.java b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/TestI18nPlatform.java new file mode 100644 index 000000000..ce747cf9e --- /dev/null +++ b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/TestI18nPlatform.java @@ -0,0 +1,16 @@ +package org.polyfrost.oneconfig.internal.ui; + +import org.polyfrost.oneconfig.api.platform.v1.I18nPlatform; + +/** No language is loaded in tests, so every text falls back to its English default */ +public final class TestI18nPlatform implements I18nPlatform { + @Override + public Object translate(String key, String fallback, Object... args) { + return fallback; + } + + @Override + public String getUnformattedText(Object component) { + return String.valueOf(component); + } +} diff --git a/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/ConfigRegistryKeybindsTest.java b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/ConfigRegistryKeybindsTest.java new file mode 100644 index 000000000..13908c9c2 --- /dev/null +++ b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/ConfigRegistryKeybindsTest.java @@ -0,0 +1,134 @@ +package org.polyfrost.oneconfig.internal.ui.api; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.lang.ref.WeakReference; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.polyfrost.oneconfig.api.config.v1.ConfigManager; +import org.polyfrost.oneconfig.api.config.v1.Properties; +import org.polyfrost.oneconfig.api.config.v1.Tree; +import org.polyfrost.oneconfig.api.ui.v1.keybind.KeybindManager; +import org.polyfrost.oneconfig.api.ui.v1.keybind.OneConfigKeybind; +import org.polyfrost.oneconfig.internal.ui.keybind.MinecraftKeybindRegistrar; + +class ConfigRegistryKeybindsTest { + private static final String ID = "config-registry-keybinds-test.json"; + + private static OneConfigKeybind bind() { + return KeybindManager.builder().build(); + } + + private static Tree tree(OneConfigKeybind bind) { + Tree tree = Tree.tree(ID); + tree.setTitle("Keybinds"); + tree.put(Tree.tree("nested").put(Properties.simple("bind", "Bind", "", bind))); + return tree; + } + + @AfterEach + void clear() throws IOException { + ConfigRegistry.INSTANCE.setOff(Set.of()); + ConfigRegistry.INSTANCE.unregister(ID); + ConfigManager.active().unregister(ID); + Files.deleteIfExists(ConfigManager.active().getFolder().resolve(ID)); + } + + @Test + void aKeybindStopsWhileItsModIsOffAndReturnsWithIt() { + OneConfigKeybind bind = bind(); + Tree tree = tree(bind); + ConfigManager.active().register(tree); + ConfigRegistry.INSTANCE.registerTree(tree, ConfigSource.OC); + assertTrue(KeybindManager.isRegistered(bind)); + + ConfigRegistry.INSTANCE.setOff(Set.of(ID)); + assertFalse(KeybindManager.isRegistered(bind)); + // as a registry reload after a profile switch does + ConfigRegistry.INSTANCE.registerTree(tree, ConfigSource.OC); + assertFalse(KeybindManager.isRegistered(bind)); + + ConfigRegistry.INSTANCE.setOff(Set.of()); + assertTrue(KeybindManager.isRegistered(bind)); + KeybindManager.unregister(bind); + } + + @Test + void aKeybindBuiltOnEveryReadStillStopsWhileItsModIsOff() { + List built = new ArrayList<>(); + Tree tree = Tree.tree(ID); + tree.setTitle("Keybinds"); + tree.put(Properties.functional(() -> { + OneConfigKeybind bind = bind(); + built.add(bind); + return bind; + }, bind -> { + }, "bind", "Bind", "", OneConfigKeybind.class)); + + MinecraftKeybindRegistrar.scan(tree); + assertTrue(built.stream().anyMatch(KeybindManager::isRegistered)); + + MinecraftKeybindRegistrar.unregister(tree); + assertTrue(built.stream().noneMatch(KeybindManager::isRegistered)); + } + + private static Tree scanned() { + Tree tree = Tree.tree("config-registry-released-test"); + tree.setTitle("Released"); + MinecraftKeybindRegistrar.scan(tree); + return tree; + } + + @Test + void aScannedTreeIsNotKeptOnceItsConfigIsGone() throws InterruptedException { + WeakReference tree = new WeakReference<>(scanned()); + for (int i = 0; i < 100 && tree.get() != null; i++) { + System.gc(); + Thread.sleep(10); + } + assertNull(tree.get()); + } + + @Test + void aReplacedConfigTakesItsKeybindsWithIt() { + OneConfigKeybind old = bind(); + OneConfigKeybind replacement = bind(); + ConfigRegistry.INSTANCE.registerTree(tree(old), ConfigSource.OC); + assertTrue(KeybindManager.isRegistered(old)); + + ConfigRegistry.INSTANCE.registerTree(tree(replacement), ConfigSource.OC); + assertFalse(KeybindManager.isRegistered(old)); + assertTrue(KeybindManager.isRegistered(replacement)); + KeybindManager.unregister(replacement); + } + + @Test + void aReplacedConfigWhoseCardHotwireTookOutTakesItsKeybindsWithIt() { + OneConfigKeybind old = bind(); + OneConfigKeybind replacement = bind(); + ConfigRegistry.INSTANCE.registerTree(tree(old), ConfigSource.OC); + ConfigRegistry.INSTANCE.unregister(ID); + + ConfigRegistry.INSTANCE.registerTree(tree(replacement), ConfigSource.OC); + assertFalse(KeybindManager.isRegistered(old)); + assertTrue(KeybindManager.isRegistered(replacement)); + KeybindManager.unregister(replacement); + } + + @Test + void aRebuiltTreeKeepsTheKeybindItShares() { + OneConfigKeybind shared = bind(); + ConfigRegistry.INSTANCE.registerTree(tree(shared), ConfigSource.OC); + + ConfigRegistry.INSTANCE.registerTree(tree(shared), ConfigSource.OC); + assertTrue(KeybindManager.isRegistered(shared)); + KeybindManager.unregister(shared); + } +} diff --git a/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/HotwireAppliedUpdatesTest.java b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/HotwireAppliedUpdatesTest.java new file mode 100644 index 000000000..a8a0722f4 --- /dev/null +++ b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/HotwireAppliedUpdatesTest.java @@ -0,0 +1,92 @@ +package org.polyfrost.oneconfig.internal.ui.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import dev.moon.hotwire.api.HotwireApi; +import dev.moon.hotwire.api.PendingTask; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class HotwireAppliedUpdatesTest { + private static HotwireBridge.ModState mod(String id, String version) { + return new HotwireBridge.ModState(id, id.toUpperCase(), version, true, true, false); + } + + private static final List BEFORE = List.of(mod("a", "1"), mod("b", "1")); + + @AfterEach + void clear() { + HotwireApi.PENDING.clear(); + } + + @Test + void aLiveUpdateCountsAsLiveThoughItsJarSwapIsQueued() { + HotwireBridge.AppliedUpdates result = HotwireBridge.INSTANCE.whenApplied(BEFORE, List.of(mod("a", "2"), mod("b", "1")), Map.of(), Map.of("a", "A")); + assertEquals(List.of("A"), result.getNow()); + assertEquals(List.of(), result.getNextLaunch()); + } + + @Test + void anUpdateRunningFromACopyCountsAsLiveThoughTheOriginalsSwapIsQueued() { + HotwireBridge.AppliedUpdates result = HotwireBridge.INSTANCE.whenApplied( + BEFORE, + List.of(new HotwireBridge.ModState("a_live1", "A", "2", true, true, false), mod("b", "1")), + Map.of(), + Map.of("a", "A") + ); + assertEquals(List.of("A"), result.getNow()); + assertEquals(List.of(), result.getNextLaunch()); + } + + @Test + void aDependentCopiedAlongUnchangedIsNotAnUpdate() { + HotwireBridge.AppliedUpdates result = HotwireBridge.INSTANCE.whenApplied( + BEFORE, + List.of(new HotwireBridge.ModState("a_live1", "A", "2", true, true, false), new HotwireBridge.ModState("b_live1", "B", "1", true, true, false)), + Map.of(), + Map.of("a", "A") + ); + assertEquals(List.of("A"), result.getNow()); + } + + @Test + void aQueuedUpdateStillRunsItsOldVersion() { + HotwireBridge.AppliedUpdates result = HotwireBridge.INSTANCE.whenApplied(BEFORE, BEFORE, Map.of(), Map.of("b", "B")); + assertEquals(List.of(), result.getNow()); + assertEquals(List.of("B"), result.getNextLaunch()); + } + + @Test + void whatWasAlreadyQueuedIsNotThisBatch() { + HotwireBridge.AppliedUpdates result = HotwireBridge.INSTANCE.whenApplied(BEFORE, BEFORE, Map.of("old", "Old"), Map.of("old", "Old")); + assertEquals(List.of(), result.getNow()); + assertEquals(List.of(), result.getNextLaunch()); + } + + @Test + void anUpdateQueuedForAModAlreadyTurningOffIsStillReported() { + HotwireApi.PENDING.add(new PendingTask("b", "B", PendingTask.Kind.OFF_AT_START)); + Map before = HotwireBridge.INSTANCE.queuedMods(); + HotwireApi.PENDING.add(new PendingTask("b", "B", PendingTask.Kind.UPDATE)); + + HotwireBridge.AppliedUpdates result = HotwireBridge.INSTANCE.whenApplied(BEFORE, BEFORE, before, HotwireBridge.INSTANCE.queuedMods()); + assertEquals(List.of("B"), result.getNextLaunch()); + } + + @Test + void aJarCopiedForTheNextLaunchIsNotAnUpdate() { + Map before = HotwireBridge.INSTANCE.queuedMods(); + HotwireApi.PENDING.add(new PendingTask("new", "New", PendingTask.Kind.COPIED)); + + HotwireBridge.AppliedUpdates result = HotwireBridge.INSTANCE.whenApplied(BEFORE, BEFORE, before, HotwireBridge.INSTANCE.queuedMods()); + assertEquals(List.of(), result.getNextLaunch()); + } + + @Test + void aModAddedByThePlanIsNotAnUpdate() { + HotwireBridge.AppliedUpdates result = HotwireBridge.INSTANCE.whenApplied(BEFORE, List.of(mod("a", "1"), mod("b", "1"), mod("new", "1")), Map.of(), Map.of()); + assertEquals(List.of(), result.getNow()); + } +} diff --git a/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/HotwireBatchPlanTest.java b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/HotwireBatchPlanTest.java new file mode 100644 index 000000000..8ee501492 --- /dev/null +++ b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/HotwireBatchPlanTest.java @@ -0,0 +1,90 @@ +package org.polyfrost.oneconfig.internal.ui.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.moon.hotwire.api.ChangePlan; +import dev.moon.hotwire.api.HotwireApi; +import dev.moon.hotwire.api.SwitchResult; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class HotwireBatchPlanTest { + /** A batch where one mod can go live and another has to wait for a restart, which Hotwire reports as needing one */ + private static final class MixedPlan implements ChangePlan { + final List accepted = new ArrayList<>(); + + private CompletableFuture record(String how) { + accepted.add(how); + return CompletableFuture.completedFuture(new SwitchResult(SwitchResult.Kind.DONE, "")); + } + + @Override + public boolean canApply() { + return true; + } + + @Override + public Reason getReason() { + return Reason.NEEDS_RESTART; + } + + @Override + public List getLines() { + return List.of(); + } + + @Override + public CompletableFuture accept() { + return record("accept"); + } + + @Override + public boolean needsReopen() { + return false; + } + + @Override + public boolean canWaitForNextStart() { + return true; + } + + @Override + public CompletableFuture acceptAtNextStart() { + return record("next start"); + } + + @Override + public void cancel() { + } + } + + private final MixedPlan mixed = new MixedPlan(); + + private HotwireBridge.JarPlan plan() { + HotwireApi.plan = mixed; + return HotwireBridge.INSTANCE.planUpdate(List.of(new HotwireBridge.StoreUpdate("a", "project", "A", "1", "2"))); + } + + @AfterEach + void clear() { + HotwireApi.plan = null; + } + + @Test + void theMainButtonGoesLiveWhereItCanEvenWhenPartNeedsARestart() throws Exception { + HotwireBridge.JarAction main = plan().getActions().getFirst(); + assertTrue(main.getPrimary()); + main.attempt().get(); + assertEquals(List.of("accept"), mixed.accepted); + } + + @Test + void waitingForTheNextLaunchIsStillOffered() throws Exception { + plan().getActions().get(1).attempt().get(); + assertEquals(List.of("next start"), mixed.accepted); + } +} diff --git a/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/HotwireDownloadsTest.java b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/HotwireDownloadsTest.java new file mode 100644 index 000000000..c9a785ef0 --- /dev/null +++ b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/HotwireDownloadsTest.java @@ -0,0 +1,119 @@ +package org.polyfrost.oneconfig.internal.ui.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.moon.hotwire.api.HotwireApi; +import dev.moon.hotwire.api.StoreDownload; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class HotwireDownloadsTest { + /** Still running as far as the list can tell, but with none of the fields read after that */ + public static final class Renamed { + public boolean over() { + return false; + } + } + + private static void running(int id, long done, StoreDownload.State state) { + HotwireApi.DOWNLOADS.add(new StoreDownload("mod.jar", done, 100, false, false, id, state)); + } + + private static void finished(int id) { + HotwireApi.DOWNLOADS.add(new StoreDownload("mod.jar", 100, 100, true, true, id, StoreDownload.State.DONE)); + } + + @AfterEach + void clear() { + HotwireApi.DOWNLOADS.clear(); + HotwireApi.CONTROLS.clear(); + } + + @Test + void finishedDownloadsAreNotProgress() { + finished(1); + assertNull(HotwireBridge.INSTANCE.downloadProgress()); + } + + @Test + void progressCoversEveryRunningJar() { + finished(1); + running(2, 10, StoreDownload.State.GETTING); + running(3, 30, StoreDownload.State.PAUSED); + HotwireBridge.DownloadProgress progress = HotwireBridge.INSTANCE.downloadProgress(); + assertEquals(0.2f, progress.getFraction()); + assertEquals("mod.jar", progress.getName()); + assertFalse(progress.getPaused()); + } + + @Test + void itIsPausedOnceEveryRunningJarIs() { + running(1, 10, StoreDownload.State.PAUSED); + assertTrue(HotwireBridge.INSTANCE.downloadProgress().getPaused()); + } + + @Test + void aFinishedJarLeavingDoesNotPullTheBarBack() { + running(1, 90, StoreDownload.State.GETTING); + running(2, 90, StoreDownload.State.GETTING); + HotwireBridge.DownloadProgress before = HotwireBridge.INSTANCE.downloadProgress(); + + HotwireApi.DOWNLOADS.clear(); + running(2, 50, StoreDownload.State.GETTING); + assertEquals(0.9f, HotwireBridge.INSTANCE.downloadProgress().shownAfter(before, 0.9f)); + } + + @Test + void aNewJarStartsTheBarOver() { + running(1, 100, StoreDownload.State.GETTING); + HotwireBridge.DownloadProgress before = HotwireBridge.INSTANCE.downloadProgress(); + + HotwireApi.DOWNLOADS.clear(); + running(2, 10, StoreDownload.State.GETTING); + assertEquals(0.1f, HotwireBridge.INSTANCE.downloadProgress().shownAfter(before, 1f)); + } + + @Test + void aFieldHotwireRenamedGivesNoProgressRatherThanAnError() { + HotwireApi.DOWNLOADS.add(new Renamed()); + assertNull(HotwireBridge.INSTANCE.downloadProgress()); + HotwireBridge.INSTANCE.stopDownloads(); + assertEquals(List.of(), HotwireApi.CONTROLS); + } + + @Test + void controlsReachEachRunningDownloadOnly() { + finished(1); + running(2, 10, StoreDownload.State.GETTING); + running(3, 10, StoreDownload.State.GETTING); + HotwireBridge.INSTANCE.pauseDownloads(true); + HotwireBridge.INSTANCE.pauseDownloads(false); + HotwireBridge.INSTANCE.stopDownloads(); + assertEquals(List.of("pause 2", "pause 3", "resume 2", "resume 3", "stop 2", "stop 3"), HotwireApi.CONTROLS); + } + + @Test + void downloadsRunningBeforeAPlanAreNotItsToShow() { + running(1, 90, StoreDownload.State.GETTING); + Set others = HotwireBridge.INSTANCE.runningDownloadIds(); + running(2, 10, StoreDownload.State.GETTING); + assertEquals(0.1f, HotwireBridge.INSTANCE.downloadProgress(others).getFraction()); + } + + @Test + void aPlanBesideOtherDownloadsCannotStopTheNextJarOfOne() { + running(1, 10, StoreDownload.State.GETTING); + Set others = HotwireBridge.INSTANCE.runningDownloadIds(); + // the plan's own and the next jar of what was already running, which Hotwire's list can't tell apart + running(2, 10, StoreDownload.State.GETTING); + running(3, 10, StoreDownload.State.GETTING); + HotwireBridge.INSTANCE.stopDownloads(others); + HotwireBridge.INSTANCE.pauseDownloads(true, others); + assertEquals(List.of(), HotwireApi.CONTROLS); + } +} diff --git a/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/HotwireFindUpdatesTest.java b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/HotwireFindUpdatesTest.java new file mode 100644 index 000000000..764b75fed --- /dev/null +++ b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/HotwireFindUpdatesTest.java @@ -0,0 +1,39 @@ +package org.polyfrost.oneconfig.internal.ui.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import dev.moon.hotwire.api.HotwireApi; +import dev.moon.hotwire.api.ModUpdate; +import dev.moon.hotwire.api.PendingTask; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class HotwireFindUpdatesTest { + private static List offered(PendingTask.Kind kind) { + HotwireApi.UPDATES.add(new ModUpdate("a", "A", "1", "2", "project-a")); + HotwireApi.PENDING.add(new PendingTask("a", "A", kind)); + return HotwireBridge.INSTANCE.findUpdates().stream().map(HotwireBridge.StoreUpdate::getModId).toList(); + } + + @AfterEach + void clear() { + HotwireApi.UPDATES.clear(); + HotwireApi.PENDING.clear(); + } + + @Test + void aQueuedUpdateIsNotOfferedAgain() { + assertEquals(List.of(), offered(PendingTask.Kind.UPDATE)); + } + + @Test + void aModTurningOffAtNextLaunchStillOffersItsUpdate() { + assertEquals(List.of("a"), offered(PendingTask.Kind.OFF_AT_START)); + } + + @Test + void aModKeepingItsOlderJarStillOffersItsUpdate() { + assertEquals(List.of("a"), offered(PendingTask.Kind.REVERTABLE)); + } +} diff --git a/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/HotwireInstalledTest.java b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/HotwireInstalledTest.java new file mode 100644 index 000000000..ee58430f0 --- /dev/null +++ b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/HotwireInstalledTest.java @@ -0,0 +1,38 @@ +package org.polyfrost.oneconfig.internal.ui.api; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.polyfrost.oneconfig.api.platform.v1.ModInfo; + +class HotwireInstalledTest { + private static HotwireBridge.StoreHit hit(String slug, String title, boolean installed) { + return new HotwireBridge.StoreHit("id", slug, title, "", "", 0, null, installed); + } + + private static boolean installed(HotwireBridge.StoreHit hit, ModInfo... mods) { + return HotwireBridge.INSTANCE.isInstalled(hit, List.of(mods)); + } + + @Test + void hotwiresOwnAnswerCounts() { + assertTrue(installed(hit("anything", "Anything", true))); + } + + @Test + void aSlugMatchingAModIdCounts() { + assertTrue(installed(hit("fabric-api", "Fabric API", false), new ModInfo("fabric-api", "Fabric API", "1", null, null))); + } + + @Test + void aTitleMatchingAModNameCounts() { + assertTrue(installed(hit("yacl", "yetanotherconfiglib", false), new ModInfo("yet_another_config_lib_v3", "YetAnotherConfigLib", "1", null, null))); + } + + @Test + void anUnrelatedProjectStaysListed() { + assertFalse(installed(hit("sodium", "Sodium", false), new ModInfo("fabric-api", "Fabric API", "1", null, null))); + } +} diff --git a/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/HotwireStorePlanTest.java b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/HotwireStorePlanTest.java new file mode 100644 index 000000000..649ffacef --- /dev/null +++ b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/HotwireStorePlanTest.java @@ -0,0 +1,63 @@ +package org.polyfrost.oneconfig.internal.ui.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import dev.moon.hotwire.api.HotwireApi; +import dev.moon.hotwire.api.StoreAdd; +import dev.moon.hotwire.api.SwitchResult; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class HotwireStorePlanTest { + private static List actions(boolean canAdd) { + HotwireApi.storeAdd = new StoreAdd() { + @Override + public String getTitle() { + return "Add"; + } + + @Override + public List getLines() { + return List.of(); + } + + @Override + public boolean needsReopen() { + return false; + } + + @Override + public boolean canAdd() { + return canAdd; + } + + @Override + public CompletableFuture accept() { + throw new AssertionError("nothing accepts this plan"); + } + + @Override + public void cancel() { + } + }; + HotwireBridge.StoreHit hit = new HotwireBridge.StoreHit("project", "slug", "Mod", "", "", 0, null, false); + return HotwireBridge.INSTANCE.storePlan(hit).getActions().stream().map(HotwireBridge.JarAction::getLabel).toList(); + } + + @AfterEach + void clear() { + HotwireApi.storeAdd = null; + } + + @Test + void anAddHotwireAllowsCanWaitForTheNextLaunch() { + assertEquals(List.of("Add now", "Add at next launch", "Save to Downloads"), actions(true)); + } + + @Test + void anAddHotwireRefusedCanOnlyBeSaved() { + assertEquals(List.of("Save to Downloads"), actions(false)); + } +} diff --git a/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/HotwireSwitchTest.java b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/HotwireSwitchTest.java new file mode 100644 index 000000000..a931fd13d --- /dev/null +++ b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/HotwireSwitchTest.java @@ -0,0 +1,43 @@ +package org.polyfrost.oneconfig.internal.ui.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import dev.moon.hotwire.api.HotwireApi; +import java.lang.reflect.Method; +import java.util.Collection; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.polyfrost.oneconfig.api.config.v1.CompatSnapshots; +import org.polyfrost.oneconfig.api.config.v1.ConfigManager; +import org.polyfrost.oneconfig.api.config.v1.Properties; +import org.polyfrost.oneconfig.api.config.v1.Property; +import org.polyfrost.oneconfig.api.config.v1.Tree; + +class HotwireSwitchTest { + private static final String ID = "hotwire-switch-test"; + + @AfterEach + void clear() { + HotwireApi.CONTROLS.clear(); + CompatSnapshots.untrack(ID); + ConfigManager.active().unregister(ID); + } + + @Test + void aModWritesItsUnsavedChangesBeforeItIsTurnedOff() throws ReflectiveOperationException { + Property property = Properties.simple("enabled", "Enabled", "", true); + Tree tree = Tree.tree(ID).put(property); + tree.addMetadata("custom_save", (Runnable) () -> HotwireApi.CONTROLS.add("save")); + CompatSnapshots.register(tree); + HotwireApi.CONTROLS.clear(); + // the snapshot of an earlier run can already have set the other value + property.setAs(!property.get()); + + // "switch" can't be called by name from Java + Method turn = HotwireBridge.class.getMethod("switch", Collection.class, boolean.class); + turn.invoke(HotwireBridge.INSTANCE, List.of("mod"), false); + + assertEquals(List.of("save", "off [mod]"), HotwireApi.CONTROLS); + } +} diff --git a/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/LoadedModsRefreshTest.java b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/LoadedModsRefreshTest.java new file mode 100644 index 000000000..7b4b6796c --- /dev/null +++ b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/LoadedModsRefreshTest.java @@ -0,0 +1,94 @@ +package org.polyfrost.oneconfig.internal.ui.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.polyfrost.oneconfig.api.config.v1.Tree; +import org.polyfrost.oneconfig.api.platform.v1.ModInfo; +import org.polyfrost.oneconfig.internal.ui.TestCompatibilityPlatform; + +class LoadedModsRefreshTest { + private static ModInfo mod(String id, String version) { + return new ModInfo(id, id, version, null, null); + } + + @AfterEach + void clearMods() { + TestCompatibilityPlatform.mods = Set.of(); + } + + @Test + void modsThatLeftTheLoaderAreSeenButNotLoaded() { + TestCompatibilityPlatform.mods = Set.of(mod("kept-off", "1"), mod("updated", "1")); + ModInfo.refreshLoadedMods(); + + TestCompatibilityPlatform.mods = Set.of(mod("updated", "2"), mod("added", "1")); + ModInfo.refreshLoadedMods(); + + Map loaded = ModInfo.getLoadedMods().stream().collect(Collectors.toMap(ModInfo::getId, ModInfo::getVersion)); + assertFalse(loaded.containsKey("kept-off")); + assertEquals("2", loaded.get("updated")); + assertEquals("1", loaded.get("added")); + + Map seen = ModInfo.getSeenMods().stream().collect(Collectors.toMap(ModInfo::getId, ModInfo::getVersion)); + assertEquals("1", seen.get("kept-off")); + assertEquals("2", seen.get("updated")); + assertEquals("1", seen.get("added")); + } + + @Test + void aSlowRefreshCannotStoreAnOlderList() throws InterruptedException { + TestCompatibilityPlatform.mods = Set.of(mod("raced", "1")); + ModInfo.refreshLoadedMods(); + + TestCompatibilityPlatform.mods = Set.of(mod("raced", "2")); + CountDownLatch reading = new CountDownLatch(1); + TestCompatibilityPlatform.afterRead = () -> { + reading.countDown(); + try { + new CountDownLatch(1).await(200, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + throw new IllegalStateException(e); + } + }; + Thread slow = new Thread(ModInfo::refreshLoadedMods); + slow.start(); + assertTrue(reading.await(5, TimeUnit.SECONDS)); + + TestCompatibilityPlatform.afterRead = () -> { + }; + TestCompatibilityPlatform.mods = Set.of(mod("raced", "3")); + Thread fast = new Thread(ModInfo::refreshLoadedMods); + fast.start(); + slow.join(); + fast.join(); + + assertEquals("3", ModInfo.getLoadedMods().iterator().next().getVersion()); + } + + @Test + void configDataFollowsAnUpdateAndKeepsItsLookupOtherwise() { + TestCompatibilityPlatform.mods = Set.of(mod("card", "1")); + ModInfo.refreshLoadedMods(); + TreeConfigData data = new TreeConfigData(Tree.tree("card"), ConfigSource.OC, null); + assertEquals("1", data.getVersion()); + + Set before = ModInfo.getLoadedMods(); + TestCompatibilityPlatform.mods = Set.of(mod("card", "1")); + ModInfo.refreshLoadedMods(); + assertSame(before, ModInfo.getLoadedMods()); + + TestCompatibilityPlatform.mods = Set.of(mod("card", "2")); + ModInfo.refreshLoadedMods(); + assertEquals("2", data.getVersion()); + } +} diff --git a/modules/internal/src/test/resources/META-INF/services/org.polyfrost.oneconfig.api.platform.v1.I18nPlatform b/modules/internal/src/test/resources/META-INF/services/org.polyfrost.oneconfig.api.platform.v1.I18nPlatform new file mode 100644 index 000000000..e83a31e1d --- /dev/null +++ b/modules/internal/src/test/resources/META-INF/services/org.polyfrost.oneconfig.api.platform.v1.I18nPlatform @@ -0,0 +1 @@ +org.polyfrost.oneconfig.internal.ui.TestI18nPlatform diff --git a/modules/ui/src/main/resources/assets/oneconfig/ico/grid.svg b/modules/ui/src/main/resources/assets/oneconfig/ico/grid.svg new file mode 100644 index 000000000..47ad580a2 --- /dev/null +++ b/modules/ui/src/main/resources/assets/oneconfig/ico/grid.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/modules/ui/src/main/resources/assets/oneconfig/ico/list.svg b/modules/ui/src/main/resources/assets/oneconfig/ico/list.svg new file mode 100644 index 000000000..6dc432e16 --- /dev/null +++ b/modules/ui/src/main/resources/assets/oneconfig/ico/list.svg @@ -0,0 +1,4 @@ + + + + diff --git a/modules/utils/api/utils.api b/modules/utils/api/utils.api index 78b537418..eb804aa8e 100644 --- a/modules/utils/api/utils.api +++ b/modules/utils/api/utils.api @@ -121,6 +121,7 @@ public final class org/polyfrost/oneconfig/api/platform/v1/ModInfo { public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/nio/file/Path;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/nio/file/Path;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/nio/file/Path;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/nio/file/Path;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ljava/lang/String; @@ -141,13 +142,18 @@ public final class org/polyfrost/oneconfig/api/platform/v1/ModInfo { public static final fun getLoadedMods ()Ljava/util/Set; public final fun getModIconPath ()Ljava/lang/String; public final fun getName ()Ljava/lang/String; + public final fun getParentId ()Ljava/lang/String; + public static final fun getSeenMods ()Ljava/util/Set; public final fun getVersion ()Ljava/lang/String; public fun hashCode ()I + public static final fun refreshLoadedMods ()V public fun toString ()Ljava/lang/String; } public final class org/polyfrost/oneconfig/api/platform/v1/ModInfo$Companion { public final fun getLoadedMods ()Ljava/util/Set; + public final fun getSeenMods ()Ljava/util/Set; + public final fun refreshLoadedMods ()V } public abstract interface class org/polyfrost/oneconfig/api/platform/v1/ModInfoProvider { diff --git a/modules/utils/src/main/kotlin/org/polyfrost/oneconfig/api/platform/v1/ModInfo.kt b/modules/utils/src/main/kotlin/org/polyfrost/oneconfig/api/platform/v1/ModInfo.kt index fc276dbcd..be3cb4efb 100644 --- a/modules/utils/src/main/kotlin/org/polyfrost/oneconfig/api/platform/v1/ModInfo.kt +++ b/modules/utils/src/main/kotlin/org/polyfrost/oneconfig/api/platform/v1/ModInfo.kt @@ -14,6 +14,24 @@ data class ModInfo @JvmOverloads constructor( val credits: String? = null, val description: String? = null, ){ + /** Id of the mod whose jar this one is nested in, or null for a top-level mod; [copy] does not carry it over */ + var parentId: String? = null + private set + + constructor( + id: String, + name: String, + version: String, + file: Path?, + modIconPath: String?, + authors: String?, + credits: String?, + description: String?, + parentId: String?, + ) : this(id, name, version, file, modIconPath, authors, credits, description) { + this.parentId = parentId + } + /** * Resolves [modIconPath] against this mod's own jar root ([file]) * @@ -47,9 +65,28 @@ data class ModInfo @JvmOverloads constructor( @Volatile private var cached: Set? = null + @Volatile + private var seen: Map = emptyMap() + @get:JvmStatic val loadedMods: Set - get() = cached ?: Platform.compatibility().mods.also { if (it.isNotEmpty()) cached = it } + get() = cached ?: synchronized(this) { + cached ?: Platform.compatibility().mods.also { if (it.isNotEmpty()) cached = it } + } + + @get:JvmStatic + val seenMods: Set + get() = (seen + loadedMods.associateBy { it.id }).values.toSet() + + @JvmStatic + fun refreshLoadedMods() { + synchronized(this) { + val live = Platform.compatibility().mods + if (live.isEmpty() || live == cached) return + seen = seen + loadedMods.associateBy { it.id } + cached = live + } + } } }