From cfd5c332d00e96ce2ceba0107696d9116481f1bc Mon Sep 17 00:00:00 2001 From: Tektonikal Date: Thu, 29 Oct 2026 22:45:57 -0400 Subject: [PATCH 01/63] initial design --- .../internal/CompatibilityPlatformImpl.java | 27 +- .../assets/oneconfig/lang/en_us.json | 36 ++ .../internal/ui/api/HotwireBridge.kt | 280 ++++++++++++ .../internal/ui/components/Header.kt | 72 ++- .../internal/ui/navigation/Navigation.kt | 2 + .../oneconfig/internal/ui/navigation/Pages.kt | 8 +- .../internal/ui/navigation/graph/Hotwire.kt | 28 ++ .../oneconfig/internal/ui/screens/Hotwire.kt | 426 ++++++++++++++++++ .../internal/ui/screens/HotwireAdd.kt | 375 +++++++++++++++ .../internal/ui/screens/ModSortControl.kt | 50 +- .../resources/assets/oneconfig/ico/grid.svg | 6 + .../resources/assets/oneconfig/ico/list.svg | 4 + modules/utils/api/utils.api | 9 +- .../oneconfig/api/platform/v1/ModInfo.kt | 2 + 14 files changed, 1285 insertions(+), 40 deletions(-) create mode 100644 modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireBridge.kt create mode 100644 modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/navigation/graph/Hotwire.kt create mode 100644 modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Hotwire.kt create mode 100644 modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/HotwireAdd.kt create mode 100644 modules/ui/src/main/resources/assets/oneconfig/ico/grid.svg create mode 100644 modules/ui/src/main/resources/assets/oneconfig/ico/list.svg 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..efa9fd5b8 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 @@ -3,6 +3,8 @@ import java.util.Set; import java.util.stream.Collectors; import net.fabricmc.loader.api.FabricLoader; +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; @@ -61,7 +63,8 @@ public Set getMods() { .map(person -> person.getName()) .filter(name -> !name.isBlank()) .collect(Collectors.joining(", ")), - metadata.getDescription() + metadata.getDescription(), + mod.getContainingMod().map(parent -> parent.getMetadata().getId()).orElseGet(() -> modMenuParent(metadata)) ); }).collect(Collectors.toSet()); //? } @@ -70,6 +73,28 @@ public Set getMods() { //? } } + //? 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/resources/assets/oneconfig/lang/en_us.json b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json index a11cfb981..b09cf2853 100644 --- a/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json +++ b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json @@ -229,6 +229,42 @@ "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.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.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.update": "Update", + "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.modrinth.installed_failed": "Couldn't check which mods are installed, so all results are shown: %s", + "oneconfig.page.hotwire.add.reading": "Reading the jar…", + "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.now": "Update now", + "oneconfig.page.hotwire.update.next_start": "Update at next launch", + "oneconfig.page.hotwire.update.same": "This version is already installed.", + "oneconfig.page.hotwire.update.unavailable": "Hotwire can't update mods in this game session.", + "oneconfig.page.hotwire.update.restart_only": "Hotwire can't swap this mod while the game runs, so it updates at the next launch.", + "oneconfig.page.hotwire.unreadable": "Hotwire can't read this jar.", "oneconfig.page.themes.title": "Themes", "oneconfig.page.themes.search": "Search Themes...", "oneconfig.page.preferences.title": "Preferences", 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..36f9376d3 --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireBridge.kt @@ -0,0 +1,280 @@ +package org.polyfrost.oneconfig.internal.ui.api + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.setValue +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit +import java.util.function.Consumer +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) + + class Preview(val possible: Boolean, val message: String) + + /** [needsReopen] means nothing failed but the change only finishes once the world is reopened, which Hotwire's API does not do itself */ + class Result(val ok: Boolean, val message: String, val needsReopen: Boolean = false) + + /** Bumped whenever Hotwire finishes a switch, whoever started it */ + var revision by mutableIntStateOf(0) + private set + + 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 { revision++ } + }) + } + }.getOrNull() + } + + private fun call(name: String, vararg args: Any): Any? { + val api = api ?: return null + val types = args.map { if (it is Collection<*>) Collection::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) + + /** 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, + ) + } + }.getOrNull() + + 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 */ + fun preview(ids: Collection, on: Boolean): Preview = runCatching { + val preview = call(if (on) "previewOn" else "previewOff", ids.toList())!! + Preview(preview.get("possible") as Boolean, preview.get("message") as String? ?: "") + }.getOrElse { Preview(false, "Hotwire is not available.") } + + /** Whether Hotwire is running a switch, add or update right now */ + fun isWorking(): Boolean = runCatching { call("getProgress")!!.get("running") as Boolean }.getOrDefault(false) + + class JarAction(val label: String, val primary: Boolean, val run: () -> Unit) + + /** What Hotwire would do with a picked jar, 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) + + // shortcut: Hotwire's public API has no add or update calls, so the jar flow reaches into its engine and may + // break on a Hotwire update, until Hotwire's API gains them + private fun engine(): Any? = runCatching { + Class.forName("dev.moon.hotwire.fabric.HotwireClient").getMethod("findEngine").invoke(null) + }.getOrNull() + + private fun Any.callMethod(name: String, vararg args: Any): Any? = + javaClass.methods.first { it.name == name && it.parameterCount == args.size }.invoke(this, *args) + + /** The constant named [constant] of the enum a two-argument [method] of the engine takes last */ + private fun Any.enumArg(method: String, constant: String): Any = + javaClass.methods.first { it.name == method && it.parameterCount == 2 }.parameterTypes[1] + .enumConstants.first { (it as Enum<*>).name == constant } + + class StoreHit( + val projectId: String, + val slug: String, + val title: String, + val author: String, + val description: String, + val downloads: Long, + val iconUrl: String?, + ) + + class StoreUpdate(internal val offer: Any, val modId: String, val name: String, val installedVersion: String, val newVersion: String) + + /** Which Modrinth projects are installed, and which of those have a newer version for this game */ + class InstalledProjects(private val model: Any, val updatable: Set) { + // the same three checks Hotwire's own store cards make: a jar Modrinth knows by hash, or a loaded mod whose + // id is the project's slug or id, which catches jars Modrinth doesn't know such as dev builds + fun isInstalled(hit: StoreHit): Boolean = + listOf(hit.projectId, hit.slug).any { it.isNotBlank() && model.callMethod("isInstalledProject", it) == true } + } + + /** Hotwire's Modrinth store, created the way Hotwire's own store screen does when it hasn't been opened yet */ + private fun store(): Any { + val engine = engine()!! + engine.callMethod("getStore")?.let { return it } + val screens = engine.javaClass.getDeclaredField("stores").apply { isAccessible = true }.get(engine) + return screens.javaClass.getDeclaredMethod("findStore").apply { isAccessible = true }.invoke(screens)!! + } + + /** Asks Hotwire which Modrinth projects are installed and which have updates; blocks on the network */ + fun installedProjects(): InstalledProjects { + val store = store() + val model = store.callMethod("getModel")!! + store.callMethod("checkInstalled") + // the store's worker is a single thread, so this runs once the check queued just before it has finished + val checked = CompletableFuture() + model.callMethod("run", Runnable { checked.complete(Unit) }) + checked.get(60, TimeUnit.SECONDS) + return InstalledProjects(model, (model.callMethod("getUpdates") as Map<*, *>).keys.mapTo(HashSet()) { it as 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)) Files.write(file, modrinth!!.callMethod("fetchFromCdn", url) as ByteArray) + return file + } + + /** Hotwire's Modrinth client, already limited to this game version and loader */ + private val modrinth: Any? by lazy { + runCatching { Class.forName("dev.moon.hotwire.fabric.store.ModStore").getMethod("makeClient").invoke(null) }.getOrNull() + } + + /** Searches Modrinth for mods matching [query]; blocks on the network, so call it off the UI thread */ + fun searchModrinth(query: String): List { + val search = modrinth!!.callMethod("search", query, "", 0, 30)!! + return (search.get("hits") as List<*>).map { + StoreHit( + it!!.get("projectId") as String, + it.get("slug") as String? ?: "", + it.get("title") 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() }, + ) + } + } + + /** + * Downloads the newest version of [projectId] that fits this game and returns the checked jar, ready for + * [planFor]; blocks on the network + */ + fun downloadLatest(projectId: String): Path { + val client = modrinth!! + val version = (client.callMethod("findVersions", projectId) as List<*>).firstOrNull() + ?: error("No version of this mod fits this game.") + val bytes = client.callMethod("fetch", version) as ByteArray + // the download is untrusted until it matches the hash Modrinth published for it + check(client.javaClass.getMethod("hashOf", ByteArray::class.java).invoke(null, bytes) == version.get("sha512")) { + "The download didn't match Modrinth's checksum." + } + val dir = Files.createDirectories(Path.of(System.getProperty("java.io.tmpdir"), "oneconfig-hotwire")) + return Files.write(dir.resolve(Path.of(version.get("fileName") as String).fileName), bytes) + } + + /** Hotwire's update check against Modrinth for the installed mods; blocks on the network */ + fun findUpdates(): List { + val check = engine()!!.callMethod("getUpdateCheck")!! + return (check.callMethod("findNow") as List<*>).map { + StoreUpdate(it!!, it.get("id") as String, it.get("name") as String, it.get("installedVersion") as String, it.get("newVersion") as String) + } + } + + /** Downloads an offered update the way Hotwire does, returning the jar for [planFor]; blocks on the network */ + fun downloadUpdate(update: StoreUpdate): Path = + engine()!!.callMethod("getUpdateCheck")!!.callMethod("fetch", update.offer) as Path + + /** + * Reads [jar] the way Hotwire does before adding or updating + * + * An [update] is read as one straight away; otherwise it is one when Hotwire says that mod is already installed + */ + fun planFor(jar: Path, update: Boolean = false): CompletableFuture = runCatching { + val engine = engine()!! + // with no class access Hotwire's update dry run has nothing to run on, so say so instead of failing inside it + if (update && engine.callMethod("canUpdate") != true) { + return CompletableFuture.completedFuture( + JarPlan( + jar.fileName.toString(), + "", + listOf(translated("oneconfig.page.hotwire.update.unavailable", "Hotwire can't update mods in this game session.")), + emptyList(), + ), + ) + } + @Suppress("UNCHECKED_CAST") + if (update || engine.callMethod("canUpdate", jar) == true) { + (engine.callMethod("dryRunUpdate", jar) as CompletableFuture).thenApply { updatePlan(engine, jar, it) } + } else { + (engine.callMethod("dryRunAdd", jar) as CompletableFuture).thenApply { addPlan(engine, jar, it) } + } + }.getOrElse { CompletableFuture.completedFuture(JarPlan(jar.fileName.toString(), "", listOf("Hotwire is not available."), emptyList())) } + + @Suppress("UNCHECKED_CAST") + private fun addPlan(engine: Any, jar: Path, preview: Any): JarPlan { + fun add(mode: String) = { engine.callMethod("add", jar, engine.enumArg("add", mode)); Unit } + val later = JarAction(translated("oneconfig.page.hotwire.add.next_start", "Add at next launch"), false, add("NEXT_START")) + val actions = when ((preview.get("mode") as Enum<*>).name) { + "NOW" -> listOf(JarAction(translated("oneconfig.page.hotwire.add.now", "Add now"), true, add("NOW")), later) + "SAVE_AND_REOPEN" -> listOf(JarAction(translated("oneconfig.page.hotwire.add.reopen", "Save and add"), true, add("SAVE_AND_REOPEN")), later) + "NEXT_START" -> listOf(JarAction(later.label, true, later.run)) + else -> emptyList() + } + return JarPlan(preview.get("name") as String, preview.get("version") as String? ?: "", preview.get("lines") as List, actions) + } + + @Suppress("UNCHECKED_CAST") + private fun updatePlan(engine: Any, jar: Path, preview: Any): JarPlan { + fun update(method: String) = { engine.callMethod("update", jar, engine.enumArg("update", method)); Unit } + val later = JarAction(translated("oneconfig.page.hotwire.update.next_start", "Update at next launch"), false, update("NEXT_START")) + return when (preview.javaClass.simpleName) { + "Ready" -> { + // Hotwire's own panel only offers a live swap when its preview allows one; the lines say what blocks it + val inPlace = (preview.get("method") as Enum<*>).name == "IN_PLACE" + val lines = preview.get("lines") as List + JarPlan( + preview.get("name") as String, + "${preview.get("installedVersion")} → ${preview.get("newVersion")}", + if (inPlace) { + lines + } else { + listOf(translated("oneconfig.page.hotwire.update.restart_only", "Hotwire can't swap this mod while the game runs, so it updates at the next launch.")) + lines + }, + if (inPlace) { + listOf(JarAction(translated("oneconfig.page.hotwire.update.now", "Update now"), true, update("IN_PLACE")), later) + } else { + listOf(JarAction(later.label, true, later.run)) + }, + ) + } + "Refused" -> JarPlan(preview.get("name") as String, "", listOf(preview.get("reason") as String), emptyList()) + "SameVersion" -> JarPlan( + preview.get("name") as String, + preview.get("version") as String, + listOf(translated("oneconfig.page.hotwire.update.same", "This version is already installed.")), + emptyList(), + ) + else -> JarPlan(jar.fileName.toString(), "", listOf(translated("oneconfig.page.hotwire.unreadable", "Hotwire can't read this jar.")), emptyList()) + } + } + + /** Turns [ids] on or off, completing on whatever thread Hotwire finishes on */ + fun switch(ids: Collection, on: Boolean): CompletableFuture = runCatching { + @Suppress("UNCHECKED_CAST") + (call(if (on) "turnOn" else "turnOff", ids.toList()) as CompletableFuture).thenApply { result -> + val kind = (result.get("kind") as Enum<*>).name + Result(kind == "DONE", result.get("message") as String? ?: "", needsReopen = kind == "NEEDS_REOPEN") + } + }.getOrElse { CompletableFuture.completedFuture(Result(false, "Hotwire is not available.")) } +} 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 417754fbf..9a4948efe 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 @@ -48,6 +48,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 @@ -63,7 +64,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.addHotwireMods +import org.polyfrost.oneconfig.internal.ui.screens.hotwireTagline import org.polyfrost.oneconfig.internal.ui.navigation.searchPlaceholder import org.polyfrost.oneconfig.internal.ui.shell.LocalShell import org.polyfrost.oneconfig.internal.ui.shell.LocalShellNavController @@ -90,6 +96,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( @@ -120,6 +128,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, + ) + } } } } @@ -159,6 +177,7 @@ fun Header(modifier: Modifier = Modifier) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(26.dp) ) { + title?.addMods?.let { HeaderButton("plus", translated("oneconfig.page.hotwire.add_mods", "Add mods"), it) } GlobalSearchBar() IconButton("close", Modifier.size(32.dp), hoverBorder = true) { closeRequest() } } @@ -175,6 +194,8 @@ private data class HeaderTitle( val credits: String? = null, val version: String? = null, val openOriginalScreen: Runnable? = null, + val tagline: String? = null, + val addMods: Runnable? = null, ) private fun NavBackStackEntry?.headerTitle(): HeaderTitle? { @@ -194,16 +215,19 @@ private fun NavBackStackEntry?.headerTitle(): HeaderTitle? { ) } + if (destination.hasRoute(HotwireAddRoute::class)) { + return HeaderTitle(translated("oneconfig.page.hotwire.add_mods", "Add mods"), tagline = hotwireTagline()) + } + if (entry.isOn(HotwireGraph)) { + return HeaderTitle(entry.currentPageTitle() ?: "Hotwire", tagline = hotwireTagline(), addMods = addHotwireMods) + } + return entry.currentPageTitle()?.let { HeaderTitle(it) } } @Composable private fun OpenOriginalScreenButton(runnable: Runnable) { val theme = LocalTheme.current - val interactionSource = rememberInteractionSource() - val isHovered by interactionSource.collectIsHoveredAsState() - val accentAlpha by animateFloatAsState(if (isHovered) 0.75f else 1f) - Tooltip( text = { Text( @@ -215,22 +239,32 @@ private fun OpenOriginalScreenButton(runnable: Runnable) { modifier = Modifier.widthIn(max = 260.dp), anchor = Alignment.BottomCenter, ) { - Box( - modifier = Modifier - .pointerHoverIcon(PointerIcon.Hand) - .background(AccentBrush, theme.buttonShape, accentAlpha) - .onClick(interactionSource) { runnable.run() } - .border(1.dp, LocalTheme.current.borderColor, LocalTheme.current.buttonShape) - .padding(horizontal = 16.dp, vertical = 6.dp), - contentAlignment = Alignment.Center, + HeaderButton("external-link", translated("oneconfig.header.original_menu", "Original menu"), runnable) + } +} + +@Composable +private fun HeaderButton(icon: String, label: String, runnable: Runnable) { + val theme = LocalTheme.current + val interactionSource = rememberInteractionSource() + val isHovered by interactionSource.collectIsHoveredAsState() + val accentAlpha by animateFloatAsState(if (isHovered) 0.75f else 1f) + + Box( + modifier = Modifier + .pointerHoverIcon(PointerIcon.Hand) + .background(AccentBrush, theme.buttonShape, accentAlpha) + .onClick(interactionSource) { runnable.run() } + .border(1.dp, theme.borderColor, theme.buttonShape) + .padding(horizontal = 16.dp, vertical = 6.dp), + contentAlignment = Alignment.Center, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - Icon("external-link", color = theme.accentTextColor, modifier = Modifier.size(14.dp)) - Text(translated("oneconfig.header.original_menu", "Original menu"), color = theme.accentTextColor, fontSize = 13.sp) - } + Icon(icon, color = theme.accentTextColor, modifier = Modifier.size(14.dp)) + Text(label, color = theme.accentTextColor, fontSize = 13.sp) } } } 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..5efee4ecb 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,7 @@ 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.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 +29,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 +63,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() }, 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..de7760014 --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Hotwire.kt @@ -0,0 +1,426 @@ +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.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.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +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.mutableStateOf +import androidx.compose.runtime.mutableStateSetOf +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.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import java.util.concurrent.CompletableFuture +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.Dispatchers +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.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 + +/** 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, 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() + + private var checked = false + + /** Checks on first use only */ + suspend fun ensure() { + if (!checked) refresh() + } + + suspend fun refresh() { + checked = true + apply(withContext(Dispatchers.IO) { runCatching { HotwireBridge.findUpdates() } }) + } + + /** + * 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 + * + * Runs on its own thread so leaving the page doesn't cancel it + */ + fun refreshWhenDone() { + checked = true + Thread({ + val start = System.nanoTime() + var sawWorking = false + while (true) { + Thread.sleep(100) + val working = HotwireBridge.isWorking() + if (working) sawWorking = true + if (sawWorking && !working || !sawWorking && System.nanoTime() - start > 3_000_000_000L) break + } + val result = runCatching { HotwireBridge.findUpdates() } + Platform.screen().runOnUiThread { apply(result) } + }, "OneConfig Hotwire update check").apply { isDaemon = true }.start() + } + + 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 id kept across scrolling so each jar is only read once */ +private val iconPaths = HashMap() + +/** + * Every mod seen this session by id, starting from the startup list + * + * Hotwire takes a mod off the loader's list when it turns it off, so this keeps its icon and parent for the grid + */ +private val seenMods by lazy { ModInfo.loadedMods.associateByTo(HashMap()) { it.id } } + +/** + * 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 + */ +private fun gridMods(states: Map?): Collection { + // read past ModInfo's cache, since Hotwire can add mods after it was filled + val live = Platform.compatibility().mods + live.forEach { seenMods[it.id] = it } + val liveIds = live.mapTo(HashSet()) { it.id } + val known = seenMods.values.filter { it.id in liveIds || states?.containsKey(it.id) == true } + val unseen = states.orEmpty().values + .filter { it.id !in seenMods } + .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(" ") +} + +/** The header's "Add mods" action on the Hotwire page */ +val addHotwireMods = Runnable { ShellController.shared.navigation.navigate(HotwireAddRoute) } + +/** + * 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() { + val revision = HotwireBridge.revision + val states = remember(revision) { HotwireBridge.mods()?.associateBy { it.id } } + LaunchedEffect(Unit) { HotwireUpdates.ensure() } + val availableUpdates = HotwireUpdates.modIds + val families = remember(states) { + // mods Hotwire refuses to switch are left out, unless Hotwire is not there to ask + modFamilies(gridMods(states)).filterKeys { states == null || states[it.id]?.switchable == true } + } + val query = ShellState.searchQuery.trim() + val mods = remember(families, query) { + families + .filter { (_, members) -> + query.isEmpty() || members.any { it.name.contains(query, true) || it.id.contains(query, true) } + } + .keys + .sortedBy { it.name.lowercase() } + } + // picked families by their root id + val selected = remember { mutableStateSetOf() } + val gridState = rememberRestorableLazyGridState("hotwire") + + Column(verticalArrangement = Arrangement.spacedBy(19.dp)) { + Row(Modifier.fillMaxWidth().padding(horizontal = PageGutter), horizontalArrangement = Arrangement.End) { + 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 } + } + ScrollbarBox(gridState, PageGutter, Modifier.weight(1f).fillMaxWidth()) { + 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), + contentPadding = PaddingValues(horizontal = PageGutter), + modifier = Modifier.fillMaxSize(), + ) { + items(mods, key = { it.id }) { mod -> + HotwireTile( + mod, + families.getValue(mod), + on = states?.get(mod.id)?.on, + 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) }, + ) + } + } + } + val picked = families.filterKeys { it.id in selected } + if (picked.isNotEmpty()) SwitchBar(picked, states, onClear = { selected.clear() }) + } +} + +/** + * 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 } + } +} + +/** 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?, + 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 idsWhere(on: Boolean) = picked + .filterKeys { states?.get(it.id)?.on == on } + .values.flatten() + .filter { states?.get(it.id)?.switchable == true } + .map { it.id } + val turnOff = remember(picked, states) { idsWhere(true) } + val turnOn = remember(picked, states) { idsWhere(false) } + val previews = remember(turnOff, turnOn) { + listOfNotNull( + turnOff.takeIf { it.isNotEmpty() }?.let { HotwireBridge.preview(it, false) }, + turnOn.takeIf { it.isNotEmpty() }?.let { HotwireBridge.preview(it, true) }, + ) + } + var running by remember { mutableStateOf(false) } + var fraction by remember { mutableFloatStateOf(0f) } + LaunchedEffect(running) { + while (running) withFrameNanos { fraction = HotwireBridge.progress() } + } + + val offCount = picked.keys.count { states?.get(it.id)?.on == true } + val onCount = picked.keys.count { states?.get(it.id)?.on == false } + val label = when { + offCount > 0 && onCount > 0 -> "Switch ${offCount + onCount}" + offCount > 0 -> "Turn off $offCount" + else -> "Turn on $onCount" + } + val message = when { + states == null -> "Hotwire isn't installed, so mods can't be switched here." + running -> "Switching… ${(fraction * 100).toInt()}%" + else -> previews.joinToString(" ") { it.message } + } + val canSwitch = !running && previews.isNotEmpty() && previews.all { it.possible } && !HotwireBridge.isSwitching + + fun start() { + running = 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, + ) + } + }.whenComplete { result, error -> + Platform.screen().runOnUiThread { + running = false + if (result?.ok == true) { + Notifications.success("Hotwire", result.message.ifBlank { "Done." }) + onClear() + } else if (result?.needsReopen == true) { + Notifications.info("Hotwire", result.message.ifBlank { "Reopen your world to finish this." }) + } else { + Notifications.error("Hotwire", result?.message?.ifBlank { null } ?: error?.message ?: "That didn't work.") + } + } + } + } + + Row( + modifier = Modifier.padding(horizontal = PageGutter).fillMaxWidth() + .background(theme.modCardBackground, theme.modCardShape) + .border(1.dp, theme.borderColor, theme.modCardShape) + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("${picked.size} selected", color = theme.textColor, fontSize = 14.sp) + if (message.isNotBlank()) { + Text(message, color = theme.textColorSecondary, fontSize = 12.sp, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + if (running) { + Box(Modifier.fillMaxWidth().height(3.dp).background(theme.borderColor, theme.modCardShape)) { + Box(Modifier.fillMaxWidth(fraction.coerceIn(0f, 1f)).fillMaxHeight().background(Accent, theme.modCardShape)) + } + } + } + Chip("Clear", selected = false, onClick = onClear) + if (canSwitch) Chip(label, selected = true, onClick = ::start) + } +} + +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 = remember(mod.id) { iconPaths.getOrPut(mod.id) { mod.extractIconFile() } } + // 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), + ) { + // keeps names lined up when a mod has no icon + Box(Modifier.size(36.dp)) { + if (icon != null) Icon(icon, 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, + ) { + if (icon != null) Icon(icon, 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(), + ) + } + 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..d52dc204f --- /dev/null +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/HotwireAdd.kt @@ -0,0 +1,375 @@ +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.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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +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.Accent +import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme + +private enum class AddSource { File, Modrinth, Updates } + +/** + * 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.Updates) } + 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) } + + fun leavePreview() { + request++ + reading = false + plan = null + } + + /** Gets a jar and its icon off the UI thread, then asks Hotwire what it would do with it */ + fun review(name: String, icon: () -> String?, update: Boolean = false, jar: () -> Path?) { + leavePreview() + val id = request + reading = true + planIcon = null + scope.launch { + val (iconPath, picked) = withContext(Dispatchers.IO) { runCatching(icon).getOrNull() to runCatching(jar) } + if (id != request) return@launch + planIcon = iconPath + val path = picked.getOrNull() + if (path == null) { + reading = false + picked.exceptionOrNull()?.let { plan = HotwireBridge.JarPlan(name, "", listOf(it.message ?: it.toString()), emptyList()) } + return@launch + } + HotwireBridge.planFor(path, update).whenComplete { result, _ -> + Platform.screen().runOnUiThread { + if (id != request) return@runOnUiThread + reading = false + plan = result ?: HotwireBridge.JarPlan(name, "", listOf("Hotwire couldn't read this jar."), emptyList()) + } + } + } + } + + fun show(next: AddSource) { + leavePreview() + source = next + } + + Column(verticalArrangement = Arrangement.spacedBy(19.dp)) { + Row(Modifier.padding(horizontal = PageGutter), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Chip(translated("oneconfig.page.hotwire.source.updates", "Updates"), source == AddSource.Updates, "refresh") { show(AddSource.Updates) } + Chip(translated("oneconfig.page.hotwire.source.modrinth", "Modrinth"), source == AddSource.Modrinth, "search") { show(AddSource.Modrinth) } + Chip(translated("oneconfig.page.hotwire.source.file", "From file"), source == AddSource.File, "folder") { show(AddSource.File) } + } + if (reading || plan != null) { + PlanCard(reading, plan, planIcon, onBack = ::leavePreview) + } else { + when (source) { + AddSource.File -> FileSource { jar -> review("", { null }, jar = jar) } + AddSource.Modrinth -> ModrinthSource { hit, update -> + review(hit.title, { hit.iconUrl?.let { HotwireBridge.iconFor(it).toString() } }, update) { HotwireBridge.downloadLatest(hit.projectId) } + } + AddSource.Updates -> UpdatesSource { update -> + review(update.name, { installedIcon(update.modId) }, update = true) { HotwireBridge.downloadUpdate(update) } + } + } + } + } +} + +/** The icon of an installed mod, read out of its jar */ +private fun installedIcon(modId: String): String? = ModInfo.loadedMods.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"), + "Mod jars", + ) + } + } + } + } +} + +/** Modrinth projects matching the header search, or Modrinth's own listing while it is empty */ +@Composable +private fun ModrinthSource(onInstall: (HotwireBridge.StoreHit, Boolean) -> Unit) { + val theme = LocalTheme.current + val query = ShellState.searchQuery.trim() + var hits by remember { mutableStateOf>?>(null) } + LaunchedEffect(query) { + // waits out typing so each keystroke doesn't hit Modrinth + delay(350) + hits = withContext(Dispatchers.IO) { runCatching { HotwireBridge.searchModrinth(query) } } + } + // a failed check shows every hit rather than none + val installed by produceState?>(null) { + value = withContext(Dispatchers.IO) { runCatching { HotwireBridge.installedProjects() } } + } + + val current = hits + val known = installed + when { + current == null || known == null -> Card { Text(translated("oneconfig.page.hotwire.modrinth.loading", "Searching Modrinth…"), color = theme.textColorSecondary, fontSize = 14.sp) } + current.isFailure -> Card { + Text( + translated("oneconfig.page.hotwire.modrinth.failed", "Couldn't reach Modrinth: %s", current.exceptionOrNull()?.message ?: ""), + color = theme.textColorSecondary, + fontSize = 14.sp, + ) + } + else -> { + // installed projects only show when there's a newer version to update to; the value says which is which + val shown = remember(current, known) { + val projects = known.getOrNull() + current.getOrThrow().mapNotNull { hit -> + val installedHit = projects?.isInstalled(hit) == true + if (installedHit && hit.projectId !in projects.updatable) null else hit to installedHit + } + } + known.exceptionOrNull()?.let { + Text( + translated("oneconfig.page.hotwire.modrinth.installed_failed", "Couldn't check which mods are installed, so all results are shown: %s", it.message ?: it.toString()), + color = theme.textColorSecondary, + fontSize = 12.sp, + modifier = Modifier.padding(horizontal = PageGutter), + ) + } + ModrinthResults(shown, onInstall) + } + } +} + +@Composable +private fun ModrinthResults(shown: List>, onInstall: (HotwireBridge.StoreHit, Boolean) -> Unit) { + val theme = LocalTheme.current + ResultList(shown) { (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) + Chip( + if (update) translated("oneconfig.page.hotwire.modrinth.update", "Update") else translated("oneconfig.page.hotwire.modrinth.install", "Install"), + selected = true, + ) { onInstall(hit, update) } + } +} + +/** 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, row: @Composable androidx.compose.foundation.layout.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) } + } + } + } +} + +/** 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?, onBack: () -> Unit) { + val theme = LocalTheme.current + var started 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) { + Text(translated("oneconfig.page.hotwire.add.reading", "Reading the jar…"), color = theme.textColorSecondary, fontSize = 14.sp) + 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( + if (working) { + translated("oneconfig.page.hotwire.add.working", "Hotwire is working on it… %s%%", (fraction * 100).toInt()) + } else { + translated("oneconfig.page.hotwire.add.sent", "Sent to Hotwire. It posts the result in chat.") + }, + color = theme.textColorSecondary, + fontSize = 13.sp, + ) + if (working) { + Box(Modifier.fillMaxWidth().height(3.dp).background(theme.borderColor, theme.modCardShape)) { + Box(Modifier.fillMaxWidth(fraction.coerceIn(0f, 1f)).fillMaxHeight().background(Accent, theme.modCardShape)) + } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (!started) { + plan.actions.forEach { action -> + Chip(action.label, selected = action.primary) { + started = true + action.run() + 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 5dae92ea2..02c2f3a36 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 @@ -54,19 +54,12 @@ fun ModSortControl(modifier: Modifier = Modifier) { } Box(modifier.padding(end = PageGutter)) { - Row( - Modifier - .height(36.dp) - .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( @@ -97,12 +90,37 @@ 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) + .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, ) { @@ -117,7 +135,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/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 b186512c3..5f4835d9d 100644 --- a/modules/utils/api/utils.api +++ b/modules/utils/api/utils.api @@ -120,7 +120,8 @@ 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;)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;)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 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;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ljava/lang/String; @@ -129,8 +130,9 @@ public final class org/polyfrost/oneconfig/api/platform/v1/ModInfo { public final fun component6 ()Ljava/lang/String; public final fun component7 ()Ljava/lang/String; public final fun component8 ()Ljava/lang/String; - public final fun copy (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;)Lorg/polyfrost/oneconfig/api/platform/v1/ModInfo; - public static synthetic fun copy$default (Lorg/polyfrost/oneconfig/api/platform/v1/ModInfo;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;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/platform/v1/ModInfo; + public final fun component9 ()Ljava/lang/String; + public final fun copy (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;)Lorg/polyfrost/oneconfig/api/platform/v1/ModInfo; + public static synthetic fun copy$default (Lorg/polyfrost/oneconfig/api/platform/v1/ModInfo;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;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/platform/v1/ModInfo; public fun equals (Ljava/lang/Object;)Z public final fun extractIconFile ()Ljava/lang/String; public final fun getAuthors ()Ljava/lang/String; @@ -141,6 +143,7 @@ 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 final fun getVersion ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; 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..f9ec2824e 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 @@ -13,6 +13,8 @@ data class ModInfo @JvmOverloads constructor( val authors: String? = null, 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 */ + val parentId: String? = null, ){ /** * Resolves [modIconPath] against this mod's own jar root ([file]) From ed1bab62c7f36e0e960dd6792bf3875d49e7ffbc Mon Sep 17 00:00:00 2001 From: Tektonikal Date: Fri, 30 Oct 2026 19:05:02 -0400 Subject: [PATCH 02/63] adjustments --- .../assets/oneconfig/lang/en_us.json | 8 +- .../internal/ui/api/HotwireBridge.kt | 244 +++++++++++++----- .../internal/ui/components/Header.kt | 54 ++-- .../oneconfig/internal/ui/navigation/Pages.kt | 9 +- .../oneconfig/internal/ui/screens/Hotwire.kt | 180 +++++++++++-- .../internal/ui/screens/HotwireAdd.kt | 189 ++++++++------ 6 files changed, 482 insertions(+), 202 deletions(-) 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 b09cf2853..47adb0038 100644 --- a/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json +++ b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json @@ -249,10 +249,16 @@ "oneconfig.page.hotwire.modrinth.downloads": "%s downloads", "oneconfig.page.hotwire.modrinth.install": "Install", "oneconfig.page.hotwire.modrinth.update": "Update", + "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.modrinth.installed_failed": "Couldn't check which mods are installed, so all results are shown: %s", + "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.downloading": "Downloading…", "oneconfig.page.hotwire.add.reading": "Reading the jar…", "oneconfig.page.hotwire.add.now": "Add now", "oneconfig.page.hotwire.add.reopen": "Save and add", 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 index 36f9376d3..678c9c2b3 100644 --- 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 @@ -3,8 +3,10 @@ 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.util.Optional import java.util.concurrent.CompletableFuture import java.util.concurrent.TimeUnit import java.util.function.Consumer @@ -76,15 +78,29 @@ object HotwireBridge { /** Whether Hotwire is running a switch, add or update right now */ fun isWorking(): Boolean = runCatching { call("getProgress")!!.get("running") as Boolean }.getOrDefault(false) - class JarAction(val label: String, val primary: Boolean, val run: () -> Unit) + /** [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 { + val reason = it.cause ?: it + CompletableFuture.completedFuture(Result(false, reason.message ?: reason.toString())) + } + } - /** What Hotwire would do with a picked jar, 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) + /** 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, + ) - // shortcut: Hotwire's public API has no add or update calls, so the jar flow reaches into its engine and may - // break on a Hotwire update, until Hotwire's API gains them + // shortcut: Hotwire's public API can't add a jar from disk or list updates, so those reach into its engine, which + // may break on a Hotwire update, until Hotwire's API gains them + // the engine lives in a package per game version, the same one as the API implementation private fun engine(): Any? = runCatching { - Class.forName("dev.moon.hotwire.fabric.HotwireClient").getMethod("findEngine").invoke(null) + Class.forName("${api!!.javaClass.packageName}.HotwireClient").getMethod("findEngine").invoke(null) }.getOrNull() private fun Any.callMethod(name: String, vararg args: Any): Any? = @@ -95,6 +111,56 @@ object HotwireBridge { javaClass.methods.first { it.name == method && it.parameterCount == 2 }.parameterTypes[1] .enumConstants.first { (it as Enum<*>).name == constant } + // 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") + } + + 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") ?: 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, @@ -103,37 +169,17 @@ object HotwireBridge { val description: String, val downloads: Long, val iconUrl: String?, + val installed: Boolean, ) - class StoreUpdate(internal val offer: Any, val modId: String, val name: String, val installedVersion: String, val newVersion: String) - - /** Which Modrinth projects are installed, and which of those have a newer version for this game */ - class InstalledProjects(private val model: Any, val updatable: Set) { - // the same three checks Hotwire's own store cards make: a jar Modrinth knows by hash, or a loaded mod whose - // id is the project's slug or id, which catches jars Modrinth doesn't know such as dev builds - fun isInstalled(hit: StoreHit): Boolean = - listOf(hit.projectId, hit.slug).any { it.isNotBlank() && model.callMethod("isInstalledProject", it) == true } - } - - /** Hotwire's Modrinth store, created the way Hotwire's own store screen does when it hasn't been opened yet */ - private fun store(): Any { - val engine = engine()!! - engine.callMethod("getStore")?.let { return it } - val screens = engine.javaClass.getDeclaredField("stores").apply { isAccessible = true }.get(engine) - return screens.javaClass.getDeclaredMethod("findStore").apply { isAccessible = true }.invoke(screens)!! - } - - /** Asks Hotwire which Modrinth projects are installed and which have updates; blocks on the network */ - fun installedProjects(): InstalledProjects { - val store = store() - val model = store.callMethod("getModel")!! - store.callMethod("checkInstalled") - // the store's worker is a single thread, so this runs once the check queued just before it has finished - val checked = CompletableFuture() - model.callMethod("run", Runnable { checked.complete(Unit) }) - checked.get(60, TimeUnit.SECONDS) - return InstalledProjects(model, (model.callMethod("getUpdates") as Map<*, *>).keys.mapTo(HashSet()) { it as String }) - } + class StoreUpdate( + internal val offer: Any, + 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 { @@ -141,53 +187,107 @@ object HotwireBridge { // 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)) Files.write(file, modrinth!!.callMethod("fetchFromCdn", url) as ByteArray) + if (!Files.exists(file)) { + val uri = URI(url) + require(uri.scheme == "https") { "Icons only load over https." } + Files.write(file, uri.toURL().openStream().use { it.readNBytes(MAX_ICON_BYTES) }) + } return file } - /** Hotwire's Modrinth client, already limited to this game version and loader */ - private val modrinth: Any? by lazy { - runCatching { Class.forName("dev.moon.hotwire.fabric.store.ModStore").getMethod("makeClient").invoke(null) }.getOrNull() - } + private fun storeQuery(words: String): Any = + Class.forName("dev.moon.hotwire.api.StoreQuery").getMethod("of", String::class.java).invoke(null, words) + + 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()) + + private var storeChecked = false - /** Searches Modrinth for mods matching [query]; blocks on the network, so call it off the UI thread */ - fun searchModrinth(query: String): List { - val search = modrinth!!.callMethod("search", query, "", 0, 30)!! - return (search.get("hits") as List<*>).map { + /** + * Searches Modrinth for mods matching [words] in [category], or every category when blank; blocks on the network, + * so call it off the UI thread + */ + fun searchStore(words: String, category: String): List { + val query = storeQuery(words).let { it.javaClass.getMethod("withCategory", String::class.java).invoke(it, category) } + fun search() = ((call("searchStore", query) as CompletableFuture<*>).get()!!.get("mods") as List<*>).map { StoreHit( - it!!.get("projectId") as String, + it!!.get("id") as String, it.get("slug") as String? ?: "", - it.get("title") 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, ) } + val first = search() + if (storeChecked) return first + storeChecked = true + // the first search creates Hotwire's store, whose installed check is still queued on its single worker, so + // this runs once that has finished and only then are the installed flags right + val model = engine()?.callMethod("getStore")?.callMethod("getModel") ?: return first + val checked = CompletableFuture() + model.callMethod("run", Runnable { checked.complete(Unit) }) + checked.get(60, TimeUnit.SECONDS) + return search() } - /** - * Downloads the newest version of [projectId] that fits this game and returns the checked jar, ready for - * [planFor]; blocks on the network - */ - fun downloadLatest(projectId: String): Path { - val client = modrinth!! - val version = (client.callMethod("findVersions", projectId) as List<*>).firstOrNull() - ?: error("No version of this mod fits this game.") - val bytes = client.callMethod("fetch", version) as ByteArray - // the download is untrusted until it matches the hash Modrinth published for it - check(client.javaClass.getMethod("hashOf", ByteArray::class.java).invoke(null, bytes) == version.get("sha512")) { - "The download didn't match Modrinth's checksum." + /** 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 */ + fun storePlan(hit: StoreHit): JarPlan { + val version = newestVersion(hit.projectId) + val add = (call("requestAdd", hit.projectId, version) as CompletableFuture<*>).get()!! + var accepted = false + val later = JarAction(translated("oneconfig.page.hotwire.add.next_start", "Add at next launch"), false) { + accepted = true + add.through("StoreAdd", "cancel") + call("queueForNextStart", hit.projectId, version).asResults() + } + val save = JarAction(translated("oneconfig.page.hotwire.add.save", "Save to Downloads"), false) { + accepted = true + add.through("StoreAdd", "cancel") + call("saveToDownloads", storeQuery(""), hit.projectId, version).asResults() + } + val now = JarAction( + if (add.through("StoreAdd", "needsReopen") == true) { + translated("oneconfig.page.hotwire.add.reopen", "Save and add") + } else { + translated("oneconfig.page.hotwire.add.now", "Add now") + }, + true, + ) { + accepted = true + add.through("StoreAdd", "accept").asResults() } - val dir = Files.createDirectories(Path.of(System.getProperty("java.io.tmpdir"), "oneconfig-hotwire")) - return Files.write(dir.resolve(Path.of(version.get("fileName") as String).fileName), bytes) + @Suppress("UNCHECKED_CAST") + return JarPlan( + add.through("StoreAdd", "getTitle") as String, + "", + add.through("StoreAdd", "getLines") as List, + (if (add.through("StoreAdd", "canAdd") == true) listOf(now, later) else emptyList()) + save, + onCancel = { if (!accepted) add.through("StoreAdd", "cancel") }, + ) } /** Hotwire's update check against Modrinth for the installed mods; blocks on the network */ fun findUpdates(): List { val check = engine()!!.callMethod("getUpdateCheck")!! return (check.callMethod("findNow") as List<*>).map { - StoreUpdate(it!!, it.get("id") as String, it.get("name") as String, it.get("installedVersion") as String, it.get("newVersion") as String) + StoreUpdate(it!!, 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) } } @@ -223,12 +323,15 @@ object HotwireBridge { @Suppress("UNCHECKED_CAST") private fun addPlan(engine: Any, jar: Path, preview: Any): JarPlan { - fun add(mode: String) = { engine.callMethod("add", jar, engine.enumArg("add", mode)); Unit } + fun add(mode: String): () -> CompletableFuture? = { + engine.callMethod("add", jar, engine.enumArg("add", mode)) + null + } val later = JarAction(translated("oneconfig.page.hotwire.add.next_start", "Add at next launch"), false, add("NEXT_START")) val actions = when ((preview.get("mode") as Enum<*>).name) { "NOW" -> listOf(JarAction(translated("oneconfig.page.hotwire.add.now", "Add now"), true, add("NOW")), later) "SAVE_AND_REOPEN" -> listOf(JarAction(translated("oneconfig.page.hotwire.add.reopen", "Save and add"), true, add("SAVE_AND_REOPEN")), later) - "NEXT_START" -> listOf(JarAction(later.label, true, later.run)) + "NEXT_START" -> listOf(JarAction(later.label, true, add("NEXT_START"))) else -> emptyList() } return JarPlan(preview.get("name") as String, preview.get("version") as String? ?: "", preview.get("lines") as List, actions) @@ -236,7 +339,10 @@ object HotwireBridge { @Suppress("UNCHECKED_CAST") private fun updatePlan(engine: Any, jar: Path, preview: Any): JarPlan { - fun update(method: String) = { engine.callMethod("update", jar, engine.enumArg("update", method)); Unit } + fun update(method: String): () -> CompletableFuture? = { + engine.callMethod("update", jar, engine.enumArg("update", method)) + null + } val later = JarAction(translated("oneconfig.page.hotwire.update.next_start", "Update at next launch"), false, update("NEXT_START")) return when (preview.javaClass.simpleName) { "Ready" -> { @@ -254,7 +360,7 @@ object HotwireBridge { if (inPlace) { listOf(JarAction(translated("oneconfig.page.hotwire.update.now", "Update now"), true, update("IN_PLACE")), later) } else { - listOf(JarAction(later.label, true, later.run)) + listOf(JarAction(later.label, true, update("NEXT_START"))) }, ) } @@ -271,10 +377,8 @@ object HotwireBridge { /** Turns [ids] on or off, completing on whatever thread Hotwire finishes on */ fun switch(ids: Collection, on: Boolean): CompletableFuture = runCatching { - @Suppress("UNCHECKED_CAST") - (call(if (on) "turnOn" else "turnOff", ids.toList()) as CompletableFuture).thenApply { result -> - val kind = (result.get("kind") as Enum<*>).name - Result(kind == "DONE", result.get("message") as String? ?: "", needsReopen = kind == "NEEDS_REOPEN") - } + call(if (on) "turnOn" else "turnOff", ids.toList()).asResults() }.getOrElse { CompletableFuture.completedFuture(Result(false, "Hotwire is not available.")) } + + private const val MAX_ICON_BYTES = 8 * 1024 * 1024 } 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 9a4948efe..dfc153788 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 @@ -68,8 +68,8 @@ 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.addHotwireMods 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 @@ -111,6 +111,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() } @@ -177,7 +179,6 @@ fun Header(modifier: Modifier = Modifier) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(26.dp) ) { - title?.addMods?.let { HeaderButton("plus", translated("oneconfig.page.hotwire.add_mods", "Add mods"), it) } GlobalSearchBar() IconButton("close", Modifier.size(32.dp), hoverBorder = true) { closeRequest() } } @@ -195,7 +196,6 @@ private data class HeaderTitle( val version: String? = null, val openOriginalScreen: Runnable? = null, val tagline: String? = null, - val addMods: Runnable? = null, ) private fun NavBackStackEntry?.headerTitle(): HeaderTitle? { @@ -216,10 +216,10 @@ private fun NavBackStackEntry?.headerTitle(): HeaderTitle? { } if (destination.hasRoute(HotwireAddRoute::class)) { - return HeaderTitle(translated("oneconfig.page.hotwire.add_mods", "Add mods"), tagline = hotwireTagline()) + 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(), addMods = addHotwireMods) + return HeaderTitle(entry.currentPageTitle() ?: "Hotwire", tagline = hotwireTagline(), openOriginalScreen = openHotwireScreen) } return entry.currentPageTitle()?.let { HeaderTitle(it) } @@ -228,6 +228,10 @@ private fun NavBackStackEntry?.headerTitle(): HeaderTitle? { @Composable private fun OpenOriginalScreenButton(runnable: Runnable) { val theme = LocalTheme.current + val interactionSource = rememberInteractionSource() + val isHovered by interactionSource.collectIsHoveredAsState() + val accentAlpha by animateFloatAsState(if (isHovered) 0.75f else 1f) + Tooltip( text = { Text( @@ -239,32 +243,22 @@ private fun OpenOriginalScreenButton(runnable: Runnable) { modifier = Modifier.widthIn(max = 260.dp), anchor = Alignment.BottomCenter, ) { - HeaderButton("external-link", translated("oneconfig.header.original_menu", "Original menu"), runnable) - } -} - -@Composable -private fun HeaderButton(icon: String, label: String, runnable: Runnable) { - val theme = LocalTheme.current - val interactionSource = rememberInteractionSource() - val isHovered by interactionSource.collectIsHoveredAsState() - val accentAlpha by animateFloatAsState(if (isHovered) 0.75f else 1f) - - Box( - modifier = Modifier - .pointerHoverIcon(PointerIcon.Hand) - .background(AccentBrush, theme.buttonShape, accentAlpha) - .onClick(interactionSource) { runnable.run() } - .border(1.dp, theme.borderColor, theme.buttonShape) - .padding(horizontal = 16.dp, vertical = 6.dp), - contentAlignment = Alignment.Center, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), + Box( + modifier = Modifier + .pointerHoverIcon(PointerIcon.Hand) + .background(AccentBrush, theme.buttonShape, accentAlpha) + .onClick(interactionSource) { runnable.run() } + .border(1.dp, LocalTheme.current.borderColor, LocalTheme.current.buttonShape) + .padding(horizontal = 16.dp, vertical = 6.dp), + contentAlignment = Alignment.Center, ) { - Icon(icon, color = theme.accentTextColor, modifier = Modifier.size(14.dp)) - Text(label, color = theme.accentTextColor, fontSize = 13.sp) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon("external-link", color = theme.accentTextColor, modifier = Modifier.size(14.dp)) + Text(translated("oneconfig.header.original_menu", "Original menu"), color = theme.accentTextColor, fontSize = 13.sp) + } } } } 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 5efee4ecb..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,7 @@ 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 @@ -184,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/screens/Hotwire.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Hotwire.kt index de7760014..e7d0b4678 100644 --- 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 @@ -13,6 +13,7 @@ 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 @@ -21,9 +22,11 @@ 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.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment @@ -41,6 +44,8 @@ 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.Dispatchers +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 @@ -51,6 +56,7 @@ 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 @@ -94,19 +100,24 @@ object HotwireUpdates { fun refreshWhenDone() { checked = true Thread({ - val start = System.nanoTime() - var sawWorking = false - while (true) { - Thread.sleep(100) - val working = HotwireBridge.isWorking() - if (working) sawWorking = true - if (sawWorking && !working || !sawWorking && System.nanoTime() - start > 3_000_000_000L) break - } + awaitIdle() val result = runCatching { HotwireBridge.findUpdates() } Platform.screen().runOnUiThread { apply(result) } }, "OneConfig Hotwire update check").apply { isDaemon = true }.start() } + /** Blocks until Hotwire stops working after having started, or a few seconds if it never reports progress */ + fun awaitIdle() { + val start = System.nanoTime() + var sawWorking = false + while (true) { + Thread.sleep(100) + val working = HotwireBridge.isWorking() + if (working) sawWorking = true + if (sawWorking && !working || !sawWorking && System.nanoTime() - start > 3_000_000_000L) return + } + } + 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 @@ -152,8 +163,21 @@ fun hotwireTagline(): String { ).joinToString(" ") } -/** The header's "Add mods" action on the Hotwire page */ -val addHotwireMods = Runnable { ShellController.shared.navigation.navigate(HotwireAddRoute) } +private val SwitchBarHeight = 56.dp + +private const val PromptWaitSeconds = 5 + +/** The header's "Original menu" action on the Hotwire pages */ +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 { "Done." }) + result.needsReopen -> Notifications.info("Hotwire", result.message.ifBlank { "Reopen your world to finish this." }) + else -> Notifications.error("Hotwire", result.message.ifBlank { "That didn't work." }) + } +} /** * Every mod the loader has in a denser grid than the mods page, where picked mods are turned on or off live @@ -161,6 +185,12 @@ val addHotwireMods = Runnable { ShellController.shared.navigation.navigate(Hotwi */ @Composable fun Hotwire() { + 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() } @@ -181,7 +211,6 @@ fun Hotwire() { // picked families by their root id val selected = remember { mutableStateSetOf() } val gridState = rememberRestorableLazyGridState("hotwire") - Column(verticalArrangement = Arrangement.spacedBy(19.dp)) { Row(Modifier.fillMaxWidth().padding(horizontal = PageGutter), horizontalArrangement = Arrangement.End) { IconSegments( @@ -214,8 +243,60 @@ fun Hotwire() { } } } - val picked = families.filterKeys { it.id in selected } - if (picked.isNotEmpty()) SwitchBar(picked, states, onClear = { selected.clear() }) + Row( + // a fixed height the switch bar fills, so the button stays centred on it and doesn't move when it appears + Modifier.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) + } + } + } +} + +/** 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(minOf(prompt.waitSeconds, PromptWaitSeconds)) } + 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() + } + } + } + } } } @@ -243,6 +324,7 @@ private fun modFamilies(mods: Collection): Map> private fun SwitchBar( picked: Map>, states: Map?, + modifier: Modifier = Modifier, onClear: () -> Unit, ) { val theme = LocalTheme.current @@ -260,6 +342,10 @@ private fun SwitchBar( 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 scope = rememberCoroutineScope() + var updating by remember { mutableStateOf(null) } var running by remember { mutableStateOf(false) } var fraction by remember { mutableFloatStateOf(0f) } LaunchedEffect(running) { @@ -274,12 +360,54 @@ private fun SwitchBar( else -> "Turn on $onCount" } val message = when { - states == null -> "Hotwire isn't installed, so mods can't be switched here." + states == null -> "Hotwire is still starting, so mods can't be switched yet." + updating != null -> translated("oneconfig.page.hotwire.updates.running", "Updating %s…", updating) running -> "Switching… ${(fraction * 100).toInt()}%" else -> previews.joinToString(" ") { it.message } } val canSwitch = !running && previews.isNotEmpty() && previews.all { it.possible } && !HotwireBridge.isSwitching + /** + * Applies each picked update in turn with whatever Hotwire allows it, live or at the next launch, since Hotwire's + * own batch update waits on confirmations in its screen + */ + fun startUpdates() { + running = true + scope.launch { + val problems = ArrayList() + try { + for (update in updates) { + updating = update.name + val plan = withContext(Dispatchers.IO) { + runCatching { HotwireBridge.planFor(HotwireBridge.downloadUpdate(update), update = true).get() } + } + val action = plan.getOrNull()?.actions?.firstOrNull { it.primary } + if (action == null) { + val reason = plan.exceptionOrNull()?.let { it.cause ?: it }?.message ?: plan.getOrNull()?.lines?.firstOrNull() + problems += "${update.name}: ${reason.orEmpty()}" + continue + } + val failed = action.attempt()?.getNow(null)?.takeUnless { it.ok } + if (failed != null) { + problems += "${update.name}: ${failed.message}" + continue + } + withContext(Dispatchers.IO) { HotwireUpdates.awaitIdle() } + } + } finally { + updating = null + running = false + HotwireUpdates.refreshWhenDone() + } + if (problems.isEmpty()) { + Notifications.success("Hotwire", translated("oneconfig.page.hotwire.add.sent", "Sent to Hotwire. It posts the result in chat.")) + onClear() + } else { + Notifications.error("Hotwire", problems.joinToString(" ")) + } + } + } + fun start() { running = true val off = if (turnOff.isEmpty()) done("") else HotwireBridge.switch(turnOff, false) @@ -295,30 +423,25 @@ private fun SwitchBar( }.whenComplete { result, error -> Platform.screen().runOnUiThread { running = false - if (result?.ok == true) { - Notifications.success("Hotwire", result.message.ifBlank { "Done." }) - onClear() - } else if (result?.needsReopen == true) { - Notifications.info("Hotwire", result.message.ifBlank { "Reopen your world to finish this." }) - } else { - Notifications.error("Hotwire", result?.message?.ifBlank { null } ?: error?.message ?: "That didn't work.") - } + notifyHotwireResult(result ?: HotwireBridge.Result(false, error?.message ?: "")) + if (result?.ok == true) onClear() } } } Row( - modifier = Modifier.padding(horizontal = PageGutter).fillMaxWidth() + modifier = modifier .background(theme.modCardBackground, theme.modCardShape) .border(1.dp, theme.borderColor, theme.modCardShape) - .padding(horizontal = 16.dp, vertical = 10.dp), + .fillMaxHeight() + .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { Text("${picked.size} selected", color = theme.textColor, fontSize = 14.sp) if (message.isNotBlank()) { - Text(message, color = theme.textColorSecondary, fontSize = 12.sp, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(message, color = theme.textColorSecondary, fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) } if (running) { Box(Modifier.fillMaxWidth().height(3.dp).background(theme.borderColor, theme.modCardShape)) { @@ -327,6 +450,9 @@ private fun SwitchBar( } } Chip("Clear", selected = false, onClick = onClear) + if (!running && updates.isNotEmpty()) { + Chip(translated("oneconfig.page.hotwire.updates.bar", "Update %s", updates.size), selected = true, onClick = ::startUpdates) + } if (canSwitch) Chip(label, selected = true, onClick = ::start) } } @@ -370,6 +496,7 @@ private fun HotwireTile( 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)) { if (icon != null) Icon(icon, color = theme.textColor, modifier = Modifier.fillMaxSize()) @@ -410,6 +537,9 @@ private fun HotwireTile( 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( 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 index d52dc204f..a4db7f878 100644 --- 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 @@ -7,6 +7,7 @@ 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 @@ -17,6 +18,7 @@ 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 @@ -67,34 +69,33 @@ fun HotwireAdd() { // bumped whenever the preview is left, so a download still running then can't bring it back var request by remember { mutableIntStateOf(0) } + // leaving the page drops a pending add the same way Back does + DisposableEffect(Unit) { onDispose { plan?.onCancel?.invoke() } } + fun leavePreview() { request++ reading = false + plan?.onCancel?.invoke() plan = null } - /** Gets a jar and its icon off the UI thread, then asks Hotwire what it would do with it */ - fun review(name: String, icon: () -> String?, update: Boolean = false, jar: () -> Path?) { + /** 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, picked) = withContext(Dispatchers.IO) { runCatching(icon).getOrNull() to runCatching(jar) } - if (id != request) return@launch - planIcon = iconPath - val path = picked.getOrNull() - if (path == null) { - reading = false - picked.exceptionOrNull()?.let { plan = HotwireBridge.JarPlan(name, "", listOf(it.message ?: it.toString()), emptyList()) } + val (iconPath, loaded) = withContext(Dispatchers.IO) { runCatching(icon).getOrNull() to runCatching(load) } + if (id != request) { + loaded.getOrNull()?.onCancel?.invoke() return@launch } - HotwireBridge.planFor(path, update).whenComplete { result, _ -> - Platform.screen().runOnUiThread { - if (id != request) return@runOnUiThread - reading = false - plan = result ?: HotwireBridge.JarPlan(name, "", listOf("Hotwire couldn't read this jar."), emptyList()) - } + planIcon = iconPath + reading = false + plan = loaded.getOrElse { + val reason = it.cause ?: it + HotwireBridge.JarPlan(name, "", listOf(reason.message ?: reason.toString()), emptyList()) } } } @@ -105,22 +106,26 @@ fun HotwireAdd() { } Column(verticalArrangement = Arrangement.spacedBy(19.dp)) { - Row(Modifier.padding(horizontal = PageGutter), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Row(Modifier.fillMaxWidth().padding(horizontal = PageGutter), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Chip(translated("oneconfig.page.hotwire.source.updates", "Updates"), source == AddSource.Updates, "refresh") { show(AddSource.Updates) } Chip(translated("oneconfig.page.hotwire.source.modrinth", "Modrinth"), source == AddSource.Modrinth, "search") { show(AddSource.Modrinth) } 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.planFor(HotwireBridge.downloadUpdate(update), update = true).get() } + if (reading || plan != null) { PlanCard(reading, plan, planIcon, onBack = ::leavePreview) } else { when (source) { - AddSource.File -> FileSource { jar -> review("", { null }, jar = jar) } - AddSource.Modrinth -> ModrinthSource { hit, update -> - review(hit.title, { hit.iconUrl?.let { HotwireBridge.iconFor(it).toString() } }, update) { HotwireBridge.downloadLatest(hit.projectId) } - } - AddSource.Updates -> UpdatesSource { update -> - review(update.name, { installedIcon(update.modId) }, update = true) { HotwireBridge.downloadUpdate(update) } - } + AddSource.File -> FileSource { jar -> review("", { null }) { jar()?.let { HotwireBridge.planFor(it).get() } } } + 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) } } } } } @@ -185,79 +190,99 @@ private fun FileSource(onPicked: (() -> Path?) -> Unit) { } } -/** Modrinth projects matching the header search, or Modrinth's own listing while it is empty */ +/** A name with only what identifies it, so "Fabric Language Kotlin" and fabric-language-kotlin are the same */ +private fun String.identity() = lowercase().filter(Char::isLetterOrDigit) + +/** A category's name from Hotwire's own translations, or the tag made readable, where a blank tag means every category */ @Composable -private fun ModrinthSource(onInstall: (HotwireBridge.StoreHit, Boolean) -> Unit) { +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) } - LaunchedEffect(query) { + LaunchedEffect(query, category) { // waits out typing so each keystroke doesn't hit Modrinth delay(350) - hits = withContext(Dispatchers.IO) { runCatching { HotwireBridge.searchModrinth(query) } } + hits = withContext(Dispatchers.IO) { runCatching { HotwireBridge.searchStore(query, 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().orEmpty() + } + } } - // a failed check shows every hit rather than none - val installed by produceState?>(null) { - value = withContext(Dispatchers.IO) { runCatching { HotwireBridge.installedProjects() } } + // 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 - val known = installed when { - current == null || known == null -> Card { Text(translated("oneconfig.page.hotwire.modrinth.loading", "Searching Modrinth…"), color = theme.textColorSecondary, fontSize = 14.sp) } + 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", current.exceptionOrNull()?.message ?: ""), + translated("oneconfig.page.hotwire.modrinth.failed", "Couldn't reach Modrinth: %s", reason?.message ?: ""), color = theme.textColorSecondary, fontSize = 14.sp, ) } else -> { - // installed projects only show when there's a newer version to update to; the value says which is which - val shown = remember(current, known) { - val projects = known.getOrNull() + val updates = HotwireUpdates.found?.getOrNull().orEmpty() + val shown = remember(current, updates) { + // Hotwire only knows jars Modrinth recognises by hash, so also match what the loader says is running by + // id and name, which catches dev builds and projects whose slug isn't their mod id + val installed = HashSet() + Platform.compatibility().mods.forEach { installed += it.id.identity(); installed += it.name.identity() } + HotwireBridge.mods()?.forEach { installed += it.id.identity(); installed += it.name.identity() } current.getOrThrow().mapNotNull { hit -> - val installedHit = projects?.isInstalled(hit) == true - if (installedHit && hit.projectId !in projects.updatable) null else hit to installedHit + val update = updates.firstOrNull { it.projectId == hit.projectId } + val isInstalled = hit.installed || listOf(hit.slug, hit.projectId, hit.title).any { it.identity() in installed } + // installed projects only show when there's a newer version to update to + if (update == null && isInstalled) null else hit to update } } - known.exceptionOrNull()?.let { - Text( - translated("oneconfig.page.hotwire.modrinth.installed_failed", "Couldn't check which mods are installed, so all results are shown: %s", it.message ?: it.toString()), - color = theme.textColorSecondary, - fontSize = 12.sp, - modifier = Modifier.padding(horizontal = PageGutter), - ) + ResultList(shown) { (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) } + else -> Chip(translated("oneconfig.page.hotwire.modrinth.install", "Install"), selected = true) { onInstall(hit) } + } } - ModrinthResults(shown, onInstall) } } } -@Composable -private fun ModrinthResults(shown: List>, onInstall: (HotwireBridge.StoreHit, Boolean) -> Unit) { - val theme = LocalTheme.current - ResultList(shown) { (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) - Chip( - if (update) translated("oneconfig.page.hotwire.modrinth.update", "Update") else translated("oneconfig.page.hotwire.modrinth.install", "Install"), - selected = true, - ) { onInstall(hit, update) } - } -} - /** Updates Hotwire found on Modrinth for the installed mods, from the session's shared [HotwireUpdates] */ @Composable private fun UpdatesSource(onUpdate: (HotwireBridge.StoreUpdate) -> Unit) { @@ -319,6 +344,9 @@ private fun ResultList(items: List, row: @Composable androidx.compose.fou private fun PlanCard(reading: Boolean, plan: HotwireBridge.JarPlan?, icon: String?, 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) { @@ -345,10 +373,11 @@ private fun PlanCard(reading: Boolean, plan: HotwireBridge.JarPlan?, icon: Strin } if (started) { Text( - if (working) { - translated("oneconfig.page.hotwire.add.working", "Hotwire is working on it… %s%%", (fraction * 100).toInt()) - } else { - translated("oneconfig.page.hotwire.add.sent", "Sent to Hotwire. It posts the result in chat.") + 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.downloading", "Downloading…") + else -> translated("oneconfig.page.hotwire.add.sent", "Sent to Hotwire. It posts the result in chat.") }, color = theme.textColorSecondary, fontSize = 13.sp, @@ -364,7 +393,17 @@ private fun PlanCard(reading: Boolean, plan: HotwireBridge.JarPlan?, icon: Strin plan.actions.forEach { action -> Chip(action.label, selected = action.primary) { started = true - action.run() + 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 ?: "That didn't work.") + outcome = done + awaiting = false + notifyHotwireResult(done) + } + } HotwireUpdates.refreshWhenDone() } } From ff630b4c7b1e9da2f0c5a3611a9066dde0afbea7 Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Fri, 30 Oct 2026 20:10:39 -0400 Subject: [PATCH 03/63] fix stuff --- .../assets/oneconfig/lang/en_us.json | 3 + .../internal/ui/api/HotwireBridge.kt | 110 +++++++++---- .../oneconfig/internal/ui/screens/Hotwire.kt | 145 +++++++++--------- .../internal/ui/screens/HotwireAdd.kt | 34 +++- modules/utils/api/utils.api | 7 +- .../oneconfig/api/platform/v1/ModInfo.kt | 20 ++- 6 files changed, 204 insertions(+), 115 deletions(-) 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 177b737fc..ff5c75991 100644 --- a/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json +++ b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json @@ -248,6 +248,7 @@ "oneconfig.page.hotwire.modrinth.downloads": "%s downloads", "oneconfig.page.hotwire.modrinth.install": "Install", "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…", @@ -266,6 +267,8 @@ "oneconfig.page.hotwire.add.sent": "Sent to Hotwire. It posts the result in chat.", "oneconfig.page.hotwire.update.now": "Update now", "oneconfig.page.hotwire.update.next_start": "Update at next launch", + "oneconfig.page.hotwire.update.with_needs": "Add and update", + "oneconfig.page.hotwire.update.adds": "Adds %s, which this update needs.", "oneconfig.page.hotwire.update.same": "This version is already installed.", "oneconfig.page.hotwire.update.unavailable": "Hotwire can't update mods in this game session.", "oneconfig.page.hotwire.update.restart_only": "Hotwire can't swap this mod while the game runs, so it updates at the next launch.", 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 index 678c9c2b3..acf0e9b1f 100644 --- 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 @@ -78,6 +78,17 @@ object HotwireBridge { /** Whether Hotwire is running a switch, add or update right now */ fun isWorking(): Boolean = runCatching { call("getProgress")!!.get("running") as Boolean }.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 */ @@ -172,6 +183,8 @@ object HotwireBridge { val installed: Boolean, ) + class StorePage(val hits: List, val total: Int) + class StoreUpdate( internal val offer: Any, val modId: String, @@ -210,22 +223,28 @@ object HotwireBridge { private var storeChecked = false /** - * Searches Modrinth for mods matching [words] in [category], or every category when blank; blocks on the network, - * so call it off the UI thread + * Searches Modrinth for mods matching [words] in [category], or every category when blank, skipping the first + * [offset] results; blocks on the network, so call it off the UI thread */ - fun searchStore(words: String, category: String): List { - val query = storeQuery(words).let { it.javaClass.getMethod("withCategory", String::class.java).invoke(it, category) } - fun search() = ((call("searchStore", query) as CompletableFuture<*>).get()!!.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, - ) + 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) } + fun search(): StorePage { + 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) } val first = search() if (storeChecked) return first @@ -291,9 +310,43 @@ object HotwireBridge { } } - /** Downloads an offered update the way Hotwire does, returning the jar for [planFor]; blocks on the network */ - fun downloadUpdate(update: StoreUpdate): Path = - engine()!!.callMethod("getUpdateCheck")!!.callMethod("fetch", update.offer) as Path + /** Downloads the mods an offered update newly needs and then the update's own jar, last; blocks on the network */ + @Suppress("UNCHECKED_CAST") + fun downloadUpdate(update: StoreUpdate): List = + engine()!!.callMethod("getUpdateCheck")!!.callMethod("fetchWithNeeds", update.offer, Consumer {}) as List + + /** What applying [jars] from [downloadUpdate] would do; blocks until Hotwire has read them */ + fun planUpdate(jars: List): JarPlan { + val plan = planFor(jars.last(), update = true).get() + val needs = jars.dropLast(1) + val engine = engine() + if (needs.isEmpty() || engine == null || engine.callMethod("canUpdate") != true) return plan + val adds = needs.map { (engine.callMethod("dryRunAdd", it) as CompletableFuture<*>).get()!! } + val refused = adds.firstOrNull { (it.get("mode") as Enum<*>).name == "REFUSED" } + @Suppress("UNCHECKED_CAST") + if (refused != null) return JarPlan(plan.title, plan.detail, refused.get("lines") as List, emptyList()) + val action = JarAction(translated("oneconfig.page.hotwire.update.with_needs", "Add and update"), true) { + CompletableFuture.supplyAsync { + runCatching { addThenUpdate(engine, needs.zip(adds), jars.last()) } + .getOrElse { Result(false, (it.cause ?: it).message ?: it.toString()) } + } + } + val lines = adds.map { translated("oneconfig.page.hotwire.update.adds", "Adds %s, which this update needs.", it.get("name") as String) } + return JarPlan(plan.title, plan.detail, lines + plan.lines, listOf(action)) + } + + /** Adds [needs] and only then reads the update, since what Hotwire allows for it depends on them being in */ + private fun addThenUpdate(engine: Any, needs: List>, jar: Path): Result { + for ((need, preview) in needs) { + Platform.screen().runOnUiThread { engine.callMethod("add", need, preview.get("mode")!!) } + awaitIdle() + } + val plan = planFor(jar, update = true).get() + val action = plan.actions.firstOrNull { it.primary } ?: return Result(false, plan.lines.firstOrNull().orEmpty()) + val attempt = CompletableFuture?>() + Platform.screen().runOnUiThread { attempt.complete(action.attempt()) } + return attempt.get()?.get() ?: Result(true, translated("oneconfig.page.hotwire.add.sent", "Sent to Hotwire. It posts the result in chat.")) + } /** * Reads [jar] the way Hotwire does before adding or updating @@ -343,11 +396,10 @@ object HotwireBridge { engine.callMethod("update", jar, engine.enumArg("update", method)) null } - val later = JarAction(translated("oneconfig.page.hotwire.update.next_start", "Update at next launch"), false, update("NEXT_START")) return when (preview.javaClass.simpleName) { "Ready" -> { - // Hotwire's own panel only offers a live swap when its preview allows one; the lines say what blocks it - val inPlace = (preview.get("method") as Enum<*>).name == "IN_PLACE" + val method = (preview.get("method") as Enum<*>).name + val inPlace = method == "IN_PLACE" val lines = preview.get("lines") as List JarPlan( preview.get("name") as String, @@ -357,11 +409,17 @@ object HotwireBridge { } else { listOf(translated("oneconfig.page.hotwire.update.restart_only", "Hotwire can't swap this mod while the game runs, so it updates at the next launch.")) + lines }, - if (inPlace) { - listOf(JarAction(translated("oneconfig.page.hotwire.update.now", "Update now"), true, update("IN_PLACE")), later) - } else { - listOf(JarAction(later.label, true, update("NEXT_START"))) - }, + listOf( + JarAction( + if (inPlace) { + translated("oneconfig.page.hotwire.update.now", "Update now") + } else { + translated("oneconfig.page.hotwire.update.next_start", "Update at next launch") + }, + true, + update(method), + ), + ), ) } "Refused" -> JarPlan(preview.get("name") as String, "", listOf(preview.get("reason") as String), emptyList()) 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 index e7d0b4678..63235439a 100644 --- 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 @@ -26,7 +26,6 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateSetOf 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 @@ -40,10 +39,14 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import java.util.concurrent.CompletableFuture +import java.util.concurrent.Executor 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.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -79,16 +82,17 @@ object HotwireUpdates { 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 - /** Checks on first use only */ - suspend fun ensure() { - if (!checked) refresh() - } + private val scope = CoroutineScope(SupervisorJob() + Executor { Platform.screen().runOnUiThread(it) }.asCoroutineDispatcher()) - suspend fun refresh() { - checked = true - apply(withContext(Dispatchers.IO) { runCatching { HotwireBridge.findUpdates() } }) + /** Checks on first use only */ + fun ensure() { + if (!checked) refreshAfter {} } /** @@ -97,24 +101,53 @@ object HotwireUpdates { * * Runs on its own thread so leaving the page doesn't cancel it */ - fun refreshWhenDone() { + fun refreshWhenDone() = refreshAfter(HotwireBridge::awaitIdle) + + private fun refreshAfter(wait: () -> Unit) { checked = true Thread({ - awaitIdle() + wait() val result = runCatching { HotwireBridge.findUpdates() } Platform.screen().runOnUiThread { apply(result) } }, "OneConfig Hotwire update check").apply { isDaemon = true }.start() } - /** Blocks until Hotwire stops working after having started, or a few seconds if it never reports progress */ - fun awaitIdle() { - val start = System.nanoTime() - var sawWorking = false - while (true) { - Thread.sleep(100) - val working = HotwireBridge.isWorking() - if (working) sawWorking = true - if (sawWorking && !working || !sawWorking && System.nanoTime() - start > 3_000_000_000L) return + /** + * Applies each update in turn with whatever Hotwire allows it, live or at the next launch, since Hotwire's own + * batch update waits on confirmations in its screen + */ + fun updateAll(updates: List, onDone: () -> Unit) { + scope.launch { + val problems = ArrayList() + try { + for (update in updates) { + updating = update.name + val plan = withContext(Dispatchers.IO) { + runCatching { HotwireBridge.planUpdate(HotwireBridge.downloadUpdate(update)) } + } + val action = plan.getOrNull()?.actions?.firstOrNull { it.primary } + if (action == null) { + val reason = plan.exceptionOrNull()?.let { it.cause ?: it }?.message ?: plan.getOrNull()?.lines?.firstOrNull() + problems += "${update.name}: ${reason.orEmpty()}" + continue + } + val failed = action.attempt()?.let { withContext(Dispatchers.IO) { it.get() } }?.takeUnless { it.ok } + if (failed != null) { + problems += "${update.name}: ${failed.message}" + continue + } + withContext(Dispatchers.IO) { HotwireBridge.awaitIdle() } + } + } finally { + updating = null + refreshWhenDone() + } + if (problems.isEmpty()) { + Notifications.success("Hotwire", translated("oneconfig.page.hotwire.add.sent", "Sent to Hotwire. It posts the result in chat.")) + onDone() + } else { + Notifications.error("Hotwire", problems.joinToString(" ")) + } } } @@ -196,8 +229,7 @@ fun Hotwire() { LaunchedEffect(Unit) { HotwireUpdates.ensure() } val availableUpdates = HotwireUpdates.modIds val families = remember(states) { - // mods Hotwire refuses to switch are left out, unless Hotwire is not there to ask - modFamilies(gridMods(states)).filterKeys { states == null || states[it.id]?.switchable == true } + modFamilies(gridMods(states)).filterValues { members -> states == null || members.any { states[it.id]?.switchable == true } } } val query = ShellState.searchQuery.trim() val mods = remember(families, query) { @@ -234,7 +266,7 @@ fun Hotwire() { HotwireTile( mod, families.getValue(mod), - on = states?.get(mod.id)?.on, + on = familyOn(families.getValue(mod), states), hasUpdate = families.getValue(mod).any { it.id in availableUpdates }, list = listLayout, selected = mod.id in selected, @@ -319,6 +351,10 @@ private fun modFamilies(mods: Collection): Map> } } +/** 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 + /** The picked mods and what switching them does, with the button that does it, as Hotwire's own screen offers */ @Composable private fun SwitchBar( @@ -329,10 +365,10 @@ private fun SwitchBar( ) { val theme = LocalTheme.current // a picked entry switches as a whole, so every member Hotwire can switch goes along with its root - fun idsWhere(on: Boolean) = picked - .filterKeys { states?.get(it.id)?.on == on } - .values.flatten() - .filter { states?.get(it.id)?.switchable == true } + fun idsWhere(on: Boolean) = picked.values + .filter { familyOn(it, states) == on } + .flatten() + .filter { mod -> states?.get(mod.id)?.let { it.switchable && it.on == on } == true } .map { it.id } val turnOff = remember(picked, states) { idsWhere(true) } val turnOn = remember(picked, states) { idsWhere(false) } @@ -344,16 +380,16 @@ private fun SwitchBar( } val pickedIds = picked.values.flatten().mapTo(HashSet()) { it.id } val updates = HotwireUpdates.found?.getOrNull().orEmpty().filter { it.modId in pickedIds } - val scope = rememberCoroutineScope() - var updating by remember { mutableStateOf(null) } - var running by remember { mutableStateOf(false) } + val updating = HotwireUpdates.updating + var switching by remember { mutableStateOf(false) } + val running = switching || updating != null var fraction by remember { mutableFloatStateOf(0f) } LaunchedEffect(running) { while (running) withFrameNanos { fraction = HotwireBridge.progress() } } - val offCount = picked.keys.count { states?.get(it.id)?.on == true } - val onCount = picked.keys.count { states?.get(it.id)?.on == false } + val offCount = picked.values.count { familyOn(it, states) == true } + val onCount = picked.values.count { familyOn(it, states) == false } val label = when { offCount > 0 && onCount > 0 -> "Switch ${offCount + onCount}" offCount > 0 -> "Turn off $offCount" @@ -367,49 +403,8 @@ private fun SwitchBar( } val canSwitch = !running && previews.isNotEmpty() && previews.all { it.possible } && !HotwireBridge.isSwitching - /** - * Applies each picked update in turn with whatever Hotwire allows it, live or at the next launch, since Hotwire's - * own batch update waits on confirmations in its screen - */ - fun startUpdates() { - running = true - scope.launch { - val problems = ArrayList() - try { - for (update in updates) { - updating = update.name - val plan = withContext(Dispatchers.IO) { - runCatching { HotwireBridge.planFor(HotwireBridge.downloadUpdate(update), update = true).get() } - } - val action = plan.getOrNull()?.actions?.firstOrNull { it.primary } - if (action == null) { - val reason = plan.exceptionOrNull()?.let { it.cause ?: it }?.message ?: plan.getOrNull()?.lines?.firstOrNull() - problems += "${update.name}: ${reason.orEmpty()}" - continue - } - val failed = action.attempt()?.getNow(null)?.takeUnless { it.ok } - if (failed != null) { - problems += "${update.name}: ${failed.message}" - continue - } - withContext(Dispatchers.IO) { HotwireUpdates.awaitIdle() } - } - } finally { - updating = null - running = false - HotwireUpdates.refreshWhenDone() - } - if (problems.isEmpty()) { - Notifications.success("Hotwire", translated("oneconfig.page.hotwire.add.sent", "Sent to Hotwire. It posts the result in chat.")) - onClear() - } else { - Notifications.error("Hotwire", problems.joinToString(" ")) - } - } - } - fun start() { - running = true + switching = 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) @@ -422,7 +417,7 @@ private fun SwitchBar( } }.whenComplete { result, error -> Platform.screen().runOnUiThread { - running = false + switching = false notifyHotwireResult(result ?: HotwireBridge.Result(false, error?.message ?: "")) if (result?.ok == true) onClear() } @@ -451,7 +446,7 @@ private fun SwitchBar( } Chip("Clear", selected = false, onClick = onClear) if (!running && updates.isNotEmpty()) { - Chip(translated("oneconfig.page.hotwire.updates.bar", "Update %s", updates.size), selected = true, onClick = ::startUpdates) + Chip(translated("oneconfig.page.hotwire.updates.bar", "Update %s", updates.size), selected = true) { HotwireUpdates.updateAll(updates, onClear) } } if (canSwitch) Chip(label, selected = true, onClick = ::start) } 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 index a4db7f878..f9987cb0a 100644 --- 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 @@ -7,6 +7,7 @@ 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize @@ -114,7 +115,7 @@ fun HotwireAdd() { 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.planFor(HotwireBridge.downloadUpdate(update), update = true).get() } + review(update.name, icon) { HotwireBridge.planUpdate(HotwireBridge.downloadUpdate(update)) } if (reading || plan != null) { PlanCard(reading, plan, planIcon, onBack = ::leavePreview) @@ -212,10 +213,17 @@ private fun ModrinthSource( var group by remember { mutableStateOf(categories.firstOrNull()) } var category by remember { mutableStateOf("") } var hits by remember { mutableStateOf>?>(null) } - LaunchedEffect(query, category) { + 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 - delay(350) - hits = withContext(Dispatchers.IO) { runCatching { HotwireBridge.searchStore(query, category) } } + 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() } @@ -223,7 +231,7 @@ private fun ModrinthSource( categories.forEach { Chip(categoryLabel(it.parts.singleOrNull() ?: it.name), group === it) { group = it - category = it.parts.firstOrNull().orEmpty() + category = it.parts.firstOrNull() ?: it.name } } } @@ -260,7 +268,15 @@ private fun ModrinthSource( if (update == null && isInstalled) null else hit to update } } - ResultList(shown) { (hit, 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) @@ -315,7 +331,7 @@ private fun UpdatesSource(onUpdate: (HotwireBridge.StoreUpdate) -> Unit) { /** A scrolling column of rows styled like the Hotwire page's list view */ @Composable -private fun ResultList(items: List, row: @Composable androidx.compose.foundation.layout.RowScope.(T) -> Unit) { +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()) { @@ -335,6 +351,7 @@ private fun ResultList(items: List, row: @Composable androidx.compose.fou horizontalArrangement = Arrangement.spacedBy(14.dp), ) { row(item) } } + item { footer() } } } } @@ -402,9 +419,10 @@ private fun PlanCard(reading: Boolean, plan: HotwireBridge.JarPlan?, icon: Strin outcome = done awaiting = false notifyHotwireResult(done) + HotwireUpdates.refreshWhenDone() } } - HotwireUpdates.refreshWhenDone() + if (pending == null) HotwireUpdates.refreshWhenDone() } } } diff --git a/modules/utils/api/utils.api b/modules/utils/api/utils.api index 5f4835d9d..7d695d897 100644 --- a/modules/utils/api/utils.api +++ b/modules/utils/api/utils.api @@ -120,8 +120,8 @@ 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;)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;)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 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;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ljava/lang/String; @@ -130,9 +130,8 @@ public final class org/polyfrost/oneconfig/api/platform/v1/ModInfo { public final fun component6 ()Ljava/lang/String; public final fun component7 ()Ljava/lang/String; public final fun component8 ()Ljava/lang/String; - public final fun component9 ()Ljava/lang/String; - public final fun copy (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;)Lorg/polyfrost/oneconfig/api/platform/v1/ModInfo; - public static synthetic fun copy$default (Lorg/polyfrost/oneconfig/api/platform/v1/ModInfo;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;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/platform/v1/ModInfo; + public final fun copy (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;)Lorg/polyfrost/oneconfig/api/platform/v1/ModInfo; + public static synthetic fun copy$default (Lorg/polyfrost/oneconfig/api/platform/v1/ModInfo;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;ILjava/lang/Object;)Lorg/polyfrost/oneconfig/api/platform/v1/ModInfo; public fun equals (Ljava/lang/Object;)Z public final fun extractIconFile ()Ljava/lang/String; public final fun getAuthors ()Ljava/lang/String; 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 f9ec2824e..b804d2956 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 @@ -13,9 +13,25 @@ data class ModInfo @JvmOverloads constructor( val authors: String? = null, 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 */ - val parentId: 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]) * From 5661953910eb907b46580d0bb2fb90a900ddba22 Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Fri, 30 Oct 2026 20:30:54 -0400 Subject: [PATCH 04/63] fixes --- .../assets/oneconfig/lang/en_us.json | 12 +- .../internal/ui/api/HotwireBridge.kt | 169 +++++++++++++++--- .../oneconfig/internal/ui/screens/Hotwire.kt | 29 +-- .../internal/ui/screens/HotwireAdd.kt | 38 ++-- 4 files changed, 189 insertions(+), 59 deletions(-) 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 ff5c75991..f4b134a74 100644 --- a/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json +++ b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json @@ -254,11 +254,13 @@ "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.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.downloading": "Downloading…", + "oneconfig.page.hotwire.add.waiting": "Waiting for Hotwire…", + "oneconfig.page.hotwire.add.failed": "Hotwire couldn't add %s - please check chat!", "oneconfig.page.hotwire.add.reading": "Reading the jar…", "oneconfig.page.hotwire.add.now": "Add now", "oneconfig.page.hotwire.add.reopen": "Save and add", @@ -267,12 +269,18 @@ "oneconfig.page.hotwire.add.sent": "Sent to Hotwire. It posts the result in chat.", "oneconfig.page.hotwire.update.now": "Update now", "oneconfig.page.hotwire.update.next_start": "Update at next launch", - "oneconfig.page.hotwire.update.with_needs": "Add and update", + "oneconfig.page.hotwire.update.with_needs": "Update with dependencies", + "oneconfig.page.hotwire.update.updates": "Updates %s, which this update needs.", + "oneconfig.page.hotwire.update.done": "%s is updated.", + "oneconfig.page.hotwire.update.queued": "%s updates at the next launch.", + "oneconfig.page.hotwire.update.failed": "Hotwire couldn't update %s - please check chat!", "oneconfig.page.hotwire.update.adds": "Adds %s, which this update needs.", "oneconfig.page.hotwire.update.same": "This version is already installed.", "oneconfig.page.hotwire.update.unavailable": "Hotwire can't update mods in this game session.", "oneconfig.page.hotwire.update.restart_only": "Hotwire can't swap this mod while the game runs, so it updates at the next launch.", "oneconfig.page.hotwire.unreadable": "Hotwire can't read this jar.", + "oneconfig.page.hotwire.upgrade.page": "This version of Hotwire is too old for this page. Update Hotwire to use it.", + "oneconfig.page.hotwire.upgrade.store": "This version of Hotwire can't browse Modrinth here. Update Hotwire to use it.", "oneconfig.page.themes.title": "Themes", "oneconfig.page.themes.search": "Search Themes...", "oneconfig.page.preferences.title": "Preferences", 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 index acf0e9b1f..513fa64a7 100644 --- 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 @@ -51,6 +51,16 @@ object HotwireBridge { private fun Any.get(accessor: String): Any? = javaClass.getMethod(accessor).invoke(this) + private fun supports(call: String) = apiClass?.methods?.any { it.name == call } == true + + /** Whether this Hotwire can hand over the warning the page must show before it switches anything */ + val supportsSwitching: Boolean by lazy { supports("findOpeningPrompt") } + + val supportsOriginalScreen: Boolean by lazy { supports("openScreen") } + + /** Whether this Hotwire has every store call, which arrived over several revisions */ + val supportsStore: Boolean by lazy { (call("getRevision") as? Int ?: 0) >= STORE_REVISION } + /** 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 { @@ -92,19 +102,27 @@ object HotwireBridge { /** [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 { - val reason = it.cause ?: it - CompletableFuture.completedFuture(Result(false, reason.message ?: reason.toString())) - } + fun attempt(): CompletableFuture? = + runCatching(run).getOrElse { CompletableFuture.completedFuture(failure(it)) }?.exceptionally(::failure) } - /** What Hotwire would do with a mod, and the choices it offers, as its own add and update panels show */ + 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 + * + * [upToDate] means the mod already has this version, so there is nothing to do + */ class JarPlan( val title: String, val detail: String, val lines: List, val actions: List, val onCancel: (() -> Unit)? = null, + val upToDate: Boolean = false, ) // shortcut: Hotwire's public API can't add a jar from disk or list updates, so those reach into its engine, which @@ -222,6 +240,10 @@ object HotwireBridge { private var storeChecked = false + /** Hotwire's offers from the last update check, which it reuses when fetching what an update needs */ + @Volatile + private var offers: List<*> = emptyList() + /** * Searches Modrinth for mods matching [words] in [category], or every category when blank, skipping the first * [offset] results; blocks on the network, so call it off the UI thread @@ -305,7 +327,9 @@ object HotwireBridge { /** Hotwire's update check against Modrinth for the installed mods; blocks on the network */ fun findUpdates(): List { val check = engine()!!.callMethod("getUpdateCheck")!! - return (check.callMethod("findNow") as List<*>).map { + val found = check.callMethod("findNow") as List<*> + offers = found + return found.map { StoreUpdate(it!!, 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) } } @@ -315,39 +339,123 @@ object HotwireBridge { fun downloadUpdate(update: StoreUpdate): List = engine()!!.callMethod("getUpdateCheck")!!.callMethod("fetchWithNeeds", update.offer, Consumer {}) as List - /** What applying [jars] from [downloadUpdate] would do; blocks until Hotwire has read them */ + private fun manifestOf(jar: Path): Any? = + Class.forName("dev.moon.hotwire.jar.JarLookup").getMethod("findManifest", Path::class.java).invoke(null, jar) + + private fun dryRunAdd(engine: Any, jar: Path): Any = (engine.callMethod("dryRunAdd", jar) as CompletableFuture<*>).get()!! + + private fun Any.lineOfAdd(): String = (get("lines") as List<*>).firstOrNull() as String? ?: get("name") as String + + /** The mods stopping an update, or none when Hotwire can't plan it, which is how it answers any other refusal */ + private fun unmetNeeds(engine: Any, jar: Path): List { + val changes = engine.javaClass.getDeclaredField("changes").apply { isAccessible = true }.get(engine)!! + val plan = changes.callMethod("planUpdate", jar) ?: return emptyList() + return (plan.get("blockers") as List<*>).filter { it!!.javaClass.simpleName == "DependencyUnmet" }.map { it!!.get("mod") as String } + } + + /** + * Adds to [steps] what has to land before [jar] can update, each after what it needs in turn, the way Hotwire's + * own update queue meets needs; false when Hotwire refuses it for anything else + */ + @Suppress("UNCHECKED_CAST") + private fun collectNeeds(engine: Any, jar: Path, seen: MutableSet, steps: MutableList): Boolean { + val preview = (engine.callMethod("dryRunUpdate", jar) as CompletableFuture<*>).get()!! + val kind = preview.javaClass.simpleName + if (kind != "Refused") return kind == "Ready" + val unmet = unmetNeeds(engine, jar) + if (unmet.isEmpty()) return false + val check = engine.callMethod("getUpdateCheck")!! + for (id in unmet) { + if (!seen.add(id)) continue + val jars = check.callMethod("fetchById", id, offers, Consumer {}) as List + if (jars.isEmpty()) return false + for (need in jars) { + if (need in steps) continue + if (engine.callMethod("canUpdate", need) == true && !collectNeeds(engine, need, seen, steps)) return false + steps.add(need) + } + } + return true + } + + /** What applying [jars] from [downloadUpdate] would do; blocks on Hotwire reading them and on the network */ + @Suppress("UNCHECKED_CAST") fun planUpdate(jars: List): JarPlan { - val plan = planFor(jars.last(), update = true).get() - val needs = jars.dropLast(1) + val jar = jars.last() + val plan = planFor(jar, update = true).get() val engine = engine() - if (needs.isEmpty() || engine == null || engine.callMethod("canUpdate") != true) return plan - val adds = needs.map { (engine.callMethod("dryRunAdd", it) as CompletableFuture<*>).get()!! } - val refused = adds.firstOrNull { (it.get("mode") as Enum<*>).name == "REFUSED" } - @Suppress("UNCHECKED_CAST") - if (refused != null) return JarPlan(plan.title, plan.detail, refused.get("lines") as List, emptyList()) - val action = JarAction(translated("oneconfig.page.hotwire.update.with_needs", "Add and update"), true) { - CompletableFuture.supplyAsync { - runCatching { addThenUpdate(engine, needs.zip(adds), jars.last()) } - .getOrElse { Result(false, (it.cause ?: it).message ?: it.toString()) } + if (engine == null || engine.callMethod("canUpdate") != true) return plan + val steps = jars.dropLast(1).toMutableList() + val seen = jars.mapNotNull(::manifestOf).flatMapTo(HashSet()) { (it.get("provides") as List) + (it.get("id") as String) } + if (!collectNeeds(engine, jar, seen, steps) || steps.isEmpty()) return plan + // a later add can be refused now only for lacking an earlier one, so those are read again as they run + steps.first().takeIf { engine.callMethod("canUpdate", it) != true }?.let { dryRunAdd(engine, it) } + ?.takeIf { (it.get("mode") as Enum<*>).name == "REFUSED" } + ?.let { return JarPlan(plan.title, plan.detail, it.get("lines") as List, emptyList()) } + val action = JarAction(translated("oneconfig.page.hotwire.update.with_needs", "Update with dependencies"), true) { + CompletableFuture.supplyAsync { runSteps(engine, steps, jar) } + } + val lines = steps.map { + val name = manifestOf(it)?.get("name") as String? ?: it.fileName.toString() + if (engine.callMethod("canUpdate", it) == true) { + translated("oneconfig.page.hotwire.update.updates", "Updates %s, which this update needs.", name) + } else { + translated("oneconfig.page.hotwire.update.adds", "Adds %s, which this update needs.", name) } } - val lines = adds.map { translated("oneconfig.page.hotwire.update.adds", "Adds %s, which this update needs.", it.get("name") as String) } - return JarPlan(plan.title, plan.detail, lines + plan.lines, listOf(action)) + return JarPlan(plan.title, plan.detail, lines + plan.lines.takeIf { plan.actions.isNotEmpty() }.orEmpty(), listOf(action)) } - /** Adds [needs] and only then reads the update, since what Hotwire allows for it depends on them being in */ - private fun addThenUpdate(engine: Any, needs: List>, jar: Path): Result { - for ((need, preview) in needs) { - Platform.screen().runOnUiThread { engine.callMethod("add", need, preview.get("mode")!!) } - awaitIdle() + /** Lands [steps] in order and then updates [jar], stopping at the first that fails, since each one's plan rests on those before */ + private fun runSteps(engine: Any, steps: List, jar: Path): Result { + for (step in steps) { + val result = if (engine.callMethod("canUpdate", step) == true) updateNow(step) else addNow(engine, step) + if (!result.ok) return result } + return updateNow(jar) + } + + private fun onUiThread(block: () -> T): T { + val done = CompletableFuture() + Platform.screen().runOnUiThread { runCatching(block).fold(done::complete, done::completeExceptionally) } + return done.get() + } + + private fun updateNow(jar: Path): Result { val plan = planFor(jar, update = true).get() + if (plan.upToDate) return Result(true, "") val action = plan.actions.firstOrNull { it.primary } ?: return Result(false, plan.lines.firstOrNull().orEmpty()) - val attempt = CompletableFuture?>() - Platform.screen().runOnUiThread { attempt.complete(action.attempt()) } - return attempt.get()?.get() ?: Result(true, translated("oneconfig.page.hotwire.add.sent", "Sent to Hotwire. It posts the result in chat.")) + return onUiThread(action::attempt)!!.get() } + private fun addNow(engine: Any, jar: Path): Result { + val preview = dryRunAdd(engine, jar) + val mode = preview.get("mode") as Enum<*> + if (mode.name == "REFUSED") return Result(false, preview.lineOfAdd()) + onUiThread { engine.callMethod("add", jar, mode) } + dryRunAdd(engine, jar) + if (mode.name == "NEXT_START" || engine.callMethod("canUpdate", jar) == true) return Result(true, "") + return Result(false, translated("oneconfig.page.hotwire.add.failed", "Hotwire couldn't add %s. It posts why in chat.", preview.get("name") as String)) + } + + /** + * How the update just handed to Hotwire ended, which its engine doesn't report: a dry run asked for now runs on + * Hotwire's single worker after that update, and then sees the new version in, queued, or neither + */ + @Suppress("UNCHECKED_CAST") + private fun updateResult(engine: Any, jar: Path, name: String, version: String): CompletableFuture = + (engine.callMethod("dryRunUpdate", jar) as CompletableFuture).thenApply { after -> + val queued = (engine.callMethod("getPendingChanges") as List<*>).any { + it!!.javaClass.simpleName == "Update" && it.get("name") == name && it.get("toVersion") == version + } + when { + after.javaClass.simpleName == "SameVersion" -> Result(true, translated("oneconfig.page.hotwire.update.done", "%s is updated.", name)) + queued -> Result(true, translated("oneconfig.page.hotwire.update.queued", "%s updates at the next launch.", name)) + after.javaClass.simpleName == "Refused" -> Result(false, after.get("reason") as String) + else -> Result(false, translated("oneconfig.page.hotwire.update.failed", "Hotwire couldn't update %s. It posts why in chat.", name)) + } + } + /** * Reads [jar] the way Hotwire does before adding or updating * @@ -394,7 +502,7 @@ object HotwireBridge { private fun updatePlan(engine: Any, jar: Path, preview: Any): JarPlan { fun update(method: String): () -> CompletableFuture? = { engine.callMethod("update", jar, engine.enumArg("update", method)) - null + updateResult(engine, jar, preview.get("name") as String, preview.get("newVersion") as String) } return when (preview.javaClass.simpleName) { "Ready" -> { @@ -428,6 +536,7 @@ object HotwireBridge { preview.get("version") as String, listOf(translated("oneconfig.page.hotwire.update.same", "This version is already installed.")), emptyList(), + upToDate = true, ) else -> JarPlan(jar.fileName.toString(), "", listOf(translated("oneconfig.page.hotwire.unreadable", "Hotwire can't read this jar.")), emptyList()) } @@ -439,4 +548,6 @@ object HotwireBridge { }.getOrElse { CompletableFuture.completedFuture(Result(false, "Hotwire is not available.")) } private const val MAX_ICON_BYTES = 8 * 1024 * 1024 + + private const val STORE_REVISION = 5 } 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 index 63235439a..93098d5fe 100644 --- 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 @@ -125,6 +125,8 @@ object HotwireUpdates { val plan = withContext(Dispatchers.IO) { runCatching { HotwireBridge.planUpdate(HotwireBridge.downloadUpdate(update)) } } + // an earlier update can already have brought this one along as a dependency + if (plan.getOrNull()?.upToDate == true) continue val action = plan.getOrNull()?.actions?.firstOrNull { it.primary } if (action == null) { val reason = plan.exceptionOrNull()?.let { it.cause ?: it }?.message ?: plan.getOrNull()?.lines?.firstOrNull() @@ -132,18 +134,14 @@ object HotwireUpdates { continue } val failed = action.attempt()?.let { withContext(Dispatchers.IO) { it.get() } }?.takeUnless { it.ok } - if (failed != null) { - problems += "${update.name}: ${failed.message}" - continue - } - withContext(Dispatchers.IO) { HotwireBridge.awaitIdle() } + if (failed != null) problems += "${update.name}: ${failed.message}" } } finally { updating = null refreshWhenDone() } if (problems.isEmpty()) { - Notifications.success("Hotwire", translated("oneconfig.page.hotwire.add.sent", "Sent to Hotwire. It posts the result in chat.")) + Notifications.success("Hotwire", translated("oneconfig.page.hotwire.updates.done", "Updates finished.")) onDone() } else { Notifications.error("Hotwire", problems.joinToString(" ")) @@ -198,10 +196,7 @@ fun hotwireTagline(): String { private val SwitchBarHeight = 56.dp -private const val PromptWaitSeconds = 5 - -/** The header's "Original menu" action on the Hotwire pages */ -val openHotwireScreen = Runnable { HotwireBridge.openScreen() } +val openHotwireScreen: Runnable? by lazy { Runnable { HotwireBridge.openScreen() }.takeIf { HotwireBridge.supportsOriginalScreen } } /** Reports what Hotwire says came of a change */ internal fun notifyHotwireResult(result: HotwireBridge.Result) { @@ -218,6 +213,18 @@ internal fun notifyHotwireResult(result: HotwireBridge.Result) { */ @Composable fun Hotwire() { + if (!HotwireBridge.supportsSwitching) { + Box(Modifier.fillMaxSize().padding(horizontal = PageGutter), contentAlignment = Alignment.Center) { + Text( + translated("oneconfig.page.hotwire.upgrade.page", "This version of Hotwire is too old for this page. 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 } @@ -294,7 +301,7 @@ fun Hotwire() { @Composable private fun OpeningPromptCard(prompt: HotwireBridge.OpeningPrompt, onDone: () -> Unit) { val theme = LocalTheme.current - var wait by remember { mutableIntStateOf(minOf(prompt.waitSeconds, PromptWaitSeconds)) } + var wait by remember { mutableIntStateOf(prompt.waitSeconds) } LaunchedEffect(Unit) { while (wait > 0) { delay(1000) 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 index f9987cb0a..d955f172d 100644 --- 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 @@ -112,7 +112,9 @@ fun HotwireAdd() { Chip(translated("oneconfig.page.hotwire.source.modrinth", "Modrinth"), source == AddSource.Modrinth, "search") { show(AddSource.Modrinth) } 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() } + if (HotwireBridge.supportsStore) { + 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(HotwireBridge.downloadUpdate(update)) } @@ -122,10 +124,20 @@ fun HotwireAdd() { } else { when (source) { AddSource.File -> FileSource { jar -> review("", { null }) { jar()?.let { HotwireBridge.planFor(it).get() } } } - 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.Modrinth -> if (HotwireBridge.supportsStore) { + 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() } } }, + ) + } else { + Card { + Text( + translated("oneconfig.page.hotwire.upgrade.store", "This version of Hotwire can't browse Modrinth here. Update Hotwire to use it."), + color = LocalTheme.current.textColorSecondary, + fontSize = 14.sp, + ) + } + } AddSource.Updates -> UpdatesSource { update -> reviewUpdate(update) { installedIcon(update.modId) } } } } @@ -191,9 +203,6 @@ private fun FileSource(onPicked: (() -> Path?) -> Unit) { } } -/** A name with only what identifies it, so "Fabric Language Kotlin" and fabric-language-kotlin are the same */ -private fun String.identity() = lowercase().filter(Char::isLetterOrDigit) - /** 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 { @@ -256,16 +265,11 @@ private fun ModrinthSource( else -> { val updates = HotwireUpdates.found?.getOrNull().orEmpty() val shown = remember(current, updates) { - // Hotwire only knows jars Modrinth recognises by hash, so also match what the loader says is running by - // id and name, which catches dev builds and projects whose slug isn't their mod id - val installed = HashSet() - Platform.compatibility().mods.forEach { installed += it.id.identity(); installed += it.name.identity() } - HotwireBridge.mods()?.forEach { installed += it.id.identity(); installed += it.name.identity() } current.getOrThrow().mapNotNull { hit -> val update = updates.firstOrNull { it.projectId == hit.projectId } - val isInstalled = hit.installed || listOf(hit.slug, hit.projectId, hit.title).any { it.identity() in installed } - // installed projects only show when there's a newer version to update to - if (update == null && isInstalled) null else hit to update + // only Hotwire's own answer hides a project, since a mod's name or id can match another project; a + // dev build it can't recognise stays listed and Hotwire says it's installed when asked to add it + if (update == null && hit.installed) null else hit to update } } val loaded = current.getOrThrow().size @@ -393,7 +397,7 @@ private fun PlanCard(reading: Boolean, plan: HotwireBridge.JarPlan?, icon: Strin 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.downloading", "Downloading…") + 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, From ff536fda93885f76d24fc00b8e84d73aa1a2a5d1 Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Fri, 30 Oct 2026 21:37:59 -0400 Subject: [PATCH 05/63] fix stuff --- .../assets/oneconfig/lang/en_us.json | 5 + .../internal/ui/api/HotwireBridge.kt | 102 ++++++++++++++++-- .../oneconfig/internal/ui/screens/Hotwire.kt | 8 +- 3 files changed, 107 insertions(+), 8 deletions(-) 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 f4b134a74..b3f4fe7f4 100644 --- a/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json +++ b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json @@ -274,6 +274,11 @@ "oneconfig.page.hotwire.update.done": "%s is updated.", "oneconfig.page.hotwire.update.queued": "%s updates at the next launch.", "oneconfig.page.hotwire.update.failed": "Hotwire couldn't update %s - please check chat!", + "oneconfig.page.hotwire.update.not_kept": "%s is updated for now, but the update won't stay after a restart - please check chat!", + "oneconfig.page.hotwire.update.waits": "This update needs %s, which only arrives at the next launch. Update this after restarting.", + "oneconfig.page.hotwire.switch.adds_back": "Adds back %s.", + "oneconfig.page.hotwire.switch.add_back_failed": "Hotwire couldn't add back %s - please check chat!", + "oneconfig.page.hotwire.switch.conflict": "Some of these mods need others you're turning off. Switch them separately.", "oneconfig.page.hotwire.update.adds": "Adds %s, which this update needs.", "oneconfig.page.hotwire.update.same": "This version is already installed.", "oneconfig.page.hotwire.update.unavailable": "Hotwire can't update mods in this game session.", 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 index 513fa64a7..e8cc54ad0 100644 --- 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 @@ -26,10 +26,14 @@ object HotwireBridge { /** [needsReopen] means nothing failed but the change only finishes once the world is reopened, which Hotwire's API does not do itself */ class Result(val ok: Boolean, val message: String, val needsReopen: Boolean = false) - /** Bumped whenever Hotwire finishes a switch, whoever started it */ + /** Bumped whenever Hotwire finishes a switch, whoever started it, or [invalidate] is called */ var revision by mutableIntStateOf(0) private set + fun invalidate() { + revision++ + } + private val apiClass: Class<*>? by lazy { runCatching { Class.forName("dev.moon.hotwire.api.HotwireApi") }.getOrNull() } private val api: Any? by lazy { @@ -81,10 +85,33 @@ object HotwireBridge { /** What turning [ids] on or off would do, including the mods Hotwire switches along with them */ fun preview(ids: Collection, on: Boolean): Preview = runCatching { - val preview = call(if (on) "previewOn" else "previewOff", ids.toList())!! - Preview(preview.get("possible") as Boolean, preview.get("message") as String? ?: "") + val kept = if (on) keptOff(ids) else emptyList() + val preview = (ids - kept.toSet()).takeIf { it.isNotEmpty() }?.let { call(if (on) "previewOn" else "previewOff", it)!! } + val addsBack = kept.takeIf { it.isNotEmpty() }?.let { mods -> + translated("oneconfig.page.hotwire.switch.adds_back", "Adds back %s.", mods.joinToString(", ") { engine()!!.callMethod("nameOfMod", it) as String }) + } + Preview(preview?.get("possible") as Boolean? ?: true, listOfNotNull(preview?.get("message") as String?, addsBack).joinToString(" ")) }.getOrElse { Preview(false, "Hotwire is not available.") } + @Suppress("UNCHECKED_CAST") + fun previewTogether(off: List, on: List): Preview? { + if (off.isEmpty() || on.isEmpty()) return null + val cameBack = runCatching { + val engine = engine()!! + val leaving = call("previewOff", off)!!.get("switched") as List + val alreadyOff = mods().orEmpty().filterNot { it.on }.map { it.id } + val planner = engine.callStatic("ModPolicy", "makePlannerFor", engine.callHidden("getGraph")!!)!! + val plan = planner.callMethod("planOn", on - keptOff(on).toSet(), (alreadyOff + leaving).toSet())!! + (plan.get("order") as List).any { it in leaving } + }.getOrDefault(true) + return if (cameBack) Preview(false, translated("oneconfig.page.hotwire.switch.conflict", "Some of these mods need others you're turning off. Switch them separately.")) else null + } + + private fun keptOff(ids: Collection): List = runCatching { + val disabled = engine()!!.callHidden("getDisabled")!! + ids.filter { disabled.callHidden("isDisabledSinceStart", it) == true } + }.getOrDefault(emptyList()) + /** Whether Hotwire is running a switch, add or update right now */ fun isWorking(): Boolean = runCatching { call("getProgress")!!.get("running") as Boolean }.getOrDefault(false) @@ -135,6 +162,12 @@ object HotwireBridge { private fun Any.callMethod(name: String, vararg args: Any): Any? = javaClass.methods.first { it.name == name && it.parameterCount == args.size }.invoke(this, *args) + private fun Any.callHidden(name: String, vararg args: Any): Any? = + javaClass.declaredMethods.first { it.name == name && it.parameterCount == args.size }.apply { isAccessible = true }.invoke(this, *args) + + private fun Any.callStatic(type: String, name: String, vararg args: Any): Any? = + Class.forName("${javaClass.packageName}.$type").methods.first { it.name == name && it.parameterCount == args.size }.invoke(null, *args) + /** The constant named [constant] of the enum a two-argument [method] of the engine takes last */ private fun Any.enumArg(method: String, constant: String): Any = javaClass.methods.first { it.name == method && it.parameterCount == 2 }.parameterTypes[1] @@ -312,7 +345,10 @@ object HotwireBridge { true, ) { accepted = true - add.through("StoreAdd", "accept").asResults() + add.through("StoreAdd", "accept").asResults().thenApply { result -> + if (result.ok) engine()?.callMethod("getStore")?.callMethod("getModel")?.callMethod("markInstalled", hit.projectId) + result + } } @Suppress("UNCHECKED_CAST") return JarPlan( @@ -415,6 +451,21 @@ object HotwireBridge { return updateNow(jar) } + @Suppress("UNCHECKED_CAST") + private fun waitingNeeds(engine: Any, jar: Path): List { + val manifest = manifestOf(jar) ?: return emptyList() + val graph = engine.callHidden("getGraph")!! + val depends = Class.forName("dev.moon.hotwire.jar.ModManifest\$Relation").enumConstants.first { (it as Enum<*>).name == "DEPENDS" } + val needs = (manifest.callMethod("getRelation", depends) as Map>).mapKeys { graph.callMethod("resolve", it.key) } + return (engine.callMethod("getPendingChanges") as List<*>).filter { change -> + if (change!!.javaClass.simpleName != "Update") return@filter false + val id = change.get("modId") as String + val ranges = needs[id] ?: return@filter false + val running = engine.callStatic("LoadedMods", "findVersion", id) + running == null || engine.callStatic("ModPolicy", "satisfies", running, ranges) != true + }.map { it!!.get("name") as String } + } + private fun onUiThread(block: () -> T): T { val done = CompletableFuture() Platform.screen().runOnUiThread { runCatching(block).fold(done::complete, done::completeExceptionally) } @@ -448,8 +499,10 @@ object HotwireBridge { val queued = (engine.callMethod("getPendingChanges") as List<*>).any { it!!.javaClass.simpleName == "Update" && it.get("name") == name && it.get("toVersion") == version } + val live = after.javaClass.simpleName == "SameVersion" when { - after.javaClass.simpleName == "SameVersion" -> Result(true, translated("oneconfig.page.hotwire.update.done", "%s is updated.", name)) + live && queued -> Result(true, translated("oneconfig.page.hotwire.update.done", "%s is updated.", name)) + live -> Result(false, translated("oneconfig.page.hotwire.update.not_kept", "%s is updated for now, but the update won't stay after a restart - please check chat!", name)) queued -> Result(true, translated("oneconfig.page.hotwire.update.queued", "%s updates at the next launch.", name)) after.javaClass.simpleName == "Refused" -> Result(false, after.get("reason") as String) else -> Result(false, translated("oneconfig.page.hotwire.update.failed", "Hotwire couldn't update %s. It posts why in chat.", name)) @@ -509,6 +562,15 @@ object HotwireBridge { val method = (preview.get("method") as Enum<*>).name val inPlace = method == "IN_PLACE" val lines = preview.get("lines") as List + val waiting = if (inPlace) waitingNeeds(engine, jar) else emptyList() + if (waiting.isNotEmpty()) { + return JarPlan( + preview.get("name") as String, + "${preview.get("installedVersion")} → ${preview.get("newVersion")}", + listOf(translated("oneconfig.page.hotwire.update.waits", "This update needs %s, which only arrives at the next launch. Update this after restarting.", waiting.joinToString(", "))), + emptyList(), + ) + } JarPlan( preview.get("name") as String, "${preview.get("installedVersion")} → ${preview.get("newVersion")}", @@ -544,9 +606,37 @@ object HotwireBridge { /** Turns [ids] on or off, completing on whatever thread Hotwire finishes on */ fun switch(ids: Collection, on: Boolean): CompletableFuture = runCatching { - call(if (on) "turnOn" else "turnOff", ids.toList()).asResults() + val kept = if (on) keptOff(ids) else emptyList() + if (kept.isEmpty()) { + call(if (on) "turnOn" else "turnOff", ids.toList()).asResults() + } else { + CompletableFuture.supplyAsync { turnOnKeptOff(engine()!!, ids, kept) } + } }.getOrElse { CompletableFuture.completedFuture(Result(false, "Hotwire is not available.")) } + private fun awaitWorker(engine: Any, jar: Path) { + runCatching { dryRunAdd(engine, jar) } + } + + private fun turnOnKeptOff(engine: Any, ids: Collection, kept: List): Result { + val jars = kept.map { engine.callMethod("findJarOf", it) as Path } + for (jar in jars) { + val preview = dryRunAdd(engine, jar) + if ((preview.get("mode") as Enum<*>).name != "NOW") return Result(false, preview.lineOfAdd()) + } + val result = call("turnOn", ids.toList()).asResults().get() + if (!result.ok) return result + awaitWorker(engine, jars.first()) + val stillOff = onUiThread { + invalidate() + val on = mods().orEmpty().filter { it.on }.map { it.id } + kept.filter { it !in on } + } + if (stillOff.isEmpty()) return result + val names = stillOff.joinToString(", ") { engine.callMethod("nameOfMod", it) as String } + return Result(false, translated("oneconfig.page.hotwire.switch.add_back_failed", "Hotwire couldn't add back %s - please check chat!", names)) + } + private const val MAX_ICON_BYTES = 8 * 1024 * 1024 private const val STORE_REVISION = 5 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 index 93098d5fe..42a1259c5 100644 --- 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 @@ -97,11 +97,14 @@ object HotwireUpdates { /** * 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 + * 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) + fun refreshWhenDone() = refreshAfter { + HotwireBridge.awaitIdle() + Platform.screen().runOnUiThread(HotwireBridge::invalidate) + } private fun refreshAfter(wait: () -> Unit) { checked = true @@ -381,6 +384,7 @@ private fun SwitchBar( val turnOn = remember(picked, states) { idsWhere(false) } val previews = remember(turnOff, turnOn) { listOfNotNull( + HotwireBridge.previewTogether(turnOff, turnOn), turnOff.takeIf { it.isNotEmpty() }?.let { HotwireBridge.preview(it, false) }, turnOn.takeIf { it.isNotEmpty() }?.let { HotwireBridge.preview(it, true) }, ) From 85cfea35153ec89fc3f766f1fec91f16b0c92606 Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Fri, 30 Oct 2026 22:33:46 -0400 Subject: [PATCH 06/63] a --- .../assets/oneconfig/lang/en_us.json | 2 + .../internal/ui/api/HotwireBridge.kt | 96 ++++++++++++++----- 2 files changed, 75 insertions(+), 23 deletions(-) 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 b3f4fe7f4..408e9d494 100644 --- a/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json +++ b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json @@ -261,6 +261,8 @@ "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.failed": "Hotwire couldn't add %s - please check chat!", + "oneconfig.page.hotwire.add.adds": "Adds %s, which this mod needs.", + "oneconfig.page.hotwire.add.done": "Added %s.", "oneconfig.page.hotwire.add.reading": "Reading the jar…", "oneconfig.page.hotwire.add.now": "Add now", "oneconfig.page.hotwire.add.reopen": "Save and add", 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 index e8cc54ad0..ec4a7c170 100644 --- 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 @@ -99,14 +99,19 @@ object HotwireBridge { val cameBack = runCatching { val engine = engine()!! val leaving = call("previewOff", off)!!.get("switched") as List - val alreadyOff = mods().orEmpty().filterNot { it.on }.map { it.id } - val planner = engine.callStatic("ModPolicy", "makePlannerFor", engine.callHidden("getGraph")!!)!! - val plan = planner.callMethod("planOn", on - keptOff(on).toSet(), (alreadyOff + leaving).toSet())!! + val disabled = engine.field("installed")!!.field("disabled") as Map + val planner = plannerWith(engine, keptOff(on).map(disabled::getValue)) + val plan = planner.callMethod("planOn", on, (offMods() + leaving).toSet())!! (plan.get("order") as List).any { it in leaving } }.getOrDefault(true) return if (cameBack) Preview(false, translated("oneconfig.page.hotwire.switch.conflict", "Some of these mods need others you're turning off. Switch them separately.")) else null } + private fun offMods(): List = mods().orEmpty().filterNot { it.on }.map { it.id } + + private fun plannerWith(engine: Any, jars: List): Any = + engine.callStatic("ModPolicy", "makePlannerFor", engine.field("installed")!!.callMethod("makeGraphWith", jars)!!)!! + private fun keptOff(ids: Collection): List = runCatching { val disabled = engine()!!.callHidden("getDisabled")!! ids.filter { disabled.callHidden("isDisabledSinceStart", it) == true } @@ -159,6 +164,8 @@ object HotwireBridge { Class.forName("${api!!.javaClass.packageName}.HotwireClient").getMethod("findEngine").invoke(null) }.getOrNull() + private fun Any.field(name: String): Any? = javaClass.getDeclaredField(name).apply { isAccessible = true }.get(this) + private fun Any.callMethod(name: String, vararg args: Any): Any? = javaClass.methods.first { it.name == name && it.parameterCount == args.size }.invoke(this, *args) @@ -325,6 +332,10 @@ object HotwireBridge { fun storePlan(hit: StoreHit): JarPlan { val version = newestVersion(hit.projectId) val add = (call("requestAdd", hit.projectId, version) as CompletableFuture<*>).get()!! + val canAdd = add.through("StoreAdd", "canAdd") == true + @Suppress("UNCHECKED_CAST") + val jars = add.field("jars") as List + val inOrder = engine()?.takeIf { !canAdd && fitTogether(it, jars) } var accepted = false val later = JarAction(translated("oneconfig.page.hotwire.add.next_start", "Add at next launch"), false) { accepted = true @@ -345,21 +356,59 @@ object HotwireBridge { true, ) { accepted = true - add.through("StoreAdd", "accept").asResults().thenApply { result -> + val added = if (inOrder == null) { + add.through("StoreAdd", "accept").asResults() + } else { + add.through("StoreAdd", "cancel") + CompletableFuture.supplyAsync { addInOrder(inOrder, jars, hit.title) } + } + added.thenApply { result -> if (result.ok) engine()?.callMethod("getStore")?.callMethod("getModel")?.callMethod("markInstalled", hit.projectId) result } } @Suppress("UNCHECKED_CAST") + val lines = if (inOrder == null) { + add.through("StoreAdd", "getLines") as List + } else { + (add.field("previews") as List<*>).dropLast(1).map { + translated("oneconfig.page.hotwire.add.adds", "Adds %s, which this mod needs.", it!!.get("name") as String) + } + } return JarPlan( add.through("StoreAdd", "getTitle") as String, "", - add.through("StoreAdd", "getLines") as List, - (if (add.through("StoreAdd", "canAdd") == true) listOf(now, later) else emptyList()) + save, + lines, + (if (canAdd || inOrder != null) listOf(now, later) else emptyList()) + save, onCancel = { if (!accepted) add.through("StoreAdd", "cancel") }, ) } + @Suppress("UNCHECKED_CAST") + private fun fitTogether(engine: Any, jars: List): Boolean = runCatching { + if (jars.size < 2 || jars.any { engine.callMethod("canUpdate", it) == true }) return false + val reader = Class.forName("dev.moon.hotwire.jar.ModJarReader").getMethod("read", Path::class.java) + val read = jars.map { reader.invoke(null, it)!! } + val ids = read.map { it.get("manifest")!!.get("id") as String } + val planner = plannerWith(engine, read) + (planner.callMethod("planOn", ids, (offMods() + ids).toSet())!!.get("refusals") as List<*>).isEmpty() && + ids.all { (planner.callMethod("findOldDependencies", it) as List<*>).isEmpty() } + }.getOrDefault(false) + + private fun addInOrder(engine: Any, jars: List, name: String): Result { + var queueing = false + for (jar in jars) { + if (queueing) { + onUiThread { engine.callMethod("add", jar, engine.enumArg("add", "NEXT_START")) } + continue + } + queueing = (dryRunAdd(engine, jar).get("mode") as Enum<*>).name == "NEXT_START" + val result = addNow(engine, jar) + if (!result.ok) return result + } + return Result(true, translated("oneconfig.page.hotwire.add.done", "Added %s.", name)) + } + /** Hotwire's update check against Modrinth for the installed mods; blocks on the network */ fun findUpdates(): List { val check = engine()!!.callMethod("getUpdateCheck")!! @@ -384,8 +433,7 @@ object HotwireBridge { /** The mods stopping an update, or none when Hotwire can't plan it, which is how it answers any other refusal */ private fun unmetNeeds(engine: Any, jar: Path): List { - val changes = engine.javaClass.getDeclaredField("changes").apply { isAccessible = true }.get(engine)!! - val plan = changes.callMethod("planUpdate", jar) ?: return emptyList() + val plan = engine.field("changes")!!.callMethod("planUpdate", jar) ?: return emptyList() return (plan.get("blockers") as List<*>).filter { it!!.javaClass.simpleName == "DependencyUnmet" }.map { it!!.get("mod") as String } } @@ -619,22 +667,24 @@ object HotwireBridge { } private fun turnOnKeptOff(engine: Any, ids: Collection, kept: List): Result { - val jars = kept.map { engine.callMethod("findJarOf", it) as Path } - for (jar in jars) { - val preview = dryRunAdd(engine, jar) - if ((preview.get("mode") as Enum<*>).name != "NOW") return Result(false, preview.lineOfAdd()) - } - val result = call("turnOn", ids.toList()).asResults().get() - if (!result.ok) return result - awaitWorker(engine, jars.first()) - val stillOff = onUiThread { - invalidate() - val on = mods().orEmpty().filter { it.on }.map { it.id } - kept.filter { it !in on } + val waiting = kept.associateWithTo(LinkedHashMap()) { engine.callMethod("findJarOf", it) as Path } + while (waiting.isNotEmpty()) { + val (id, jar) = waiting.entries.firstOrNull { (dryRunAdd(engine, it.value).get("mode") as Enum<*>).name == "NOW" } + ?: return Result(false, dryRunAdd(engine, waiting.values.first()).lineOfAdd()) + val result = call("turnOn", listOf(id)).asResults().get() + if (!result.ok) return result + awaitWorker(engine, jar) + val back = onUiThread { + invalidate() + mods().orEmpty().any { it.id == id && it.on } + } + if (!back) { + return Result(false, translated("oneconfig.page.hotwire.switch.add_back_failed", "Hotwire couldn't add back %s - please check chat!", engine.callMethod("nameOfMod", id) as String)) + } + waiting.remove(id) } - if (stillOff.isEmpty()) return result - val names = stillOff.joinToString(", ") { engine.callMethod("nameOfMod", it) as String } - return Result(false, translated("oneconfig.page.hotwire.switch.add_back_failed", "Hotwire couldn't add back %s - please check chat!", names)) + val rest = ids - kept.toSet() + return if (rest.isEmpty()) Result(true, "") else call("turnOn", rest).asResults().get() } private const val MAX_ICON_BYTES = 8 * 1024 * 1024 From 9e4a7b3db18e68470331c362db5c51e15633b13f Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Fri, 30 Oct 2026 23:11:36 -0400 Subject: [PATCH 07/63] Update HotwireBridge.kt --- .../internal/ui/api/HotwireBridge.kt | 83 ++++++++++++++----- 1 file changed, 61 insertions(+), 22 deletions(-) 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 index ec4a7c170..b3fafc2d9 100644 --- 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 @@ -314,10 +314,20 @@ object HotwireBridge { // the first search creates Hotwire's store, whose installed check is still queued on its single worker, so // this runs once that has finished and only then are the installed flags right val model = engine()?.callMethod("getStore")?.callMethod("getModel") ?: return first + awaitStoreWorker(model) + return search() + } + + private fun awaitStoreWorker(model: Any) { val checked = CompletableFuture() model.callMethod("run", Runnable { checked.complete(Unit) }) checked.get(60, TimeUnit.SECONDS) - return search() + } + + private fun refreshInstalled() { + val store = engine()?.callMethod("getStore") ?: return + store.callMethod("checkInstalled") + awaitStoreWorker(store.callMethod("getModel")!!) } /** The newest version of a project that fits this game; blocks on the network */ @@ -362,7 +372,8 @@ object HotwireBridge { add.through("StoreAdd", "cancel") CompletableFuture.supplyAsync { addInOrder(inOrder, jars, hit.title) } } - added.thenApply { result -> + added.thenApplyAsync { result -> + runCatching { refreshInstalled() } if (result.ok) engine()?.callMethod("getStore")?.callMethod("getModel")?.callMethod("markInstalled", hit.projectId) result } @@ -384,12 +395,16 @@ object HotwireBridge { ) } + private fun readJar(jar: Path): Any = + Class.forName("dev.moon.hotwire.jar.ModJarReader").getMethod("read", Path::class.java).invoke(null, jar)!! + + private fun Any.modId(): String = get("manifest")!!.get("id") as String + @Suppress("UNCHECKED_CAST") private fun fitTogether(engine: Any, jars: List): Boolean = runCatching { if (jars.size < 2 || jars.any { engine.callMethod("canUpdate", it) == true }) return false - val reader = Class.forName("dev.moon.hotwire.jar.ModJarReader").getMethod("read", Path::class.java) - val read = jars.map { reader.invoke(null, it)!! } - val ids = read.map { it.get("manifest")!!.get("id") as String } + val read = jars.map(::readJar) + val ids = read.map { it.modId() } val planner = plannerWith(engine, read) (planner.callMethod("planOn", ids, (offMods() + ids).toSet())!!.get("refusals") as List<*>).isEmpty() && ids.all { (planner.callMethod("findOldDependencies", it) as List<*>).isEmpty() } @@ -441,27 +456,34 @@ object HotwireBridge { * Adds to [steps] what has to land before [jar] can update, each after what it needs in turn, the way Hotwire's * own update queue meets needs; false when Hotwire refuses it for anything else */ - @Suppress("UNCHECKED_CAST") private fun collectNeeds(engine: Any, jar: Path, seen: MutableSet, steps: MutableList): Boolean { val preview = (engine.callMethod("dryRunUpdate", jar) as CompletableFuture<*>).get()!! val kind = preview.javaClass.simpleName if (kind != "Refused") return kind == "Ready" val unmet = unmetNeeds(engine, jar) - if (unmet.isEmpty()) return false - val check = engine.callMethod("getUpdateCheck")!! - for (id in unmet) { - if (!seen.add(id)) continue - val jars = check.callMethod("fetchById", id, offers, Consumer {}) as List - if (jars.isEmpty()) return false - for (need in jars) { - if (need in steps) continue - if (engine.callMethod("canUpdate", need) == true && !collectNeeds(engine, need, seen, steps)) return false - steps.add(need) - } + return unmet.isNotEmpty() && unmet.all { fetchNeed(engine, it, seen, steps) } + } + + @Suppress("UNCHECKED_CAST") + private fun fetchNeed(engine: Any, id: String, seen: MutableSet, steps: MutableList): Boolean { + if (!seen.add(id)) return true + val jars = engine.callMethod("getUpdateCheck")!!.callMethod("fetchById", id, offers, Consumer {}) as List + if (jars.isEmpty()) return false + for (need in jars) { + if (need in steps) continue + val met = if (engine.callMethod("canUpdate", need) == true) collectNeeds(engine, need, seen, steps) else meetOldDependencies(engine, need, seen, steps) + if (!met) return false + steps.add(need) } return true } + private fun meetOldDependencies(engine: Any, jar: Path, seen: MutableSet, steps: MutableList): Boolean { + val read = readJar(jar) + val old = plannerWith(engine, listOf(read)).callMethod("findOldDependencies", read.modId()) as List<*> + return old.all { fetchNeed(engine, it!!.get("dependency") as String, seen, steps) } + } + /** What applying [jars] from [downloadUpdate] would do; blocks on Hotwire reading them and on the network */ @Suppress("UNCHECKED_CAST") fun planUpdate(jars: List): JarPlan { @@ -469,13 +491,17 @@ object HotwireBridge { val plan = planFor(jar, update = true).get() val engine = engine() if (engine == null || engine.callMethod("canUpdate") != true) return plan - val steps = jars.dropLast(1).toMutableList() + val steps = mutableListOf() val seen = jars.mapNotNull(::manifestOf).flatMapTo(HashSet()) { (it.get("provides") as List) + (it.get("id") as String) } - if (!collectNeeds(engine, jar, seen, steps) || steps.isEmpty()) return plan + val needs = jars.dropLast(1) + val collected = needs.all { need -> + (engine.callMethod("canUpdate", need) == true || meetOldDependencies(engine, need, seen, steps)).also { if (it) steps.add(need) } + } && collectNeeds(engine, jar, seen, steps) // a later add can be refused now only for lacking an earlier one, so those are read again as they run - steps.first().takeIf { engine.callMethod("canUpdate", it) != true }?.let { dryRunAdd(engine, it) } - ?.takeIf { (it.get("mode") as Enum<*>).name == "REFUSED" } + (if (collected) steps.take(1) else needs).filter { engine.callMethod("canUpdate", it) != true } + .map { dryRunAdd(engine, it) }.firstOrNull { (it.get("mode") as Enum<*>).name == "REFUSED" } ?.let { return JarPlan(plan.title, plan.detail, it.get("lines") as List, emptyList()) } + if (!collected || steps.isEmpty()) return plan val action = JarAction(translated("oneconfig.page.hotwire.update.with_needs", "Update with dependencies"), true) { CompletableFuture.supplyAsync { runSteps(engine, steps, jar) } } @@ -561,6 +587,7 @@ object HotwireBridge { * Reads [jar] the way Hotwire does before adding or updating * * An [update] is read as one straight away; otherwise it is one when Hotwire says that mod is already installed + * or that this jar replaces an installed mod under a new id */ fun planFor(jar: Path, update: Boolean = false): CompletableFuture = runCatching { val engine = engine()!! @@ -579,10 +606,22 @@ object HotwireBridge { if (update || engine.callMethod("canUpdate", jar) == true) { (engine.callMethod("dryRunUpdate", jar) as CompletableFuture).thenApply { updatePlan(engine, jar, it) } } else { - (engine.callMethod("dryRunAdd", jar) as CompletableFuture).thenApply { addPlan(engine, jar, it) } + (engine.callMethod("dryRunUpdate", jar) as CompletableFuture).thenCompose { preview -> + if (replacesInstalled(preview, jar)) { + CompletableFuture.completedFuture(updatePlan(engine, jar, preview)) + } else { + (engine.callMethod("dryRunAdd", jar) as CompletableFuture).thenApply { addPlan(engine, jar, it) } + } + } } }.getOrElse { CompletableFuture.completedFuture(JarPlan(jar.fileName.toString(), "", listOf("Hotwire is not available."), emptyList())) } + private fun replacesInstalled(preview: Any, jar: Path): Boolean = when (preview.javaClass.simpleName) { + "Ready" -> true + "Refused" -> preview.get("name") != jar.fileName.toString() + else -> false + } + @Suppress("UNCHECKED_CAST") private fun addPlan(engine: Any, jar: Path, preview: Any): JarPlan { fun add(mode: String): () -> CompletableFuture? = { From 9cb0969a3c7d42ed665c5744cef6d32946780804 Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Fri, 30 Oct 2026 23:48:02 -0400 Subject: [PATCH 08/63] fa --- .../assets/oneconfig/lang/en_us.json | 7 + .../internal/ui/api/HotwireBridge.kt | 156 +++++++++++++----- .../oneconfig/internal/ui/screens/Hotwire.kt | 17 +- .../internal/ui/screens/HotwireAdd.kt | 7 +- 4 files changed, 143 insertions(+), 44 deletions(-) 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 408e9d494..e71c05fbc 100644 --- a/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json +++ b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json @@ -255,6 +255,8 @@ "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.reopen": "It has to save and reopen your world, so update it on its own.", + "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...", @@ -263,6 +265,9 @@ "oneconfig.page.hotwire.add.failed": "Hotwire couldn't add %s - please check chat!", "oneconfig.page.hotwire.add.adds": "Adds %s, which this mod needs.", "oneconfig.page.hotwire.add.done": "Added %s.", + "oneconfig.page.hotwire.add.queued": "%s is added at the next launch.", + "oneconfig.page.hotwire.add.queue_failed": "Hotwire couldn't queue %s - please check chat!", + "oneconfig.page.hotwire.reopen_confirm": "%s has to save and reopen your world. Try again to confirm.", "oneconfig.page.hotwire.add.reading": "Reading the jar…", "oneconfig.page.hotwire.add.now": "Add now", "oneconfig.page.hotwire.add.reopen": "Save and add", @@ -281,6 +286,8 @@ "oneconfig.page.hotwire.switch.adds_back": "Adds back %s.", "oneconfig.page.hotwire.switch.add_back_failed": "Hotwire couldn't add back %s - please check chat!", "oneconfig.page.hotwire.switch.conflict": "Some of these mods need others you're turning off. Switch them separately.", + "oneconfig.page.hotwire.switch.after_off": "Turns on %s once the others are off.", + "oneconfig.page.hotwire.switch.reopen": "Save and switch", "oneconfig.page.hotwire.update.adds": "Adds %s, which this update needs.", "oneconfig.page.hotwire.update.same": "This version is already installed.", "oneconfig.page.hotwire.update.unavailable": "Hotwire can't update mods in this game session.", 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 index b3fafc2d9..8fe88f541 100644 --- 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 @@ -96,15 +96,19 @@ object HotwireBridge { @Suppress("UNCHECKED_CAST") fun previewTogether(off: List, on: List): Preview? { if (off.isEmpty() || on.isEmpty()) return null - val cameBack = runCatching { + val plan = runCatching { val engine = engine()!! val leaving = call("previewOff", off)!!.get("switched") as List val disabled = engine.field("installed")!!.field("disabled") as Map val planner = plannerWith(engine, keptOff(on).map(disabled::getValue)) - val plan = planner.callMethod("planOn", on, (offMods() + leaving).toSet())!! - (plan.get("order") as List).any { it in leaving } - }.getOrDefault(true) - return if (cameBack) Preview(false, translated("oneconfig.page.hotwire.switch.conflict", "Some of these mods need others you're turning off. Switch them separately.")) else null + planner.callMethod("planOn", on, (offMods() + leaving).toSet())!!.takeUnless { plan -> + (plan.get("order") as List).any { it in leaving } + } + }.getOrNull() ?: return Preview(false, translated("oneconfig.page.hotwire.switch.conflict", "Some of these mods need others you're turning off. Switch them separately.")) + val alone = preview(on, true) + if (alone.possible || (plan.get("refusals") as List<*>).isNotEmpty()) return alone + val names = (plan.get("order") as List).joinToString(", ") { engine()!!.callMethod("nameOfMod", it) as String } + return Preview(true, translated("oneconfig.page.hotwire.switch.after_off", "Turns on %s once the others are off.", names)) } private fun offMods(): List = mods().orEmpty().filterNot { it.on }.map { it.id } @@ -155,6 +159,7 @@ object HotwireBridge { val actions: List, val onCancel: (() -> Unit)? = null, val upToDate: Boolean = false, + val reopens: Boolean = false, ) // shortcut: Hotwire's public API can't add a jar from disk or list updates, so those reach into its engine, which @@ -261,7 +266,11 @@ object HotwireBridge { if (!Files.exists(file)) { val uri = URI(url) require(uri.scheme == "https") { "Icons only load over https." } - Files.write(file, uri.toURL().openStream().use { it.readNBytes(MAX_ICON_BYTES) }) + val connection = uri.toURL().openConnection().apply { + connectTimeout = ICON_TIMEOUT_MS + readTimeout = ICON_TIMEOUT_MS + } + Files.write(file, connection.getInputStream().use { it.readNBytes(MAX_ICON_BYTES) }) } return file } @@ -346,8 +355,11 @@ object HotwireBridge { @Suppress("UNCHECKED_CAST") val jars = add.field("jars") as List val inOrder = engine()?.takeIf { !canAdd && fitTogether(it, jars) } + val reopen = add.through("StoreAdd", "needsReopen") == true + val modes = (add.field("previews") as List<*>).map { it!!.addMode() } + val onlyLater = modes.lastOrNull() == "NEXT_START" || inOrder != null && "NEXT_START" in modes var accepted = false - val later = JarAction(translated("oneconfig.page.hotwire.add.next_start", "Add at next launch"), false) { + val later = JarAction(translated("oneconfig.page.hotwire.add.next_start", "Add at next launch"), onlyLater) { accepted = true add.through("StoreAdd", "cancel") call("queueForNextStart", hit.projectId, version).asResults() @@ -358,7 +370,7 @@ object HotwireBridge { call("saveToDownloads", storeQuery(""), hit.projectId, version).asResults() } val now = JarAction( - if (add.through("StoreAdd", "needsReopen") == true) { + if (reopen) { translated("oneconfig.page.hotwire.add.reopen", "Save and add") } else { translated("oneconfig.page.hotwire.add.now", "Add now") @@ -370,7 +382,7 @@ object HotwireBridge { add.through("StoreAdd", "accept").asResults() } else { add.through("StoreAdd", "cancel") - CompletableFuture.supplyAsync { addInOrder(inOrder, jars, hit.title) } + CompletableFuture.supplyAsync { addInOrder(inOrder, jars, hit.title, reopen) } } added.thenApplyAsync { result -> runCatching { refreshInstalled() } @@ -390,7 +402,11 @@ object HotwireBridge { add.through("StoreAdd", "getTitle") as String, "", lines, - (if (canAdd || inOrder != null) listOf(now, later) else emptyList()) + save, + when { + !canAdd && inOrder == null -> listOf(save) + onlyLater -> listOf(later, save) + else -> listOf(now, later, save) + }, onCancel = { if (!accepted) add.through("StoreAdd", "cancel") }, ) } @@ -410,26 +426,30 @@ object HotwireBridge { ids.all { (planner.callMethod("findOldDependencies", it) as List<*>).isEmpty() } }.getOrDefault(false) - private fun addInOrder(engine: Any, jars: List, name: String): Result { + private fun addInOrder(engine: Any, jars: List, name: String, reopen: Boolean): Result { var queueing = false for (jar in jars) { - if (queueing) { - onUiThread { engine.callMethod("add", jar, engine.enumArg("add", "NEXT_START")) } - continue - } - queueing = (dryRunAdd(engine, jar).get("mode") as Enum<*>).name == "NEXT_START" - val result = addNow(engine, jar) + queueing = queueing || dryRunAdd(engine, jar).addMode() == "NEXT_START" + val result = if (queueing) queueAdd(engine, jar) else addNow(engine, jar, reopen) if (!result.ok) return result } - return Result(true, translated("oneconfig.page.hotwire.add.done", "Added %s.", name)) + return Result( + true, + if (queueing) { + translated("oneconfig.page.hotwire.add.queued", "%s is added at the next launch.", name) + } else { + translated("oneconfig.page.hotwire.add.done", "Added %s.", name) + }, + ) } /** Hotwire's update check against Modrinth for the installed mods; blocks on the network */ fun findUpdates(): List { - val check = engine()!!.callMethod("getUpdateCheck")!! - val found = check.callMethod("findNow") as List<*> + val engine = engine()!! + val found = engine.callMethod("getUpdateCheck")!!.callMethod("findNow") as List<*> offers = found - return found.map { + val queued = (engine.callMethod("getPendingChanges") as List<*>).filter { it!!.javaClass.simpleName == "Update" }.map { it!!.get("modId") } + return found.filterNot { it!!.get("id") in queued }.map { StoreUpdate(it!!, 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) } } @@ -446,6 +466,8 @@ object HotwireBridge { private fun Any.lineOfAdd(): String = (get("lines") as List<*>).firstOrNull() as String? ?: get("name") as String + private fun Any.addMode(): String = (get("mode") as Enum<*>).name + /** The mods stopping an update, or none when Hotwire can't plan it, which is how it answers any other refusal */ private fun unmetNeeds(engine: Any, jar: Path): List { val plan = engine.field("changes")!!.callMethod("planUpdate", jar) ?: return emptyList() @@ -498,12 +520,19 @@ object HotwireBridge { (engine.callMethod("canUpdate", need) == true || meetOldDependencies(engine, need, seen, steps)).also { if (it) steps.add(need) } } && collectNeeds(engine, jar, seen, steps) // a later add can be refused now only for lacking an earlier one, so those are read again as they run - (if (collected) steps.take(1) else needs).filter { engine.callMethod("canUpdate", it) != true } - .map { dryRunAdd(engine, it) }.firstOrNull { (it.get("mode") as Enum<*>).name == "REFUSED" } + val firstAdds = (if (collected) steps.take(1) else needs).filter { engine.callMethod("canUpdate", it) != true } + .map { dryRunAdd(engine, it) } + firstAdds.firstOrNull { it.addMode() == "REFUSED" } ?.let { return JarPlan(plan.title, plan.detail, it.get("lines") as List, emptyList()) } if (!collected || steps.isEmpty()) return plan - val action = JarAction(translated("oneconfig.page.hotwire.update.with_needs", "Update with dependencies"), true) { - CompletableFuture.supplyAsync { runSteps(engine, steps, jar) } + val reopen = firstAdds.any { it.addMode() == "SAVE_AND_REOPEN" } + val label = if (reopen) { + translated("oneconfig.page.hotwire.update.reopen", "Save and update") + } else { + translated("oneconfig.page.hotwire.update.with_needs", "Update with dependencies") + } + val action = JarAction(label, true) { + CompletableFuture.supplyAsync { runSteps(engine, steps, jar, reopen) } } val lines = steps.map { val name = manifestOf(it)?.get("name") as String? ?: it.fileName.toString() @@ -513,13 +542,13 @@ object HotwireBridge { translated("oneconfig.page.hotwire.update.adds", "Adds %s, which this update needs.", name) } } - return JarPlan(plan.title, plan.detail, lines + plan.lines.takeIf { plan.actions.isNotEmpty() }.orEmpty(), listOf(action)) + return JarPlan(plan.title, plan.detail, lines + plan.lines.takeIf { plan.actions.isNotEmpty() }.orEmpty(), listOf(action), reopens = reopen) } /** Lands [steps] in order and then updates [jar], stopping at the first that fails, since each one's plan rests on those before */ - private fun runSteps(engine: Any, steps: List, jar: Path): Result { + private fun runSteps(engine: Any, steps: List, jar: Path, reopen: Boolean): Result { for (step in steps) { - val result = if (engine.callMethod("canUpdate", step) == true) updateNow(step) else addNow(engine, step) + val result = if (engine.callMethod("canUpdate", step) == true) updateNow(step) else addNow(engine, step, reopen) if (!result.ok) return result } return updateNow(jar) @@ -553,14 +582,32 @@ object HotwireBridge { return onUiThread(action::attempt)!!.get() } - private fun addNow(engine: Any, jar: Path): Result { + private fun reopenNotAgreed(name: String) = + Result(false, translated("oneconfig.page.hotwire.reopen_confirm", "%s has to save and reopen your world. Try again to confirm.", name), needsReopen = true) + + private fun addNow(engine: Any, jar: Path, reopen: Boolean): Result { val preview = dryRunAdd(engine, jar) val mode = preview.get("mode") as Enum<*> - if (mode.name == "REFUSED") return Result(false, preview.lineOfAdd()) + val name = preview.get("name") as String + when (mode.name) { + "REFUSED" -> return Result(false, preview.lineOfAdd()) + "NEXT_START" -> return queueAdd(engine, jar) + "SAVE_AND_REOPEN" -> if (!reopen) return reopenNotAgreed(name) + } onUiThread { engine.callMethod("add", jar, mode) } dryRunAdd(engine, jar) - if (mode.name == "NEXT_START" || engine.callMethod("canUpdate", jar) == true) return Result(true, "") - return Result(false, translated("oneconfig.page.hotwire.add.failed", "Hotwire couldn't add %s. It posts why in chat.", preview.get("name") as String)) + if (engine.callMethod("canUpdate", jar) == true) return Result(true, "") + return Result(false, translated("oneconfig.page.hotwire.add.failed", "Hotwire couldn't add %s. It posts why in chat.", name)) + } + + private fun queueAdd(engine: Any, jar: Path): Result { + onUiThread { engine.callMethod("add", jar, engine.enumArg("add", "NEXT_START")) } + val manifest = manifestOf(jar) + val queued = manifest != null && (engine.callMethod("getPendingChanges") as List<*>).any { + it!!.javaClass.simpleName == "Update" && it.get("modId") == manifest.get("id") && it.get("toVersion") == manifest.get("version") + } + if (queued) return Result(true, "") + return Result(false, translated("oneconfig.page.hotwire.add.queue_failed", "Hotwire couldn't queue %s - please check chat!", manifest?.get("name") as String? ?: jar.fileName.toString())) } /** @@ -691,13 +738,17 @@ object HotwireBridge { } } - /** Turns [ids] on or off, completing on whatever thread Hotwire finishes on */ - fun switch(ids: Collection, on: Boolean): CompletableFuture = runCatching { + fun reopensToTurnOn(ids: Collection): Boolean = runCatching { + val engine = engine()!! + keptOff(ids).any { dryRunAdd(engine, engine.callMethod("findJarOf", it) as Path).addMode() == "SAVE_AND_REOPEN" } + }.getOrDefault(false) + + fun switch(ids: Collection, on: Boolean, reopen: Boolean = false): CompletableFuture = runCatching { val kept = if (on) keptOff(ids) else emptyList() if (kept.isEmpty()) { call(if (on) "turnOn" else "turnOff", ids.toList()).asResults() } else { - CompletableFuture.supplyAsync { turnOnKeptOff(engine()!!, ids, kept) } + CompletableFuture.supplyAsync { turnOnKeptOff(engine()!!, ids, kept, reopen) } } }.getOrElse { CompletableFuture.completedFuture(Result(false, "Hotwire is not available.")) } @@ -705,12 +756,37 @@ object HotwireBridge { runCatching { dryRunAdd(engine, jar) } } - private fun turnOnKeptOff(engine: Any, ids: Collection, kept: List): Result { + private fun addBackWithReopen(engine: Any, id: String, jar: Path): Result { + val added = CompletableFuture() + val mode = engine.enumArg("add", "SAVE_AND_REOPEN") + onUiThread { + engine.callHidden("add", jar, mode, Consumer { outcome -> + added.complete( + runCatching { + if (outcome.javaClass.simpleName != "Added") return@runCatching Result(false, outcome.get("reason") as String) + val enabled = runCatching { + Class.forName("${engine.javaClass.packageName}.DisabledJars") + .getDeclaredMethod("enable", Path::class.java).apply { isAccessible = true }.invoke(null, jar) + }.getOrDefault(jar) + engine.field("installed")!!.callMethod("onBroughtBack", id, enabled!!) + Result(true, "") + }.getOrElse(::failure), + ) + }) + } + return added.get(REOPEN_TIMEOUT_MINUTES, TimeUnit.MINUTES) + } + + private fun turnOnKeptOff(engine: Any, ids: Collection, kept: List, reopen: Boolean): Result { val waiting = kept.associateWithTo(LinkedHashMap()) { engine.callMethod("findJarOf", it) as Path } while (waiting.isNotEmpty()) { - val (id, jar) = waiting.entries.firstOrNull { (dryRunAdd(engine, it.value).get("mode") as Enum<*>).name == "NOW" } + val modes = waiting.entries.map { it to dryRunAdd(engine, it.value).addMode() } + val (entry, mode) = modes.firstOrNull { it.second == "NOW" } + ?: modes.firstOrNull { it.second == "SAVE_AND_REOPEN" } ?: return Result(false, dryRunAdd(engine, waiting.values.first()).lineOfAdd()) - val result = call("turnOn", listOf(id)).asResults().get() + val (id, jar) = entry + if (mode == "SAVE_AND_REOPEN" && !reopen) return reopenNotAgreed(engine.callMethod("nameOfMod", id) as String) + val result = if (mode == "NOW") call("turnOn", listOf(id)).asResults().get() else addBackWithReopen(engine, id, jar) if (!result.ok) return result awaitWorker(engine, jar) val back = onUiThread { @@ -728,5 +804,9 @@ object HotwireBridge { private const val MAX_ICON_BYTES = 8 * 1024 * 1024 + private const val ICON_TIMEOUT_MS = 10_000 + + private const val REOPEN_TIMEOUT_MINUTES = 15L + private const val STORE_REVISION = 5 } 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 index 42a1259c5..04d381486 100644 --- 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 @@ -25,6 +25,7 @@ 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 @@ -130,6 +131,10 @@ object HotwireUpdates { } // an earlier update can already have brought this one along as a dependency if (plan.getOrNull()?.upToDate == true) continue + if (plan.getOrNull()?.reopens == true) { + problems += "${update.name}: ${translated("oneconfig.page.hotwire.updates.reopen", "It has to save and reopen your world, so update it on its own.")}" + continue + } val action = plan.getOrNull()?.actions?.firstOrNull { it.primary } if (action == null) { val reason = plan.exceptionOrNull()?.let { it.cause ?: it }?.message ?: plan.getOrNull()?.lines?.firstOrNull() @@ -384,11 +389,14 @@ private fun SwitchBar( val turnOn = remember(picked, states) { idsWhere(false) } val previews = remember(turnOff, turnOn) { listOfNotNull( - HotwireBridge.previewTogether(turnOff, turnOn), turnOff.takeIf { it.isNotEmpty() }?.let { HotwireBridge.preview(it, false) }, - turnOn.takeIf { it.isNotEmpty() }?.let { HotwireBridge.preview(it, true) }, + HotwireBridge.previewTogether(turnOff, turnOn) ?: turnOn.takeIf { it.isNotEmpty() }?.let { HotwireBridge.preview(it, true) }, ) } + val reopens by produceState(null, turnOn) { + value = null + value = withContext(Dispatchers.IO) { HotwireBridge.reopensToTurnOn(turnOn) } + } val pickedIds = picked.values.flatten().mapTo(HashSet()) { it.id } val updates = HotwireUpdates.found?.getOrNull().orEmpty().filter { it.modId in pickedIds } val updating = HotwireUpdates.updating @@ -402,6 +410,7 @@ private fun SwitchBar( val offCount = picked.values.count { familyOn(it, states) == true } val onCount = picked.values.count { familyOn(it, states) == false } val label = when { + reopens == true -> translated("oneconfig.page.hotwire.switch.reopen", "Save and switch") offCount > 0 && onCount > 0 -> "Switch ${offCount + onCount}" offCount > 0 -> "Turn off $offCount" else -> "Turn on $onCount" @@ -412,14 +421,14 @@ private fun SwitchBar( running -> "Switching… ${(fraction * 100).toInt()}%" else -> previews.joinToString(" ") { it.message } } - val canSwitch = !running && previews.isNotEmpty() && previews.all { it.possible } && !HotwireBridge.isSwitching + val canSwitch = !running && reopens != null && previews.isNotEmpty() && previews.all { it.possible } && !HotwireBridge.isSwitching fun start() { switching = 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.switch(turnOn, true, reopens == true).thenApply { second -> HotwireBridge.Result( second.ok, listOf(first.message, second.message).filter { it.isNotBlank() }.joinToString(" "), 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 index d955f172d..420eb28d2 100644 --- 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 @@ -87,12 +87,15 @@ fun HotwireAdd() { reading = true planIcon = null scope.launch { - val (iconPath, loaded) = withContext(Dispatchers.IO) { runCatching(icon).getOrNull() to runCatching(load) } + val iconPath = withContext(Dispatchers.IO) { runCatching(icon).getOrNull() } + if (id == request) planIcon = iconPath + } + scope.launch { + val loaded = withContext(Dispatchers.IO) { runCatching(load) } if (id != request) { loaded.getOrNull()?.onCancel?.invoke() return@launch } - planIcon = iconPath reading = false plan = loaded.getOrElse { val reason = it.cause ?: it From 872348972a7f7a184832f231bf3a3a53b2e40f96 Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Sat, 31 Oct 2026 00:13:19 -0400 Subject: [PATCH 09/63] a --- .../assets/oneconfig/lang/en_us.json | 3 +- .../internal/ui/api/HotwireBridge.kt | 34 +++++++++++++----- .../oneconfig/internal/ui/screens/Hotwire.kt | 19 ++++------ .../ui/TestCompatibilityPlatform.java | 4 ++- .../ui/api/LoadedModsRefreshTest.java | 36 +++++++++++++++++++ modules/utils/api/utils.api | 2 ++ .../oneconfig/api/platform/v1/ModInfo.kt | 6 ++++ 7 files changed, 80 insertions(+), 24 deletions(-) create mode 100644 modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/LoadedModsRefreshTest.java 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 e71c05fbc..e08de36bb 100644 --- a/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json +++ b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json @@ -285,7 +285,8 @@ "oneconfig.page.hotwire.update.waits": "This update needs %s, which only arrives at the next launch. Update this after restarting.", "oneconfig.page.hotwire.switch.adds_back": "Adds back %s.", "oneconfig.page.hotwire.switch.add_back_failed": "Hotwire couldn't add back %s - please check chat!", - "oneconfig.page.hotwire.switch.conflict": "Some of these mods need others you're turning off. Switch them separately.", + "oneconfig.page.hotwire.switch.rename_failed": "%s is back on, but it will be off at the next launch: %s", + "oneconfig.page.hotwire.switch.conflict":"Some of these mods need others you're turning off. Switch them separately.", "oneconfig.page.hotwire.switch.after_off": "Turns on %s once the others are off.", "oneconfig.page.hotwire.switch.reopen": "Save and switch", "oneconfig.page.hotwire.update.adds": "Adds %s, which this update needs.", 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 index 8fe88f541..b154e29ab 100644 --- 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 @@ -10,6 +10,8 @@ import java.util.Optional import java.util.concurrent.CompletableFuture import java.util.concurrent.TimeUnit import java.util.function.Consumer +import java.util.function.Function +import org.polyfrost.oneconfig.api.platform.v1.ModInfo import org.polyfrost.oneconfig.api.platform.v1.Platform import org.polyfrost.oneconfig.internal.ui.components.translated @@ -31,6 +33,7 @@ object HotwireBridge { private set fun invalidate() { + ModInfo.refreshLoadedMods() revision++ } @@ -41,7 +44,7 @@ object HotwireBridge { runCatching { cls.getMethod("getInstance").invoke(null).also { api -> cls.getMethod("addListener", Consumer::class.java).invoke(api, Consumer { - Platform.screen().runOnUiThread { revision++ } + Platform.screen().runOnUiThread(::invalidate) }) } }.getOrNull() @@ -355,8 +358,8 @@ object HotwireBridge { @Suppress("UNCHECKED_CAST") val jars = add.field("jars") as List val inOrder = engine()?.takeIf { !canAdd && fitTogether(it, jars) } - val reopen = add.through("StoreAdd", "needsReopen") == true - val modes = (add.field("previews") as List<*>).map { it!!.addMode() } + val reopen = add.through("StoreAdd", "needsReopen") == true || inOrder != null && jars.any { reopensOnceMet(inOrder, it) } + val modes =(add.field("previews") as List<*>).map { it!!.addMode() } val onlyLater = modes.lastOrNull() == "NEXT_START" || inOrder != null && "NEXT_START" in modes var accepted = false val later = JarAction(translated("oneconfig.page.hotwire.add.next_start", "Add at next launch"), onlyLater) { @@ -426,6 +429,15 @@ object HotwireBridge { ids.all { (planner.callMethod("findOldDependencies", it) as List<*>).isEmpty() } }.getOrDefault(false) + private fun reopensOnceMet(engine: Any, jar: Path): Boolean = runCatching { + val adds = engine.callHidden("findParts")!!.field("adds")!! + if (adds.callMethod("isInWorld") != true) return false + val scan = Class.forName("dev.moon.hotwire.jar.ContentScan") + val classes = scan.getMethod("readClasses", Path::class.java).invoke(null, jar) + val superOf = Function { adds.callMethod("findSuperOf", it) as String? } + (scan.getMethod("findKinds", classes.javaClass, Function::class.java).invoke(null, classes, superOf) as Set<*>).isNotEmpty() + }.getOrDefault(false) + private fun addInOrder(engine: Any, jars: List, name: String, reopen: Boolean): Result { var queueing = false for (jar in jars) { @@ -525,7 +537,8 @@ object HotwireBridge { firstAdds.firstOrNull { it.addMode() == "REFUSED" } ?.let { return JarPlan(plan.title, plan.detail, it.get("lines") as List, emptyList()) } if (!collected || steps.isEmpty()) return plan - val reopen = firstAdds.any { it.addMode() == "SAVE_AND_REOPEN" } + val reopen = firstAdds.any { it.addMode() == "SAVE_AND_REOPEN" } || + steps.any { engine.callMethod("canUpdate", it) != true && reopensOnceMet(engine, it) } val label = if (reopen) { translated("oneconfig.page.hotwire.update.reopen", "Save and update") } else { @@ -766,10 +779,13 @@ object HotwireBridge { if (outcome.javaClass.simpleName != "Added") return@runCatching Result(false, outcome.get("reason") as String) val enabled = runCatching { Class.forName("${engine.javaClass.packageName}.DisabledJars") - .getDeclaredMethod("enable", Path::class.java).apply { isAccessible = true }.invoke(null, jar) - }.getOrDefault(jar) - engine.field("installed")!!.callMethod("onBroughtBack", id, enabled!!) - Result(true, "") + .getDeclaredMethod("enable", Path::class.java).apply { isAccessible = true }.invoke(null, jar)!! + } + engine.field("installed")!!.callMethod("onBroughtBack", id, enabled.getOrDefault(jar)) + enabled.fold({ Result(true, "") }) { + val name = engine.callMethod("nameOfMod", id) as String + Result(false, translated("oneconfig.page.hotwire.switch.rename_failed", "%s is back on, but it will be off at the next launch: %s", name, failure(it).message)) + } }.getOrElse(::failure), ) }) @@ -787,12 +803,12 @@ object HotwireBridge { val (id, jar) = entry if (mode == "SAVE_AND_REOPEN" && !reopen) return reopenNotAgreed(engine.callMethod("nameOfMod", id) as String) val result = if (mode == "NOW") call("turnOn", listOf(id)).asResults().get() else addBackWithReopen(engine, id, jar) - if (!result.ok) return result awaitWorker(engine, jar) val back = onUiThread { invalidate() mods().orEmpty().any { it.id == id && it.on } } + if (!result.ok) return result if (!back) { return Result(false, translated("oneconfig.page.hotwire.switch.add_back_failed", "Hotwire couldn't add back %s - please check chat!", engine.callMethod("nameOfMod", id) as String)) } 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 index 04d381486..c027fe5b5 100644 --- 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 @@ -167,25 +167,18 @@ object HotwireUpdates { /** Extracted icon path per mod id kept across scrolling so each jar is only read once */ private val iconPaths = HashMap() -/** - * Every mod seen this session by id, starting from the startup list - * - * Hotwire takes a mod off the loader's list when it turns it off, so this keeps its icon and parent for the grid - */ -private val seenMods by lazy { ModInfo.loadedMods.associateByTo(HashMap()) { it.id } } - /** * 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 */ private fun gridMods(states: Map?): Collection { - // read past ModInfo's cache, since Hotwire can add mods after it was filled - val live = Platform.compatibility().mods - live.forEach { seenMods[it.id] = it } - val liveIds = live.mapTo(HashSet()) { it.id } - val known = seenMods.values.filter { it.id in liveIds || states?.containsKey(it.id) == true } + ModInfo.refreshLoadedMods() + val seen = ModInfo.loadedMods + val seenIds = seen.mapTo(HashSet()) { it.id } + val liveIds = Platform.compatibility().mods.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 seenMods } + .filter { it.id !in seenIds } .map { ModInfo(it.id, it.name.ifBlank { it.id }, it.version, null, null) } return known + unseen } 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..92cdc8564 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,11 @@ public final class TestCompatibilityPlatform implements CompatibilityPlatform { public void displayChatMessage(Component text) { } + public static Set mods = Set.of(); + @Override public Set getMods() { - return Set.of(); + return mods; } @Override 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..a172540e8 --- /dev/null +++ b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/LoadedModsRefreshTest.java @@ -0,0 +1,36 @@ +package org.polyfrost.oneconfig.internal.ui.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +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 refreshAddsAndUpdatesWithoutDroppingModsLeftTheLoader() { + 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 versions = ModInfo.getLoadedMods().stream().collect(Collectors.toMap(ModInfo::getId, ModInfo::getVersion)); + assertEquals("1", versions.get("kept-off")); + assertEquals("2", versions.get("updated")); + assertEquals("1", versions.get("added")); + } +} diff --git a/modules/utils/api/utils.api b/modules/utils/api/utils.api index 7d695d897..c20976483 100644 --- a/modules/utils/api/utils.api +++ b/modules/utils/api/utils.api @@ -145,11 +145,13 @@ public final class org/polyfrost/oneconfig/api/platform/v1/ModInfo { public final fun getParentId ()Ljava/lang/String; 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 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 b804d2956..3355a7156 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 @@ -68,6 +68,12 @@ data class ModInfo @JvmOverloads constructor( @get:JvmStatic val loadedMods: Set get() = cached ?: Platform.compatibility().mods.also { if (it.isNotEmpty()) cached = it } + + @JvmStatic + fun refreshLoadedMods() { + val live = Platform.compatibility().mods + if (live.isNotEmpty()) cached = (loadedMods.associateBy { it.id } + live.associateBy { it.id }).values.toSet() + } } } From ba0da5d17c39b317ed678799966476f85cdf1d64 Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Sat, 31 Oct 2026 01:11:24 -0400 Subject: [PATCH 10/63] Update HotwireBridge.kt --- .../internal/ui/api/HotwireBridge.kt | 94 +++++++++++++------ 1 file changed, 67 insertions(+), 27 deletions(-) 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 index b154e29ab..fc6f0ee12 100644 --- 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 @@ -290,7 +290,8 @@ object HotwireBridge { (call("getStoreCategories", mod) as List<*>).map { StoreCategory(it!!.get("name") as String, it.get("parts") as List) } }.getOrDefault(emptyList()) - private var storeChecked = false + @Volatile + private var storeChecked: CompletableFuture? = null /** Hotwire's offers from the last update check, which it reuses when fetching what an update needs */ @Volatile @@ -320,26 +321,30 @@ object HotwireBridge { } return StorePage(hits, page.get("total") as Int) } + if (storeChecked?.isDone == true) return search() val first = search() - if (storeChecked) return first - storeChecked = true - // the first search creates Hotwire's store, whose installed check is still queued on its single worker, so - // this runs once that has finished and only then are the installed flags right - val model = engine()?.callMethod("getStore")?.callMethod("getModel") ?: return first - awaitStoreWorker(model) + val checked = installedCheck() ?: return first + checked.get() return search() } - private fun awaitStoreWorker(model: Any) { - val checked = CompletableFuture() - model.callMethod("run", Runnable { checked.complete(Unit) }) - checked.get(60, TimeUnit.SECONDS) + @Synchronized + private fun installedCheck(): CompletableFuture? { + storeChecked?.let { return it } + val model = engine()?.callMethod("getStore")?.callMethod("getModel") ?: return null + return storeWorkerDone(model).completeOnTimeout(Unit, STORE_WORKER_TIMEOUT_SECONDS, TimeUnit.SECONDS).also { storeChecked = it } + } + + private fun storeWorkerDone(model: Any): CompletableFuture { + val done = CompletableFuture() + model.callMethod("run", Runnable { done.complete(Unit) }) + return done } private fun refreshInstalled() { val store = engine()?.callMethod("getStore") ?: return store.callMethod("checkInstalled") - awaitStoreWorker(store.callMethod("getModel")!!) + storeWorkerDone(store.callMethod("getModel")!!).get(STORE_WORKER_TIMEOUT_SECONDS, TimeUnit.SECONDS) } /** The newest version of a project that fits this game; blocks on the network */ @@ -357,7 +362,9 @@ object HotwireBridge { val canAdd = add.through("StoreAdd", "canAdd") == true @Suppress("UNCHECKED_CAST") val jars = add.field("jars") as List - val inOrder = engine()?.takeIf { !canAdd && fitTogether(it, jars) } + val fitting = engine()?.takeIf { !canAdd && fitTogether(it, jars) } + val refusal = fitting?.let { refusalOnceMet(it, jars) } + val inOrder = fitting.takeIf { refusal == null } val reopen = add.through("StoreAdd", "needsReopen") == true || inOrder != null && jars.any { reopensOnceMet(inOrder, it) } val modes =(add.field("previews") as List<*>).map { it!!.addMode() } val onlyLater = modes.lastOrNull() == "NEXT_START" || inOrder != null && "NEXT_START" in modes @@ -365,7 +372,7 @@ object HotwireBridge { val later = JarAction(translated("oneconfig.page.hotwire.add.next_start", "Add at next launch"), onlyLater) { accepted = true add.through("StoreAdd", "cancel") - call("queueForNextStart", hit.projectId, version).asResults() + CompletableFuture.supplyAsync { addInOrder(engine()!!, jars, hit.title, reopen = false, later = true) } } val save = JarAction(translated("oneconfig.page.hotwire.add.save", "Save to Downloads"), false) { accepted = true @@ -382,7 +389,10 @@ object HotwireBridge { ) { accepted = true val added = if (inOrder == null) { - add.through("StoreAdd", "accept").asResults() + add.through("StoreAdd", "accept").asResults().thenApply { result -> + val lost = jars.filterIndexed { at, _ -> modes[at] == "NEXT_START" }.firstOrNull { !isQueued(engine()!!, it) } + if (result.ok && lost != null) queueFailed(lost) else result + } } else { add.through("StoreAdd", "cancel") CompletableFuture.supplyAsync { addInOrder(inOrder, jars, hit.title, reopen) } @@ -394,7 +404,9 @@ object HotwireBridge { } } @Suppress("UNCHECKED_CAST") - val lines = if (inOrder == null) { + val lines = if (refusal != null) { + listOf(refusal) + } else if (inOrder == null) { add.through("StoreAdd", "getLines") as List } else { (add.field("previews") as List<*>).dropLast(1).map { @@ -438,8 +450,24 @@ object HotwireBridge { (scan.getMethod("findKinds", classes.javaClass, Function::class.java).invoke(null, classes, superOf) as Set<*>).isNotEmpty() }.getOrDefault(false) - private fun addInOrder(engine: Any, jars: List, name: String, reopen: Boolean): Result { - var queueing = false + private fun Any.askAdder(type: String, method: String, arg: Any): String? = + (Class.forName("${javaClass.packageName}.add.$type").declaredMethods.first { it.name == method } + .apply { isAccessible = true }.invoke(null, arg) as Optional<*>).orElse(null) as String? + + private fun Any.parts(): List = listOf(this) + (get("nested") as List<*>).flatMap { it!!.parts() } + + private fun worldRefusal(engine: Any): String? = + (engine.callStatic("add.WorldReopen", "findRefusal") as Optional<*>).orElse(null) as String? + + private fun refusalOnceMet(engine: Any, jars: List): String? = runCatching { + jars.firstNotNullOfOrNull { jar -> + engine.askAdder("AddFirstLook", "findRefusal", jar) + ?: readJar(jar).parts().firstNotNullOfOrNull { engine.askAdder("AddDependencies", "findMissingRefusal", it) } + } ?: worldRefusal(engine)?.takeIf { jars.any { reopensOnceMet(engine, it) } } + }.getOrNull() + + private fun addInOrder(engine: Any, jars: List, name: String, reopen: Boolean, later: Boolean = false): Result { + var queueing = later for (jar in jars) { queueing = queueing || dryRunAdd(engine, jar).addMode() == "NEXT_START" val result = if (queueing) queueAdd(engine, jar) else addNow(engine, jar, reopen) @@ -537,8 +565,9 @@ object HotwireBridge { firstAdds.firstOrNull { it.addMode() == "REFUSED" } ?.let { return JarPlan(plan.title, plan.detail, it.get("lines") as List, emptyList()) } if (!collected || steps.isEmpty()) return plan - val reopen = firstAdds.any { it.addMode() == "SAVE_AND_REOPEN" } || - steps.any { engine.callMethod("canUpdate", it) != true && reopensOnceMet(engine, it) } + val adds = steps.filter { engine.callMethod("canUpdate", it) != true } + refusalOnceMet(engine, adds)?.let { return JarPlan(plan.title, plan.detail, listOf(it), emptyList()) } + val reopen = firstAdds.any { it.addMode() == "SAVE_AND_REOPEN" } || adds.any { reopensOnceMet(engine, it) } val label = if (reopen) { translated("oneconfig.page.hotwire.update.reopen", "Save and update") } else { @@ -613,14 +642,19 @@ object HotwireBridge { return Result(false, translated("oneconfig.page.hotwire.add.failed", "Hotwire couldn't add %s. It posts why in chat.", name)) } - private fun queueAdd(engine: Any, jar: Path): Result { - onUiThread { engine.callMethod("add", jar, engine.enumArg("add", "NEXT_START")) } - val manifest = manifestOf(jar) - val queued = manifest != null && (engine.callMethod("getPendingChanges") as List<*>).any { + private fun isQueued(engine: Any, jar: Path): Boolean { + val manifest = manifestOf(jar) ?: return false + return (engine.callMethod("getPendingChanges") as List<*>).any { it!!.javaClass.simpleName == "Update" && it.get("modId") == manifest.get("id") && it.get("toVersion") == manifest.get("version") } - if (queued) return Result(true, "") - return Result(false, translated("oneconfig.page.hotwire.add.queue_failed", "Hotwire couldn't queue %s - please check chat!", manifest?.get("name") as String? ?: jar.fileName.toString())) + } + + private fun queueFailed(jar: Path) = + Result(false, translated("oneconfig.page.hotwire.add.queue_failed", "Hotwire couldn't queue %s - please check chat!", manifestOf(jar)?.get("name") as String? ?: jar.fileName.toString())) + + private fun queueAdd(engine: Any, jar: Path): Result { + onUiThread { engine.callMethod("add", jar, engine.enumArg("add", "NEXT_START")) } + return if (isQueued(engine, jar)) Result(true, "") else queueFailed(jar) } /** @@ -753,7 +787,10 @@ object HotwireBridge { fun reopensToTurnOn(ids: Collection): Boolean = runCatching { val engine = engine()!! - keptOff(ids).any { dryRunAdd(engine, engine.callMethod("findJarOf", it) as Path).addMode() == "SAVE_AND_REOPEN" } + worldRefusal(engine) == null && keptOff(ids).any { + val jar = engine.callMethod("findJarOf", it) as Path + dryRunAdd(engine, jar).addMode() == "SAVE_AND_REOPEN" || reopensOnceMet(engine, jar) + } }.getOrDefault(false) fun switch(ids: Collection, on: Boolean, reopen: Boolean = false): CompletableFuture = runCatching { @@ -795,6 +832,7 @@ object HotwireBridge { private fun turnOnKeptOff(engine: Any, ids: Collection, kept: List, reopen: Boolean): Result { val waiting = kept.associateWithTo(LinkedHashMap()) { engine.callMethod("findJarOf", it) as Path } + refusalOnceMet(engine, waiting.values.toList())?.let { return Result(false, it) } while (waiting.isNotEmpty()) { val modes = waiting.entries.map { it to dryRunAdd(engine, it.value).addMode() } val (entry, mode) = modes.firstOrNull { it.second == "NOW" } @@ -824,5 +862,7 @@ object HotwireBridge { private const val REOPEN_TIMEOUT_MINUTES = 15L + private const val STORE_WORKER_TIMEOUT_SECONDS = 60L + private const val STORE_REVISION = 5 } From 8d251eef0d71129b8516d5a93a07a1b5b401ac0f Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Sat, 31 Oct 2026 10:52:15 -0400 Subject: [PATCH 11/63] aaaaaaaa --- .../assets/oneconfig/lang/en_us.json | 1 + .../internal/ui/api/HotwireBridge.kt | 55 +++++++++++++++---- .../internal/ui/api/TreeConfigData.kt | 2 +- .../oneconfig/internal/ui/hud/HudModInfo.kt | 2 +- .../oneconfig/internal/ui/screens/Hotwire.kt | 15 +++-- .../internal/ui/screens/HotwireAdd.kt | 2 +- .../ui/api/LoadedModsRefreshTest.java | 16 ++++-- modules/utils/api/utils.api | 2 + .../oneconfig/api/platform/v1/ModInfo.kt | 11 +++- 9 files changed, 81 insertions(+), 25 deletions(-) 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 e08de36bb..b9986dd43 100644 --- a/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json +++ b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json @@ -267,6 +267,7 @@ "oneconfig.page.hotwire.add.done": "Added %s.", "oneconfig.page.hotwire.add.queued": "%s is added at the next launch.", "oneconfig.page.hotwire.add.queue_failed": "Hotwire couldn't queue %s - please check chat!", + "oneconfig.page.hotwire.add.not_kept": "%s is added for now, but it won't stay after a restart - please check chat!", "oneconfig.page.hotwire.reopen_confirm": "%s has to save and reopen your world. Try again to confirm.", "oneconfig.page.hotwire.add.reading": "Reading the jar…", "oneconfig.page.hotwire.add.now": "Add now", 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 index fc6f0ee12..e6cadba17 100644 --- 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 @@ -99,6 +99,7 @@ object HotwireBridge { @Suppress("UNCHECKED_CAST") fun previewTogether(off: List, on: List): Preview? { if (off.isEmpty() || on.isEmpty()) return null + val conflict = translated("oneconfig.page.hotwire.switch.conflict", "Some of these mods need others you're turning off. Switch them separately.") val plan = runCatching { val engine = engine()!! val leaving = call("previewOff", off)!!.get("switched") as List @@ -107,13 +108,30 @@ object HotwireBridge { planner.callMethod("planOn", on, (offMods() + leaving).toSet())!!.takeUnless { plan -> (plan.get("order") as List).any { it in leaving } } - }.getOrNull() ?: return Preview(false, translated("oneconfig.page.hotwire.switch.conflict", "Some of these mods need others you're turning off. Switch them separately.")) + }.getOrNull() ?: return Preview(false, conflict) + val engine = engine()!! + if ((plan.get("refusals") as List<*>).isNotEmpty()) { + return Preview(false, runCatching { describeRefusal(engine, plan) }.getOrDefault(conflict)) + } + refusalOnceMet(engine, keptOff(on).mapNotNull { engine.callMethod("findJarOf", it) as Path? })?.let { return Preview(false, it) } val alone = preview(on, true) - if (alone.possible || (plan.get("refusals") as List<*>).isNotEmpty()) return alone - val names = (plan.get("order") as List).joinToString(", ") { engine()!!.callMethod("nameOfMod", it) as String } + if (alone.possible) return alone + val names = (plan.get("order") as List).joinToString(", ") { engine.callMethod("nameOfMod", it) as String } return Preview(true, translated("oneconfig.page.hotwire.switch.after_off", "Turns on %s once the others are off.", names)) } + fun keptOnWhenOff(ids: Collection): Set = runCatching { + val plan = plannerWith(engine()!!, emptyList()).callMethod("planOff", ids, offMods().toSet())!! + (plan.get("enabledNested") as List<*>).mapTo(HashSet()) { it!!.get("mod") as String } + }.getOrDefault(emptySet()) + + private fun describeRefusal(engine: Any, plan: Any): String { + val refused = Class.forName("dev.moon.hotwire.switching.ModSwitcher\$Result\$Refused").constructors.single().newInstance(plan) + val nameOf = Function { engine.callMethod("nameOfMod", it) as String } + return Class.forName("dev.moon.hotwire.switching.PlayerMessages").methods + .first { it.name == "describe" && it.parameterCount == 2 }.invoke(null, refused, nameOf) as String + } + private fun offMods(): List = mods().orEmpty().filterNot { it.on }.map { it.id } private fun plannerWith(engine: Any, jars: List): Any = @@ -389,9 +407,8 @@ object HotwireBridge { ) { accepted = true val added = if (inOrder == null) { - add.through("StoreAdd", "accept").asResults().thenApply { result -> - val lost = jars.filterIndexed { at, _ -> modes[at] == "NEXT_START" }.firstOrNull { !isQueued(engine()!!, it) } - if (result.ok && lost != null) queueFailed(lost) else result + add.through("StoreAdd", "accept").asResults().thenApplyAsync { result -> + if (result.ok) lostJar(engine()!!, jars, modes) ?: result else result } } else { add.through("StoreAdd", "cancel") @@ -502,6 +519,8 @@ object HotwireBridge { private fun manifestOf(jar: Path): Any? = Class.forName("dev.moon.hotwire.jar.JarLookup").getMethod("findManifest", Path::class.java).invoke(null, jar) + private fun nameOfJar(jar: Path): String = manifestOf(jar)?.get("name") as String? ?: jar.fileName.toString() + private fun dryRunAdd(engine: Any, jar: Path): Any = (engine.callMethod("dryRunAdd", jar) as CompletableFuture<*>).get()!! private fun Any.lineOfAdd(): String = (get("lines") as List<*>).firstOrNull() as String? ?: get("name") as String @@ -577,7 +596,7 @@ object HotwireBridge { CompletableFuture.supplyAsync { runSteps(engine, steps, jar, reopen) } } val lines = steps.map { - val name = manifestOf(it)?.get("name") as String? ?: it.fileName.toString() + val name = nameOfJar(it) if (engine.callMethod("canUpdate", it) == true) { translated("oneconfig.page.hotwire.update.updates", "Updates %s, which this update needs.", name) } else { @@ -638,8 +657,24 @@ object HotwireBridge { } onUiThread { engine.callMethod("add", jar, mode) } dryRunAdd(engine, jar) - if (engine.callMethod("canUpdate", jar) == true) return Result(true, "") - return Result(false, translated("oneconfig.page.hotwire.add.failed", "Hotwire couldn't add %s. It posts why in chat.", name)) + if (engine.callMethod("canUpdate", jar) != true) { + return Result(false, translated("oneconfig.page.hotwire.add.failed", "Hotwire couldn't add %s. It posts why in chat.", name)) + } + return if (onUiThread { keptForNextLaunch(engine, jar) }) Result(true, "") else notKept(jar) + } + + private fun keptForNextLaunch(engine: Any, jar: Path): Boolean { + val kept = (engine.field("updateFolders")!!.get("mods") as Path).resolve(jar.fileName.toString()) + return Files.exists(kept) && Files.mismatch(kept, jar) == -1L + } + + private fun notKept(jar: Path) = + Result(false, translated("oneconfig.page.hotwire.add.not_kept", "%s is added for now, but it won't stay after a restart - please check chat!", nameOfJar(jar))) + + private fun lostJar(engine: Any, jars: List, modes: List): Result? { + val (queued, live) = jars.indices.partition { modes[it] == "NEXT_START" } + return queued.firstOrNull { !isQueued(engine, jars[it]) }?.let { queueFailed(jars[it]) } + ?: live.firstOrNull { at -> !onUiThread { keptForNextLaunch(engine, jars[at]) } }?.let { notKept(jars[it]) } } private fun isQueued(engine: Any, jar: Path): Boolean { @@ -650,7 +685,7 @@ object HotwireBridge { } private fun queueFailed(jar: Path) = - Result(false, translated("oneconfig.page.hotwire.add.queue_failed", "Hotwire couldn't queue %s - please check chat!", manifestOf(jar)?.get("name") as String? ?: jar.fileName.toString())) + Result(false, translated("oneconfig.page.hotwire.add.queue_failed", "Hotwire couldn't queue %s - please check chat!", nameOfJar(jar))) private fun queueAdd(engine: Any, jar: Path): Result { onUiThread { engine.callMethod("add", jar, engine.enumArg("add", "NEXT_START")) } 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..2c1ad4242 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 @@ -77,7 +77,7 @@ class TreeConfigData( get() = resolvedModInfo ?: findModInfo()?.also { resolvedModInfo = it } 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/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/screens/Hotwire.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Hotwire.kt index c027fe5b5..fd7502722 100644 --- 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 @@ -173,9 +173,9 @@ private val iconPaths = HashMap() */ private fun gridMods(states: Map?): Collection { ModInfo.refreshLoadedMods() - val seen = ModInfo.loadedMods + val seen = ModInfo.seenMods val seenIds = seen.mapTo(HashSet()) { it.id } - val liveIds = Platform.compatibility().mods.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 } @@ -373,13 +373,16 @@ private fun SwitchBar( ) { val theme = LocalTheme.current // a picked entry switches as a whole, so every member Hotwire can switch goes along with its root - fun idsWhere(on: Boolean) = picked.values + 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 } - .map { it.id } - val turnOff = remember(picked, states) { idsWhere(true) } - val turnOn = remember(picked, states) { idsWhere(false) } + val turnOff = remember(picked, states) { + val members = membersWhere(true) + val ids = members.map { it.id } + ids - HotwireBridge.keptOnWhenOff(members.filter { it.parentId !in ids }.map { it.id }) + } + val turnOn = remember(picked, states) { membersWhere(false).map { it.id } } val previews = remember(turnOff, turnOn) { listOfNotNull( turnOff.takeIf { it.isNotEmpty() }?.let { HotwireBridge.preview(it, false) }, 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 index 420eb28d2..08120b05d 100644 --- 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 @@ -148,7 +148,7 @@ fun HotwireAdd() { } /** The icon of an installed mod, read out of its jar */ -private fun installedIcon(modId: String): String? = ModInfo.loadedMods.firstOrNull { it.id == modId }?.extractIconFile() +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 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 index a172540e8..ef708c76b 100644 --- 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 @@ -1,6 +1,7 @@ package org.polyfrost.oneconfig.internal.ui.api; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import java.util.Map; import java.util.Set; @@ -21,16 +22,21 @@ void clearMods() { } @Test - void refreshAddsAndUpdatesWithoutDroppingModsLeftTheLoader() { + 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 versions = ModInfo.getLoadedMods().stream().collect(Collectors.toMap(ModInfo::getId, ModInfo::getVersion)); - assertEquals("1", versions.get("kept-off")); - assertEquals("2", versions.get("updated")); - assertEquals("1", versions.get("added")); + 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")); } } diff --git a/modules/utils/api/utils.api b/modules/utils/api/utils.api index c20976483..699e7d523 100644 --- a/modules/utils/api/utils.api +++ b/modules/utils/api/utils.api @@ -143,6 +143,7 @@ public final class org/polyfrost/oneconfig/api/platform/v1/ModInfo { 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 @@ -151,6 +152,7 @@ public final class org/polyfrost/oneconfig/api/platform/v1/ModInfo { 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 } 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 3355a7156..0d7cf1d15 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 @@ -65,14 +65,23 @@ 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:JvmStatic + val seenMods: Set + get() = (seen + loadedMods.associateBy { it.id }).values.toSet() + @JvmStatic fun refreshLoadedMods() { val live = Platform.compatibility().mods - if (live.isNotEmpty()) cached = (loadedMods.associateBy { it.id } + live.associateBy { it.id }).values.toSet() + if (live.isEmpty()) return + seen = seen + loadedMods.associateBy { it.id } + cached = live } } } From c00e94093e294410eded2589803732faff7c669a Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Sat, 31 Oct 2026 11:11:23 -0400 Subject: [PATCH 12/63] fix --- .../internal/CompatibilityPlatformImpl.java | 45 ++++++++++--------- .../internal/ui/api/HotwireBridge.kt | 22 +++++---- .../oneconfig/internal/ui/screens/Hotwire.kt | 6 ++- 3 files changed, 42 insertions(+), 31 deletions(-) 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 efa9fd5b8..8181b9fb8 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 @@ -46,27 +46,30 @@ 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(), - mod.getContainingMod().map(parent -> parent.getMetadata().getId()).orElseGet(() -> modMenuParent(metadata)) - ); - }).collect(Collectors.toSet()); + FabricLoader loader = FabricLoader.getInstance(); + synchronized (loader) { + return loader.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(), + mod.getContainingMod().map(parent -> parent.getMetadata().getId()).orElseGet(() -> modMenuParent(metadata)) + ); + }).collect(Collectors.toSet()); + } //? } //? neoforge { 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 index e6cadba17..cf2ba0caf 100644 --- 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 @@ -299,6 +299,10 @@ object HotwireBridge { 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 */ @@ -395,7 +399,8 @@ object HotwireBridge { val save = JarAction(translated("oneconfig.page.hotwire.add.save", "Save to Downloads"), false) { accepted = true add.through("StoreAdd", "cancel") - call("saveToDownloads", storeQuery(""), hit.projectId, version).asResults() + val query = if (jars.isEmpty()) directDownloadQuery() else storeQuery("") + call("saveToDownloads", query, hit.slug.ifBlank { hit.projectId }, version).asResults() } val now = JarAction( if (reopen) { @@ -464,7 +469,8 @@ object HotwireBridge { val scan = Class.forName("dev.moon.hotwire.jar.ContentScan") val classes = scan.getMethod("readClasses", Path::class.java).invoke(null, jar) val superOf = Function { adds.callMethod("findSuperOf", it) as String? } - (scan.getMethod("findKinds", classes.javaClass, Function::class.java).invoke(null, classes, superOf) as Set<*>).isNotEmpty() + val findKinds = scan.methods.first { it.name == "findKinds" && it.parameterTypes[0].isInstance(classes) } + (findKinds.invoke(null, classes, superOf) as Set<*>).isNotEmpty() }.getOrDefault(false) private fun Any.askAdder(type: String, method: String, arg: Any): String? = @@ -680,7 +686,7 @@ object HotwireBridge { private fun isQueued(engine: Any, jar: Path): Boolean { val manifest = manifestOf(jar) ?: return false return (engine.callMethod("getPendingChanges") as List<*>).any { - it!!.javaClass.simpleName == "Update" && it.get("modId") == manifest.get("id") && it.get("toVersion") == manifest.get("version") + it!!.javaClass.simpleName == "Update" && it.get("newJar") == jar.fileName.toString() && it.get("toVersion") == manifest.get("version") } } @@ -697,11 +703,9 @@ object HotwireBridge { * Hotwire's single worker after that update, and then sees the new version in, queued, or neither */ @Suppress("UNCHECKED_CAST") - private fun updateResult(engine: Any, jar: Path, name: String, version: String): CompletableFuture = + private fun updateResult(engine: Any, jar: Path, name: String): CompletableFuture = (engine.callMethod("dryRunUpdate", jar) as CompletableFuture).thenApply { after -> - val queued = (engine.callMethod("getPendingChanges") as List<*>).any { - it!!.javaClass.simpleName == "Update" && it.get("name") == name && it.get("toVersion") == version - } + val queued = isQueued(engine, jar) val live = after.javaClass.simpleName == "SameVersion" when { live && queued -> Result(true, translated("oneconfig.page.hotwire.update.done", "%s is updated.", name)) @@ -771,7 +775,7 @@ object HotwireBridge { private fun updatePlan(engine: Any, jar: Path, preview: Any): JarPlan { fun update(method: String): () -> CompletableFuture? = { engine.callMethod("update", jar, engine.enumArg("update", method)) - updateResult(engine, jar, preview.get("name") as String, preview.get("newVersion") as String) + updateResult(engine, jar, preview.get("name") as String) } return when (preview.javaClass.simpleName) { "Ready" -> { @@ -900,4 +904,6 @@ object HotwireBridge { private const val STORE_WORKER_TIMEOUT_SECONDS = 60L private const val STORE_REVISION = 5 + + private const val MODRINTH_LOADER = "fabric" } 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 index fd7502722..eb21586d8 100644 --- 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 @@ -111,8 +111,10 @@ object HotwireUpdates { checked = true Thread({ wait() - val result = runCatching { HotwireBridge.findUpdates() } - Platform.screen().runOnUiThread { apply(result) } + synchronized(this) { + val result = runCatching { HotwireBridge.findUpdates() } + Platform.screen().runOnUiThread { apply(result) } + } }, "OneConfig Hotwire update check").apply { isDaemon = true }.start() } From 96bd0527926551ed4638c76c5c8a0c4e2636ec2e Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Sat, 31 Oct 2026 11:58:24 -0400 Subject: [PATCH 13/63] a --- .../oneconfig/internal/compat/CompatLoader.kt | 54 ++++++++++--------- .../internal/ui/api/HotwireBridge.kt | 11 ++++ .../internal/ui/api/TreeConfigData.kt | 11 +++- .../oneconfig/internal/ui/screens/Hotwire.kt | 16 ++++-- .../ui/api/LoadedModsRefreshTest.java | 19 +++++++ .../oneconfig/api/platform/v1/ModInfo.kt | 2 +- 6 files changed, 82 insertions(+), 31 deletions(-) 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 b4b80a490..7016a6412 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() @@ -57,7 +55,32 @@ 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 currentIndex(): ModIndex { + val mods = Platform.compatibility().mods + return index.takeIf { it.mods == mods } ?: ModIndex(mods).also { index = it } + } fun findFirstMod(): ModInfo? { forcedModId.get()?.let { forcedId -> @@ -65,9 +88,7 @@ object CompatLoader { if (forcedMod != null) return forcedMod } val callerClass = callerClassName() ?: return null - return ownerByClassName - .computeIfAbsent(callerClass) { Optional.ofNullable(resolveOwner(it)) } - .orElse(null) + return currentIndex().ownerOf(callerClass) } private fun callerClassName(): String? = @@ -75,20 +96,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? = currentIndex().ownerOf(cls.name) fun markFirstModAsSkip() { val mod = findFirstMod() @@ -171,12 +179,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)) } 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 index cf2ba0caf..64c520789 100644 --- 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 @@ -81,6 +81,9 @@ object HotwireBridge { } }.getOrNull() + fun installedState(): List = mods().orEmpty().map { listOf(it.id, it.version, it.on) } + + runCatching { engine()!!.callMethod("getPendingChanges") as List<*> }.getOrDefault(emptyList()) + val isSwitching: Boolean get() = call("isSwitching") as? Boolean ?: false /** How far the running switch is, from 0 to 1 */ @@ -377,9 +380,17 @@ object HotwireBridge { return version.get("id") as String } + private fun browseThisGame() { + val model = engine()?.callMethod("getStore")?.callMethod("getModel") ?: return + val client = model.callMethod("getClient")!! + if (client.callMethod("isBrowsingOwn") == true) return + model.callMethod("setBrowsing", client.callMethod("getLoader")!!, client.callMethod("getGameVersion")!!) + } + /** What adding [hit] would do, including what Hotwire adds along with it; blocks on the network */ fun storePlan(hit: StoreHit): JarPlan { val version = newestVersion(hit.projectId) + runCatching { onUiThread(::browseThisGame) } val add = (call("requestAdd", hit.projectId, version) as CompletableFuture<*>).get()!! val canAdd = add.through("StoreAdd", "canAdd") == true @Suppress("UNCHECKED_CAST") 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 2c1ad4242..a73e799e5 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,10 +71,19 @@ 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 = null + } + return resolvedModInfo ?: findModInfo()?.also { resolvedModInfo = it } + } private fun findModInfo(): ModInfo? { val mods = ModInfo.seenMods 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 index eb21586d8..d46209e9d 100644 --- 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 @@ -74,7 +74,8 @@ 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, so the Hotwire page and the Updates tab share one answer instead of each asking Modrinth + * 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 */ @@ -89,11 +90,19 @@ object HotwireUpdates { 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 only */ + /** Checks on first use, and again when opened after the mods changed in Hotwire's own screens */ fun ensure() { - if (!checked) refreshAfter {} + if (!checked) { + refreshAfter {} + } else if (HotwireBridge.installedState() != checkedState) { + HotwireBridge.invalidate() + refreshAfter {} + } } /** @@ -112,6 +121,7 @@ object HotwireUpdates { Thread({ wait() synchronized(this) { + checkedState = HotwireBridge.installedState() val result = runCatching { HotwireBridge.findUpdates() } Platform.screen().runOnUiThread { apply(result) } } 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 index ef708c76b..8491e16de 100644 --- 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 @@ -2,12 +2,14 @@ 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 java.util.Map; import java.util.Set; 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; @@ -39,4 +41,21 @@ void modsThatLeftTheLoaderAreSeenButNotLoaded() { assertEquals("2", seen.get("updated")); assertEquals("1", seen.get("added")); } + + @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/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 0d7cf1d15..d45aaefc7 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 @@ -79,7 +79,7 @@ data class ModInfo @JvmOverloads constructor( @JvmStatic fun refreshLoadedMods() { val live = Platform.compatibility().mods - if (live.isEmpty()) return + if (live.isEmpty() || live == cached) return seen = seen + loadedMods.associateBy { it.id } cached = live } From a7fa3e89ae19d4d588c66ad9195a093055cc4ecb Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Sat, 31 Oct 2026 13:06:09 -0400 Subject: [PATCH 14/63] a --- .../oneconfig/internal/compat/CompatLoader.kt | 18 +++++++++----- .../assets/oneconfig/lang/en_us.json | 1 + .../internal/ui/api/HotwireBridge.kt | 24 +++++++++++++++---- 3 files changed, 32 insertions(+), 11 deletions(-) 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 7016a6412..0f9d290cc 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 @@ -77,9 +77,12 @@ object CompatLoader { @Volatile private var index = ModIndex(emptySet()) - private fun currentIndex(): ModIndex { + private fun syncedIndex(): ModIndex { val mods = Platform.compatibility().mods - return index.takeIf { it.mods == mods } ?: ModIndex(mods).also { index = it } + return index.takeIf { it.mods == mods } ?: ModIndex(mods).also { + ModInfo.refreshLoadedMods() + index = it + } } fun findFirstMod(): ModInfo? { @@ -88,7 +91,7 @@ object CompatLoader { if (forcedMod != null) return forcedMod } val callerClass = callerClassName() ?: return null - return currentIndex().ownerOf(callerClass) + return syncedIndex().ownerOf(callerClass) } private fun callerClassName(): String? = @@ -96,7 +99,7 @@ object CompatLoader { illegalPaths.none { path -> element.className.startsWith(path) } }?.className - fun findModByClass(cls: Class<*>): ModInfo? = currentIndex().ownerOf(cls.name) + fun findModByClass(cls: Class<*>): ModInfo? = syncedIndex().ownerOf(cls.name) fun markFirstModAsSkip() { val mod = findFirstMod() @@ -128,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" @@ -226,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/resources/assets/oneconfig/lang/en_us.json b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json index b9986dd43..7f45edfb8 100644 --- a/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json +++ b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json @@ -290,6 +290,7 @@ "oneconfig.page.hotwire.switch.conflict":"Some of these mods need others you're turning off. Switch them separately.", "oneconfig.page.hotwire.switch.after_off": "Turns on %s once the others are off.", "oneconfig.page.hotwire.switch.reopen": "Save and switch", + "oneconfig.page.hotwire.switch.reopen_unsupported": "Update Hotwire to turn %s back on from here.", "oneconfig.page.hotwire.update.adds": "Adds %s, which this update needs.", "oneconfig.page.hotwire.update.same": "This version is already installed.", "oneconfig.page.hotwire.update.unavailable": "Hotwire can't update mods in this game session.", 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 index 64c520789..d2f87a2e2 100644 --- 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 @@ -694,10 +694,15 @@ object HotwireBridge { ?: live.firstOrNull { at -> !onUiThread { keptForNextLaunch(engine, jars[at]) } }?.let { notKept(jars[it]) } } + private fun unwrapped(engine: Any, jar: Path): Path = runCatching { + engine.callHidden("findParts")!!.field("updates")!!.field("updater")!!.callMethod("unwrap", jar) as Path + }.getOrDefault(jar) + private fun isQueued(engine: Any, jar: Path): Boolean { - val manifest = manifestOf(jar) ?: return false - return (engine.callMethod("getPendingChanges") as List<*>).any { - it!!.javaClass.simpleName == "Update" && it.get("newJar") == jar.fileName.toString() && it.get("toVersion") == manifest.get("version") + val updates = (engine.callMethod("getPendingChanges") as List<*>).filter { it!!.javaClass.simpleName == "Update" } + return listOf(jar, unwrapped(engine, jar)).any { queued -> + val manifest = manifestOf(queued) ?: return@any false + updates.any { it!!.get("newJar") == queued.fileName.toString() && it.get("toVersion") == manifest.get("version") } } } @@ -837,7 +842,7 @@ object HotwireBridge { fun reopensToTurnOn(ids: Collection): Boolean = runCatching { val engine = engine()!! - worldRefusal(engine) == null && keptOff(ids).any { + canReopenToAddBack(engine) && worldRefusal(engine) == null && keptOff(ids).any { val jar = engine.callMethod("findJarOf", it) as Path dryRunAdd(engine, jar).addMode() == "SAVE_AND_REOPEN" || reopensOnceMet(engine, jar) } @@ -856,6 +861,9 @@ object HotwireBridge { runCatching { dryRunAdd(engine, jar) } } + private fun canReopenToAddBack(engine: Any): Boolean = + engine.javaClass.declaredMethods.any { it.name == "add" && it.parameterCount == 3 } + private fun addBackWithReopen(engine: Any, id: String, jar: Path): Result { val added = CompletableFuture() val mode = engine.enumArg("add", "SAVE_AND_REOPEN") @@ -889,7 +897,13 @@ object HotwireBridge { ?: modes.firstOrNull { it.second == "SAVE_AND_REOPEN" } ?: return Result(false, dryRunAdd(engine, waiting.values.first()).lineOfAdd()) val (id, jar) = entry - if (mode == "SAVE_AND_REOPEN" && !reopen) return reopenNotAgreed(engine.callMethod("nameOfMod", id) as String) + if (mode == "SAVE_AND_REOPEN") { + val name = engine.callMethod("nameOfMod", id) as String + if (!canReopenToAddBack(engine)) { + return Result(false, translated("oneconfig.page.hotwire.switch.reopen_unsupported", "Update Hotwire to turn %s back on from here.", name)) + } + if (!reopen) return reopenNotAgreed(name) + } val result = if (mode == "NOW") call("turnOn", listOf(id)).asResults().get() else addBackWithReopen(engine, id, jar) awaitWorker(engine, jar) val back = onUiThread { From 7923090473188bbd928cb9f8b6630b343b4213d9 Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Sat, 31 Oct 2026 13:44:38 -0400 Subject: [PATCH 15/63] fixes --- .../oneconfig/internal/OneConfig.java | 2 + .../internal/compat/ConfigEntrypoints.java | 132 ++++++++++++------ .../oneconfig/internal/compat/CompatLoader.kt | 1 + .../assets/oneconfig/lang/en_us.json | 1 + .../api/config/v1/ConfigEntrypoint.java | 3 +- .../api/config/v1/ConfigManager.java | 17 +++ .../api/config/v1/LateInitializationTest.java | 37 +++++ .../internal/ui/api/HotwireBridge.kt | 50 ++++--- .../oneconfig/internal/ui/screens/Hotwire.kt | 2 +- .../internal/ui/screens/HotwireAdd.kt | 2 +- .../ui/TestCompatibilityPlatform.java | 7 +- .../ui/api/LoadedModsRefreshTest.java | 33 +++++ .../oneconfig/api/platform/v1/ModInfo.kt | 14 +- 13 files changed, 233 insertions(+), 68 deletions(-) create mode 100644 modules/config-impl/src/test/java/org/polyfrost/oneconfig/api/config/v1/LateInitializationTest.java 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 da1e1f6b2..715812d37 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; @@ -347,6 +348,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/compat/ConfigEntrypoints.java b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/compat/ConfigEntrypoints.java index 4ffa67833..5d09ff935 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 @@ -50,6 +50,7 @@ import org.polyfrost.oneconfig.api.config.v1.ConfigManager; import org.polyfrost.oneconfig.api.config.v1.Tree; import org.polyfrost.oneconfig.api.platform.v1.ModInfo; +import org.polyfrost.oneconfig.internal.ui.api.HotwireBridge; //? if !neoforge { import net.fabricmc.loader.api.FabricLoader; import net.fabricmc.loader.api.ModContainer; @@ -78,6 +79,9 @@ private record Skipped(String modId, Runnable extraction) { // 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 AtomicBoolean listening = new AtomicBoolean(); + private static volatile boolean startupDecided; + private static int seenModCount; private 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(); @@ -154,15 +158,39 @@ 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; + int modCount = FabricLoader.getInstance().getAllMods().size(); + if (modCount == seenModCount || HotwireBridge.INSTANCE.isWorking()) return; + seenModCount = modCount; + rediscover(); + Map> added = new LinkedHashMap<>(); + for (Provider provider : providers()) { + if (!isDecided(provider.modId)) added.computeIfAbsent(provider.modId, k -> new ArrayList<>()).add(provider); + } + if (added.isEmpty()) return; + prepare(added.keySet()); + ConfigManager.initializeLate(() -> added.forEach((modId, entrypoints) -> undecided.put(modId, register(modId, entrypoints)))); + decidePending(); + //?} + } + /** * 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 +198,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,6 +210,9 @@ public static void finishRegistration() { } } } finally { + //? if !neoforge + seenModCount = FabricLoader.getInstance().getAllMods().size(); + startupDecided = true; // caught so a broken CompatLoader never hides an exception from ConfigManager.initialize() try { CompatLoader.INSTANCE.onEntrypointsDecided(); @@ -205,11 +222,28 @@ public static void finishRegistration() { } } + 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; @@ -345,6 +379,9 @@ 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); LOGGER.info("Using the OneConfig configs registered by {}", modId); @@ -495,10 +532,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,26 +554,26 @@ 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 { 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 0f9d290cc..ae433afaf 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 @@ -81,6 +81,7 @@ object CompatLoader { val mods = Platform.compatibility().mods return index.takeIf { it.mods == mods } ?: ModIndex(mods).also { ModInfo.refreshLoadedMods() + ConfigEntrypoints.rediscover() index = it } } 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 7f45edfb8..1d8108384 100644 --- a/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json +++ b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json @@ -295,6 +295,7 @@ "oneconfig.page.hotwire.update.same": "This version is already installed.", "oneconfig.page.hotwire.update.unavailable": "Hotwire can't update mods in this game session.", "oneconfig.page.hotwire.update.restart_only": "Hotwire can't swap this mod while the game runs, so it updates at the next launch.", + "oneconfig.page.hotwire.update.self": "Hotwire can't update itself while the game runs, so it updates at the next launch.", "oneconfig.page.hotwire.unreadable": "Hotwire can't read this jar.", "oneconfig.page.hotwire.upgrade.page": "This version of Hotwire is too old for this page. Update Hotwire to use it.", "oneconfig.page.hotwire.upgrade.store": "This version of Hotwire can't browse Modrinth here. Update Hotwire to use it.", 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..f7e4ffac6 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 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..36202ac71 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 @@ -240,6 +240,16 @@ public static void initialize() { LOGGER.info("Initialized configs in {}ms", (System.nanoTime() - t1) / 1_000_000.0); } + @ApiStatus.Internal + public static void initializeLate(Runnable creator) { + initialized = false; + try { + creator.run(); + } finally { + initialize(); + } + } + private static final Set failedInitialization = ConcurrentHashMap.newKeySet(); static boolean didInitializationFail(Config config) { @@ -296,6 +306,13 @@ public static List pendingConfigs() { return new ArrayList<>(pendingInitialization); } + @ApiStatus.Internal + public static synchronized List createdConfigs() { + List created = new ArrayList<>(initializedConfigs.values()); + created.addAll(pendingInitialization); + return created; + } + static boolean isPendingInitialization(Config config) { return pendingInitialization.contains(config); } 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..36abf50fc --- /dev/null +++ b/modules/config-impl/src/test/java/org/polyfrost/oneconfig/api/config/v1/LateInitializationTest.java @@ -0,0 +1,37 @@ +package org.polyfrost.oneconfig.api.config.v1; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Field; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class LateInitializationTest { + static class LateConfig extends Config { + LateConfig(String id) { + super(id, "Late", Category.QOL); + } + } + + @AfterEach + void reopenStartup() throws ReflectiveOperationException { + Field initialized = ConfigManager.class.getDeclaredField("initialized"); + initialized.setAccessible(true); + initialized.setBoolean(null, false); + } + + @Test + void configsCreatedLateAreInitializedOnceTheCreatorReturns() { + AtomicReference created = new AtomicReference<>(); + ConfigManager.initializeLate(() -> { + 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())); + } +} 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 index d2f87a2e2..0c1bb11b4 100644 --- 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 @@ -315,9 +315,10 @@ object HotwireBridge { (call("getStoreCategories", mod) as List<*>).map { StoreCategory(it!!.get("name") as String, it.get("parts") as List) } }.getOrDefault(emptyList()) - @Volatile private var storeChecked: CompletableFuture? = null + private var storeCheckedState: List? = null + /** Hotwire's offers from the last update check, which it reuses when fetching what an update needs */ @Volatile private var offers: List<*> = emptyList() @@ -346,18 +347,19 @@ object HotwireBridge { } return StorePage(hits, page.get("total") as Int) } - if (storeChecked?.isDone == true) return search() - val first = search() - val checked = installedCheck() ?: return first - checked.get() + installedCheck()?.get() return search() } @Synchronized private fun installedCheck(): CompletableFuture? { - storeChecked?.let { return it } - val model = engine()?.callMethod("getStore")?.callMethod("getModel") ?: return null - return storeWorkerDone(model).completeOnTimeout(Unit, STORE_WORKER_TIMEOUT_SECONDS, TimeUnit.SECONDS).also { storeChecked = it } + val state = installedState() + if (state == storeCheckedState) return storeChecked + val store = engine()?.callMethod("getStore") ?: return null + store.callMethod("checkInstalled") + storeCheckedState = state + return storeWorkerDone(store.callMethod("getModel")!!) + .completeOnTimeout(Unit, STORE_WORKER_TIMEOUT_SECONDS, TimeUnit.SECONDS).also { storeChecked = it } } private fun storeWorkerDone(model: Any): CompletableFuture { @@ -366,12 +368,6 @@ object HotwireBridge { return done } - private fun refreshInstalled() { - val store = engine()?.callMethod("getStore") ?: return - store.callMethod("checkInstalled") - storeWorkerDone(store.callMethod("getModel")!!).get(STORE_WORKER_TIMEOUT_SECONDS, TimeUnit.SECONDS) - } - /** 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<*> @@ -431,7 +427,7 @@ object HotwireBridge { CompletableFuture.supplyAsync { addInOrder(inOrder, jars, hit.title, reopen) } } added.thenApplyAsync { result -> - runCatching { refreshInstalled() } + runCatching { installedCheck()?.get() } if (result.ok) engine()?.callMethod("getStore")?.callMethod("getModel")?.callMethod("markInstalled", hit.projectId) result } @@ -530,9 +526,29 @@ object HotwireBridge { /** Downloads the mods an offered update newly needs and then the update's own jar, last; blocks on the network */ @Suppress("UNCHECKED_CAST") - fun downloadUpdate(update: StoreUpdate): List = + private fun downloadUpdate(update: StoreUpdate): List = engine()!!.callMethod("getUpdateCheck")!!.callMethod("fetchWithNeeds", update.offer, Consumer {}) as List + fun planUpdate(update: StoreUpdate): JarPlan = + if (update.modId == "hotwire" || update.modId.startsWith("hotwire-")) planSelfUpdate(update) else planUpdate(downloadUpdate(update)) + + private fun planSelfUpdate(update: StoreUpdate): JarPlan { + val engine = engine()!! + val jars = downloadUpdate(update) + val action = JarAction(translated("oneconfig.page.hotwire.update.next_start", "Update at next launch"), true) { + CompletableFuture.supplyAsync { + jars.firstNotNullOfOrNull { jar -> queueAdd(engine, jar).takeUnless { it.ok } } + ?: Result(true, translated("oneconfig.page.hotwire.update.queued", "%s updates at the next launch.", update.name)) + } + } + return JarPlan( + update.name, + "${update.installedVersion} → ${update.newVersion}", + listOf(translated("oneconfig.page.hotwire.update.self", "Hotwire can't update itself while the game runs, so it updates at the next launch.")), + listOf(action), + ) + } + private fun manifestOf(jar: Path): Any? = Class.forName("dev.moon.hotwire.jar.JarLookup").getMethod("findManifest", Path::class.java).invoke(null, jar) @@ -584,7 +600,7 @@ object HotwireBridge { /** What applying [jars] from [downloadUpdate] would do; blocks on Hotwire reading them and on the network */ @Suppress("UNCHECKED_CAST") - fun planUpdate(jars: List): JarPlan { + private fun planUpdate(jars: List): JarPlan { val jar = jars.last() val plan = planFor(jar, update = true).get() val engine = engine() 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 index d46209e9d..81e0f5b60 100644 --- 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 @@ -139,7 +139,7 @@ object HotwireUpdates { for (update in updates) { updating = update.name val plan = withContext(Dispatchers.IO) { - runCatching { HotwireBridge.planUpdate(HotwireBridge.downloadUpdate(update)) } + runCatching { HotwireBridge.planUpdate(update) } } // an earlier update can already have brought this one along as a dependency if (plan.getOrNull()?.upToDate == true) continue 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 index 08120b05d..be1b8629a 100644 --- 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 @@ -120,7 +120,7 @@ fun HotwireAdd() { } } fun reviewUpdate(update: HotwireBridge.StoreUpdate, icon: () -> String?) = - review(update.name, icon) { HotwireBridge.planUpdate(HotwireBridge.downloadUpdate(update)) } + review(update.name, icon) { HotwireBridge.planUpdate(update) } if (reading || plan != null) { PlanCard(reading, plan, planIcon, onBack = ::leavePreview) 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 92cdc8564..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 @@ -41,9 +41,14 @@ public void displayChatMessage(Component text) { public static Set mods = Set.of(); + public static volatile Runnable afterRead = () -> { + }; + @Override public Set getMods() { - return mods; + Set read = mods; + afterRead.run(); + return read; } @Override 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 index 8491e16de..7b4b6796c 100644 --- 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 @@ -3,9 +3,12 @@ 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; @@ -42,6 +45,36 @@ void modsThatLeftTheLoaderAreSeenButNotLoaded() { 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")); 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 d45aaefc7..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 @@ -70,7 +70,9 @@ data class ModInfo @JvmOverloads constructor( @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 @@ -78,10 +80,12 @@ data class ModInfo @JvmOverloads constructor( @JvmStatic fun refreshLoadedMods() { - val live = Platform.compatibility().mods - if (live.isEmpty() || live == cached) return - seen = seen + loadedMods.associateBy { it.id } - cached = live + synchronized(this) { + val live = Platform.compatibility().mods + if (live.isEmpty() || live == cached) return + seen = seen + loadedMods.associateBy { it.id } + cached = live + } } } } From d1c58f8671d998865b43decf53c91c7d55765c92 Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Sat, 31 Oct 2026 14:32:37 -0400 Subject: [PATCH 16/63] fixe moreeee --- .../internal/compat/ConfigEntrypoints.java | 11 ++-- .../internal/compat/ModMenuCompat.kt | 31 ++++++++---- .../internal/ui/api/HotwireBridge.kt | 50 +++++++++++++++++-- .../ui/api/HotwireSelfUpdateQueueTest.kt | 49 ++++++++++++++++++ 4 files changed, 123 insertions(+), 18 deletions(-) create mode 100644 modules/internal/src/test/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireSelfUpdateQueueTest.kt 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 5d09ff935..7000f81af 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 @@ -184,10 +184,13 @@ public static void registerAdded() { for (Provider provider : providers()) { if (!isDecided(provider.modId)) added.computeIfAbsent(provider.modId, k -> new ArrayList<>()).add(provider); } - if (added.isEmpty()) return; - prepare(added.keySet()); - ConfigManager.initializeLate(() -> added.forEach((modId, entrypoints) -> undecided.put(modId, register(modId, entrypoints)))); - decidePending(); + if (!added.isEmpty()) { + prepare(added.keySet()); + ConfigManager.initializeLate(() -> added.forEach((modId, entrypoints) -> undecided.put(modId, register(modId, entrypoints)))); + decidePending(); + } + //? modmenu_compat + if (CompatLoader.INSTANCE.hasMod("modmenu")) ModMenuCompat.refresh(); //?} } 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..f87f7b156 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,7 +44,16 @@ object ModMenuCompat { postLoad() } - fun postLoad() = CompatLoader.requireTranslations(1000, true) { + fun postLoad() = CompatLoader.requireTranslations(1000, true) { list(mods) } + + @JvmStatic + fun refresh() = CompatLoader.requireTranslations(1001, true) { + val added = findUnlisted() + mods.addAll(added) + list(added) + } + + private fun list(mods: List) { val nativeCoveredMods = mods.mapNotNull { mod -> val nativeChild = mod.findNativeLoadedChild() ?: return@mapNotNull null mod.aliasNativeChildConfig(nativeChild) 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 index 0c1bb11b4..e7018cbd5 100644 --- 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 @@ -355,8 +355,10 @@ object HotwireBridge { private fun installedCheck(): CompletableFuture? { val state = installedState() if (state == storeCheckedState) return storeChecked - val store = engine()?.callMethod("getStore") ?: return null - store.callMethod("checkInstalled") + val engine = engine() ?: return null + val made = engine.callMethod("getStore") + val store = made ?: runCatching { onUiThread { engine.callHidden("findStore") } }.getOrNull() ?: return null + if (made != null) store.callMethod("checkInstalled") storeCheckedState = state return storeWorkerDone(store.callMethod("getModel")!!) .completeOnTimeout(Unit, STORE_WORKER_TIMEOUT_SECONDS, TimeUnit.SECONDS).also { storeChecked = it } @@ -537,7 +539,8 @@ object HotwireBridge { val jars = downloadUpdate(update) val action = JarAction(translated("oneconfig.page.hotwire.update.next_start", "Update at next launch"), true) { CompletableFuture.supplyAsync { - jars.firstNotNullOfOrNull { jar -> queueAdd(engine, jar).takeUnless { it.ok } } + jars.dropLast(1).firstNotNullOfOrNull { jar -> queueAdd(engine, jar).takeUnless { it.ok } } + ?: queueSelfUpdate(engine, update, jars.last()).takeUnless { it.ok } ?: Result(true, translated("oneconfig.page.hotwire.update.queued", "%s updates at the next launch.", update.name)) } } @@ -549,6 +552,47 @@ object HotwireBridge { ) } + private fun queueSelfUpdate(engine: Any, update: StoreUpdate, jar: Path): Result { + val queued = queueAdd(engine, jar) + if (!queued.ok) return queued + val queue = engine.field("updateQueue")!! + return runCatching { + val running = engine.callMethod("findJarOf", update.modId) as Path + val hash = Class.forName("dev.moon.hotwire.jar.FileHashes").getMethod("findSha256", Path::class.java).invoke(null, running) as String + replaceQueued(queue, jar, update.installedVersion, running, hash) + Result(true, "") + }.getOrElse { + removeQueued(queue, jar) + queueFailed(jar) + } + } + + private fun queuedEntry(queue: Any, jar: Path): Any = + (queue.callMethod("getEntries") as List<*>).first { Path.of(it!!.get("stagedJar") as String).fileName == jar.fileName }!! + + private fun removeQueued(queue: Any, jar: Path) { + queue.callMethod("remove", queuedEntry(queue, jar).get("modId")!!) + } + + fun replaceQueued(queue: Any, jar: Path, fromVersion: String, running: Path, runningSha256: String) { + val entry = queuedEntry(queue, jar) + queue.callMethod( + "add", + entry.javaClass.constructors.single().newInstance( + entry.get("modId"), + entry.get("name"), + fromVersion, + entry.get("toVersion"), + running.fileName.toString(), + runningSha256, + entry.get("stagedJar"), + entry.get("stagedSha256"), + entry.get("reason"), + entry.get("queuedAtMillis"), + ), + ) + } + private fun manifestOf(jar: Path): Any? = Class.forName("dev.moon.hotwire.jar.JarLookup").getMethod("findManifest", Path::class.java).invoke(null, jar) diff --git a/modules/internal/src/test/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireSelfUpdateQueueTest.kt b/modules/internal/src/test/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireSelfUpdateQueueTest.kt new file mode 100644 index 000000000..061d660d7 --- /dev/null +++ b/modules/internal/src/test/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireSelfUpdateQueueTest.kt @@ -0,0 +1,49 @@ +package org.polyfrost.oneconfig.internal.ui.api + +import java.nio.file.Path +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class HotwireSelfUpdateQueueTest { + data class Entry( + val modId: String, + val name: String, + val fromVersion: String, + val toVersion: String, + val runningJar: String, + val runningSha256: String, + val stagedJar: String, + val stagedSha256: String, + val reason: String, + val queuedAtMillis: Long, + ) { + fun modId() = modId + fun name() = name + fun toVersion() = toVersion + fun stagedJar() = stagedJar + fun stagedSha256() = stagedSha256 + fun reason() = reason + fun queuedAtMillis() = queuedAtMillis + } + + class Queue(val entries: MutableList) { + fun add(entry: Entry) { + entries.removeIf { it.modId == entry.modId } + entries.add(entry) + } + } + + @Test + fun aQueuedAddBecomesAReplacementOfTheRunningJar() { + val other = Entry("other", "Other", "", "2", "", "", "/staging/other.jar", "o", "", 1) + val added = Entry("hotwire", "Hotwire", "", "2", "", "", "/staging/hotwire-2.jar", "new", "", 2) + val queue = Queue(mutableListOf(other, added)) + + HotwireBridge.replaceQueued(queue, Path.of("/store/hotwire-2.jar"), "1", Path.of("/mods/hotwire-1.jar"), "old") + + assertEquals( + listOf(other, Entry("hotwire", "Hotwire", "1", "2", "hotwire-1.jar", "old", "/staging/hotwire-2.jar", "new", "", 2)), + queue.entries, + ) + } +} From 80096d5c1dce79cd2445c082f81db865f1e16d88 Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Sat, 31 Oct 2026 15:08:28 -0400 Subject: [PATCH 17/63] aaaa --- .../internal/OneConfigMixinInit.java | 2 + .../internal/compat/ConfigEntrypoints.java | 33 +++++++++--- .../compat/Mixin_RegisterAddedConfigs.java | 20 ++++++++ .../assets/oneconfig/lang/en_us.json | 1 + .../internal/ui/api/HotwireBridge.kt | 42 +++++++++++++++- .../ui/api/HotwireSelfUpdateQueueTest.kt | 50 +++++++++++++++++++ 6 files changed, 140 insertions(+), 8 deletions(-) create mode 100644 minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/compat/Mixin_RegisterAddedConfigs.java 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 f18389b75..38c75e2b1 100644 --- a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfigMixinInit.java +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfigMixinInit.java @@ -169,6 +169,8 @@ public List getMixins() { mixins.add("compat.armorhud.Mixin_ArmorHudWidgetShown"); mixins.add("compat.firmament.Mixin_FirmamentHudMeta"); + + mixins.add("compat.Mixin_RegisterAddedConfigs"); //?} // Firmament has no stable release for 26.2 yet //? >= 1.21.8 && < 26.2 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 7000f81af..c6d304c29 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 @@ -81,7 +81,7 @@ private record Skipped(String modId, Runnable extraction) { private static final AtomicBoolean registered = new AtomicBoolean(); private static final AtomicBoolean listening = new AtomicBoolean(); private static volatile boolean startupDecided; - private static int seenModCount; + private static Set seenMods = Set.of(); private 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(); @@ -175,10 +175,12 @@ private static void prepare(Set modIds) { public static void registerAdded() { //? if !neoforge { - if (!startupDecided) return; - int modCount = FabricLoader.getInstance().getAllMods().size(); - if (modCount == seenModCount || HotwireBridge.INSTANCE.isWorking()) return; - seenModCount = modCount; + if (!startupDecided || !modsChanged()) return; + Set mods = loadedModIds(); + Set newIds = new HashSet<>(mods); + newIds.removeAll(seenMods); + if (HotwireBridge.INSTANCE.isStarting(newIds)) return; + seenMods = mods; rediscover(); Map> added = new LinkedHashMap<>(); for (Provider provider : providers()) { @@ -214,7 +216,7 @@ public static void finishRegistration() { } } finally { //? if !neoforge - seenModCount = FabricLoader.getInstance().getAllMods().size(); + seenMods = loadedModIds(); startupDecided = true; // caught so a broken CompatLoader never hides an exception from ConfigManager.initialize() try { @@ -580,6 +582,25 @@ private static List discover() { } //? if !neoforge { + private static boolean modsChanged() { + synchronized (FabricLoader.getInstance()) { + Collection mods = FabricLoader.getInstance().getAllMods(); + if (mods.size() != seenMods.size()) return true; + for (ModContainer mod : mods) { + if (!seenMods.contains(mod.getMetadata().getId())) return true; + } + return false; + } + } + + private static Set loadedModIds() { + Set ids = new HashSet<>(); + synchronized (FabricLoader.getInstance()) { + for (ModContainer mod : FabricLoader.getInstance().getAllMods()) ids.add(mod.getMetadata().getId()); + } + return ids; + } + // 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/resources/assets/oneconfig/lang/en_us.json b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json index 1d8108384..2cfc3e85b 100644 --- a/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json +++ b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json @@ -296,6 +296,7 @@ "oneconfig.page.hotwire.update.unavailable": "Hotwire can't update mods in this game session.", "oneconfig.page.hotwire.update.restart_only": "Hotwire can't swap this mod while the game runs, so it updates at the next launch.", "oneconfig.page.hotwire.update.self": "Hotwire can't update itself while the game runs, so it updates at the next launch.", + "oneconfig.page.hotwire.update.self_unchecked": "Hotwire couldn't check this update against your other mods, so it wasn't queued.", "oneconfig.page.hotwire.unreadable": "Hotwire can't read this jar.", "oneconfig.page.hotwire.upgrade.page": "This version of Hotwire is too old for this page. Update Hotwire to use it.", "oneconfig.page.hotwire.upgrade.store": "This version of Hotwire can't browse Modrinth here. Update Hotwire to use it.", 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 index e7018cbd5..9ba240653 100644 --- 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 @@ -148,6 +148,11 @@ object HotwireBridge { /** Whether Hotwire is running a switch, add or update right now */ fun isWorking(): Boolean = runCatching { call("getProgress")!!.get("running") as Boolean }.getOrDefault(false) + fun isStarting(ids: Collection): Boolean = isWorking() && runCatching { + val installed = engine()!!.field("installed")!! + ids.any { installed.callMethod("isAddedThisSession", it) != true } + }.getOrDefault(true) + fun awaitIdle() { val start = System.nanoTime() var sawWorking = false @@ -537,21 +542,54 @@ object HotwireBridge { private fun planSelfUpdate(update: StoreUpdate): JarPlan { val engine = engine()!! val jars = downloadUpdate(update) + val detail = "${update.installedVersion} → ${update.newVersion}" + nextLaunchRefusal(engine, jars)?.let { return JarPlan(update.name, detail, listOf(it), emptyList()) } val action = JarAction(translated("oneconfig.page.hotwire.update.next_start", "Update at next launch"), true) { CompletableFuture.supplyAsync { - jars.dropLast(1).firstNotNullOfOrNull { jar -> queueAdd(engine, jar).takeUnless { it.ok } } + nextLaunchRefusal(engine, jars)?.let { Result(false, it) } + ?: jars.dropLast(1).firstNotNullOfOrNull { jar -> queueAdd(engine, jar).takeUnless { it.ok } } ?: queueSelfUpdate(engine, update, jars.last()).takeUnless { it.ok } ?: Result(true, translated("oneconfig.page.hotwire.update.queued", "%s updates at the next launch.", update.name)) } } return JarPlan( update.name, - "${update.installedVersion} → ${update.newVersion}", + 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(action), ) } + @Suppress("UNCHECKED_CAST") + private fun nextLaunchRefusal(engine: Any, jars: List): String? = runCatching { + val incoming = jars.map(::readJar) + val ids = incoming.map { it.modId() } + val queued = (engine.field("updateQueue")!!.callMethod("getEntries") as List).mapNotNull { entry -> + runCatching { readJar(Path.of(entry.get("stagedJar") as String)) }.getOrNull()?.let { entry.get("modId") as String to it } + } + val installed = engine.field("installed")!! + val leaving = ids + queued.flatMap { (id, jar) -> listOf(id, jar.modId()) } + (installed.field("disabled") as Map).keys + val staying = (installed.callHidden("getGraphJars") as List).filterNot { it.modId() in leaving } + // the jars that land go first, since the graph keeps the first copy of a mod it sees + val graph = Class.forName("dev.moon.hotwire.plan.ModGraph").getConstructor(Collection::class.java) + .newInstance(incoming + queued.map { it.second } + staying) + refusedPlan(engine.callStatic("ModPolicy", "makePlannerFor", graph)!!, graph, ids)?.let { describeRefusal(engine, it) } + }.getOrElse { + translated("oneconfig.page.hotwire.update.self_unchecked", "Hotwire couldn't check this update against your other mods, so it wasn't queued.") + } + + @Suppress("UNCHECKED_CAST") + fun refusedPlan(planner: Any, graph: Any, ids: List): Any? { + val plan = planner.callMethod("planOn", ids, ids.toSet())!! + val old = (ids + ids.flatMap { graph.callMethod("getDependents", it) as Collection }).distinct() + .flatMap { planner.callMethod("findOldDependencies", it) as List } + .filter { it.get("mod") in ids || it.get("dependency") in ids } + val refusals = plan.get("refusals") as List + old + if (refusals.isEmpty()) return null + return plan.javaClass.constructors.single() + .newInstance(plan.get("direction"), plan.get("order"), plan.get("why"), plan.get("enabledNested"), refusals) + } + private fun queueSelfUpdate(engine: Any, update: StoreUpdate, jar: Path): Result { val queued = queueAdd(engine, jar) if (!queued.ok) return queued diff --git a/modules/internal/src/test/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireSelfUpdateQueueTest.kt b/modules/internal/src/test/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireSelfUpdateQueueTest.kt index 061d660d7..4eda4029c 100644 --- a/modules/internal/src/test/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireSelfUpdateQueueTest.kt +++ b/modules/internal/src/test/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireSelfUpdateQueueTest.kt @@ -2,9 +2,59 @@ package org.polyfrost.oneconfig.internal.ui.api import java.nio.file.Path import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Test class HotwireSelfUpdateQueueTest { + class Old(private val mod: String, private val dependency: String) { + fun mod() = mod + fun dependency() = dependency + } + + class Plan(private val direction: String, private val order: List<*>, private val why: Map<*, *>, private val nested: List<*>, private val refusals: List) { + fun direction() = direction + fun order() = order + fun why() = why + fun enabledNested() = nested + fun refusals() = refusals + } + + class Planner(private val refused: List, private val old: Map>) { + @Suppress("UNUSED_PARAMETER") + fun planOn(ids: Collection, off: Set) = Plan("ON", emptyList(), emptyMap(), emptyList(), refused) + fun findOldDependencies(id: String) = old[id].orEmpty() + } + + class Graph(private val dependents: Map>) { + fun getDependents(id: String) = dependents[id].orEmpty() + } + + private fun refusals(planner: Planner, graph: Graph) = + (HotwireBridge.refusedPlan(planner, graph, listOf("hotwire")) as Plan?)?.refusals() + + @Test + fun aNewVersionThatFitsIsNotRefused() { + val unrelated = Old("other", "lib") + assertNull(refusals(Planner(emptyList(), mapOf("other" to listOf(unrelated))), Graph(mapOf("hotwire" to setOf("other"))))) + } + + @Test + fun aDependencyTooOldForTheNewVersionIsRefused() { + val old = Old("hotwire", "fabric-api") + assertEquals(listOf(old), refusals(Planner(emptyList(), mapOf("hotwire" to listOf(old))), Graph(emptyMap()))) + } + + @Test + fun aModThatNeedsAnOlderVersionRefusesIt() { + val old = Old("other", "hotwire") + assertEquals(listOf(old), refusals(Planner(emptyList(), mapOf("other" to listOf(old))), Graph(mapOf("hotwire" to setOf("other"))))) + } + + @Test + fun aRefusalOfThePlanIsKept() { + assertEquals(listOf("breaks"), refusals(Planner(listOf("breaks"), emptyMap()), Graph(emptyMap()))) + } + data class Entry( val modId: String, val name: String, From a07f57d1e98e8c28bbf86867c54d926856703007 Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Sat, 31 Oct 2026 15:33:54 -0400 Subject: [PATCH 18/63] ughhhhhh --- .../internal/compat/ModMenuCompat.kt | 20 +++++++++---- .../api/config/v1/ConfigManager.java | 9 ++++-- .../api/config/v1/LateInitializationTest.java | 28 +++++++++++++++++++ .../internal/ui/api/HotwireBridge.kt | 21 ++++++++++++-- .../ui/api/HotwireSelfUpdateQueueTest.kt | 19 +++++++++++-- 5 files changed, 84 insertions(+), 13 deletions(-) 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 f87f7b156..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 @@ -49,16 +49,26 @@ object ModMenuCompat { @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 = mods.mapNotNull { mod -> - val nativeChild = mod.findNativeLoadedChild() ?: return@mapNotNull null - mod.aliasNativeChildConfig(nativeChild) - mod.id - }.toSet() + val nativeCoveredMods = coveredByNativeChild(mods) val foundMods = mods.filterNot { CompatLoader.nativeLoadedConfigs.contains(it.id) || 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 36202ac71..f952bbf0b 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 @@ -40,6 +40,7 @@ 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; @@ -81,6 +82,7 @@ public final class ConfigManager { private static boolean initialized = false; private static boolean isFirstRun = false; private static final Queue pendingInitialization = new ArrayDeque<>(); + private static final Queue createdAfterStartup = new ConcurrentLinkedQueue<>(); private static final Map initializedConfigs = new LinkedHashMap<>(); private static final ReentrantLock PROFILE_LIFECYCLE_LOCK = new ReentrantLock(); private static final long PROFILE_OPERATION_BUDGET_NANOS = TimeUnit.SECONDS.toNanos(30L); @@ -243,6 +245,7 @@ public static void initialize() { @ApiStatus.Internal public static void initializeLate(Runnable creator) { initialized = false; + for (Config config; (config = createdAfterStartup.poll()) != null; ) pendingInitialization.add(config); try { creator.run(); } finally { @@ -260,9 +263,7 @@ static boolean didInitializationFail(Config config) { 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) { - pendingInitialization.add(config); - } + (initialized ? createdAfterStartup : pendingInitialization).add(config); for (ConfigSubmissionListener listener : submissionListeners) { try { listener.onConfigSubmitted(config); @@ -288,6 +289,7 @@ public static boolean isRebindingProfiles() { static void removePendingInitialization(Config config) { pendingInitialization.remove(config); + createdAfterStartup.remove(config); } /** @@ -310,6 +312,7 @@ public static List pendingConfigs() { public static synchronized List createdConfigs() { List created = new ArrayList<>(initializedConfigs.values()); created.addAll(pendingInitialization); + created.addAll(createdAfterStartup); return created; } 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 index 36abf50fc..2394a6f54 100644 --- 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 @@ -1,7 +1,10 @@ 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.lang.reflect.Field; @@ -34,4 +37,29 @@ void configsCreatedLateAreInitializedOnceTheCreatorReturns() { assertNotNull(created.get().getTree()); assertTrue(ConfigManager.createdConfigs().contains(created.get())); } + + @Test + void configsCreatedBeforeTheWindowOpensAreKeptForIt() { + ConfigManager.initializeLate(() -> { + }); + Config early = new LateConfig("late-initialization-early-test.json"); + assertNull(early.getTree()); + assertFalse(ConfigManager.isPendingInitialization(early)); + assertTrue(ConfigManager.createdConfigs().contains(early)); + ConfigManager.initializeLate(() -> assertNull(early.getTree())); + assertNotNull(early.getTree()); + } + + @Test + void configsThatInitializedThemselvesAreNotKept() { + ConfigManager.initializeLate(() -> { + }); + Config early = new LateConfig("late-initialization-preload-test.json"); + early.preload(); + Tree tree = early.getTree(); + ConfigManager.initializeLate(() -> { + }); + assertSame(tree, early.getTree()); + assertEquals(1, ConfigManager.createdConfigs().stream().filter(early::equals).count()); + } } 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 index 9ba240653..6155b27f6 100644 --- 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 @@ -573,18 +573,33 @@ object HotwireBridge { // the jars that land go first, since the graph keeps the first copy of a mod it sees val graph = Class.forName("dev.moon.hotwire.plan.ModGraph").getConstructor(Collection::class.java) .newInstance(incoming + queued.map { it.second } + staying) - refusedPlan(engine.callStatic("ModPolicy", "makePlannerFor", graph)!!, graph, ids)?.let { describeRefusal(engine, it) } + val depends = Class.forName("dev.moon.hotwire.jar.ModManifest\$Relation").enumConstants.first { (it as Enum<*>).name == "DEPENDS" } + val oldDependency = Class.forName("dev.moon.hotwire.plan.SwitchPlan\$Refusal\$OldDependency").constructors.single() + val builtIns = incoming.flatMap { it.parts() }.flatMap { part -> + oldBuiltIns( + part.modId(), + part.get("manifest")!!.callMethod("getRelation", depends) as Map>, + { id -> (engine.callStatic("LoadedMods", "findVersion", id) as String?).takeIf { (graph.callMethod("find", id) as Optional<*>).isEmpty } }, + { version, ranges -> engine.callStatic("ModPolicy", "satisfies", version, ranges) == true }, + ) + }.map { oldDependency.newInstance(*it.toTypedArray()) } + refusedPlan(engine.callStatic("ModPolicy", "makePlannerFor", graph)!!, graph, ids, builtIns)?.let { describeRefusal(engine, it) } }.getOrElse { translated("oneconfig.page.hotwire.update.self_unchecked", "Hotwire couldn't check this update against your other mods, so it wasn't queued.") } + fun oldBuiltIns(mod: String, needs: Map>, present: (String) -> String?, fits: (String, List) -> Boolean): List> = + needs.mapNotNull { (id, ranges) -> + present(id)?.takeUnless { fits(it, ranges) }?.let { listOf(mod, id, ranges.joinToString(" or "), it) } + } + @Suppress("UNCHECKED_CAST") - fun refusedPlan(planner: Any, graph: Any, ids: List): Any? { + fun refusedPlan(planner: Any, graph: Any, ids: List, builtIns: List = emptyList()): Any? { val plan = planner.callMethod("planOn", ids, ids.toSet())!! val old = (ids + ids.flatMap { graph.callMethod("getDependents", it) as Collection }).distinct() .flatMap { planner.callMethod("findOldDependencies", it) as List } .filter { it.get("mod") in ids || it.get("dependency") in ids } - val refusals = plan.get("refusals") as List + old + val refusals = plan.get("refusals") as List + old + builtIns if (refusals.isEmpty()) return null return plan.javaClass.constructors.single() .newInstance(plan.get("direction"), plan.get("order"), plan.get("why"), plan.get("enabledNested"), refusals) diff --git a/modules/internal/src/test/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireSelfUpdateQueueTest.kt b/modules/internal/src/test/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireSelfUpdateQueueTest.kt index 4eda4029c..4d3bd8436 100644 --- a/modules/internal/src/test/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireSelfUpdateQueueTest.kt +++ b/modules/internal/src/test/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireSelfUpdateQueueTest.kt @@ -29,8 +29,8 @@ class HotwireSelfUpdateQueueTest { fun getDependents(id: String) = dependents[id].orEmpty() } - private fun refusals(planner: Planner, graph: Graph) = - (HotwireBridge.refusedPlan(planner, graph, listOf("hotwire")) as Plan?)?.refusals() + private fun refusals(planner: Planner, graph: Graph, builtIns: List = emptyList()) = + (HotwireBridge.refusedPlan(planner, graph, listOf("hotwire"), builtIns) as Plan?)?.refusals() @Test fun aNewVersionThatFitsIsNotRefused() { @@ -55,6 +55,21 @@ class HotwireSelfUpdateQueueTest { assertEquals(listOf("breaks"), refusals(Planner(listOf("breaks"), emptyMap()), Graph(emptyMap()))) } + @Test + fun aBuiltInTooOldForTheNewVersionIsReported() { + val old = HotwireBridge.oldBuiltIns( + "hotwire", + mapOf("java" to listOf(">=25"), "minecraft" to listOf("26.3"), "fabric-api" to listOf(">=2")), + mapOf("java" to "21", "minecraft" to "26.3")::get, + ) { version, ranges -> version == ranges.single() } + assertEquals(listOf(listOf("hotwire", "java", ">=25", "21")), old) + } + + @Test + fun aTooOldBuiltInRefusesThePlan() { + assertEquals(listOf("old java"), refusals(Planner(emptyList(), emptyMap()), Graph(emptyMap()), listOf("old java"))) + } + data class Entry( val modId: String, val name: String, From 201467696b32edfa98d9b9c9267062d67a10ca6b Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Sat, 31 Oct 2026 15:53:34 -0400 Subject: [PATCH 19/63] fix more more more more more stupid shit --- .../internal/compat/ConfigEntrypoints.java | 11 +-- .../api/config/v1/ConfigManager.java | 9 ++- .../api/config/v1/LateInitializationTest.java | 71 ++++++++++++++++--- 3 files changed, 74 insertions(+), 17 deletions(-) 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 c6d304c29..9b7ac79ca 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 @@ -186,11 +186,12 @@ public static void registerAdded() { for (Provider provider : providers()) { if (!isDecided(provider.modId)) added.computeIfAbsent(provider.modId, k -> new ArrayList<>()).add(provider); } - if (!added.isEmpty()) { - prepare(added.keySet()); - ConfigManager.initializeLate(() -> added.forEach((modId, entrypoints) -> undecided.put(modId, register(modId, entrypoints)))); - decidePending(); - } + prepare(added.keySet()); + ConfigManager.initializeLate(config -> { + ModInfo owner = CompatLoader.INSTANCE.findModByClass(config.getClass()); + return owner != null && newIds.contains(owner.getId()); + }, () -> added.forEach((modId, entrypoints) -> undecided.put(modId, register(modId, entrypoints)))); + decidePending(); //? modmenu_compat if (CompatLoader.INSTANCE.hasMod("modmenu")) ModMenuCompat.refresh(); //?} 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 f952bbf0b..413a7c20d 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 @@ -48,6 +48,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; @@ -243,10 +244,12 @@ public static void initialize() { } @ApiStatus.Internal - public static void initializeLate(Runnable creator) { + public static void initializeLate(Predicate added, Runnable creator) { initialized = false; - for (Config config; (config = createdAfterStartup.poll()) != null; ) pendingInitialization.add(config); try { + for (Config config : createdAfterStartup) { + if (added.test(config) && createdAfterStartup.remove(config)) pendingInitialization.add(config); + } creator.run(); } finally { initialize(); @@ -317,7 +320,7 @@ public static synchronized List createdConfigs() { } static boolean isPendingInitialization(Config config) { - return pendingInitialization.contains(config); + return pendingInitialization.contains(config) || createdAfterStartup.contains(config); } static void reportResetOptions(Config config, List options) { 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 index 2394a6f54..7a30a3704 100644 --- 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 @@ -7,10 +7,14 @@ import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.io.IOException; import java.lang.reflect.Field; +import java.nio.file.Files; +import java.util.List; 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 { @@ -19,6 +23,22 @@ static class LateConfig extends Config { } } + 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"}); + } + } + @AfterEach void reopenStartup() throws ReflectiveOperationException { Field initialized = ConfigManager.class.getDeclaredField("initialized"); @@ -26,10 +46,15 @@ void reopenStartup() throws ReflectiveOperationException { initialized.setBoolean(null, false); } + private static void finishStartup() { + ConfigManager.initializeLate(config -> false, () -> { + }); + } + @Test void configsCreatedLateAreInitializedOnceTheCreatorReturns() { AtomicReference created = new AtomicReference<>(); - ConfigManager.initializeLate(() -> { + ConfigManager.initializeLate(config -> false, () -> { created.set(new LateConfig("late-initialization-test.json")); assertNull(created.get().getTree()); assertTrue(ConfigManager.createdConfigs().contains(created.get())); @@ -39,25 +64,53 @@ void configsCreatedLateAreInitializedOnceTheCreatorReturns() { } @Test - void configsCreatedBeforeTheWindowOpensAreKeptForIt() { - ConfigManager.initializeLate(() -> { - }); + void addedConfigsCreatedBeforeTheWindowOpensAreKeptForIt() { + finishStartup(); Config early = new LateConfig("late-initialization-early-test.json"); assertNull(early.getTree()); - assertFalse(ConfigManager.isPendingInitialization(early)); assertTrue(ConfigManager.createdConfigs().contains(early)); - ConfigManager.initializeLate(() -> assertNull(early.getTree())); + ConfigManager.initializeLate(early::equals, () -> assertNull(early.getTree())); assertNotNull(early.getTree()); } @Test - void configsThatInitializedThemselvesAreNotKept() { - ConfigManager.initializeLate(() -> { + 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(); + 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 configsThatInitializedThemselvesAreNotKept() { + finishStartup(); Config early = new LateConfig("late-initialization-preload-test.json"); early.preload(); Tree tree = early.getTree(); - ConfigManager.initializeLate(() -> { + ConfigManager.initializeLate(early::equals, () -> { }); assertSame(tree, early.getTree()); assertEquals(1, ConfigManager.createdConfigs().stream().filter(early::equals).count()); From 8bf844b61277b4901fe778ea9f2bdbb8e14b2da8 Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Sat, 31 Oct 2026 16:26:33 -0400 Subject: [PATCH 20/63] aaa --- .../internal/compat/ConfigEntrypoints.java | 36 ++++--- .../assets/oneconfig/lang/en_us.json | 2 +- .../compat/ConfigEntrypointsTest.java | 28 ++++++ .../oneconfig/api/config/v1/Config.java | 1 + .../api/config/v1/ConfigManager.java | 21 ++++- .../api/config/v1/LateInitializationTest.java | 93 ++++++++++++++++++- .../internal/ui/api/HotwireBridge.kt | 19 ++-- 7 files changed, 171 insertions(+), 29 deletions(-) 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 9b7ac79ca..212db09b0 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 @@ -81,7 +81,8 @@ private record Skipped(String modId, Runnable extraction) { private static final AtomicBoolean registered = new AtomicBoolean(); private static final AtomicBoolean listening = new AtomicBoolean(); private static volatile boolean startupDecided; - private static Set seenMods = Set.of(); + //? if !neoforge + private static Map seenMods = Map.of(); private 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(); @@ -176,11 +177,12 @@ private static void prepare(Set modIds) { public static void registerAdded() { //? if !neoforge { if (!startupDecided || !modsChanged()) return; - Set mods = loadedModIds(); - Set newIds = new HashSet<>(mods); - newIds.removeAll(seenMods); + Map mods = loadedMods(); + Set newIds = new HashSet<>(mods.keySet()); + newIds.removeAll(seenMods.keySet()); if (HotwireBridge.INSTANCE.isStarting(newIds)) return; seenMods = mods; + HotwireBridge.INSTANCE.invalidate(); rediscover(); Map> added = new LinkedHashMap<>(); for (Provider provider : providers()) { @@ -217,7 +219,7 @@ public static void finishRegistration() { } } finally { //? if !neoforge - seenMods = loadedModIds(); + seenMods = loadedMods(); startupDecided = true; // caught so a broken CompatLoader never hides an exception from ConfigManager.initialize() try { @@ -510,6 +512,7 @@ public T register(T config) { 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 @@ -585,21 +588,24 @@ private static List discover() { //? if !neoforge { private static boolean modsChanged() { synchronized (FabricLoader.getInstance()) { - Collection mods = FabricLoader.getInstance().getAllMods(); - if (mods.size() != seenMods.size()) return true; - for (ModContainer mod : mods) { - if (!seenMods.contains(mod.getMetadata().getId())) return true; - } - return false; + return modsChanged(FabricLoader.getInstance().getAllMods(), seenMods); } } - private static Set loadedModIds() { - Set ids = new HashSet<>(); + 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; + } + + private static Map loadedMods() { + Map mods = new HashMap<>(); synchronized (FabricLoader.getInstance()) { - for (ModContainer mod : FabricLoader.getInstance().getAllMods()) ids.add(mod.getMetadata().getId()); + for (ModContainer mod : FabricLoader.getInstance().getAllMods()) mods.put(mod.getMetadata().getId(), mod); } - return ids; + return mods; } // a nested mod that provides an entrypoint itself is left as its own provider 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 2cfc3e85b..c5ef9e0a2 100644 --- a/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json +++ b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json @@ -296,7 +296,7 @@ "oneconfig.page.hotwire.update.unavailable": "Hotwire can't update mods in this game session.", "oneconfig.page.hotwire.update.restart_only": "Hotwire can't swap this mod while the game runs, so it updates at the next launch.", "oneconfig.page.hotwire.update.self": "Hotwire can't update itself while the game runs, so it updates at the next launch.", - "oneconfig.page.hotwire.update.self_unchecked": "Hotwire couldn't check this update against your other mods, so it wasn't queued.", + "oneconfig.page.hotwire.next_launch_unchecked": "Hotwire couldn't check this against your other mods, so nothing was changed.", "oneconfig.page.hotwire.unreadable": "Hotwire can't read this jar.", "oneconfig.page.hotwire.upgrade.page": "This version of Hotwire is too old for this page. Update Hotwire to use it.", "oneconfig.page.hotwire.upgrade.store": "This version of Hotwire can't browse Modrinth here. Update Hotwire to use it.", 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..123a55fa7 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,5 +1,10 @@ package org.polyfrost.oneconfig.internal.compat; +import java.lang.reflect.Proxy; +import java.util.List; +import java.util.Map; +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.Config; @@ -154,4 +159,27 @@ static class Base extends Config { static class Derived extends Base { } + + private static ModContainer mod(String id) { + ClassLoader loader = ConfigEntrypointsTest.class.getClassLoader(); + ModMetadata metadata = (ModMetadata) Proxy.newProxyInstance(loader, new Class[]{ModMetadata.class}, (proxy, method, args) -> 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 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/Config.java b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/Config.java index c649d11a9..aad0729f9 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 @@ -56,6 +56,7 @@ public abstract class Config { private Map defaultSnapshot; private List deferredSetup; private boolean initializing; + final Thread creator = Thread.currentThread(); public final String id, title, iconPath; public final Category category; 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 413a7c20d..d831c663c 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 @@ -82,7 +82,7 @@ public final class ConfigManager { private static ConfigManager active; private static boolean initialized = false; private static boolean isFirstRun = false; - private static final Queue pendingInitialization = new ArrayDeque<>(); + private static final Queue pendingInitialization = new ConcurrentLinkedQueue<>(); private static final Queue createdAfterStartup = new ConcurrentLinkedQueue<>(); private static final Map initializedConfigs = new LinkedHashMap<>(); private static final ReentrantLock PROFILE_LIFECYCLE_LOCK = new ReentrantLock(); @@ -90,6 +90,7 @@ public final class ConfigManager { 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 Consumer profileSwitchDispatcher = Runnable::run; private static final CopyOnWriteArrayList profileListeners = new CopyOnWriteArrayList<>(); private static final CopyOnWriteArrayList treeListeners = new CopyOnWriteArrayList<>(); @@ -219,6 +220,10 @@ public static void initialize() { return; } initialized = true; + initializePending(); + } + + private static void initializePending() { long t1 = System.nanoTime(); LOGGER.info("Initializing {} configs...", pendingInitialization.size()); while (!pendingInitialization.isEmpty()) { @@ -245,17 +250,23 @@ public static void initialize() { @ApiStatus.Internal public static void initializeLate(Predicate added, Runnable creator) { - initialized = false; + LATE_INITIALIZING.set(Boolean.TRUE); try { for (Config config : createdAfterStartup) { - if (added.test(config) && createdAfterStartup.remove(config)) pendingInitialization.add(config); + if (config.creator == Thread.currentThread() && added.test(config) && createdAfterStartup.remove(config)) pendingInitialization.add(config); } creator.run(); } finally { - initialize(); + LATE_INITIALIZING.remove(); + initializePending(); } } + @ApiStatus.Internal + public static synchronized void claimForLateInitialization(Config config) { + if (LATE_INITIALIZING.get() && !initializedConfigs.containsKey(config.id) && createdAfterStartup.remove(config)) pendingInitialization.add(config); + } + private static final Set failedInitialization = ConcurrentHashMap.newKeySet(); static boolean didInitializationFail(Config config) { @@ -266,7 +277,7 @@ static boolean didInitializationFail(Config config) { 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 - (initialized ? createdAfterStartup : pendingInitialization).add(config); + (initialized && !LATE_INITIALIZING.get() ? createdAfterStartup : pendingInitialization).add(config); for (ConfigSubmissionListener listener : submissionListeners) { try { listener.onConfigSubmitted(config); 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 index 7a30a3704..a1d507430 100644 --- 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 @@ -11,6 +11,8 @@ import java.lang.reflect.Field; import java.nio.file.Files; import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -39,6 +41,35 @@ static class SetupConfig extends ValueConfig { } } + 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); + } + } + + 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 { Field initialized = ConfigManager.class.getDeclaredField("initialized"); @@ -47,12 +78,12 @@ void reopenStartup() throws ReflectiveOperationException { } private static void finishStartup() { - ConfigManager.initializeLate(config -> false, () -> { - }); + ConfigManager.initialize(); } @Test void configsCreatedLateAreInitializedOnceTheCreatorReturns() { + finishStartup(); AtomicReference created = new AtomicReference<>(); ConfigManager.initializeLate(config -> false, () -> { created.set(new LateConfig("late-initialization-test.json")); @@ -115,4 +146,62 @@ void configsThatInitializedThemselvesAreNotKept() { 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 configsHandedOverInTheWindowAreInitialized() { + finishStartup(); + Config library = new LateConfig("late-initialization-claimed-test.json"); + ConfigManager.claimForLateInitialization(library); + assertTrue(ConfigManager.isPendingInitialization(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/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 index 6155b27f6..dad0ef545 100644 --- 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 @@ -401,6 +401,7 @@ object HotwireBridge { val fitting = engine()?.takeIf { !canAdd && fitTogether(it, jars) } val refusal = fitting?.let { refusalOnceMet(it, jars) } val inOrder = fitting.takeIf { refusal == null } + val nextLaunch = engine()?.takeIf { jars.isNotEmpty() && (canAdd || inOrder != null) }?.let { nextLaunchRefusal(it, jars) } val reopen = add.through("StoreAdd", "needsReopen") == true || inOrder != null && jars.any { reopensOnceMet(inOrder, it) } val modes =(add.field("previews") as List<*>).map { it!!.addMode() } val onlyLater = modes.lastOrNull() == "NEXT_START" || inOrder != null && "NEXT_START" in modes @@ -440,8 +441,8 @@ object HotwireBridge { } } @Suppress("UNCHECKED_CAST") - val lines = if (refusal != null) { - listOf(refusal) + val lines = if (refusal != null || nextLaunch != null) { + listOf(nextLaunch ?: refusal!!) } else if (inOrder == null) { add.through("StoreAdd", "getLines") as List } else { @@ -454,7 +455,7 @@ object HotwireBridge { "", lines, when { - !canAdd && inOrder == null -> listOf(save) + nextLaunch != null || !canAdd && inOrder == null -> listOf(save) onlyLater -> listOf(later, save) else -> listOf(now, later, save) }, @@ -504,6 +505,7 @@ object HotwireBridge { }.getOrNull() private fun addInOrder(engine: Any, jars: List, name: String, reopen: Boolean, later: Boolean = false): Result { + nextLaunchRefusal(engine, jars)?.let { return Result(false, it) } var queueing = later for (jar in jars) { queueing = queueing || dryRunAdd(engine, jar).addMode() == "NEXT_START" @@ -585,7 +587,7 @@ object HotwireBridge { }.map { oldDependency.newInstance(*it.toTypedArray()) } refusedPlan(engine.callStatic("ModPolicy", "makePlannerFor", graph)!!, graph, ids, builtIns)?.let { describeRefusal(engine, it) } }.getOrElse { - translated("oneconfig.page.hotwire.update.self_unchecked", "Hotwire couldn't check this update against your other mods, so it wasn't queued.") + translated("oneconfig.page.hotwire.next_launch_unchecked", "Hotwire couldn't check this against your other mods, so nothing was changed.") } fun oldBuiltIns(mod: String, needs: Map>, present: (String) -> String?, fits: (String, List) -> Boolean): List> = @@ -715,7 +717,8 @@ object HotwireBridge { ?.let { return JarPlan(plan.title, plan.detail, it.get("lines") as List, emptyList()) } if (!collected || steps.isEmpty()) return plan val adds = steps.filter { engine.callMethod("canUpdate", it) != true } - refusalOnceMet(engine, adds)?.let { return JarPlan(plan.title, plan.detail, listOf(it), emptyList()) } + (refusalOnceMet(engine, adds) ?: adds.takeIf { it.isNotEmpty() }?.let { nextLaunchRefusal(engine, steps + jar) }) + ?.let { return JarPlan(plan.title, plan.detail, listOf(it), emptyList()) } val reopen = firstAdds.any { it.addMode() == "SAVE_AND_REOPEN" } || adds.any { reopensOnceMet(engine, it) } val label = if (reopen) { translated("oneconfig.page.hotwire.update.reopen", "Save and update") @@ -891,7 +894,11 @@ object HotwireBridge { null } val later = JarAction(translated("oneconfig.page.hotwire.add.next_start", "Add at next launch"), false, add("NEXT_START")) - val actions = when ((preview.get("mode") as Enum<*>).name) { + val mode = (preview.get("mode") as Enum<*>).name + if (mode != "REFUSED") { + nextLaunchRefusal(engine, listOf(jar))?.let { return JarPlan(preview.get("name") as String, preview.get("version") as String? ?: "", listOf(it), emptyList()) } + } + val actions = when (mode) { "NOW" -> listOf(JarAction(translated("oneconfig.page.hotwire.add.now", "Add now"), true, add("NOW")), later) "SAVE_AND_REOPEN" -> listOf(JarAction(translated("oneconfig.page.hotwire.add.reopen", "Save and add"), true, add("SAVE_AND_REOPEN")), later) "NEXT_START" -> listOf(JarAction(later.label, true, add("NEXT_START"))) From f037fe830f9bf2726c914872bd0a7f536a54a2cf Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Mon, 2 Nov 2026 00:30:23 -0500 Subject: [PATCH 21/63] Bruhhh --- .../internal/compat/ConfigEntrypoints.java | 79 ++++++--- .../assets/oneconfig/lang/en_us.json | 1 + .../compat/ConfigEntrypointsTest.java | 10 ++ .../api/config/v1/ConfigManager.java | 40 ++++- .../api/config/v1/LateInitializationTest.java | 82 ++++++++- .../internal/ui/api/HotwireBridge.kt | 144 ++++++++++++---- .../ui/api/HotwireSelfUpdateQueueTest.kt | 163 +++++++++++++++--- 7 files changed, 430 insertions(+), 89 deletions(-) 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 212db09b0..af95d6a1b 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 @@ -82,7 +82,8 @@ private record Skipped(String modId, Runnable extraction) { private static final AtomicBoolean listening = new AtomicBoolean(); private static volatile boolean startupDecided; //? if !neoforge - private static Map seenMods = Map.of(); + private static volatile Map seenMods = Map.of(); + private static volatile Set lateIds = Set.of(); private 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(); @@ -176,26 +177,10 @@ private static void prepare(Set modIds) { public static void registerAdded() { //? if !neoforge { - if (!startupDecided || !modsChanged()) return; - Map mods = loadedMods(); - Set newIds = new HashSet<>(mods.keySet()); - newIds.removeAll(seenMods.keySet()); - if (HotwireBridge.INSTANCE.isStarting(newIds)) return; - seenMods = mods; - HotwireBridge.INSTANCE.invalidate(); - rediscover(); - Map> added = new LinkedHashMap<>(); - for (Provider provider : providers()) { - if (!isDecided(provider.modId)) added.computeIfAbsent(provider.modId, k -> new ArrayList<>()).add(provider); - } - prepare(added.keySet()); - ConfigManager.initializeLate(config -> { - ModInfo owner = CompatLoader.INSTANCE.findModByClass(config.getClass()); - return owner != null && newIds.contains(owner.getId()); - }, () -> added.forEach((modId, entrypoints) -> undecided.put(modId, register(modId, entrypoints)))); - decidePending(); - //? modmenu_compat - if (CompatLoader.INSTANCE.hasMod("modmenu")) ModMenuCompat.refresh(); + if (!startupDecided) return; + if (modsChanged()) registerChanged(); + else if (!lateIds.isEmpty()) finishLate(() -> { + }); //?} } @@ -218,8 +203,10 @@ public static void finishRegistration() { } } } finally { - //? if !neoforge + //? if !neoforge { seenMods = loadedMods(); + ConfigManager.setAwaitingLateWindow(ConfigEntrypoints::awaitsLateWindow); + //?} startupDecided = true; // caught so a broken CompatLoader never hides an exception from ConfigManager.initialize() try { @@ -586,6 +573,46 @@ private static List discover() { } //? if !neoforge { + private static void registerChanged() { + Map mods = loadedMods(); + Set changedIds = changedIds(mods, seenMods); + Set newIds = new HashSet<>(changedIds); + newIds.removeAll(seenMods.keySet()); + if (HotwireBridge.INSTANCE.isStarting(newIds) || changedIds.size() > newIds.size() && HotwireBridge.INSTANCE.isUpdating()) return; + seenMods = mods; + HotwireBridge.INSTANCE.invalidate(); + rediscover(); + Map> added = new LinkedHashMap<>(); + for (Provider provider : providers()) { + if (!isDecided(provider.modId)) added.computeIfAbsent(provider.modId, k -> new ArrayList<>()).add(provider); + } + prepare(added.keySet()); + Set late = new HashSet<>(lateIds); + late.addAll(changedIds); + lateIds = late; + finishLate(() -> added.forEach((modId, entrypoints) -> undecided.put(modId, register(modId, entrypoints)))); + } + + private static void finishLate(Runnable creator) { + Set ids = lateIds; + if (ConfigManager.initializeLate(config -> ownedBy(config, ids), creator)) return; + lateIds = Set.of(); + decidePending(); + //? modmenu_compat + if (CompatLoader.INSTANCE.hasMod("modmenu")) ModMenuCompat.refresh(); + } + + private static boolean ownedBy(Config config, Set ids) { + ModInfo owner = CompatLoader.INSTANCE.findModByClass(config.getClass()); + return owner != null && ids.contains(owner.getId()); + } + + private static boolean awaitsLateWindow(Config config) { + Set ids = new HashSet<>(lateIds); + if (modsChanged()) ids.addAll(changedIds(loadedMods(), seenMods)); + return !ids.isEmpty() && ownedBy(config, ids); + } + private static boolean modsChanged() { synchronized (FabricLoader.getInstance()) { return modsChanged(FabricLoader.getInstance().getAllMods(), seenMods); @@ -600,6 +627,14 @@ static boolean modsChanged(Collection mods, Map changedIds(Map mods, Map seen) { + Set changed = new HashSet<>(); + mods.forEach((id, mod) -> { + if (seen.get(id) != mod) changed.add(id); + }); + return changed; + } + private static Map loadedMods() { Map mods = new HashMap<>(); synchronized (FabricLoader.getInstance()) { 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 c5ef9e0a2..8c175d529 100644 --- a/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json +++ b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json @@ -267,6 +267,7 @@ "oneconfig.page.hotwire.add.done": "Added %s.", "oneconfig.page.hotwire.add.queued": "%s is added at the next launch.", "oneconfig.page.hotwire.add.queue_failed": "Hotwire couldn't queue %s - please check chat!", + "oneconfig.page.hotwire.update.name_taken": "A different jar named %s is already there, so nothing was changed.", "oneconfig.page.hotwire.add.not_kept": "%s is added for now, but it won't stay after a restart - please check chat!", "oneconfig.page.hotwire.reopen_confirm": "%s has to save and reopen your world. Try again to confirm.", "oneconfig.page.hotwire.add.reading": "Reading the jar…", 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 123a55fa7..db74eaa8f 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 @@ -3,6 +3,7 @@ import java.lang.reflect.Proxy; import java.util.List; import java.util.Map; +import java.util.Set; import net.fabricmc.loader.api.ModContainer; import net.fabricmc.loader.api.metadata.ModMetadata; import org.junit.jupiter.api.Test; @@ -178,6 +179,15 @@ void anAddedModIsAChange() { 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 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/ConfigManager.java b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/ConfigManager.java index d831c663c..a2c81121d 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 @@ -91,6 +91,7 @@ public final class ConfigManager { 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<>(); @@ -249,22 +250,49 @@ private static void initializePending() { } @ApiStatus.Internal - public static void initializeLate(Predicate added, Runnable creator) { + public static boolean initializeLate(Predicate added, Runnable creator) { LATE_INITIALIZING.set(Boolean.TRUE); try { for (Config config : createdAfterStartup) { - if (config.creator == Thread.currentThread() && added.test(config) && createdAfterStartup.remove(config)) pendingInitialization.add(config); + if (isBuilt(config) && added.test(config) && createdAfterStartup.remove(config)) pendingInitialization.add(config); } creator.run(); } finally { LATE_INITIALIZING.remove(); - initializePending(); + holdBackDuplicates(); + if (!pendingInitialization.isEmpty()) initializePending(); + } + for (Config config : createdAfterStartup) { + if (!isBuilt(config) && added.test(config)) return true; + } + return false; + } + + @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; + } + + private static synchronized void holdBackDuplicates() { + Set ids = new HashSet<>(initializedConfigs.keySet()); + for (Config config : pendingInitialization) { + if (!ids.add(config.id) && pendingInitialization.remove(config)) createdAfterStartup.add(config); } } @ApiStatus.Internal - public static synchronized void claimForLateInitialization(Config config) { - if (LATE_INITIALIZING.get() && !initializedConfigs.containsKey(config.id) && createdAfterStartup.remove(config)) pendingInitialization.add(config); + public static void claimForLateInitialization(Config config) { + if (LATE_INITIALIZING.get() && createdAfterStartup.remove(config)) pendingInitialization.add(config); } private static final Set failedInitialization = ConcurrentHashMap.newKeySet(); @@ -331,7 +359,7 @@ public static synchronized List createdConfigs() { } static boolean isPendingInitialization(Config config) { - return pendingInitialization.contains(config) || createdAfterStartup.contains(config); + return pendingInitialization.contains(config) || createdAfterStartup.contains(config) && awaitingLateWindow.test(config); } static void reportResetOptions(Config config, List options) { 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 index a1d507430..74d27727d 100644 --- 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 @@ -9,6 +9,7 @@ import java.io.IOException; import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.util.List; import java.util.concurrent.CountDownLatch; @@ -72,6 +73,7 @@ private static Thread buildBlocked(String id, CountDownLatch entered, CountDownL @AfterEach void reopenStartup() throws ReflectiveOperationException { + ConfigManager.setAwaitingLateWindow(config -> false); Field initialized = ConfigManager.class.getDeclaredField("initialized"); initialized.setAccessible(true); initialized.setBoolean(null, false); @@ -127,6 +129,7 @@ void otherConfigsStayOutOfTheWindowAndKeepSavedValues() throws IOException { @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, () -> { @@ -135,6 +138,40 @@ void constructorSetupWaitsForLateInitialization() { 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(); @@ -174,12 +211,55 @@ void configsOtherThreadsAreBuildingStayOutOfTheWindow() throws InterruptedExcept 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 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.isPendingInitialization(library)); + assertTrue(ConfigManager.createdConfigs().contains(library)); assertNull(library.getTree()); ConfigManager.initializeLate(config -> false, () -> ConfigManager.claimForLateInitialization(library)); 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 index dad0ef545..2a2fb71fb 100644 --- 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 @@ -6,8 +6,10 @@ 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.concurrent.ThreadPoolExecutor import java.util.concurrent.TimeUnit import java.util.function.Consumer import java.util.function.Function @@ -145,7 +147,7 @@ object HotwireBridge { ids.filter { disabled.callHidden("isDisabledSinceStart", it) == true } }.getOrDefault(emptyList()) - /** Whether Hotwire is running a switch, add or update right now */ + /** 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() && runCatching { @@ -153,6 +155,10 @@ object HotwireBridge { ids.any { installed.callMethod("isAddedThisSession", it) != true } }.getOrDefault(true) + fun isUpdating(): Boolean = runCatching { + (engine()!!.field("tasks")!!.field("workers") as ThreadPoolExecutor).activeCount > 0 + }.getOrDefault(false) + fun awaitIdle() { val start = System.nanoTime() var sawWorking = false @@ -548,9 +554,9 @@ object HotwireBridge { nextLaunchRefusal(engine, jars)?.let { return JarPlan(update.name, detail, listOf(it), emptyList()) } val action = JarAction(translated("oneconfig.page.hotwire.update.next_start", "Update at next launch"), true) { CompletableFuture.supplyAsync { + val needs = jars.dropLast(1).map { jar -> { nameClash(engine, jar, readJar(jar).modId(), null) ?: queueAdd(engine, jar) } } nextLaunchRefusal(engine, jars)?.let { Result(false, it) } - ?: jars.dropLast(1).firstNotNullOfOrNull { jar -> queueAdd(engine, jar).takeUnless { it.ok } } - ?: queueSelfUpdate(engine, update, jars.last()).takeUnless { it.ok } + ?: queueTogether(engine.field("updateQueue")!!, needs + { queueSelfUpdate(engine, update, jars.last()) }) ?: Result(true, translated("oneconfig.page.hotwire.update.queued", "%s updates at the next launch.", update.name)) } } @@ -562,6 +568,13 @@ object HotwireBridge { ) } + fun queueTogether(queue: Any, steps: List<() -> Result>): Result? { + val before = queue.callMethod("getEntries")!! + val failed = runCatching { steps.firstNotNullOfOrNull { step -> step().takeUnless { it.ok } } }.getOrElse(::failure) ?: return null + queue.callHidden("replace", before) + return failed + } + @Suppress("UNCHECKED_CAST") private fun nextLaunchRefusal(engine: Any, jars: List): String? = runCatching { val incoming = jars.map(::readJar) @@ -570,22 +583,30 @@ object HotwireBridge { runCatching { readJar(Path.of(entry.get("stagedJar") as String)) }.getOrNull()?.let { entry.get("modId") as String to it } } val installed = engine.field("installed")!! - val leaving = ids + queued.flatMap { (id, jar) -> listOf(id, jar.modId()) } + (installed.field("disabled") as Map).keys - val staying = (installed.callHidden("getGraphJars") as List).filterNot { it.modId() in leaving } + val replaced = ids + queued.flatMap { (id, jar) -> listOf(id, jar.modId()) } + val leaving = replaced + (installed.field("disabled") as Map).keys + val graphJars = installed.callHidden("getGraphJars") as List + val staying = graphJars.filterNot { it.modId() in leaving } // the jars that land go first, since the graph keeps the first copy of a mod it sees - val graph = Class.forName("dev.moon.hotwire.plan.ModGraph").getConstructor(Collection::class.java) - .newInstance(incoming + queued.map { it.second } + staying) + val landing = incoming + queued.map { it.second } + staying + val graph = Class.forName("dev.moon.hotwire.plan.ModGraph").getConstructor(Collection::class.java).newInstance(landing) val depends = Class.forName("dev.moon.hotwire.jar.ModManifest\$Relation").enumConstants.first { (it as Enum<*>).name == "DEPENDS" } val oldDependency = Class.forName("dev.moon.hotwire.plan.SwitchPlan\$Refusal\$OldDependency").constructors.single() + val fits = { version: String, ranges: List -> engine.callStatic("ModPolicy", "satisfies", version, ranges) == true } val builtIns = incoming.flatMap { it.parts() }.flatMap { part -> oldBuiltIns( part.modId(), part.get("manifest")!!.callMethod("getRelation", depends) as Map>, { id -> (engine.callStatic("LoadedMods", "findVersion", id) as String?).takeIf { (graph.callMethod("find", id) as Optional<*>).isEmpty } }, - { version, ranges -> engine.callStatic("ModPolicy", "satisfies", version, ranges) == true }, + fits, ) }.map { oldDependency.newInstance(*it.toTypedArray()) } - refusedPlan(engine.callStatic("ModPolicy", "makePlannerFor", graph)!!, graph, ids, builtIns)?.let { describeRefusal(engine, it) } + val missing = Class.forName("dev.moon.hotwire.plan.SwitchPlan\$Refusal\$MissingDependency").constructors.single() + val lost = lostNeeds(graph, graphJars.filter { it.modId() in replaced }.flatMap { it.parts() }.map { it.modId() }) + .map { (mod, dependency) -> missing.newInstance(mod, dependency) } + val copies = landing.flatMap { it.parts() }.groupBy({ it.modId() }, { it.get("manifest")!!.get("version") as String }) + val nested = (incoming.flatMap { it.parts() }.map { it.modId() } - landing.map { it.modId() }.toSet()).associateWith(copies::getValue) + refusedPlan(engine.callStatic("ModPolicy", "makePlannerFor", graph)!!, graph, ids, builtIns + lost, nested, fits)?.let { describeRefusal(engine, it) } }.getOrElse { translated("oneconfig.page.hotwire.next_launch_unchecked", "Hotwire couldn't check this against your other mods, so nothing was changed.") } @@ -596,54 +617,101 @@ object HotwireBridge { } @Suppress("UNCHECKED_CAST") - fun refusedPlan(planner: Any, graph: Any, ids: List, builtIns: List = emptyList()): Any? { + fun lostNeeds(graph: Any, outgoing: Collection): List> = + outgoing.distinct().filter { (graph.callMethod("find", it) as Optional<*>).isEmpty }.flatMap { id -> + (graph.callMethod("getDependents", id) as Collection).filter { isNeeded(graph, it) }.map { it to id } + } + + @Suppress("UNCHECKED_CAST") + private fun isNeeded(graph: Any, id: String, seen: MutableSet = HashSet()): Boolean = + graph.callMethod("isInstalledOnItsOwn", id) == true || + seen.add(id) && (graph.callMethod("getDependents", id) as Collection).any { isNeeded(graph, it, seen) } + + @Suppress("UNCHECKED_CAST") + fun refusedPlan( + planner: Any, + graph: Any, + ids: List, + extra: List = emptyList(), + nested: Map> = emptyMap(), + fits: (String, List) -> Boolean = { _, _ -> true }, + ): Any? { val plan = planner.callMethod("planOn", ids, ids.toSet())!! val old = (ids + ids.flatMap { graph.callMethod("getDependents", it) as Collection }).distinct() .flatMap { planner.callMethod("findOldDependencies", it) as List } .filter { it.get("mod") in ids || it.get("dependency") in ids } - val refusals = plan.get("refusals") as List + old + builtIns + val oldNested = nested.flatMap { (id, versions) -> + (graph.callMethod("getDependents", id) as Collection) + .flatMap { planner.callMethod("findOldDependencies", it) as List } + .filter { refusal -> refusal.get("dependency") == id && versions.none { fits(it, (refusal.get("wanted") as String).split(" or ")) } } + } + val nestedRefusals = nested.filterValues { it.distinct().size == 1 }.keys.toList().takeIf { it.isNotEmpty() } + ?.let { only -> planner.callMethod("planOn", only, only.toSet())!!.get("refusals") as List } + .orEmpty().filter { + when (it.javaClass.simpleName) { + "Breaks" -> true + "MissingDependency" -> isNeeded(graph, it.get("mod") as String) + else -> false + } + } + val refusals = (plan.get("refusals") as List + old + oldNested + nestedRefusals + extra).distinct() if (refusals.isEmpty()) return null return plan.javaClass.constructors.single() .newInstance(plan.get("direction"), plan.get("order"), plan.get("why"), plan.get("enabledNested"), refusals) } - private fun queueSelfUpdate(engine: Any, update: StoreUpdate, jar: Path): Result { - val queued = queueAdd(engine, jar) - if (!queued.ok) return queued - val queue = engine.field("updateQueue")!! - return runCatching { - val running = engine.callMethod("findJarOf", update.modId) as Path - val hash = Class.forName("dev.moon.hotwire.jar.FileHashes").getMethod("findSha256", Path::class.java).invoke(null, running) as String - replaceQueued(queue, jar, update.installedVersion, running, hash) - Result(true, "") - }.getOrElse { - removeQueued(queue, jar) - queueFailed(jar) + private fun queueSelfUpdate(engine: Any, update: StoreUpdate, jar: Path): Result = runCatching { + engine.askAdder("AddFirstLook", "findRefusal", jar)?.let { return Result(false, it) } + val running = engine.callMethod("findJarOf", update.modId) as Path + nameClash(engine, jar, update.modId, running)?.let { return it } + val staging = Files.createDirectories(engine.field("updateFolders")!!.get("staging") as Path) + val staged = staging.resolve(jar.fileName.toString()) + Files.copy(jar, staged, StandardCopyOption.REPLACE_EXISTING) + queueReplacement( + engine.field("updateQueue")!!, + Class.forName("dev.moon.hotwire.update.UpdateQueue\$Entry"), + readJar(jar).get("manifest")!!, + update.installedVersion, + running, + sha256(running), + staged, + sha256(staged), + ) + Result(true, "") + }.getOrElse { queueFailed(jar) } + + private fun nameClash(engine: Any, jar: Path, modId: String, running: Path?): Result? { + val name = jar.fileName.toString() + val queued = (engine.field("updateQueue")!!.callMethod("getEntries") as List<*>) + .filter { it!!.get("modId") != modId }.map { it!!.get("stagedJar") as String } + val target = (engine.field("updateFolders")!!.get("mods") as Path).resolve(name) + return if (nameTaken(name, target, running, queued)) { + Result(false, translated("oneconfig.page.hotwire.update.name_taken", "A different jar named %s is already there, so nothing was changed.", name)) + } else { + null } } - private fun queuedEntry(queue: Any, jar: Path): Any = - (queue.callMethod("getEntries") as List<*>).first { Path.of(it!!.get("stagedJar") as String).fileName == jar.fileName }!! + fun nameTaken(name: String, target: Path, running: Path?, queued: List): Boolean = + queued.any { Path.of(it).fileName.toString() == name } || Files.exists(target) && (running == null || !Files.isSameFile(target, running)) - private fun removeQueued(queue: Any, jar: Path) { - queue.callMethod("remove", queuedEntry(queue, jar).get("modId")!!) - } + private fun sha256(file: Path): String = + Class.forName("dev.moon.hotwire.jar.FileHashes").getMethod("findSha256", Path::class.java).invoke(null, file) as String - fun replaceQueued(queue: Any, jar: Path, fromVersion: String, running: Path, runningSha256: String) { - val entry = queuedEntry(queue, jar) + fun queueReplacement(queue: Any, entryType: Class<*>, manifest: Any, fromVersion: String, running: Path, runningSha256: String, staged: Path, stagedSha256: String) { queue.callMethod( "add", - entry.javaClass.constructors.single().newInstance( - entry.get("modId"), - entry.get("name"), + entryType.constructors.single().newInstance( + manifest.get("id"), + manifest.get("name"), fromVersion, - entry.get("toVersion"), + manifest.get("version"), running.fileName.toString(), runningSha256, - entry.get("stagedJar"), - entry.get("stagedSha256"), - entry.get("reason"), - entry.get("queuedAtMillis"), + staged.toAbsolutePath().toString(), + stagedSha256, + "", + System.currentTimeMillis(), ), ) } diff --git a/modules/internal/src/test/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireSelfUpdateQueueTest.kt b/modules/internal/src/test/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireSelfUpdateQueueTest.kt index 4d3bd8436..45b5aea76 100644 --- a/modules/internal/src/test/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireSelfUpdateQueueTest.kt +++ b/modules/internal/src/test/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireSelfUpdateQueueTest.kt @@ -1,14 +1,26 @@ package org.polyfrost.oneconfig.internal.ui.api +import java.nio.file.Files import java.nio.file.Path +import java.util.Optional import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir class HotwireSelfUpdateQueueTest { - class Old(private val mod: String, private val dependency: String) { + class Old(private val mod: String, private val dependency: String, private val wanted: String = "") { fun mod() = mod fun dependency() = dependency + fun wanted() = wanted + } + + class Breaks + + class MissingDependency(private val mod: String = "lib") { + fun mod() = mod } class Plan(private val direction: String, private val order: List<*>, private val why: Map<*, *>, private val nested: List<*>, private val refusals: List) { @@ -19,14 +31,18 @@ class HotwireSelfUpdateQueueTest { fun refusals() = refusals } - class Planner(private val refused: List, private val old: Map>) { + class Planner(private val refused: List, private val old: Map>, private val nestedRefused: List = emptyList()) { @Suppress("UNUSED_PARAMETER") - fun planOn(ids: Collection, off: Set) = Plan("ON", emptyList(), emptyMap(), emptyList(), refused) + fun planOn(ids: Collection, off: Set) = + Plan("ON", emptyList(), emptyMap(), emptyList(), if ("hotwire" in ids) refused else nestedRefused) + fun findOldDependencies(id: String) = old[id].orEmpty() } - class Graph(private val dependents: Map>) { + class Graph(private val dependents: Map>, private val roots: Set = setOf("hotwire", "other"), private val present: Set = emptySet()) { fun getDependents(id: String) = dependents[id].orEmpty() + fun isInstalledOnItsOwn(id: String) = id in roots + fun find(id: String): Optional = Optional.ofNullable(id.takeIf { it in present }) } private fun refusals(planner: Planner, graph: Graph, builtIns: List = emptyList()) = @@ -70,6 +86,112 @@ class HotwireSelfUpdateQueueTest { assertEquals(listOf("old java"), refusals(Planner(emptyList(), emptyMap()), Graph(emptyMap()), listOf("old java"))) } + private fun nestedRefusals(planner: Planner, graph: Graph, vararg versions: String) = + (HotwireBridge.refusedPlan(planner, graph, listOf("hotwire"), emptyList(), mapOf("lib" to versions.toList())) { version, ranges -> version in ranges } as Plan?)?.refusals() + + @Test + fun aModNeedingAnotherVersionOfANestedModRefusesIt() { + val old = Old("other", "lib", "1") + assertEquals(listOf(old), nestedRefusals(Planner(emptyList(), mapOf("other" to listOf(old))), Graph(mapOf("lib" to setOf("other"))), "2")) + } + + @Test + fun aNestedModWithACopyThatFitsIsNotRefused() { + val old = Old("other", "lib", "1 or 3") + assertNull(nestedRefusals(Planner(emptyList(), mapOf("other" to listOf(old))), Graph(mapOf("lib" to setOf("other"))), "2", "3")) + } + + @Test + fun aDependentsOtherOldDependencyIsNotBlamedOnANestedMod() { + val old = Old("other", "unrelated", "1") + assertNull(nestedRefusals(Planner(emptyList(), mapOf("other" to listOf(old))), Graph(mapOf("lib" to setOf("other"))), "2")) + } + + @Test + fun aNestedModInAConflictRefusesIt() { + val breaks = Breaks() + assertEquals(listOf(breaks), nestedRefusals(Planner(emptyList(), emptyMap(), listOf(breaks)), Graph(emptyMap()), "2")) + } + + @Test + fun aConflictWithOneOfSeveralNestedVersionsIsNotRefused() { + assertNull(nestedRefusals(Planner(emptyList(), emptyMap(), listOf(Breaks())), Graph(emptyMap()), "2", "3")) + } + + @Test + fun aNestedModsOwnMissingNeedIsNotRefused() { + assertNull(nestedRefusals(Planner(emptyList(), emptyMap(), listOf(MissingDependency())), Graph(emptyMap()), "2")) + } + + @Test + fun aRequiredNestedModsMissingNeedRefusesIt() { + val missing = MissingDependency() + assertEquals(listOf(missing), nestedRefusals(Planner(emptyList(), emptyMap(), listOf(missing)), Graph(mapOf("lib" to setOf("hotwire"))), "2")) + } + + @Test + fun aMissingNeedOfANestedModOnlyAnOptionalModWantsIsNotRefused() { + assertNull(nestedRefusals(Planner(emptyList(), emptyMap(), listOf(MissingDependency())), Graph(mapOf("lib" to setOf("unused"))), "2")) + } + + @Test + fun aRemovedNestedModAStayingModNeedsIsReported() { + val graph = Graph(mapOf("lib" to setOf("other", "unused"), "kept" to setOf("other")), present = setOf("kept")) + assertEquals(listOf("other" to "lib"), HotwireBridge.lostNeeds(graph, listOf("lib", "kept", "lib"))) + } + + @Test + fun aFailedStepPutsTheQueueBack() { + val queue = RollbackQueue(listOf("earlier")) + val failed = HotwireBridge.Result(false, "no") + + val result = HotwireBridge.queueTogether(queue, listOf({ queue.current += "need"; HotwireBridge.Result(true, "") }, { failed })) + + assertEquals(failed, result) + assertEquals(listOf("earlier"), queue.current) + } + + @Test + fun aThrowingStepPutsTheQueueBack() { + val queue = RollbackQueue(emptyList()) + + val result = HotwireBridge.queueTogether(queue, listOf({ queue.current += "need"; error("broken") })) + + assertEquals("broken", result?.message) + assertEquals(emptyList(), queue.current) + } + + @Test + fun stepsThatAllLandStayQueued() { + val queue = RollbackQueue(emptyList()) + + assertNull(HotwireBridge.queueTogether(queue, listOf({ queue.current += "need"; HotwireBridge.Result(true, "") }))) + assertEquals(listOf("need"), queue.current) + } + + @Test + fun aJarNameMayOnlyReplaceTheRunningJar(@TempDir mods: Path) { + val running = Files.createFile(mods.resolve("hotwire-1.jar")) + val other = Files.createFile(mods.resolve("other.jar")) + + assertFalse(HotwireBridge.nameTaken("hotwire-1.jar", running, running, emptyList())) + assertFalse(HotwireBridge.nameTaken("hotwire-2.jar", mods.resolve("hotwire-2.jar"), running, emptyList())) + assertTrue(HotwireBridge.nameTaken("other.jar", other, running, emptyList())) + assertTrue(HotwireBridge.nameTaken("other.jar", other, null, emptyList())) + assertTrue(HotwireBridge.nameTaken("hotwire-2.jar", mods.resolve("hotwire-2.jar"), running, listOf("/staging/hotwire-2.jar"))) + } + + class RollbackQueue(start: List) { + var current = start + + fun getEntries() = current + + @Suppress("unused") + private fun replace(entries: List) { + current = entries + } + } + data class Entry( val modId: String, val name: String, @@ -81,34 +203,31 @@ class HotwireSelfUpdateQueueTest { val stagedSha256: String, val reason: String, val queuedAtMillis: Long, - ) { - fun modId() = modId - fun name() = name - fun toVersion() = toVersion - fun stagedJar() = stagedJar - fun stagedSha256() = stagedSha256 - fun reason() = reason - fun queuedAtMillis() = queuedAtMillis + ) + + class Manifest { + fun id() = "hotwire" + fun name() = "Hotwire" + fun version() = "2" } - class Queue(val entries: MutableList) { + class Queue { + val writes = mutableListOf() + fun add(entry: Entry) { - entries.removeIf { it.modId == entry.modId } - entries.add(entry) + writes.add(entry) } } @Test - fun aQueuedAddBecomesAReplacementOfTheRunningJar() { - val other = Entry("other", "Other", "", "2", "", "", "/staging/other.jar", "o", "", 1) - val added = Entry("hotwire", "Hotwire", "", "2", "", "", "/staging/hotwire-2.jar", "new", "", 2) - val queue = Queue(mutableListOf(other, added)) + fun aSelfUpdateIsQueuedAsAReplacementInOneWrite() { + val queue = Queue() - HotwireBridge.replaceQueued(queue, Path.of("/store/hotwire-2.jar"), "1", Path.of("/mods/hotwire-1.jar"), "old") + HotwireBridge.queueReplacement(queue, Entry::class.java, Manifest(), "1", Path.of("/mods/hotwire-1.jar"), "old", Path.of("/staging/hotwire-2.jar"), "new") assertEquals( - listOf(other, Entry("hotwire", "Hotwire", "1", "2", "hotwire-1.jar", "old", "/staging/hotwire-2.jar", "new", "", 2)), - queue.entries, + listOf(Entry("hotwire", "Hotwire", "1", "2", "hotwire-1.jar", "old", "/staging/hotwire-2.jar", "new", "", 0)), + queue.writes.map { it.copy(queuedAtMillis = 0) }, ) } } From e0a5c399fe46c59ce27a78a46608a9250d3ae78e Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Mon, 2 Nov 2026 01:07:05 -0500 Subject: [PATCH 22/63] fix even more stupid shit --- .../internal/ui/api/HotwireBridge.kt | 53 +++++++++++++------ .../ui/api/HotwireSelfUpdateQueueTest.kt | 28 ++++++---- 2 files changed, 56 insertions(+), 25 deletions(-) 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 index 2a2fb71fb..14cec4b90 100644 --- 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 @@ -432,13 +432,14 @@ object HotwireBridge { true, ) { accepted = true - val added = if (inOrder == null) { + val ordered = inOrder ?: engine()?.takeIf { "NEXT_START" in modes } + val added = if (ordered == null) { add.through("StoreAdd", "accept").asResults().thenApplyAsync { result -> - if (result.ok) lostJar(engine()!!, jars, modes) ?: result else result + if (result.ok) lostJar(engine()!!, jars) ?: result else result } } else { add.through("StoreAdd", "cancel") - CompletableFuture.supplyAsync { addInOrder(inOrder, jars, hit.title, reopen) } + CompletableFuture.supplyAsync { addInOrder(ordered, jars, hit.title, reopen) } } added.thenApplyAsync { result -> runCatching { installedCheck()?.get() } @@ -554,7 +555,7 @@ object HotwireBridge { nextLaunchRefusal(engine, jars)?.let { return JarPlan(update.name, detail, listOf(it), emptyList()) } val action = JarAction(translated("oneconfig.page.hotwire.update.next_start", "Update at next launch"), true) { CompletableFuture.supplyAsync { - val needs = jars.dropLast(1).map { jar -> { nameClash(engine, jar, readJar(jar).modId(), null) ?: queueAdd(engine, jar) } } + val needs = jars.dropLast(1).map { jar -> { queueAdd(engine, jar) } } nextLaunchRefusal(engine, jars)?.let { Result(false, it) } ?: queueTogether(engine.field("updateQueue")!!, needs + { queueSelfUpdate(engine, update, jars.last()) }) ?: Result(true, translated("oneconfig.page.hotwire.update.queued", "%s updates at the next launch.", update.name)) @@ -685,15 +686,32 @@ object HotwireBridge { val queued = (engine.field("updateQueue")!!.callMethod("getEntries") as List<*>) .filter { it!!.get("modId") != modId }.map { it!!.get("stagedJar") as String } val target = (engine.field("updateFolders")!!.get("mods") as Path).resolve(name) - return if (nameTaken(name, target, running, queued)) { + return if (nameTaken(jar, target, running, queued)) { Result(false, translated("oneconfig.page.hotwire.update.name_taken", "A different jar named %s is already there, so nothing was changed.", name)) } else { null } } - fun nameTaken(name: String, target: Path, running: Path?, queued: List): Boolean = - queued.any { Path.of(it).fileName.toString() == name } || Files.exists(target) && (running == null || !Files.isSameFile(target, running)) + fun nameTaken(jar: Path, target: Path, running: Path?, queued: List): Boolean { + val name = jar.fileName.toString() + return queued.any { Path.of(it).fileName.toString() == name } || + Files.exists(target) && name != running?.fileName?.toString() && Files.mismatch(target, jar) != -1L + } + + private fun updateClash(engine: Any, jar: Path): Result? { + val incoming = unwrapped(engine, jar) + val manifest = manifestOf(incoming) ?: return null + val id = manifest.get("id") as String + val replaced = if (engine.callMethod("findJarOf", id) != null) { + id + } else { + runCatching { + engine.callHidden("findParts")!!.field("updates")!!.field("updater")!!.field("idChanged")!!.callHidden("findReplacedBy", manifest) as String? + }.getOrNull() + } + return nameClash(engine, incoming, replaced ?: id, replaced?.let { engine.callMethod("findJarOf", it) as Path? }) + } private fun sha256(file: Path): String = Class.forName("dev.moon.hotwire.jar.FileHashes").getMethod("findSha256", Path::class.java).invoke(null, file) as String @@ -872,11 +890,8 @@ object HotwireBridge { private fun notKept(jar: Path) = Result(false, translated("oneconfig.page.hotwire.add.not_kept", "%s is added for now, but it won't stay after a restart - please check chat!", nameOfJar(jar))) - private fun lostJar(engine: Any, jars: List, modes: List): Result? { - val (queued, live) = jars.indices.partition { modes[it] == "NEXT_START" } - return queued.firstOrNull { !isQueued(engine, jars[it]) }?.let { queueFailed(jars[it]) } - ?: live.firstOrNull { at -> !onUiThread { keptForNextLaunch(engine, jars[at]) } }?.let { notKept(jars[it]) } - } + private fun lostJar(engine: Any, jars: List): Result? = + jars.firstOrNull { !onUiThread { keptForNextLaunch(engine, it) } }?.let(::notKept) private fun unwrapped(engine: Any, jar: Path): Path = runCatching { engine.callHidden("findParts")!!.field("updates")!!.field("updater")!!.callMethod("unwrap", jar) as Path @@ -894,6 +909,7 @@ object HotwireBridge { Result(false, translated("oneconfig.page.hotwire.add.queue_failed", "Hotwire couldn't queue %s - please check chat!", nameOfJar(jar))) private fun queueAdd(engine: Any, jar: Path): Result { + nameClash(engine, jar, readJar(jar).modId(), null)?.let { return it } onUiThread { engine.callMethod("add", jar, engine.enumArg("add", "NEXT_START")) } return if (isQueued(engine, jar)) Result(true, "") else queueFailed(jar) } @@ -961,7 +977,10 @@ object HotwireBridge { engine.callMethod("add", jar, engine.enumArg("add", mode)) null } - val later = JarAction(translated("oneconfig.page.hotwire.add.next_start", "Add at next launch"), false, add("NEXT_START")) + val addLater: () -> CompletableFuture? = { + CompletableFuture.supplyAsync { addInOrder(engine, listOf(jar), preview.get("name") as String, reopen = false, later = true) } + } + val later = JarAction(translated("oneconfig.page.hotwire.add.next_start", "Add at next launch"), false, addLater) val mode = (preview.get("mode") as Enum<*>).name if (mode != "REFUSED") { nextLaunchRefusal(engine, listOf(jar))?.let { return JarPlan(preview.get("name") as String, preview.get("version") as String? ?: "", listOf(it), emptyList()) } @@ -969,7 +988,7 @@ object HotwireBridge { val actions = when (mode) { "NOW" -> listOf(JarAction(translated("oneconfig.page.hotwire.add.now", "Add now"), true, add("NOW")), later) "SAVE_AND_REOPEN" -> listOf(JarAction(translated("oneconfig.page.hotwire.add.reopen", "Save and add"), true, add("SAVE_AND_REOPEN")), later) - "NEXT_START" -> listOf(JarAction(later.label, true, add("NEXT_START"))) + "NEXT_START" -> listOf(JarAction(later.label, true, addLater)) else -> emptyList() } return JarPlan(preview.get("name") as String, preview.get("version") as String? ?: "", preview.get("lines") as List, actions) @@ -978,8 +997,10 @@ object HotwireBridge { @Suppress("UNCHECKED_CAST") private fun updatePlan(engine: Any, jar: Path, preview: Any): JarPlan { fun update(method: String): () -> CompletableFuture? = { - engine.callMethod("update", jar, engine.enumArg("update", method)) - updateResult(engine, jar, preview.get("name") as String) + updateClash(engine, jar)?.let { CompletableFuture.completedFuture(it) } ?: run { + engine.callMethod("update", jar, engine.enumArg("update", method)) + updateResult(engine, jar, preview.get("name") as String) + } } return when (preview.javaClass.simpleName) { "Ready" -> { diff --git a/modules/internal/src/test/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireSelfUpdateQueueTest.kt b/modules/internal/src/test/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireSelfUpdateQueueTest.kt index 45b5aea76..a8d8af8c1 100644 --- a/modules/internal/src/test/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireSelfUpdateQueueTest.kt +++ b/modules/internal/src/test/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireSelfUpdateQueueTest.kt @@ -170,15 +170,25 @@ class HotwireSelfUpdateQueueTest { } @Test - fun aJarNameMayOnlyReplaceTheRunningJar(@TempDir mods: Path) { - val running = Files.createFile(mods.resolve("hotwire-1.jar")) - val other = Files.createFile(mods.resolve("other.jar")) - - assertFalse(HotwireBridge.nameTaken("hotwire-1.jar", running, running, emptyList())) - assertFalse(HotwireBridge.nameTaken("hotwire-2.jar", mods.resolve("hotwire-2.jar"), running, emptyList())) - assertTrue(HotwireBridge.nameTaken("other.jar", other, running, emptyList())) - assertTrue(HotwireBridge.nameTaken("other.jar", other, null, emptyList())) - assertTrue(HotwireBridge.nameTaken("hotwire-2.jar", mods.resolve("hotwire-2.jar"), running, listOf("/staging/hotwire-2.jar"))) + fun aJarNameMayOnlyReplaceTheRunningJar(@TempDir mods: Path, @TempDir staging: Path) { + val running = Files.writeString(mods.resolve("hotwire-1.jar"), "old") + val other = Files.writeString(mods.resolve("other.jar"), "other") + fun incoming(name: String, content: String = "new") = Files.writeString(staging.resolve(name), content) + + assertFalse(HotwireBridge.nameTaken(incoming("hotwire-1.jar"), running, running, emptyList())) + assertFalse(HotwireBridge.nameTaken(incoming("hotwire-1.jar"), running, Path.of("/hotwire/added/hotwire-1.jar"), emptyList())) + assertFalse(HotwireBridge.nameTaken(incoming("hotwire-2.jar"), mods.resolve("hotwire-2.jar"), running, emptyList())) + assertTrue(HotwireBridge.nameTaken(incoming("other.jar"), other, running, emptyList())) + assertTrue(HotwireBridge.nameTaken(incoming("other.jar"), other, null, emptyList())) + assertTrue(HotwireBridge.nameTaken(incoming("hotwire-2.jar"), mods.resolve("hotwire-2.jar"), running, listOf("/staging/hotwire-2.jar"))) + } + + @Test + fun theSameJarIsNotAClash(@TempDir mods: Path, @TempDir staging: Path) { + val present = Files.writeString(mods.resolve("mod.jar"), "bytes") + + assertFalse(HotwireBridge.nameTaken(present, present, null, emptyList())) + assertFalse(HotwireBridge.nameTaken(Files.writeString(staging.resolve("mod.jar"), "bytes"), present, null, emptyList())) } class RollbackQueue(start: List) { From 434f21d5888c9202dd2e087dd644e49b799e2f20 Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Mon, 2 Nov 2026 02:03:10 -0500 Subject: [PATCH 23/63] a --- .../internal/ui/api/HotwireBridge.kt | 46 ++++++----- .../ui/api/HotwireSelfUpdateQueueTest.kt | 78 ++++++++++++++++--- 2 files changed, 95 insertions(+), 29 deletions(-) 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 index 14cec4b90..1cb707d6e 100644 --- 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 @@ -514,11 +514,13 @@ object HotwireBridge { private fun addInOrder(engine: Any, jars: List, name: String, reopen: Boolean, later: Boolean = false): Result { nextLaunchRefusal(engine, jars)?.let { return Result(false, it) } var queueing = later - for (jar in jars) { - queueing = queueing || dryRunAdd(engine, jar).addMode() == "NEXT_START" - val result = if (queueing) queueAdd(engine, jar) else addNow(engine, jar, reopen) - if (!result.ok) return result + val steps = jars.map { jar -> + { + queueing = queueing || dryRunAdd(engine, jar).addMode() == "NEXT_START" + if (queueing) queueAdd(engine, jar) else addNow(engine, jar, reopen) + } } + queueTogether(engine.field("updateQueue")!!, jars.map { readJar(it).modId() }, steps)?.let { return it } return Result( true, if (queueing) { @@ -557,7 +559,7 @@ object HotwireBridge { CompletableFuture.supplyAsync { val needs = jars.dropLast(1).map { jar -> { queueAdd(engine, jar) } } nextLaunchRefusal(engine, jars)?.let { Result(false, it) } - ?: queueTogether(engine.field("updateQueue")!!, needs + { queueSelfUpdate(engine, update, jars.last()) }) + ?: queueTogether(engine.field("updateQueue")!!, jars.map { readJar(it).modId() }, needs + { queueSelfUpdate(engine, update, jars.last()) }) ?: Result(true, translated("oneconfig.page.hotwire.update.queued", "%s updates at the next launch.", update.name)) } } @@ -569,10 +571,13 @@ object HotwireBridge { ) } - fun queueTogether(queue: Any, steps: List<() -> Result>): Result? { - val before = queue.callMethod("getEntries")!! + @Suppress("UNCHECKED_CAST") + fun queueTogether(queue: Any, ids: List, steps: List<() -> Result>): Result? { + val before = (queue.callMethod("getEntries") as List).filter { it.get("modId") in ids } val failed = runCatching { steps.firstNotNullOfOrNull { step -> step().takeUnless { it.ok } } }.getOrElse(::failure) ?: return null - queue.callHidden("replace", before) + synchronized(queue) { + queue.callHidden("replace", (queue.callMethod("getEntries") as List).filterNot { it.get("modId") in ids } + before) + } return failed } @@ -580,7 +585,7 @@ object HotwireBridge { private fun nextLaunchRefusal(engine: Any, jars: List): String? = runCatching { val incoming = jars.map(::readJar) val ids = incoming.map { it.modId() } - val queued = (engine.field("updateQueue")!!.callMethod("getEntries") as List).mapNotNull { entry -> + val queued = (engine.field("updateQueue")!!.callMethod("getEntries") as List).filterNot { it.get("modId") in ids }.mapNotNull { entry -> runCatching { readJar(Path.of(entry.get("stagedJar") as String)) }.getOrNull()?.let { entry.get("modId") as String to it } } val installed = engine.field("installed")!! @@ -594,6 +599,10 @@ object HotwireBridge { val depends = Class.forName("dev.moon.hotwire.jar.ModManifest\$Relation").enumConstants.first { (it as Enum<*>).name == "DEPENDS" } val oldDependency = Class.forName("dev.moon.hotwire.plan.SwitchPlan\$Refusal\$OldDependency").constructors.single() val fits = { version: String, ranges: List -> engine.callStatic("ModPolicy", "satisfies", version, ranges) == true } + val wants = { mod: String, dependency: String -> + ((graph.callMethod("find", mod) as Optional<*>).orElseThrow().callMethod("getRelation", depends) as Map>) + .filterKeys { graph.callMethod("resolve", it) == dependency }.values.toList() + } val builtIns = incoming.flatMap { it.parts() }.flatMap { part -> oldBuiltIns( part.modId(), @@ -607,7 +616,7 @@ object HotwireBridge { .map { (mod, dependency) -> missing.newInstance(mod, dependency) } val copies = landing.flatMap { it.parts() }.groupBy({ it.modId() }, { it.get("manifest")!!.get("version") as String }) val nested = (incoming.flatMap { it.parts() }.map { it.modId() } - landing.map { it.modId() }.toSet()).associateWith(copies::getValue) - refusedPlan(engine.callStatic("ModPolicy", "makePlannerFor", graph)!!, graph, ids, builtIns + lost, nested, fits)?.let { describeRefusal(engine, it) } + refusedPlan(engine.callStatic("ModPolicy", "makePlannerFor", graph)!!, graph, ids, builtIns + lost, nested, fits, wants)?.let { describeRefusal(engine, it) } }.getOrElse { translated("oneconfig.page.hotwire.next_launch_unchecked", "Hotwire couldn't check this against your other mods, so nothing was changed.") } @@ -636,15 +645,17 @@ object HotwireBridge { extra: List = emptyList(), nested: Map> = emptyMap(), fits: (String, List) -> Boolean = { _, _ -> true }, + wants: (String, String) -> List> = { _, _ -> emptyList() }, ): Any? { val plan = planner.callMethod("planOn", ids, ids.toSet())!! val old = (ids + ids.flatMap { graph.callMethod("getDependents", it) as Collection }).distinct() .flatMap { planner.callMethod("findOldDependencies", it) as List } .filter { it.get("mod") in ids || it.get("dependency") in ids } val oldNested = nested.flatMap { (id, versions) -> - (graph.callMethod("getDependents", id) as Collection) - .flatMap { planner.callMethod("findOldDependencies", it) as List } - .filter { refusal -> refusal.get("dependency") == id && versions.none { fits(it, (refusal.get("wanted") as String).split(" or ")) } } + val dependents = (graph.callMethod("getDependents", id) as Collection).filter { isNeeded(graph, it) } + val needs = dependents.flatMap { wants(it, id) } + if (versions.any { version -> needs.all { fits(version, it) } }) return@flatMap emptyList() + dependents.flatMap { planner.callMethod("findOldDependencies", it) as List }.filter { it.get("dependency") == id } } val nestedRefusals = nested.filterValues { it.distinct().size == 1 }.keys.toList().takeIf { it.isNotEmpty() } ?.let { only -> planner.callMethod("planOn", only, only.toSet())!!.get("refusals") as List } @@ -827,11 +838,10 @@ object HotwireBridge { /** Lands [steps] in order and then updates [jar], stopping at the first that fails, since each one's plan rests on those before */ private fun runSteps(engine: Any, steps: List, jar: Path, reopen: Boolean): Result { - for (step in steps) { - val result = if (engine.callMethod("canUpdate", step) == true) updateNow(step) else addNow(engine, step, reopen) - if (!result.ok) return result - } - return updateNow(jar) + val adds = steps.filter { engine.callMethod("canUpdate", it) != true } + val landing = steps.map { step -> { if (step in adds) addNow(engine, step, reopen) else updateNow(step) } } + lateinit var updated: Result + return queueTogether(engine.field("updateQueue")!!, adds.map { readJar(it).modId() }, landing + { updateNow(jar).also { updated = it } }) ?: updated } @Suppress("UNCHECKED_CAST") diff --git a/modules/internal/src/test/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireSelfUpdateQueueTest.kt b/modules/internal/src/test/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireSelfUpdateQueueTest.kt index a8d8af8c1..24ef8b78f 100644 --- a/modules/internal/src/test/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireSelfUpdateQueueTest.kt +++ b/modules/internal/src/test/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireSelfUpdateQueueTest.kt @@ -86,8 +86,19 @@ class HotwireSelfUpdateQueueTest { assertEquals(listOf("old java"), refusals(Planner(emptyList(), emptyMap()), Graph(emptyMap()), listOf("old java"))) } - private fun nestedRefusals(planner: Planner, graph: Graph, vararg versions: String) = - (HotwireBridge.refusedPlan(planner, graph, listOf("hotwire"), emptyList(), mapOf("lib" to versions.toList())) { version, ranges -> version in ranges } as Plan?)?.refusals() + private fun nestedRefusals(planner: Planner, graph: Graph, vararg versions: String, wants: Map>? = null) = + (HotwireBridge.refusedPlan( + planner, + graph, + listOf("hotwire"), + emptyList(), + mapOf("lib" to versions.toList()), + { version, ranges -> version in ranges }, + { mod, dependency -> + wants?.get(mod)?.let(::listOf) + ?: planner.findOldDependencies(mod).filterIsInstance().filter { it.dependency() == dependency }.map { it.wanted().split(" or ") } + }, + ) as Plan?)?.refusals() @Test fun aModNeedingAnotherVersionOfANestedModRefusesIt() { @@ -107,6 +118,27 @@ class HotwireSelfUpdateQueueTest { assertNull(nestedRefusals(Planner(emptyList(), mapOf("other" to listOf(old))), Graph(mapOf("lib" to setOf("other"))), "2")) } + @Test + fun nestedCopiesThatEachSuitOnlyOneDependentRefuseIt() { + val old = Old("b", "lib", "2") + val graph = Graph(mapOf("lib" to setOf("a", "b")), roots = setOf("a", "b")) + assertEquals(listOf(old), nestedRefusals(Planner(emptyList(), mapOf("b" to listOf(old))), graph, "1", "2", wants = mapOf("a" to listOf("1"), "b" to listOf("2")))) + } + + @Test + fun aNestedCopySuitingEveryDependentIsNotRefused() { + val old = Old("b", "lib", "2") + val graph = Graph(mapOf("lib" to setOf("a", "b")), roots = setOf("a", "b")) + assertNull(nestedRefusals(Planner(emptyList(), mapOf("b" to listOf(old))), graph, "1", "2", wants = mapOf("a" to listOf("1", "2"), "b" to listOf("2")))) + } + + @Test + fun anOptionalDependentDoesNotNarrowTheNestedCopies() { + val old = Old("b", "lib", "2") + val graph = Graph(mapOf("lib" to setOf("unused", "b")), roots = setOf("b")) + assertNull(nestedRefusals(Planner(emptyList(), mapOf("b" to listOf(old))), graph, "1", "2", wants = mapOf("unused" to listOf("1"), "b" to listOf("2")))) + } + @Test fun aNestedModInAConflictRefusesIt() { val breaks = Breaks() @@ -140,33 +172,53 @@ class HotwireSelfUpdateQueueTest { assertEquals(listOf("other" to "lib"), HotwireBridge.lostNeeds(graph, listOf("lib", "kept", "lib"))) } + private val ok = HotwireBridge.Result(true, "") + @Test fun aFailedStepPutsTheQueueBack() { - val queue = RollbackQueue(listOf("earlier")) + val queue = RollbackQueue(listOf(Queued("earlier"))) val failed = HotwireBridge.Result(false, "no") - val result = HotwireBridge.queueTogether(queue, listOf({ queue.current += "need"; HotwireBridge.Result(true, "") }, { failed })) + val result = HotwireBridge.queueTogether(queue, listOf("need"), listOf({ queue.current += Queued("need"); ok }, { failed })) assertEquals(failed, result) - assertEquals(listOf("earlier"), queue.current) + assertEquals(listOf(Queued("earlier")), queue.current) } @Test fun aThrowingStepPutsTheQueueBack() { val queue = RollbackQueue(emptyList()) - val result = HotwireBridge.queueTogether(queue, listOf({ queue.current += "need"; error("broken") })) + val result = HotwireBridge.queueTogether(queue, listOf("need"), listOf({ queue.current += Queued("need"); error("broken") })) assertEquals("broken", result?.message) - assertEquals(emptyList(), queue.current) + assertEquals(emptyList(), queue.current) } @Test fun stepsThatAllLandStayQueued() { val queue = RollbackQueue(emptyList()) - assertNull(HotwireBridge.queueTogether(queue, listOf({ queue.current += "need"; HotwireBridge.Result(true, "") }))) - assertEquals(listOf("need"), queue.current) + assertNull(HotwireBridge.queueTogether(queue, listOf("need"), listOf({ queue.current += Queued("need"); ok }))) + assertEquals(listOf(Queued("need")), queue.current) + } + + @Test + fun aFailureKeepsWhatAnotherOperationQueuedMeanwhile() { + val queue = RollbackQueue(emptyList()) + + HotwireBridge.queueTogether(queue, listOf("need"), listOf({ queue.current += Queued("need"); queue.current += Queued("other"); HotwireBridge.Result(false, "no") })) + + assertEquals(listOf(Queued("other")), queue.current) + } + + @Test + fun aFailureRestoresTheEntryItReplaced() { + val queue = RollbackQueue(listOf(Queued("need", "old"))) + + HotwireBridge.queueTogether(queue, listOf("need"), listOf({ queue.current = listOf(Queued("need", "new")); HotwireBridge.Result(false, "no") })) + + assertEquals(listOf(Queued("need", "old")), queue.current) } @Test @@ -191,13 +243,17 @@ class HotwireSelfUpdateQueueTest { assertFalse(HotwireBridge.nameTaken(Files.writeString(staging.resolve("mod.jar"), "bytes"), present, null, emptyList())) } - class RollbackQueue(start: List) { + data class Queued(private val id: String, val jar: String = "") { + fun modId() = id + } + + class RollbackQueue(start: List) { var current = start fun getEntries() = current @Suppress("unused") - private fun replace(entries: List) { + private fun replace(entries: List) { current = entries } } From d5d1908be004c4c056fc1daff8b901dd88660c7e Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Mon, 2 Nov 2026 02:24:49 -0500 Subject: [PATCH 24/63] gadg --- .../internal/compat/ConfigEntrypoints.java | 27 +++++++++++++ .../compat/ConfigEntrypointsTest.java | 13 +++++++ .../internal/ui/api/HotwireBridge.kt | 7 +++- .../ui/api/HotwireSelfUpdateQueueTest.kt | 39 +++++++++++++++++++ 4 files changed, 85 insertions(+), 1 deletion(-) 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 af95d6a1b..27f527266 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 @@ -45,11 +45,14 @@ 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; @@ -76,6 +79,8 @@ 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<>(); + // failed mods running their entrypoints again after an update + private static final Set retrying = 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(); @@ -367,6 +372,7 @@ static int constructorDepth(Class type, StackTraceElement[] frames) { } private static void decide(String modId, boolean broken) { + boolean retried = retrying.remove(modId); List owned = ownedTrees(modId); // an entrypoint that threw only keeps the configs it registered itself, not ones that existed before it ran if (broken) owned.removeIf(tree -> earlierConfigs.contains(tree.getID())); @@ -379,6 +385,7 @@ private static void decide(String modId, boolean broken) { } // CompatLoader only marked the providers it saw when it initialized CompatLoader.INSTANCE.getNativeLoadedConfigs().add(modId); + if (retried) dropExtracted(modId); LOGGER.info("Using the OneConfig configs registered by {}", modId); return; } @@ -386,6 +393,14 @@ private static void decide(String modId, boolean broken) { fallBackToOriginal(modId); } + private static void dropExtracted(String modId) { + ConfigManager manager = ConfigManager.active(); + if (treeOwners.containsKey(modId) || !manager.exists(modId) || !Boolean.TRUE.equals(manager.get(modId).getMetadata(Backend.UI_ONLY_METADATA))) return; + manager.unregister(modId); + CompatSnapshots.untrack(modId); + ConfigRegistry.INSTANCE.unregister(modId); + } + private static void fallBackToOriginal(String modId) { // un-marked before failing so a hook firing in between still sees provides() and queues itself try { @@ -579,9 +594,13 @@ private static void registerChanged() { Set newIds = new HashSet<>(changedIds); newIds.removeAll(seenMods.keySet()); if (HotwireBridge.INSTANCE.isStarting(newIds) || changedIds.size() > newIds.size() && HotwireBridge.INSTANCE.isUpdating()) return; + Map seen = seenMods; seenMods = mods; HotwireBridge.INSTANCE.invalidate(); rediscover(); + Set retried = retriedIds(providerIds(), failed, changedIds, seen.keySet()); + failed.removeAll(retried); + retrying.addAll(retried); Map> added = new LinkedHashMap<>(); for (Provider provider : providers()) { if (!isDecided(provider.modId)) added.computeIfAbsent(provider.modId, k -> new ArrayList<>()).add(provider); @@ -635,6 +654,14 @@ static Set changedIds(Map mods, Map 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()) { 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 db74eaa8f..d902607ac 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 @@ -188,6 +188,19 @@ void addedAndReplacedModsAreChangedIds() { ); } + @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") + ) + ); + } + @Test void aContainerReplacedUnderTheSameIdIsAChange() { assertTrue(ConfigEntrypoints.modsChanged(List.of(mod("lib")), Map.of("lib", mod("lib")))); 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 index 1cb707d6e..8feb3a559 100644 --- 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 @@ -575,6 +575,7 @@ object HotwireBridge { fun queueTogether(queue: Any, ids: List, steps: List<() -> Result>): Result? { val before = (queue.callMethod("getEntries") as List).filter { it.get("modId") in ids } val failed = runCatching { steps.firstNotNullOfOrNull { step -> step().takeUnless { it.ok } } }.getOrElse(::failure) ?: return null + staged.forEach { (jar, bytes) -> runCatching { Files.write(jar, bytes) } } synchronized(queue) { queue.callHidden("replace", (queue.callMethod("getEntries") as List).filterNot { it.get("modId") in ids } + before) } @@ -612,7 +613,7 @@ object HotwireBridge { ) }.map { oldDependency.newInstance(*it.toTypedArray()) } val missing = Class.forName("dev.moon.hotwire.plan.SwitchPlan\$Refusal\$MissingDependency").constructors.single() - val lost = lostNeeds(graph, graphJars.filter { it.modId() in replaced }.flatMap { it.parts() }.map { it.modId() }) + val lost = lostNeeds(graph, graphJars.filter { it.modId() in replaced }.flatMap(::namesIn)) .map { (mod, dependency) -> missing.newInstance(mod, dependency) } val copies = landing.flatMap { it.parts() }.groupBy({ it.modId() }, { it.get("manifest")!!.get("version") as String }) val nested = (incoming.flatMap { it.parts() }.map { it.modId() } - landing.map { it.modId() }.toSet()).associateWith(copies::getValue) @@ -626,6 +627,10 @@ object HotwireBridge { present(id)?.takeUnless { fits(it, ranges) }?.let { listOf(mod, id, ranges.joinToString(" or "), it) } } + @Suppress("UNCHECKED_CAST") + fun namesIn(jar: Any): List = + jar.parts().flatMap { (it.get("manifest")!!.get("provides") as List) + it.modId() } + @Suppress("UNCHECKED_CAST") fun lostNeeds(graph: Any, outgoing: Collection): List> = outgoing.distinct().filter { (graph.callMethod("find", it) as Optional<*>).isEmpty }.flatMap { id -> diff --git a/modules/internal/src/test/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireSelfUpdateQueueTest.kt b/modules/internal/src/test/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireSelfUpdateQueueTest.kt index 24ef8b78f..6e88a1fcc 100644 --- a/modules/internal/src/test/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireSelfUpdateQueueTest.kt +++ b/modules/internal/src/test/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireSelfUpdateQueueTest.kt @@ -172,6 +172,24 @@ class HotwireSelfUpdateQueueTest { assertEquals(listOf("other" to "lib"), HotwireBridge.lostNeeds(graph, listOf("lib", "kept", "lib"))) } + class JarManifest(private val id: String, private val provides: List) { + fun id() = id + fun provides() = provides + } + + class Jar(private val manifest: JarManifest, private val nested: List = emptyList()) { + fun manifest() = manifest + fun nested() = nested + } + + @Test + fun anAliasNoLandingModProvidesIsLost() { + val old = Jar(JarManifest("mod", listOf("mod-api")), listOf(Jar(JarManifest("inner", listOf("inner-api"))))) + val graph = Graph(mapOf("mod-api" to setOf("other"), "inner-api" to setOf("other")), present = setOf("mod", "inner", "inner-api")) + + assertEquals(listOf("other" to "mod-api"), HotwireBridge.lostNeeds(graph, HotwireBridge.namesIn(old))) + } + private val ok = HotwireBridge.Result(true, "") @Test @@ -221,6 +239,26 @@ class HotwireSelfUpdateQueueTest { assertEquals(listOf(Queued("need", "old")), queue.current) } + @Test + fun aFailureRestoresTheStagedJarItOverwrote(@TempDir staging: Path) { + val jar = Files.writeString(staging.resolve("need.jar"), "old") + val queue = RollbackQueue(listOf(Queued("need", jar.toString()))) + + HotwireBridge.queueTogether(queue, listOf("need"), listOf({ Files.writeString(jar, "new"); HotwireBridge.Result(false, "no") })) + + assertEquals("old", Files.readString(jar)) + } + + @Test + fun stepsThatAllLandKeepTheNewStagedJar(@TempDir staging: Path) { + val jar = Files.writeString(staging.resolve("need.jar"), "old") + val queue = RollbackQueue(listOf(Queued("need", jar.toString()))) + + HotwireBridge.queueTogether(queue, listOf("need"), listOf({ Files.writeString(jar, "new"); ok })) + + assertEquals("new", Files.readString(jar)) + } + @Test fun aJarNameMayOnlyReplaceTheRunningJar(@TempDir mods: Path, @TempDir staging: Path) { val running = Files.writeString(mods.resolve("hotwire-1.jar"), "old") @@ -245,6 +283,7 @@ class HotwireSelfUpdateQueueTest { data class Queued(private val id: String, val jar: String = "") { fun modId() = id + fun stagedJar() = jar } class RollbackQueue(start: List) { From 5b35417a40e997d8c42e673991578f58c5fbba79 Mon Sep 17 00:00:00 2001 From: Tektonikal Date: Mon, 2 Nov 2026 12:36:38 -0500 Subject: [PATCH 25/63] fix build issues and add 5 mod limit --- buildSrc/src/main/kotlin/oneconfig-setup.gradle.kts | 5 +++++ .../src/main/resources/assets/oneconfig/lang/en_us.json | 1 + .../polyfrost/oneconfig/internal/ui/api/HotwireBridge.kt | 2 ++ .../org/polyfrost/oneconfig/internal/ui/screens/Hotwire.kt | 6 +++++- 4 files changed, 13 insertions(+), 1 deletion(-) 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/resources/assets/oneconfig/lang/en_us.json b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json index 8c175d529..bccf2e23d 100644 --- a/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json +++ b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json @@ -292,6 +292,7 @@ "oneconfig.page.hotwire.switch.after_off": "Turns on %s once the others are off.", "oneconfig.page.hotwire.switch.reopen": "Save and switch", "oneconfig.page.hotwire.switch.reopen_unsupported": "Update Hotwire to turn %s back on from here.", + "oneconfig.page.hotwire.switch.too_many": "Only %s mods can be switched at once.", "oneconfig.page.hotwire.update.adds": "Adds %s, which this update needs.", "oneconfig.page.hotwire.update.same": "This version is already installed.", "oneconfig.page.hotwire.update.unavailable": "Hotwire can't update mods in this game session.", 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 index 8feb3a559..637a0dcb3 100644 --- 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 @@ -574,6 +574,8 @@ object HotwireBridge { @Suppress("UNCHECKED_CAST") fun queueTogether(queue: Any, ids: List, steps: List<() -> Result>): Result? { val before = (queue.callMethod("getEntries") as List).filter { it.get("modId") in ids } + // a step may overwrite a staged jar in place, so keep its bytes to put back + val staged = before.mapNotNull { entry -> runCatching { Path.of(entry.get("stagedJar") as String).let { it to Files.readAllBytes(it) } }.getOrNull() } val failed = runCatching { steps.firstNotNullOfOrNull { step -> step().takeUnless { it.ok } } }.getOrElse(::failure) ?: return null staged.forEach { (jar, bytes) -> runCatching { Files.write(jar, bytes) } } synchronized(queue) { 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 index 81e0f5b60..6edf44044 100644 --- 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 @@ -417,6 +417,7 @@ private fun SwitchBar( 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 { reopens == true -> translated("oneconfig.page.hotwire.switch.reopen", "Save and switch") offCount > 0 && onCount > 0 -> "Switch ${offCount + onCount}" @@ -427,9 +428,10 @@ private fun SwitchBar( states == null -> "Hotwire is still starting, so mods can't be switched yet." updating != null -> translated("oneconfig.page.hotwire.updates.running", "Updating %s…", updating) running -> "Switching… ${(fraction * 100).toInt()}%" + tooMany -> translated("oneconfig.page.hotwire.switch.too_many", "Only %s mods can be switched at once.", MaxSwitchedMods) else -> previews.joinToString(" ") { it.message } } - val canSwitch = !running && reopens != null && previews.isNotEmpty() && previews.all { it.possible } && !HotwireBridge.isSwitching + val canSwitch = !running && !tooMany && reopens != null && previews.isNotEmpty() && previews.all { it.possible } && !HotwireBridge.isSwitching fun start() { switching = true @@ -480,6 +482,8 @@ private fun SwitchBar( } } +private const val MaxSwitchedMods = 5 + private fun done(message: String) = CompletableFuture.completedFuture(HotwireBridge.Result(true, message)) @Composable From b959e1cb96756c1ea565c49a13f0d6d6d21c303a Mon Sep 17 00:00:00 2001 From: Tektonikal Date: Mon, 2 Nov 2026 23:06:18 -0500 Subject: [PATCH 26/63] update to use latest hotwire API --- .../assets/oneconfig/lang/en_us.json | 30 +- .../internal/ui/api/HotwireBridge.kt | 982 +++--------------- .../oneconfig/internal/ui/screens/Hotwire.kt | 41 +- .../internal/ui/screens/HotwireAdd.kt | 30 +- .../ui/api/HotwireSelfUpdateQueueTest.kt | 338 ------ 5 files changed, 161 insertions(+), 1260 deletions(-) delete mode 100644 modules/internal/src/test/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireSelfUpdateQueueTest.kt 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 bccf2e23d..7b53e4b61 100644 --- a/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json +++ b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json @@ -262,15 +262,10 @@ "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.failed": "Hotwire couldn't add %s - please check chat!", - "oneconfig.page.hotwire.add.adds": "Adds %s, which this mod needs.", - "oneconfig.page.hotwire.add.done": "Added %s.", - "oneconfig.page.hotwire.add.queued": "%s is added at the next launch.", - "oneconfig.page.hotwire.add.queue_failed": "Hotwire couldn't queue %s - please check chat!", - "oneconfig.page.hotwire.update.name_taken": "A different jar named %s is already there, so nothing was changed.", - "oneconfig.page.hotwire.add.not_kept": "%s is added for now, but it won't stay after a restart - please check chat!", - "oneconfig.page.hotwire.reopen_confirm": "%s has to save and reopen your world. Try again to confirm.", "oneconfig.page.hotwire.add.reading": "Reading the jar…", + "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", @@ -278,30 +273,11 @@ "oneconfig.page.hotwire.add.sent": "Sent to Hotwire. It posts the result in chat.", "oneconfig.page.hotwire.update.now": "Update now", "oneconfig.page.hotwire.update.next_start": "Update at next launch", - "oneconfig.page.hotwire.update.with_needs": "Update with dependencies", - "oneconfig.page.hotwire.update.updates": "Updates %s, which this update needs.", - "oneconfig.page.hotwire.update.done": "%s is updated.", - "oneconfig.page.hotwire.update.queued": "%s updates at the next launch.", - "oneconfig.page.hotwire.update.failed": "Hotwire couldn't update %s - please check chat!", - "oneconfig.page.hotwire.update.not_kept": "%s is updated for now, but the update won't stay after a restart - please check chat!", - "oneconfig.page.hotwire.update.waits": "This update needs %s, which only arrives at the next launch. Update this after restarting.", "oneconfig.page.hotwire.switch.adds_back": "Adds back %s.", - "oneconfig.page.hotwire.switch.add_back_failed": "Hotwire couldn't add back %s - please check chat!", - "oneconfig.page.hotwire.switch.rename_failed": "%s is back on, but it will be off at the next launch: %s", "oneconfig.page.hotwire.switch.conflict":"Some of these mods need others you're turning off. Switch them separately.", - "oneconfig.page.hotwire.switch.after_off": "Turns on %s once the others are off.", - "oneconfig.page.hotwire.switch.reopen": "Save and switch", - "oneconfig.page.hotwire.switch.reopen_unsupported": "Update Hotwire to turn %s back on from here.", "oneconfig.page.hotwire.switch.too_many": "Only %s mods can be switched at once.", - "oneconfig.page.hotwire.update.adds": "Adds %s, which this update needs.", - "oneconfig.page.hotwire.update.same": "This version is already installed.", - "oneconfig.page.hotwire.update.unavailable": "Hotwire can't update mods in this game session.", - "oneconfig.page.hotwire.update.restart_only": "Hotwire can't swap this mod while the game runs, so it updates at the next launch.", "oneconfig.page.hotwire.update.self": "Hotwire can't update itself while the game runs, so it updates at the next launch.", - "oneconfig.page.hotwire.next_launch_unchecked": "Hotwire couldn't check this against your other mods, so nothing was changed.", - "oneconfig.page.hotwire.unreadable": "Hotwire can't read this jar.", "oneconfig.page.hotwire.upgrade.page": "This version of Hotwire is too old for this page. Update Hotwire to use it.", - "oneconfig.page.hotwire.upgrade.store": "This version of Hotwire can't browse Modrinth here. Update Hotwire to use it.", "oneconfig.page.themes.title": "Themes", "oneconfig.page.themes.search": "Search Themes...", "oneconfig.page.preferences.title": "Preferences", 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 index 637a0dcb3..69c678f4b 100644 --- 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 @@ -6,13 +6,9 @@ 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.concurrent.ThreadPoolExecutor -import java.util.concurrent.TimeUnit import java.util.function.Consumer -import java.util.function.Function import org.polyfrost.oneconfig.api.platform.v1.ModInfo import org.polyfrost.oneconfig.api.platform.v1.Platform import org.polyfrost.oneconfig.internal.ui.components.translated @@ -25,7 +21,8 @@ import org.polyfrost.oneconfig.internal.ui.components.translated object HotwireBridge { class ModState(val id: String, val name: String, val version: String, val on: Boolean, val switchable: Boolean) - class Preview(val possible: Boolean, val message: String) + /** [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 */ class Result(val ok: Boolean, val message: String, val needsReopen: Boolean = false) @@ -62,14 +59,11 @@ object HotwireBridge { private fun supports(call: String) = apiClass?.methods?.any { it.name == call } == true - /** Whether this Hotwire can hand over the warning the page must show before it switches anything */ - val supportsSwitching: Boolean by lazy { supports("findOpeningPrompt") } + /** Whether this Hotwire has the change plans every part of the page goes through */ + val supportsSwitching: Boolean by lazy { supports("planChange") } val supportsOriginalScreen: Boolean by lazy { supports("openScreen") } - /** Whether this Hotwire has every store call, which arrived over several revisions */ - val supportsStore: Boolean by lazy { (call("getRevision") as? Int ?: 0) >= STORE_REVISION } - /** 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 { @@ -83,66 +77,59 @@ object HotwireBridge { } }.getOrNull() - fun installedState(): List = mods().orEmpty().map { listOf(it.id, it.version, it.on) } + - runCatching { engine()!!.callMethod("getPendingChanges") as List<*> }.getOrDefault(emptyList()) + fun installedState(): List = mods().orEmpty().map { listOf(it.id, it.version, it.on) } + pendingTasks() + + private fun pendingTasks(): List<*> = call("getPendingTasks") as? List<*> ?: emptyList() 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) + private fun nameOf(id: String): String = mods().orEmpty().firstOrNull { it.id == id }?.name ?: id + + private fun addsBack(kept: List): String? = kept.takeIf { it.isNotEmpty() }?.let { mods -> + translated("oneconfig.page.hotwire.switch.adds_back", "Adds back %s.", mods.joinToString(", ", transform = ::nameOf)) + } + /** 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 kept = if (on) keptOff(ids) else emptyList() val preview = (ids - kept.toSet()).takeIf { it.isNotEmpty() }?.let { call(if (on) "previewOn" else "previewOff", it)!! } - val addsBack = kept.takeIf { it.isNotEmpty() }?.let { mods -> - translated("oneconfig.page.hotwire.switch.adds_back", "Adds back %s.", mods.joinToString(", ") { engine()!!.callMethod("nameOfMod", it) as String }) - } - Preview(preview?.get("possible") as Boolean? ?: true, listOfNotNull(preview?.get("message") as String?, addsBack).joinToString(" ")) + Preview( + preview?.get("possible") as Boolean? ?: true, + listOfNotNull(preview?.get("message") as String?, addsBack(kept)).joinToString(" "), + preview?.get("switched") as List? ?: emptyList(), + ) }.getOrElse { Preview(false, "Hotwire is not available.") } @Suppress("UNCHECKED_CAST") fun previewTogether(off: List, on: List): Preview? { if (off.isEmpty() || on.isEmpty()) return null - val conflict = translated("oneconfig.page.hotwire.switch.conflict", "Some of these mods need others you're turning off. Switch them separately.") - val plan = runCatching { - val engine = engine()!! - val leaving = call("previewOff", off)!!.get("switched") as List - val disabled = engine.field("installed")!!.field("disabled") as Map - val planner = plannerWith(engine, keptOff(on).map(disabled::getValue)) - planner.callMethod("planOn", on, (offMods() + leaving).toSet())!!.takeUnless { plan -> - (plan.get("order") as List).any { it in leaving } - } - }.getOrNull() ?: return Preview(false, conflict) - val engine = engine()!! - if ((plan.get("refusals") as List<*>).isNotEmpty()) { - return Preview(false, runCatching { describeRefusal(engine, plan) }.getOrDefault(conflict)) + return runCatching { + val kept = keptOff(on) + val plan = changePlan(off = off, on = on - kept.toSet()) + Preview( + plan.through("ChangePlan", "canApply") == true, + (plan.through("ChangePlan", "getLines") as List + listOfNotNull(addsBack(kept))).joinToString(" "), + ) + }.getOrElse { + Preview(false, translated("oneconfig.page.hotwire.switch.conflict", "Some of these mods need others you're turning off. Switch them separately.")) } - refusalOnceMet(engine, keptOff(on).mapNotNull { engine.callMethod("findJarOf", it) as Path? })?.let { return Preview(false, it) } - val alone = preview(on, true) - if (alone.possible) return alone - val names = (plan.get("order") as List).joinToString(", ") { engine.callMethod("nameOfMod", it) as String } - return Preview(true, translated("oneconfig.page.hotwire.switch.after_off", "Turns on %s once the others are off.", names)) } - fun keptOnWhenOff(ids: Collection): Set = runCatching { - val plan = plannerWith(engine()!!, emptyList()).callMethod("planOff", ids, offMods().toSet())!! - (plan.get("enabledNested") as List<*>).mapTo(HashSet()) { it!!.get("mod") as String } - }.getOrDefault(emptySet()) - - private fun describeRefusal(engine: Any, plan: Any): String { - val refused = Class.forName("dev.moon.hotwire.switching.ModSwitcher\$Result\$Refused").constructors.single().newInstance(plan) - val nameOf = Function { engine.callMethod("nameOfMod", it) as String } - return Class.forName("dev.moon.hotwire.switching.PlayerMessages").methods - .first { it.name == "describe" && it.parameterCount == 2 }.invoke(null, refused, nameOf) as String - } - - private fun offMods(): List = mods().orEmpty().filterNot { it.on }.map { it.id } + // shortcut: Hotwire's API can't tell which mods were kept off since the last launch, and refuses to preview them + // though its turnOn brings them back, so this one question reaches into its engine until the API can answer it + // the engine lives in a package per game version, the same one as the API implementation + private fun engine(): Any? = runCatching { + Class.forName("${api!!.javaClass.packageName}.HotwireClient").getMethod("findEngine").invoke(null) + }.getOrNull() - private fun plannerWith(engine: Any, jars: List): Any = - engine.callStatic("ModPolicy", "makePlannerFor", engine.field("installed")!!.callMethod("makeGraphWith", jars)!!)!! + private fun Any.callHidden(name: String, vararg args: Any): Any? = + javaClass.declaredMethods.first { it.name == name && it.parameterCount == args.size }.apply { isAccessible = true }.invoke(this, *args) - private fun keptOff(ids: Collection): List = runCatching { + fun keptOff(ids: Collection): List = runCatching { val disabled = engine()!!.callHidden("getDisabled")!! ids.filter { disabled.callHidden("isDisabledSinceStart", it) == true } }.getOrDefault(emptyList()) @@ -150,14 +137,9 @@ object HotwireBridge { /** 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() && runCatching { - val installed = engine()!!.field("installed")!! - ids.any { installed.callMethod("isAddedThisSession", it) != true } - }.getOrDefault(true) + fun isStarting(ids: Collection): Boolean = isWorking() && ids.any { call("isAddedThisSession", it) != true } - fun isUpdating(): Boolean = runCatching { - (engine()!!.field("tasks")!!.field("workers") as ThreadPoolExecutor).activeCount > 0 - }.getOrDefault(false) + fun isUpdating(): Boolean = runCatching { (call("getStatus")!!.get("state") as Enum<*>).name == "BUSY" }.getOrDefault(false) fun awaitIdle() { val start = System.nanoTime() @@ -182,44 +164,16 @@ object HotwireBridge { 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 - * - * [upToDate] means the mod already has this version, so there is nothing to do - */ + /** 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 upToDate: Boolean = false, val reopens: Boolean = false, ) - // shortcut: Hotwire's public API can't add a jar from disk or list updates, so those reach into its engine, which - // may break on a Hotwire update, until Hotwire's API gains them - // the engine lives in a package per game version, the same one as the API implementation - private fun engine(): Any? = runCatching { - Class.forName("${api!!.javaClass.packageName}.HotwireClient").getMethod("findEngine").invoke(null) - }.getOrNull() - - private fun Any.field(name: String): Any? = javaClass.getDeclaredField(name).apply { isAccessible = true }.get(this) - - private fun Any.callMethod(name: String, vararg args: Any): Any? = - javaClass.methods.first { it.name == name && it.parameterCount == args.size }.invoke(this, *args) - - private fun Any.callHidden(name: String, vararg args: Any): Any? = - javaClass.declaredMethods.first { it.name == name && it.parameterCount == args.size }.apply { isAccessible = true }.invoke(this, *args) - - private fun Any.callStatic(type: String, name: String, vararg args: Any): Any? = - Class.forName("${javaClass.packageName}.$type").methods.first { it.name == name && it.parameterCount == args.size }.invoke(null, *args) - - /** The constant named [constant] of the enum a two-argument [method] of the engine takes last */ - private fun Any.enumArg(method: String, constant: String): Any = - javaClass.methods.first { it.name == method && it.parameterCount == 2 }.parameterTypes[1] - .enumConstants.first { (it as Enum<*>).name == constant } - // 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) @@ -242,7 +196,11 @@ object HotwireBridge { /** 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") ?: return null + 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) @@ -284,9 +242,7 @@ object HotwireBridge { class StorePage(val hits: List, val total: Int) class StoreUpdate( - internal val offer: Any, val modId: String, - val projectId: String, val name: String, val installedVersion: String, val newVersion: String, @@ -326,14 +282,6 @@ object HotwireBridge { (call("getStoreCategories", mod) as List<*>).map { StoreCategory(it!!.get("name") as String, it.get("parts") as List) } }.getOrDefault(emptyList()) - private var storeChecked: CompletableFuture? = null - - private var storeCheckedState: List? = null - - /** Hotwire's offers from the last update check, which it reuses when fetching what an update needs */ - @Volatile - private var offers: List<*> = emptyList() - /** * Searches Modrinth for mods matching [words] in [category], or every category when blank, skipping the first * [offset] results; blocks on the network, so call it off the UI thread @@ -342,43 +290,20 @@ object HotwireBridge { 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) } - fun search(): StorePage { - 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) + 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, + ) } - installedCheck()?.get() - return search() - } - - @Synchronized - private fun installedCheck(): CompletableFuture? { - val state = installedState() - if (state == storeCheckedState) return storeChecked - val engine = engine() ?: return null - val made = engine.callMethod("getStore") - val store = made ?: runCatching { onUiThread { engine.callHidden("findStore") } }.getOrNull() ?: return null - if (made != null) store.callMethod("checkInstalled") - storeCheckedState = state - return storeWorkerDone(store.callMethod("getModel")!!) - .completeOnTimeout(Unit, STORE_WORKER_TIMEOUT_SECONDS, TimeUnit.SECONDS).also { storeChecked = it } - } - - private fun storeWorkerDone(model: Any): CompletableFuture { - val done = CompletableFuture() - model.callMethod("run", Runnable { done.complete(Unit) }) - return done + return StorePage(hits, page.get("total") as Int) } /** The newest version of a project that fits this game; blocks on the network */ @@ -389,42 +314,27 @@ object HotwireBridge { return version.get("id") as String } - private fun browseThisGame() { - val model = engine()?.callMethod("getStore")?.callMethod("getModel") ?: return - val client = model.callMethod("getClient")!! - if (client.callMethod("isBrowsingOwn") == true) return - model.callMethod("setBrowsing", client.callMethod("getLoader")!!, client.callMethod("getGameVersion")!!) - } - /** 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) - runCatching { onUiThread(::browseThisGame) } val add = (call("requestAdd", hit.projectId, version) as CompletableFuture<*>).get()!! - val canAdd = add.through("StoreAdd", "canAdd") == true - @Suppress("UNCHECKED_CAST") - val jars = add.field("jars") as List - val fitting = engine()?.takeIf { !canAdd && fitTogether(it, jars) } - val refusal = fitting?.let { refusalOnceMet(it, jars) } - val inOrder = fitting.takeIf { refusal == null } - val nextLaunch = engine()?.takeIf { jars.isNotEmpty() && (canAdd || inOrder != null) }?.let { nextLaunchRefusal(it, jars) } - val reopen = add.through("StoreAdd", "needsReopen") == true || inOrder != null && jars.any { reopensOnceMet(inOrder, it) } - val modes =(add.field("previews") as List<*>).map { it!!.addMode() } - val onlyLater = modes.lastOrNull() == "NEXT_START" || inOrder != null && "NEXT_START" in modes + 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"), onlyLater) { + val later = JarAction(translated("oneconfig.page.hotwire.add.next_start", "Add at next launch"), false) { accepted = true - add.through("StoreAdd", "cancel") - CompletableFuture.supplyAsync { addInOrder(engine()!!, jars, hit.title, reopen = false, later = true) } + ask("cancel") + call("queueForNextStart", hit.projectId, version).asResults() } val save = JarAction(translated("oneconfig.page.hotwire.add.save", "Save to Downloads"), false) { accepted = true - add.through("StoreAdd", "cancel") - val query = if (jars.isEmpty()) directDownloadQuery() else storeQuery("") + ask("cancel") + val query = if (canAdd) storeQuery("") else directDownloadQuery() call("saveToDownloads", query, hit.slug.ifBlank { hit.projectId }, version).asResults() } val now = JarAction( - if (reopen) { + if (ask("needsReopen") == true) { translated("oneconfig.page.hotwire.add.reopen", "Save and add") } else { translated("oneconfig.page.hotwire.add.now", "Add now") @@ -432,729 +342,93 @@ object HotwireBridge { true, ) { accepted = true - val ordered = inOrder ?: engine()?.takeIf { "NEXT_START" in modes } - val added = if (ordered == null) { - add.through("StoreAdd", "accept").asResults().thenApplyAsync { result -> - if (result.ok) lostJar(engine()!!, jars) ?: result else result - } - } else { - add.through("StoreAdd", "cancel") - CompletableFuture.supplyAsync { addInOrder(ordered, jars, hit.title, reopen) } - } - added.thenApplyAsync { result -> - runCatching { installedCheck()?.get() } - if (result.ok) engine()?.callMethod("getStore")?.callMethod("getModel")?.callMethod("markInstalled", hit.projectId) - result - } - } - @Suppress("UNCHECKED_CAST") - val lines = if (refusal != null || nextLaunch != null) { - listOf(nextLaunch ?: refusal!!) - } else if (inOrder == null) { - add.through("StoreAdd", "getLines") as List - } else { - (add.field("previews") as List<*>).dropLast(1).map { - translated("oneconfig.page.hotwire.add.adds", "Adds %s, which this mod needs.", it!!.get("name") as String) - } + ask("accept").asResults() } return JarPlan( - add.through("StoreAdd", "getTitle") as String, + ask("getTitle") as String, "", - lines, - when { - nextLaunch != null || !canAdd && inOrder == null -> listOf(save) - onlyLater -> listOf(later, save) - else -> listOf(now, later, save) - }, - onCancel = { if (!accepted) add.through("StoreAdd", "cancel") }, - ) - } - - private fun readJar(jar: Path): Any = - Class.forName("dev.moon.hotwire.jar.ModJarReader").getMethod("read", Path::class.java).invoke(null, jar)!! - - private fun Any.modId(): String = get("manifest")!!.get("id") as String - - @Suppress("UNCHECKED_CAST") - private fun fitTogether(engine: Any, jars: List): Boolean = runCatching { - if (jars.size < 2 || jars.any { engine.callMethod("canUpdate", it) == true }) return false - val read = jars.map(::readJar) - val ids = read.map { it.modId() } - val planner = plannerWith(engine, read) - (planner.callMethod("planOn", ids, (offMods() + ids).toSet())!!.get("refusals") as List<*>).isEmpty() && - ids.all { (planner.callMethod("findOldDependencies", it) as List<*>).isEmpty() } - }.getOrDefault(false) - - private fun reopensOnceMet(engine: Any, jar: Path): Boolean = runCatching { - val adds = engine.callHidden("findParts")!!.field("adds")!! - if (adds.callMethod("isInWorld") != true) return false - val scan = Class.forName("dev.moon.hotwire.jar.ContentScan") - val classes = scan.getMethod("readClasses", Path::class.java).invoke(null, jar) - val superOf = Function { adds.callMethod("findSuperOf", it) as String? } - val findKinds = scan.methods.first { it.name == "findKinds" && it.parameterTypes[0].isInstance(classes) } - (findKinds.invoke(null, classes, superOf) as Set<*>).isNotEmpty() - }.getOrDefault(false) - - private fun Any.askAdder(type: String, method: String, arg: Any): String? = - (Class.forName("${javaClass.packageName}.add.$type").declaredMethods.first { it.name == method } - .apply { isAccessible = true }.invoke(null, arg) as Optional<*>).orElse(null) as String? - - private fun Any.parts(): List = listOf(this) + (get("nested") as List<*>).flatMap { it!!.parts() } - - private fun worldRefusal(engine: Any): String? = - (engine.callStatic("add.WorldReopen", "findRefusal") as Optional<*>).orElse(null) as String? - - private fun refusalOnceMet(engine: Any, jars: List): String? = runCatching { - jars.firstNotNullOfOrNull { jar -> - engine.askAdder("AddFirstLook", "findRefusal", jar) - ?: readJar(jar).parts().firstNotNullOfOrNull { engine.askAdder("AddDependencies", "findMissingRefusal", it) } - } ?: worldRefusal(engine)?.takeIf { jars.any { reopensOnceMet(engine, it) } } - }.getOrNull() - - private fun addInOrder(engine: Any, jars: List, name: String, reopen: Boolean, later: Boolean = false): Result { - nextLaunchRefusal(engine, jars)?.let { return Result(false, it) } - var queueing = later - val steps = jars.map { jar -> - { - queueing = queueing || dryRunAdd(engine, jar).addMode() == "NEXT_START" - if (queueing) queueAdd(engine, jar) else addNow(engine, jar, reopen) - } - } - queueTogether(engine.field("updateQueue")!!, jars.map { readJar(it).modId() }, steps)?.let { return it } - return Result( - true, - if (queueing) { - translated("oneconfig.page.hotwire.add.queued", "%s is added at the next launch.", name) - } else { - translated("oneconfig.page.hotwire.add.done", "Added %s.", name) - }, + ask("getLines") as List, + 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 engine = engine()!! - val found = engine.callMethod("getUpdateCheck")!!.callMethod("findNow") as List<*> - offers = found - val queued = (engine.callMethod("getPendingChanges") as List<*>).filter { it!!.javaClass.simpleName == "Update" }.map { it!!.get("modId") } - return found.filterNot { it!!.get("id") in queued }.map { - StoreUpdate(it!!, 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) + val found = (call("checkUpdates") as CompletableFuture<*>).get()!! + (found.get("failure") as String).takeIf { it.isNotBlank() }?.let { error(it) } + val queued = pendingTasks().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("name") as String, it.get("installedVersion") as String, it.get("newVersion") as String) } } - /** Downloads the mods an offered update newly needs and then the update's own jar, last; blocks on the network */ - @Suppress("UNCHECKED_CAST") - private fun downloadUpdate(update: StoreUpdate): List = - engine()!!.callMethod("getUpdateCheck")!!.callMethod("fetchWithNeeds", update.offer, Consumer {}) as List - - fun planUpdate(update: StoreUpdate): JarPlan = - if (update.modId == "hotwire" || update.modId.startsWith("hotwire-")) planSelfUpdate(update) else planUpdate(downloadUpdate(update)) - - private fun planSelfUpdate(update: StoreUpdate): JarPlan { - val engine = engine()!! - val jars = downloadUpdate(update) - val detail = "${update.installedVersion} → ${update.newVersion}" - nextLaunchRefusal(engine, jars)?.let { return JarPlan(update.name, detail, listOf(it), emptyList()) } - val action = JarAction(translated("oneconfig.page.hotwire.update.next_start", "Update at next launch"), true) { - CompletableFuture.supplyAsync { - val needs = jars.dropLast(1).map { jar -> { queueAdd(engine, jar) } } - nextLaunchRefusal(engine, jars)?.let { Result(false, it) } - ?: queueTogether(engine.field("updateQueue")!!, jars.map { readJar(it).modId() }, needs + { queueSelfUpdate(engine, update, jars.last()) }) - ?: Result(true, translated("oneconfig.page.hotwire.update.queued", "%s updates at the next launch.", update.name)) - } - } - return JarPlan( - update.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(action), - ) + 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" */ @Suppress("UNCHECKED_CAST") - fun queueTogether(queue: Any, ids: List, steps: List<() -> Result>): Result? { - val before = (queue.callMethod("getEntries") as List).filter { it.get("modId") in ids } - // a step may overwrite a staged jar in place, so keep its bytes to put back - val staged = before.mapNotNull { entry -> runCatching { Path.of(entry.get("stagedJar") as String).let { it to Files.readAllBytes(it) } }.getOrNull() } - val failed = runCatching { steps.firstNotNullOfOrNull { step -> step().takeUnless { it.ok } } }.getOrElse(::failure) ?: return null - staged.forEach { (jar, bytes) -> runCatching { Files.write(jar, bytes) } } - synchronized(queue) { - queue.callHidden("replace", (queue.callMethod("getEntries") as List).filterNot { it.get("modId") in ids } + before) - } - return failed - } - - @Suppress("UNCHECKED_CAST") - private fun nextLaunchRefusal(engine: Any, jars: List): String? = runCatching { - val incoming = jars.map(::readJar) - val ids = incoming.map { it.modId() } - val queued = (engine.field("updateQueue")!!.callMethod("getEntries") as List).filterNot { it.get("modId") in ids }.mapNotNull { entry -> - runCatching { readJar(Path.of(entry.get("stagedJar") as String)) }.getOrNull()?.let { entry.get("modId") as String to it } - } - val installed = engine.field("installed")!! - val replaced = ids + queued.flatMap { (id, jar) -> listOf(id, jar.modId()) } - val leaving = replaced + (installed.field("disabled") as Map).keys - val graphJars = installed.callHidden("getGraphJars") as List - val staying = graphJars.filterNot { it.modId() in leaving } - // the jars that land go first, since the graph keeps the first copy of a mod it sees - val landing = incoming + queued.map { it.second } + staying - val graph = Class.forName("dev.moon.hotwire.plan.ModGraph").getConstructor(Collection::class.java).newInstance(landing) - val depends = Class.forName("dev.moon.hotwire.jar.ModManifest\$Relation").enumConstants.first { (it as Enum<*>).name == "DEPENDS" } - val oldDependency = Class.forName("dev.moon.hotwire.plan.SwitchPlan\$Refusal\$OldDependency").constructors.single() - val fits = { version: String, ranges: List -> engine.callStatic("ModPolicy", "satisfies", version, ranges) == true } - val wants = { mod: String, dependency: String -> - ((graph.callMethod("find", mod) as Optional<*>).orElseThrow().callMethod("getRelation", depends) as Map>) - .filterKeys { graph.callMethod("resolve", it) == dependency }.values.toList() - } - val builtIns = incoming.flatMap { it.parts() }.flatMap { part -> - oldBuiltIns( - part.modId(), - part.get("manifest")!!.callMethod("getRelation", depends) as Map>, - { id -> (engine.callStatic("LoadedMods", "findVersion", id) as String?).takeIf { (graph.callMethod("find", id) as Optional<*>).isEmpty } }, - fits, - ) - }.map { oldDependency.newInstance(*it.toTypedArray()) } - val missing = Class.forName("dev.moon.hotwire.plan.SwitchPlan\$Refusal\$MissingDependency").constructors.single() - val lost = lostNeeds(graph, graphJars.filter { it.modId() in replaced }.flatMap(::namesIn)) - .map { (mod, dependency) -> missing.newInstance(mod, dependency) } - val copies = landing.flatMap { it.parts() }.groupBy({ it.modId() }, { it.get("manifest")!!.get("version") as String }) - val nested = (incoming.flatMap { it.parts() }.map { it.modId() } - landing.map { it.modId() }.toSet()).associateWith(copies::getValue) - refusedPlan(engine.callStatic("ModPolicy", "makePlannerFor", graph)!!, graph, ids, builtIns + lost, nested, fits, wants)?.let { describeRefusal(engine, it) } - }.getOrElse { - translated("oneconfig.page.hotwire.next_launch_unchecked", "Hotwire couldn't check this against your other mods, so nothing was changed.") - } - - fun oldBuiltIns(mod: String, needs: Map>, present: (String) -> String?, fits: (String, List) -> Boolean): List> = - needs.mapNotNull { (id, ranges) -> - present(id)?.takeUnless { fits(it, ranges) }?.let { listOf(mod, id, ranges.joinToString(" or "), it) } - } - - @Suppress("UNCHECKED_CAST") - fun namesIn(jar: Any): List = - jar.parts().flatMap { (it.get("manifest")!!.get("provides") as List) + it.modId() } - - @Suppress("UNCHECKED_CAST") - fun lostNeeds(graph: Any, outgoing: Collection): List> = - outgoing.distinct().filter { (graph.callMethod("find", it) as Optional<*>).isEmpty }.flatMap { id -> - (graph.callMethod("getDependents", id) as Collection).filter { isNeeded(graph, it) }.map { it to id } - } - - @Suppress("UNCHECKED_CAST") - private fun isNeeded(graph: Any, id: String, seen: MutableSet = HashSet()): Boolean = - graph.callMethod("isInstalledOnItsOwn", id) == true || - seen.add(id) && (graph.callMethod("getDependents", id) as Collection).any { isNeeded(graph, it, seen) } - - @Suppress("UNCHECKED_CAST") - fun refusedPlan( - planner: Any, - graph: Any, - ids: List, - extra: List = emptyList(), - nested: Map> = emptyMap(), - fits: (String, List) -> Boolean = { _, _ -> true }, - wants: (String, String) -> List> = { _, _ -> emptyList() }, - ): Any? { - val plan = planner.callMethod("planOn", ids, ids.toSet())!! - val old = (ids + ids.flatMap { graph.callMethod("getDependents", it) as Collection }).distinct() - .flatMap { planner.callMethod("findOldDependencies", it) as List } - .filter { it.get("mod") in ids || it.get("dependency") in ids } - val oldNested = nested.flatMap { (id, versions) -> - val dependents = (graph.callMethod("getDependents", id) as Collection).filter { isNeeded(graph, it) } - val needs = dependents.flatMap { wants(it, id) } - if (versions.any { version -> needs.all { fits(version, it) } }) return@flatMap emptyList() - dependents.flatMap { planner.callMethod("findOldDependencies", it) as List }.filter { it.get("dependency") == id } - } - val nestedRefusals = nested.filterValues { it.distinct().size == 1 }.keys.toList().takeIf { it.isNotEmpty() } - ?.let { only -> planner.callMethod("planOn", only, only.toSet())!!.get("refusals") as List } - .orEmpty().filter { - when (it.javaClass.simpleName) { - "Breaks" -> true - "MissingDependency" -> isNeeded(graph, it.get("mod") as String) - else -> false - } - } - val refusals = (plan.get("refusals") as List + old + oldNested + nestedRefusals + extra).distinct() - if (refusals.isEmpty()) return null - return plan.javaClass.constructors.single() - .newInstance(plan.get("direction"), plan.get("order"), plan.get("why"), plan.get("enabledNested"), refusals) - } - - private fun queueSelfUpdate(engine: Any, update: StoreUpdate, jar: Path): Result = runCatching { - engine.askAdder("AddFirstLook", "findRefusal", jar)?.let { return Result(false, it) } - val running = engine.callMethod("findJarOf", update.modId) as Path - nameClash(engine, jar, update.modId, running)?.let { return it } - val staging = Files.createDirectories(engine.field("updateFolders")!!.get("staging") as Path) - val staged = staging.resolve(jar.fileName.toString()) - Files.copy(jar, staged, StandardCopyOption.REPLACE_EXISTING) - queueReplacement( - engine.field("updateQueue")!!, - Class.forName("dev.moon.hotwire.update.UpdateQueue\$Entry"), - readJar(jar).get("manifest")!!, - update.installedVersion, - running, - sha256(running), - staged, - sha256(staged), - ) - Result(true, "") - }.getOrElse { queueFailed(jar) } - - private fun nameClash(engine: Any, jar: Path, modId: String, running: Path?): Result? { - val name = jar.fileName.toString() - val queued = (engine.field("updateQueue")!!.callMethod("getEntries") as List<*>) - .filter { it!!.get("modId") != modId }.map { it!!.get("stagedJar") as String } - val target = (engine.field("updateFolders")!!.get("mods") as Path).resolve(name) - return if (nameTaken(jar, target, running, queued)) { - Result(false, translated("oneconfig.page.hotwire.update.name_taken", "A different jar named %s is already there, so nothing was changed.", name)) - } else { - null - } - } - - fun nameTaken(jar: Path, target: Path, running: Path?, queued: List): Boolean { - val name = jar.fileName.toString() - return queued.any { Path.of(it).fileName.toString() == name } || - Files.exists(target) && name != running?.fileName?.toString() && Files.mismatch(target, jar) != -1L - } - - private fun updateClash(engine: Any, jar: Path): Result? { - val incoming = unwrapped(engine, jar) - val manifest = manifestOf(incoming) ?: return null - val id = manifest.get("id") as String - val replaced = if (engine.callMethod("findJarOf", id) != null) { - id - } else { - runCatching { - engine.callHidden("findParts")!!.field("updates")!!.field("updater")!!.field("idChanged")!!.callHidden("findReplacedBy", manifest) as String? - }.getOrNull() - } - return nameClash(engine, incoming, replaced ?: id, replaced?.let { engine.callMethod("findJarOf", it) as Path? }) - } - - private fun sha256(file: Path): String = - Class.forName("dev.moon.hotwire.jar.FileHashes").getMethod("findSha256", Path::class.java).invoke(null, file) as String - - fun queueReplacement(queue: Any, entryType: Class<*>, manifest: Any, fromVersion: String, running: Path, runningSha256: String, staged: Path, stagedSha256: String) { - queue.callMethod( - "add", - entryType.constructors.single().newInstance( - manifest.get("id"), - manifest.get("name"), - fromVersion, - manifest.get("version"), - running.fileName.toString(), - runningSha256, - staged.toAbsolutePath().toString(), - stagedSha256, - "", - System.currentTimeMillis(), - ), - ) - } - - private fun manifestOf(jar: Path): Any? = - Class.forName("dev.moon.hotwire.jar.JarLookup").getMethod("findManifest", Path::class.java).invoke(null, jar) - - private fun nameOfJar(jar: Path): String = manifestOf(jar)?.get("name") as String? ?: jar.fileName.toString() - - private fun dryRunAdd(engine: Any, jar: Path): Any = (engine.callMethod("dryRunAdd", jar) as CompletableFuture<*>).get()!! - - private fun Any.lineOfAdd(): String = (get("lines") as List<*>).firstOrNull() as String? ?: get("name") as String - - private fun Any.addMode(): String = (get("mode") as Enum<*>).name - - /** The mods stopping an update, or none when Hotwire can't plan it, which is how it answers any other refusal */ - private fun unmetNeeds(engine: Any, jar: Path): List { - val plan = engine.field("changes")!!.callMethod("planUpdate", jar) ?: return emptyList() - return (plan.get("blockers") as List<*>).filter { it!!.javaClass.simpleName == "DependencyUnmet" }.map { it!!.get("mod") as String } - } - - /** - * Adds to [steps] what has to land before [jar] can update, each after what it needs in turn, the way Hotwire's - * own update queue meets needs; false when Hotwire refuses it for anything else - */ - private fun collectNeeds(engine: Any, jar: Path, seen: MutableSet, steps: MutableList): Boolean { - val preview = (engine.callMethod("dryRunUpdate", jar) as CompletableFuture<*>).get()!! - val kind = preview.javaClass.simpleName - if (kind != "Refused") return kind == "Ready" - val unmet = unmetNeeds(engine, jar) - return unmet.isNotEmpty() && unmet.all { fetchNeed(engine, it, seen, steps) } - } - - @Suppress("UNCHECKED_CAST") - private fun fetchNeed(engine: Any, id: String, seen: MutableSet, steps: MutableList): Boolean { - if (!seen.add(id)) return true - val jars = engine.callMethod("getUpdateCheck")!!.callMethod("fetchById", id, offers, Consumer {}) as List - if (jars.isEmpty()) return false - for (need in jars) { - if (need in steps) continue - val met = if (engine.callMethod("canUpdate", need) == true) collectNeeds(engine, need, seen, steps) else meetOldDependencies(engine, need, seen, steps) - if (!met) return false - steps.add(need) - } - return true - } - - private fun meetOldDependencies(engine: Any, jar: Path, seen: MutableSet, steps: MutableList): Boolean { - val read = readJar(jar) - val old = plannerWith(engine, listOf(read)).callMethod("findOldDependencies", read.modId()) as List<*> - return old.all { fetchNeed(engine, it!!.get("dependency") as String, seen, steps) } - } - - /** What applying [jars] from [downloadUpdate] would do; blocks on Hotwire reading them and on the network */ - @Suppress("UNCHECKED_CAST") - private fun planUpdate(jars: List): JarPlan { - val jar = jars.last() - val plan = planFor(jar, update = true).get() - val engine = engine() - if (engine == null || engine.callMethod("canUpdate") != true) return plan - val steps = mutableListOf() - val seen = jars.mapNotNull(::manifestOf).flatMapTo(HashSet()) { (it.get("provides") as List) + (it.get("id") as String) } - val needs = jars.dropLast(1) - val collected = needs.all { need -> - (engine.callMethod("canUpdate", need) == true || meetOldDependencies(engine, need, seen, steps)).also { if (it) steps.add(need) } - } && collectNeeds(engine, jar, seen, steps) - // a later add can be refused now only for lacking an earlier one, so those are read again as they run - val firstAdds = (if (collected) steps.take(1) else needs).filter { engine.callMethod("canUpdate", it) != true } - .map { dryRunAdd(engine, it) } - firstAdds.firstOrNull { it.addMode() == "REFUSED" } - ?.let { return JarPlan(plan.title, plan.detail, it.get("lines") as List, emptyList()) } - if (!collected || steps.isEmpty()) return plan - val adds = steps.filter { engine.callMethod("canUpdate", it) != true } - (refusalOnceMet(engine, adds) ?: adds.takeIf { it.isNotEmpty() }?.let { nextLaunchRefusal(engine, steps + jar) }) - ?.let { return JarPlan(plan.title, plan.detail, listOf(it), emptyList()) } - val reopen = firstAdds.any { it.addMode() == "SAVE_AND_REOPEN" } || adds.any { reopensOnceMet(engine, it) } - val label = if (reopen) { - translated("oneconfig.page.hotwire.update.reopen", "Save and update") - } else { - translated("oneconfig.page.hotwire.update.with_needs", "Update with dependencies") - } - val action = JarAction(label, true) { - CompletableFuture.supplyAsync { runSteps(engine, steps, jar, reopen) } - } - val lines = steps.map { - val name = nameOfJar(it) - if (engine.callMethod("canUpdate", it) == true) { - translated("oneconfig.page.hotwire.update.updates", "Updates %s, which this update needs.", name) - } else { - translated("oneconfig.page.hotwire.update.adds", "Adds %s, which this update needs.", name) - } - } - return JarPlan(plan.title, plan.detail, lines + plan.lines.takeIf { plan.actions.isNotEmpty() }.orEmpty(), listOf(action), reopens = reopen) - } - - /** Lands [steps] in order and then updates [jar], stopping at the first that fails, since each one's plan rests on those before */ - private fun runSteps(engine: Any, steps: List, jar: Path, reopen: Boolean): Result { - val adds = steps.filter { engine.callMethod("canUpdate", it) != true } - val landing = steps.map { step -> { if (step in adds) addNow(engine, step, reopen) else updateNow(step) } } - lateinit var updated: Result - return queueTogether(engine.field("updateQueue")!!, adds.map { readJar(it).modId() }, landing + { updateNow(jar).also { updated = it } }) ?: updated - } - - @Suppress("UNCHECKED_CAST") - private fun waitingNeeds(engine: Any, jar: Path): List { - val manifest = manifestOf(jar) ?: return emptyList() - val graph = engine.callHidden("getGraph")!! - val depends = Class.forName("dev.moon.hotwire.jar.ModManifest\$Relation").enumConstants.first { (it as Enum<*>).name == "DEPENDS" } - val needs = (manifest.callMethod("getRelation", depends) as Map>).mapKeys { graph.callMethod("resolve", it.key) } - return (engine.callMethod("getPendingChanges") as List<*>).filter { change -> - if (change!!.javaClass.simpleName != "Update") return@filter false - val id = change.get("modId") as String - val ranges = needs[id] ?: return@filter false - val running = engine.callStatic("LoadedMods", "findVersion", id) - running == null || engine.callStatic("ModPolicy", "satisfies", running, ranges) != true - }.map { it!!.get("name") as String } - } - - private fun onUiThread(block: () -> T): T { - val done = CompletableFuture() - Platform.screen().runOnUiThread { runCatching(block).fold(done::complete, done::completeExceptionally) } - return done.get() - } - - private fun updateNow(jar: Path): Result { - val plan = planFor(jar, update = true).get() - if (plan.upToDate) return Result(true, "") - val action = plan.actions.firstOrNull { it.primary } ?: return Result(false, plan.lines.firstOrNull().orEmpty()) - return onUiThread(action::attempt)!!.get() - } - - private fun reopenNotAgreed(name: String) = - Result(false, translated("oneconfig.page.hotwire.reopen_confirm", "%s has to save and reopen your world. Try again to confirm.", name), needsReopen = true) - - private fun addNow(engine: Any, jar: Path, reopen: Boolean): Result { - val preview = dryRunAdd(engine, jar) - val mode = preview.get("mode") as Enum<*> - val name = preview.get("name") as String - when (mode.name) { - "REFUSED" -> return Result(false, preview.lineOfAdd()) - "NEXT_START" -> return queueAdd(engine, jar) - "SAVE_AND_REOPEN" -> if (!reopen) return reopenNotAgreed(name) - } - onUiThread { engine.callMethod("add", jar, mode) } - dryRunAdd(engine, jar) - if (engine.callMethod("canUpdate", jar) != true) { - return Result(false, translated("oneconfig.page.hotwire.add.failed", "Hotwire couldn't add %s. It posts why in chat.", name)) - } - return if (onUiThread { keptForNextLaunch(engine, jar) }) Result(true, "") else notKept(jar) - } - - private fun keptForNextLaunch(engine: Any, jar: Path): Boolean { - val kept = (engine.field("updateFolders")!!.get("mods") as Path).resolve(jar.fileName.toString()) - return Files.exists(kept) && Files.mismatch(kept, jar) == -1L - } - - private fun notKept(jar: Path) = - Result(false, translated("oneconfig.page.hotwire.add.not_kept", "%s is added for now, but it won't stay after a restart - please check chat!", nameOfJar(jar))) - - private fun lostJar(engine: Any, jars: List): Result? = - jars.firstOrNull { !onUiThread { keptForNextLaunch(engine, it) } }?.let(::notKept) - - private fun unwrapped(engine: Any, jar: Path): Path = runCatching { - engine.callHidden("findParts")!!.field("updates")!!.field("updater")!!.callMethod("unwrap", jar) as Path - }.getOrDefault(jar) - - private fun isQueued(engine: Any, jar: Path): Boolean { - val updates = (engine.callMethod("getPendingChanges") as List<*>).filter { it!!.javaClass.simpleName == "Update" } - return listOf(jar, unwrapped(engine, jar)).any { queued -> - val manifest = manifestOf(queued) ?: return@any false - updates.any { it!!.get("newJar") == queued.fileName.toString() && it.get("toVersion") == manifest.get("version") } - } - } - - private fun queueFailed(jar: Path) = - Result(false, translated("oneconfig.page.hotwire.add.queue_failed", "Hotwire couldn't queue %s - please check chat!", nameOfJar(jar))) - - private fun queueAdd(engine: Any, jar: Path): Result { - nameClash(engine, jar, readJar(jar).modId(), null)?.let { return it } - onUiThread { engine.callMethod("add", jar, engine.enumArg("add", "NEXT_START")) } - return if (isQueued(engine, jar)) Result(true, "") else queueFailed(jar) - } - - /** - * How the update just handed to Hotwire ended, which its engine doesn't report: a dry run asked for now runs on - * Hotwire's single worker after that update, and then sees the new version in, queued, or neither - */ - @Suppress("UNCHECKED_CAST") - private fun updateResult(engine: Any, jar: Path, name: String): CompletableFuture = - (engine.callMethod("dryRunUpdate", jar) as CompletableFuture).thenApply { after -> - val queued = isQueued(engine, jar) - val live = after.javaClass.simpleName == "SameVersion" + 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 nextStart = translated("oneconfig.page.hotwire.$kind.next_start", "Apply at next launch") + val now = JarAction( when { - live && queued -> Result(true, translated("oneconfig.page.hotwire.update.done", "%s is updated.", name)) - live -> Result(false, translated("oneconfig.page.hotwire.update.not_kept", "%s is updated for now, but the update won't stay after a restart - please check chat!", name)) - queued -> Result(true, translated("oneconfig.page.hotwire.update.queued", "%s updates at the next launch.", name)) - after.javaClass.simpleName == "Refused" -> Result(false, after.get("reason") as String) - else -> Result(false, translated("oneconfig.page.hotwire.update.failed", "Hotwire couldn't update %s. It posts why in chat.", name)) - } - } - - /** - * Reads [jar] the way Hotwire does before adding or updating - * - * An [update] is read as one straight away; otherwise it is one when Hotwire says that mod is already installed - * or that this jar replaces an installed mod under a new id - */ - fun planFor(jar: Path, update: Boolean = false): CompletableFuture = runCatching { - val engine = engine()!! - // with no class access Hotwire's update dry run has nothing to run on, so say so instead of failing inside it - if (update && engine.callMethod("canUpdate") != true) { - return CompletableFuture.completedFuture( - JarPlan( - jar.fileName.toString(), - "", - listOf(translated("oneconfig.page.hotwire.update.unavailable", "Hotwire can't update mods in this game session.")), - emptyList(), - ), - ) - } - @Suppress("UNCHECKED_CAST") - if (update || engine.callMethod("canUpdate", jar) == true) { - (engine.callMethod("dryRunUpdate", jar) as CompletableFuture).thenApply { updatePlan(engine, jar, it) } - } else { - (engine.callMethod("dryRunUpdate", jar) as CompletableFuture).thenCompose { preview -> - if (replacesInstalled(preview, jar)) { - CompletableFuture.completedFuture(updatePlan(engine, jar, preview)) - } else { - (engine.callMethod("dryRunAdd", jar) as CompletableFuture).thenApply { addPlan(engine, jar, it) } - } - } - } - }.getOrElse { CompletableFuture.completedFuture(JarPlan(jar.fileName.toString(), "", listOf("Hotwire is not available."), emptyList())) } - - private fun replacesInstalled(preview: Any, jar: Path): Boolean = when (preview.javaClass.simpleName) { - "Ready" -> true - "Refused" -> preview.get("name") != jar.fileName.toString() - else -> false - } - - @Suppress("UNCHECKED_CAST") - private fun addPlan(engine: Any, jar: Path, preview: Any): JarPlan { - fun add(mode: String): () -> CompletableFuture? = { - engine.callMethod("add", jar, engine.enumArg("add", mode)) - null - } - val addLater: () -> CompletableFuture? = { - CompletableFuture.supplyAsync { addInOrder(engine, listOf(jar), preview.get("name") as String, reopen = false, later = true) } - } - val later = JarAction(translated("oneconfig.page.hotwire.add.next_start", "Add at next launch"), false, addLater) - val mode = (preview.get("mode") as Enum<*>).name - if (mode != "REFUSED") { - nextLaunchRefusal(engine, listOf(jar))?.let { return JarPlan(preview.get("name") as String, preview.get("version") as String? ?: "", listOf(it), emptyList()) } - } - val actions = when (mode) { - "NOW" -> listOf(JarAction(translated("oneconfig.page.hotwire.add.now", "Add now"), true, add("NOW")), later) - "SAVE_AND_REOPEN" -> listOf(JarAction(translated("oneconfig.page.hotwire.add.reopen", "Save and add"), true, add("SAVE_AND_REOPEN")), later) - "NEXT_START" -> listOf(JarAction(later.label, true, addLater)) - else -> emptyList() - } - return JarPlan(preview.get("name") as String, preview.get("version") as String? ?: "", preview.get("lines") as List, actions) - } - - @Suppress("UNCHECKED_CAST") - private fun updatePlan(engine: Any, jar: Path, preview: Any): JarPlan { - fun update(method: String): () -> CompletableFuture? = { - updateClash(engine, jar)?.let { CompletableFuture.completedFuture(it) } ?: run { - engine.callMethod("update", jar, engine.enumArg("update", method)) - updateResult(engine, jar, preview.get("name") as String) - } - } - return when (preview.javaClass.simpleName) { - "Ready" -> { - val method = (preview.get("method") as Enum<*>).name - val inPlace = method == "IN_PLACE" - val lines = preview.get("lines") as List - val waiting = if (inPlace) waitingNeeds(engine, jar) else emptyList() - if (waiting.isNotEmpty()) { - return JarPlan( - preview.get("name") as String, - "${preview.get("installedVersion")} → ${preview.get("newVersion")}", - listOf(translated("oneconfig.page.hotwire.update.waits", "This update needs %s, which only arrives at the next launch. Update this after restarting.", waiting.joinToString(", "))), - emptyList(), - ) - } - JarPlan( - preview.get("name") as String, - "${preview.get("installedVersion")} → ${preview.get("newVersion")}", - if (inPlace) { - lines - } else { - listOf(translated("oneconfig.page.hotwire.update.restart_only", "Hotwire can't swap this mod while the game runs, so it updates at the next launch.")) + lines + reopens -> translated("oneconfig.page.hotwire.$kind.reopen", "Save and apply") + restartOnly -> nextStart + else -> translated("oneconfig.page.hotwire.$kind.now", "Apply now") + }, + true, + ) { ask("accept").asResults() } + val later = JarAction(nextStart, false) { ask("acceptAtNextStart").asResults() } + val actions = when { + ask("canApply") != true -> emptyList() + !restartOnly && 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" || update.modId.startsWith("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 }, - listOf( - JarAction( - if (inPlace) { - translated("oneconfig.page.hotwire.update.now", "Update now") - } else { - translated("oneconfig.page.hotwire.update.next_start", "Update at next launch") - }, - true, - update(method), - ), - ), - ) - } - "Refused" -> JarPlan(preview.get("name") as String, "", listOf(preview.get("reason") as String), emptyList()) - "SameVersion" -> JarPlan( - preview.get("name") as String, - preview.get("version") as String, - listOf(translated("oneconfig.page.hotwire.update.same", "This version is already installed.")), - emptyList(), - upToDate = true, + ), ) - else -> JarPlan(jar.fileName.toString(), "", listOf(translated("oneconfig.page.hotwire.unreadable", "Hotwire can't read this jar.")), emptyList()) } + return planFrom("update", updates.joinToString(", ") { it.name }, detail, changePlan(update = updates.map { it.modId })) } - fun reopensToTurnOn(ids: Collection): Boolean = runCatching { - val engine = engine()!! - canReopenToAddBack(engine) && worldRefusal(engine) == null && keptOff(ids).any { - val jar = engine.callMethod("findJarOf", it) as Path - dryRunAdd(engine, jar).addMode() == "SAVE_AND_REOPEN" || reopensOnceMet(engine, jar) - } - }.getOrDefault(false) + /** 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))) - fun switch(ids: Collection, on: Boolean, reopen: Boolean = false): CompletableFuture = runCatching { - val kept = if (on) keptOff(ids) else emptyList() - if (kept.isEmpty()) { - call(if (on) "turnOn" else "turnOff", ids.toList()).asResults() - } else { - CompletableFuture.supplyAsync { turnOnKeptOff(engine()!!, ids, kept, reopen) } - } + fun switch(ids: Collection, on: Boolean): CompletableFuture = runCatching { + call(if (on) "turnOn" else "turnOff", ids.toList()).asResults() }.getOrElse { CompletableFuture.completedFuture(Result(false, "Hotwire is not available.")) } - private fun awaitWorker(engine: Any, jar: Path) { - runCatching { dryRunAdd(engine, jar) } - } - - private fun canReopenToAddBack(engine: Any): Boolean = - engine.javaClass.declaredMethods.any { it.name == "add" && it.parameterCount == 3 } - - private fun addBackWithReopen(engine: Any, id: String, jar: Path): Result { - val added = CompletableFuture() - val mode = engine.enumArg("add", "SAVE_AND_REOPEN") - onUiThread { - engine.callHidden("add", jar, mode, Consumer { outcome -> - added.complete( - runCatching { - if (outcome.javaClass.simpleName != "Added") return@runCatching Result(false, outcome.get("reason") as String) - val enabled = runCatching { - Class.forName("${engine.javaClass.packageName}.DisabledJars") - .getDeclaredMethod("enable", Path::class.java).apply { isAccessible = true }.invoke(null, jar)!! - } - engine.field("installed")!!.callMethod("onBroughtBack", id, enabled.getOrDefault(jar)) - enabled.fold({ Result(true, "") }) { - val name = engine.callMethod("nameOfMod", id) as String - Result(false, translated("oneconfig.page.hotwire.switch.rename_failed", "%s is back on, but it will be off at the next launch: %s", name, failure(it).message)) - } - }.getOrElse(::failure), - ) - }) - } - return added.get(REOPEN_TIMEOUT_MINUTES, TimeUnit.MINUTES) - } - - private fun turnOnKeptOff(engine: Any, ids: Collection, kept: List, reopen: Boolean): Result { - val waiting = kept.associateWithTo(LinkedHashMap()) { engine.callMethod("findJarOf", it) as Path } - refusalOnceMet(engine, waiting.values.toList())?.let { return Result(false, it) } - while (waiting.isNotEmpty()) { - val modes = waiting.entries.map { it to dryRunAdd(engine, it.value).addMode() } - val (entry, mode) = modes.firstOrNull { it.second == "NOW" } - ?: modes.firstOrNull { it.second == "SAVE_AND_REOPEN" } - ?: return Result(false, dryRunAdd(engine, waiting.values.first()).lineOfAdd()) - val (id, jar) = entry - if (mode == "SAVE_AND_REOPEN") { - val name = engine.callMethod("nameOfMod", id) as String - if (!canReopenToAddBack(engine)) { - return Result(false, translated("oneconfig.page.hotwire.switch.reopen_unsupported", "Update Hotwire to turn %s back on from here.", name)) - } - if (!reopen) return reopenNotAgreed(name) - } - val result = if (mode == "NOW") call("turnOn", listOf(id)).asResults().get() else addBackWithReopen(engine, id, jar) - awaitWorker(engine, jar) - val back = onUiThread { - invalidate() - mods().orEmpty().any { it.id == id && it.on } - } - if (!result.ok) return result - if (!back) { - return Result(false, translated("oneconfig.page.hotwire.switch.add_back_failed", "Hotwire couldn't add back %s - please check chat!", engine.callMethod("nameOfMod", id) as String)) - } - waiting.remove(id) - } - val rest = ids - kept.toSet() - return if (rest.isEmpty()) Result(true, "") else call("turnOn", rest).asResults().get() - } - private const val MAX_ICON_BYTES = 8 * 1024 * 1024 private const val ICON_TIMEOUT_MS = 10_000 - private const val REOPEN_TIMEOUT_MINUTES = 15L - - private const val STORE_WORKER_TIMEOUT_SECONDS = 60L - - private const val STORE_REVISION = 5 - private const val MODRINTH_LOADER = "fabric" } 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 index 6edf44044..243fda9c3 100644 --- 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 @@ -25,7 +25,6 @@ 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 @@ -129,32 +128,32 @@ object HotwireUpdates { } /** - * Applies each update in turn with whatever Hotwire allows it, live or at the next launch, since Hotwire's own - * batch update waits on confirmations in its screen + * Applies the updates with whatever Hotwire allows them, live or at the next launch, as one plan so what one brings + * along isn't offered again; Hotwire updates itself apart, since it can't go in a plan */ fun updateAll(updates: List, onDone: () -> Unit) { scope.launch { val problems = ArrayList() try { - for (update in updates) { - updating = update.name + val (self, mods) = updates.partition(HotwireBridge::isSelf) + for (group in self.map { listOf(it) } + listOf(mods).filter { it.isNotEmpty() }) { + val name = group.joinToString(", ") { it.name } + updating = name val plan = withContext(Dispatchers.IO) { - runCatching { HotwireBridge.planUpdate(update) } + runCatching { HotwireBridge.planUpdate(group) } } - // an earlier update can already have brought this one along as a dependency - if (plan.getOrNull()?.upToDate == true) continue if (plan.getOrNull()?.reopens == true) { - problems += "${update.name}: ${translated("oneconfig.page.hotwire.updates.reopen", "It has to save and reopen your world, so update it on its own.")}" + problems += "$name: ${translated("oneconfig.page.hotwire.updates.reopen", "It has to save and reopen your world, so update it on its own.")}" continue } val action = plan.getOrNull()?.actions?.firstOrNull { it.primary } if (action == null) { val reason = plan.exceptionOrNull()?.let { it.cause ?: it }?.message ?: plan.getOrNull()?.lines?.firstOrNull() - problems += "${update.name}: ${reason.orEmpty()}" + problems += "$name: ${reason.orEmpty()}" continue } val failed = action.attempt()?.let { withContext(Dispatchers.IO) { it.get() } }?.takeUnless { it.ok } - if (failed != null) problems += "${update.name}: ${failed.message}" + if (failed != null) problems += "$name: ${failed.message}" } } finally { updating = null @@ -392,19 +391,17 @@ private fun SwitchBar( val turnOff = remember(picked, states) { val members = membersWhere(true) val ids = members.map { it.id } - ids - HotwireBridge.keptOnWhenOff(members.filter { it.parentId !in ids }.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 previews = remember(turnOff, turnOn) { - listOfNotNull( + HotwireBridge.previewTogether(turnOff, turnOn)?.let { listOf(it) } ?: listOfNotNull( turnOff.takeIf { it.isNotEmpty() }?.let { HotwireBridge.preview(it, false) }, - HotwireBridge.previewTogether(turnOff, turnOn) ?: turnOn.takeIf { it.isNotEmpty() }?.let { HotwireBridge.preview(it, true) }, + turnOn.takeIf { it.isNotEmpty() }?.let { HotwireBridge.preview(it, true) }, ) } - val reopens by produceState(null, turnOn) { - value = null - value = withContext(Dispatchers.IO) { HotwireBridge.reopensToTurnOn(turnOn) } - } val pickedIds = picked.values.flatten().mapTo(HashSet()) { it.id } val updates = HotwireUpdates.found?.getOrNull().orEmpty().filter { it.modId in pickedIds } val updating = HotwireUpdates.updating @@ -419,7 +416,6 @@ private fun SwitchBar( val onCount = picked.values.count { familyOn(it, states) == false } val tooMany = offCount + onCount > MaxSwitchedMods val label = when { - reopens == true -> translated("oneconfig.page.hotwire.switch.reopen", "Save and switch") offCount > 0 && onCount > 0 -> "Switch ${offCount + onCount}" offCount > 0 -> "Turn off $offCount" else -> "Turn on $onCount" @@ -431,14 +427,16 @@ private fun SwitchBar( tooMany -> translated("oneconfig.page.hotwire.switch.too_many", "Only %s mods can be switched at once.", MaxSwitchedMods) else -> previews.joinToString(" ") { it.message } } - val canSwitch = !running && !tooMany && reopens != null && previews.isNotEmpty() && previews.all { it.possible } && !HotwireBridge.isSwitching + 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 = HotwireBridge.keptOff(turnOn).isNotEmpty() 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, reopens == true).thenApply { second -> + HotwireBridge.switch(turnOn, true).thenApply { second -> HotwireBridge.Result( second.ok, listOf(first.message, second.message).filter { it.isNotBlank() }.joinToString(" "), @@ -450,6 +448,7 @@ private fun SwitchBar( switching = false notifyHotwireResult(result ?: HotwireBridge.Result(false, error?.message ?: "")) if (result?.ok == true) onClear() + if (bringsBack) HotwireUpdates.refreshWhenDone() } } } 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 index be1b8629a..0d1c8504c 100644 --- 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 @@ -115,32 +115,20 @@ fun HotwireAdd() { Chip(translated("oneconfig.page.hotwire.source.modrinth", "Modrinth"), source == AddSource.Modrinth, "search") { show(AddSource.Modrinth) } Chip(translated("oneconfig.page.hotwire.source.file", "From file"), source == AddSource.File, "folder") { show(AddSource.File) } Spacer(Modifier.weight(1f)) - if (HotwireBridge.supportsStore) { - Chip(translated("oneconfig.page.hotwire.downloads_folder", "Downloads folder"), selected = false, icon = "external-link") { HotwireBridge.openDownloads() } - } + 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(update) } + review(update.name, icon) { HotwireBridge.planUpdate(listOf(update)) } if (reading || plan != null) { PlanCard(reading, plan, planIcon, onBack = ::leavePreview) } else { when (source) { - AddSource.File -> FileSource { jar -> review("", { null }) { jar()?.let { HotwireBridge.planFor(it).get() } } } - AddSource.Modrinth -> if (HotwireBridge.supportsStore) { - 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() } } }, - ) - } else { - Card { - Text( - translated("oneconfig.page.hotwire.upgrade.store", "This version of Hotwire can't browse Modrinth here. Update Hotwire to use it."), - color = LocalTheme.current.textColorSecondary, - fontSize = 14.sp, - ) - } - } + 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) } } } } @@ -269,7 +257,9 @@ private fun ModrinthSource( val updates = HotwireUpdates.found?.getOrNull().orEmpty() val shown = remember(current, updates) { current.getOrThrow().mapNotNull { hit -> - val update = updates.firstOrNull { it.projectId == hit.projectId } + // shortcut: Hotwire's updates don't name their Modrinth project, so an installed hit is paired by name + // and one named differently only offers its update in the Updates tab, until the API gives the project + val update = updates.firstOrNull { hit.installed && it.name == hit.title } // only Hotwire's own answer hides a project, since a mod's name or id can match another project; a // dev build it can't recognise stays listed and Hotwire says it's installed when asked to add it if (update == null && hit.installed) null else hit to update diff --git a/modules/internal/src/test/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireSelfUpdateQueueTest.kt b/modules/internal/src/test/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireSelfUpdateQueueTest.kt deleted file mode 100644 index 6e88a1fcc..000000000 --- a/modules/internal/src/test/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireSelfUpdateQueueTest.kt +++ /dev/null @@ -1,338 +0,0 @@ -package org.polyfrost.oneconfig.internal.ui.api - -import java.nio.file.Files -import java.nio.file.Path -import java.util.Optional -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.io.TempDir - -class HotwireSelfUpdateQueueTest { - class Old(private val mod: String, private val dependency: String, private val wanted: String = "") { - fun mod() = mod - fun dependency() = dependency - fun wanted() = wanted - } - - class Breaks - - class MissingDependency(private val mod: String = "lib") { - fun mod() = mod - } - - class Plan(private val direction: String, private val order: List<*>, private val why: Map<*, *>, private val nested: List<*>, private val refusals: List) { - fun direction() = direction - fun order() = order - fun why() = why - fun enabledNested() = nested - fun refusals() = refusals - } - - class Planner(private val refused: List, private val old: Map>, private val nestedRefused: List = emptyList()) { - @Suppress("UNUSED_PARAMETER") - fun planOn(ids: Collection, off: Set) = - Plan("ON", emptyList(), emptyMap(), emptyList(), if ("hotwire" in ids) refused else nestedRefused) - - fun findOldDependencies(id: String) = old[id].orEmpty() - } - - class Graph(private val dependents: Map>, private val roots: Set = setOf("hotwire", "other"), private val present: Set = emptySet()) { - fun getDependents(id: String) = dependents[id].orEmpty() - fun isInstalledOnItsOwn(id: String) = id in roots - fun find(id: String): Optional = Optional.ofNullable(id.takeIf { it in present }) - } - - private fun refusals(planner: Planner, graph: Graph, builtIns: List = emptyList()) = - (HotwireBridge.refusedPlan(planner, graph, listOf("hotwire"), builtIns) as Plan?)?.refusals() - - @Test - fun aNewVersionThatFitsIsNotRefused() { - val unrelated = Old("other", "lib") - assertNull(refusals(Planner(emptyList(), mapOf("other" to listOf(unrelated))), Graph(mapOf("hotwire" to setOf("other"))))) - } - - @Test - fun aDependencyTooOldForTheNewVersionIsRefused() { - val old = Old("hotwire", "fabric-api") - assertEquals(listOf(old), refusals(Planner(emptyList(), mapOf("hotwire" to listOf(old))), Graph(emptyMap()))) - } - - @Test - fun aModThatNeedsAnOlderVersionRefusesIt() { - val old = Old("other", "hotwire") - assertEquals(listOf(old), refusals(Planner(emptyList(), mapOf("other" to listOf(old))), Graph(mapOf("hotwire" to setOf("other"))))) - } - - @Test - fun aRefusalOfThePlanIsKept() { - assertEquals(listOf("breaks"), refusals(Planner(listOf("breaks"), emptyMap()), Graph(emptyMap()))) - } - - @Test - fun aBuiltInTooOldForTheNewVersionIsReported() { - val old = HotwireBridge.oldBuiltIns( - "hotwire", - mapOf("java" to listOf(">=25"), "minecraft" to listOf("26.3"), "fabric-api" to listOf(">=2")), - mapOf("java" to "21", "minecraft" to "26.3")::get, - ) { version, ranges -> version == ranges.single() } - assertEquals(listOf(listOf("hotwire", "java", ">=25", "21")), old) - } - - @Test - fun aTooOldBuiltInRefusesThePlan() { - assertEquals(listOf("old java"), refusals(Planner(emptyList(), emptyMap()), Graph(emptyMap()), listOf("old java"))) - } - - private fun nestedRefusals(planner: Planner, graph: Graph, vararg versions: String, wants: Map>? = null) = - (HotwireBridge.refusedPlan( - planner, - graph, - listOf("hotwire"), - emptyList(), - mapOf("lib" to versions.toList()), - { version, ranges -> version in ranges }, - { mod, dependency -> - wants?.get(mod)?.let(::listOf) - ?: planner.findOldDependencies(mod).filterIsInstance().filter { it.dependency() == dependency }.map { it.wanted().split(" or ") } - }, - ) as Plan?)?.refusals() - - @Test - fun aModNeedingAnotherVersionOfANestedModRefusesIt() { - val old = Old("other", "lib", "1") - assertEquals(listOf(old), nestedRefusals(Planner(emptyList(), mapOf("other" to listOf(old))), Graph(mapOf("lib" to setOf("other"))), "2")) - } - - @Test - fun aNestedModWithACopyThatFitsIsNotRefused() { - val old = Old("other", "lib", "1 or 3") - assertNull(nestedRefusals(Planner(emptyList(), mapOf("other" to listOf(old))), Graph(mapOf("lib" to setOf("other"))), "2", "3")) - } - - @Test - fun aDependentsOtherOldDependencyIsNotBlamedOnANestedMod() { - val old = Old("other", "unrelated", "1") - assertNull(nestedRefusals(Planner(emptyList(), mapOf("other" to listOf(old))), Graph(mapOf("lib" to setOf("other"))), "2")) - } - - @Test - fun nestedCopiesThatEachSuitOnlyOneDependentRefuseIt() { - val old = Old("b", "lib", "2") - val graph = Graph(mapOf("lib" to setOf("a", "b")), roots = setOf("a", "b")) - assertEquals(listOf(old), nestedRefusals(Planner(emptyList(), mapOf("b" to listOf(old))), graph, "1", "2", wants = mapOf("a" to listOf("1"), "b" to listOf("2")))) - } - - @Test - fun aNestedCopySuitingEveryDependentIsNotRefused() { - val old = Old("b", "lib", "2") - val graph = Graph(mapOf("lib" to setOf("a", "b")), roots = setOf("a", "b")) - assertNull(nestedRefusals(Planner(emptyList(), mapOf("b" to listOf(old))), graph, "1", "2", wants = mapOf("a" to listOf("1", "2"), "b" to listOf("2")))) - } - - @Test - fun anOptionalDependentDoesNotNarrowTheNestedCopies() { - val old = Old("b", "lib", "2") - val graph = Graph(mapOf("lib" to setOf("unused", "b")), roots = setOf("b")) - assertNull(nestedRefusals(Planner(emptyList(), mapOf("b" to listOf(old))), graph, "1", "2", wants = mapOf("unused" to listOf("1"), "b" to listOf("2")))) - } - - @Test - fun aNestedModInAConflictRefusesIt() { - val breaks = Breaks() - assertEquals(listOf(breaks), nestedRefusals(Planner(emptyList(), emptyMap(), listOf(breaks)), Graph(emptyMap()), "2")) - } - - @Test - fun aConflictWithOneOfSeveralNestedVersionsIsNotRefused() { - assertNull(nestedRefusals(Planner(emptyList(), emptyMap(), listOf(Breaks())), Graph(emptyMap()), "2", "3")) - } - - @Test - fun aNestedModsOwnMissingNeedIsNotRefused() { - assertNull(nestedRefusals(Planner(emptyList(), emptyMap(), listOf(MissingDependency())), Graph(emptyMap()), "2")) - } - - @Test - fun aRequiredNestedModsMissingNeedRefusesIt() { - val missing = MissingDependency() - assertEquals(listOf(missing), nestedRefusals(Planner(emptyList(), emptyMap(), listOf(missing)), Graph(mapOf("lib" to setOf("hotwire"))), "2")) - } - - @Test - fun aMissingNeedOfANestedModOnlyAnOptionalModWantsIsNotRefused() { - assertNull(nestedRefusals(Planner(emptyList(), emptyMap(), listOf(MissingDependency())), Graph(mapOf("lib" to setOf("unused"))), "2")) - } - - @Test - fun aRemovedNestedModAStayingModNeedsIsReported() { - val graph = Graph(mapOf("lib" to setOf("other", "unused"), "kept" to setOf("other")), present = setOf("kept")) - assertEquals(listOf("other" to "lib"), HotwireBridge.lostNeeds(graph, listOf("lib", "kept", "lib"))) - } - - class JarManifest(private val id: String, private val provides: List) { - fun id() = id - fun provides() = provides - } - - class Jar(private val manifest: JarManifest, private val nested: List = emptyList()) { - fun manifest() = manifest - fun nested() = nested - } - - @Test - fun anAliasNoLandingModProvidesIsLost() { - val old = Jar(JarManifest("mod", listOf("mod-api")), listOf(Jar(JarManifest("inner", listOf("inner-api"))))) - val graph = Graph(mapOf("mod-api" to setOf("other"), "inner-api" to setOf("other")), present = setOf("mod", "inner", "inner-api")) - - assertEquals(listOf("other" to "mod-api"), HotwireBridge.lostNeeds(graph, HotwireBridge.namesIn(old))) - } - - private val ok = HotwireBridge.Result(true, "") - - @Test - fun aFailedStepPutsTheQueueBack() { - val queue = RollbackQueue(listOf(Queued("earlier"))) - val failed = HotwireBridge.Result(false, "no") - - val result = HotwireBridge.queueTogether(queue, listOf("need"), listOf({ queue.current += Queued("need"); ok }, { failed })) - - assertEquals(failed, result) - assertEquals(listOf(Queued("earlier")), queue.current) - } - - @Test - fun aThrowingStepPutsTheQueueBack() { - val queue = RollbackQueue(emptyList()) - - val result = HotwireBridge.queueTogether(queue, listOf("need"), listOf({ queue.current += Queued("need"); error("broken") })) - - assertEquals("broken", result?.message) - assertEquals(emptyList(), queue.current) - } - - @Test - fun stepsThatAllLandStayQueued() { - val queue = RollbackQueue(emptyList()) - - assertNull(HotwireBridge.queueTogether(queue, listOf("need"), listOf({ queue.current += Queued("need"); ok }))) - assertEquals(listOf(Queued("need")), queue.current) - } - - @Test - fun aFailureKeepsWhatAnotherOperationQueuedMeanwhile() { - val queue = RollbackQueue(emptyList()) - - HotwireBridge.queueTogether(queue, listOf("need"), listOf({ queue.current += Queued("need"); queue.current += Queued("other"); HotwireBridge.Result(false, "no") })) - - assertEquals(listOf(Queued("other")), queue.current) - } - - @Test - fun aFailureRestoresTheEntryItReplaced() { - val queue = RollbackQueue(listOf(Queued("need", "old"))) - - HotwireBridge.queueTogether(queue, listOf("need"), listOf({ queue.current = listOf(Queued("need", "new")); HotwireBridge.Result(false, "no") })) - - assertEquals(listOf(Queued("need", "old")), queue.current) - } - - @Test - fun aFailureRestoresTheStagedJarItOverwrote(@TempDir staging: Path) { - val jar = Files.writeString(staging.resolve("need.jar"), "old") - val queue = RollbackQueue(listOf(Queued("need", jar.toString()))) - - HotwireBridge.queueTogether(queue, listOf("need"), listOf({ Files.writeString(jar, "new"); HotwireBridge.Result(false, "no") })) - - assertEquals("old", Files.readString(jar)) - } - - @Test - fun stepsThatAllLandKeepTheNewStagedJar(@TempDir staging: Path) { - val jar = Files.writeString(staging.resolve("need.jar"), "old") - val queue = RollbackQueue(listOf(Queued("need", jar.toString()))) - - HotwireBridge.queueTogether(queue, listOf("need"), listOf({ Files.writeString(jar, "new"); ok })) - - assertEquals("new", Files.readString(jar)) - } - - @Test - fun aJarNameMayOnlyReplaceTheRunningJar(@TempDir mods: Path, @TempDir staging: Path) { - val running = Files.writeString(mods.resolve("hotwire-1.jar"), "old") - val other = Files.writeString(mods.resolve("other.jar"), "other") - fun incoming(name: String, content: String = "new") = Files.writeString(staging.resolve(name), content) - - assertFalse(HotwireBridge.nameTaken(incoming("hotwire-1.jar"), running, running, emptyList())) - assertFalse(HotwireBridge.nameTaken(incoming("hotwire-1.jar"), running, Path.of("/hotwire/added/hotwire-1.jar"), emptyList())) - assertFalse(HotwireBridge.nameTaken(incoming("hotwire-2.jar"), mods.resolve("hotwire-2.jar"), running, emptyList())) - assertTrue(HotwireBridge.nameTaken(incoming("other.jar"), other, running, emptyList())) - assertTrue(HotwireBridge.nameTaken(incoming("other.jar"), other, null, emptyList())) - assertTrue(HotwireBridge.nameTaken(incoming("hotwire-2.jar"), mods.resolve("hotwire-2.jar"), running, listOf("/staging/hotwire-2.jar"))) - } - - @Test - fun theSameJarIsNotAClash(@TempDir mods: Path, @TempDir staging: Path) { - val present = Files.writeString(mods.resolve("mod.jar"), "bytes") - - assertFalse(HotwireBridge.nameTaken(present, present, null, emptyList())) - assertFalse(HotwireBridge.nameTaken(Files.writeString(staging.resolve("mod.jar"), "bytes"), present, null, emptyList())) - } - - data class Queued(private val id: String, val jar: String = "") { - fun modId() = id - fun stagedJar() = jar - } - - class RollbackQueue(start: List) { - var current = start - - fun getEntries() = current - - @Suppress("unused") - private fun replace(entries: List) { - current = entries - } - } - - data class Entry( - val modId: String, - val name: String, - val fromVersion: String, - val toVersion: String, - val runningJar: String, - val runningSha256: String, - val stagedJar: String, - val stagedSha256: String, - val reason: String, - val queuedAtMillis: Long, - ) - - class Manifest { - fun id() = "hotwire" - fun name() = "Hotwire" - fun version() = "2" - } - - class Queue { - val writes = mutableListOf() - - fun add(entry: Entry) { - writes.add(entry) - } - } - - @Test - fun aSelfUpdateIsQueuedAsAReplacementInOneWrite() { - val queue = Queue() - - HotwireBridge.queueReplacement(queue, Entry::class.java, Manifest(), "1", Path.of("/mods/hotwire-1.jar"), "old", Path.of("/staging/hotwire-2.jar"), "new") - - assertEquals( - listOf(Entry("hotwire", "Hotwire", "1", "2", "hotwire-1.jar", "old", "/staging/hotwire-2.jar", "new", "", 0)), - queue.writes.map { it.copy(queuedAtMillis = 0) }, - ) - } -} From dbacf14d9ee58aef193a0bf831e884b622d1925b Mon Sep 17 00:00:00 2001 From: Tektonikal Date: Tue, 3 Nov 2026 14:46:03 -0500 Subject: [PATCH 27/63] Support Hotwire 0.6.1 API and overlay the switch bar Read kept-off mods from ModState.offSinceStart and match Modrinth results to updates by ModUpdate.projectId, which removes the last reach into Hotwire's engine and the name matching. The page now requires a Hotwire with offSinceStart, since the API revision didn't change with it. Draw the switch bar over the grid instead of shrinking it, with an opaque background, and open the Hotwire page from Hotwire's mod card. --- .../assets/oneconfig/lang/en_us.json | 1 - .../internal/ui/api/HotwireBridge.kt | 53 ++++---------- .../oneconfig/internal/ui/screens/Hotwire.kt | 71 ++++++++++--------- .../internal/ui/screens/HotwireAdd.kt | 4 +- .../oneconfig/internal/ui/screens/Mods.kt | 11 ++- 5 files changed, 59 insertions(+), 81 deletions(-) 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 7b53e4b61..3ab2c5e31 100644 --- a/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json +++ b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json @@ -273,7 +273,6 @@ "oneconfig.page.hotwire.add.sent": "Sent to Hotwire. It posts the result in chat.", "oneconfig.page.hotwire.update.now": "Update now", "oneconfig.page.hotwire.update.next_start": "Update at next launch", - "oneconfig.page.hotwire.switch.adds_back": "Adds back %s.", "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.update.self": "Hotwire can't update itself while the game runs, so it updates at the next launch.", 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 index 69c678f4b..6f481e73a 100644 --- 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 @@ -19,7 +19,7 @@ import org.polyfrost.oneconfig.internal.ui.components.translated * 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) + 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()) @@ -59,8 +59,13 @@ object HotwireBridge { private fun supports(call: String) = apiClass?.methods?.any { it.name == call } == true - /** Whether this Hotwire has the change plans every part of the page goes through */ - val supportsSwitching: Boolean by lazy { supports("planChange") } + /** + * Whether this Hotwire has the change plans every part of the page goes through, and the fields [mods] and + * [findUpdates] read, which arrived without a new API revision + */ + val supportsSwitching: Boolean by lazy { + supports("planChange") && runCatching { Class.forName("dev.moon.hotwire.api.ModState").getMethod("offSinceStart") }.isSuccess + } val supportsOriginalScreen: Boolean by lazy { supports("openScreen") } @@ -73,6 +78,7 @@ object HotwireBridge { it.get("version") as String? ?: "", it.get("on") as Boolean, it.get("switchable") as Boolean, + it.get("offSinceStart") as Boolean, ) } }.getOrNull() @@ -86,54 +92,24 @@ object HotwireBridge { /** How far the running switch is, from 0 to 1 */ fun progress(): Float = runCatching { (call("getProgress")!!.get("fraction") as Double).toFloat() }.getOrDefault(0f) - private fun nameOf(id: String): String = mods().orEmpty().firstOrNull { it.id == id }?.name ?: id - - private fun addsBack(kept: List): String? = kept.takeIf { it.isNotEmpty() }?.let { mods -> - translated("oneconfig.page.hotwire.switch.adds_back", "Adds back %s.", mods.joinToString(", ", transform = ::nameOf)) - } - /** 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 kept = if (on) keptOff(ids) else emptyList() - val preview = (ids - kept.toSet()).takeIf { it.isNotEmpty() }?.let { call(if (on) "previewOn" else "previewOff", it)!! } - Preview( - preview?.get("possible") as Boolean? ?: true, - listOfNotNull(preview?.get("message") as String?, addsBack(kept)).joinToString(" "), - preview?.get("switched") as List? ?: emptyList(), - ) + 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, "Hotwire is not available.") } @Suppress("UNCHECKED_CAST") fun previewTogether(off: List, on: List): Preview? { if (off.isEmpty() || on.isEmpty()) return null return runCatching { - val kept = keptOff(on) - val plan = changePlan(off = off, on = on - kept.toSet()) - Preview( - plan.through("ChangePlan", "canApply") == true, - (plan.through("ChangePlan", "getLines") as List + listOfNotNull(addsBack(kept))).joinToString(" "), - ) + 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.")) } } - // shortcut: Hotwire's API can't tell which mods were kept off since the last launch, and refuses to preview them - // though its turnOn brings them back, so this one question reaches into its engine until the API can answer it - // the engine lives in a package per game version, the same one as the API implementation - private fun engine(): Any? = runCatching { - Class.forName("${api!!.javaClass.packageName}.HotwireClient").getMethod("findEngine").invoke(null) - }.getOrNull() - - private fun Any.callHidden(name: String, vararg args: Any): Any? = - javaClass.declaredMethods.first { it.name == name && it.parameterCount == args.size }.apply { isAccessible = true }.invoke(this, *args) - - fun keptOff(ids: Collection): List = runCatching { - val disabled = engine()!!.callHidden("getDisabled")!! - ids.filter { disabled.callHidden("isDisabledSinceStart", it) == true } - }.getOrDefault(emptyList()) - /** Whether Hotwire is running a switch or add right now */ fun isWorking(): Boolean = runCatching { call("getProgress")!!.get("running") as Boolean }.getOrDefault(false) @@ -243,6 +219,7 @@ object HotwireBridge { class StoreUpdate( val modId: String, + val projectId: String, val name: String, val installedVersion: String, val newVersion: String, @@ -359,7 +336,7 @@ object HotwireBridge { (found.get("failure") as String).takeIf { it.isNotBlank() }?.let { error(it) } val queued = pendingTasks().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("name") as String, it.get("installedVersion") as String, it.get("newVersion") as String) + 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) } } 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 index 243fda9c3..38fbc147e 100644 --- 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 @@ -34,6 +34,7 @@ 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 @@ -272,38 +273,41 @@ fun Hotwire() { selected = if (listLayout) 1 else 0, ) { listLayout = it == 1 } } - ScrollbarBox(gridState, PageGutter, Modifier.weight(1f).fillMaxWidth()) { - 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), - contentPadding = PaddingValues(horizontal = PageGutter), - 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) }, - ) + 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.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) + 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) + } } } } @@ -432,7 +436,7 @@ private fun SwitchBar( 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 = HotwireBridge.keptOff(turnOn).isNotEmpty() + 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) @@ -455,9 +459,12 @@ private fun SwitchBar( Row( modifier = modifier - .background(theme.modCardBackground, theme.modCardShape) + // 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), 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 index 0d1c8504c..0d0426dea 100644 --- 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 @@ -257,9 +257,7 @@ private fun ModrinthSource( val updates = HotwireUpdates.found?.getOrNull().orEmpty() val shown = remember(current, updates) { current.getOrThrow().mapNotNull { hit -> - // shortcut: Hotwire's updates don't name their Modrinth project, so an installed hit is paired by name - // and one named differently only offers its update in the Updates tab, until the API gives the project - val update = updates.firstOrNull { hit.installed && it.name == hit.title } + val update = updates.firstOrNull { it.projectId == hit.projectId } // only Hotwire's own answer hides a project, since a mod's name or id can match another project; a // dev build it can't recognise stays listed and Hotwire says it's installed when asked to add it if (update == null && hit.installed) null else hit to update 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 ec6fae7ca..5e4f79ff7 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 @@ -104,6 +104,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 @@ -399,6 +400,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" -> navigation.navigate(HotwireGraph) onOpen != null -> onOpen() mod.source == ConfigSource.OC -> navigation.navigate(ModConfigRoute(mod.id)) } @@ -479,13 +482,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) From b9ff64e646efd805eb30cc389d7f1e966e3ee001 Mon Sep 17 00:00:00 2001 From: Tektonikal Date: Thu, 5 Nov 2026 01:32:06 -0500 Subject: [PATCH 28/63] fix review issues, improve UI --- .../internal/CompatibilityPlatformImpl.java | 13 +- .../internal/OneConfigMixinInit.java | 5 +- .../internal/compat/ConfigEntrypoints.java | 68 ++++- .../assets/oneconfig/lang/en_us.json | 25 ++ .../compat/ConfigEntrypointsTest.java | 51 ++++ .../oneconfig/api/config/v1/Config.java | 8 +- .../api/config/v1/ConfigManager.java | 36 +-- .../api/config/v1/LateInitializationTest.java | 73 +++++ .../internal/ui/api/ConfigRegistry.kt | 15 +- .../internal/ui/api/HotwireBridge.kt | 87 +++++- .../internal/ui/api/TreeConfigData.kt | 4 +- .../oneconfig/internal/ui/screens/Hotwire.kt | 252 ++++++++++++++---- .../internal/ui/screens/HotwireAdd.kt | 84 ++++-- .../java/dev/moon/hotwire/api/HotwireApi.java | 36 +++ .../dev/moon/hotwire/api/StoreDownload.java | 12 + .../internal/ui/api/HotwireDownloadsTest.java | 97 +++++++ .../internal/ui/api/HotwireInstalledTest.java | 38 +++ 17 files changed, 793 insertions(+), 111 deletions(-) create mode 100644 modules/internal/src/test/java/dev/moon/hotwire/api/HotwireApi.java create mode 100644 modules/internal/src/test/java/dev/moon/hotwire/api/StoreDownload.java create mode 100644 modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/HotwireDownloadsTest.java create mode 100644 modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/HotwireInstalledTest.java 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 8181b9fb8..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,10 @@ 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; @@ -27,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) { @@ -48,7 +55,9 @@ public Set getMods() { FabricLoader loader = FabricLoader.getInstance(); synchronized (loader) { - return loader.getAllMods().stream() + List containers = List.copyOf(loader.getAllMods()); + if (containers.equals(modsFrom)) return mods; + mods = containers.stream() .map(mod -> { var metadata = mod.getMetadata(); return new ModInfo( @@ -69,6 +78,8 @@ public Set getMods() { mod.getContainingMod().map(parent -> parent.getMetadata().getId()).orElseGet(() -> modMenuParent(metadata)) ); }).collect(Collectors.toSet()); + modsFrom = containers; + return mods; } //? } 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 7d7465af5..7aa6cdcbb 100644 --- a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfigMixinInit.java +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfigMixinInit.java @@ -158,9 +158,10 @@ public List getMixins() { //? if skyblock { mixins.add("compat.skyhanni.Mixin_SkyHanniRenderData"); mixins.add("compat.firmament.Mixin_FirmamentHudMeta"); - - mixins.add("compat.Mixin_RegisterAddedConfigs"); //?} + // 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 27f527266..ee072f425 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 @@ -79,8 +79,6 @@ 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<>(); - // failed mods running their entrypoints again after an update - private static final Set retrying = 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(); @@ -89,7 +87,11 @@ private record Skipped(String modId, Runnable extraction) { //? if !neoforge private static volatile Map seenMods = Map.of(); private static volatile Set lateIds = Set.of(); - private static final Map treeOwners = new ConcurrentHashMap<>(); + // mods the loader no longer lists, which Hotwire turned off + private static Set offMods = Set.of(); + // the config ids of each mod as of the last change, as a class no longer resolves to its mod once that mod is off + private static final Map> modConfigs = new HashMap<>(); + 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<>(); @@ -219,6 +221,14 @@ public static void finishRegistration() { } 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 { + rememberConfigOwners(); + } catch (Throwable t) { + LOGGER.error("Failed to note which mods own which configs", t); + } + //?} } } @@ -372,7 +382,6 @@ static int constructorDepth(Class type, StackTraceElement[] frames) { } private static void decide(String modId, boolean broken) { - boolean retried = retrying.remove(modId); List owned = ownedTrees(modId); // an entrypoint that threw only keeps the configs it registered itself, not ones that existed before it ran if (broken) owned.removeIf(tree -> earlierConfigs.contains(tree.getID())); @@ -385,7 +394,7 @@ private static void decide(String modId, boolean broken) { } // CompatLoader only marked the providers it saw when it initialized CompatLoader.INSTANCE.getNativeLoadedConfigs().add(modId); - if (retried) dropExtracted(modId); + if (startupDecided) dropExtracted(modId); LOGGER.info("Using the OneConfig configs registered by {}", modId); return; } @@ -393,11 +402,13 @@ private static void decide(String modId, boolean broken) { fallBackToOriginal(modId); } - private static void dropExtracted(String 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) { + // a native config under this id already took the tree over, but the snapshot would still apply on profile changes + CompatSnapshots.untrack(modId); ConfigManager manager = ConfigManager.active(); if (treeOwners.containsKey(modId) || !manager.exists(modId) || !Boolean.TRUE.equals(manager.get(modId).getMetadata(Backend.UI_ONLY_METADATA))) return; manager.unregister(modId); - CompatSnapshots.untrack(modId); ConfigRegistry.INSTANCE.unregister(modId); } @@ -593,14 +604,18 @@ private static void registerChanged() { Set changedIds = changedIds(mods, seenMods); Set newIds = new HashSet<>(changedIds); newIds.removeAll(seenMods.keySet()); - if (HotwireBridge.INSTANCE.isStarting(newIds) || changedIds.size() > newIds.size() && HotwireBridge.INSTANCE.isUpdating()) return; + 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; 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); - retrying.addAll(retried); Map> added = new LinkedHashMap<>(); for (Provider provider : providers()) { if (!isDecided(provider.modId)) added.computeIfAbsent(provider.modId, k -> new ArrayList<>()).add(provider); @@ -619,6 +634,28 @@ private static void finishLate(Runnable creator) { decidePending(); //? modmenu_compat if (CompatLoader.INSTANCE.hasMod("modmenu")) ModMenuCompat.refresh(); + rememberConfigOwners(); + } + + // 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) -> modConfigs.computeIfAbsent(modId, k -> new HashSet<>()).add(id)); + for (Config config : ConfigManager.createdConfigs()) { + ModInfo owner = CompatLoader.INSTANCE.findModByClass(config.getClass()); + if (owner != null) modConfigs.computeIfAbsent(owner.getId(), k -> new HashSet<>()).add(config.id); + } + } + + // their extracted and Mod Menu cards go by the mod's id + static Set offConfigIds(Set off, Map> configs) { + Set ids = new HashSet<>(); + for (String modId : off) { + ids.add(modId); + ids.add(modId + ".json"); + ids.addAll(configs.getOrDefault(modId, Set.of())); + } + return ids; } private static boolean ownedBy(Config config, Set ids) { @@ -654,6 +691,19 @@ static Set changedIds(Map mods, Map 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); 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 3ab2c5e31..7f93709fd 100644 --- a/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json +++ b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json @@ -234,11 +234,18 @@ "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", @@ -256,6 +263,7 @@ "oneconfig.page.hotwire.updates.none": "Everything is up to date.", "oneconfig.page.hotwire.updates.done": "Updates finished.", "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", @@ -263,6 +271,11 @@ "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.now": "Apply now", "oneconfig.page.hotwire.change.reopen": "Save and apply", "oneconfig.page.hotwire.change.next_start": "Apply at next launch", @@ -275,6 +288,18 @@ "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 version of Hotwire is too old for this page. Update Hotwire to use it.", "oneconfig.page.themes.title": "Themes", 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 d902607ac..e8f4ee984 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,5 +1,6 @@ package org.polyfrost.oneconfig.internal.compat; +import java.lang.reflect.Field; import java.lang.reflect.Proxy; import java.util.List; import java.util.Map; @@ -7,7 +8,9 @@ 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.Tree; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -188,6 +191,54 @@ void addedAndReplacedModsAreChangedIds() { ); } + @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 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 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 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( 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 aad0729f9..1331d35cd 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; @@ -54,9 +55,14 @@ public abstract class Config { protected Tree tree; /** 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; @@ -85,7 +91,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; } 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 a2c81121d..b5e9e080d 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,6 @@ 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; @@ -83,7 +82,9 @@ public final class ConfigManager { private static boolean initialized = false; private static boolean isFirstRun = false; private static final Queue pendingInitialization = new ConcurrentLinkedQueue<>(); - private static final Queue createdAfterStartup = new ConcurrentLinkedQueue<>(); + // weak so configs no late window claims, such as a mod's temporary ones, aren't kept for the whole session + // shortcut: a config its mod keeps no reference to can be collected before its window, holding the ones awaitingLateWindow accepts would close that + private static final Set createdAfterStartup = Collections.synchronizedSet(Collections.newSetFromMap(new WeakHashMap<>())); private static final Map initializedConfigs = new LinkedHashMap<>(); private static final ReentrantLock PROFILE_LIFECYCLE_LOCK = new ReentrantLock(); private static final long PROFILE_OPERATION_BUDGET_NANOS = TimeUnit.SECONDS.toNanos(30L); @@ -233,7 +234,7 @@ private static void initializePending() { 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) { @@ -251,21 +252,32 @@ private static void initializePending() { @ApiStatus.Internal public static boolean initializeLate(Predicate added, Runnable creator) { + boolean building; LATE_INITIALIZING.set(Boolean.TRUE); try { - for (Config config : createdAfterStartup) { - if (isBuilt(config) && added.test(config) && createdAfterStartup.remove(config)) pendingInitialization.add(config); - } + claimBuilt(added); creator.run(); } finally { LATE_INITIALIZING.remove(); + // again, as a worker the creator started can have finished a config by now + building = claimBuilt(added); holdBackDuplicates(); if (!pendingInitialization.isEmpty()) initializePending(); } - for (Config config : createdAfterStartup) { - if (!isBuilt(config) && added.test(config)) return true; + 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 if (createdAfterStartup.remove(config)) pendingInitialization.add(config); } - return false; + return building; } @ApiStatus.Internal @@ -295,12 +307,6 @@ public static void claimForLateInitialization(Config config) { if (LATE_INITIALIZING.get() && createdAfterStartup.remove(config)) pendingInitialization.add(config); } - private static final Set failedInitialization = ConcurrentHashMap.newKeySet(); - - static boolean didInitializationFail(Config config) { - return failedInitialization.contains(config.id); - } - @ApiStatus.Internal public static void submitForInitialization(Config config) { // never initialize synchronously here because this runs from the Config base constructor 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 index 74d27727d..1473f2a65 100644 --- 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 @@ -8,9 +8,11 @@ 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.util.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicBoolean; @@ -59,6 +61,17 @@ protected void onInitialized(Tree tree) { } } + static class FailingConfig extends ValueConfig { + FailingConfig(String id) { + super(id); + } + + @Override + protected Tree makeTree() { + throw new IllegalStateException("this config never builds"); + } + } + private static Thread buildBlocked(String id, CountDownLatch entered, CountDownLatch release) { Thread thread = new Thread(() -> { try { @@ -236,6 +249,66 @@ void configsOtherThreadsFinishedBuildingJoinTheWindow() throws InterruptedExcept 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()); + } + @Test void aSecondInstanceInTheWindowKeepsSavedValues() throws IOException { String id = "late-initialization-duplicate-test.json"; 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..774bacd5d 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,16 @@ 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()) + + fun setOff(ids: Set) { + if (ids == offIds) return + offIds = ids + SearchCorpus.invalidate(ConfigDocumentSource) + revision++ + } + init { ModToggleStore.install() // Index configs as they come in (compat layers etc...) @@ -104,7 +115,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 +123,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 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 index 6f481e73a..0e8229e6d 100644 --- 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 @@ -24,8 +24,11 @@ object HotwireBridge { /** [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 */ - class Result(val ok: Boolean, val message: String, val needsReopen: Boolean = false) + /** + * [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) @@ -51,7 +54,13 @@ object HotwireBridge { private fun call(name: String, vararg args: Any): Any? { val api = api ?: return null - val types = args.map { if (it is Collection<*>) Collection::class.java else it.javaClass }.toTypedArray() + 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() } @@ -59,16 +68,25 @@ object HotwireBridge { private fun supports(call: String) = apiClass?.methods?.any { it.name == call } == true + private fun unavailable() = translated("oneconfig.page.hotwire.unavailable", "Hotwire is not available.") + /** - * Whether this Hotwire has the change plans every part of the page goes through, and the fields [mods] and - * [findUpdates] read, which arrived without a new API revision + * Whether this Hotwire has the change plans every part of the page goes through, the download controls, and the + * fields [mods] and [findUpdates] read, which arrived without a new API revision */ val supportsSwitching: Boolean by lazy { - supports("planChange") && runCatching { Class.forName("dev.moon.hotwire.api.ModState").getMethod("offSinceStart") }.isSuccess + supports("planChange") && supports("stopDownload") && runCatching { Class.forName("dev.moon.hotwire.api.ModState").getMethod("offSinceStart") }.isSuccess } val supportsOriginalScreen: Boolean by lazy { supports("openScreen") } + val supportsReports: Boolean by lazy { supports("writeReport") } + + /** 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 { @@ -97,8 +115,9 @@ object HotwireBridge { 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, "Hotwire is not available.") } + }.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 @@ -110,6 +129,37 @@ object HotwireBridge { } } + private fun runningDownloads(): List = runCatching { + (call("getStoreDownloads") as List<*>).filterNotNull().filter { it.get("over") == false } + }.getOrDefault(emptyList()) + + /** [fraction] goes from 0 to 1, and [paused] says every running download is paused */ + class DownloadProgress(val fraction: Float, val paused: Boolean, 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 are together, or null while none is running */ + fun downloadProgress(): DownloadProgress? = runCatching { + val running = runningDownloads().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.mapTo(HashSet()) { it.get("id") as Int }, + ) + }.getOrNull() + + private fun controlDownloads(action: String) { + runCatching { runningDownloads().forEach { call(action, it.get("id") as Int) } } + } + + fun pauseDownloads(paused: Boolean) = controlDownloads(if (paused) "pauseDownload" else "resumeDownload") + + /** Ends the running downloads, and Hotwire then answers whatever was waiting on them with nothing to add */ + fun stopDownloads() = controlDownloads("stopDownload") + /** Whether Hotwire is running a switch or add right now */ fun isWorking(): Boolean = runCatching { call("getProgress")!!.get("running") as Boolean }.getOrDefault(false) @@ -157,7 +207,7 @@ object HotwireBridge { @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") + Result(kind == "DONE", result.get("message") as String? ?: "", needsReopen = kind == "NEEDS_REOPEN", refused = kind == "REFUSED") } class OpeningPrompt( @@ -217,6 +267,13 @@ object HotwireBridge { 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 + */ + 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, @@ -261,7 +318,7 @@ object HotwireBridge { /** * Searches Modrinth for mods matching [words] in [category], or every category when blank, skipping the first - * [offset] results; blocks on the network, so call it off the UI thread + * [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) @@ -325,7 +382,7 @@ object HotwireBridge { ask("getTitle") as String, "", ask("getLines") as List, - if (canAdd) listOf(now, later, save) else listOf(save), + if (canAdd) listOf(now, later, save) else listOf(later, save), onCancel = { if (!accepted) ask("cancel") }, ) } @@ -356,6 +413,7 @@ object HotwireBridge { fun ask(method: String) = plan.through("ChangePlan", method) val reopens = ask("needsReopen") == true val restartOnly = (ask("getReason") as Enum<*>).name == "NEEDS_RESTART" + val canWait = ask("canWaitForNextStart") == true val nextStart = translated("oneconfig.page.hotwire.$kind.next_start", "Apply at next launch") val now = JarAction( when { @@ -364,17 +422,18 @@ object HotwireBridge { else -> translated("oneconfig.page.hotwire.$kind.now", "Apply now") }, true, - ) { ask("accept").asResults() } + // accepting a plan that only needs a restart would still apply whatever in it can go live + ) { ask(if (restartOnly && canWait) "acceptAtNextStart" else "accept").asResults() } val later = JarAction(nextStart, false) { ask("acceptAtNextStart").asResults() } val actions = when { ask("canApply") != true -> emptyList() - !restartOnly && ask("canWaitForNextStart") == true -> listOf(now, later) + !restartOnly && canWait -> listOf(now, later) else -> listOf(now) } return JarPlan(title, detail, ask("getLines") as List, actions, reopens = reopens) } - fun isSelf(update: StoreUpdate) = update.modId == "hotwire" || update.modId.startsWith("hotwire-") + 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 { @@ -401,7 +460,7 @@ object HotwireBridge { fun switch(ids: Collection, on: Boolean): CompletableFuture = runCatching { call(if (on) "turnOn" else "turnOff", ids.toList()).asResults() - }.getOrElse { CompletableFuture.completedFuture(Result(false, "Hotwire is not available.")) } + }.getOrElse { CompletableFuture.completedFuture(Result(false, unavailable())) } private const val MAX_ICON_BYTES = 8 * 1024 * 1024 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 a73e799e5..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 @@ -80,9 +80,9 @@ class TreeConfigData( val loaded = ModInfo.loadedMods if (loaded !== resolvedFrom) { resolvedFrom = loaded - resolvedModInfo = null + resolvedModInfo = findModInfo() } - return resolvedModInfo ?: findModInfo()?.also { resolvedModInfo = it } + return resolvedModInfo } private fun findModInfo(): ModInfo? { 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 index 38fbc147e..8c0f4ea45 100644 --- 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 @@ -7,6 +7,7 @@ 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 @@ -17,6 +18,8 @@ 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 @@ -25,6 +28,7 @@ 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 @@ -39,13 +43,20 @@ 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 @@ -68,6 +79,7 @@ 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) @@ -128,31 +140,69 @@ object HotwireUpdates { }, "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 + + /** 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 + else -> (plan.exceptionOrNull()?.let { it.cause ?: it }?.message ?: plan.getOrNull()?.lines?.firstOrNull()).orEmpty() + } + /** - * Applies the updates with whatever Hotwire allows them, live or at the next launch, as one plan so what one brings - * along isn't offered again; Hotwire updates itself apart, since it can't go in a plan + * 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 */ - fun updateAll(updates: List, onDone: () -> Unit) { + fun reviewUpdates(updates: List, onDone: () -> Unit) { + planningJob = scope.launch { + planning = true + try { + val (self, mods) = updates.partition(HotwireBridge::isSelf) + val groups = self.map { listOf(it) } + listOf(mods).filter { it.isNotEmpty() } + review = Review( + groups.map { group -> + group.joinToString(", ") { it.name } to withContext(Dispatchers.IO) { runCatching { HotwireBridge.planUpdate(group) } } + }, + onDone, + ) + } finally { + planning = false + } + } + } + + fun dismissReview() { + // planning is what downloads the jars, which nothing would use now + if (planning) HotwireBridge.stopDownloads() + 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() try { - val (self, mods) = updates.partition(HotwireBridge::isSelf) - for (group in self.map { listOf(it) } + listOf(mods).filter { it.isNotEmpty() }) { - val name = group.joinToString(", ") { it.name } + for ((name, plan) in accepted.plans) { updating = name - val plan = withContext(Dispatchers.IO) { - runCatching { HotwireBridge.planUpdate(group) } - } - if (plan.getOrNull()?.reopens == true) { - problems += "$name: ${translated("oneconfig.page.hotwire.updates.reopen", "It has to save and reopen your world, so update it on its own.")}" - continue - } - val action = plan.getOrNull()?.actions?.firstOrNull { it.primary } - if (action == null) { - val reason = plan.exceptionOrNull()?.let { it.cause ?: it }?.message ?: plan.getOrNull()?.lines?.firstOrNull() - problems += "$name: ${reason.orEmpty()}" + val skipped = skipReason(plan) + if (skipped != null) { + problems += "$name: $skipped" continue } + val action = plan.getOrThrow().actions.first { it.primary } val failed = action.attempt()?.let { withContext(Dispatchers.IO) { it.get() } }?.takeUnless { it.ok } if (failed != null) problems += "$name: ${failed.message}" } @@ -162,7 +212,7 @@ object HotwireUpdates { } if (problems.isEmpty()) { Notifications.success("Hotwire", translated("oneconfig.page.hotwire.updates.done", "Updates finished.")) - onDone() + accepted.onDone() } else { Notifications.error("Hotwire", problems.joinToString(" ")) } @@ -183,7 +233,7 @@ 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 */ -private fun gridMods(states: Map?): Collection { +internal fun gridMods(states: Map?): Collection { ModInfo.refreshLoadedMods() val seen = ModInfo.seenMods val seenIds = seen.mapTo(HashSet()) { it.id } @@ -214,9 +264,52 @@ val openHotwireScreen: Runnable? by lazy { 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 { "Done." }) - result.needsReopen -> Notifications.info("Hotwire", result.message.ifBlank { "Reopen your world to finish this." }) - else -> Notifications.error("Hotwire", result.message.ifBlank { "That didn't work." }) + 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 (HotwireBridge.supportsReports && !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)) + } + } } } @@ -247,15 +340,19 @@ fun Hotwire() { 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) { + val mods = remember(families, query, onlyUpdates, availableUpdates) { families .filter { (_, members) -> - query.isEmpty() || members.any { it.name.contains(query, true) || it.id.contains(query, true) } + (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() } @@ -264,7 +361,13 @@ fun Hotwire() { val selected = remember { mutableStateSetOf() } val gridState = rememberRestorableLazyGridState("hotwire") Column(verticalArrangement = Arrangement.spacedBy(19.dp)) { - Row(Modifier.fillMaxWidth().padding(horizontal = PageGutter), horizontalArrangement = Arrangement.End) { + 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")), @@ -309,6 +412,39 @@ fun Hotwire() { ShellController.shared.navigation.navigate(HotwireAddRoute) } } + 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), + ) { + Text(translated("oneconfig.page.hotwire.updates.review", "Review these updates"), color = theme.textColor, fontSize = 18.sp) + 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() } + } } } } @@ -378,6 +514,14 @@ private fun modFamilies(mods: Collection): Map> private fun familyOn(family: List, states: Map?): Boolean? = family.firstNotNullOfOrNull { mod -> states?.get(mod.id)?.takeIf { it.switchable } }?.on +@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( @@ -400,17 +544,27 @@ private fun SwitchBar( if (plan.possible) ids.filter { it in plan.switched } else ids } val turnOn = remember(picked, states) { membersWhere(false).map { it.id } } - val previews = remember(turnOff, turnOn) { - HotwireBridge.previewTogether(turnOff, turnOn)?.let { listOf(it) } ?: listOfNotNull( - turnOff.takeIf { it.isNotEmpty() }?.let { HotwireBridge.preview(it, false) }, - turnOn.takeIf { it.isNotEmpty() }?.let { HotwireBridge.preview(it, true) }, - ) + 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 running = switching || updating != null + val planning = HotwireUpdates.planning + val running = switching || updating != null || planning var fraction by remember { mutableFloatStateOf(0f) } LaunchedEffect(running) { while (running) withFrameNanos { fraction = HotwireBridge.progress() } @@ -420,15 +574,17 @@ private fun SwitchBar( val onCount = picked.values.count { familyOn(it, states) == false } val tooMany = offCount + onCount > MaxSwitchedMods val label = when { - offCount > 0 && onCount > 0 -> "Switch ${offCount + onCount}" - offCount > 0 -> "Turn off $offCount" - else -> "Turn on $onCount" + 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 -> "Hotwire is still starting, so mods can't be switched yet." + 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) - running -> "Switching… ${(fraction * 100).toInt()}%" + planning -> 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 @@ -445,6 +601,7 @@ private fun SwitchBar( second.ok, listOf(first.message, second.message).filter { it.isNotBlank() }.joinToString(" "), second.needsReopen, + second.refused, ) } }.whenComplete { result, error -> @@ -470,19 +627,15 @@ private fun SwitchBar( horizontalArrangement = Arrangement.spacedBy(12.dp), ) { Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text("${picked.size} selected", color = theme.textColor, fontSize = 14.sp) + 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) { - Box(Modifier.fillMaxWidth().height(3.dp).background(theme.borderColor, theme.modCardShape)) { - Box(Modifier.fillMaxWidth(fraction.coerceIn(0f, 1f)).fillMaxHeight().background(Accent, theme.modCardShape)) - } - } + if (running) HotwireProgressBar(fraction) } - Chip("Clear", selected = false, onClick = onClear) + Chip(translated("oneconfig.page.hotwire.switch.clear", "Clear"), selected = false, onClick = onClear) if (!running && updates.isNotEmpty()) { - Chip(translated("oneconfig.page.hotwire.updates.bar", "Update %s", updates.size), selected = true) { HotwireUpdates.updateAll(updates, onClear) } + 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) } @@ -503,7 +656,12 @@ private fun HotwireTile( onSelect: () -> Unit, ) { val theme = LocalTheme.current - val icon = remember(mod.id) { iconPaths.getOrPut(mod.id) { mod.extractIconFile() } } + val icon by produceState(iconPaths[mod.id], mod.id) { + if (mod.id in iconPaths) return@produceState + val path = withContext(Dispatchers.IO) { runCatching { mod.extractIconFile() }.getOrNull() } + iconPaths[mod.id] = path + value = path + } // 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 @@ -532,7 +690,7 @@ private fun HotwireTile( CheckboxIndicator(selected, onToggle = onSelect) // keeps names lined up when a mod has no icon Box(Modifier.size(36.dp)) { - if (icon != null) Icon(icon, color = theme.textColor, modifier = Modifier.fillMaxSize()) + 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) @@ -559,7 +717,7 @@ private fun HotwireTile( verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally, ) { - if (icon != null) Icon(icon, color = theme.textColor, modifier = Modifier.size(56.dp)) + icon?.let { Icon(it, color = theme.textColor, modifier = Modifier.size(56.dp)) } Text( mod.name, color = nameColor, 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 index 0d0426dea..8dd385d1d 100644 --- 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 @@ -9,7 +9,6 @@ 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -51,11 +50,12 @@ 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.Accent import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme private enum class AddSource { File, Modrinth, Updates } +private 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 @@ -63,20 +63,28 @@ private enum class AddSource { File, Modrinth, Updates } @Composable fun HotwireAdd() { val scope = rememberCoroutineScope() - var source by remember { mutableStateOf(AddSource.Updates) } + 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) } + + fun dropPending() { + if (loading) HotwireBridge.stopDownloads() + loading = false + plan?.onCancel?.invoke() + } // leaving the page drops a pending add the same way Back does - DisposableEffect(Unit) { onDispose { plan?.onCancel?.invoke() } } + DisposableEffect(Unit) { onDispose { dropPending() } } fun leavePreview() { request++ reading = false - plan?.onCancel?.invoke() + dropPending() plan = null } @@ -91,11 +99,16 @@ fun HotwireAdd() { 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.downloadProgress() != null) delay(DOWNLOAD_POLL_MILLIS) + if (id != request) return@launch + 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 @@ -111,8 +124,8 @@ fun HotwireAdd() { Column(verticalArrangement = Arrangement.spacedBy(19.dp)) { Row(Modifier.fillMaxWidth().padding(horizontal = PageGutter), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Chip(translated("oneconfig.page.hotwire.source.updates", "Updates"), source == AddSource.Updates, "refresh") { show(AddSource.Updates) } 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() } @@ -186,7 +199,7 @@ private fun FileSource(onPicked: (() -> Path?) -> Unit) { translated("oneconfig.page.hotwire.add.pick", "Choose a mod jar"), null, arrayOf("*.jar"), - "Mod jars", + translated("oneconfig.page.hotwire.add.pick_filter", "Mod jars"), ) } } @@ -255,12 +268,12 @@ private fun ModrinthSource( } else -> { val updates = HotwireUpdates.found?.getOrNull().orEmpty() - val shown = remember(current, updates) { + 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 } - // only Hotwire's own answer hides a project, since a mod's name or id can match another project; a - // dev build it can't recognise stays listed and Hotwire says it's installed when asked to add it - if (update == null && hit.installed) null else hit to update + if (update == null && HotwireBridge.isInstalled(hit, installed)) null else hit to update } } val loaded = current.getOrThrow().size @@ -351,6 +364,45 @@ private fun ResultList(items: List, footer: @Composable () -> Unit = {}, } } +/** The wait for a plan, which for a Modrinth mod is Hotwire downloading it and what it needs */ +@Composable +private fun PlanLoading(onCancel: () -> Unit) { + val theme = LocalTheme.current + var progress by remember { mutableStateOf(null) } + var fraction by remember { mutableFloatStateOf(0f) } + LaunchedEffect(Unit) { + while (true) { + val latest = HotwireBridge.downloadProgress() + fraction = latest?.shownAfter(progress, fraction) ?: 0f + progress = latest + delay(DOWNLOAD_POLL_MILLIS) + } + } + val current = progress + 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)) { + if (current != null) { + 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) } + } + 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?, onBack: () -> Unit) { @@ -370,7 +422,7 @@ private fun PlanCard(reading: Boolean, plan: HotwireBridge.JarPlan?, icon: Strin Card { if (reading || plan == null) { - Text(translated("oneconfig.page.hotwire.add.reading", "Reading the jar…"), color = theme.textColorSecondary, fontSize = 14.sp) + PlanLoading(onCancel = onBack) return@Card } Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) { @@ -394,11 +446,7 @@ private fun PlanCard(reading: Boolean, plan: HotwireBridge.JarPlan?, icon: Strin color = theme.textColorSecondary, fontSize = 13.sp, ) - if (working) { - Box(Modifier.fillMaxWidth().height(3.dp).background(theme.borderColor, theme.modCardShape)) { - Box(Modifier.fillMaxWidth(fraction.coerceIn(0f, 1f)).fillMaxHeight().background(Accent, theme.modCardShape)) - } - } + if (working) HotwireProgressBar(fraction) } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { if (!started) { @@ -410,7 +458,7 @@ private fun PlanCard(reading: Boolean, plan: HotwireBridge.JarPlan?, icon: Strin awaiting = pending != null pending?.whenComplete { result, error -> Platform.screen().runOnUiThread { - val done = result ?: HotwireBridge.Result(false, (error?.cause ?: error)?.message ?: "That didn't work.") + 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) 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..40e642aa1 --- /dev/null +++ b/modules/internal/src/test/java/dev/moon/hotwire/api/HotwireApi.java @@ -0,0 +1,36 @@ +package dev.moon.hotwire.api; + +import java.util.ArrayList; +import java.util.List; +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 HotwireApi getInstance() { + return INSTANCE; + } + + public void addListener(Consumer listener) { + } + + public List getStoreDownloads() { + return DOWNLOADS; + } + + 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/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/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..2c1a2866c --- /dev/null +++ b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/HotwireDownloadsTest.java @@ -0,0 +1,97 @@ +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 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()); + 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); + } +} 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))); + } +} From 7ea8e318051c7262335e4467098f09d1e46022c4 Mon Sep 17 00:00:00 2001 From: Tektonikal Date: Thu, 5 Nov 2026 19:06:37 -0500 Subject: [PATCH 29/63] more tests and improvements for hotwire --- .../assets/oneconfig/lang/en_us.json | 8 +- .../assets/oneconfig/open-classes.txt | 173 ++++++++++++++++ .../internal/ui/api/HotwireBridge.kt | 103 ++++++---- .../oneconfig/internal/ui/screens/Hotwire.kt | 184 ++++++++++++------ .../internal/ui/screens/HotwireAdd.kt | 42 ++-- .../oneconfig/internal/ui/screens/Mods.kt | 3 +- .../java/dev/moon/hotwire/api/ChangePlan.java | 27 +++ .../dev/moon/hotwire/api/ChangeRequest.java | 7 + .../java/dev/moon/hotwire/api/HotwireApi.java | 20 ++ .../java/dev/moon/hotwire/api/ModUpdate.java | 4 + .../java/dev/moon/hotwire/api/ModUpdates.java | 6 + .../dev/moon/hotwire/api/PendingTask.java | 10 + .../dev/moon/hotwire/api/SwitchResult.java | 7 + .../internal/ui/TestI18nPlatform.java | 16 ++ .../ui/api/HotwireAppliedUpdatesTest.java | 42 ++++ .../internal/ui/api/HotwireBatchPlanTest.java | 90 +++++++++ .../internal/ui/api/HotwireDownloadsTest.java | 13 ++ .../ui/api/HotwireFindUpdatesTest.java | 39 ++++ ...ost.oneconfig.api.platform.v1.I18nPlatform | 1 + 19 files changed, 674 insertions(+), 121 deletions(-) create mode 100644 modules/internal/src/test/java/dev/moon/hotwire/api/ChangePlan.java create mode 100644 modules/internal/src/test/java/dev/moon/hotwire/api/ChangeRequest.java create mode 100644 modules/internal/src/test/java/dev/moon/hotwire/api/ModUpdate.java create mode 100644 modules/internal/src/test/java/dev/moon/hotwire/api/ModUpdates.java create mode 100644 modules/internal/src/test/java/dev/moon/hotwire/api/PendingTask.java create mode 100644 modules/internal/src/test/java/dev/moon/hotwire/api/SwitchResult.java create mode 100644 modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/TestI18nPlatform.java create mode 100644 modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/HotwireAppliedUpdatesTest.java create mode 100644 modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/HotwireBatchPlanTest.java create mode 100644 modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/HotwireFindUpdatesTest.java create mode 100644 modules/internal/src/test/resources/META-INF/services/org.polyfrost.oneconfig.api.platform.v1.I18nPlatform 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 7f93709fd..9def383cc 100644 --- a/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json +++ b/minecraft/src/main/resources/assets/oneconfig/lang/en_us.json @@ -254,6 +254,7 @@ "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", @@ -262,6 +263,9 @@ "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", @@ -276,6 +280,7 @@ "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", @@ -284,6 +289,7 @@ "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.", @@ -301,7 +307,7 @@ "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 version of Hotwire is too old for this page. Update Hotwire to use it.", + "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/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 index 0e8229e6d..d1f1a0eb2 100644 --- 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 @@ -6,6 +6,7 @@ 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 @@ -66,21 +67,10 @@ object HotwireBridge { private fun Any.get(accessor: String): Any? = javaClass.getMethod(accessor).invoke(this) - private fun supports(call: String) = apiClass?.methods?.any { it.name == call } == true - private fun unavailable() = translated("oneconfig.page.hotwire.unavailable", "Hotwire is not available.") - /** - * Whether this Hotwire has the change plans every part of the page goes through, the download controls, and the - * fields [mods] and [findUpdates] read, which arrived without a new API revision - */ - val supportsSwitching: Boolean by lazy { - supports("planChange") && supports("stopDownload") && runCatching { Class.forName("dev.moon.hotwire.api.ModState").getMethod("offSinceStart") }.isSuccess - } - - val supportsOriginalScreen: Boolean by lazy { supports("openScreen") } - - val supportsReports: Boolean by lazy { supports("writeReport") } + /** 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") @@ -105,6 +95,23 @@ object HotwireBridge { private fun pendingTasks(): List<*> = call("getPendingTasks") as? List<*> ?: emptyList() + /** The name of each mod waiting for the next launch, by id */ + fun queuedMods(): Map = runCatching { + pendingTasks().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 live = modsAfter.filter { it.id in versions && versions[it.id] != it.version } + return AppliedUpdates(live.map { it.name }, (queuedAfter - queuedBefore.keys - live.map { it.id }.toSet()).values.toList()) + } + val isSwitching: Boolean get() = call("isSwitching") as? Boolean ?: false /** How far the running switch is, from 0 to 1 */ @@ -129,36 +136,46 @@ object HotwireBridge { } } - private fun runningDownloads(): List = runCatching { - (call("getStoreDownloads") as List<*>).filterNotNull().filter { it.get("over") == false } + /** + * 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()) - /** [fraction] goes from 0 to 1, and [paused] says every running download is paused */ - class DownloadProgress(val fraction: Float, val paused: Boolean, private val ids: Set) { + 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 are together, or null while none is running */ - fun downloadProgress(): DownloadProgress? = runCatching { - val running = runningDownloads().ifEmpty { return null } + /** 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() - private fun controlDownloads(action: String) { - runCatching { runningDownloads().forEach { call(action, it.get("id") as Int) } } + private fun controlDownloads(action: String, others: Set) { + runCatching { runningDownloads(others).forEach { call(action, it.get("id") as Int) } } } - fun pauseDownloads(paused: Boolean) = controlDownloads(if (paused) "pauseDownload" else "resumeDownload") + @JvmOverloads + fun pauseDownloads(paused: Boolean, others: Set = emptySet()) = controlDownloads(if (paused) "pauseDownload" else "resumeDownload", others) - /** Ends the running downloads, and Hotwire then answers whatever was waiting on them with nothing to add */ - fun stopDownloads() = controlDownloads("stopDownload") + /** Ends the running downloads but [others], 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) @@ -269,7 +286,8 @@ object HotwireBridge { /** * 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 + * 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) } @@ -295,7 +313,15 @@ object HotwireBridge { connectTimeout = ICON_TIMEOUT_MS readTimeout = ICON_TIMEOUT_MS } - Files.write(file, connection.getInputStream().use { it.readNBytes(MAX_ICON_BYTES) }) + 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 } @@ -391,7 +417,8 @@ object HotwireBridge { fun findUpdates(): List { val found = (call("checkUpdates") as CompletableFuture<*>).get()!! (found.get("failure") as String).takeIf { it.isNotBlank() }?.let { error(it) } - val queued = pendingTasks().map { it!!.get("modId") } + // only a queued update means the update is taken care of, not a mod turning off at the next launch + val queued = pendingTasks().filter { (it!!.get("kind") as Enum<*>).name == "UPDATE" }.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) } @@ -407,27 +434,29 @@ object HotwireBridge { return (call("planChange", request) as CompletableFuture<*>).get()!! } - /** The choices a change plan offers, labelled from the [kind] of change: "add", "update" or "change" */ + /** + * 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 canWait = ask("canWaitForNextStart") == true - val nextStart = translated("oneconfig.page.hotwire.$kind.next_start", "Apply at next launch") val now = JarAction( when { reopens -> translated("oneconfig.page.hotwire.$kind.reopen", "Save and apply") - restartOnly -> nextStart + restartOnly -> translated("oneconfig.page.hotwire.$kind.as_planned", "Apply") else -> translated("oneconfig.page.hotwire.$kind.now", "Apply now") }, true, - // accepting a plan that only needs a restart would still apply whatever in it can go live - ) { ask(if (restartOnly && canWait) "acceptAtNextStart" else "accept").asResults() } - val later = JarAction(nextStart, false) { ask("acceptAtNextStart").asResults() } + ) { 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() - !restartOnly && canWait -> listOf(now, later) + ask("canWaitForNextStart") == true -> listOf(now, later) else -> listOf(now) } return JarPlan(title, detail, ask("getLines") as List, actions, reopens = reopens) @@ -467,4 +496,6 @@ object HotwireBridge { 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/screens/Hotwire.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Hotwire.kt index 8c0f4ea45..d98137f66 100644 --- 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 @@ -152,27 +152,37 @@ object HotwireUpdates { 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 - else -> (plan.exceptionOrNull()?.let { it.cause ?: it }?.message ?: plan.getOrNull()?.lines?.firstOrNull()).orEmpty() + // 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 groups = self.map { listOf(it) } + listOf(mods).filter { it.isNotEmpty() } + val together = mods.takeIf { it.isNotEmpty() }?.let { plan(it) } + val refused = mods.size > 1 && together!!.second.isSuccess && skipReason(together.second) != null review = Review( - groups.map { group -> - group.joinToString(", ") { it.name } to withContext(Dispatchers.IO) { runCatching { HotwireBridge.planUpdate(group) } } - }, + self.map { plan(listOf(it)) } + if (refused) mods.map { plan(listOf(it)) } else listOfNotNull(together), onDone, ) } finally { @@ -183,7 +193,7 @@ object HotwireUpdates { fun dismissReview() { // planning is what downloads the jars, which nothing would use now - if (planning) HotwireBridge.stopDownloads() + if (planning) HotwireBridge.stopDownloads(otherDownloads) planningJob?.cancel() review = null } @@ -194,6 +204,9 @@ object HotwireUpdates { 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 @@ -203,17 +216,27 @@ object HotwireUpdates { continue } val action = plan.getOrThrow().actions.first { it.primary } - val failed = action.attempt()?.let { withContext(Dispatchers.IO) { it.get() } }?.takeUnless { it.ok } + 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", translated("oneconfig.page.hotwire.updates.done", "Updates finished.")) + 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(" ")) } } @@ -259,7 +282,7 @@ fun hotwireTagline(): String { private val SwitchBarHeight = 56.dp -val openHotwireScreen: Runnable? by lazy { Runnable { HotwireBridge.openScreen() }.takeIf { HotwireBridge.supportsOriginalScreen } } +val openHotwireScreen = Runnable { HotwireBridge.openScreen() } /** Reports what Hotwire says came of a change */ internal fun notifyHotwireResult(result: HotwireBridge.Result) { @@ -273,7 +296,7 @@ internal fun notifyHotwireResult(result: HotwireBridge.Result) { 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 (HotwireBridge.supportsReports && !result.refused) { + if (!result.refused) { persistent() action(NotificationAction(translated("oneconfig.page.hotwire.report.copy", "Copy bug report"), true) { copyHotwireReport(message) }) } @@ -322,7 +345,7 @@ fun Hotwire() { if (!HotwireBridge.supportsSwitching) { Box(Modifier.fillMaxSize().padding(horizontal = PageGutter), contentAlignment = Alignment.Center) { Text( - translated("oneconfig.page.hotwire.upgrade.page", "This version of Hotwire is too old for this page. Update Hotwire to use it."), + 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, @@ -360,60 +383,63 @@ fun Hotwire() { // picked families by their root id val selected = remember { mutableStateSetOf() } val gridState = rememberRestorableLazyGridState("hotwire") - Column(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) }, - ) - } - } - } + Box(Modifier.fillMaxSize()) { + Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(19.dp)) { 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), + Modifier.fillMaxWidth().padding(horizontal = PageGutter), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - 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) + 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) + } } } - HotwireUpdates.review?.let { UpdateReviewCard(it) } } + // over the whole page rather than the grid, so it's centred on the page + HotwireUpdates.review?.let { UpdateReviewCard(it) } } } @@ -429,8 +455,9 @@ private fun UpdateReviewCard(review: HotwireUpdates.Review) { .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) + 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) @@ -514,6 +541,29 @@ private fun modFamilies(mods: Collection): Map> 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 @@ -569,6 +619,8 @@ private fun SwitchBar( 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 } @@ -581,7 +633,9 @@ private fun SwitchBar( 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) - planning -> translated("oneconfig.page.hotwire.switch.checking", "Checking what this changes…") + // 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…") @@ -631,9 +685,13 @@ private fun SwitchBar( if (message.isNotBlank()) { Text(message, color = theme.textColorSecondary, fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) } - if (running) HotwireProgressBar(fraction) + 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() } - Chip(translated("oneconfig.page.hotwire.switch.clear", "Clear"), selected = false, onClick = onClear) if (!running && updates.isNotEmpty()) { Chip(translated("oneconfig.page.hotwire.updates.bar", "Update %s", updates.size), selected = true) { HotwireUpdates.reviewUpdates(updates, onClear) } } 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 index 8dd385d1d..c503e8937 100644 --- 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 @@ -54,7 +54,7 @@ import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme private enum class AddSource { File, Modrinth, Updates } -private const val DOWNLOAD_POLL_MILLIS = 100L +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 @@ -71,9 +71,15 @@ fun HotwireAdd() { 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) HotwireBridge.stopDownloads() + if (loading) { + stopped = HotwireBridge.runningDownloadIds() - others + HotwireBridge.stopDownloads(others) + } loading = false plan?.onCancel?.invoke() } @@ -100,8 +106,9 @@ fun HotwireAdd() { } 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.downloadProgress() != null) delay(DOWNLOAD_POLL_MILLIS) + 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) { @@ -134,7 +141,7 @@ fun HotwireAdd() { review(update.name, icon) { HotwireBridge.planUpdate(listOf(update)) } if (reading || plan != null) { - PlanCard(reading, plan, planIcon, onBack = ::leavePreview) + PlanCard(reading, plan, planIcon, others, onBack = ::leavePreview) } else { when (source) { AddSource.File -> FileSource { jar -> review("", { null }) { jar()?.let(HotwireBridge::planFor) } } @@ -273,7 +280,7 @@ private fun ModrinthSource( val shown = remember(current, updates, installed) { current.getOrThrow().mapNotNull { hit -> val update = updates.firstOrNull { it.projectId == hit.projectId } - if (update == null && HotwireBridge.isInstalled(hit, installed)) null else hit to update + if (update == null && hit.installed) null else hit to update } } val loaded = current.getOrThrow().size @@ -300,6 +307,9 @@ private fun ModrinthSource( 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) } } } @@ -366,19 +376,11 @@ private fun ResultList(items: List, footer: @Composable () -> Unit = {}, /** The wait for a plan, which for a Modrinth mod is Hotwire downloading it and what it needs */ @Composable -private fun PlanLoading(onCancel: () -> Unit) { +private fun PlanLoading(others: Set, onCancel: () -> Unit) { val theme = LocalTheme.current - var progress by remember { mutableStateOf(null) } - var fraction by remember { mutableFloatStateOf(0f) } - LaunchedEffect(Unit) { - while (true) { - val latest = HotwireBridge.downloadProgress() - fraction = latest?.shownAfter(progress, fraction) ?: 0f - progress = latest - delay(DOWNLOAD_POLL_MILLIS) - } - } - val current = progress + val download = rememberDownloadWatch(others = others) + val current = download.progress + val fraction = download.fraction val percent = (fraction * 100).toInt() Text( when { @@ -397,7 +399,7 @@ private fun PlanLoading(onCancel: () -> Unit) { } else { translated("oneconfig.page.hotwire.add.pause", "Pause") } - Chip(label, selected = false) { HotwireBridge.pauseDownloads(!current.paused) } + Chip(label, selected = false) { HotwireBridge.pauseDownloads(!current.paused, others) } } Chip(translated("oneconfig.page.hotwire.add.cancel", "Cancel"), selected = false, onClick = onCancel) } @@ -405,7 +407,7 @@ private fun PlanLoading(onCancel: () -> Unit) { /** 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?, onBack: () -> Unit) { +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 @@ -422,7 +424,7 @@ private fun PlanCard(reading: Boolean, plan: HotwireBridge.JarPlan?, icon: Strin Card { if (reading || plan == null) { - PlanLoading(onCancel = onBack) + PlanLoading(otherDownloads, onCancel = onBack) return@Card } Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.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 5e4f79ff7..e6d65a29c 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 @@ -78,6 +78,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 @@ -401,7 +402,7 @@ 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" -> navigation.navigate(HotwireGraph) + mod.id == "hotwire" && HotwireBridge.supportsSwitching -> navigation.navigate(HotwireGraph) onOpen != null -> onOpen() mod.source == ConfigSource.OC -> navigation.navigate(ModConfigRoute(mod.id)) } 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 index 40e642aa1..e56a698f7 100644 --- a/modules/internal/src/test/java/dev/moon/hotwire/api/HotwireApi.java +++ b/modules/internal/src/test/java/dev/moon/hotwire/api/HotwireApi.java @@ -2,6 +2,7 @@ import java.util.ArrayList; import java.util.List; +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 */ @@ -10,14 +11,33 @@ public final class 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 HotwireApi getInstance() { return INSTANCE; } + 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; } 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/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/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/HotwireAppliedUpdatesTest.java b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/HotwireAppliedUpdatesTest.java new file mode 100644 index 000000000..6e92261ce --- /dev/null +++ b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/HotwireAppliedUpdatesTest.java @@ -0,0 +1,42 @@ +package org.polyfrost.oneconfig.internal.ui.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.Map; +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")); + + @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 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 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 index 2c1a2866c..2091a77d4 100644 --- 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 @@ -8,6 +8,7 @@ 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; @@ -46,6 +47,7 @@ void progressCoversEveryRunningJar() { 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()); } @@ -94,4 +96,15 @@ void controlsReachEachRunningDownloadOnly() { HotwireBridge.INSTANCE.stopDownloads(); assertEquals(List.of("pause 2", "pause 3", "resume 2", "resume 3", "stop 2", "stop 3"), HotwireApi.CONTROLS); } + + @Test + void downloadsRunningBeforeAPlanAreNotItsToShowOrStop() { + 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()); + HotwireBridge.INSTANCE.stopDownloads(others); + HotwireBridge.INSTANCE.pauseDownloads(true, others); + assertEquals(List.of("stop 2", "pause 2"), 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/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 From ac1c1b0c000a381a7b1e5d72a5b270c8e25af4e0 Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 6 Nov 2026 01:33:27 +0100 Subject: [PATCH 30/63] fix: stop offering to queue store adds Hotwire refused --- .../internal/ui/api/HotwireBridge.kt | 3 +- .../java/dev/moon/hotwire/api/HotwireApi.java | 10 +++ .../java/dev/moon/hotwire/api/StoreAdd.java | 18 ++++++ .../dev/moon/hotwire/api/StoreProject.java | 8 +++ .../java/dev/moon/hotwire/api/StoreQuery.java | 7 +++ .../internal/ui/api/HotwireStorePlanTest.java | 63 +++++++++++++++++++ 6 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 modules/internal/src/test/java/dev/moon/hotwire/api/StoreAdd.java create mode 100644 modules/internal/src/test/java/dev/moon/hotwire/api/StoreProject.java create mode 100644 modules/internal/src/test/java/dev/moon/hotwire/api/StoreQuery.java create mode 100644 modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/HotwireStorePlanTest.java 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 index d1f1a0eb2..5612b7e00 100644 --- 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 @@ -408,7 +408,8 @@ object HotwireBridge { ask("getTitle") as String, "", ask("getLines") as List, - if (canAdd) listOf(now, later, save) else listOf(later, save), + // 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") }, ) } 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 index e56a698f7..e697dfd37 100644 --- a/modules/internal/src/test/java/dev/moon/hotwire/api/HotwireApi.java +++ b/modules/internal/src/test/java/dev/moon/hotwire/api/HotwireApi.java @@ -2,6 +2,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; @@ -14,11 +15,20 @@ public final class HotwireApi { 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; } 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/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/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)); + } +} From 917811c0e78827a5222a90ebccbdbe25a0ae0b8a Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 6 Nov 2026 01:34:46 +0100 Subject: [PATCH 31/63] fix: report updates queued for mods with other pending tasks --- .../internal/ui/api/HotwireBridge.kt | 10 ++++--- .../ui/api/HotwireAppliedUpdatesTest.java | 27 +++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) 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 index 5612b7e00..ef9d7f70a 100644 --- 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 @@ -95,9 +95,12 @@ object HotwireBridge { private fun pendingTasks(): List<*> = call("getPendingTasks") as? List<*> ?: emptyList() - /** The name of each mod waiting for the next launch, by id */ + // 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 { - pendingTasks().associate { it!!.get("modId") as String to it.get("name") as String } + queuedUpdates().associate { it!!.get("modId") as String to it.get("name") as String } }.getOrDefault(emptyMap()) class AppliedUpdates(val now: List, val nextLaunch: List) @@ -418,8 +421,7 @@ object HotwireBridge { fun findUpdates(): List { val found = (call("checkUpdates") as CompletableFuture<*>).get()!! (found.get("failure") as String).takeIf { it.isNotBlank() }?.let { error(it) } - // only a queued update means the update is taken care of, not a mod turning off at the next launch - val queued = pendingTasks().filter { (it!!.get("kind") as Enum<*>).name == "UPDATE" }.map { it!!.get("modId") } + 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) } 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 index 6e92261ce..d469891db 100644 --- 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 @@ -2,8 +2,11 @@ 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 { @@ -13,6 +16,11 @@ private static HotwireBridge.ModState mod(String id, String version) { 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")); @@ -34,6 +42,25 @@ void whatWasAlreadyQueuedIsNotThisBatch() { 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()); From c114e8a6835395c0305ce2d33e7e09ccbf24653a Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 6 Nov 2026 01:36:44 +0100 Subject: [PATCH 32/63] fix: keep late configs alive until their window claims them --- .../api/config/v1/ConfigManager.java | 20 ++++++++++--- .../api/config/v1/LateInitializationTest.java | 29 +++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) 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 b5e9e080d..259b77014 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 @@ -83,8 +83,9 @@ public final class ConfigManager { private static boolean isFirstRun = false; 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 - // shortcut: a config its mod keeps no reference to can be collected before its window, holding the ones awaitingLateWindow accepts would close that 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<>()); private static final Map initializedConfigs = new LinkedHashMap<>(); private static final ReentrantLock PROFILE_LIFECYCLE_LOCK = new ReentrantLock(); private static final long PROFILE_OPERATION_BUDGET_NANOS = TimeUnit.SECONDS.toNanos(30L); @@ -275,11 +276,16 @@ private static boolean claimBuilt(Predicate added) { for (Config config : waiting) { if (!added.test(config)) continue; if (!isBuilt(config)) building = true; - else if (createdAfterStartup.remove(config)) pendingInitialization.add(config); + else claim(config); } return building; } + private static void claim(Config config) { + heldForLateWindow.remove(config); + if (createdAfterStartup.remove(config)) pendingInitialization.add(config); + } + @ApiStatus.Internal public static void setAwaitingLateWindow(Predicate awaiting) { awaitingLateWindow = awaiting; @@ -304,14 +310,19 @@ private static synchronized void holdBackDuplicates() { @ApiStatus.Internal public static void claimForLateInitialization(Config config) { - if (LATE_INITIALIZING.get() && createdAfterStartup.remove(config)) pendingInitialization.add(config); + if (LATE_INITIALIZING.get()) claim(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 - (initialized && !LATE_INITIALIZING.get() ? createdAfterStartup : pendingInitialization).add(config); + if (initialized && !LATE_INITIALIZING.get()) { + createdAfterStartup.add(config); + if (awaitingLateWindow.test(config)) heldForLateWindow.add(config); + } else { + pendingInitialization.add(config); + } for (ConfigSubmissionListener listener : submissionListeners) { try { listener.onConfigSubmitted(config); @@ -338,6 +349,7 @@ public static boolean isRebindingProfiles() { static void removePendingInitialization(Config config) { pendingInitialization.remove(config); createdAfterStartup.remove(config); + heldForLateWindow.remove(config); } /** 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 index 1473f2a65..4deec6c67 100644 --- 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 @@ -309,6 +309,35 @@ void configsNoWindowClaimsAreReleased() throws InterruptedException { 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 aSecondInstanceInTheWindowKeepsSavedValues() throws IOException { String id = "late-initialization-duplicate-test.json"; From 3d0db03c142321a2977c658f6e2c5a5aa0aaa07f Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 6 Nov 2026 01:39:22 +0100 Subject: [PATCH 33/63] fix: run config entrypoints an update adds to a decided mod --- .../internal/compat/ConfigEntrypoints.java | 23 +++++++++++++++++-- .../compat/ConfigEntrypointsTest.java | 13 +++++++++++ .../api/config/v1/ConfigEntrypoint.java | 2 +- 3 files changed, 35 insertions(+), 3 deletions(-) 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 ee072f425..a3e94ec0f 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 @@ -79,6 +79,8 @@ 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(); @@ -267,6 +269,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 { @@ -425,6 +428,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); @@ -618,13 +633,17 @@ private static void registerChanged() { failed.removeAll(retried); Map> added = new LinkedHashMap<>(); for (Provider provider : providers()) { - if (!isDecided(provider.modId)) added.computeIfAbsent(provider.modId, k -> new ArrayList<>()).add(provider); + if (needsRunning(provider)) added.computeIfAbsent(provider.modId, k -> new ArrayList<>()).add(provider); } prepare(added.keySet()); Set late = new HashSet<>(lateIds); late.addAll(changedIds); lateIds = late; - finishLate(() -> added.forEach((modId, entrypoints) -> undecided.put(modId, register(modId, entrypoints)))); + 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) { 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 e8f4ee984..792bde76d 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 @@ -207,6 +207,19 @@ void aModStaysOffUntilTheLoaderListsItAgain() { 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( 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 f7e4ffac6..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,7 @@ public interface ConfigEntrypoint { String ENTRYPOINT = "oneconfig:config"; /** - * Called once after every mod has initialized, or after your mod has if it was added while the game runs. + * 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)}. From e8fedf7234ab87ee804dfa5f2693ad8b9033fa08 Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 6 Nov 2026 01:42:28 +0100 Subject: [PATCH 34/63] fix: let an updated mod's entrypoint replace its config instance Temporary copies and other mods' instances are still held back, and a replacement that fails restores the older instance. --- .../api/config/v1/ConfigManager.java | 41 ++++++++-- .../api/config/v1/LateInitializationTest.java | 75 +++++++++++++++++++ .../internal/ui/api/ConfigRegistry.kt | 2 + .../ui/keybind/MinecraftKeybindRegistrar.kt | 24 +++++- .../ui/api/ConfigRegistryKeybindsTest.java | 54 +++++++++++++ 5 files changed, 185 insertions(+), 11 deletions(-) create mode 100644 modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/ConfigRegistryKeybindsTest.java 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 259b77014..9951687ca 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 @@ -86,6 +86,8 @@ public final class ConfigManager { 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 + private static final Set fromWindowCreator = Collections.newSetFromMap(new IdentityHashMap<>()); private static final Map initializedConfigs = new LinkedHashMap<>(); private static final ReentrantLock PROFILE_LIFECYCLE_LOCK = new ReentrantLock(); private static final long PROFILE_OPERATION_BUDGET_NANOS = TimeUnit.SECONDS.toNanos(30L); @@ -262,8 +264,13 @@ public static boolean initializeLate(Predicate added, Runnable creator) LATE_INITIALIZING.remove(); // again, as a worker the creator started can have finished a config by now building = claimBuilt(added); - holdBackDuplicates(); + Map replaced = holdBackDuplicates(added); + fromWindowCreator.clear(); 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()); + }); } return building; } @@ -281,9 +288,9 @@ private static boolean claimBuilt(Predicate added) { return building; } - private static void claim(Config config) { + private static boolean claim(Config config) { heldForLateWindow.remove(config); - if (createdAfterStartup.remove(config)) pendingInitialization.add(config); + return createdAfterStartup.remove(config) && pendingInitialization.add(config); } @ApiStatus.Internal @@ -301,16 +308,35 @@ private static boolean isBuilt(Config config) { return true; } - private static synchronized void holdBackDuplicates() { - Set ids = new HashSet<>(initializedConfigs.keySet()); + /** + * 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<>(); for (Config config : pendingInitialization) { - if (!ids.add(config.id) && pendingInitialization.remove(config)) createdAfterStartup.add(config); + Config old = initializedConfigs.get(config.id); + boolean replaces = old != null && fromWindowCreator.contains(config) && added.test(config) && added.test(old); + if ((old == null || replaces) && ids.add(config.id)) { + if (replaces) replaced.put(config, old); + } else if (pendingInitialization.remove(config)) { + createdAfterStartup.add(config); + } + } + ConfigManager manager = active(); + for (Config replacement : replaced.keySet()) { + // written first as the replacement loads its values from the file + if (manager.exists(replacement.id)) manager.save(replacement.id); + manager.unregister(replacement.id); } + return replaced; } @ApiStatus.Internal public static void claimForLateInitialization(Config config) { - if (LATE_INITIALIZING.get()) claim(config); + if (LATE_INITIALIZING.get() && claim(config)) fromWindowCreator.add(config); } @ApiStatus.Internal @@ -322,6 +348,7 @@ public static void submitForInitialization(Config config) { if (awaitingLateWindow.test(config)) heldForLateWindow.add(config); } else { pendingInitialization.add(config); + if (LATE_INITIALIZING.get()) fromWindowCreator.add(config); } for (ConfigSubmissionListener listener : submissionListeners) { try { 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 index 4deec6c67..d419549b9 100644 --- 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 @@ -338,6 +338,81 @@ void configsAWindowIsComingForNeedNoOtherReference() throws IOException, Interru } } + @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 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 aSecondInstanceInTheWindowKeepsSavedValues() throws IOException { String id = "late-initialization-duplicate-test.json"; 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 774bacd5d..18fb43e3e 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 @@ -151,6 +151,8 @@ object ConfigRegistry { onOpen: (() -> Unit)? = null, bumpRevision: Boolean = true ): Boolean { + // a config replaced after its mod updated leaves binds behind that would fire alongside the new ones + tree.id?.let(::findTree)?.takeIf { it !== tree }?.let { MinecraftKeybindRegistrar.unregister(it, kept = tree) } MinecraftKeybindRegistrar.scan(tree, force = bumpRevision) if (tree.id == null || tree.title == null) return false if (tree.getMetadata("hidden") != null) return false 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..a3f9417b3 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 @@ -30,7 +30,23 @@ object MinecraftKeybindRegistrar { scannedTrees[tree] = true 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() + (mirroredBinds(tree) - keeping).forEach { KeybindManager.unregister(it) } + } + + 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 +65,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/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..15c2a206d --- /dev/null +++ b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/ConfigRegistryKeybindsTest.java @@ -0,0 +1,54 @@ +package org.polyfrost.oneconfig.internal.ui.api; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +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; + +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() { + ConfigRegistry.INSTANCE.unregister(ID); + } + + @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 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); + } +} From 83ae468a25a9d026f864f204fa8acf989f566e67 Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 6 Nov 2026 01:43:17 +0100 Subject: [PATCH 35/63] fix: suspend config keybinds while their mod is turned off --- .../internal/ui/api/ConfigRegistry.kt | 10 ++++++- .../ui/api/ConfigRegistryKeybindsTest.java | 28 ++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) 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 18fb43e3e..5978ff66e 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 @@ -78,9 +78,17 @@ object ConfigRegistry { /** 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 + // 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) + } SearchCorpus.invalidate(ConfigDocumentSource) revision++ } @@ -153,7 +161,7 @@ object ConfigRegistry { ): Boolean { // a config replaced after its mod updated leaves binds behind that would fire alongside the new ones tree.id?.let(::findTree)?.takeIf { it !== tree }?.let { MinecraftKeybindRegistrar.unregister(it, kept = tree) } - 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/test/java/org/polyfrost/oneconfig/internal/ui/api/ConfigRegistryKeybindsTest.java b/modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/ConfigRegistryKeybindsTest.java index 15c2a206d..a711d03ab 100644 --- 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 @@ -3,8 +3,12 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.io.IOException; +import java.nio.file.Files; +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; @@ -25,8 +29,30 @@ private static Tree tree(OneConfigKeybind bind) { } @AfterEach - void clear() { + 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 From 877f3931c2af9ffea56cc5f356afeedf88311886 Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 6 Nov 2026 01:47:26 +0100 Subject: [PATCH 36/63] fix: skip profile rebinds for configs of turned off mods Rebuilding one runs its mod's code, so it catches up when the mod is back. --- .../api/config/v1/ConfigManager.java | 43 +++++++++++++++++-- .../api/config/v1/LateInitializationTest.java | 43 +++++++++++++++++++ .../internal/ui/api/ConfigRegistry.kt | 3 ++ 3 files changed, 85 insertions(+), 4 deletions(-) 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 9951687ca..cde5ac94e 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 @@ -89,6 +89,9 @@ public final class ConfigManager { // what the running window's creator made or was handed, the only configs that can replace an older instance private static final Set fromWindowCreator = Collections.newSetFromMap(new IdentityHashMap<>()); private static final Map initializedConfigs = new LinkedHashMap<>(); + private static Set offConfigIds = Set.of(); + // the profile switches a config of a turned off mod sat out, with whether any of them restored its defaults + private static final Map rebindsWhenBack = 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); @@ -1335,12 +1338,44 @@ 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.merge(config, restoreDefaults, Boolean::logicalOr); + else rebind(config, restoreDefaults); + } + } + + 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; + boolean caughtUp = false; + boolean wasRebinding = REBINDING_PROFILES.get(); + REBINDING_PROFILES.set(Boolean.TRUE); + try { + for (Config config : new ArrayList<>(rebindsWhenBack.keySet())) { + if (ids.contains(config.id)) continue; + boolean restoreDefaults = rebindsWhenBack.remove(config); + if (initializedConfigs.get(config.id) != config) continue; + rebind(config, restoreDefaults); + caughtUp = true; } + } 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/LateInitializationTest.java b/modules/config-impl/src/test/java/org/polyfrost/oneconfig/api/config/v1/LateInitializationTest.java index d419549b9..a2aacdba7 100644 --- 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 @@ -14,8 +14,10 @@ import java.nio.file.Files; 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; @@ -338,6 +340,47 @@ void configsAWindowIsComingForNeedNoOtherReference() throws IOException, Interru } } + @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 anEntrypointOfTheUpdatedModReplacesItsOlderInstance() throws IOException { String id = "late-initialization-replaced-test.json"; 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 5978ff66e..534c3a48c 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 @@ -83,12 +83,15 @@ object ConfigRegistry { 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++ } From 8e5fac86f0e6ed86f0bfe6ea0752d04ca9d43778 Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 6 Nov 2026 02:19:45 +0100 Subject: [PATCH 37/63] fix: keep the older config when saving it for a replacement fails --- .../api/config/v1/ConfigManager.java | 12 ++++----- .../api/config/v1/LateInitializationTest.java | 25 +++++++++++++++++++ 2 files changed, 30 insertions(+), 7 deletions(-) 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 cde5ac94e..a9f2d028a 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 @@ -319,21 +319,19 @@ private static boolean isBuilt(Config config) { 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); + 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); } } - ConfigManager manager = active(); - for (Config replacement : replaced.keySet()) { - // written first as the replacement loads its values from the file - if (manager.exists(replacement.id)) manager.save(replacement.id); - manager.unregister(replacement.id); - } + for (Config replacement : replaced.keySet()) manager.unregister(replacement.id); return replaced; } 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 index a2aacdba7..ac1d878d2 100644 --- 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 @@ -12,6 +12,7 @@ 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; @@ -456,6 +457,30 @@ void aReplacementThatFailsLeavesTheOlderInstanceRegistered() throws IOException } } + @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"; From ce04a5ba0dbc9ed9fbe3eb6515a7d98aeb7f2cdd Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 6 Nov 2026 02:21:15 +0100 Subject: [PATCH 38/63] fix: restore the tracked config after a failed replacement --- .../api/config/v1/ConfigManager.java | 5 ++- .../api/config/v1/LateInitializationTest.java | 32 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) 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 a9f2d028a..57aeda387 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 @@ -272,7 +272,10 @@ public static boolean initializeLate(Predicate added, Runnable creator) 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()); + if (replacement.getTree() == null && old.getTree() != null) { + active().register(old.getTree()); + markInitialized(old); + } }); } return building; 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 index ac1d878d2..772687e58 100644 --- 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 @@ -75,6 +75,18 @@ protected Tree makeTree() { } } + 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 { @@ -457,6 +469,26 @@ void aReplacementThatFailsLeavesTheOlderInstanceRegistered() throws IOException } } + @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 aFailedSaveKeepsTheOlderInstanceAndItsUnsavedValues() throws IOException { String id = "late-initialization-unsaved-test.json"; From 305f70f6870bec939c291b96cc1b37ddd558f5b1 Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 6 Nov 2026 02:23:16 +0100 Subject: [PATCH 39/63] fix: honor explicit ownership when replacing library configs --- .../internal/compat/ConfigEntrypoints.java | 14 ++++++-- .../compat/ConfigEntrypointsTest.java | 36 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) 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 a3e94ec0f..8092d8e9d 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,6 +40,7 @@ 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; @@ -647,8 +648,7 @@ private static void registerChanged() { } private static void finishLate(Runnable creator) { - Set ids = lateIds; - if (ConfigManager.initializeLate(config -> ownedBy(config, ids), creator)) return; + if (ConfigManager.initializeLate(lateConfigs(lateIds), creator)) return; lateIds = Set.of(); decidePending(); //? modmenu_compat @@ -682,6 +682,16 @@ private static boolean ownedBy(Config config, Set ids) { return owner != null && ids.contains(owner.getId()); } + // an entrypoint can register a library's config class as its mod's own, so the id's owner counts as well as the class's + // 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 -> { + String owner = owners.get(config.id); + return owner != null && ids.contains(owner) || ownedBy(config, ids); + }; + } + private static boolean awaitsLateWindow(Config config) { Set ids = new HashSet<>(lateIds); if (modsChanged()) ids.addAll(changedIds(loadedMods(), seenMods)); 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 792bde76d..7b1ba2f42 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 @@ -5,11 +5,13 @@ 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; @@ -265,6 +267,40 @@ void onlyReplacedModsThatFailedRunAgain() { ); } + 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 aContainerReplacedUnderTheSameIdIsAChange() { assertTrue(ConfigEntrypoints.modsChanged(List.of(mod("lib")), Map.of("lib", mod("lib")))); From 5b9d5884a38f35162c132bb7a3ce9ddedf50ded1 Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 6 Nov 2026 02:26:13 +0100 Subject: [PATCH 40/63] fix: refresh the icon of a mod turned on after starting off --- .../oneconfig/internal/ui/screens/Hotwire.kt | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) 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 index d98137f66..404abcc37 100644 --- 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 @@ -249,8 +249,11 @@ object HotwireUpdates { } } -/** Extracted icon path per mod id kept across scrolling so each jar is only read once */ -private val iconPaths = HashMap() +/** + * 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 @@ -714,11 +717,9 @@ private fun HotwireTile( onSelect: () -> Unit, ) { val theme = LocalTheme.current - val icon by produceState(iconPaths[mod.id], mod.id) { - if (mod.id in iconPaths) return@produceState - val path = withContext(Dispatchers.IO) { runCatching { mod.extractIconFile() }.getOrNull() } - iconPaths[mod.id] = path - value = path + 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 From 0f3d4c1d7db142e7a275e0e9b132e482807a55d9 Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 6 Nov 2026 02:28:03 +0100 Subject: [PATCH 41/63] fix: leave unrelated downloads alone when controlling a plan's Hotwire's download list doesn't say who started what, so the next jar of something already running looks like the plan's own, and stopping it deletes its partial file. A plan begun beside other downloads now can't pause or stop any, and its discarded jars just finish. --- .../oneconfig/internal/ui/api/HotwireBridge.kt | 7 +++++-- .../oneconfig/internal/ui/screens/HotwireAdd.kt | 3 ++- .../internal/ui/api/HotwireDownloadsTest.java | 13 +++++++++++-- 3 files changed, 18 insertions(+), 5 deletions(-) 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 index ef9d7f70a..fed5cae43 100644 --- 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 @@ -169,14 +169,17 @@ object HotwireBridge { ) }.getOrNull() + // shortcut: a plan begun beside [others] can't pause or stop anything, as Hotwire doesn't say who started a download + // and stopping the next jar of one of those would delete its partial file; narrow this if its API ever names an owner private fun controlDownloads(action: String, others: Set) { - runCatching { runningDownloads(others).forEach { call(action, it.get("id") as Int) } } + 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 but [others], and Hotwire then answers whatever was waiting on them with nothing to add */ + /** 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) 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 index c503e8937..8fdccdc70 100644 --- 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 @@ -393,7 +393,8 @@ private fun PlanLoading(others: Set, onCancel: () -> Unit) { ) if (current != null) HotwireProgressBar(fraction) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - if (current != null) { + // 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 { 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 index 2091a77d4..c9a785ef0 100644 --- 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 @@ -98,13 +98,22 @@ void controlsReachEachRunningDownloadOnly() { } @Test - void downloadsRunningBeforeAPlanAreNotItsToShowOrStop() { + 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("stop 2", "pause 2"), HotwireApi.CONTROLS); + assertEquals(List.of(), HotwireApi.CONTROLS); } } From 3b0c72fdc4e223f74e519671bcefae97dcac338c Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 6 Nov 2026 03:05:11 +0100 Subject: [PATCH 42/63] fix: stop a late config's preload overwriting saved values --- .../oneconfig/api/config/v1/Config.java | 1 + .../api/config/v1/ConfigManager.java | 12 ++++ .../api/config/v1/LateInitializationTest.java | 72 +++++++++++++++++++ 3 files changed, 85 insertions(+) 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 1331d35cd..bfa4917da 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 @@ -179,6 +179,7 @@ protected void onInitialized(Tree tree) { } final void runInitialize(boolean byConfigManager) { + if (!byConfigManager && ConfigManager.waitsForLateWindow(this)) return; boolean was = initializing; initializing = true; try { 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 57aeda387..9dc1913ee 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 @@ -411,6 +411,18 @@ static boolean isPendingInitialization(Config config) { return pendingInitialization.contains(config) || createdAfterStartup.contains(config) && awaitingLateWindow.test(config); } + /** + * Whether a config initializing itself has to leave that 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 + */ + static boolean waitsForLateWindow(Config config) { + Config old; + synchronized (ConfigManager.class) { + old = initializedConfigs.get(config.id); + } + return initialized && old != null && old != config && isPendingInitialization(config); + } + static void reportResetOptions(Config config, List options) { Tree tree = config.getTree(); if (tree == null || tree.getID() == null) return; 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 index 772687e58..faf8ad2bd 100644 --- 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 @@ -415,6 +415,78 @@ void anEntrypointOfTheUpdatedModReplacesItsOlderInstance() throws IOException { } } + @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 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"; From 66360b1da27612d7ef3405a5ea2d7f6919d004ee Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 6 Nov 2026 03:06:03 +0100 Subject: [PATCH 43/63] fix: start profiles created while a mod is off from its defaults --- .../api/config/v1/ConfigManager.java | 27 +++++++--- .../api/config/v1/LateInitializationTest.java | 51 +++++++++++++++++++ 2 files changed, 71 insertions(+), 7 deletions(-) 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 9dc1913ee..ee54dcafb 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 @@ -90,8 +90,8 @@ public final class ConfigManager { private static final Set fromWindowCreator = Collections.newSetFromMap(new IdentityHashMap<>()); private static final Map initializedConfigs = new LinkedHashMap<>(); private static Set offConfigIds = Set.of(); - // the profile switches a config of a turned off mod sat out, with whether any of them restored its defaults - private static final Map rebindsWhenBack = new IdentityHashMap<>(); + // the configs of turned off mods that sat out a profile switch + private static final Set rebindsWhenBack = 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); @@ -1351,8 +1351,21 @@ private static void rebindInitializedConfigs(boolean restoreDefaults) { if (initializedConfigs.isEmpty()) return; ArrayList configs = new ArrayList<>(initializedConfigs.values()); for (Config config : configs) { - if (offConfigIds.contains(config.id)) rebindsWhenBack.merge(config, restoreDefaults, Boolean::logicalOr); - else rebind(config, restoreDefaults); + 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); } } @@ -1377,11 +1390,11 @@ public static synchronized boolean setOff(Set ids) { boolean wasRebinding = REBINDING_PROFILES.get(); REBINDING_PROFILES.set(Boolean.TRUE); try { - for (Config config : new ArrayList<>(rebindsWhenBack.keySet())) { + for (Config config : new ArrayList<>(rebindsWhenBack)) { if (ids.contains(config.id)) continue; - boolean restoreDefaults = rebindsWhenBack.remove(config); + rebindsWhenBack.remove(config); if (initializedConfigs.get(config.id) != config) continue; - rebind(config, restoreDefaults); + rebind(config, false); caughtUp = true; } } finally { 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 index faf8ad2bd..6079eef77 100644 --- 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 @@ -394,6 +394,57 @@ protected void onInitialized(Tree tree) { } } + @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 anEntrypointOfTheUpdatedModReplacesItsOlderInstance() throws IOException { String id = "late-initialization-replaced-test.json"; From 87ae3295afa0f59e8c2dee745c0904d9b6a4b438 Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 6 Nov 2026 03:07:40 +0100 Subject: [PATCH 44/63] fix: keep the older defaults backup when a replacement fails --- .../api/config/v1/ConfigManager.java | 27 ++++++++++ .../api/config/v1/LateInitializationTest.java | 50 +++++++++++++++++++ 2 files changed, 77 insertions(+) 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 ee54dcafb..0f6fe6fc6 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 @@ -269,12 +269,19 @@ public static boolean initializeLate(Predicate added, Runnable creator) building = claimBuilt(added); Map replaced = holdBackDuplicates(added); fromWindowCreator.clear(); + // a replacement writes its own over these before it can fail + Map oldDefaults = new HashMap<>(); + for (Config old : replaced.values()) { + oldDefaults.put(old.id, readDefaultsBackup(old.id)); + 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); + writeDefaultsBackup(old.id, oldDefaults.get(old.id)); } }); } @@ -338,6 +345,26 @@ private static synchronized Map holdBackDuplicates(Predicate 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 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"; From 335ca0b257053c6913310a6002094b6880bb42cb Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 6 Nov 2026 03:09:39 +0100 Subject: [PATCH 45/63] fix: let a config's explicit owner decide over its class's mod --- .../internal/compat/ConfigEntrypoints.java | 18 +++++++++--------- .../internal/compat/ConfigEntrypointsTest.java | 7 +++++++ 2 files changed, 16 insertions(+), 9 deletions(-) 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 8092d8e9d..3a136edf7 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 @@ -677,25 +677,25 @@ static Set offConfigIds(Set off, Map> config return ids; } - private static boolean ownedBy(Config config, Set ids) { - ModInfo owner = CompatLoader.INSTANCE.findModByClass(config.getClass()); - return owner != null && ids.contains(owner.getId()); - } - - // an entrypoint can register a library's config class as its mod's own, so the id's owner counts as well as the class's // 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 -> { - String owner = owners.get(config.id); - return owner != null && ids.contains(owner) || ownedBy(config, ids); + ModInfo classOwner = CompatLoader.INSTANCE.findModByClass(config.getClass()); + return isLate(owners.get(config.id), classOwner == null ? null : classOwner.getId(), ids); }; } + // an entrypoint can register a library's config class as its mod's own, so the id's owner goes before the class's + static boolean isLate(@Nullable String owner, @Nullable String classOwner, Set ids) { + String decider = owner != null ? owner : classOwner; + return decider != null && ids.contains(decider); + } + private static boolean awaitsLateWindow(Config config) { Set ids = new HashSet<>(lateIds); if (modsChanged()) ids.addAll(changedIds(loadedMods(), seenMods)); - return !ids.isEmpty() && ownedBy(config, ids); + return !ids.isEmpty() && lateConfigs(ids).test(config); } private static boolean modsChanged() { 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 7b1ba2f42..8f38dc925 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 @@ -301,6 +301,13 @@ void claimingAnIdInTheWindowDoesNotTakeAConfigFromAnotherMod() { } } + @Test + void aLibraryChangingLeavesAConfigAnotherModRegisteredAlone() { + assertFalse(ConfigEntrypoints.isLate("mod", "library", Set.of("library"))); + assertTrue(ConfigEntrypoints.isLate("mod", "library", Set.of("mod"))); + assertTrue(ConfigEntrypoints.isLate(null, "library", Set.of("library"))); + } + @Test void aContainerReplacedUnderTheSameIdIsAChange() { assertTrue(ConfigEntrypoints.modsChanged(List.of(mod("lib")), Map.of("lib", mod("lib")))); From 2fc9524411385fc7fb4ba6aca4c6a485104e012d Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 6 Nov 2026 03:11:14 +0100 Subject: [PATCH 46/63] fix: keep a config with its mod when an update removes its class --- .../internal/compat/ConfigEntrypoints.java | 12 +++++++----- .../internal/compat/ConfigEntrypointsTest.java | 16 +++++++++++++--- 2 files changed, 20 insertions(+), 8 deletions(-) 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 3a136edf7..2fb2b7529 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 @@ -682,14 +682,16 @@ static Predicate lateConfigs(Set ids) { Map owners = new HashMap<>(treeOwners); return config -> { ModInfo classOwner = CompatLoader.INSTANCE.findModByClass(config.getClass()); - return isLate(owners.get(config.id), classOwner == null ? null : classOwner.getId(), ids); + 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 - static boolean isLate(@Nullable String owner, @Nullable String classOwner, Set ids) { - String decider = owner != null ? owner : classOwner; - return decider != null && ids.contains(decider); + // 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) { 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 8f38dc925..9172beb0e 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 @@ -303,9 +303,19 @@ void claimingAnIdInTheWindowDoesNotTakeAConfigFromAnotherMod() { @Test void aLibraryChangingLeavesAConfigAnotherModRegisteredAlone() { - assertFalse(ConfigEntrypoints.isLate("mod", "library", Set.of("library"))); - assertTrue(ConfigEntrypoints.isLate("mod", "library", Set.of("mod"))); - assertTrue(ConfigEntrypoints.isLate(null, "library", Set.of("library"))); + 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 From bf1c716827c9207808285227e253b9826065a6fd Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 6 Nov 2026 03:14:08 +0100 Subject: [PATCH 47/63] fix: note the owners of configs created after startup --- .../internal/compat/ConfigEntrypoints.java | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) 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 2fb2b7529..9760811db 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 @@ -92,8 +92,8 @@ private record Skipped(String modId, Runnable extraction) { 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 as of the last change, as a class no longer resolves to its mod once that mod is off - private static final Map> modConfigs = new HashMap<>(); + // 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 + private 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(); @@ -227,6 +227,8 @@ public static void finishRegistration() { //? 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); @@ -624,6 +626,8 @@ private static void registerChanged() { // 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); @@ -659,11 +663,17 @@ private static void finishLate(Runnable creator) { // 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) -> modConfigs.computeIfAbsent(modId, k -> new HashSet<>()).add(id)); - for (Config config : ConfigManager.createdConfigs()) { - ModInfo owner = CompatLoader.INSTANCE.findModByClass(config.getClass()); - if (owner != null) modConfigs.computeIfAbsent(owner.getId(), k -> new HashSet<>()).add(config.id); - } + 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); + } + + private static void rememberConfigOwner(String modId, String id) { + modConfigs.computeIfAbsent(modId, k -> ConcurrentHashMap.newKeySet()).add(id); } // their extracted and Mod Menu cards go by the mod's id From ea42d1f2ad769f2ccd00728e8bdf9b65bc6c9a7a Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 6 Nov 2026 03:15:50 +0100 Subject: [PATCH 48/63] fix: unregister the keybinds a scan actually registered --- .../ui/keybind/MinecraftKeybindRegistrar.kt | 9 +++++++- .../ui/api/ConfigRegistryKeybindsTest.java | 22 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) 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 a3f9417b3..5edca8922 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,7 @@ package org.polyfrost.oneconfig.internal.ui.keybind +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 @@ -38,7 +40,12 @@ object MinecraftKeybindRegistrar { @JvmOverloads fun unregister(tree: Tree, kept: Tree? = null) { val keeping = kept?.let(::mirroredBinds).orEmpty() - (mirroredBinds(tree) - keeping).forEach { KeybindManager.unregister(it) } + 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 { 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 index a711d03ab..50095f44c 100644 --- 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 @@ -5,6 +5,8 @@ import java.io.IOException; 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; @@ -13,6 +15,7 @@ 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"; @@ -55,6 +58,25 @@ void aKeybindStopsWhileItsModIsOffAndReturnsWithIt() { 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)); + } + @Test void aReplacedConfigTakesItsKeybindsWithIt() { OneConfigKeybind old = bind(); From 6da975ae18b51c057a526e8f1dd20cc49e4241dc Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 6 Nov 2026 03:15:50 +0100 Subject: [PATCH 49/63] docs: note the limit of a plan's download controls --- .../org/polyfrost/oneconfig/internal/ui/api/HotwireBridge.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 index fed5cae43..8f497b529 100644 --- 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 @@ -169,8 +169,9 @@ object HotwireBridge { ) }.getOrNull() - // shortcut: a plan begun beside [others] can't pause or stop anything, as Hotwire doesn't say who started a download - // and stopping the next jar of one of those would delete its partial file; narrow this if its API ever names an owner + // 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) } } From 27a217d360785239800743bfd9aed4498fcf29e3 Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 6 Nov 2026 03:16:04 +0100 Subject: [PATCH 50/63] docs: update changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22954016f..b4f33446b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,2 +1,8 @@ ## Unreleased changes - 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 +- Added a Hotwire page, available with Hotwire 0.7.3 or newer, for turning mods on and off, updating them and adding new ones from Modrinth or a jar while the game runs +- Configs, keybinds and mod cards now follow the mods Hotwire adds, updates or turns off while the game runs + +### 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 while the game runs From 698a2b034c9c81fdbe67e810d4580f628573aa50 Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 6 Nov 2026 03:54:07 +0100 Subject: [PATCH 51/63] fix: keep a defaults backup that could not be read --- .../api/config/v1/ConfigManager.java | 18 +++++++-------- .../api/config/v1/LateInitializationTest.java | 22 +++++++++++++++++++ 2 files changed, 31 insertions(+), 9 deletions(-) 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 0f6fe6fc6..becb729b8 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 @@ -272,7 +272,11 @@ public static boolean initializeLate(Predicate added, Runnable creator) // a replacement writes its own over these before it can fail Map oldDefaults = new HashMap<>(); for (Config old : replaced.values()) { - oldDefaults.put(old.id, readDefaultsBackup(old.id)); + 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(); @@ -281,7 +285,8 @@ public static boolean initializeLate(Predicate added, Runnable creator) if (replacement.getTree() == null && old.getTree() != null) { active().register(old.getTree()); markInitialized(old); - writeDefaultsBackup(old.id, oldDefaults.get(old.id)); + // 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)); } }); } @@ -345,14 +350,9 @@ private static synchronized Map holdBackDuplicates(Predicate 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"; From 9fa77fb887b403f676ecda02d034fafbcb09bf12 Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 6 Nov 2026 03:55:12 +0100 Subject: [PATCH 52/63] fix: hold back duplicate configs made while Hotwire starts a mod Hotwire reopens the startup queue to run an added mod's entrypoints, so a copy of a registered config used to merge into it and overwrite its saved values. It now waits for the late window that follows. --- .../api/config/v1/ConfigManager.java | 14 ++-- .../api/config/v1/LateInitializationTest.java | 68 +++++++++++++++++++ 2 files changed, 78 insertions(+), 4 deletions(-) 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 becb729b8..8bfea1313 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 @@ -80,6 +80,8 @@ 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 ConcurrentLinkedQueue<>(); // weak so configs no late window claims, such as a mod's temporary ones, aren't kept for the whole session @@ -87,7 +89,8 @@ public final class ConfigManager { // 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 - private static final Set fromWindowCreator = Collections.newSetFromMap(new IdentityHashMap<>()); + // 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 final Map initializedConfigs = new LinkedHashMap<>(); private static Set offConfigIds = Set.of(); // the configs of turned off mods that sat out a profile switch @@ -228,6 +231,8 @@ public static void initialize() { return; } initialized = true; + if (startedUp) holdBackDuplicates(config -> false); + startedUp = true; initializePending(); } @@ -379,7 +384,7 @@ public static void submitForInitialization(Config config) { if (awaitingLateWindow.test(config)) heldForLateWindow.add(config); } else { pendingInitialization.add(config); - if (LATE_INITIALIZING.get()) fromWindowCreator.add(config); + if (startedUp || LATE_INITIALIZING.get()) fromWindowCreator.add(config); } for (ConfigSubmissionListener listener : submissionListeners) { try { @@ -435,7 +440,8 @@ public static synchronized List createdConfigs() { } static boolean isPendingInitialization(Config config) { - return pendingInitialization.contains(config) || createdAfterStartup.contains(config) && awaitingLateWindow.test(config); + return pendingInitialization.contains(config) + || createdAfterStartup.contains(config) && (fromWindowCreator.contains(config) || awaitingLateWindow.test(config)); } /** @@ -447,7 +453,7 @@ static boolean waitsForLateWindow(Config config) { synchronized (ConfigManager.class) { old = initializedConfigs.get(config.id); } - return initialized && old != null && old != config && isPendingInitialization(config); + return startedUp && old != null && old != config && isPendingInitialization(config); } static void reportResetOptions(Config config, List options) { 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 index e5641b96a..ebff5438c 100644 --- 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 @@ -489,6 +489,74 @@ void aReplacementThatPreloadsKeepsTheSavedValues() throws IOException { } } + // 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 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"; From 9c770fb346f755391bcbb08b8ddd012572207446 Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 6 Nov 2026 03:57:06 +0100 Subject: [PATCH 53/63] fix: leave compat trees alone while their mod is off --- .../api/config/v1/CompatSnapshots.java | 40 +++++++++++- .../api/config/v1/ConfigManager.java | 1 + .../api/config/v1/CompatSnapshotsTest.java | 62 +++++++++++++++++++ 3 files changed, 100 insertions(+), 3 deletions(-) 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..ee8d329f6 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,40 @@ 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())) off.putIfAbsent(tree, profile); + } + for (Tree tree : new ArrayList<>(off.keySet())) { + if (ids.contains(tree.getID())) continue; + String held = off.remove(tree); + 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 +227,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 +579,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/ConfigManager.java b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/ConfigManager.java index 8bfea1313..280f32e9f 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 @@ -1419,6 +1419,7 @@ private static void rebind(Config config, boolean restoreDefaults) { @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); 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..6ae693f99 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,67 @@ 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 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); From c90a295e08b33b8946227c67cda127ade9c4f439 Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 6 Nov 2026 03:59:43 +0100 Subject: [PATCH 54/63] fix: note the owners of trees extracted under other ids --- .../oneconfig/internal/compat/ConfigEntrypoints.java | 11 ++++++----- .../internal/compat/AxolotlClientConfigCompat.kt | 1 + .../oneconfig/internal/compat/KaleidoCompat.kt | 1 + .../oneconfig/internal/compat/MidnightLibCompat.kt | 1 + .../oneconfig/internal/compat/OslConfigCompat.kt | 1 + .../oneconfig/internal/compat/RConfigCompat.kt | 1 + .../internal/compat/ConfigEntrypointsTest.java | 7 +++++++ 7 files changed, 18 insertions(+), 5 deletions(-) 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 9760811db..e6ee9238a 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 @@ -93,7 +93,7 @@ private record Skipped(String modId, Runnable extraction) { // 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 - private static final Map> modConfigs = new ConcurrentHashMap<>(); + 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(); @@ -505,6 +505,11 @@ 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) modConfigs.computeIfAbsent(modId, k -> ConcurrentHashMap.newKeySet()).add(id); + } + private static void attachOpener(Tree tree, String modId) { if (failed.contains(modId)) return; Runnable opener = customOpeners.get(modId); @@ -672,10 +677,6 @@ private static void rememberClassOwner(Config config) { if (owner != null) rememberConfigOwner(owner.getId(), config.id); } - private static void rememberConfigOwner(String modId, String id) { - modConfigs.computeIfAbsent(modId, k -> ConcurrentHashMap.newKeySet()).add(id); - } - // their extracted and Mod Menu cards go by the mod's id static Set offConfigIds(Set off, Map> configs) { Set ids = new HashSet<>(); 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/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/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/test/java/org/polyfrost/oneconfig/internal/compat/ConfigEntrypointsTest.java b/minecraft/src/test/java/org/polyfrost/oneconfig/internal/compat/ConfigEntrypointsTest.java index 9172beb0e..b10622d14 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 @@ -230,6 +230,13 @@ void anOffModHidesItsCardsAndConfigs() { ); } + @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 aNativeConfigKeepingTheIdStillUntracksTheSnapshot() throws ReflectiveOperationException { String id = "drop-extracted-test"; From 8b49ffb5f6d7214a487b1f8ff63a3be0fc803995 Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 6 Nov 2026 04:02:39 +0100 Subject: [PATCH 55/63] fix: hand configs over to the copies of live updates A live copy runs under its own id, so the old mod kept owning the config ids it made again, which hid them and suspended their keybinds until a restart. Copies now take over the configs they make again, and the old tree's binds go even after Hotwire took out its card. --- .../internal/compat/ConfigEntrypoints.java | 61 ++++++++++++++++--- .../compat/ConfigEntrypointsTest.java | 38 +++++++++++- .../internal/ui/api/ConfigRegistry.kt | 2 - .../ui/keybind/MinecraftKeybindRegistrar.kt | 3 + .../ui/api/ConfigRegistryKeybindsTest.java | 13 ++++ 5 files changed, 105 insertions(+), 12 deletions(-) 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 e6ee9238a..1646ded02 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 @@ -479,7 +479,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; @@ -495,7 +495,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()); @@ -507,7 +507,18 @@ private static void onConfigSubmitted(Config config) { // 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) modConfigs.computeIfAbsent(modId, k -> ConcurrentHashMap.newKeySet()).add(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) { @@ -543,8 +554,8 @@ 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; } @@ -648,6 +659,7 @@ private static void registerChanged() { 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); @@ -663,6 +675,35 @@ private static void finishLate(Runnable creator) { //? 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 @@ -677,12 +718,16 @@ private static void rememberClassOwner(Config config) { if (owner != null) rememberConfigOwner(owner.getId(), config.id); } - // their extracted and Mod Menu cards go by the mod's 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) { - ids.add(modId); - ids.add(modId + ".json"); + 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; 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 b10622d14..b561454ed 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 @@ -2,6 +2,8 @@ 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; @@ -166,9 +168,10 @@ static class Base extends Config { static class Derived extends Base { } - private static ModContainer mod(String id) { + 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) -> id); + 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); } @@ -237,6 +240,37 @@ void anOffModHidesATreeExtractedUnderAnotherId() { 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"; 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 534c3a48c..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 @@ -162,8 +162,6 @@ object ConfigRegistry { onOpen: (() -> Unit)? = null, bumpRevision: Boolean = true ): Boolean { - // a config replaced after its mod updated leaves binds behind that would fire alongside the new ones - tree.id?.let(::findTree)?.takeIf { it !== tree }?.let { MinecraftKeybindRegistrar.unregister(it, kept = tree) } 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 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 5edca8922..f6d23a190 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 @@ -23,6 +23,7 @@ object MinecraftKeybindRegistrar { private val wiredProps = WeakHashMap, Boolean>() private val scannedTrees = WeakHashMap() + private val lastScanned = HashMap() private var listenerInstalled = false @JvmStatic @@ -30,6 +31,8 @@ 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, tree) }?.takeIf { it !== tree }?.let { unregister(it, kept = tree) } installRebindListener() val category = "${tree.localizedTitle()} (OneConfig)" walk(tree) { wire(it, category) } 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 index 50095f44c..fb3b84032 100644 --- 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 @@ -90,6 +90,19 @@ void aReplacedConfigTakesItsKeybindsWithIt() { 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(); From 4e86a4796c38476e14d90d9f3be1aa1ce5857ba0 Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 6 Nov 2026 04:46:49 +0100 Subject: [PATCH 56/63] fix: let a config waiting for its window read its saved values --- .../oneconfig/api/config/v1/Config.java | 45 ++++++++--- .../api/config/v1/ConfigManager.java | 5 +- .../api/config/v1/LateInitializationTest.java | 76 +++++++++++++++++++ 3 files changed, 114 insertions(+), 12 deletions(-) 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 bfa4917da..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 @@ -53,6 +53,8 @@ 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(); @@ -132,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); @@ -140,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 { @@ -178,8 +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)) return; + if (!byConfigManager && ConfigManager.waitsForLateWindow(this)) { + buildPreview(); + return; + } boolean was = initializing; initializing = true; try { @@ -189,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) { @@ -535,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/ConfigManager.java b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/ConfigManager.java index 280f32e9f..0f95d809e 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 @@ -445,8 +445,9 @@ static boolean isPendingInitialization(Config config) { } /** - * Whether a config initializing itself has to leave that 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 + * 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; 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 index ebff5438c..b7c1a8640 100644 --- 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 @@ -489,6 +489,59 @@ void aReplacementThatPreloadsKeepsTheSavedValues() throws IOException { } } + @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"); @@ -549,6 +602,29 @@ void aCopyAnAddedModPreloadsLeavesTheSavedValues() throws IOException, Reflectiv } } + @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<>(); From dd0ea16489747e1c171505206228436920c71191 Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 6 Nov 2026 04:48:30 +0100 Subject: [PATCH 57/63] fix: recognize replacements built on an entrypoint's worker --- .../api/config/v1/ConfigManager.java | 10 ++- .../api/config/v1/LateInitializationTest.java | 67 +++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) 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 0f95d809e..04d1af145 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 @@ -88,9 +88,11 @@ public final class ConfigManager { 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 + // 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 @@ -265,15 +267,18 @@ private static void initializePending() { 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); - fromWindowCreator.clear(); + // 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()) { @@ -381,6 +386,7 @@ public static void submitForInitialization(Config config) { // before subclass field initializers have run so collect() would read uninitialized fields if (initialized && !LATE_INITIALIZING.get()) { createdAfterStartup.add(config); + if (lateWindowOpen) fromWindowCreator.add(config); if (awaitingLateWindow.test(config)) heldForLateWindow.add(config); } else { pendingInitialization.add(config); 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 index b7c1a8640..953ab242e 100644 --- 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 @@ -489,6 +489,73 @@ void aReplacementThatPreloadsKeepsTheSavedValues() throws IOException { } } + @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"; From 5cc77cfdc9cabd22ebc7dee7f8ee849c5dcf61bc Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 6 Nov 2026 04:51:29 +0100 Subject: [PATCH 58/63] fix: hold the saves of turned off mods until they are back --- .../api/config/v1/CompatSnapshots.java | 5 ++- .../api/config/v1/CompatSnapshotsTest.java | 27 ++++++++++++ .../api/config/v1/backend/Backend.java | 6 +++ .../internal/ui/api/HotwireBridge.kt | 12 +++++- .../java/dev/moon/hotwire/api/HotwireApi.java | 6 +++ .../internal/ui/api/HotwireSwitchTest.java | 43 +++++++++++++++++++ 6 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 modules/internal/src/test/java/org/polyfrost/oneconfig/internal/ui/api/HotwireSwitchTest.java 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 ee8d329f6..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 @@ -155,11 +155,14 @@ private void dispatchApply(Tree tree, String profile) { void setOff(Set ids) { String profile = ConfigManager.activeProfile(); for (Tree tree : known.values()) { - if (ids.contains(tree.getID())) off.putIfAbsent(tree, profile); + 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); } } 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 6ae693f99..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 @@ -146,6 +146,33 @@ void aTurnedOffModsCallbacksSitOutANewProfileWhichGivesItItsDefaultsOnceBack() t } } + @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"; 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/HotwireBridge.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/api/HotwireBridge.kt index 8f497b529..97b09a2ed 100644 --- 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 @@ -10,6 +10,7 @@ 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 @@ -459,7 +460,10 @@ object HotwireBridge { else -> translated("oneconfig.page.hotwire.$kind.now", "Apply now") }, true, - ) { ask("accept").asResults() } + ) { + 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() @@ -494,7 +498,13 @@ object HotwireBridge { /** 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())) } 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 index e697dfd37..b562e2cfb 100644 --- a/modules/internal/src/test/java/dev/moon/hotwire/api/HotwireApi.java +++ b/modules/internal/src/test/java/dev/moon/hotwire/api/HotwireApi.java @@ -1,6 +1,7 @@ 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; @@ -52,6 +53,11 @@ 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); } 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); + } +} From f1a67b8c05945ce9d23995275dcc786ae9cbf958 Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 6 Nov 2026 04:53:54 +0100 Subject: [PATCH 59/63] fix: leave directly registered trees alone while their mod is off --- .../api/config/v1/ConfigManager.java | 40 ++++++++++-- .../api/config/v1/LateInitializationTest.java | 62 +++++++++++++++++++ 2 files changed, 98 insertions(+), 4 deletions(-) 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 04d1af145..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 @@ -97,6 +97,8 @@ public final class ConfigManager { 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); @@ -619,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); @@ -636,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); @@ -1409,6 +1415,20 @@ private static void copyDefaultsToActiveProfile(String id) { } } + // 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); @@ -1438,6 +1458,18 @@ public static synchronized boolean setOff(Set ids) { 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(); 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 index 953ab242e..bc3fa1891 100644 --- 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 @@ -445,6 +445,68 @@ void aProfileCreatedAndKeptWhileAModIsOffGivesItItsDefaultsOnceBack() throws IOE } } + @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"; From 3da6482c7e39c0dc1ff9aa30b14a32a2ca350c89 Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 6 Nov 2026 04:55:47 +0100 Subject: [PATCH 60/63] fix: drop extracted trees under the ids they were noted under --- .../internal/compat/ConfigEntrypoints.java | 19 ++++++--- .../compat/ConfigEntrypointsTest.java | 42 +++++++++++++++++++ 2 files changed, 56 insertions(+), 5 deletions(-) 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 1646ded02..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 @@ -410,12 +410,21 @@ private static void decide(String modId, boolean broken) { // 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) { - // a native config under this id already took the tree over, but the snapshot would still apply on profile changes - CompatSnapshots.untrack(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(); - if (treeOwners.containsKey(modId) || !manager.exists(modId) || !Boolean.TRUE.equals(manager.get(modId).getMetadata(Backend.UI_ONLY_METADATA))) return; - manager.unregister(modId); - ConfigRegistry.INSTANCE.unregister(modId); + 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) { 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 b561454ed..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 @@ -289,6 +289,48 @@ void aNativeConfigKeepingTheIdStillUntracksTheSnapshot() throws ReflectiveOperat } } + @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"); From 8e48b2f0c61ddce277f49505f24e6cdc83f1bff2 Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 6 Nov 2026 04:57:54 +0100 Subject: [PATCH 61/63] fix: release scanned trees once nothing else holds them --- .../ui/keybind/MinecraftKeybindRegistrar.kt | 8 ++++++-- .../ui/api/ConfigRegistryKeybindsTest.java | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) 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 f6d23a190..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,6 @@ package org.polyfrost.oneconfig.internal.ui.keybind +import java.lang.ref.WeakReference import java.util.Collections import java.util.IdentityHashMap import java.util.WeakHashMap @@ -23,7 +24,10 @@ object MinecraftKeybindRegistrar { private val wiredProps = WeakHashMap, Boolean>() private val scannedTrees = WeakHashMap() - private val lastScanned = HashMap() + // 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 @@ -32,7 +36,7 @@ object MinecraftKeybindRegistrar { 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, tree) }?.takeIf { it !== tree }?.let { unregister(it, kept = tree) } + 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) { wire(it, category) } 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 index fb3b84032..13908c9c2 100644 --- 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 @@ -1,9 +1,11 @@ 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; @@ -77,6 +79,23 @@ void aKeybindBuiltOnEveryReadStillStopsWhileItsModIsOff() { 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(); From 49591695c217d736feb5de4feabc977b2351fcfc Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 6 Nov 2026 05:02:46 +0100 Subject: [PATCH 62/63] fix: recognize updates running from relocated copies --- .../internal/ui/api/HotwireBridge.kt | 10 ++++++-- .../ui/api/HotwireAppliedUpdatesTest.java | 23 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) 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 index 97b09a2ed..5863c39cb 100644 --- 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 @@ -112,8 +112,14 @@ object HotwireBridge { */ fun whenApplied(modsBefore: List, modsAfter: List, queuedBefore: Map, queuedAfter: Map): AppliedUpdates { val versions = modsBefore.associate { it.id to it.version } - val live = modsAfter.filter { it.id in versions && versions[it.id] != it.version } - return AppliedUpdates(live.map { it.name }, (queuedAfter - queuedBefore.keys - live.map { it.id }.toSet()).values.toList()) + 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 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 index d469891db..a8a0722f4 100644 --- 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 @@ -28,6 +28,29 @@ void aLiveUpdateCountsAsLiveThoughItsJarSwapIsQueued() { 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")); From 52aa2245c3d85d3114420dc80e90461f840404f2 Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 6 Nov 2026 17:47:35 +0100 Subject: [PATCH 63/63] docs: update changelog --- CHANGELOG.md | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5abc7b9d0..aba0dd60c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +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 -- Added a Hotwire page, available with Hotwire 0.7.3 or newer, for turning mods on and off, updating them and adding new ones from Modrinth or a jar while the game runs -- Configs, keybinds and mod cards now follow the mods Hotwire adds, updates or turns off while the game runs +## 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 while the game runs +- `ConfigEntrypoint`s now also run for mods added or updated via Hotwire while the game is running