diff --git a/buildSrc/src/main/kotlin/oneconfig-setup.gradle.kts b/buildSrc/src/main/kotlin/oneconfig-setup.gradle.kts index 8651a5b35..44ed3113d 100644 --- a/buildSrc/src/main/kotlin/oneconfig-setup.gradle.kts +++ b/buildSrc/src/main/kotlin/oneconfig-setup.gradle.kts @@ -180,14 +180,6 @@ val firmamentRelocatedConfiguration: Configuration by configurations.creating { attributes { attribute(firmamentRelocated, true) } } -val dandelionBpRelocated = registerRelocationAttribute("relocate-dandelion-bp-moulconfig") { - relocate("io.github.notenoughupdates.moulconfig", "net.azureaaron.dandelion_bp.deps.moulconfig") -} - -val dandelionBpRelocatedConfiguration: Configuration by configurations.creating { - attributes { attribute(dandelionBpRelocated, true) } -} - dependencies { listOf("compat", "common-compat").forEach { versionedCatalog.bundles.getOrNull(it)?.let { bundle -> @@ -205,7 +197,7 @@ dependencies { } } - moulConfig(skyhanniRelocatedConfiguration, firmamentRelocatedConfiguration, dandelionBpRelocatedConfiguration) + moulConfig(skyhanniRelocatedConfiguration, firmamentRelocatedConfiguration) "api"(versionedCatalog["jetbrains.compose.foundation"]) "api"(versionedCatalog["jetbrains.compose.material"]) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a6773cbe0..cba9d2624 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,7 +31,7 @@ log4j-api = "2.0-beta9" # used because this is the version that 1.8.9 supports, # Compose compose = "1.12.0-alpha01" compose-navigation = "2.10.0-alpha01" -skiko = "0.999.4" +skiko = "0.999.5" lifecycle = "2.11.0-beta01" viewmodel = "2.11.0-beta01" diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/api/platform/v1/internal/ScreenPlatformImpl.java b/minecraft/src/main/java/org/polyfrost/oneconfig/api/platform/v1/internal/ScreenPlatformImpl.java index b94c67aa9..d8ab29988 100644 --- a/minecraft/src/main/java/org/polyfrost/oneconfig/api/platform/v1/internal/ScreenPlatformImpl.java +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/api/platform/v1/internal/ScreenPlatformImpl.java @@ -38,6 +38,13 @@ import org.polyfrost.oneconfig.internal.ui.compose.SkiaCtx; public class ScreenPlatformImpl implements ScreenPlatform { + @Override + public void runOnUiThread(Runnable action) { + Minecraft minecraft = Minecraft.getInstance(); + if (minecraft == null || minecraft.isSameThread()) action.run(); + else minecraft.execute(action); + } + @Override public int viewportWidth() { return Minecraft.getInstance().getWindow().getWidth(); 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 f377c5628..61f0a1373 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.InitializationEvent; 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.WorldEvent; import org.polyfrost.oneconfig.api.hud.v1.HudManager; import org.polyfrost.oneconfig.api.hud.v1.events.HudEditorToggleEvent; @@ -68,6 +69,7 @@ import org.polyfrost.oneconfig.internal.ui.hud.LegacyHudRenderer; import org.polyfrost.oneconfig.internal.ui.keybind.KeybindProviderRegistry; import org.polyfrost.oneconfig.internal.ui.keybind.MinecraftKeybindProvider; +import org.polyfrost.oneconfig.internal.ui.keybind.MinecraftKeybindProfiles; import org.polyfrost.oneconfig.internal.ui.keybind.RightShiftConflicts; import org.polyfrost.oneconfig.internal.ui.search.SearchCorpus; import org.polyfrost.oneconfig.test.TestMod_Test; @@ -217,7 +219,15 @@ private static void installNotificationRenderer() { } private static void registerEventHandlers() { - EventManager.register(InitializationEvent.class, e -> HudManager.INSTANCE.initialize()); + EventManager.register(ShutdownEvent.class, e -> MinecraftKeybindProfiles.shutdown()); + EventManager.register(InitializationEvent.class, e -> { + HudManager.INSTANCE.setProfileReloadDispatcher(r -> { + Minecraft mc = Minecraft.getInstance(); + if (mc != null && !mc.isSameThread()) mc.execute(r); + else r.run(); + }); + HudManager.INSTANCE.initialize(); + }); EventManager.register( HudEditorToggleEvent.class, e -> { if (e.open) { @@ -244,10 +254,10 @@ private static void registerEventHandlers() { RightShiftConflicts.unbindMinecraftKeybinds(); org.polyfrost.oneconfig.api.config.v1.CompatSnapshots.setDispatcher(r -> { net.minecraft.client.Minecraft mc = net.minecraft.client.Minecraft.getInstance(); - if (mc != null) mc.execute(r); + if (mc != null && !mc.isSameThread()) mc.execute(r); else r.run(); }); - org.polyfrost.oneconfig.internal.ui.keybind.MinecraftKeybindProfiles.init(); + MinecraftKeybindProfiles.init(); ConfigRegistry.INSTANCE.loadFrom(ConfigManager.active(), ConfigSource.OC); org.polyfrost.oneconfig.internal.ui.hud.BuiltinHudRegistrar.register(); org.polyfrost.oneconfig.internal.compat.FirmamentHudCompat.register(); 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 f8a51f3b1..78f9ef6e8 100644 --- a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfigMixinInit.java +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/OneConfigMixinInit.java @@ -74,7 +74,6 @@ public List getMixins() { //? } //? moul_compat { mixins.add("compat.moulconfig.Mixin_MCConfigEditorIntegration_Firmament"); - mixins.add("compat.moulconfig.Mixin_MoulConfigAdapter_DandelionBp"); //? } //? dandelion_compat @@ -175,6 +174,7 @@ public List getMixins() { mixins.add("keybind.Mixin_OneConfigKeybindRebind"); mixins.add("keybind.Mixin_KeyMappingResetDetect"); + mixins.add("keybind.Mixin_OptionsSaveDetect"); //? cinnabar //mixins.add("skia.Mixin_CinnabarSkiaFlush"); diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/compat/moulconfig/Mixin_MoulConfigAdapter_DandelionBp.java b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/compat/moulconfig/Mixin_MoulConfigAdapter_DandelionBp.java deleted file mode 100644 index ef4603811..000000000 --- a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/compat/moulconfig/Mixin_MoulConfigAdapter_DandelionBp.java +++ /dev/null @@ -1,38 +0,0 @@ -package org.polyfrost.oneconfig.internal.mixin.compat.moulconfig; - -//? moul_compat { - -import org.polyfrost.oneconfig.internal.compat.MoulConfigDispatch; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.Pseudo; -import org.spongepowered.asm.mixin.injection.At; -import org.spongepowered.asm.mixin.injection.Coerce; -import org.spongepowered.asm.mixin.injection.Inject; -import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; - -import java.lang.reflect.Field; -import java.lang.reflect.Method; -import java.util.Collection; -import java.util.List; - -@Pseudo -@Mixin(targets = "net.azureaaron.dandelion_bp.impl.moulconfig.MoulConfigAdapter", remap = false) -public class Mixin_MoulConfigAdapter_DandelionBp { - @Inject(method = "generateMoulConfigScreen", at = @At("RETURN"), require = 0) - private void oneconfig$onGenerateMoulConfigScreen(List categories, @Coerce Object parent, String search, CallbackInfoReturnable cir) { - try { - Method generateProcessedCategories = this.getClass().getDeclaredMethod("generateProcessedCategories", List.class); - generateProcessedCategories.setAccessible(true); - Object processed = generateProcessedCategories.invoke(this, categories); - - Field configDefinitionField = this.getClass().getDeclaredField("configDefinition"); - configDefinitionField.setAccessible(true); - Object configDefinition = configDefinitionField.get(this); - - if (processed instanceof Collection && configDefinition != null) { - MoulConfigDispatch.parseMoulconfigFromUnknownEditor((Collection) processed, configDefinition); - } - } catch (Throwable ignored) {} - } -} -//? } diff --git a/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/keybind/Mixin_OptionsSaveDetect.java b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/keybind/Mixin_OptionsSaveDetect.java new file mode 100644 index 000000000..94723740c --- /dev/null +++ b/minecraft/src/main/java/org/polyfrost/oneconfig/internal/mixin/keybind/Mixin_OptionsSaveDetect.java @@ -0,0 +1,16 @@ +package org.polyfrost.oneconfig.internal.mixin.keybind; + +import net.minecraft.client.Options; +import org.polyfrost.oneconfig.internal.ui.keybind.MinecraftKeybindProfiles; +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.CallbackInfo; + +@Mixin(Options.class) +public class Mixin_OptionsSaveDetect { + @Inject(method = "save", at = @At("RETURN")) + private void oneconfig$captureSavedControls(CallbackInfo ci) { + MinecraftKeybindProfiles.onOptionsSaved(); + } +} 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 34569df13..d0c95ade1 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 @@ -2,14 +2,21 @@ package org.polyfrost.oneconfig.internal.compat import org.polyfrost.oneconfig.api.event.v1.EventManager import org.polyfrost.oneconfig.api.event.v1.events.Event +import org.polyfrost.oneconfig.api.event.v1.events.FramebufferRenderEvent import org.polyfrost.oneconfig.api.event.v1.events.ResourceFinishedLoading import org.polyfrost.oneconfig.api.platform.v1.ModInfo import org.polyfrost.oneconfig.api.platform.v1.Platform +import org.polyfrost.oneconfig.internal.ui.compose.SkiaCtx +import org.polyfrost.oneconfig.internal.ui.compose.opengl.resyncTextureBindCache import java.net.URI import java.util.Optional import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ConcurrentLinkedDeque +import java.util.concurrent.atomic.AtomicBoolean object CompatLoader { + private val LOGGER = org.apache.logging.log4j.LogManager.getLogger("OneConfig/Compat") + private val forcedModId = ThreadLocal() private var bypassDelay = false @@ -121,6 +128,21 @@ object CompatLoader { } } + private val screenWarmups = ConcurrentLinkedDeque<() -> Unit>() + private val screenWarmupScheduled = AtomicBoolean(false) + + fun queueScreenWarmup(block: () -> Unit) { + screenWarmups.add(block) + if (!screenWarmupScheduled.compareAndSet(false, true)) return + EventManager.register(FramebufferRenderEvent.End::class.java) { _ -> runNextScreenWarmup() } + } + + private fun runNextScreenWarmup() { + val warmup = screenWarmups.poll() ?: return + if (!SkiaCtx.isVulkanMode) runCatching { resyncTextureBindCache() } + runCatching { warmup() }.onFailure { LOGGER.warn("Config screen warmup failed", it) } + } + private val list: MutableList Unit>> = mutableListOf() init { diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/DandelionCompat.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/DandelionCompat.kt index e9097dfc2..21d0dbcb3 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/DandelionCompat.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/DandelionCompat.kt @@ -205,6 +205,7 @@ object DandelionCompat { ) property.addMetadata("searchTags", option.tags()) + (defaultValue as Any?)?.let { property.addMetadata("default", it) } property.category = category property.subcategory = subcategory property.addDisplayCondition { if (option.modifiable()) Display.SHOWN else Display.DISABLED } 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 418e9b118..1047e11f0 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 @@ -8,8 +8,6 @@ import org.polyfrost.oneconfig.api.config.v1.CompatSnapshots 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.event.v1.EventManager -import org.polyfrost.oneconfig.api.event.v1.events.FramebufferRenderEvent import org.polyfrost.oneconfig.api.platform.v1.ModInfo import org.polyfrost.oneconfig.api.platform.v1.Platform import org.polyfrost.oneconfig.internal.ui.api.ConfigRegistry @@ -17,12 +15,8 @@ import org.polyfrost.oneconfig.internal.ui.api.ConfigSource import org.polyfrost.oneconfig.internal.ui.compose.impls.OneConfigUIScreen import org.polyfrost.oneconfig.internal.ui.navigation.graph.ModConfigRoute import org.polyfrost.oneconfig.internal.ui.shell.LocalNavController -import java.util.concurrent.ConcurrentLinkedDeque -import java.util.concurrent.atomic.AtomicBoolean object ModMenuCompat { - private val LOGGER = org.apache.logging.log4j.LogManager.getLogger("OneConfig/ModMenu-Compat") - val mods: MutableList = mutableListOf() private val ownModIds = setOf( @@ -118,27 +112,14 @@ object ModMenuCompat { scheduleWarmup(foundMods) } - // Some compat layers can only load when a config UI is opened, - // this has to be done when the render thread is available. - // Do this one per frame to prevent a huge lag spike - private val warmupQueue = ConcurrentLinkedDeque() - private val warmupScheduled = AtomicBoolean(false) - private fun scheduleWarmup(mods: List) { - if (mods.isEmpty()) return - warmupQueue.addAll(mods) - if (!warmupScheduled.compareAndSet(false, true)) return - EventManager.register(FramebufferRenderEvent.End::class.java) { _ -> warmupNext() } - } - - private fun warmupNext() { - val mod = warmupQueue.poll() ?: return - runCatching { - CompatLoader.withForcedModId(mod.id) { - // The screen is thrown away; building it is what makes the compat mixins fire. - ModMenu.getConfigScreen(mod.id, Platform.screen().current()) + mods.forEach { mod -> + CompatLoader.queueScreenWarmup { + CompatLoader.withForcedModId(mod.id) { + ModMenu.getConfigScreen(mod.id, Platform.screen().current()) + } } - }.onFailure { LOGGER.warn("Failed to warm up config screen for '{}'", mod.id, it) } + } } // A mod can ship BOTH a native OneConfig config and a Mod Menu entrypoint. The native config diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MoulConfigCompat.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MoulConfigCompat.kt index 7b72d841c..7d6b7d673 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MoulConfigCompat.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MoulConfigCompat.kt @@ -166,17 +166,18 @@ data object MoulConfigCompat { } is GuiOptionEditorColour -> { - property.getter = { + fun toArgb(value: Any?): Int? { val colour = when (children.type) { - String::class.java -> ChromaColour.forLegacyString(children.get() as String) - ChromaColour::class.java -> children.get() as ChromaColour + String::class.java -> (value as? String)?.let { ChromaColour.forLegacyString(it) } + ChromaColour::class.java -> value as? ChromaColour else -> null - } - colour?.let { - val rgb = Color.HSBtoRGB(it.hue, it.saturation, it.brightness) - (it.alpha shl 24) or (rgb and 0x00FFFFFF) - } ?: 0xFFFFFFFF.toInt() + } ?: return null + val rgb = Color.HSBtoRGB(colour.hue, colour.saturation, colour.brightness) + return (colour.alpha shl 24) or (rgb and 0x00FFFFFF) } + + property.getter = { toArgb(children.get()) ?: 0xFFFFFFFF.toInt() } + property.defaultMapper = ::toArgb property.setter = setter@{ val argb = it as? Int ?: return@setter val awtColor = Color(argb, true) @@ -191,8 +192,8 @@ data object MoulConfigCompat { } is MoulConfigGuiOptionEditorDropdownAccessor -> { - fun getIndex(): Int { - val selectedObject: Any = children.get() ?: return -1 + fun indexOf(selectedObject: Any?): Int { + if (selectedObject == null) return -1 return if (editor.`oneconfig$useOrdinal`()) { selectedObject as Int @@ -203,6 +204,8 @@ data object MoulConfigCompat { } } + fun getIndex(): Int = indexOf(children.get()) + fun setIndex(index: Int) { if (editor.`oneconfig$constants`() != null) { children.set(editor.`oneconfig$constants`()[index]) @@ -214,6 +217,7 @@ data object MoulConfigCompat { } property.getter = ::getIndex + property.defaultMapper = { indexOf(it).takeIf { index -> index >= 0 } } property.setter = setter@{ val index = it as? Int ?: return@setter setIndex(index) @@ -229,6 +233,7 @@ data object MoulConfigCompat { property.metadata["min"] = editor.`oneconfig$minValue` property.metadata["max"] = editor.`oneconfig$maxValue` property.getter = { (children.get() as? Number)?.toFloat() ?: editor.`oneconfig$maxValue` } + property.defaultMapper = { (it as? Number)?.toFloat() } property.setter = setter@{ value -> val numberValue = value as? Number ?: return@setter fun isAny(type: Type, numberType: KClass): Boolean { @@ -253,14 +258,14 @@ data object MoulConfigCompat { // MoulConfig stores a keybind as a single int GLFW key code on an int/Integer property; a code <= 0 // (GLFW_KEY_UNKNOWN / "none") means unbound. Bridge it to OneConfig's OneConfigKeybind, which carries // an array of key codes. The action is a no-op stub since MoulConfig owns the actual bind firing. - property.getter = { - val code = (children.get() as? Number)?.toInt() ?: KeyboardConstants.none - if (code <= 0) { - OneConfigKeybind(null, null, KeyModifiers.NONE, 0L) { true } - } else { - OneConfigKeybind(intArrayOf(code), null, KeyModifiers.NONE, 0L) { true } - } + fun keybindOf(code: Int) = if (code <= 0) { + OneConfigKeybind(null, null, KeyModifiers.NONE, 0L) { true } + } else { + OneConfigKeybind(intArrayOf(code), null, KeyModifiers.NONE, 0L) { true } } + + property.getter = { keybindOf((children.get() as? Number)?.toInt() ?: KeyboardConstants.none) } + property.defaultMapper = { value -> (value as? Number)?.toInt()?.let(::keybindOf) } property.setter = setter@{ value -> val keybind = value as? OneConfigKeybind ?: return@setter val code = keybind.keyCodes?.firstOrNull() diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MoulConfigDispatch.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MoulConfigDispatch.kt index 82798549a..46612399d 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MoulConfigDispatch.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MoulConfigDispatch.kt @@ -11,6 +11,8 @@ package org.polyfrost.oneconfig.internal.compat */ object MoulConfigDispatch { + private val LOGGER = org.apache.logging.log4j.LogManager.getLogger("OneConfig/MoulConfigDispatch") + private const val COMPAT_PACKAGE = "org.polyfrost.oneconfig.internal.compat" private const val COMPAT_PREFIX = "MoulConfigCompat_" @@ -25,23 +27,18 @@ object MoulConfigDispatch { configClass.startsWith("moe.nea.firmament.compat.moulconfig.") -> listOf("firmament") to listOf("firmament") - configClass.startsWith("net.azureaaron.dandelion_bp.deps.moulconfig.") -> - listOf("dandelion_bp", "dandelion") to listOf("skyblocker", "dandelion-bp") - - configClass.startsWith("net.azureaaron.dandelion_bp.impl.moulconfig.") -> - listOf("dandelion_bp", "dandelion") to listOf("skyblocker", "dandelion-bp") - - configClass.startsWith("net.azureaaron.dandelion.deps.moulconfig.") -> - listOf("dandelion") to listOf("skyblocker", "dandelion-bp") - configClass.startsWith("at.hannibal2.skyhanni.deps.moulconfig.") -> listOf("skyhanni") to listOf("skyhanni") else -> emptyList() to emptyList() } - if (candidates.isEmpty()) return + if (candidates.isEmpty()) { + LOGGER.debug("No relocation target known for MoulConfig editor of {}", configClass) + return + } val forcedModId = forcedModIds.firstOrNull { CompatLoader.hasMod(it) } + var failure: Throwable? = null for (target in candidates) { val fqcn = "$COMPAT_PACKAGE.$COMPAT_PREFIX$target" runCatching { @@ -53,7 +50,8 @@ object MoulConfigDispatch { method.invoke(null, categories, config) } return - } + }.onFailure { failure = it } } + LOGGER.warn("No usable compat class for MoulConfig editor of {} (tried {})", configClass, candidates, failure) } } diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MoulPropertyBuilder.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MoulPropertyBuilder.kt index b3a291284..e42ab0b78 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MoulPropertyBuilder.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/MoulPropertyBuilder.kt @@ -1,13 +1,18 @@ //? if > 1.21.10 && fabric && moul_compat { package org.polyfrost.oneconfig.internal.compat +import io.github.notenoughupdates.moulconfig.observer.Property as MoulProperty import io.github.notenoughupdates.moulconfig.processor.ProcessedOption import org.polyfrost.oneconfig.api.config.v1.CompatSnapshots import org.polyfrost.oneconfig.api.config.v1.Properties import org.polyfrost.oneconfig.internal.compat.CompatIds.idPart import org.polyfrost.oneconfig.internal.compat.CompatIds.uniqueId import org.polyfrost.oneconfig.relocator.annotations.MoulConfig +import org.polyfrost.oneconfig.utils.v1.WrappingUtils import java.lang.reflect.Field +import java.lang.reflect.Modifier +import java.util.Optional +import java.util.concurrent.ConcurrentHashMap @MoulConfig class MoulPropertyBuilder internal constructor(option: ProcessedOption) { @@ -15,19 +20,29 @@ class MoulPropertyBuilder internal constructor(option: ProcessedOption) { val name: String? = resolveTextGetter(option, "getName") val description: String? = resolveTextGetter(option, "getDescription") + private var getterReplaced = false + var setter: (Any) -> Unit = option::set var getter: () -> Any = option::get + set(value) { + field = value + getterReplaced = true + } + + var defaultMapper: ((Any) -> Any?)? = null val metadata: MutableMap = mutableMapOf() val backingField: Field? = resolveBackingField(option) + private val foreign: ForeignOption? = if (backingField == null) resolveForeign(option) else null + val declaringClass: Class<*>? get() = backingField?.declaringClass - private val snapshotKey: String? = backingField?.let { "${it.declaringClass.name}#${it.name}" } + private val snapshotKey: String? = backingField?.let { "${it.declaringClass.name}#${it.name}" } ?: foreign?.key fun build(usedIds: MutableSet) = Properties.functional( - id = uniqueId(usedIds, idPart(path ?: snapshotKey ?: name, "option")), + id = uniqueId(usedIds, idPart(foreign?.key ?: path ?: snapshotKey ?: name, "option")), getter = getter, setter = setter, name = name, @@ -35,14 +50,49 @@ class MoulPropertyBuilder internal constructor(option: ProcessedOption) { ).apply { snapshotKey?.let { addMetadata("oc_snapshot_key", it) } if (isRepoConfigField(backingField)) addMetadata(CompatSnapshots.NO_SNAPSHOT_META, true) + else codeDefault()?.let { addMetadata("default", it) } this@MoulPropertyBuilder.metadata.entries.forEach { (key, value) -> addMetadata(key, value) } } - private fun resolveBackingField(option: Any): Field? = runCatching { - val members = option.javaClass.fields.asSequence() + option.javaClass.declaredFields.asSequence() - members - .mapNotNull { m -> runCatching { m.isAccessible = true; m.get(option) as? Field }.getOrNull() } - .firstOrNull() + private fun codeDefault(): Any? { + val raw = rawDefault() ?: return null + defaultMapper?.let { return runCatching { it(raw) }.getOrNull() } + if (getterReplaced) return null + return raw.takeIf(::isSimpleValue) + } + + private fun rawDefault(): Any? { + foreign?.let { return runCatching { it.default() }.getOrNull() } + val field = backingField ?: return null + if (Modifier.isStatic(field.modifiers)) return null + return runCatching { + field.isAccessible = true + when (val value = field.get(pristine(field.declaringClass))) { + is MoulProperty<*> -> value.get() + else -> value + } + }.getOrNull() + } + + private fun resolveBackingField(option: ProcessedOption): Field? = + runCatching { (option as? ProcessedOption.HasField)?.field }.getOrNull() + ?: runCatching { + val members = option.javaClass.fields.asSequence() + option.javaClass.declaredFields.asSequence() + members + .mapNotNull { m -> runCatching { m.isAccessible = true; m.get(option) as? Field }.getOrNull() } + .firstOrNull() + }.getOrNull() + + private fun resolveForeign(option: Any): ForeignOption? = runCatching { + val managed = readMember(option, "managedOption", "getManagedOption") ?: return null + val propertyName = invoke(managed, "getPropertyName") as? String ?: return null + val configName = invoke(invoke(managed, "getElement"), "getName") as? String ?: "config" + ForeignOption("firmament#$configName.$propertyName") { + val default = (invoke(managed, "getDefault") as? Function0<*>)?.invoke() ?: return@ForeignOption null + runCatching { + option.javaClass.getMethod("fromT", Any::class.java).invoke(option, default) + }.getOrNull() ?: default + } }.getOrNull() private fun isRepoConfigField(field: Field?): Boolean { @@ -69,5 +119,39 @@ class MoulPropertyBuilder internal constructor(option: ProcessedOption) { else -> fromGetText.toString() } } + + private class ForeignOption(val key: String, val default: () -> Any?) + + private companion object { + private val pristines = ConcurrentHashMap, Optional>() + + fun pristine(cls: Class<*>): Any? = pristines.computeIfAbsent(cls) { + Optional.ofNullable( + runCatching { + if (it.enclosingClass != null && !Modifier.isStatic(it.modifiers)) null + else it.getDeclaredConstructor().apply { isAccessible = true }.newInstance() + }.getOrNull() + ) + }.orElse(null) + + fun isSimpleValue(value: Any): Boolean = + value is Enum<*> || WrappingUtils.isSimpleClass(value.javaClass) + + fun invoke(target: Any?, method: String): Any? = + target?.let { runCatching { it.javaClass.getMethod(method).invoke(it) }.getOrNull() } + + fun readMember(target: Any, fieldName: String, getterName: String): Any? = + invoke(target, getterName) ?: runCatching { + var cls: Class<*>? = target.javaClass + while (cls != null) { + cls.declaredFields.firstOrNull { it.name == fieldName }?.let { + it.isAccessible = true + return@runCatching it.get(target) + } + cls = cls.superclass + } + null + }.getOrNull() + } } -//? } \ No newline at end of file +//? } diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/Tr7zwConfigCompat.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/Tr7zwConfigCompat.kt index 602e7cfdc..651b302df 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/Tr7zwConfigCompat.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/Tr7zwConfigCompat.kt @@ -58,6 +58,9 @@ object Tr7zwConfigCompat { findMethod(screen.javaClass, "save")?.let { saveMethod -> tree.saveFunction = Runnable { runCatching { saveMethod.invoke(screen) } } } + findMethod(screen.javaClass, "reset")?.let { resetMethod -> + tree.addMetadata(CompatSnapshots.CUSTOM_RESET_METADATA, Runnable { resetMethod.invoke(screen) }) + } var category = DEFAULT_CATEGORY var categoryPath = idPart(DEFAULT_CATEGORY, "general") diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/WWaypointsBridge.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/WWaypointsBridge.kt index 6f3983202..e6fbcaa69 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/WWaypointsBridge.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/WWaypointsBridge.kt @@ -4,8 +4,10 @@ package org.polyfrost.oneconfig.internal.compat import com.mojang.blaze3d.platform.InputConstants import net.minecraft.client.KeyMapping import net.minecraft.client.gui.screens.Screen +import org.polyfrost.oneconfig.utils.v1.WrappingUtils import java.lang.reflect.Field import java.lang.reflect.Method +import java.lang.reflect.Modifier /** * Reflective access to wWaypoints. The mod is closed-source and is not a compile-time dependency, so every @@ -76,6 +78,18 @@ internal object WWaypointsBridge { .onFailure { LOGGER.warn("Failed to write wWaypoints config field '{}'", name, it) } } + private val pristineConfig: Any? by lazy { + runCatching { cls(CONFIG)!!.getDeclaredConstructor().apply { isAccessible = true }.newInstance() } + .onFailure { LOGGER.warn("Could not construct a pristine wWaypoints config", it) } + .getOrNull() + } + + fun configDefault(name: String): Any? { + val field = configField(name) ?: return null + if (!WrappingUtils.isSimpleClass(field.type) || Modifier.isStatic(field.modifiers)) return null + return runCatching { field.get(pristineConfig) }.getOrNull() + } + fun bool(name: String, fallback: Boolean = false): Boolean = get(name) ?: fallback fun int(name: String, fallback: Int = 0): Int = get(name)?.toInt() ?: fallback diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/WWaypointsCompat.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/WWaypointsCompat.kt index c2f9d9d9d..9abb463c5 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/WWaypointsCompat.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/compat/WWaypointsCompat.kt @@ -919,6 +919,7 @@ object WWaypointsCompat { prop.visualizer = Visualizer.SwitchVisualizer::class.java prop.category = "General" prop.addMetadata("searchTags", tags) + (WWaypointsBridge.configDefault(field) as? Boolean)?.let { prop.addMetadata("default", it) } return prop } @@ -944,6 +945,7 @@ object WWaypointsCompat { prop.addMetadata("step", 1f) prop.category = "General" prop.addMetadata("searchTags", tags) + (WWaypointsBridge.configDefault(field) as? Number)?.let { prop.addMetadata("default", it.toInt()) } return prop } @@ -970,6 +972,7 @@ object WWaypointsCompat { prop.addMetadata("step", step) prop.category = "General" prop.addMetadata("searchTags", tags) + (WWaypointsBridge.configDefault(field) as? Number)?.let { prop.addMetadata("default", it.toFloat()) } return prop } @@ -1003,6 +1006,7 @@ object WWaypointsCompat { prop.addMetadata("options", labels) prop.category = "General" prop.addMetadata("searchTags", tags) + prop.addMetadata("default", WWaypointsBridge.configDefault(field)?.takeIf(enumClass::isInstance) ?: fallbackConstant) return prop } diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/ComposeScreen.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/ComposeScreen.kt index 189c7694e..abd52e583 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/ComposeScreen.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/ComposeScreen.kt @@ -653,13 +653,14 @@ abstract class ComposeScreen : Screen(CommonComponents.EMPTY) { private fun glfwKeyLocation(glfwKey: Int): Int = when (glfwKey) { GLFW.GLFW_KEY_RIGHT_SHIFT, GLFW.GLFW_KEY_RIGHT_CONTROL, GLFW.GLFW_KEY_RIGHT_ALT, GLFW.GLFW_KEY_RIGHT_SUPER -> KeyEvent.KEY_LOCATION_RIGHT GLFW.GLFW_KEY_LEFT_SHIFT, GLFW.GLFW_KEY_LEFT_CONTROL, GLFW.GLFW_KEY_LEFT_ALT, GLFW.GLFW_KEY_LEFT_SUPER -> KeyEvent.KEY_LOCATION_LEFT + GLFW.GLFW_KEY_KP_ENTER -> KeyEvent.KEY_LOCATION_NUMPAD else -> KeyEvent.KEY_LOCATION_STANDARD } private fun glfwToAwtKeyCode(glfwKey: Int): Int = when (glfwKey) { GLFW.GLFW_KEY_BACKSPACE -> KeyEvent.VK_BACK_SPACE GLFW.GLFW_KEY_TAB -> KeyEvent.VK_TAB - GLFW.GLFW_KEY_ENTER -> KeyEvent.VK_ENTER + GLFW.GLFW_KEY_ENTER, GLFW.GLFW_KEY_KP_ENTER -> KeyEvent.VK_ENTER GLFW.GLFW_KEY_ESCAPE -> KeyEvent.VK_ESCAPE GLFW.GLFW_KEY_DELETE -> KeyEvent.VK_DELETE GLFW.GLFW_KEY_RIGHT -> KeyEvent.VK_RIGHT diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/opengl/StoredGLState.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/opengl/StoredGLState.kt index 94b5322f5..e80a5373b 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/opengl/StoredGLState.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/compose/opengl/StoredGLState.kt @@ -8,6 +8,14 @@ import com.mojang.blaze3d.opengl.GlStateManager import org.lwjgl.opengl.GL import org.lwjgl.opengl.GL45.* +fun resyncTextureBindCache() { + for (unit in 0..7) { + GlStateManager._activeTexture(GL_TEXTURE0 + unit) + GlStateManager._bindTexture(0) + } + GlStateManager._activeTexture(GL_TEXTURE0) +} + class StoredGLState(private val glVersion: Int) { private val props = StoredGLStateProps() @@ -99,11 +107,7 @@ class StoredGLState(private val glVersion: Int) { } glActiveTexture(lastActiveTexture[0]) - for (unit in 0..7) { - GlStateManager._activeTexture(GL_TEXTURE0 + unit) - GlStateManager._bindTexture(0) - } - GlStateManager._activeTexture(GL_TEXTURE0) + resyncTextureBindCache() glBindVertexArray(lastVertexArrayObject[0]) glBindBuffer(GL_ARRAY_BUFFER, lastArrayBuffer[0]) diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/keybind/MinecraftKeybindModeTransitions.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/keybind/MinecraftKeybindModeTransitions.kt new file mode 100644 index 000000000..4d27f7214 --- /dev/null +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/keybind/MinecraftKeybindModeTransitions.kt @@ -0,0 +1,173 @@ +package org.polyfrost.oneconfig.internal.ui.keybind + +internal data class MinecraftKeybindLiveOwner( + val profile: String, + val separateControls: Boolean, +) + +internal class MinecraftKeybindModeTransitions( + private val saveProfile: (String) -> Unit, + private val restoreProfile: (String) -> Unit, + private val saveShared: () -> Unit, + private val restoreShared: () -> Unit, +) { + private val stateLock = Any() + private var owner: MinecraftKeybindLiveOwner? = null + private var capturing = false + + /** + * Runs an Options.save capture against one stable owner. Transitions hold the same reentrant + * lock and temporarily disable captures, so the save performed by restoreProfile/restoreShared + * cannot publish the newly applied mappings into the previous owner. + */ + fun captureSavedOptions(capture: (MinecraftKeybindLiveOwner) -> Unit) { + synchronized(stateLock) { + if (capturing) owner?.let(capture) + } + } + + fun liveOwner(): MinecraftKeybindLiveOwner? = synchronized(stateLock) { owner } + + fun initialize(profile: String, separateControls: Boolean) { + synchronized(stateLock) { + check(owner == null) { "Minecraft keybind profiles are already initialized" } + if (separateControls) { + // options.txt already contains the mappings that were live when this profile last ran. + // It is also where vanilla persists edits made immediately before shutdown. + saveProfile(profile) + } else { + // While controls are shared, options.txt is the live source of truth. + saveShared() + } + owner = MinecraftKeybindLiveOwner(profile, separateControls) + capturing = true + } + } + + fun profileChanged(newProfile: String) { + val oldOwner = requireOwner() + if (oldOwner.profile == newProfile) return + val newOwner = oldOwner.copy(profile = newProfile) + transitionTo(newOwner) { publish -> + if (oldOwner.separateControls) { + // ConfigManager saves the live owner before committing the active profile. + try { + restoreProfile(newProfile) + } finally { + // The config backend is already committed. Even a corrupt target snapshot must + // not leave later Options.save calls writing the inherited live keys into old. + publish() + } + saveProfile(newProfile) + } else { + // Shared mappings stay live; only their selected profile label changes. + publish() + } + } + } + + fun profileCreated(profile: String) { + val oldOwner = requireOwner() + val newOwner = oldOwner.copy(profile = profile) + transitionTo(newOwner) { publish -> + // The backend has already committed the new profile and its mappings stay live. + publish() + if (oldOwner.separateControls) { + // A newly created profile intentionally inherits the mappings which are live now. + saveProfile(profile) + } else { + saveShared() + } + } + } + + fun profileRenamed(oldProfile: String, newProfile: String) { + val oldOwner = requireOwner() + if (oldOwner.profile != oldProfile) return + transitionTo(oldOwner.copy(profile = newProfile)) { publish -> publish() } + } + + fun profileDeleted(profile: String) { + val oldOwner = requireOwner() + if (oldOwner.profile != profile) return + val newOwner = oldOwner.copy(profile = "") + transitionTo(newOwner) { publish -> + if (oldOwner.separateControls) { + try { + restoreProfile("") + } finally { + publish() + } + saveProfile("") + } else { + publish() + saveShared() + } + } + } + + fun change(separateControls: Boolean) { + val oldOwner = requireOwner() + if (oldOwner.separateControls == separateControls) return + val newOwner = oldOwner.copy(separateControls = separateControls) + transitionTo( + newOwner, + rollbackAfterPublish = { + // ConfigManager rolls the persisted preference back when this listener fails. Put + // the mappings back too, without first trying to save the owner which just failed. + if (oldOwner.separateControls) restoreProfile(oldOwner.profile) else restoreShared() + }, + ) { publish -> + if (separateControls) { + // Preserve edits made in shared mode, then restore the selected profile's mappings. + saveShared() + restoreProfile(oldOwner.profile) + publish() + saveProfile(oldOwner.profile) + } else { + // Preserve the selected profile before replacing its mappings with the shared set. + saveProfile(oldOwner.profile) + restoreShared() + publish() + saveShared() + } + } + } + + private fun requireOwner(): MinecraftKeybindLiveOwner = + synchronized(stateLock) { checkNotNull(owner) { "Minecraft keybind profiles are not initialized" } } + + private fun transitionTo( + newOwner: MinecraftKeybindLiveOwner, + rollbackAfterPublish: (() -> Unit)? = null, + action: (() -> Unit) -> Unit, + ) { + synchronized(stateLock) { + val previousOwner = checkNotNull(owner) { "Minecraft keybind profiles are not initialized" } + check(capturing) { "Minecraft keybind profile transition is already running" } + capturing = false + var published = false + val publish = { + check(!published) { "Minecraft keybind live owner was already published" } + owner = newOwner + published = true + } + try { + action(publish) + check(published) { "Minecraft keybind transition did not publish its live owner" } + } catch (failure: Throwable) { + if (published && rollbackAfterPublish != null) { + try { + rollbackAfterPublish() + owner = previousOwner + } catch (rollbackFailure: Throwable) { + failure.addSuppressed(rollbackFailure) + } + } + throw failure + } finally { + capturing = true + } + } + } +} diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/keybind/MinecraftKeybindProfiles.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/keybind/MinecraftKeybindProfiles.kt index fbd1577a5..e74a6cf55 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/keybind/MinecraftKeybindProfiles.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/keybind/MinecraftKeybindProfiles.kt @@ -5,50 +5,166 @@ import net.minecraft.client.KeyMapping import net.minecraft.client.Minecraft import org.polyfrost.oneconfig.api.config.v1.CompatSnapshotStore import org.polyfrost.oneconfig.api.config.v1.ConfigManager +import java.util.concurrent.TimeUnit object MinecraftKeybindProfiles : ConfigManager.ProfileChangeListener { private const val NAMESPACE = "controls" + private const val SHARED_NAMESPACE = "shared-controls" + private const val CLIENT_THREAD_TIMEOUT_SECONDS = 30L private val LOGGER = org.apache.logging.log4j.LogManager.getLogger("OneConfig/MC-Keybind-Profiles") private val store = CompatSnapshotStore("minecraft-keybinds.json") - - @Volatile - private var currentProfile: String = "" + private val modeTransitions = MinecraftKeybindModeTransitions( + saveProfile = { profile -> + capture(profile) + store.flushOrThrow(profile) + }, + restoreProfile = { profile -> apply(profile) }, + saveShared = { + capture("", SHARED_NAMESPACE) + store.flushOrThrow("") + }, + restoreShared = { apply("", SHARED_NAMESPACE) }, + ) @Volatile private var initialized = false @JvmStatic + @Synchronized fun init() { if (initialized) return - initialized = true - currentProfile = ConfigManager.activeProfile() - runCatching { capture(currentProfile) } - .onFailure { LOGGER.warn("Failed to capture baseline Minecraft keybinds", it) } + val profile = ConfigManager.activeProfile() + val separateControls = ConfigManager.profileSpecificControls() + try { + runOnClientThreadAndWait { + modeTransitions.initialize(profile, separateControls) + } + } catch (failure: Throwable) { + LOGGER.warn("Failed to initialize Minecraft keybind profiles", failure) + return + } ConfigManager.addProfileChangeListener(this) + // Publish initialized only after the live owner and its initial snapshot are both ready. + initialized = true } override fun onProfileChanged(newProfile: String) { - val old = currentProfile - currentProfile = newProfile - if (old == newProfile) return - val mc = Minecraft.getInstance() ?: return - mc.execute { + runCatching { + runOnClientThreadAndWait { + modeTransitions.profileChanged(newProfile) + } + }.onFailure { LOGGER.warn("Failed to switch Minecraft keybinds to profile '{}'", newProfile, it) } + } + + override fun onProfileSaving(profile: String) { + val owner = modeTransitions.liveOwner() ?: return + if (!owner.separateControls) { + if (store.hasLoadFailure("")) { + LOGGER.warn("Leaving the shared controls snapshot unreadable instead of overwriting it") + } else { + runOnClientThreadAndWait { capture("", SHARED_NAMESPACE) } + store.flushOrThrow("") + } + if (profile.isNotEmpty()) { + if (store.hasLoadFailure(profile)) { + LOGGER.warn( + "Leaving profile '{}' without rewriting its unreadable Minecraft controls snapshot", + profile, + ) + } else { + store.flushOrThrow(profile) + } + } + return + } + if (store.hasLoadFailure(profile)) { + LOGGER.warn( + "Continuing the profile operation without rewriting unreadable Minecraft controls snapshot '{}'", + profile, + ) + return + } + if (profile == owner.profile) runOnClientThreadAndWait { capture(profile) } + store.flushOrThrow(profile) + } + + override fun onProfileCreated(profile: String) { + store.deleteProfile(profile) + runOnClientThreadAndWait { modeTransitions.profileCreated(profile) } + } + + override fun onProfileSpecificControlsChanged(enabled: Boolean) { + runOnClientThreadAndWait { + modeTransitions.change(enabled) + } + } + + override fun onProfileRenamed(oldProfile: String, newProfile: String) { + runOnClientThreadAndWait { + // Keep the cache remap ordered with Options.save callbacks queued by earlier listeners. + store.renameProfile(oldProfile, newProfile) + modeTransitions.profileRenamed(oldProfile, newProfile) + } + } + + override fun onProfileDeleted(profile: String) { + runOnClientThreadAndWait { + // A queued Options.save may still target the outgoing identity. Delete its cache only + // after those earlier client-thread callbacks have completed. + store.deleteProfile(profile) + modeTransitions.profileDeleted(profile) + } + } + + @JvmStatic + fun onOptionsSaved() { + if (!initialized) return + val captureSaved: () -> Unit = { runCatching { - capture(old) - store.flush(old) - apply(newProfile) - }.onFailure { LOGGER.warn("Failed to switch Minecraft keybinds to profile '{}'", newProfile, it) } + modeTransitions.captureSavedOptions { owner -> captureLiveControls(owner, false) } + } + .onFailure { LOGGER.warn("Failed to capture saved Minecraft keybinds", it) } + Unit } + val mc = Minecraft.getInstance() ?: return + if (mc.isSameThread) captureSaved() else mc.execute(captureSaved) + } + + @JvmStatic + fun shutdown() { + if (!initialized) return + runCatching { + runOnClientThreadAndWait { + modeTransitions.captureSavedOptions { owner -> captureLiveControls(owner, true) } + } + }.onFailure { LOGGER.warn("Failed to flush Minecraft keybind profiles during shutdown", it) } } - private fun capture(profile: String) { + private fun runOnClientThreadAndWait(action: () -> Unit) { + val mc = Minecraft.getInstance() + ConfigManager.dispatchAndWait( + { task -> if (mc == null || mc.isSameThread) task.run() else mc.execute(task) }, + action, + TimeUnit.SECONDS.toNanos(CLIENT_THREAD_TIMEOUT_SECONDS), + "the client thread", + ) + } + + private fun capture(profile: String, namespace: String = NAMESPACE) { for (mapping in MinecraftKeybindProvider.managedMappings()) { - store.putValue(profile, NAMESPACE, mapping.name, mapping.saveString()) + store.putValue(profile, namespace, mapping.name, mapping.saveString()) } } - private fun apply(profile: String) { - val snapshot = store.load(profile)[NAMESPACE] ?: return + private fun captureLiveControls(owner: MinecraftKeybindLiveOwner, flush: Boolean) { + val profile = if (owner.separateControls) owner.profile else "" + val namespace = if (owner.separateControls) NAMESPACE else SHARED_NAMESPACE + capture(profile, namespace) + if (flush) store.flushOrThrow(profile) + } + + private fun apply(profile: String, namespace: String = NAMESPACE) { + val snapshot = store.load(profile)[namespace] ?: return var changed = false for (mapping in MinecraftKeybindProvider.managedMappings()) { val saved = snapshot[mapping.name] as? String ?: continue diff --git a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/test/TestItemHud_Test.kt b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/test/TestItemHud_Test.kt index a9285fe58..477f14dc4 100644 --- a/minecraft/src/main/kotlin/org/polyfrost/oneconfig/test/TestItemHud_Test.kt +++ b/minecraft/src/main/kotlin/org/polyfrost/oneconfig/test/TestItemHud_Test.kt @@ -84,6 +84,7 @@ class TestItemHud_Test : Hud("test-item-hud", "Item List Hud", Category.INFO) { (padLeft + padRight + 8f) to (padTop + padBottom + 8f) override fun clone(): Hud = (super.clone() as TestItemHud_Test).also { + it.items = this.items.toMutableList() it.displayItems = mutableStateOf(emptyList()) } } diff --git a/minecraft/src/modMenuShim/java/org/polyfrost/oneconfig/internal/compat/ModMenuApiCompat.java b/minecraft/src/modMenuShim/java/org/polyfrost/oneconfig/internal/compat/ModMenuApiCompat.java index a658bb6e1..588b4d5f6 100644 --- a/minecraft/src/modMenuShim/java/org/polyfrost/oneconfig/internal/compat/ModMenuApiCompat.java +++ b/minecraft/src/modMenuShim/java/org/polyfrost/oneconfig/internal/compat/ModMenuApiCompat.java @@ -34,10 +34,16 @@ public static void enable() { private static void preloadFactories() { collectFactories().forEach((modId, factory) -> { if (CompatLoader.INSTANCE.getNativeLoadedConfigs().contains(modId) || hasRegisteredTree(modId)) return; - Screen screen = createScreen(modId, factory); - if (screen != null && !CompatLoader.INSTANCE.getNativeLoadedConfigs().contains(modId) && !hasRegisteredTree(modId)) { - registerFallbackTree(modId, factory, screen); - } + CompatLoader.INSTANCE.queueScreenWarmup(() -> { + if (CompatLoader.INSTANCE.getNativeLoadedConfigs().contains(modId) || hasRegisteredTree(modId)) { + return Unit.INSTANCE; + } + Screen screen = createScreen(modId, factory); + if (screen != null && !CompatLoader.INSTANCE.getNativeLoadedConfigs().contains(modId) && !hasRegisteredTree(modId)) { + registerFallbackTree(modId, factory, screen); + } + return Unit.INSTANCE; + }); }); } diff --git a/minecraft/src/test/kotlin/org/polyfrost/oneconfig/internal/ui/keybind/MinecraftKeybindModeTransitionsTest.kt b/minecraft/src/test/kotlin/org/polyfrost/oneconfig/internal/ui/keybind/MinecraftKeybindModeTransitionsTest.kt new file mode 100644 index 000000000..6490fbfa1 --- /dev/null +++ b/minecraft/src/test/kotlin/org/polyfrost/oneconfig/internal/ui/keybind/MinecraftKeybindModeTransitionsTest.kt @@ -0,0 +1,320 @@ +package org.polyfrost.oneconfig.internal.ui.keybind + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test + +class MinecraftKeybindModeTransitionsTest { + @Test + fun reenablingSeparateControlsRestoresTheSelectedProfile() { + val controls = FakeControls( + live = "profile-a", + profiles = mutableMapOf("A" to "profile-a", "B" to "default"), + ) + controls.transitions.initialize("A", true) + + controls.transitions.change(false) + assertEquals("profile-a", controls.shared) + + controls.preSaveLiveOwner() + controls.transitions.profileChanged("B") + assertEquals("profile-a", controls.live) + controls.transitions.change(true) + + assertEquals("default", controls.live) + assertEquals("profile-a", controls.profiles["A"]) + assertEquals("default", controls.profiles["B"]) + } + + @Test + fun editsMadeWhileSharedAreKeptForTheNextSharedSession() { + val controls = FakeControls( + live = "profile-a", + profiles = mutableMapOf("A" to "profile-a", "B" to "profile-b"), + ) + controls.transitions.initialize("A", true) + + controls.transitions.change(false) + controls.live = "edited-shared" + controls.preSaveLiveOwner() + controls.transitions.profileChanged("B") + controls.transitions.change(true) + + assertEquals("profile-b", controls.live) + assertEquals("edited-shared", controls.shared) + + controls.transitions.change(false) + assertEquals("edited-shared", controls.live) + } + + @Test + fun disablingSeparateControlsRestoresAnExistingSharedMapping() { + val controls = FakeControls( + live = "profile-a", + profiles = mutableMapOf("A" to "profile-a"), + ) + controls.transitions.initialize("A", true) + controls.shared = "shared" + + controls.transitions.change(false) + + assertEquals("shared", controls.live) + assertEquals("profile-a", controls.profiles["A"]) + assertEquals("shared", controls.shared) + } + + @Test + fun profileCreatedWhileSharedUsesCurrentMappingsOnFirstEnable() { + val controls = FakeControls(live = "shared", profiles = mutableMapOf()) + controls.transitions.initialize("old", false) + + controls.transitions.profileCreated("new") + controls.transitions.change(true) + + assertEquals("shared", controls.live) + assertEquals("shared", controls.profiles["new"]) + } + + @Test + fun initializationPublishesOwnerOnlyAfterInitialSave() { + val separate = FakeControls( + live = "options", + profiles = mutableMapOf("A" to "saved-profile"), + ) + separate.transitions.initialize("A", true) + + assertEquals("options", separate.live) + assertEquals("options", separate.profiles["A"]) + assertNull(separate.shared) + assertEquals(listOf(null), separate.ownersSeenDuringProfileSave) + assertEquals(MinecraftKeybindLiveOwner("A", true), separate.transitions.liveOwner()) + + val shared = FakeControls( + live = "saved-options", + profiles = mutableMapOf("A" to "saved-profile"), + ) + shared.transitions.initialize("A", false) + assertEquals("saved-options", shared.shared) + assertEquals("saved-options", shared.live) + assertEquals(MinecraftKeybindLiveOwner("A", false), shared.transitions.liveOwner()) + } + + @Test + fun switchingSeparateProfilesOnlyActivatesTheAlreadyPresavedTarget() { + val controls = FakeControls( + live = "profile-a", + profiles = mutableMapOf("A" to "profile-a", "B" to "profile-b"), + ) + controls.transitions.initialize("A", true) + controls.profileSaveCalls.clear() + + controls.preSaveLiveOwner() + controls.profileSaveCalls.clear() + controls.transitions.profileChanged("B") + + assertEquals("profile-b", controls.live) + assertEquals(listOf("B"), controls.profileSaveCalls) + assertEquals(MinecraftKeybindLiveOwner("B", true), controls.transitions.liveOwner()) + } + + @Test + fun earlyOptionsSaveUsesOldModeUntilModeTransitionCompletes() { + val controls = FakeControls( + live = "shared", + profiles = mutableMapOf("B" to "default"), + ) + controls.transitions.initialize("B", false) + + // ConfigManager has committed ON, but an earlier listener queued Options.save before this + // controller receives its mode callback. Its own owner must remain shared until change(). + controls.optionsSaved() + assertEquals(listOf(MinecraftKeybindLiveOwner("B", false)), controls.capturedOptionsOwners) + assertEquals("default", controls.profiles["B"]) + + controls.transitions.change(true) + + assertEquals("default", controls.live) + assertEquals("shared", controls.shared) + assertEquals("default", controls.profiles["B"]) + assertEquals(MinecraftKeybindLiveOwner("B", true), controls.transitions.liveOwner()) + } + + @Test + fun optionsSaveTriggeredByRestoreIsSuppressedUntilNewOwnerIsPublished() { + val controls = FakeControls( + live = "shared", + profiles = mutableMapOf("B" to "profile-b"), + ) + controls.transitions.initialize("B", false) + controls.capturedOptionsOwners.clear() + + controls.transitions.change(true) + + // Fake restore invokes optionsSaved(), matching apply() -> Options.save() in production. + // The transition explicitly saves B afterwards; the hook must not rewrite shared meanwhile. + assertEquals(emptyList(), controls.capturedOptionsOwners) + assertEquals("shared", controls.shared) + assertEquals("profile-b", controls.profiles["B"]) + assertEquals("profile-b", controls.live) + } + + @Test + fun queuedOptionsSaveBeforeProfileSwitchStillBelongsToOldProfile() { + val controls = FakeControls( + live = "edited-a", + profiles = mutableMapOf("A" to "old-a", "B" to "profile-b"), + ) + controls.transitions.initialize("A", true) + controls.live = "queued-save-a" + controls.capturedOptionsOwners.clear() + + controls.optionsSaved() + controls.preSaveLiveOwner() + controls.transitions.profileChanged("B") + + assertEquals(listOf(MinecraftKeybindLiveOwner("A", true)), controls.capturedOptionsOwners) + assertEquals("queued-save-a", controls.profiles["A"]) + assertEquals("profile-b", controls.profiles["B"]) + assertEquals("profile-b", controls.live) + } + + @Test + fun failedEnableCompensatesWithoutSavingTheBrokenOwnerAgain() { + val controls = FakeControls( + live = "shared", + profiles = mutableMapOf("B" to "profile-b"), + ) + controls.transitions.initialize("B", false) + controls.failProfileSave = "B" + + assertThrows(IllegalStateException::class.java) { + controls.transitions.change(true) + } + + assertEquals("shared", controls.live) + assertEquals(MinecraftKeybindLiveOwner("B", false), controls.transitions.liveOwner()) + + // This matches ConfigManager rolling the preference back after the listener failure. + controls.transitions.change(false) + assertEquals("shared", controls.live) + assertEquals(MinecraftKeybindLiveOwner("B", false), controls.transitions.liveOwner()) + } + + @Test + fun failedDisableCompensatesWithoutSavingTheBrokenSharedSlotAgain() { + val controls = FakeControls( + live = "profile-a", + profiles = mutableMapOf("A" to "profile-a"), + ) + controls.transitions.initialize("A", true) + controls.shared = "shared" + controls.failSharedSave = true + + assertThrows(IllegalStateException::class.java) { + controls.transitions.change(false) + } + + assertEquals("profile-a", controls.live) + assertEquals(MinecraftKeybindLiveOwner("A", true), controls.transitions.liveOwner()) + controls.transitions.change(true) + assertEquals("profile-a", controls.live) + } + + @Test + fun failedPostCommitProfileRestoreStillAdoptsTheTargetOwner() { + val controls = FakeControls( + live = "profile-a", + profiles = mutableMapOf("A" to "profile-a", "B" to "profile-b"), + ) + controls.transitions.initialize("A", true) + controls.failProfileRestore = "B" + + assertThrows(IllegalStateException::class.java) { + controls.transitions.profileChanged("B") + } + + assertEquals("profile-a", controls.live) + assertEquals(MinecraftKeybindLiveOwner("B", true), controls.transitions.liveOwner()) + } + + @Test + fun failedRootRestoreAfterDeleteStillAdoptsTheRootOwner() { + val controls = FakeControls( + live = "profile-a", + profiles = mutableMapOf("A" to "profile-a", "" to "root"), + ) + controls.transitions.initialize("A", true) + controls.failProfileRestore = "" + + assertThrows(IllegalStateException::class.java) { + controls.transitions.profileDeleted("A") + } + + assertEquals("profile-a", controls.live) + assertEquals(MinecraftKeybindLiveOwner("", true), controls.transitions.liveOwner()) + } + + private class FakeControls( + var live: String, + val profiles: MutableMap, + ) { + var shared: String? = null + val capturedOptionsOwners = mutableListOf() + val ownersSeenDuringProfileSave = mutableListOf() + val profileSaveCalls = mutableListOf() + var failProfileSave: String? = null + var failProfileRestore: String? = null + var failSharedSave = false + + val transitions: MinecraftKeybindModeTransitions + + init { + lateinit var initializedTransitions: MinecraftKeybindModeTransitions + initializedTransitions = MinecraftKeybindModeTransitions( + saveProfile = { profile -> + profileSaveCalls += profile + ownersSeenDuringProfileSave += initializedTransitions.liveOwner() + if (profile == failProfileSave) throw IllegalStateException("profile save failed") + profiles[profile] = live + }, + restoreProfile = { profile -> + if (profile == failProfileRestore) throw IllegalStateException("profile restore failed") + val saved = profiles[profile] + if (saved != null) { + live = saved + optionsSaved(initializedTransitions) + } + }, + saveShared = { + if (failSharedSave) throw IllegalStateException("shared save failed") + shared = live + }, + restoreShared = { + val saved = shared + if (saved != null) { + live = saved + optionsSaved(initializedTransitions) + } + }, + ) + transitions = initializedTransitions + } + + fun optionsSaved() = optionsSaved(transitions) + + fun preSaveLiveOwner() { + val owner = transitions.liveOwner() ?: return + if (owner.separateControls) profiles[owner.profile] = live + else shared = live + } + + private fun optionsSaved(transitions: MinecraftKeybindModeTransitions) { + transitions.captureSavedOptions { owner -> + capturedOptionsOwners += owner + if (owner.separateControls) profiles[owner.profile] = live + else shared = live + } + } + } +} diff --git a/modules/config-impl/api/config-impl.api b/modules/config-impl/api/config-impl.api index 406fdba26..121604d7c 100644 --- a/modules/config-impl/api/config-impl.api +++ b/modules/config-impl/api/config-impl.api @@ -1,17 +1,26 @@ public final class org/polyfrost/oneconfig/api/config/v1/CompatSnapshotStore { public fun ()V public fun (Ljava/lang/String;)V + public fun deleteProfile (Ljava/lang/String;)V public fun flush (Ljava/lang/String;)V + public fun flushOrThrow (Ljava/lang/String;)V public fun getValue (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/Object; + public fun hasLoadFailure (Ljava/lang/String;)Z public fun load (Ljava/lang/String;)Ljava/util/Map; public fun putValue (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;)V + public fun renameProfile (Ljava/lang/String;Ljava/lang/String;)V } public final class org/polyfrost/oneconfig/api/config/v1/CompatSnapshots : org/polyfrost/oneconfig/api/config/v1/ConfigManager$ProfileChangeListener { + public static final field CUSTOM_RESET_METADATA Ljava/lang/String; public static final field INSTANCE Lorg/polyfrost/oneconfig/api/config/v1/CompatSnapshots; public static final field NO_SNAPSHOT_META Ljava/lang/String; public static final field SNAPSHOT_METADATA Ljava/lang/String; public fun onProfileChanged (Ljava/lang/String;)V + public fun onProfileCreated (Ljava/lang/String;)V + public fun onProfileDeleted (Ljava/lang/String;)V + public fun onProfileRenamed (Ljava/lang/String;Ljava/lang/String;)V + public fun onProfileSaving (Ljava/lang/String;)V public static fun register (Lorg/polyfrost/oneconfig/api/config/v1/Tree;)Lorg/polyfrost/oneconfig/api/config/v1/Tree; public static fun setDispatcher (Ljava/util/function/Consumer;)V } @@ -35,6 +44,7 @@ public abstract class org/polyfrost/oneconfig/api/config/v1/Config { protected fun addMigrationEntry (Ljava/lang/String;Ljava/lang/String;)V protected fun addToInitQueue ()V public static fun captureDefaults (Lorg/polyfrost/oneconfig/api/config/v1/Tree;)V + public static fun copyDefault (Ljava/lang/Class;Ljava/lang/Object;)Ljava/lang/Object; protected fun getProperty (Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Property; protected static fun getProperty (Lorg/polyfrost/oneconfig/api/config/v1/Tree;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Property; public fun getTree ()Lorg/polyfrost/oneconfig/api/config/v1/Tree; @@ -45,6 +55,7 @@ public abstract class org/polyfrost/oneconfig/api/config/v1/Config { protected fun loadFrom (Ljava/nio/file/Path;)V protected fun makeTree ()Lorg/polyfrost/oneconfig/api/config/v1/Tree; public fun preload ()V + public static fun restoreCapturedDefaults (Lorg/polyfrost/oneconfig/api/config/v1/Tree;)V protected fun restoreDefaults ()V protected fun restoreProperty (Ljava/lang/String;)V public fun save ()V @@ -73,11 +84,14 @@ public final class org/polyfrost/oneconfig/api/config/v1/ConfigManager { public static fun addProfileChangeListener (Lorg/polyfrost/oneconfig/api/config/v1/ConfigManager$ProfileChangeListener;)V public static fun addTreeRegistrationListener (Lorg/polyfrost/oneconfig/api/config/v1/ConfigManager$TreeRegistrationListener;)V public static fun backup ()Lorg/polyfrost/oneconfig/api/config/v1/ConfigManager; + public static fun cloneProfile (Ljava/lang/String;Ljava/lang/String;)V public static fun collect (Ljava/lang/Object;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Tree; public static fun core ()Lorg/polyfrost/oneconfig/api/config/v1/ConfigManager; public static fun createProfile (Ljava/lang/String;)V public fun delete (Ljava/lang/String;)Z public static fun deleteProfile (Ljava/lang/String;)V + public static fun dispatchAndWait (Ljava/util/function/Consumer;Ljava/lang/Runnable;JLjava/lang/String;)V + public static fun exportProfile (Ljava/lang/String;Ljava/nio/file/Path;)V public static fun favoriteProfiles ()Ljava/util/List; public fun gatherAll (Ljava/lang/String;)Ljava/util/Collection; public fun get (Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Tree; @@ -87,27 +101,37 @@ public final class org/polyfrost/oneconfig/api/config/v1/ConfigManager { public static fun internal ()Lorg/polyfrost/oneconfig/api/config/v1/ConfigManager; public static fun isFavoriteProfile (Ljava/lang/String;)Z public static fun isFirstRun ()Z + public static fun isRebindingProfiles ()Z public fun load (Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Tree; public static fun openProfile (Ljava/lang/String;)V public static fun profileDir (Ljava/lang/String;)Ljava/nio/file/Path; public static fun profileIcon (Ljava/lang/String;)Ljava/lang/String; public static fun profileIcons ()Ljava/util/Map; + public static fun profileSpecificControls ()Z public static fun profiles ()Ljava/util/List; public fun register (Ljava/lang/Object;Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Tree; public fun register (Lorg/polyfrost/oneconfig/api/config/v1/Tree;)Lorg/polyfrost/oneconfig/api/config/v1/backend/Backend$RegistrationResult; public static fun registerCollector (Lorg/polyfrost/oneconfig/api/config/v1/collect/PropertyCollector;)V + public static fun removeProfileChangeListener (Lorg/polyfrost/oneconfig/api/config/v1/ConfigManager$ProfileChangeListener;)V public static fun renameProfile (Ljava/lang/String;Ljava/lang/String;)V public fun save (Ljava/lang/String;)Z public fun save (Lorg/polyfrost/oneconfig/api/config/v1/Tree;)Z public fun saveAll ()V public static fun setFavoriteProfile (Ljava/lang/String;Z)V public static fun setProfileIcon (Ljava/lang/String;Ljava/lang/String;)V + public static fun setProfileSpecificControls (Z)V public static fun submitForInitialization (Lorg/polyfrost/oneconfig/api/config/v1/Config;)V public fun trees ()Ljava/util/Collection; + public fun unregister (Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Tree; } public abstract interface class org/polyfrost/oneconfig/api/config/v1/ConfigManager$ProfileChangeListener { public abstract fun onProfileChanged (Ljava/lang/String;)V + public fun onProfileCreated (Ljava/lang/String;)V + public fun onProfileDeleted (Ljava/lang/String;)V + public fun onProfileRenamed (Ljava/lang/String;Ljava/lang/String;)V + public fun onProfileSaving (Ljava/lang/String;)V + public fun onProfileSpecificControlsChanged (Z)V } public abstract interface class org/polyfrost/oneconfig/api/config/v1/ConfigManager$TreeRegistrationListener { diff --git a/modules/config-impl/src/j21Tests/kotlin/org/polyfrost/oneconfig/api/config/v1/KtConfigProfileRebindTest.kt b/modules/config-impl/src/j21Tests/kotlin/org/polyfrost/oneconfig/api/config/v1/KtConfigProfileRebindTest.kt index 634001576..28c2bde9f 100644 --- a/modules/config-impl/src/j21Tests/kotlin/org/polyfrost/oneconfig/api/config/v1/KtConfigProfileRebindTest.kt +++ b/modules/config-impl/src/j21Tests/kotlin/org/polyfrost/oneconfig/api/config/v1/KtConfigProfileRebindTest.kt @@ -72,6 +72,7 @@ class KtConfigProfileRebindTest { config.save() ConfigManager.createProfile(PROFILE) + assertFalse(config.enabled, "new profile should start from Kotlin config defaults") config.enabled = false config.save() diff --git a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/CompatSnapshotStore.java b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/CompatSnapshotStore.java index 44a70ec6b..80babd4a1 100644 --- a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/CompatSnapshotStore.java +++ b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/CompatSnapshotStore.java @@ -35,11 +35,17 @@ import com.electronwill.nightconfig.core.io.ParsingMode; import com.electronwill.nightconfig.json.JsonFormat; import com.electronwill.nightconfig.json.JsonParser; +import org.jetbrains.annotations.Nullable; +import java.io.IOException; import java.lang.reflect.Constructor; import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.NoSuchFileException; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -48,10 +54,12 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; @org.jetbrains.annotations.ApiStatus.Internal public final class CompatSnapshotStore { static final String FILE_NAME = "compat-snapshots.json"; + static final int MAX_QUARANTINED = 3; private final String fileName; private final ConfigWriter writer = JsonFormat.fancyInstance().createWriter(); @@ -66,6 +74,7 @@ public CompatSnapshotStore(String fileName) { } private final Map>> cache = new ConcurrentHashMap<>(); + private final Map loadFailures = new ConcurrentHashMap<>(); private final ScheduledExecutorService flusher = Executors.newSingleThreadScheduledExecutor(r -> { Thread t = new Thread(r, "OneConfig-CompatSnapshots"); @@ -74,64 +83,243 @@ public CompatSnapshotStore(String fileName) { }); private final Map> pendingFlush = new ConcurrentHashMap<>(); - public Map> load(String profile) { - return cache.computeIfAbsent(profile, this::readFromDisk); + private static IllegalStateException poisoned(String profile, IllegalStateException cause) { + return new IllegalStateException("Compat snapshot for profile '" + profile + "' is unreadable", cause); } - public Object getValue(String profile, String treeId, String key) { + public synchronized Map> load(String profile) { + IllegalStateException previousFailure = loadFailures.get(profile); + if (previousFailure != null) throw poisoned(profile, previousFailure); + Map> snapshot = cache.get(profile); + if (snapshot != null) return snapshot; + try { + snapshot = readFromDisk(profile); + } catch (IllegalStateException failure) { + snapshot = quarantine(profile, failure); + if (snapshot == null) { + loadFailures.put(profile, failure); + throw failure; + } + } + cache.put(profile, snapshot); + return snapshot; + } + + private @Nullable Map> quarantine(String profile, IllegalStateException failure) { + if (!(failure instanceof MalformedSnapshotException)) return null; + Path file = ConfigManager.profileDir(profile).resolve(fileName); + Path corrupt = nextFreeName(file.resolveSibling(fileName + ".corrupt")); + try { + Files.move(file, corrupt, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException | RuntimeException moveFailure) { + failure.addSuppressed(moveFailure); + return null; + } + ConfigManager.LOGGER.error( + "Compat snapshot for profile '{}' is unreadable; moved it to {} and started a new one", + profile, corrupt, failure + ); + return new ConcurrentHashMap<>(); + } + + private static Path nextFreeName(Path preferred) { + Path candidate = preferred; + for (int i = 2; i <= MAX_QUARANTINED && Files.exists(candidate, LinkOption.NOFOLLOW_LINKS); i++) { + candidate = preferred.resolveSibling(preferred.getFileName() + "." + i); + } + return candidate; + } + + public synchronized Object getValue(String profile, String treeId, String key) { Map tree = load(profile).get(treeId); return tree == null ? null : tree.get(key); } - public void putValue(String profile, String treeId, String key, Object serialized) { + public synchronized boolean hasLoadFailure(String profile) { + if (loadFailures.containsKey(profile)) return true; + if (cache.containsKey(profile)) return false; + try { + readFromDisk(profile); + return false; + } catch (IllegalStateException failure) { + if (quarantine(profile, failure) != null) return false; + loadFailures.put(profile, failure); + return true; + } + } + + public synchronized void putValue(String profile, String treeId, String key, Object serialized) { + putValue(profile, treeId, key, serialized, true); + } + + synchronized void putValueWithoutScheduling(String profile, String treeId, String key, Object serialized) { + putValue(profile, treeId, key, serialized, false); + } + + private void putValue(String profile, String treeId, String key, Object serialized, boolean schedule) { load(profile).computeIfAbsent(treeId, k -> new ConcurrentHashMap<>()).put(key, serialized); - scheduleFlush(profile); + if (schedule) scheduleFlush(profile); } - private void scheduleFlush(String profile) { - ScheduledFuture prev = pendingFlush.put(profile, flusher.schedule(() -> { - pendingFlush.remove(profile); - flush(profile); - }, 200, TimeUnit.MILLISECONDS)); - if (prev != null) prev.cancel(false); + private synchronized void scheduleFlush(String profile) { + if (pendingFlush.containsKey(profile)) return; + AtomicReference> scheduled = new AtomicReference<>(); + ScheduledFuture next = flusher.schedule( + () -> flushScheduled(profile, scheduled.get()), 200, TimeUnit.MILLISECONDS + ); + scheduled.set(next); + pendingFlush.put(profile, next); + } + + private synchronized void flushScheduled(String profile, ScheduledFuture expected) { + if (expected != null && pendingFlush.remove(profile, expected)) flush(profile); } public synchronized void flush(String profile) { - Map> snapshot = cache.get(profile); - if (snapshot == null) return; - Path file = ConfigManager.profileDir(profile).resolve(fileName); try { - Files.createDirectories(file.getParent()); - Files.write(file, writer.writeToString(toConfig(snapshot)).getBytes(StandardCharsets.UTF_8)); + flushInternal(profile, false); } catch (Exception e) { ConfigManager.LOGGER.error("Failed to write compat snapshot for profile '{}'", profile, e); } } + /** + * Flushes a snapshot for a profile lifecycle operation, propagating any failure to the caller. + * Unlike {@link #flush(String)}, a missing named profile directory is an error rather than a + * silently ignored delayed write. + */ + public synchronized void flushOrThrow(String profile) throws IOException { + flushInternal(profile, true); + } + + private void flushInternal(String profile, boolean requireProfileDirectory) throws IOException { + cancelPending(profile); + IllegalStateException loadFailure = loadFailures.get(profile); + if (loadFailure != null) throw poisoned(profile, loadFailure); + Map> snapshot = cache.get(profile); + if (snapshot == null) return; + Path profileDir = ConfigManager.profileDir(profile); + // A delayed flush must not recreate a profile that was just renamed or deleted. + if (!profile.isEmpty() && !Files.isDirectory(profileDir)) { + if (requireProfileDirectory) throw new NoSuchFileException(profileDir.toString()); + return; + } + Path file = profileDir.resolve(fileName); + // Named profile directories are lifecycle-managed by ConfigManager. Recreating one here + // after a concurrent rename/delete would resurrect a profile from a delayed flush. + if (profile.isEmpty()) Files.createDirectories(file.getParent()); + final byte[] bytes; + try { + bytes = writer.writeToString(toConfig(snapshot)).getBytes(StandardCharsets.UTF_8); + } catch (Exception failure) { + throw new IOException("Failed to serialize compat snapshot for profile '" + profile + "'", failure); + } + writeAtomically(file, bytes); + } + + private static void writeAtomically(Path file, byte[] bytes) throws IOException { + Path parent = file.getParent(); + if (parent == null) throw new NoSuchFileException(file.toString()); + Path temporary = Files.createTempFile(parent, "." + file.getFileName() + ".", ".tmp"); + try { + Files.write(temporary, bytes); + try { + Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException ignored) { + Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException | RuntimeException failure) { + try { + Files.deleteIfExists(temporary); + } catch (IOException cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + throw failure; + } + } + + public synchronized void renameProfile(String oldProfile, String newProfile) throws IOException { + cancelPending(oldProfile); + cancelPending(newProfile); + IllegalStateException loadFailure = loadFailures.remove(oldProfile); + loadFailures.remove(newProfile); + Map> snapshot = cache.remove(oldProfile); + Map> updates = cache.remove(newProfile); + if (loadFailure != null) { + // The filesystem rename moved the unreadable source file. Keep the destination poisoned + // rather than allowing an unrelated update under its new identity to overwrite it. + loadFailures.put(newProfile, loadFailure); + return; + } + if (snapshot == null) { + if (updates != null) { + cache.put(newProfile, updates); + flushOrThrow(newProfile); + } + return; + } + if (updates != null) { + for (Map.Entry> entry : updates.entrySet()) { + snapshot.computeIfAbsent(entry.getKey(), ignored -> new ConcurrentHashMap<>()) + .putAll(entry.getValue()); + } + } + cache.put(newProfile, snapshot); + flushOrThrow(newProfile); + } + + public synchronized void deleteProfile(String profile) { + cancelPending(profile); + cache.remove(profile); + loadFailures.remove(profile); + } + + private void cancelPending(String profile) { + ScheduledFuture pending = pendingFlush.remove(profile); + if (pending != null) pending.cancel(false); + } + @SuppressWarnings("unchecked") private Map> readFromDisk(String profile) { Map> out = new ConcurrentHashMap<>(); Path file = ConfigManager.profileDir(profile).resolve(fileName); - if (!Files.isRegularFile(file)) return out; + if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return out; + if (!Files.isRegularFile(file)) { + throw new IllegalStateException("Compat snapshot is not a regular file: " + file); + } + final String text; + try { + text = new String(Files.readAllBytes(file), StandardCharsets.UTF_8); + } catch (Exception failure) { + throw new IllegalStateException("Failed to read compat snapshot for profile '" + profile + "'", failure); + } try { - String text = new String(Files.readAllBytes(file), StandardCharsets.UTF_8); - if (text.isEmpty()) return out; + if (text.trim().isEmpty()) return out; BackedConfig cfg = new BackedConfig(new HashMap<>()); parser.parse(text, cfg, ParsingMode.MERGE); Map root = fromConfig(cfg); for (Map.Entry e : root.entrySet()) { - if (e.getValue() instanceof Map) { - Map tree = new ConcurrentHashMap<>(); - tree.putAll((Map) e.getValue()); - out.put(e.getKey(), tree); + if (!(e.getValue() instanceof Map)) { + throw new IOException("Snapshot namespace '" + e.getKey() + "' is not an object"); } + Map tree = new ConcurrentHashMap<>(); + tree.putAll((Map) e.getValue()); + out.put(e.getKey(), tree); } - } catch (Exception e) { - ConfigManager.LOGGER.error("Failed to read compat snapshot for profile '{}'", profile, e); + } catch (Exception failure) { + throw new MalformedSnapshotException( + "Failed to parse compat snapshot for profile '" + profile + "'", failure + ); } return out; } + private static final class MalformedSnapshotException extends IllegalStateException { + MalformedSnapshotException(String message, Throwable cause) { + super(message, cause); + } + } + private static Config toConfig(Map map) { Map backing = new HashMap<>(map.size(), 1f); for (Map.Entry e : map.entrySet()) { 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 c19007fe6..b987500f8 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 @@ -44,12 +44,15 @@ public final class CompatSnapshots implements ConfigManager.ProfileChangeListene public static final CompatSnapshots INSTANCE = new CompatSnapshots(); public static final String SNAPSHOT_METADATA = "oc_compat_snapshot"; + public static final String CUSTOM_RESET_METADATA = "custom_reset"; private static final String TAG = SNAPSHOT_METADATA; private final CompatSnapshotStore store = new CompatSnapshotStore(); private final CompatSnapshotStore baselineStore = new CompatSnapshotStore("compat-baseline.json"); private static final String BASELINE_BUCKET = ""; + private static final long DISPATCH_TIMEOUT_SECONDS = 30L; private final Map known = new ConcurrentHashMap<>(); + private final Map> defaults = new ConcurrentHashMap<>(); private final Map, Boolean> wired = Collections.synchronizedMap(new WeakHashMap<>()); private final Set> applying = Collections.synchronizedSet(Collections.newSetFromMap(new IdentityHashMap<>())); private volatile java.util.function.Consumer dispatcher = Runnable::run; @@ -76,6 +79,7 @@ private Tree register0(Tree tree) { known.put(reg.getID(), reg); String profile = ConfigManager.activeProfile(); if (currentProfile == null) currentProfile = profile; + captureDefaults(reg); wire(reg); dispatcher.accept(() -> { try { @@ -91,16 +95,8 @@ private Tree register0(Tree tree) { public void onProfileChanged(String newProfile) { String old = currentProfile != null ? currentProfile : ConfigManager.activeProfile(); currentProfile = newProfile; + if (newProfile.equals(old)) return; dispatcher.accept(() -> { - if (old != null && !old.equals(newProfile)) { - for (Tree tree : known.values()) { - try { - captureAll(tree, old); - } catch (Throwable t) { - ConfigManager.LOGGER.error("Failed to capture compat snapshot for '{}'", tree.getID(), t); - } - } - } for (Tree tree : known.values()) { try { applyProfile(tree, newProfile); @@ -111,6 +107,93 @@ public void onProfileChanged(String newProfile) { }); } + @Override + public void onProfileSaving(String profile) { + if (store.hasLoadFailure(profile)) { + ConfigManager.LOGGER.warn( + "Continuing the profile operation without rewriting unreadable compat snapshot '{}'", profile + ); + return; + } + if (!profile.equals(currentProfile)) { + flushForLifecycle(store, profile); + return; + } + dispatchAndWait(() -> { + for (Tree tree : known.values()) { + captureAll(tree, profile); + } + }); + flushSnapshotThenBaseline(store, profile, baselineStore, BASELINE_BUCKET); + } + + @Override + public void onProfileCreated(String profile) { + store.deleteProfile(profile); + currentProfile = profile; + dispatchAndWait(() -> { + for (Tree tree : known.values()) { + try { + restoreDefaults(tree); + captureAll(tree, profile); + } catch (Throwable t) { + ConfigManager.LOGGER.error("Failed to initialize compat defaults for '{}'", tree.getID(), t); + } + } + }); + flushSnapshotThenBaseline(store, profile, baselineStore, BASELINE_BUCKET); + } + + @Override + public void onProfileRenamed(String oldProfile, String newProfile) { + if (oldProfile.equals(currentProfile)) currentProfile = newProfile; + try { + store.renameProfile(oldProfile, newProfile); + } catch (java.io.IOException e) { + throw new IllegalStateException("Failed to move compat snapshot to profile '" + newProfile + "'", e); + } + } + + @Override + public void onProfileDeleted(String profile) { + store.deleteProfile(profile); + if (profile.equals(currentProfile)) { + currentProfile = ""; + dispatchAndWait(() -> { + for (Tree tree : known.values()) { + applyProfile(tree, ""); + } + }); + } + } + + private void dispatchAndWait(Runnable action) { + ConfigManager.dispatchAndWait( + dispatcher, + action, + java.util.concurrent.TimeUnit.SECONDS.toNanos(DISPATCH_TIMEOUT_SECONDS), + "the compat snapshot dispatcher" + ); + } + + private static void flushForLifecycle(CompatSnapshotStore snapshotStore, String profile) { + try { + snapshotStore.flushOrThrow(profile); + } catch (java.io.IOException e) { + throw new IllegalStateException("Failed to save compat snapshot for profile '" + profile + "'", e); + } + } + + static void flushSnapshotThenBaseline( + CompatSnapshotStore snapshotStore, + String profile, + CompatSnapshotStore baselineStore, + String baselineProfile + ) { + flushForLifecycle(snapshotStore, profile); + flushForLifecycle(baselineStore, baselineProfile); + } + private void applyProfile(Tree tree, String profile) { ensureKeys(tree); String treeId = tree.getID(); @@ -160,8 +243,11 @@ private void applyProfile(Tree tree, String profile) { applying.remove(p); } }); - store.flush(profile); - baselineStore.flush(BASELINE_BUCKET); + // Persist the profile snapshot before its baseline. If the first write fails, keeping an + // older baseline is safe: the next load treats the live value as an external change and + // repairs the snapshot. The opposite order could make a stale snapshot look current and + // roll a setting back after a restart. + flushSnapshotThenBaseline(store, profile, baselineStore, BASELINE_BUCKET); if (changed[0]) runSave(tree); } @@ -177,8 +263,50 @@ private void captureAll(Tree tree, String profile) { setBaseline(treeId, key, serialized); } }); - store.flush(profile); - baselineStore.flush(BASELINE_BUCKET); + } + + private void captureDefaults(Tree tree) { + ensureKeys(tree); + Map snapshot = defaults.computeIfAbsent(tree.getID(), ignored -> new ConcurrentHashMap<>()); + forEachProp(tree, property -> { + if (!isValueProp(property)) return; + Object defaultValue = property.getMetadata("default"); + Object serialized = trySerialize(defaultValue != null ? defaultValue : property.get()); + if (serialized != null) snapshot.putIfAbsent(keyOf(property), serialized); + }); + } + + private void restoreDefaults(Tree tree) { + if (runCustomReset(tree)) return; + Map snapshot = defaults.get(tree.getID()); + if (snapshot == null) return; + boolean[] changed = {false}; + forEachProp(tree, property -> { + Object stored = snapshot.get(keyOf(property)); + if (stored == null) return; + Object value; + try { + value = deserialize(stored); + } catch (Throwable t) { + ConfigManager.LOGGER.warn("Failed to deserialize compat default for '{}'", keyOf(property), t); + return; + } + Object live = property.get(); + if (live != null && value != null && live.getClass() != value.getClass() + && !(live instanceof Number && value instanceof Number)) { + return; + } + applying.add(property); + try { + property.setAsReferential(value); + changed[0] = true; + } catch (Throwable t) { + ConfigManager.LOGGER.warn("Failed to apply compat default for '{}'", keyOf(property), t); + } finally { + applying.remove(property); + } + }); + if (changed[0]) runSave(tree); } private void wire(Tree tree) { @@ -203,6 +331,19 @@ private void wire(Tree tree) { }); } + private boolean runCustomReset(Tree tree) { + Object customReset = tree.getMetadata(CUSTOM_RESET_METADATA); + if (!(customReset instanceof Runnable)) return false; + try { + ((Runnable) customReset).run(); + } catch (Throwable t) { + ConfigManager.LOGGER.warn("custom_reset failed for compat tree '{}'", tree.getID(), t); + return false; + } + runSave(tree); + return true; + } + private void runSave(Tree tree) { Object customSave = tree.getMetadata("custom_save"); if (customSave instanceof Runnable) { @@ -219,7 +360,9 @@ private Object getBaseline(String treeId, String key) { } private void setBaseline(String treeId, String key, Object serialized) { - baselineStore.putValue(BASELINE_BUCKET, treeId, key, serialized); + // Baselines are deliberately not scheduled independently. They are only made durable + // after the corresponding profile snapshot has been flushed successfully. + baselineStore.putValueWithoutScheduling(BASELINE_BUCKET, treeId, key, serialized); } @SuppressWarnings("unchecked") 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 c459ed7a2..ae1311cef 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 @@ -34,12 +34,17 @@ import org.polyfrost.oneconfig.api.config.v1.serialize.ObjectSerializer; import org.polyfrost.oneconfig.utils.v1.WrappingUtils; +import java.lang.reflect.Array; import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.BooleanSupplier; import java.util.function.Predicate; import java.util.function.Supplier; @@ -144,6 +149,19 @@ public static void captureDefaults(Tree tree) { captureDefaults(tree, null, null); } + @ApiStatus.Internal + public static void restoreCapturedDefaults(Tree tree) { + for (Node node : tree.map.values()) { + if (node instanceof Property) { + Property property = (Property) node; + Object value = property.getMetadata("default"); + if (value != null) property.setAsReferential(copyDefault(property.type, value)); + } else if (node instanceof Tree) { + restoreCapturedDefaults((Tree) node); + } + } + } + private static void captureDefaults(Tree tree, String prefix, Map out) { for (Map.Entry entry : tree.map.entrySet()) { Node node = entry.getValue(); @@ -173,9 +191,12 @@ private static void applyDefaultSnapshot(Tree tree, String prefix, Map type, Object value) { + public static Object copyDefault(Class type, Object value) { if (WrappingUtils.isSimpleClass(type)) return value; + Object container = copyContainer(value); + if (container != null) return container; try { Object serialized = ObjectSerializer.INSTANCE.serialize(value, false, false); if (serialized instanceof Map) { @@ -188,8 +209,79 @@ private static Object copyDefault(Class type, Object value) { return value; } + @SuppressWarnings("unchecked") + private static @Nullable Object copyContainer(@Nullable Object value) { + if (value == null) return null; + Class cls = value.getClass(); + try { + if (cls.isArray()) { + Class component = cls.getComponentType(); + Object copy = shallowCopy(value, cls); + if (copy == null || component.isPrimitive()) return copy; + for (int i = 0, length = Array.getLength(copy); i < length; i++) { + Object entry = Array.get(copy, i); + Object copied = copyEntry(entry); + if (copied == entry) continue; + try { + Array.set(copy, i, copied); + } catch (IllegalArgumentException mismatch) { + ConfigManager.LOGGER.warn("failed to copy default array entry of type {}", component, mismatch); + } + } + return copy; + } + if (value instanceof Collection) { + Collection in = (Collection) value; + Collection out = value instanceof Set ? new LinkedHashSet<>() : new ArrayList<>(in.size()); + for (Object entry : in) out.add(copyEntry(entry)); + return out; + } + if (value instanceof Map) { + LinkedHashMap out = new LinkedHashMap<>(); + for (Map.Entry entry : ((Map) value).entrySet()) { + out.put(entry.getKey(), copyEntry(entry.getValue())); + } + return out; + } + } catch (Throwable t) { + ConfigManager.LOGGER.warn("failed to copy default container of type {}", cls, t); + return shallowCopy(value, cls); + } + return null; + } + + @SuppressWarnings("unchecked") + private static @Nullable Object shallowCopy(Object value, Class cls) { + try { + if (cls.isArray()) { + int length = Array.getLength(value); + Object copy = Array.newInstance(cls.getComponentType(), length); + //noinspection SuspiciousSystemArraycopy + System.arraycopy(value, 0, copy, 0, length); + return copy; + } + if (value instanceof Set) return new LinkedHashSet<>((Set) value); + if (value instanceof Collection) return new ArrayList<>((Collection) value); + if (value instanceof Map) return new LinkedHashMap<>((Map) value); + } catch (Throwable t) { + ConfigManager.LOGGER.warn("failed to copy default container of type {}", cls, t); + } + return null; + } + + private static @Nullable Object copyEntry(@Nullable Object entry) { + if (entry == null) return null; + try { + return copyDefault(entry.getClass(), entry); + } catch (Throwable t) { + ConfigManager.LOGGER.warn("failed to copy default entry of type {}", entry.getClass(), t); + return entry; + } + } + @ApiStatus.Internal - void rebindToActiveProfile() { + void rebindToActiveProfile(boolean restoreDefaults) { + if (restoreDefaults) restoreDefaults(); tree = null; initialize(true); } 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 58855f296..ef25e8909 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 @@ -43,13 +43,23 @@ import java.io.IOException; import java.nio.file.DirectoryStream; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.LinkOption; import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.Paths; -import java.nio.file.Files; import java.nio.file.StandardCopyOption; import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Consumer; import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; import static org.polyfrost.oneconfig.api.config.v1.Tree.tree; @@ -67,7 +77,11 @@ public final class ConfigManager { // public static List newOrUpdatedModIds; private static final Queue pendingInitialization = new ArrayDeque<>(); private static final Map initializedConfigs = new LinkedHashMap<>(); - private static boolean rebindingProfiles = false; + private static final ReentrantLock PROFILE_LIFECYCLE_LOCK = new ReentrantLock(); + private static final long PROFILE_OPERATION_BUDGET_NANOS = java.util.concurrent.TimeUnit.SECONDS.toNanos(30L); + private static final long MINIMUM_WAIT_NANOS = java.util.concurrent.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 java.util.concurrent.CopyOnWriteArrayList profileListeners = new java.util.concurrent.CopyOnWriteArrayList<>(); private static final java.util.concurrent.CopyOnWriteArrayList treeListeners = new java.util.concurrent.CopyOnWriteArrayList<>(); @@ -75,6 +89,21 @@ public final class ConfigManager { public interface ProfileChangeListener { void onProfileChanged(String newProfile); + + default void onProfileSaving(String profile) { + } + + default void onProfileCreated(String profile) { + } + + default void onProfileRenamed(String oldProfile, String newProfile) { + } + + default void onProfileDeleted(String profile) { + } + + default void onProfileSpecificControlsChanged(boolean enabled) { + } } public interface TreeRegistrationListener { @@ -90,6 +119,10 @@ public static void addTreeRegistrationListener(TreeRegistrationListener listener treeListeners.add(listener); } + public static void removeProfileChangeListener(ProfileChangeListener listener) { + profileListeners.remove(listener); + } + public static Path profileDir(String profile) { profile = normalizeProfileName(profile, true); return profile.isEmpty() ? Paths.get("config") : PROFILES_DIR.resolve(profile); @@ -131,7 +164,12 @@ public static ConfigManager backup() { * Returns a reference to the active config manager, which is mounted to the current active profile. */ public static synchronized ConfigManager active() { - if (active == null) initProfiles(); + return activeLocked(); + } + + private static ConfigManager activeLocked() { + if (active != null) return active; + initProfiles(); return active; } @@ -173,8 +211,9 @@ static synchronized void markInitialized(Config config) { initializedConfigs.put(config.id, config); } - static boolean isRebindingProfiles() { - return rebindingProfiles; + @ApiStatus.Internal + public static boolean isRebindingProfiles() { + return REBINDING_PROFILES.get(); } /*private static List doModsListScan() { @@ -246,13 +285,20 @@ private static void notifyResetOptions(Config config, List options) { } } - private static synchronized void initProfiles() { + private static void initProfiles() { addProfileChangeListener(CompatSnapshots.INSTANCE); + Property ownedProfileSubdirs = Properties.simple( + "ownedProfileSubdirs", "Owned Profile Subdirectories", + "Profile-backed config directories known to OneConfig.", new String[0], String[].class + ); + ownedProfileSubdirs.addMetadata("hidden", true); Backend.RegistrationResult result = internal().register( tree("profiles.json").put( Properties.simple("activeProfile", "Active Profile", "The profile which is currently open.", ""), Properties.simple("favoriteProfiles", "Favorite Profiles", "Profiles marked as favorites.", new String[0], String[].class), - Properties.simple("profileIcons", "Profile Icons", "Icon names assigned to profiles.", new String[0], String[].class) + Properties.simple("profileIcons", "Profile Icons", "Icon names assigned to profiles.", new String[0], String[].class), + Properties.simple("profileSpecificControls", "Profile-specific Controls", "Whether Minecraft controls are stored per profile.", true), + ownedProfileSubdirs ) ); if (result.state == Backend.RegistrationResult.NEW) { @@ -268,6 +314,19 @@ private static synchronized void initProfiles() { result.get().put(Properties.simple("profileIcons", "Profile Icons", "Icon names assigned to profiles.", new String[0], String[].class)); internal().save("profiles.json"); } + if (result.get().getProp("profileSpecificControls") == null) { + result.get().put(Properties.simple("profileSpecificControls", "Profile-specific Controls", "Whether Minecraft controls are stored per profile.", true)); + internal().save("profiles.json"); + } + if (result.get().getProp("ownedProfileSubdirs") == null) { + Property property = Properties.simple( + "ownedProfileSubdirs", "Owned Profile Subdirectories", + "Profile-backed config directories known to OneConfig.", new String[0], String[].class + ); + property.addMetadata("hidden", true); + result.get().put(property); + internal().save("profiles.json"); + } String activeProfile = result.get().getProp("activeProfile").getAs(); try { activeProfile = normalizeProfileName(activeProfile, true); @@ -279,14 +338,36 @@ private static synchronized void initProfiles() { LOGGER.warn("Active profile {} does not exist, falling back to root", activeProfile); activeProfile = ""; } - openProfile(activeProfile); + openProfile(activeProfile, false); } - public static synchronized void openProfile(String profile) { - openProfile(profile, true); + public static void openProfile(String profile) { + runProfileOperation(() -> openProfile0(profile)); + } + + private static void openProfile0(String profile) { + openProfile0(profile, null); + } + + private static void openProfile0(String profile, @Nullable String alreadySavedProfile) { + profile = normalizeProfileName(profile, true); + String previousProfile; + synchronized (ConfigManager.class) { + if (!profile.isEmpty() && !Files.isDirectory(profilePath(profile))) { + throw new IllegalArgumentException("Profile does not exist: " + profile); + } + previousProfile = activeProfile(); + } + // Profile listeners own state which is not part of the config backend (for example, + // Minecraft controls). Save it while the outgoing profile is still the committed owner. + if (!previousProfile.equals(alreadySavedProfile)) saveProfileState(previousProfile); + synchronized (ConfigManager.class) { + openProfile(profile, false); + } + notifyProfileChanged(profile); } - private static void openProfile(String profile, boolean saveCurrent) { + private static void openProfile(String profile, boolean restoreDefaults) { profile = normalizeProfileName(profile, true); if (!profile.isEmpty() && !Files.isDirectory(profilePath(profile))) { throw new IllegalArgumentException("Profile does not exist: " + profile); @@ -299,7 +380,6 @@ private static void openProfile(String profile, boolean saveCurrent) { if (Boolean.TRUE.equals(t.getMetadata(PROFILE_LOCAL_METADATA))) continue; externalTrees.add(t); } - if (saveCurrent) active.saveAll(); active.close(); } internal().get("profiles.json").getProp("activeProfile").setAs(profile); @@ -311,31 +391,36 @@ private static void openProfile(String profile, boolean saveCurrent) { LOGGER.info("opening profile {}", profile); active = new ConfigManager(PROFILES_DIR.resolve(profile), core.backend.getSerializers().toArray(new FileSerializer[0])).withHook().withWatcher(); } - rebindInitializedConfigs(); - for (Tree t : externalTrees) { - try { - active.register(t); - } catch (Throwable ex) { - LOGGER.error("Failed to rebind external tree {} onto profile {}", t.getID(), profile, ex); - } - } - for (ProfileChangeListener listener : profileListeners) { - try { - listener.onProfileChanged(profile); - } catch (Throwable t) { - LOGGER.error("Profile change listener failed", t); + boolean wasRebinding = REBINDING_PROFILES.get(); + REBINDING_PROFILES.set(Boolean.TRUE); + try { + 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); + } + active.register(t); + } catch (Throwable ex) { + LOGGER.error("Failed to rebind external tree {} onto profile {}", t.getID(), profile, ex); + } } + } finally { + if (wasRebinding) REBINDING_PROFILES.set(Boolean.TRUE); + else REBINDING_PROFILES.remove(); } } public static synchronized String activeProfile() { - active(); + activeLocked(); String profile = internal().get("profiles.json").getProp("activeProfile").getAs(); return profile == null ? "" : profile; } public static synchronized List profiles() { - active(); + activeLocked(); ArrayList out = new ArrayList<>(); out.add(""); try { @@ -353,38 +438,161 @@ public static synchronized List profiles() { } public static void createProfile(String profile) { + runProfileOperation(() -> createProfile0(profile)); + } + + private static void createProfile0(String profile) { String name = normalizeProfileName(profile, false); Path path = profilePath(name); - Path source; - Set ownedSubdirs; + String previousProfile; synchronized (ConfigManager.class) { if (Files.exists(path)) throw new IllegalArgumentException("Profile already exists: " + name); - active().saveAll(); - source = active.getFolder(); - ownedSubdirs = oneConfigSubdirs(active); + previousProfile = activeProfile(); } + saveProfileState(previousProfile); try { - Files.createDirectories(path); - copyProfileFiles(source, path, ownedSubdirs); + Files.createDirectories(PROFILES_DIR); + Files.createDirectory(path); + } catch (FileAlreadyExistsException e) { + throw new IllegalArgumentException("Profile already exists: " + name, e); } catch (IOException e) { throw new IllegalStateException("Failed to create profile: " + name, e); } - openProfile(name); + try { + synchronized (ConfigManager.class) { + openProfile(name, true); + } + } catch (Throwable failure) { + try { + synchronized (ConfigManager.class) { + if (!activeProfile().equals(previousProfile)) openProfile(previousProfile, false); + } + } catch (Throwable restoreFailure) { + failure.addSuppressed(restoreFailure); + } + try { + deleteDirectory(path); + } catch (IOException cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + throw failure; + } + notifyProfileCreated(name); + synchronized (ConfigManager.class) { + activeLocked().saveAll(); + } + notifyProfileChanged(name); } - private static Set oneConfigSubdirs(ConfigManager mgr) { - Set subdirs = new HashSet<>(); - for (Tree t : mgr.trees()) { - String id = t.getID(); - if (id == null) continue; - int slash = id.indexOf('/'); - int back = id.indexOf('\\'); - if (back >= 0 && (slash < 0 || back < slash)) slash = back; - if (slash > 0) subdirs.add(id.substring(0, slash)); + public static void cloneProfile(String profile, String newProfile) { + runProfileOperation(() -> cloneProfile0(profile, newProfile)); + } + + private static void cloneProfile0(String profile, String newProfile) { + profile = normalizeProfileName(profile, true); + String name = normalizeProfileName(newProfile, false); + Path source = profileDir(profile); + Path target = profilePath(name); + Set ownedSubdirs; + String currentProfile; + synchronized (ConfigManager.class) { + if (!profile.isEmpty() && !Files.isDirectory(source)) { + throw new IllegalArgumentException("Profile does not exist: " + profile); + } + if (Files.exists(target)) throw new IllegalArgumentException("Profile already exists: " + name); + ownedSubdirs = profile.isEmpty() ? oneConfigSubdirs() : Collections.emptySet(); + currentProfile = activeProfile(); + } + saveProfileState(profile); + if (!profile.equals(currentProfile)) saveProfileState(currentProfile); + try { + Files.createDirectories(PROFILES_DIR); + Files.createDirectory(target); + } catch (FileAlreadyExistsException e) { + throw new IllegalArgumentException("Profile already exists: " + name, e); + } catch (IOException e) { + throw new IllegalStateException("Failed to create cloned profile: " + name, e); + } + try { + if (profile.isEmpty()) { + copyProfileFiles(source, target, ownedSubdirs); + } else { + copyDirectory(source, target); + } + } catch (IOException e) { + try { + deleteDirectory(target); + } catch (IOException cleanupError) { + e.addSuppressed(cleanupError); + } + throw new IllegalStateException("Failed to clone profile: " + profile, e); } + String icon = profileIcon(profile); + if (!icon.equals(defaultProfileIcon())) setProfileIcon(name, icon); + openProfile0(name, currentProfile); + } + + private static Set oneConfigSubdirs() { + activeLocked(); + Set subdirs = new HashSet<>(); + // HUD files belong to OneConfig even when their providing mod is not present in this run, + // so they must not disappear from a clone or export of the Default profile. + subdirs.add("huds"); + Property property = internal().get("profiles.json").getProp("ownedProfileSubdirs"); + if (property != null) addOwnedProfileSubdirs(subdirs, property.get()); return subdirs; } + private static void rememberProfileSubdir(@Nullable String id) { + String subdir = profileSubdir(id); + if (subdir == null) return; + synchronized (ConfigManager.class) { + Property property = internal().get("profiles.json").getProp("ownedProfileSubdirs"); + if (property == null) return; + Set subdirs = new HashSet<>(); + addOwnedProfileSubdirs(subdirs, property.get()); + if (!subdirs.add(subdir)) return; + ArrayList sorted = new ArrayList<>(subdirs); + sorted.sort(String.CASE_INSENSITIVE_ORDER); + property.setAs(sorted.toArray(new String[0])); + internal().save("profiles.json"); + } + } + + private static void addOwnedProfileSubdirs(Set out, @Nullable Object value) { + if (value instanceof Object[]) { + for (Object entry : (Object[]) value) addOwnedProfileSubdir(out, entry); + } else if (value instanceof Iterable) { + for (Object entry : (Iterable) value) addOwnedProfileSubdir(out, entry); + } else { + addOwnedProfileSubdir(out, value); + } + } + + private static void addOwnedProfileSubdir(Set out, @Nullable Object value) { + if (value == null) return; + String subdir = value.toString(); + if (isSafeProfileSubdir(subdir)) out.add(subdir); + } + + private static @Nullable String profileSubdir(@Nullable String id) { + if (id == null) return null; + int slash = id.indexOf('/'); + int backslash = id.indexOf('\\'); + int separator = slash < 0 ? backslash : backslash < 0 ? slash : Math.min(slash, backslash); + if (separator <= 0) return null; + String subdir = id.substring(0, separator); + return isSafeProfileSubdir(subdir) ? subdir : null; + } + + private static boolean isSafeProfileSubdir(String subdir) { + return !subdir.isEmpty() + && !subdir.equals(".") + && !subdir.equals("..") + && subdir.indexOf('/') < 0 + && subdir.indexOf('\\') < 0; + } + private static void copyProfileFiles(Path source, Path target, Set ownedSubdirs) throws IOException { if (!Files.exists(source)) return; Files.createDirectories(target); @@ -409,49 +617,224 @@ private static void copyFileSafely(Path from, Path to) throws IOException { } } - public static synchronized void renameProfile(String profile, String newProfile) { + public static void renameProfile(String profile, String newProfile) { + runProfileOperation(() -> renameProfile0(profile, newProfile)); + } + + private static void renameProfile0(String profile, String newProfile) { profile = normalizeProfileName(profile, false); newProfile = normalizeProfileName(newProfile, false); if (profile.equals(newProfile)) return; Path oldPath = profilePath(profile); Path newPath = profilePath(newProfile); - if (!Files.isDirectory(oldPath)) throw new IllegalArgumentException("Profile does not exist: " + profile); - if (Files.exists(newPath)) throw new IllegalArgumentException("Profile already exists: " + newProfile); - if (activeProfile().equals(profile)) active.saveAll(); - String icon = profileIcon(profile); - try { - Files.move(oldPath, newPath); - } catch (IOException e) { - throw new IllegalStateException("Failed to rename profile: " + profile, e); + synchronized (ConfigManager.class) { + if (!Files.isDirectory(oldPath)) throw new IllegalArgumentException("Profile does not exist: " + profile); + if (Files.exists(newPath)) throw new IllegalArgumentException("Profile already exists: " + newProfile); + } + saveProfileState(profile); + boolean activeProfile; + synchronized (ConfigManager.class) { + if (!Files.isDirectory(oldPath)) throw new IllegalArgumentException("Profile does not exist: " + profile); + if (Files.exists(newPath)) throw new IllegalArgumentException("Profile already exists: " + newProfile); + activeProfile = activeProfile().equals(profile); + boolean favorite = isFavoriteProfile(profile); + String icon = profileIcon(profile); + if (activeProfile) active.saveAll(); + try { + Files.move(oldPath, newPath); + } catch (IOException e) { + throw new IllegalStateException("Failed to rename profile: " + profile, e); + } + if (favorite) { + setFavoriteProfile(profile, false); + setFavoriteProfile(newProfile, true); + } + setProfileIcon(profile, null); + if (!icon.equals(defaultProfileIcon())) { + setProfileIcon(newProfile, icon); + } + if (activeProfile) { + openProfile(newProfile, false); + } } - if (isFavoriteProfile(profile)) { - setFavoriteProfile(profile, false); - setFavoriteProfile(newProfile, true); + notifyProfileRenamed(profile, newProfile); + if (activeProfile) notifyProfileChanged(newProfile); + } + + public static void deleteProfile(String profile) { + runProfileOperation(() -> deleteProfile0(profile)); + } + + private static void deleteProfile0(String profile) { + profile = normalizeProfileName(profile, false); + synchronized (ConfigManager.class) { + if (!Files.isDirectory(profilePath(profile))) { + throw new IllegalArgumentException("Profile does not exist: " + profile); + } } - if (!icon.equals(defaultProfileIcon())) { - setProfileIcon(newProfile, icon); + boolean switchedToRoot; + IllegalStateException failure = null; + synchronized (ConfigManager.class) { + Path path = profilePath(profile); + if (!Files.isDirectory(path)) throw new IllegalArgumentException("Profile does not exist: " + profile); + switchedToRoot = activeProfile().equals(profile); + if (switchedToRoot) openProfile("", false); + try { + deleteDirectory(path); + setProfileIcon(profile, null); + setFavoriteProfile(profile, false); + } catch (IOException e) { + failure = new IllegalStateException("Failed to delete profile: " + profile, e); + } } - if (activeProfile().equals(profile)) { - openProfile(newProfile, false); + if (failure != null) { + if (switchedToRoot) notifyProfileChanged(""); + throw failure; } + notifyProfileDeleted(profile); + if (switchedToRoot) notifyProfileChanged(""); } - public static synchronized void deleteProfile(String profile) { - profile = normalizeProfileName(profile, false); - Path path = profilePath(profile); - if (!Files.isDirectory(path)) throw new IllegalArgumentException("Profile does not exist: " + profile); - if (activeProfile().equals(profile)) openProfile(""); - setProfileIcon(profile, null); + public static void exportProfile(String profile, Path destination) { + runProfileOperation(() -> exportProfile0(profile, destination)); + } + + private static void exportProfile0(String profile, Path destination) { + profile = normalizeProfileName(profile, true); + Objects.requireNonNull(destination, "destination"); + Path source = profileDir(profile).toAbsolutePath().normalize(); + Path target = destination.toAbsolutePath().normalize(); + if (target.getParent() == null) { + throw new IllegalArgumentException("Export destination must be a file path"); + } + Set ownedSubdirs; + synchronized (ConfigManager.class) { + if (!profile.isEmpty() && !Files.isDirectory(source)) { + throw new IllegalArgumentException("Profile does not exist: " + profile); + } + if (target.startsWith(source)) { + throw new IllegalArgumentException("Export destination cannot be inside the profile"); + } + ownedSubdirs = profile.isEmpty() ? oneConfigSubdirs() : Collections.emptySet(); + } + saveProfileState(profile); + Path temporary = null; try { - deleteDirectory(path); + Path parent = target.getParent(); + Files.createDirectories(parent); + Path realSource = source.toRealPath(); + Path realParent = parent.toRealPath(); + if (realParent.startsWith(realSource)) { + throw new IllegalArgumentException("Export destination cannot be inside the profile"); + } + temporary = Files.createTempFile(parent, "oneconfig-profile-", ".zip.tmp"); + try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(temporary))) { + if (profile.isEmpty()) { + if (Files.exists(source)) { + try (DirectoryStream stream = Files.newDirectoryStream(source)) { + for (Path entry : stream) { + if (Files.isDirectory(entry) && ownedSubdirs.contains(entry.getFileName().toString())) { + zipDirectory(entry, source, zip); + } else if (Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS)) { + zipFile(entry, source, zip); + } + } + } + } + } else { + zipDirectory(source, source, zip); + } + } + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (java.nio.file.AtomicMoveNotSupportedException ignored) { + Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING); + } + temporary = null; } catch (IOException e) { - throw new IllegalStateException("Failed to delete profile: " + profile, e); + try { + if (temporary != null) Files.deleteIfExists(temporary); + } catch (IOException cleanupError) { + e.addSuppressed(cleanupError); + } + throw new IllegalStateException("Failed to export profile: " + profile, e); + } + } + + private static void zipDirectory(Path directory, Path root, ZipOutputStream zip) throws IOException { + if (!Files.exists(directory)) return; + try (Stream stream = Files.walk(directory)) { + Iterator iterator = stream.iterator(); + while (iterator.hasNext()) { + Path entry = iterator.next(); + if (Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS)) zipFile(entry, root, zip); + } + } + } + + private static void zipFile(Path file, Path root, ZipOutputStream zip) throws IOException { + String name = root.relativize(file).toString().replace('\\', '/'); + ZipEntry entry = new ZipEntry(name); + zip.putNextEntry(entry); + Files.copy(file, zip); + zip.closeEntry(); + } + + private static void saveProfileState(String profile) { + synchronized (ConfigManager.class) { + if (activeProfile().equals(profile)) activeLocked().saveAll(); + } + for (ProfileChangeListener listener : profileListeners) { + try { + listener.onProfileSaving(profile); + } catch (Throwable t) { + throw new IllegalStateException("Failed to save profile state: " + profile, t); + } + } + } + + private static void notifyProfileDeleted(String profile) { + for (ProfileChangeListener listener : profileListeners) { + try { + listener.onProfileDeleted(profile); + } catch (Throwable t) { + LOGGER.error("Profile delete listener failed", t); + } + } + } + + private static void notifyProfileCreated(String profile) { + for (ProfileChangeListener listener : profileListeners) { + try { + listener.onProfileCreated(profile); + } catch (Throwable t) { + LOGGER.error("Profile create listener failed", t); + } + } + } + + private static void notifyProfileChanged(String profile) { + for (ProfileChangeListener listener : profileListeners) { + try { + listener.onProfileChanged(profile); + } catch (Throwable t) { + LOGGER.error("Profile change listener failed", t); + } + } + } + + private static void notifyProfileRenamed(String oldProfile, String newProfile) { + for (ProfileChangeListener listener : profileListeners) { + try { + listener.onProfileRenamed(oldProfile, newProfile); + } catch (Throwable t) { + LOGGER.error("Profile rename listener failed", t); + } } - setFavoriteProfile(profile, false); } public static synchronized List favoriteProfiles() { - active(); + activeLocked(); Object favorites = internal().get("profiles.json").getProp("favoriteProfiles").get(); if (favorites == null) return Collections.emptyList(); ArrayList out = new ArrayList<>(); @@ -503,7 +886,7 @@ public static synchronized void setFavoriteProfile(String profile, boolean favor } public static synchronized Map profileIcons() { - active(); + activeLocked(); Object icons = internal().get("profiles.json").getProp("profileIcons").get(); LinkedHashMap out = new LinkedHashMap<>(); if (icons instanceof Object[]) { @@ -544,11 +927,11 @@ public static synchronized String profileIcon(String profile) { public static synchronized void setProfileIcon(String profile, @Nullable String icon) { profile = normalizeProfileName(profile, true); if (profile.isEmpty()) return; - if (!Files.isDirectory(profilePath(profile))) { + String normalizedIcon = normalizeProfileIcon(icon); + if (!normalizedIcon.equals(defaultProfileIcon()) && !Files.isDirectory(profilePath(profile))) { throw new IllegalArgumentException("Profile does not exist: " + profile); } LinkedHashMap icons = new LinkedHashMap<>(profileIcons()); - String normalizedIcon = normalizeProfileIcon(icon); if (normalizedIcon.equals(defaultProfileIcon())) { icons.remove(profile); } else { @@ -562,24 +945,127 @@ public static synchronized void setProfileIcon(String profile, @Nullable String internal().save("profiles.json"); } + public static synchronized boolean profileSpecificControls() { + activeLocked(); + Object value = internal().get("profiles.json").getProp("profileSpecificControls").get(); + return !(value instanceof Boolean) || (Boolean) value; + } + + public static void setProfileSpecificControls(boolean enabled) { + runProfileOperation(() -> setProfileSpecificControls0(enabled)); + } + + private static void setProfileSpecificControls0(boolean enabled) { + boolean previous; + synchronized (ConfigManager.class) { + activeLocked(); + Property property = internal().get("profiles.json").getProp("profileSpecificControls"); + if (Objects.equals(property.get(), enabled)) return; + previous = !(property.get() instanceof Boolean) || (Boolean) property.get(); + property.setAs(enabled); + if (!internal().save("profiles.json")) { + property.setAs(previous); + IllegalStateException failure = new IllegalStateException( + "Failed to persist profile-specific controls preference" + ); + if (!internal().save("profiles.json")) { + failure.addSuppressed(new IllegalStateException( + "Failed to restore profile-specific controls preference after save failure" + )); + } + throw failure; + } + } + ArrayList attempted = new ArrayList<>(); + for (ProfileChangeListener listener : profileListeners) { + attempted.add(listener); + try { + listener.onProfileSpecificControlsChanged(enabled); + } catch (Throwable t) { + synchronized (ConfigManager.class) { + internal().get("profiles.json").getProp("profileSpecificControls").setAs(previous); + if (!internal().save("profiles.json")) { + t.addSuppressed(new IllegalStateException( + "Failed to persist the rolled-back profile-specific controls preference" + )); + } + } + Collections.reverse(attempted); + for (ProfileChangeListener notified : attempted) { + try { + notified.onProfileSpecificControlsChanged(previous); + } catch (Throwable rollbackFailure) { + t.addSuppressed(rollbackFailure); + } + } + throw new IllegalStateException("Failed to change profile-specific controls", t); + } + } + } + + private static void runProfileOperation(Runnable operation) { + if (PROFILE_LIFECYCLE_LOCK.isHeldByCurrentThread() || !PROFILE_LIFECYCLE_LOCK.tryLock()) { + throw new IllegalStateException("Another profile operation is already in progress"); + } + PROFILE_OPERATION_DEADLINE.set(System.nanoTime() + PROFILE_OPERATION_BUDGET_NANOS); + try { + operation.run(); + } finally { + PROFILE_OPERATION_DEADLINE.remove(); + PROFILE_LIFECYCLE_LOCK.unlock(); + } + } + + private static long waitBudgetNanos(long fallbackNanos) { + Long deadline = PROFILE_OPERATION_DEADLINE.get(); + if (deadline == null) return fallbackNanos; + return Math.max(MINIMUM_WAIT_NANOS, deadline - System.nanoTime()); + } + + @ApiStatus.Internal + public static void dispatchAndWait(Consumer dispatcher, Runnable action, long fallbackNanos, String what) { + CompletableFuture complete = new CompletableFuture<>(); + try { + dispatcher.accept(() -> { + try { + action.run(); + complete.complete(null); + } catch (Throwable t) { + complete.completeExceptionally(t); + } + }); + } catch (Throwable t) { + complete.completeExceptionally(t); + } + try { + complete.get(waitBudgetNanos(fallbackNanos), TimeUnit.NANOSECONDS); + } catch (TimeoutException e) { + throw new IllegalStateException("Timed out waiting for " + what, e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted waiting for " + what, e); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + if (cause == null) throw new IllegalStateException(what + " failed", e); + if (cause instanceof RuntimeException) throw (RuntimeException) cause; + if (cause instanceof Error) throw (Error) cause; + throw new IllegalStateException(cause); + } + } + private static String defaultProfileIcon() { return "profiles"; } - private static void rebindInitializedConfigs() { + private static void rebindInitializedConfigs(boolean restoreDefaults) { if (initializedConfigs.isEmpty()) return; ArrayList configs = new ArrayList<>(initializedConfigs.values()); - rebindingProfiles = true; - try { - for (Config config : configs) { - try { - config.rebindToActiveProfile(); - } catch (Throwable ex) { - LOGGER.error("Failed to rebind config {} onto active profile", config.id, ex); - } + for (Config config : configs) { + try { + config.rebindToActiveProfile(restoreDefaults); + } catch (Throwable ex) { + LOGGER.error("Failed to rebind config {} onto active profile", config.id, ex); } - } finally { - rebindingProfiles = false; } } @@ -715,8 +1201,17 @@ public Path getFolder() { } public Backend.RegistrationResult register(Tree t) { + if (this == active && !Boolean.TRUE.equals(t.getMetadata(CompatSnapshots.SNAPSHOT_METADATA))) { + Config.captureDefaults(t); + } Backend.RegistrationResult result = backend.register(t); - if (this == active) notifyTreeRegistered(result.get()); + if (this == active) { + Tree registered = result.get(); + if (registered != null && registered.getID() != null) { + rememberProfileSubdir(registered.getID()); + } + notifyTreeRegistered(registered); + } return result; } @@ -735,6 +1230,12 @@ public boolean delete(String id) { return backend.delete(id); } + /** Stops tracking a tree without deleting its file, so it can be rebound to a new owner. */ + @ApiStatus.Internal + public Tree unregister(String id) { + return backend.unregister(id); + } + @ApiStatus.Internal public Collection gatherAll(String sub) { return backend.gatherAll(sub); diff --git a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/backend/impl/FileBackend.java b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/backend/impl/FileBackend.java index 19c6aa3dc..ac91224e9 100644 --- a/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/backend/impl/FileBackend.java +++ b/modules/config-impl/src/main/java/org/polyfrost/oneconfig/api/config/v1/backend/impl/FileBackend.java @@ -247,7 +247,10 @@ public Collection gatherAll(@Nullable String sub) { if (serializer == null) continue; try { Tree t = serializer.deserialize(read(p)); - t.setID(folder.relativize(p).toString()); + String id = folder.relativize(p).toString(); + String separator = p.getFileSystem().getSeparator(); + if (!"/".equals(separator)) id = id.replace(separator, "/"); + t.setID(id); out.add(t); } catch (Exception e) { LOGGER.error("didn't gather tree from {}: {}", p, e.getMessage()); diff --git a/modules/config-impl/src/test/java/org/polyfrost/oneconfig/api/config/v1/CompatSnapshotStoreTest.java b/modules/config-impl/src/test/java/org/polyfrost/oneconfig/api/config/v1/CompatSnapshotStoreTest.java new file mode 100644 index 000000000..746ae3375 --- /dev/null +++ b/modules/config-impl/src/test/java/org/polyfrost/oneconfig/api/config/v1/CompatSnapshotStoreTest.java @@ -0,0 +1,271 @@ +/* + * This file is part of OneConfig. + * OneConfig - Next Generation Config Library for Minecraft: Java Edition + * Copyright (C) 2021~2024 Polyfrost. + * + * + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * OneConfig is licensed under the terms of version 3 of the GNU Lesser + * General Public License as published by the Free Software Foundation, AND + * under the Additional Terms Applicable to OneConfig, as published by Polyfrost, + * either version 1.0 of the Additional Terms, or (at your option) any later + * version. + * + * OneConfig is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License. If not, see . You should + * have also received a copy of the Additional Terms Applicable + * to OneConfig, as published by Polyfrost. If not, see + * + */ + +package org.polyfrost.oneconfig.api.config.v1; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CompatSnapshotStoreTest { + private static final String FILE_NAME = "snapshot-store-test.json"; + + private String profile; + private Path profileDirectory; + + @BeforeEach + void setUp() { + ConfigManager.active(); + ConfigManager.openProfile(""); + profile = "oc_snapshot_store_" + UUID.randomUUID().toString().replace("-", ""); + ConfigManager.createProfile(profile); + profileDirectory = ConfigManager.profileDir(profile); + } + + @AfterEach + void tearDown() { + if (!ConfigManager.activeProfile().isEmpty()) ConfigManager.openProfile(""); + if (Files.isDirectory(profileDirectory)) ConfigManager.deleteProfile(profile); + } + + @Test + void aBurstOfWritesCoalescesIntoOneScheduledFlushAndLosesNothing() { + CompatSnapshotStore store = new CompatSnapshotStore(FILE_NAME); + for (int i = 0; i < 200; i++) { + store.putValue(profile, "controls", "key." + i, "key.keyboard." + i); + } + + Path file = profileDirectory.resolve(FILE_NAME); + assertTrue( + waitUntil(() -> Files.isRegularFile(file)), + "the debounced flush never wrote " + file + ); + + CompatSnapshotStore reloaded = new CompatSnapshotStore(FILE_NAME); + for (int i = 0; i < 200; i++) { + assertEquals("key.keyboard." + i, reloaded.getValue(profile, "controls", "key." + i)); + } + } + + private static boolean waitUntil(BooleanSupplier condition) { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (System.nanoTime() < deadline) { + if (condition.getAsBoolean()) return true; + try { + Thread.sleep(20); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + return condition.getAsBoolean(); + } + + @Test + void flushOrThrowWritesAReadableSnapshot() throws IOException { + CompatSnapshotStore store = new CompatSnapshotStore(FILE_NAME); + store.putValue(profile, "controls", "key.jump", "key.keyboard.space"); + + store.flushOrThrow(profile); + + Path file = profileDirectory.resolve(FILE_NAME); + assertTrue(Files.isRegularFile(file)); + CompatSnapshotStore reloaded = new CompatSnapshotStore(FILE_NAME); + assertEquals("key.keyboard.space", reloaded.getValue(profile, "controls", "key.jump")); + } + + @Test + void flushOrThrowPropagatesWriteFailure() throws IOException { + Path blockedParent = profileDirectory.resolve("blocked"); + Files.writeString(blockedParent, "not a directory", StandardCharsets.UTF_8); + CompatSnapshotStore store = new CompatSnapshotStore("blocked/snapshot.json"); + store.putValue(profile, "controls", "key.jump", "key.keyboard.space"); + + assertThrows(IOException.class, () -> store.flushOrThrow(profile)); + assertEquals("not a directory", Files.readString(blockedParent, StandardCharsets.UTF_8)); + } + + @Test + void corruptSnapshotIsMovedAsideAndTheProfileStartsOver() throws IOException { + Path file = profileDirectory.resolve(FILE_NAME); + Path quarantined = profileDirectory.resolve(FILE_NAME + ".corrupt"); + byte[] corrupt = "{ definitely-not-json".getBytes(StandardCharsets.UTF_8); + Files.write(file, corrupt); + CompatSnapshotStore store = new CompatSnapshotStore(FILE_NAME); + + assertTrue(store.load(profile).isEmpty()); + assertFalse(store.hasLoadFailure(profile)); + assertArrayEquals(corrupt, Files.readAllBytes(quarantined)); + assertFalse(Files.exists(file)); + + assertDoesNotThrow(() -> store.putValue(profile, "controls", "key.jump", "key.keyboard.space")); + assertDoesNotThrow(() -> store.flushOrThrow(profile)); + assertEquals("key.keyboard.space", + new CompatSnapshotStore(FILE_NAME).getValue(profile, "controls", "key.jump")); + } + + @Test + void snapshotWhichCouldNotBeReadAtAllStaysPoisonedAndUntouched() throws IOException { + Path file = profileDirectory.resolve(FILE_NAME); + Files.createDirectories(file.resolve("occupied")); + CompatSnapshotStore store = new CompatSnapshotStore(FILE_NAME); + + assertThrows(IllegalStateException.class, () -> store.load(profile)); + assertTrue(store.hasLoadFailure(profile)); + assertThrows(IllegalStateException.class, + () -> store.putValue(profile, "controls", "key.jump", "key.keyboard.space")); + assertDoesNotThrow(() -> store.flush(profile)); + assertThrows(IllegalStateException.class, () -> store.flushOrThrow(profile)); + assertTrue(Files.isDirectory(file)); + assertFalse(Files.exists(profileDirectory.resolve(FILE_NAME + ".corrupt"))); + } + + @Test + void aSecondCorruptSnapshotDoesNotOverwriteTheFirstQuarantinedCopy() throws IOException { + Path file = profileDirectory.resolve(FILE_NAME); + byte[] first = "{ definitely-not-json".getBytes(StandardCharsets.UTF_8); + Files.write(file, first); + assertTrue(new CompatSnapshotStore(FILE_NAME).load(profile).isEmpty()); + + byte[] second = "{ also-not-json".getBytes(StandardCharsets.UTF_8); + Files.write(file, second); + assertTrue(new CompatSnapshotStore(FILE_NAME).load(profile).isEmpty()); + + assertArrayEquals(first, Files.readAllBytes(profileDirectory.resolve(FILE_NAME + ".corrupt"))); + assertArrayEquals(second, Files.readAllBytes(profileDirectory.resolve(FILE_NAME + ".corrupt.2"))); + } + + @Test + void quarantinedCopiesStopAtTheCapAndReuseTheLastSlot() throws IOException { + Path file = profileDirectory.resolve(FILE_NAME); + byte[] last = null; + for (int i = 1; i <= CompatSnapshotStore.MAX_QUARANTINED + 2; i++) { + last = ("{ not-json-" + i).getBytes(StandardCharsets.UTF_8); + Files.write(file, last); + assertTrue(new CompatSnapshotStore(FILE_NAME).load(profile).isEmpty()); + } + + assertArrayEquals("{ not-json-1".getBytes(StandardCharsets.UTF_8), + Files.readAllBytes(profileDirectory.resolve(FILE_NAME + ".corrupt"))); + assertArrayEquals(last, + Files.readAllBytes(profileDirectory.resolve(FILE_NAME + ".corrupt." + CompatSnapshotStore.MAX_QUARANTINED))); + assertFalse(Files.exists(profileDirectory.resolve(FILE_NAME + ".corrupt." + (CompatSnapshotStore.MAX_QUARANTINED + 1)))); + } + + @Test + void hasLoadFailureRepairsACorruptSnapshotBeforeAnythingHasLoadedIt() throws IOException { + Path file = profileDirectory.resolve(FILE_NAME); + Path quarantined = profileDirectory.resolve(FILE_NAME + ".corrupt"); + byte[] corrupt = "{ definitely-not-json".getBytes(StandardCharsets.UTF_8); + Files.write(file, corrupt); + CompatSnapshotStore store = new CompatSnapshotStore(FILE_NAME); + + assertFalse(store.hasLoadFailure(profile)); + assertArrayEquals(corrupt, Files.readAllBytes(quarantined)); + assertFalse(Files.exists(file)); + } + + @Test + void blankSnapshotIsTreatedAsEmptyRatherThanCorrupt() throws IOException { + Path file = profileDirectory.resolve(FILE_NAME); + Files.write(file, new byte[0]); + CompatSnapshotStore store = new CompatSnapshotStore(FILE_NAME); + + assertFalse(store.hasLoadFailure(profile)); + assertTrue(store.load(profile).isEmpty()); + assertDoesNotThrow(() -> store.putValue(profile, "controls", "key.jump", "key.keyboard.space")); + assertDoesNotThrow(() -> store.flushOrThrow(profile)); + assertEquals("key.keyboard.space", store.getValue(profile, "controls", "key.jump")); + + CompatSnapshotStore reopened = new CompatSnapshotStore(FILE_NAME); + assertEquals("key.keyboard.space", reopened.getValue(profile, "controls", "key.jump")); + } + + @Test + void hasLoadFailureDoesNotCacheOrCreateAReadableSnapshot() throws IOException { + CompatSnapshotStore store = new CompatSnapshotStore(FILE_NAME); + + assertFalse(store.hasLoadFailure(profile)); + + assertFalse(Files.exists(profileDirectory.resolve(FILE_NAME))); + assertDoesNotThrow(() -> store.flushOrThrow(profile)); + assertFalse(Files.exists(profileDirectory.resolve(FILE_NAME))); + } + + @Test + void structurallyInvalidSnapshotIsAlsoMovedAside() throws IOException { + Path file = profileDirectory.resolve(FILE_NAME); + Path quarantined = profileDirectory.resolve(FILE_NAME + ".corrupt"); + byte[] corrupt = "{\"controls\":\"not-an-object\"}".getBytes(StandardCharsets.UTF_8); + Files.write(file, corrupt); + CompatSnapshotStore store = new CompatSnapshotStore(FILE_NAME); + + assertTrue(store.load(profile).isEmpty()); + assertArrayEquals(corrupt, Files.readAllBytes(quarantined)); + assertFalse(Files.exists(file)); + } + + @Test + void failedSnapshotWriteDoesNotAdvanceItsBaseline() throws IOException { + String snapshotName = "snapshot-order-" + UUID.randomUUID() + ".json"; + String baselineName = "baseline-order-" + UUID.randomUUID() + ".json"; + CompatSnapshotStore snapshot = new CompatSnapshotStore(snapshotName); + CompatSnapshotStore baseline = new CompatSnapshotStore(baselineName); + Path baselineFile = ConfigManager.profileDir("").resolve(baselineName); + + try { + baseline.putValueWithoutScheduling("", "tree", "value", "old"); + baseline.flushOrThrow(""); + baseline.putValueWithoutScheduling("", "tree", "value", "new"); + + snapshot.putValueWithoutScheduling(profile, "tree", "value", "new"); + Files.createDirectory(profileDirectory.resolve(snapshotName)); + + assertThrows(IllegalStateException.class, () -> + CompatSnapshots.flushSnapshotThenBaseline(snapshot, profile, baseline, "")); + + CompatSnapshotStore reloadedBaseline = new CompatSnapshotStore(baselineName); + assertEquals("old", reloadedBaseline.getValue("", "tree", "value")); + } finally { + Files.deleteIfExists(baselineFile); + } + } +} 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 new file mode 100644 index 000000000..ec956a07b --- /dev/null +++ b/modules/config-impl/src/test/java/org/polyfrost/oneconfig/api/config/v1/CompatSnapshotsTest.java @@ -0,0 +1,94 @@ +/* + * This file is part of OneConfig. + * OneConfig - Next Generation Config Library for Minecraft: Java Edition + * Copyright (C) 2021~2024 Polyfrost. + * + * + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * OneConfig is licensed under the terms of version 3 of the GNU Lesser + * General Public License as published by the Free Software Foundation, AND + * under the Additional Terms Applicable to OneConfig, as published by Polyfrost, + * either version 1.0 of the Additional Terms, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License. If not, see . You should + * have also received a copy of the Additional Terms Applicable + * to OneConfig, as published by Polyfrost. If not, see + * + */ + +package org.polyfrost.oneconfig.api.config.v1; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CompatSnapshotsTest { + private static final String TREE_ID = "compat_metadata_default_test"; + + @Test + void blankProfileDefaultsPreferPropertyMetadataOverTheLiveValue() throws Exception { + Property property = Properties.simple("enabled", "Enabled", "", true); + property.addMetadata("default", Boolean.FALSE); + Tree tree = Tree.tree(TREE_ID).put(property); + + Method capture = CompatSnapshots.class.getDeclaredMethod("captureDefaults", Tree.class); + Method restore = CompatSnapshots.class.getDeclaredMethod("restoreDefaults", Tree.class); + capture.setAccessible(true); + restore.setAccessible(true); + try { + capture.invoke(CompatSnapshots.INSTANCE, tree); + property.setAs(true); + restore.invoke(CompatSnapshots.INSTANCE, tree); + + assertEquals(Boolean.FALSE, property.get(), + "a blank profile must use the compat adapter's declared default"); + } finally { + defaults().remove(TREE_ID); + } + } + + @Test + void aModsOwnResetHookWinsOverTheCapturedDefaults() throws Exception { + Property property = Properties.simple("enabled", "Enabled", "", true); + Tree tree = Tree.tree(TREE_ID).put(property); + boolean[] reset = {false}; + tree.addMetadata(CompatSnapshots.CUSTOM_RESET_METADATA, (Runnable) () -> { + reset[0] = true; + property.setAs(false); + }); + + Method capture = CompatSnapshots.class.getDeclaredMethod("captureDefaults", Tree.class); + Method restore = CompatSnapshots.class.getDeclaredMethod("restoreDefaults", Tree.class); + capture.setAccessible(true); + restore.setAccessible(true); + try { + capture.invoke(CompatSnapshots.INSTANCE, tree); + restore.invoke(CompatSnapshots.INSTANCE, tree); + + assertTrue(reset[0], "the mod's own reset hook must be used when it declares one"); + assertEquals(Boolean.FALSE, property.get(), "the captured default must not overwrite the mod's reset"); + } finally { + defaults().remove(TREE_ID); + } + } + + @SuppressWarnings("unchecked") + private static Map> defaults() throws Exception { + Field field = CompatSnapshots.class.getDeclaredField("defaults"); + field.setAccessible(true); + return (Map>) field.get(CompatSnapshots.INSTANCE); + } +} diff --git a/modules/config-impl/src/test/java/org/polyfrost/oneconfig/api/config/v1/CopyDefaultTest.java b/modules/config-impl/src/test/java/org/polyfrost/oneconfig/api/config/v1/CopyDefaultTest.java new file mode 100644 index 000000000..58f00abd3 --- /dev/null +++ b/modules/config-impl/src/test/java/org/polyfrost/oneconfig/api/config/v1/CopyDefaultTest.java @@ -0,0 +1,105 @@ +package org.polyfrost.oneconfig.api.config.v1; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; + +public class CopyDefaultTest { + @Test + void copiesLists() { + List original = new ArrayList<>(Arrays.asList("one", "two")); + @SuppressWarnings("unchecked") + List copy = (List) Config.copyDefault(List.class, original); + assertNotSame(original, copy); + assertEquals(original, copy); + original.clear(); + original.add("edited"); + assertEquals(Arrays.asList("one", "two"), copy); + } + + @Test + void copiesArrays() { + String[] original = {"one", "two"}; + String[] copy = (String[]) Config.copyDefault(String[].class, original); + assertNotSame(original, copy); + assertEquals(String[].class, copy.getClass()); + original[0] = "edited"; + assertEquals("one", copy[0]); + } + + @Test + void copiesPrimitiveArrays() { + int[] original = {1, 2}; + int[] copy = (int[]) Config.copyDefault(int[].class, original); + assertNotSame(original, copy); + original[0] = 9; + assertEquals(1, copy[0]); + } + + @Test + void copiesMaps() { + Map original = new LinkedHashMap<>(); + original.put("a", "1"); + Map copy = (Map) Config.copyDefault(Map.class, original); + assertNotSame(original, copy); + original.clear(); + assertEquals(1, copy.size()); + } + + @Test + void copiesUnmodifiableCollectionsToAWritableOne() { + List original = Collections.unmodifiableList(new ArrayList<>(Arrays.asList("one"))); + @SuppressWarnings("unchecked") + List copy = (List) Config.copyDefault(List.class, original); + assertNotSame(original, copy); + assertEquals(original, copy); + } + + @Test + @SuppressWarnings("unchecked") + void copiesEntriesInsideContainers() { + List inner = new ArrayList<>(Arrays.asList("one")); + List> original = new ArrayList<>(Collections.singletonList(inner)); + List> copy = (List>) Config.copyDefault(List.class, original); + assertNotSame(inner, copy.get(0)); + inner.add("edited"); + assertEquals(Collections.singletonList("one"), copy.get(0)); + } + + @Test + @SuppressWarnings("unchecked") + void copiesValuesInsideMaps() { + List inner = new ArrayList<>(Arrays.asList("one")); + Map> original = new LinkedHashMap<>(); + original.put("a", inner); + Map> copy = (Map>) Config.copyDefault(Map.class, original); + assertNotSame(inner, copy.get("a")); + inner.add("edited"); + assertEquals(Collections.singletonList("one"), copy.get("a")); + } + + @Test + void copiesEntriesInsideObjectArrays() { + String[] inner = {"one"}; + String[][] original = {inner}; + String[][] copy = (String[][]) Config.copyDefault(String[][].class, original); + assertNotSame(inner, copy[0]); + inner[0] = "edited"; + assertEquals("one", copy[0][0]); + } + + @Test + void leavesSimpleValuesAlone() { + String value = "one"; + assertSame(value, Config.copyDefault(String.class, value)); + } +} diff --git a/modules/config-impl/src/test/java/org/polyfrost/oneconfig/api/config/v1/ProfileManagerTest.java b/modules/config-impl/src/test/java/org/polyfrost/oneconfig/api/config/v1/ProfileManagerTest.java index b94d4fb48..56a4b69c0 100644 --- a/modules/config-impl/src/test/java/org/polyfrost/oneconfig/api/config/v1/ProfileManagerTest.java +++ b/modules/config-impl/src/test/java/org/polyfrost/oneconfig/api/config/v1/ProfileManagerTest.java @@ -29,13 +29,19 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.polyfrost.oneconfig.api.config.v1.annotations.Switch; +import org.polyfrost.oneconfig.api.config.v1.backend.Backend; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.Comparator; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Stream; +import java.util.zip.ZipFile; import static org.junit.jupiter.api.Assertions.*; @@ -43,6 +49,17 @@ class ProfileManagerTest { private static final String PROFILE_A = "oc_test_profile_a"; private static final String PROFILE_B = "oc_test_profile_b"; private static final String PROFILE_C = "oc_test_profile_c"; + private static final String BLANK_CONFIG = "profile_blank_test.json"; + private static final String CLONE_CONFIG = "profile_clone_test.json"; + private static final String ROOT_NESTED_CONFIG = "oc_profile_root/nested.json"; + private static final String ROOT_PERSISTED_NESTED_CONFIG = "oc_profile_persisted/nested.json"; + private static final String ROOT_ORPHAN_HUD = "huds/oc_profile_orphan_hud.json"; + private static final String DIRECT_TREE_CONFIG = "profile_direct_tree_test.json"; + private static final String GLOBAL_UI_TREE_CONFIG = "profile_global_ui_tree_test.json"; + private static final String SEEDED_CONFIG = "profile_seeded_test.json"; + + @TempDir + Path tempDir; @BeforeEach void setUp() throws IOException { @@ -74,6 +91,201 @@ void createsAndOpensProfile() { assertTrue(Files.isDirectory(ConfigManager.PROFILES_DIR.resolve(PROFILE_A))); } + @Test + void createsBlankProfile() { + ProfileTestConfig config = new ProfileTestConfig(BLANK_CONFIG); + config.initialize(false); + config.enabled = true; + config.save(); + + ConfigManager.createProfile(PROFILE_A); + + assertFalse(config.enabled); + } + + @Test + void noListenerIsEverNotifiedWhileTheConfigManagerMonitorIsHeld() { + List violations = new ArrayList<>(); + ConfigManager.ProfileChangeListener listener = new ConfigManager.ProfileChangeListener() { + private void check(String callback) { + if (Thread.holdsLock(ConfigManager.class)) violations.add(callback); + } + + @Override + public void onProfileChanged(String newProfile) { + check("onProfileChanged"); + } + + @Override + public void onProfileSaving(String profile) { + check("onProfileSaving"); + } + + @Override + public void onProfileCreated(String profile) { + check("onProfileCreated"); + } + + @Override + public void onProfileRenamed(String oldProfile, String newProfile) { + check("onProfileRenamed"); + } + + @Override + public void onProfileDeleted(String profile) { + check("onProfileDeleted"); + } + + @Override + public void onProfileSpecificControlsChanged(boolean enabled) { + check("onProfileSpecificControlsChanged"); + } + }; + ConfigManager.addProfileChangeListener(listener); + boolean controls = ConfigManager.profileSpecificControls(); + try { + ConfigManager.createProfile(PROFILE_A); + ConfigManager.cloneProfile(PROFILE_A, PROFILE_B); + ConfigManager.renameProfile(PROFILE_B, PROFILE_C); + ConfigManager.setProfileSpecificControls(!controls); + ConfigManager.openProfile(""); + ConfigManager.deleteProfile(PROFILE_C); + ConfigManager.deleteProfile(PROFILE_A); + } finally { + ConfigManager.setProfileSpecificControls(controls); + ConfigManager.removeProfileChangeListener(listener); + } + + assertEquals(List.of(), violations, "notified while holding the ConfigManager monitor"); + } + + @Test + void onProfileCreatedCanSeedTheNewProfileAndTheValuesArePersisted() throws IOException { + ProfileTestConfig config = new ProfileTestConfig(SEEDED_CONFIG); + config.initialize(false); + ConfigManager.ProfileChangeListener listener = new ConfigManager.ProfileChangeListener() { + @Override + public void onProfileChanged(String newProfile) { + } + + @Override + public void onProfileCreated(String profile) { + assertFalse(config.enabled, "the seam must run after configs are reset to defaults"); + config.enabled = true; + } + }; + ConfigManager.addProfileChangeListener(listener); + try { + ConfigManager.createProfile(PROFILE_A); + assertTrue(config.enabled); + + Path file = ConfigManager.profileDir(PROFILE_A).resolve(SEEDED_CONFIG); + assertTrue(Files.isRegularFile(file)); + assertTrue(new String(Files.readAllBytes(file), java.nio.charset.StandardCharsets.UTF_8) + .replace(" ", "").contains("\"enabled\":true"), + "seeded values must be on disk before the profile-changed listeners run"); + } finally { + ConfigManager.removeProfileChangeListener(listener); + } + } + + @Test + void createsBlankProfileForDirectlyRegisteredTrees() { + Tree direct = ConfigManager.active().register( + Tree.tree(DIRECT_TREE_CONFIG).put(Properties.simple("enabled", "Enabled", "", false)) + ).get(); + direct.getProp("enabled").setAs(true); + ConfigManager.active().save(DIRECT_TREE_CONFIG); + + ConfigManager.createProfile(PROFILE_A); + + assertEquals(Boolean.FALSE, ConfigManager.active().get(DIRECT_TREE_CONFIG).getProp("enabled").getAs()); + } + + @Test + void creatingBlankProfileDoesNotResetGlobalUiTrees() { + Tree global = Tree.tree(GLOBAL_UI_TREE_CONFIG) + .put(Properties.simple("enabled", "Enabled", "", false)); + global.addMetadata(Backend.UI_ONLY_METADATA, Boolean.TRUE); + global = ConfigManager.active().register(global).get(); + global.getProp("enabled").setAs(true); + + ConfigManager.createProfile(PROFILE_A); + + assertEquals(Boolean.TRUE, ConfigManager.active().get(GLOBAL_UI_TREE_CONFIG).getProp("enabled").getAs()); + } + + @Test + void clonesProfileContents() { + ProfileTestConfig config = new ProfileTestConfig(CLONE_CONFIG); + config.initialize(false); + config.enabled = true; + config.save(); + + ConfigManager.cloneProfile("", PROFILE_A); + + assertEquals(PROFILE_A, ConfigManager.activeProfile()); + assertTrue(config.enabled); + } + + @Test + void cloneDoesNotMergeWithOrDeleteADirectoryCreatedWhileSaving() throws IOException { + Path target = ConfigManager.profileDir(PROFILE_A); + Path marker = target.resolve("belongs-to-someone-else.txt"); + ConfigManager.ProfileChangeListener listener = new ConfigManager.ProfileChangeListener() { + @Override + public void onProfileChanged(String newProfile) { + } + + @Override + public void onProfileSaving(String profile) { + try { + Files.createDirectories(target); + Files.writeString(marker, "keep"); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + }; + ConfigManager.addProfileChangeListener(listener); + try { + assertThrows(IllegalArgumentException.class, + () -> ConfigManager.cloneProfile("", PROFILE_A)); + assertEquals("keep", Files.readString(marker)); + } finally { + ConfigManager.removeProfileChangeListener(listener); + } + } + + @Test + void profileListenersCannotStartANestedLifecycleOperation() { + AtomicReference nestedFailure = new AtomicReference<>(); + ConfigManager.ProfileChangeListener listener = new ConfigManager.ProfileChangeListener() { + @Override + public void onProfileChanged(String newProfile) { + } + + @Override + public void onProfileCreated(String profile) { + try { + ConfigManager.deleteProfile(profile); + } catch (Throwable failure) { + nestedFailure.set(failure); + } + } + }; + ConfigManager.addProfileChangeListener(listener); + try { + ConfigManager.createProfile(PROFILE_A); + + assertInstanceOf(IllegalStateException.class, nestedFailure.get()); + assertEquals(PROFILE_A, ConfigManager.activeProfile()); + assertTrue(Files.isDirectory(ConfigManager.profileDir(PROFILE_A))); + } finally { + ConfigManager.removeProfileChangeListener(listener); + } + } + @Test void persistsFavorites() { ConfigManager.createProfile(PROFILE_A); @@ -98,6 +310,164 @@ void persistsDefaultProfileFavorite() { assertFalse(ConfigManager.favoriteProfiles().contains("")); } + @Test + void persistsProfileSpecificControlsPreference() { + AtomicReference notified = new AtomicReference<>(); + ConfigManager.ProfileChangeListener listener = new ConfigManager.ProfileChangeListener() { + @Override + public void onProfileChanged(String newProfile) { + } + + @Override + public void onProfileSpecificControlsChanged(boolean enabled) { + notified.set(enabled); + } + }; + ConfigManager.addProfileChangeListener(listener); + try { + ConfigManager.setProfileSpecificControls(false); + assertFalse(ConfigManager.profileSpecificControls()); + assertEquals(Boolean.FALSE, notified.get()); + + ConfigManager.setProfileSpecificControls(true); + assertTrue(ConfigManager.profileSpecificControls()); + assertEquals(Boolean.TRUE, notified.get()); + } finally { + ConfigManager.removeProfileChangeListener(listener); + } + } + + @Test + void failedProfileSpecificControlsTransitionRollsBackThePreferenceAndListeners() { + List observed = new ArrayList<>(); + ConfigManager.ProfileChangeListener observer = new ConfigManager.ProfileChangeListener() { + @Override + public void onProfileChanged(String newProfile) { + } + + @Override + public void onProfileSpecificControlsChanged(boolean enabled) { + observed.add(enabled); + } + }; + ConfigManager.ProfileChangeListener failing = new ConfigManager.ProfileChangeListener() { + @Override + public void onProfileChanged(String newProfile) { + } + + @Override + public void onProfileSpecificControlsChanged(boolean enabled) { + if (!enabled) throw new IllegalStateException("transition failed"); + } + }; + ConfigManager.addProfileChangeListener(observer); + ConfigManager.addProfileChangeListener(failing); + try { + assertThrows(IllegalStateException.class, + () -> ConfigManager.setProfileSpecificControls(false)); + + assertTrue(ConfigManager.profileSpecificControls()); + assertEquals(List.of(false, true), observed); + } finally { + ConfigManager.removeProfileChangeListener(failing); + ConfigManager.removeProfileChangeListener(observer); + } + } + + @Test + void exportsProfileAsZip() throws IOException { + ConfigManager.createProfile(PROFILE_A); + Files.writeString(ConfigManager.profileDir(PROFILE_A).resolve("marker.txt"), "profile data"); + Path archive = tempDir.resolve("profile.zip"); + + ConfigManager.exportProfile(PROFILE_A, archive); + + assertTrue(Files.isRegularFile(archive)); + try (ZipFile zip = new ZipFile(archive.toFile())) { + assertNotNull(zip.getEntry("marker.txt")); + } + } + + @Test + void failedExportDoesNotDeleteTheExistingDestination() throws IOException { + ConfigManager.createProfile(PROFILE_A); + Path destination = Files.createDirectory(tempDir.resolve("existing-destination")); + Path marker = Files.writeString(destination.resolve("keep.txt"), "keep"); + + assertThrows(IllegalStateException.class, () -> ConfigManager.exportProfile(PROFILE_A, destination)); + + assertEquals("keep", Files.readString(marker)); + } + + @Test + void rejectsExportThroughASymlinkBackIntoTheProfile() throws IOException { + Path linkedParent = tempDir.resolve("profile-link"); + try { + Files.createSymbolicLink(linkedParent, ConfigManager.profileDir("").toAbsolutePath()); + } catch (UnsupportedOperationException | IOException ignored) { + return; + } + + assertThrows(IllegalArgumentException.class, + () -> ConfigManager.exportProfile("", linkedParent.resolve("recursive.zip"))); + assertFalse(Files.exists(ConfigManager.profileDir("").resolve("recursive.zip"))); + } + + @Test + void clonesAndExportsRootOwnedSubdirectoriesWhileAnotherProfileIsActive() throws IOException { + Tree rootOnly = Tree.tree(ROOT_NESTED_CONFIG).put( + Properties.simple("enabled", "Enabled", "", true) + ); + rootOnly.addMetadata(ConfigManager.PROFILE_LOCAL_METADATA, true); + ConfigManager.active().register(rootOnly); + ConfigManager.active().save(ROOT_NESTED_CONFIG); + Path orphanHud = ConfigManager.profileDir("").resolve(ROOT_ORPHAN_HUD); + Files.createDirectories(orphanHud.getParent()); + Files.writeString(orphanHud, "orphan HUD data"); + ConfigManager.createProfile(PROFILE_A); + + Path archive = tempDir.resolve("root-profile.zip"); + ConfigManager.exportProfile("", archive); + ConfigManager.cloneProfile("", PROFILE_B); + + assertTrue(Files.isRegularFile(ConfigManager.profileDir(PROFILE_B).resolve(ROOT_NESTED_CONFIG))); + assertEquals("orphan HUD data", + Files.readString(ConfigManager.profileDir(PROFILE_B).resolve(ROOT_ORPHAN_HUD))); + try (ZipFile zip = new ZipFile(archive.toFile())) { + assertNotNull(zip.getEntry(ROOT_NESTED_CONFIG)); + assertNotNull(zip.getEntry(ROOT_ORPHAN_HUD)); + } + } + + @Test + void remembersRootOwnedSubdirectoriesAfterTheirModIsNoLongerLoaded() throws IOException { + Property ownedSubdirs = ConfigManager.internal().get("profiles.json").getProp("ownedProfileSubdirs"); + Object previousOwnedSubdirs = ownedSubdirs.get(); + Tree rootOnly = Tree.tree(ROOT_PERSISTED_NESTED_CONFIG).put( + Properties.simple("enabled", "Enabled", "", true) + ); + rootOnly.addMetadata(ConfigManager.PROFILE_LOCAL_METADATA, true); + try { + ConfigManager.active().register(rootOnly); + ConfigManager.active().save(ROOT_PERSISTED_NESTED_CONFIG); + ConfigManager.active().unregister(ROOT_PERSISTED_NESTED_CONFIG); + + ConfigManager.createProfile(PROFILE_A); + Path archive = tempDir.resolve("persisted-root-profile.zip"); + ConfigManager.exportProfile("", archive); + ConfigManager.cloneProfile("", PROFILE_B); + + assertTrue(Files.isRegularFile( + ConfigManager.profileDir(PROFILE_B).resolve(ROOT_PERSISTED_NESTED_CONFIG))); + try (ZipFile zip = new ZipFile(archive.toFile())) { + assertNotNull(zip.getEntry(ROOT_PERSISTED_NESTED_CONFIG)); + } + } finally { + ownedSubdirs.setAs(previousOwnedSubdirs); + ConfigManager.internal().save("profiles.json"); + } + } + @Test void persistsProfileIcons() { ConfigManager.createProfile(PROFILE_A); @@ -116,6 +486,7 @@ void persistsProfileIcons() { void renamesActiveProfileAndKeepsItActive() { ConfigManager.createProfile(PROFILE_A); ConfigManager.setProfileIcon(PROFILE_A, "star"); + ConfigManager.setFavoriteProfile(PROFILE_A, true); ConfigManager.renameProfile(PROFILE_A, PROFILE_B); @@ -124,6 +495,199 @@ void renamesActiveProfileAndKeepsItActive() { assertTrue(ConfigManager.profiles().contains(PROFILE_B)); assertEquals("star", ConfigManager.profileIcon(PROFILE_B)); assertFalse(ConfigManager.profileIcons().containsKey(PROFILE_A)); + assertTrue(ConfigManager.isFavoriteProfile(PROFILE_B)); + assertFalse(ConfigManager.favoriteProfiles().contains(PROFILE_A)); + } + + @Test + void reusingARenamedProfileNameDoesNotInheritItsFavoriteOrIcon() { + ConfigManager.createProfile(PROFILE_A); + ConfigManager.setProfileIcon(PROFILE_A, "star"); + ConfigManager.setFavoriteProfile(PROFILE_A, true); + ConfigManager.renameProfile(PROFILE_A, PROFILE_B); + + ConfigManager.createProfile(PROFILE_A); + + assertFalse(ConfigManager.isFavoriteProfile(PROFILE_A)); + assertEquals("profiles", ConfigManager.profileIcon(PROFILE_A)); + } + + @Test + void delayedSnapshotWritesDoNotResurrectRenamedOrDeletedProfiles() { + CompatSnapshotStore delayedStore = new CompatSnapshotStore("delayed-profile-state.json"); + ConfigManager.createProfile(PROFILE_A); + delayedStore.putValue(PROFILE_A, "test", "value", "latest"); + delayedStore.flush(PROFILE_A); + + ConfigManager.renameProfile(PROFILE_A, PROFILE_B); + delayedStore.flush(PROFILE_A); + ConfigManager.cloneProfile(PROFILE_B, PROFILE_C); + + assertEquals(3, ConfigManager.profiles().size()); + assertFalse(Files.exists(ConfigManager.profileDir(PROFILE_A))); + assertTrue(Files.isDirectory(ConfigManager.profileDir(PROFILE_B))); + assertTrue(Files.isDirectory(ConfigManager.profileDir(PROFILE_C))); + + ConfigManager.deleteProfile(PROFILE_C); + delayedStore.putValue(PROFILE_C, "test", "value", "too late"); + delayedStore.flush(PROFILE_C); + + assertFalse(Files.exists(ConfigManager.profileDir(PROFILE_A))); + assertFalse(Files.exists(ConfigManager.profileDir(PROFILE_C))); + assertEquals(List.of("", PROFILE_B), ConfigManager.profiles()); + delayedStore.deleteProfile(PROFILE_A); + delayedStore.deleteProfile(PROFILE_C); + } + + @Test + void movesSnapshotCacheWithRenamedProfileWithoutLosingNewIdentityUpdates() throws java.io.IOException { + CompatSnapshotStore store = new CompatSnapshotStore("renamed-profile-state.json"); + ConfigManager.createProfile(PROFILE_A); + store.putValue(PROFILE_A, "test", "value", "latest"); + store.flush(PROFILE_A); + + ConfigManager.renameProfile(PROFILE_A, PROFILE_B); + store.putValue(PROFILE_B, "test", "value", "new identity update"); + store.renameProfile(PROFILE_A, PROFILE_B); + + assertEquals("new identity update", store.getValue(PROFILE_B, "test", "value")); + assertFalse(Files.exists(ConfigManager.profileDir(PROFILE_A))); + assertTrue(Files.isRegularFile(ConfigManager.profileDir(PROFILE_B).resolve("renamed-profile-state.json"))); + } + + @Test + void reportsInactiveRenameWithoutChangingActiveProfile() { + ConfigManager.createProfile(PROFILE_A); + ConfigManager.openProfile(""); + AtomicReference changed = new AtomicReference<>(); + AtomicReference renamed = new AtomicReference<>(); + ConfigManager.ProfileChangeListener listener = new ConfigManager.ProfileChangeListener() { + @Override + public void onProfileChanged(String newProfile) { + changed.set(newProfile); + } + + @Override + public void onProfileRenamed(String oldProfile, String newProfile) { + renamed.set(oldProfile + "->" + newProfile); + } + }; + ConfigManager.addProfileChangeListener(listener); + try { + ConfigManager.renameProfile(PROFILE_A, PROFILE_B); + + assertEquals("", ConfigManager.activeProfile()); + assertEquals(PROFILE_A + "->" + PROFILE_B, renamed.get()); + assertNull(changed.get()); + } finally { + ConfigManager.removeProfileChangeListener(listener); + } + } + + @Test + void savesListenerStateBeforeCloneAndExport() throws IOException { + ConfigManager.createProfile(PROFILE_A); + ConfigManager.ProfileChangeListener listener = new ConfigManager.ProfileChangeListener() { + @Override + public void onProfileChanged(String newProfile) { + } + + @Override + public void onProfileSaving(String profile) { + try { + Files.writeString(ConfigManager.profileDir(profile).resolve("listener-state.txt"), "fresh:" + profile); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + }; + ConfigManager.addProfileChangeListener(listener); + try { + ConfigManager.cloneProfile(PROFILE_A, PROFILE_B); + assertEquals("fresh:" + PROFILE_A, + Files.readString(ConfigManager.profileDir(PROFILE_B).resolve("listener-state.txt"))); + + Path archive = tempDir.resolve("listener-state.zip"); + ConfigManager.exportProfile(PROFILE_B, archive); + try (ZipFile zip = new ZipFile(archive.toFile())) { + assertNotNull(zip.getEntry("listener-state.txt")); + } + } finally { + ConfigManager.removeProfileChangeListener(listener); + } + } + + @Test + void failedListenerSavePreventsProfileSwitch() { + ConfigManager.createProfile(PROFILE_A); + ConfigManager.ProfileChangeListener listener = new ConfigManager.ProfileChangeListener() { + @Override + public void onProfileChanged(String newProfile) { + } + + @Override + public void onProfileSaving(String profile) { + if (PROFILE_A.equals(profile)) throw new IllegalStateException("save failed"); + } + }; + ConfigManager.addProfileChangeListener(listener); + try { + assertThrows(IllegalStateException.class, () -> ConfigManager.openProfile("")); + assertEquals(PROFILE_A, ConfigManager.activeProfile()); + } finally { + ConfigManager.removeProfileChangeListener(listener); + } + } + + @Test + void cloningAnInactiveProfileSavesTheCurrentProfileBeforeSwitching() { + ConfigManager.createProfile(PROFILE_A); + ConfigManager.createProfile(PROFILE_B); + List saved = new ArrayList<>(); + ConfigManager.ProfileChangeListener listener = new ConfigManager.ProfileChangeListener() { + @Override + public void onProfileChanged(String newProfile) { + } + + @Override + public void onProfileSaving(String profile) { + saved.add(profile); + } + }; + ConfigManager.addProfileChangeListener(listener); + try { + ConfigManager.cloneProfile(PROFILE_A, PROFILE_C); + + assertEquals(List.of(PROFILE_A, PROFILE_B), saved); + assertEquals(PROFILE_C, ConfigManager.activeProfile()); + } finally { + ConfigManager.removeProfileChangeListener(listener); + } + } + + @Test + void failedCurrentSaveDoesNotLeaveAPartialInactiveClone() { + ConfigManager.createProfile(PROFILE_A); + ConfigManager.createProfile(PROFILE_B); + ConfigManager.ProfileChangeListener listener = new ConfigManager.ProfileChangeListener() { + @Override + public void onProfileChanged(String newProfile) { + } + + @Override + public void onProfileSaving(String profile) { + if (PROFILE_B.equals(profile)) throw new IllegalStateException("current save failed"); + } + }; + ConfigManager.addProfileChangeListener(listener); + try { + assertThrows(IllegalStateException.class, + () -> ConfigManager.cloneProfile(PROFILE_A, PROFILE_C)); + assertEquals(PROFILE_B, ConfigManager.activeProfile()); + assertFalse(Files.exists(ConfigManager.profileDir(PROFILE_C))); + } finally { + ConfigManager.removeProfileChangeListener(listener); + } } @Test @@ -137,6 +701,31 @@ void deleteActiveProfileFallsBackToRoot() { assertFalse(ConfigManager.profileIcons().containsKey(PROFILE_A)); } + @Test + void deletesSnapshotStateBeforeReportingTheFallbackProfile() { + ConfigManager.createProfile(PROFILE_A); + List events = new ArrayList<>(); + ConfigManager.ProfileChangeListener listener = new ConfigManager.ProfileChangeListener() { + @Override + public void onProfileChanged(String newProfile) { + events.add("changed:" + newProfile); + } + + @Override + public void onProfileDeleted(String profile) { + events.add("deleted:" + profile); + } + }; + ConfigManager.addProfileChangeListener(listener); + try { + ConfigManager.deleteProfile(PROFILE_A); + + assertEquals(List.of("deleted:" + PROFILE_A, "changed:"), events); + } finally { + ConfigManager.removeProfileChangeListener(listener); + } + } + @Test void rejectsInvalidProfileNames() { assertThrows(IllegalArgumentException.class, () -> ConfigManager.createProfile("")); @@ -163,6 +752,7 @@ void switchingProfilesRebindsInitializedConfigs() { } private static void cleanupProfiles() throws IOException { + ConfigManager.setProfileSpecificControls(true); ConfigManager.setFavoriteProfile("", false); ConfigManager.setFavoriteProfile(PROFILE_A, false); ConfigManager.setFavoriteProfile(PROFILE_B, false); @@ -173,10 +763,21 @@ private static void cleanupProfiles() throws IOException { deleteProfileDirectory(PROFILE_A); deleteProfileDirectory(PROFILE_B); deleteProfileDirectory(PROFILE_C); + Files.deleteIfExists(ConfigManager.profileDir("").resolve(BLANK_CONFIG)); + Files.deleteIfExists(ConfigManager.profileDir("").resolve(CLONE_CONFIG)); + Files.deleteIfExists(ConfigManager.profileDir("").resolve(ROOT_ORPHAN_HUD)); + ConfigManager.active().delete(DIRECT_TREE_CONFIG); + ConfigManager.active().delete(GLOBAL_UI_TREE_CONFIG); + Files.deleteIfExists(ConfigManager.profileDir("").resolve(DIRECT_TREE_CONFIG)); + deletePath(ConfigManager.profileDir("").resolve("oc_profile_root")); + deletePath(ConfigManager.profileDir("").resolve("oc_profile_persisted")); } private static void deleteProfileDirectory(String profile) throws IOException { - Path path = ConfigManager.PROFILES_DIR.resolve(profile); + deletePath(ConfigManager.PROFILES_DIR.resolve(profile)); + } + + private static void deletePath(Path path) throws IOException { if (!Files.exists(path)) return; try (Stream stream = Files.walk(path)) { for (Path entry : stream.sorted(Comparator.reverseOrder()).toList()) { @@ -190,7 +791,11 @@ private static final class ProfileTestConfig extends Config { boolean enabled = false; private ProfileTestConfig() { - super("profile_test.json", "Profile Test", Category.OTHER); + this("profile_test.json"); + } + + private ProfileTestConfig(String id) { + super(id, "Profile Test", Category.OTHER); } } } diff --git a/modules/config-impl/src/test/java/org/polyfrost/oneconfig/api/config/v1/SharedColorDefaultTest.java b/modules/config-impl/src/test/java/org/polyfrost/oneconfig/api/config/v1/SharedColorDefaultTest.java index d7f6cc47c..ce82815d3 100644 --- a/modules/config-impl/src/test/java/org/polyfrost/oneconfig/api/config/v1/SharedColorDefaultTest.java +++ b/modules/config-impl/src/test/java/org/polyfrost/oneconfig/api/config/v1/SharedColorDefaultTest.java @@ -71,4 +71,13 @@ void chromaFlagIsNotLeakedOntoSiblingsSharingTheSameDefault() throws Exception { void namedColoursHandOutTheirOwnInstance() { assertNotSame(PolyColor.Companion.getWHITE(), PolyColor.Companion.getWHITE()); } + + @Test + void copyDefaultCopiesComplexValuesAndPassesSimpleOnesThrough() { + PolyColor colour = PolyColor.Companion.getWHITE(); + assertNotSame(colour, Config.copyDefault(PolyColor.class, colour)); + + String simple = "text"; + assertSame(simple, Config.copyDefault(String.class, simple)); + } } diff --git a/modules/config/api/config.api b/modules/config/api/config.api index bf4c680d3..1af4395f8 100644 --- a/modules/config/api/config.api +++ b/modules/config/api/config.api @@ -156,6 +156,7 @@ public abstract class org/polyfrost/oneconfig/api/config/v1/backend/Backend { protected abstract fun save0 (Lorg/polyfrost/oneconfig/api/config/v1/Tree;)Z public final fun saveAll ()V public final fun saveAll (Ljava/lang/String;)V + public final fun unregister (Ljava/lang/String;)Lorg/polyfrost/oneconfig/api/config/v1/Tree; } public final class org/polyfrost/oneconfig/api/config/v1/backend/Backend$RegistrationResult { 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 55033e698..06aaf9ada 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 @@ -279,6 +279,15 @@ public final boolean delete(String id) { } } + /** + * Stops tracking a tree without deleting its stored data. + */ + @ApiStatus.Internal + public final @Nullable Tree unregister(String id) { + if (id == null) throw new NullPointerException("id cannot be null"); + return trees.remove(id); + } + public boolean exists(String id) { if (id == null) return false; return trees.containsKey(id); diff --git a/modules/hud/api/hud.api b/modules/hud/api/hud.api index da1a5cf93..5994bd68e 100644 --- a/modules/hud/api/hud.api +++ b/modules/hud/api/hud.api @@ -285,6 +285,7 @@ public final class org/polyfrost/oneconfig/api/hud/v1/HudManager { public static synthetic fun removeHud$default (Lorg/polyfrost/oneconfig/api/hud/v1/HudManager;Lorg/polyfrost/oneconfig/api/hud/v1/Hud;ZILjava/lang/Object;)V public final fun render (Lorg/polyfrost/compose/render/RenderContext;FF)V public static final fun setMergeExclusions (Ljava/util/Collection;)V + public final fun setProfileReloadDispatcher (Ljava/util/function/Consumer;)V public final fun toggleAllHuds (Lorg/polyfrost/oneconfig/api/hud/v1/Hud;Z)V public final fun toggleEditor ()V public final fun unregister (Lorg/polyfrost/oneconfig/api/hud/v1/Hud;ZZ)Ljava/util/ArrayList; diff --git a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/Hud.kt b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/Hud.kt index f7cdef963..c534ae669 100644 --- a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/Hud.kt +++ b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/Hud.kt @@ -861,6 +861,9 @@ abstract class Hud(id: String, title: String, val category: Category) : Cloneabl @Transient internal var _runtime: PolyComposeRuntime? = null + @Transient + private var capturedDefaults: Tree? = null + /** Returns the runtime only if it has already been created; null otherwise. */ val runtimeOrNull: PolyComposeRuntime? get() = _runtime @@ -974,8 +977,9 @@ abstract class Hud(id: String, title: String, val category: Category) : Cloneabl addCallbacks(tree) if (with == null) LOGGER.info("generated new HUD config for $title -> ${tree.id}") Config.captureDefaults(tree) - sanitizeHudCapturedDefaults(tree) + if (out.capturedDefaults == null) out.capturedDefaults = tree ConfigManager.active().register(tree) + sanitizeHudCapturedDefaults(tree) this.tree = tree } return out @@ -1059,6 +1063,11 @@ abstract class Hud(id: String, title: String, val category: Category) : Cloneabl tree = null } + @ApiStatus.Internal + internal fun restoreCapturedDefaults() { + capturedDefaults?.let(Config::restoreCapturedDefaults) + } + open fun remove() {} @MustBeInvokedByOverriders diff --git a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudManager.kt b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudManager.kt index f8dcbdd8c..cdb3238dd 100644 --- a/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudManager.kt +++ b/modules/hud/src/main/kotlin/org/polyfrost/oneconfig/api/hud/v1/HudManager.kt @@ -43,6 +43,12 @@ import org.polyfrost.oneconfig.api.hud.v1.events.HudEditorToggleEvent import org.polyfrost.oneconfig.api.platform.v1.Platform import org.polyfrost.oneconfig.utils.v1.MHUtils import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import java.util.function.Consumer + +@Suppress("DEPRECATION") +private fun Throwable.isFatalHudFailure(): Boolean = this is VirtualMachineError || this is ThreadDeath object HudManager { internal val LOGGER = LogManager.getLogger("OneConfig/HUD") @@ -55,6 +61,19 @@ object HudManager { private set private var init = false + private const val UI_THREAD_TIMEOUT_SECONDS = 30L + @Volatile private var profileReloadDispatcher = Consumer { it.run() } + private data class ProfileReload( + val profile: String, + val saveCurrent: Boolean, + ) + private val pendingProfileReload = AtomicReference(null) + private val profileChangeListener = object : ConfigManager.ProfileChangeListener { + override fun onProfileChanged(newProfile: String) { + pendingProfileReload.set(ProfileReload(newProfile, saveCurrent = false)) + applyPendingProfileReload() + } + } private val hiddenHudPaint by lazy { org.jetbrains.skia.Paint().apply { setAlphaf(0.35f) } } /** @@ -217,6 +236,18 @@ object HudManager { fun register(hud: Hud) { hudProviders[hud::class.java] = hud revision++ + // Providers are commonly registered by later InitializationEvent handlers, after the + // manager has already performed its first load. Coalesce those registrations into one + // render-thread reload so default and persisted HUD instances appear on the first launch. + if (init && activeInstances.none { it::class.java == hud::class.java }) { + pendingProfileReload.compareAndSet( + null, + ProfileReload( + ConfigManager.activeProfile(), + saveCurrent = true, + ), + ) + } if (hud.updateFrequency() == 0L) LOGGER.warn("update of HUD ${hud.title} is 0, this is not recommended!") notifyRegistrationChanged() } @@ -262,7 +293,7 @@ object HudManager { val it = iter.next() if (it::class.java == hud::class.java) { iter.remove() - disposeHud(it, delete) + disposeHudLogging(it, delete) @Suppress("UNCHECKED_CAST") out.add(it as T) } @@ -301,36 +332,59 @@ object HudManager { fun removeHud(hud: Hud, delete: Boolean = false) { require(hud.isReal) { "Tried to remove a non-real HUD - use unregister() instead." } activeInstances.remove(hud) - disposeHud(hud, delete) + disposeHudLogging(hud, delete) + } + + private fun disposeHudLogging(hud: Hud, delete: Boolean) { + try { + disposeHud(hud, delete) + } catch (failure: Throwable) { + if (failure.isFatalHudFailure()) throw failure + LOGGER.error("Failed to dispose HUD ${hud.title}", failure) + } } private fun disposeHud(hud: Hud, delete: Boolean) { - hud._runtime?.dispose() + val treeId = hud.tree?.id + var failure: Throwable? = null + fun cleanup(action: () -> Unit) { + try { + action() + } catch (next: Throwable) { + val first = failure + if (first == null) failure = next else first.addSuppressed(next) + } + } + + // A user DisposableEffect is allowed to run while the composition is disposed. Even if it + // fails, finish detaching the HUD so a profile switch cannot leave instances from two + // profiles active at the same time. + cleanup { hud._runtime?.dispose() } hud._runtime = null lastUpdates.remove(hud) // anything hanging off this HUD goes back to being positioned against the screen, staying // where it is: the relative position it keeps alongside the anchor is already up to date - hud.tree?.id?.let { gone -> + treeId?.let { gone -> for (it in activeInstances) { - if (it.anchorTargetId == gone) it.clearAnchor() + if (it.anchorTargetId == gone) cleanup { it.clearAnchor() } } } for (it in activeInstances) { - if (it.mergeLinkX?.parent === hud || it.mergeLinkY?.parent === hud) it.clearMergeLink() + if (it.mergeLinkX?.parent === hud || it.mergeLinkY?.parent === hud) cleanup { it.clearMergeLink() } } lastMergeKey = null - invalidate() + cleanup { invalidate() } try { hud.remove() } catch (_: Throwable) {} - val treeId = hud.tree?.id // a HUD which cannot be deleted by the user must never lose its config: without this an // errant unregister(delete = true) wipes it from disk and it can never be restored. if (delete && !hud.deletable()) { LOGGER.warn("refusing to delete the config of ${hud.title}, which is marked as not user-deletable") } else if (delete && treeId != null) { - ConfigManager.active().delete(treeId) + cleanup { ConfigManager.active().delete(treeId) } } // back to being a plain provider, so a single-instance HUD can be made again later - hud.detachTree() + cleanup { hud.detachTree() } + failure?.let { throw it } } private fun screenBounds(hud: Hud): FloatArray? { @@ -752,15 +806,19 @@ object HudManager { @ApiStatus.Internal fun initialize() { if (init) throw IllegalStateException("HudManager.initialize() called twice!") + ConfigManager.active() init = true - ConfigManager.addProfileChangeListener { profile -> pendingProfileReload = profile } + ConfigManager.addProfileChangeListener(profileChangeListener) LOGGER.info("Initializing HUD...") loadFromActiveProfile() } - @Volatile private var pendingProfileReload: String? = null + @ApiStatus.Internal + fun setProfileReloadDispatcher(dispatcher: Consumer) { + profileReloadDispatcher = dispatcher + } - private fun reloadForProfile(profile: String) { + private fun teardownForProfile(profile: String) { val kept = ArrayList(activeInstances.size) for (hud in ArrayList(activeInstances)) { if (!hud.profileLocalTree) { @@ -768,48 +826,117 @@ object HudManager { continue } activeInstances.remove(hud) - disposeHud(hud, delete = false) + try { + disposeHud(hud, delete = false) + } catch (failure: Throwable) { + if (failure.isFatalHudFailure()) throw failure + LOGGER.error("Failed to dispose HUD ${hud.title} while switching profiles", failure) + } } knownProviders.clear() registryTree = null zOrderCache = emptyList() + preparedFrameValid = false lastMergeKey = null frameOrder.clear() + layoutOrder.clear() + frameGroups = emptyList() + setMergeExclusions(emptyList()) + pendingSelection = null + pendingAdd = null LOGGER.info("Reloading HUDs for profile '{}' ({} wrapped HUDs kept)", profile, kept.size) - loadFromActiveProfile() - revision++ - invalidate() + } + + private fun restoreProviderDefaults() { + for (provider in hudProviders.values) { + if (!provider.profileLocalTree) continue + try { + provider.restoreCapturedDefaults() + } catch (failure: Throwable) { + if (failure.isFatalHudFailure()) throw failure + LOGGER.error("Failed to restore defaults for HUD ${provider.title}", failure) + } + } } private fun drainProfileReload() { - val profile = pendingProfileReload ?: return - pendingProfileReload = null + val reload = pendingProfileReload.getAndSet(null) ?: return try { - reloadForProfile(profile) + synchronized(ConfigManager::class.java) { + if (reload.saveCurrent && ConfigManager.activeProfile() == reload.profile) { + ConfigManager.active().saveAll() + } + } + teardownForProfile(reload.profile) + synchronized(ConfigManager::class.java) { + restoreProviderDefaults() + loadFromActiveProfile() + } + revision++ + invalidate() } catch (e: Throwable) { - LOGGER.error("Failed to reload HUDs for profile '{}'", profile, e) + if (e.isFatalHudFailure()) throw e + LOGGER.error("Failed to reload HUDs for profile '{}'", reload.profile, e) } } + /** Applies a queued profile change on the UI thread and waits for it to finish. */ + private fun applyPendingProfileReload() { + if (pendingProfileReload.get() == null) return + ConfigManager.dispatchAndWait( + profileReloadDispatcher, + ::drainProfileReload, + TimeUnit.SECONDS.toNanos(UI_THREAD_TIMEOUT_SECONDS), + "the HUD profile reload", + ) + } + @Suppress("UNCHECKED_CAST") private fun loadFromActiveProfile() { val now = System.nanoTime() val loader = HudManager::class.java.classLoader val used = HashSet>(hudProviders.size) + val failedProviders = HashSet>() val failed = HashMap(8) var i = 0 + fun rollback(candidate: Hud?) { + if (candidate == null) return + val treeId = candidate.tree?.id + if (treeId != null) { + try { + ConfigManager.active().unregister(treeId) + } catch (failure: Throwable) { + LOGGER.error("Failed to untrack broken HUD tree $treeId", failure) + } + } + activeInstances.remove(candidate) + try { + disposeHud(candidate, delete = false) + } catch (failure: Throwable) { + candidate.detachTree() + LOGGER.error("Failed to dispose broken HUD ${candidate.title}", failure) + } + } + loadRegistry() ConfigManager.active().gatherAll("huds").forEach { data -> + var candidate: Hud? = null + var providerClass: Class? = null try { val clsName = data.getProp("hudClass").get() as? String ?: throw IllegalArgumentException("hud tree ${data.id} is missing class name") if (clsName.endsWith(".OneConfigHudCompat")) return@forEach val cls = Class.forName(clsName, true, loader) as? Class ?: throw IllegalArgumentException("$clsName is not a subclass of Hud") + providerClass = cls val h = hudProviders[cls] ?: MHUtils.instantiate(cls, true).getOrThrow() + // A previous HUD instance may still own this ID when the same backend is reloaded. + // Drop only the in-memory binding; make() will load the unchanged file into the new HUD. + ConfigManager.active().unregister(data.id) val hud = h.make(data) + candidate = hud val sec = data.getProp("section")?.getAs() if (sec != null) { hud.section = sec @@ -821,18 +948,20 @@ object HudManager { hud.setAbsolutePosition(absX, absY) } activeInstances.add(hud) - // only once the instance actually exists: marking the class used on a failed load - // would both suppress the default instance below and mark the provider known, - // permanently "deleting" a HUD because of a transient load error. - used.add(cls) hud.setup() hud.captureStaticSizeDefaults() hud.capturePositionDefaults() + used.add(cls) i++ } catch (e: ClassNotFoundException) { + rollback(candidate) + providerClass?.let(failedProviders::add) val cls = e.message?.substringAfter(':')?.trim() ?: "unknown" failed[cls] = failed.getOrDefault(cls, 0) + 1 - } catch (e: Exception) { + } catch (e: Throwable) { + rollback(candidate) + providerClass?.let(failedProviders::add) + if (e.isFatalHudFailure()) throw e LOGGER.error("Failed to load HUD from ${data.id}", e) } } @@ -846,31 +975,38 @@ object HudManager { for (cls in used) registryChanged = knownProviders.add(cls.name) or registryChanged hudProviders.forEach { (cls, h) -> - if (cls in used) return@forEach - if (h.isReal) return@forEach - val known = cls.name in knownProviders - // A HUD the user cannot delete has no legitimate "deleted" state, so a missing instance - // always means its config was lost (failed/incomplete write, corrupt file, a launch - // without the mod, ...). Restore it instead of leaving it stranded in the HUD library. - val restore = if (h.deletable()) { - // the user deleted every instance of this HUD; don't resurrect it. - h.showByDefault() && !known - } else { - h.showByDefault() || known - } - if (!restore) return@forEach - if (known && !h.deletable()) { - LOGGER.warn("HUD ${h.title} cannot be deleted but had no instance; restoring it") + var candidate: Hud? = null + try { + if (cls in used || cls in failedProviders) return@forEach + if (h.isReal) return@forEach + val known = cls.name in knownProviders + val deletable = h.deletable() + // A HUD the user cannot delete has no legitimate "deleted" state, so a missing + // instance always means its config was lost. Restore it instead of stranding it. + val restore = if (deletable) { + h.showByDefault() && !known + } else { + h.showByDefault() || known + } + if (!restore) return@forEach + if (known && !deletable) { + LOGGER.warn("HUD ${h.title} cannot be deleted but had no instance; restoring it") + } + val (dx, dy) = h.defaultPosition() + val hud = h.make() + candidate = hud + hud.setAbsolutePosition(dx, dy) + activeInstances.add(hud) + hud.setup() + hud.captureStaticSizeDefaults() + hud.capturePositionDefaults() + registryChanged = knownProviders.add(cls.name) or registryChanged + LOGGER.info("Added HUD ${hud.title} at default position ($dx, $dy)") + } catch (e: Throwable) { + rollback(candidate) + if (e.isFatalHudFailure()) throw e + LOGGER.error("Failed to add default HUD ${h.title}", e) } - val (dx, dy) = h.defaultPosition() - registryChanged = knownProviders.add(cls.name) or registryChanged - val hud = h.make() - hud.setAbsolutePosition(dx, dy) - activeInstances.add(hud) - hud.setup() - hud.captureStaticSizeDefaults() - hud.capturePositionDefaults() - LOGGER.info("Added HUD ${hud.title} at default position ($dx, $dy)") } if (registryChanged) saveRegistry() diff --git a/modules/hud/src/test/java/org/polyfrost/oneconfig/api/hud/v1/HudProfileIsolationTest.java b/modules/hud/src/test/java/org/polyfrost/oneconfig/api/hud/v1/HudProfileIsolationTest.java index c38227b87..9e5f59388 100644 --- a/modules/hud/src/test/java/org/polyfrost/oneconfig/api/hud/v1/HudProfileIsolationTest.java +++ b/modules/hud/src/test/java/org/polyfrost/oneconfig/api/hud/v1/HudProfileIsolationTest.java @@ -41,10 +41,14 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Comparator; +import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Stream; +import org.polyfrost.oneconfig.api.config.v1.Tree; + 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; class HudProfileIsolationTest { @@ -62,6 +66,7 @@ void setUp() throws Exception { void tearDown() throws Exception { ConfigManager.openProfile(""); HudManager.INSTANCE.unregister(new ProfileTestHud(), true, false); + HudManager.INSTANCE.unregister(new LateRegisteredHud(), true, false); wipeHudState(); deleteProfile(); } @@ -71,21 +76,52 @@ void hudSettingsDoNotLeakBetweenProfiles() throws Exception { HudManager.register(new ProfileTestHud()); launch(); assertEquals(1, instances()); + hud().setHidden(true); + ConfigManager.active().saveAll(); ConfigManager.createProfile(PROFILE); - drain(); - assertEquals(1, instances(), "the copied profile should have its own instance of the HUD"); + assertEquals(1, instances(), "the blank profile should have its own instance of the HUD"); + assertFalse(hud().getHidden(), "a blank profile must start from the HUD defaults"); - hud().setHidden(true); + hud().setHidden(false); ConfigManager.active().saveAll(); ConfigManager.openProfile(""); - drain(); - assertFalse(hud().getHidden(), "hiding a HUD in one profile must not hide it in another"); + assertTrue(hud().getHidden(), "the Default profile must keep its own HUD settings"); ConfigManager.openProfile(PROFILE); - drain(); - assertTrue(hud().getHidden(), "the HUD must come back hidden in the profile it was hidden in"); + assertFalse(hud().getHidden(), "the blank profile must keep its own HUD settings"); + } + + @Test + void aHudWhoseStaticSizeIsNotKnownYetKeepsItsResetDefaultOpen() throws Exception { + UnsizedHud provider = new UnsizedHud(); + HudManager.register(provider); + try { + launch(); + Tree tree = HudManager.INSTANCE.getHudsOfType(UnsizedHud.class).get(0).getTree(); + assertNull(tree.getProp("staticW").getMetadata("default"), + "a zero staticW must not be recorded as the reset default"); + assertNull(tree.getProp("staticH").getMetadata("default"), + "a zero staticH must not be recorded as the reset default"); + } finally { + HudManager.INSTANCE.unregister(provider, true, true); + } + } + + @Test + void clonedProfileKeepsHudSettings() throws Exception { + HudManager.register(new ProfileTestHud()); + launch(); + hud().setHidden(true); + ConfigManager.active().saveAll(); + + ConfigManager.cloneProfile("", PROFILE); + + assertEquals(1, instances()); + assertTrue(hud().getHidden(), "a cloned profile must copy the source HUD settings"); + assertEquals(Boolean.FALSE, hud().getTree().getProp("hidden").getMetadata("default"), + "Reset must still use the HUD's code default after cloning a changed profile"); } @Test @@ -94,13 +130,11 @@ void hudDeletedInOneProfileStillExistsInTheOther() throws Exception { launch(); ConfigManager.createProfile(PROFILE); - drain(); HudManager.INSTANCE.removeHud(hud(), true); assertEquals(0, instances()); ConfigManager.openProfile(""); - drain(); assertEquals(1, instances(), "deleting a HUD in one profile must not delete it in another"); } @@ -114,10 +148,133 @@ void hudTreeOfTheOldProfileIsNotCarriedOntoTheNewOne() throws Exception { assertFalse(ConfigManager.active().trees().stream().anyMatch(t -> t == old), "the old profile's HUD tree must not be carried onto the new profile"); - drain(); assertFalse(hud().getTree() == old, "the reload must build the HUD from the new profile's own tree"); } + @Test + void failedDefaultHudIsRolledBackWithoutStoppingOtherProviders() throws Exception { + FailingSetupHud failing = new FailingSetupHud(); + HealthySetupHud healthy = new HealthySetupHud(); + HudManager.register(failing, healthy); + try { + launch(); + + assertTrue(HudManager.INSTANCE.getHudsOfType(FailingSetupHud.class).isEmpty()); + assertFalse(failing.isReal(), "a failed single-instance provider must be usable again"); + assertFalse(ConfigManager.active().trees().stream() + .anyMatch(tree -> "huds/test-failing-setup".equals(tree.getID())), + "a failed candidate must not remain tracked by the backend"); + assertEquals(1, HudManager.INSTANCE.getHudsOfType(HealthySetupHud.class).size(), + "one broken provider must not abort the rest of the HUD load"); + } finally { + HudManager.INSTANCE.unregister(failing, true, false); + HudManager.INSTANCE.unregister(healthy, true, false); + ConfigManager.active().delete("huds/test-failing-setup"); + ConfigManager.active().delete("huds/test-healthy-setup"); + } + } + + @Test + void linkageErrorInOneHudDoesNotAbortOtherProviders() throws Exception { + LinkageFailingSetupHud failing = new LinkageFailingSetupHud(); + HealthySetupHud healthy = new HealthySetupHud(); + HudManager.register(failing, healthy); + try { + launch(); + + assertTrue(HudManager.INSTANCE.getHudsOfType(LinkageFailingSetupHud.class).isEmpty()); + assertFalse(failing.isReal()); + assertEquals(1, HudManager.INSTANCE.getHudsOfType(HealthySetupHud.class).size(), + "a missing optional HUD dependency must not stop healthy providers"); + } finally { + HudManager.INSTANCE.unregister(failing, true, false); + HudManager.INSTANCE.unregister(healthy, true, false); + ConfigManager.active().delete("huds/test-linkage-failing-setup"); + ConfigManager.active().delete("huds/test-healthy-setup"); + } + } + + @Test + void linkageErrorWhileCheckingADefaultHudDoesNotAbortOtherProviders() throws Exception { + EligibilityFailingHud failing = new EligibilityFailingHud(); + HealthySetupHud healthy = new HealthySetupHud(); + HudManager.register(failing, healthy); + try { + launch(); + + assertTrue(HudManager.INSTANCE.getHudsOfType(EligibilityFailingHud.class).isEmpty()); + assertEquals(1, HudManager.INSTANCE.getHudsOfType(HealthySetupHud.class).size(), + "a broken default-visibility check must not stop healthy providers"); + } finally { + HudManager.INSTANCE.unregister(failing, true, false); + HudManager.INSTANCE.unregister(healthy, true, false); + ConfigManager.active().delete("huds/test-eligibility-failing"); + ConfigManager.active().delete("huds/test-healthy-setup"); + } + } + + @Test + void providerRegisteredAfterInitializationIsLoadedWithoutAProfileSwitch() throws Exception { + LateRegisteredHud provider = new LateRegisteredHud(); + launch(); + assertTrue(HudManager.INSTANCE.getHudsOfType(LateRegisteredHud.class).isEmpty()); + + HudManager.register(provider); + try { + drainPendingProfileReload(); + assertEquals(1, HudManager.INSTANCE.getHudsOfType(LateRegisteredHud.class).size(), + "a provider registered by a later startup handler must be available immediately"); + } finally { + HudManager.INSTANCE.unregister(provider, true, true); + } + } + + @Test + void lateRegistrationDoesNotBreakAHudAlreadyLoadedFromDisk() throws Exception { + LateRegisteredHud initialProvider = new LateRegisteredHud(); + HudManager.register(initialProvider); + launch(); + assertEquals(1, HudManager.INSTANCE.getHudsOfType(LateRegisteredHud.class).size()); + ConfigManager.active().saveAll(); + + // Simulate the next launch loading the persisted class before that mod reaches its own + // InitializationEvent handler. The backend is intentionally kept warm to catch tree merges. + HudManager.INSTANCE.unregister(initialProvider, true, false); + launch(); + assertEquals(1, HudManager.INSTANCE.getHudsOfType(LateRegisteredHud.class).size()); + + LateRegisteredHud registeredProvider = new LateRegisteredHud(); + HudManager.register(registeredProvider); + try { + drainPendingProfileReload(); + assertEquals(1, HudManager.INSTANCE.getHudsOfType(LateRegisteredHud.class).size()); + Hud loaded = HudManager.INSTANCE.getHudsOfType(LateRegisteredHud.class).get(0); + assertTrue(loaded.getTree().getProp("prefix") != null, + "the same-backend reload must not clear the rebuilt HUD tree"); + } finally { + HudManager.INSTANCE.unregister(registeredProvider, true, true); + } + } + + @Test + void lateRegistrationDoesNotDiscardUnsavedHudChanges() throws Exception { + ProfileTestHud existingProvider = new ProfileTestHud(); + LateRegisteredHud lateProvider = new LateRegisteredHud(); + HudManager.register(existingProvider); + launch(); + hud().setHidden(true); + + HudManager.register(lateProvider); + try { + drainPendingProfileReload(); + assertTrue(hud().getHidden(), + "reloading for a late provider must first persist the live HUD state"); + assertEquals(1, HudManager.INSTANCE.getHudsOfType(LateRegisteredHud.class).size()); + } finally { + HudManager.INSTANCE.unregister(lateProvider, true, true); + } + } + private static Hud hud() { return HudManager.INSTANCE.getHudsOfType(ProfileTestHud.class).get(0); } @@ -126,12 +283,6 @@ private static int instances() { return HudManager.INSTANCE.getHudsOfType(ProfileTestHud.class).size(); } - private static void drain() throws Exception { - Method m = HudManager.class.getDeclaredMethod("drainProfileReload"); - m.setAccessible(true); - m.invoke(HudManager.INSTANCE); - } - private static void launch() throws Exception { HudManager.INSTANCE.getActiveInstances().clear(); knownProviders().clear(); @@ -152,7 +303,7 @@ private static void wipeHudState() throws Exception { knownProviders().clear(); set("registryTree", null); set("init", false); - set("pendingProfileReload", null); + pendingProfileReload().set(null); Path folder = ConfigManager.active().getFolder(); deleteRecursively(folder.resolve("huds")); Files.deleteIfExists(folder.resolve("hud-registry.json")); @@ -185,10 +336,23 @@ private static java.util.Set knownProviders() throws Exception { return (java.util.Set) f.get(HudManager.INSTANCE); } + @SuppressWarnings("unchecked") + private static AtomicReference pendingProfileReload() throws Exception { + Field f = HudManager.class.getDeclaredField("pendingProfileReload"); + f.setAccessible(true); + return (AtomicReference) f.get(HudManager.INSTANCE); + } + private static void set(String name, Object value) throws Exception { set(HudManager.INSTANCE, name, value); } + private static void drainPendingProfileReload() throws Exception { + Method method = HudManager.class.getDeclaredMethod("drainProfileReload"); + method.setAccessible(true); + method.invoke(HudManager.INSTANCE); + } + private static void set(Object owner, String name, Object value) throws Exception { Class cls = owner.getClass(); while (cls != null) { @@ -229,4 +393,164 @@ public String getText() { return "test"; } } + + /** Stands in for a wrapped external HUD, whose size is not known when its tree is built. */ + static class UnsizedHud extends TextHud { + UnsizedHud() { + super("test-unsized", "Test Unsized HUD", Hud.Category.getINFO(), "", ""); + } + + @Override + public float getStaticW() { + return 0f; + } + + @Override + public void setStaticW(float value) { + } + + @Override + public float getStaticH() { + return 0f; + } + + @Override + public void setStaticH(float value) { + } + + @Override + public boolean showByDefault() { + return true; + } + + @Override + public boolean multipleInstancesAllowed() { + return false; + } + + @Override + public String getText() { + return "test"; + } + } + + static class LateRegisteredHud extends TextHud { + LateRegisteredHud() { + super("test-late-registration", "Test Late Registration HUD", Hud.Category.getINFO(), "", ""); + } + + @Override + public Pair defaultPosition() { + return new Pair<>(10f, 10f); + } + + @Override + public boolean showByDefault() { + return true; + } + + @Override + public boolean multipleInstancesAllowed() { + return false; + } + + @Override + public String getText() { + return "late"; + } + } + + static class FailingSetupHud extends TextHud { + FailingSetupHud() { + super("test-failing-setup", "Failing Setup HUD", Hud.Category.getINFO(), "", ""); + } + + @Override + public boolean showByDefault() { + return true; + } + + @Override + public boolean multipleInstancesAllowed() { + return false; + } + + @Override + public void setup() { + throw new IllegalStateException("setup failed"); + } + + @Override + public String getText() { + return "fail"; + } + } + + static class HealthySetupHud extends TextHud { + HealthySetupHud() { + super("test-healthy-setup", "Healthy Setup HUD", Hud.Category.getINFO(), "", ""); + } + + @Override + public boolean showByDefault() { + return true; + } + + @Override + public boolean multipleInstancesAllowed() { + return false; + } + + @Override + public String getText() { + return "healthy"; + } + } + + static class LinkageFailingSetupHud extends TextHud { + LinkageFailingSetupHud() { + super("test-linkage-failing-setup", "Linkage Failing Setup HUD", Hud.Category.getINFO(), "", ""); + } + + @Override + public boolean showByDefault() { + return true; + } + + @Override + public boolean multipleInstancesAllowed() { + return false; + } + + @Override + public void setup() { + throw new NoClassDefFoundError("missing.optional.HudDependency"); + } + + @Override + public String getText() { + return "linkage-fail"; + } + } + + static class EligibilityFailingHud extends TextHud { + EligibilityFailingHud() { + super("test-eligibility-failing", "Eligibility Failing HUD", Hud.Category.getINFO(), "", ""); + } + + @Override + public boolean showByDefault() { + throw new NoClassDefFoundError("missing.optional.VisibilityDependency"); + } + + @Override + public boolean multipleInstancesAllowed() { + return false; + } + + @Override + public String getText() { + return "eligibility-fail"; + } + } } 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 49ee15769..736e0344e 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 @@ -5,8 +5,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList +import org.apache.logging.log4j.LogManager import org.polyfrost.oneconfig.api.config.v1.ConfigManager import org.polyfrost.oneconfig.api.config.v1.Tree +import org.polyfrost.oneconfig.api.platform.v1.Platform import org.polyfrost.oneconfig.internal.ui.components.asRenderText import org.polyfrost.oneconfig.internal.ui.hud.hudModCardConfigs import org.polyfrost.oneconfig.internal.ui.keybind.MinecraftKeybindRegistrar @@ -14,6 +16,8 @@ import org.polyfrost.oneconfig.internal.ui.search.ConfigDocumentSource import org.polyfrost.oneconfig.internal.ui.search.SearchCorpus object ConfigRegistry { + private val logger = LogManager.getLogger("OneConfig/ConfigRegistry") + private val hiddenModCardIds = setOf( "oneconfig.json", "themes.json", @@ -61,7 +65,30 @@ object ConfigRegistry { init { // Index configs as they come in (compat layers etc...) - ConfigManager.addTreeRegistrationListener { tree -> registerTree(tree, ConfigSource.OC) } + ConfigManager.addTreeRegistrationListener { tree -> + if (ConfigManager.isRebindingProfiles()) return@addTreeRegistrationListener + // Profile rebinding may register trees from a background worker. Registry state and + // Minecraft's key-mapping array both belong to the UI thread. + Platform.screen().runOnUiThread { + try { + // A queued registration from an older profile must not overwrite the active one. + if (ConfigManager.active().trees().any { it === tree }) { + registerTree(tree, ConfigSource.OC) + } + } catch (failure: Throwable) { + logger.error("Failed to register config tree {}", tree.id, failure) + } + } + } + ConfigManager.addProfileChangeListener { + Platform.screen().runOnUiThread { + try { + loadFrom(ConfigManager.active(), ConfigSource.OC) + } catch (failure: Throwable) { + logger.error("Failed to reload configs after a profile change", failure) + } + } + } } fun shouldShowModCard(config: ConfigData): Boolean = diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/Options.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/Options.kt index 05743bbd0..58ab8b25d 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/Options.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/components/settings/Options.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import org.polyfrost.compose.render.PolyColor +import org.polyfrost.oneconfig.api.config.v1.Config import org.polyfrost.oneconfig.api.config.v1.Property import org.polyfrost.oneconfig.api.config.v1.Visualizer import org.polyfrost.oneconfig.api.ui.v1.keybind.OneConfigKeybind @@ -66,7 +67,7 @@ fun bumpResetEpoch(prop: Property<*>) { @Suppress("UNCHECKED_CAST") fun resetOption(prop: Property<*>) { val def = prop.getMetadata("default") ?: return - (prop as Property).setAsReferential(def) + (prop as Property).setAsReferential(Config.copyDefault(prop.type, def)) // mirror ColorOption: a PolyColor change must refresh the live accent (otherwise it only // updates after the GUI is reopened). if (prop.type == PolyColor::class.java) updateAccent() diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/components/HudPreview.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/components/HudPreview.kt index b6a96e88f..47e9a8fda 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/components/HudPreview.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/hud/components/HudPreview.kt @@ -27,9 +27,11 @@ import androidx.compose.ui.unit.Constraints import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.apache.logging.log4j.LogManager import org.polyfrost.compose.render.RenderContext import org.polyfrost.compose.runtime.PolyComposeRuntime import org.polyfrost.oneconfig.api.hud.v1.Hud +import org.polyfrost.oneconfig.api.hud.v1.HudManager import org.polyfrost.oneconfig.internal.ui.components.Text import org.polyfrost.oneconfig.internal.ui.components.localizedValue import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme @@ -37,6 +39,10 @@ import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme private const val MAX_PREVIEW_SCALE = 2f private const val MEASURE_BOUNDS = 2000f +private val LOGGER = LogManager.getLogger("OneConfig/HUD-Preview") + +@Suppress("DEPRECATION") +private fun Throwable.isFatalPreviewFailure(): Boolean = this is VirtualMachineError || this is ThreadDeath internal class HudPreviewState(val runtime: PolyComposeRuntime) { var naturalWidth by mutableStateOf(0f) @@ -49,12 +55,20 @@ internal class HudPreviewState(val runtime: PolyComposeRuntime) { @Composable internal fun rememberHudPreview(hud: Hud): HudPreviewState { - val state = remember(hud) { + val revision = HudManager.revision + val state = remember(hud, revision) { hud.update() HudPreviewState(PolyComposeRuntime().also { rt -> rt.setContent { hud.Content() } }) } DisposableEffect(state) { - onDispose { state.runtime.dispose() } + onDispose { + try { + state.runtime.dispose() + } catch (failure: Throwable) { + if (failure.isFatalPreviewFailure()) throw failure + LOGGER.warn("Failed to dispose HUD preview", failure) + } + } } LaunchedEffect(state) { hud.update() diff --git a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Profiles.kt b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Profiles.kt index b4d43c861..57c693b76 100644 --- a/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Profiles.kt +++ b/modules/internal/src/main/kotlin/org/polyfrost/oneconfig/internal/ui/screens/Profiles.kt @@ -1,14 +1,17 @@ package org.polyfrost.oneconfig.internal.ui.screens import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -20,7 +23,10 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.BasicText 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.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -30,28 +36,43 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.center import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight 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 androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties import org.polyfrost.oneconfig.api.config.v1.ConfigManager -import org.polyfrost.oneconfig.internal.ui.api.ConfigRegistry -import org.polyfrost.oneconfig.internal.ui.api.ConfigSource +import org.polyfrost.oneconfig.api.notifications.v1.Notifications +import org.polyfrost.oneconfig.api.platform.v1.DesktopHelper +import org.polyfrost.oneconfig.api.platform.v1.Platform +import org.polyfrost.oneconfig.api.ui.v1.api.TinyFdApi import org.polyfrost.oneconfig.internal.ui.components.Chip import org.polyfrost.oneconfig.internal.ui.components.Icon import org.polyfrost.oneconfig.internal.ui.components.Text @@ -61,6 +82,8 @@ import org.polyfrost.oneconfig.internal.ui.search.searchMatches import org.polyfrost.oneconfig.internal.ui.shell.ShellState import org.polyfrost.oneconfig.internal.ui.themes.Accent import org.polyfrost.oneconfig.internal.ui.themes.LocalTheme +import org.polyfrost.oneconfig.internal.ui.themes.concentric +import java.nio.file.Files enum class ProfileCategory(val title: String, val icon: String?) { All("All profiles", null), @@ -77,7 +100,13 @@ private data class UiProfile( val editable: Boolean get() = id.isNotEmpty() } +private data class ProfileActionResult( + val profiles: List?, + val failure: Throwable?, +) + private val ProfileCardHeight = 180.dp +private enum class ProfileEditor { Rename, Clone, Icon } private val ProfileIconOptions = listOf( "profiles", "star", @@ -96,34 +125,100 @@ fun Profiles() { var activeCategory by remember { mutableStateOf(ProfileCategory.All) } var profiles by remember { mutableStateOf(emptyList()) } var newProfileName by remember { mutableStateOf("") } - var error by remember { mutableStateOf(null) } + var createError by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() var busy by remember { mutableStateOf(false) } var createTick by remember { mutableIntStateOf(0) } + var profileSpecificControls by remember { mutableStateOf(true) } LaunchedEffect(Unit) { - profiles = withContext(Dispatchers.IO) { loadProfiles() } + val loaded = withContext(Dispatchers.IO) { + loadProfiles() to ConfigManager.profileSpecificControls() + } + profiles = loaded.first + profileSpecificControls = loaded.second } - fun refresh() { - ConfigRegistry.loadFrom(ConfigManager.active(), ConfigSource.OC) - profiles = loadProfiles() + fun runProfileAction( + onSuccess: () -> Unit = {}, + onError: (String) -> Unit = { Notifications.error("Profile action failed", it) }, + holdsBusy: Boolean = false, + action: () -> Unit, + ) { + Platform.screen().runOnUiThread { + if (busy && !holdsBusy) return@runOnUiThread + busy = true + // A profile operation must finish refreshing the global registry even if this page is + // closed while its blocking IO is still running. Enter the non-cancellable section + // before the first suspension so disposal of the composition cannot strand the UI on + // the previous profile. + val ownerJob = scope.coroutineContext[Job]!! + scope.launch(start = CoroutineStart.UNDISPATCHED) { + withContext(NonCancellable) { + val result = withContext(Dispatchers.IO) { + try { + action() + ProfileActionResult(loadProfiles(), null) + } catch (failure: Throwable) { + val refreshed = try { + loadProfiles() + } catch (refreshFailure: Throwable) { + failure.addSuppressed(refreshFailure) + null + } + ProfileActionResult(refreshed, failure) + } + } + Platform.screen().runOnUiThread { + if (ownerJob.isCancelled) { + result.failure?.let { failure -> + Notifications.error( + "Profile action failed", + failure.messageOrType(), + ) + } + return@runOnUiThread + } + try { + result.profiles?.let { profiles = it } + val failure = result.failure + if (failure == null) onSuccess() + else onError(failure.messageOrType()) + } catch (failure: Throwable) { + Notifications.error( + "Profile action failed", + failure.messageOrType(), + ) + } finally { + busy = false + } + } + } + } + } } - fun runProfileAction(onSuccess: () -> Unit = {}, action: () -> Unit) { - if (busy) return - busy = true - scope.launch(Dispatchers.IO) { - try { - action() - error = null - refresh() - onSuccess() - } catch (t: Throwable) { - error = t.message ?: t::class.java.simpleName - profiles = loadProfiles() - } finally { - busy = false + fun runProfileDialog(prompt: () -> T?, action: (T) -> Unit) { + Platform.screen().runOnUiThread { + if (busy) return@runOnUiThread + busy = true + scope.launch(start = CoroutineStart.UNDISPATCHED) { + withContext(NonCancellable) { + val choice = try { + withContext(Dispatchers.IO) { prompt() } + } catch (failure: Throwable) { + Platform.screen().runOnUiThread { + busy = false + Notifications.error("Profile action failed", failure.messageOrType()) + } + return@withContext + } + if (choice == null) { + Platform.screen().runOnUiThread { busy = false } + return@withContext + } + runProfileAction(holdsBusy = true) { action(choice) } + } } } } @@ -145,36 +240,75 @@ fun Profiles() { if (localSearchQuery.isBlank()) categorizedProfiles else categorizedProfiles.filter { it.matchesSearch(localSearchQuery) } } + val existingProfileIds = remember(profiles) { profiles.mapTo(HashSet()) { it.id.lowercase() } } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - ProfileCategory.entries.forEach { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ProfileCategory.entries.forEach { + Chip( + label = it.title, + selected = activeCategory == it, + icon = it.icon, + onClick = { activeCategory = it } + ) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Chip( - label = it.title, - selected = activeCategory == it, - icon = it.icon, - onClick = { activeCategory = it } + label = "Separate controls", + selected = profileSpecificControls, + icon = "keyboard", + onClick = { + val enabled = !profileSpecificControls + runProfileAction(onSuccess = { profileSpecificControls = enabled }) { + ConfigManager.setProfileSpecificControls(enabled) + } + }, + ) + Chip( + label = "Open folder", + selected = false, + icon = "folder", + onClick = { + scope.launch(Dispatchers.IO) { + runCatching { + Files.createDirectories(ConfigManager.PROFILES_DIR) + DesktopHelper.open(ConfigManager.PROFILES_DIR.toFile()) + }.onFailure { + Notifications.error("Could not open the profiles folder", it.messageOrType()) + } + } + }, ) } } ProfilesGrid( profiles = visibleProfiles, + existingProfileIds = existingProfileIds, showCreateProfile = activeCategory == ProfileCategory.All && localSearchQuery.isBlank(), emptyMessage = if (localSearchQuery.isBlank()) "No favorite profiles." else "No profiles match \"$localSearchQuery\"", newProfileName = newProfileName, - createError = error, + createError = createError, busy = busy, createTick = createTick, onNewProfileNameChange = { newProfileName = it - error = null + createError = null }, onCreateProfile = { val profileName = newProfileName - runProfileAction(onSuccess = { createTick++ }) { - ConfigManager.createProfile(profileName) + runProfileAction(onSuccess = { newProfileName = "" + createError = null + createTick++ + }, onError = { createError = it }) { + ConfigManager.createProfile(profileName) } }, onOpen = { profile -> @@ -185,19 +319,60 @@ fun Profiles() { onFavorite = { profile -> runProfileAction { ConfigManager.setFavoriteProfile(profile.id, !profile.favorite) } }, - onRename = { profile, newName -> - runProfileAction { ConfigManager.renameProfile(profile.id, newName) } + onRename = { profile, newName, onSuccess, onError -> + runProfileAction(onSuccess, onError) { + ConfigManager.renameProfile(profile.id, newName) + } }, - onIconChange = { profile, icon -> - runProfileAction { ConfigManager.setProfileIcon(profile.id, icon) } + onClone = { profile, newName, onSuccess, onError -> + runProfileAction(onSuccess, onError) { + ConfigManager.cloneProfile(profile.id, newName) + } + }, + onIconChange = { profile, icon, onSuccess, onError -> + runProfileAction(onSuccess, onError) { ConfigManager.setProfileIcon(profile.id, icon) } }, onDelete = { profile -> - runProfileAction { ConfigManager.deleteProfile(profile.id) } + runProfileDialog( + prompt = { + TinyFdApi.getInstance().showMessageBox( + "Delete profile", + "Delete ${profile.name}? This cannot be undone.", + TinyFdApi.YES_NO_DIALOG, + TinyFdApi.WARNING_ICON, + false, + ).takeIf { it } + }, + action = { ConfigManager.deleteProfile(profile.id) }, + ) + }, + onExport = { profile -> + runProfileDialog( + prompt = { + val defaultName = profile.name.replace(Regex("[^a-zA-Z0-9._-]"), "_") + ".zip" + TinyFdApi.getInstance().openSaveSelector( + "Export profile", + defaultName, + arrayOf("*.zip"), + "Zip archive", + ) + }, + action = { destination -> + val archive = if (destination.fileName.toString().endsWith(".zip", ignoreCase = true)) { + destination + } else { + destination.resolveSibling(destination.fileName.toString() + ".zip") + } + ConfigManager.exportProfile(profile.id, archive) + }, + ) } ) } } +private fun Throwable.messageOrType(): String = message ?: this::class.java.simpleName + private fun UiProfile.matchesSearch(query: String): Boolean { val q = query.lowercase() return listOf(name, id, icon) @@ -222,6 +397,7 @@ private fun loadProfiles(): List { @Composable private fun ColumnScope.ProfilesGrid( profiles: List, + existingProfileIds: Set, showCreateProfile: Boolean, emptyMessage: String, newProfileName: String, @@ -232,10 +408,27 @@ private fun ColumnScope.ProfilesGrid( onCreateProfile: () -> Unit, onOpen: (UiProfile) -> Unit, onFavorite: (UiProfile) -> Unit, - onRename: (UiProfile, String) -> Unit, - onIconChange: (UiProfile, String) -> Unit, + onRename: (UiProfile, String, () -> Unit, (String) -> Unit) -> Unit, + onClone: (UiProfile, String, () -> Unit, (String) -> Unit) -> Unit, + onIconChange: (UiProfile, String, () -> Unit, (String) -> Unit) -> Unit, onDelete: (UiProfile) -> Unit, + onExport: (UiProfile) -> Unit, ) { + var editingProfileId by remember { mutableStateOf(null) } + var activeEditor by remember { mutableStateOf(null) } + var menuProfileId by remember { mutableStateOf(null) } + var creatingProfile by remember { mutableStateOf(false) } + val visibleProfileIds = profiles.mapTo(HashSet()) { it.id } + + LaunchedEffect(visibleProfileIds, showCreateProfile) { + if (editingProfileId != null && editingProfileId !in visibleProfileIds) { + editingProfileId = null + activeEditor = null + } + if (menuProfileId != null && menuProfileId !in visibleProfileIds) menuProfileId = null + if (!showCreateProfile) creatingProfile = false + } + if (profiles.isEmpty() && !showCreateProfile) { Box(Modifier.weight(1f).fillMaxSize(), contentAlignment = Alignment.Center) { Text(emptyMessage, color = LocalTheme.current.textColorSecondary) @@ -250,49 +443,99 @@ private fun ColumnScope.ProfilesGrid( horizontalArrangement = Arrangement.spacedBy(19.dp), ) { if (showCreateProfile) { - item { + item(key = "create-profile") { CreateProfileCard( value = newProfileName, error = createError, busy = busy, + creating = creatingProfile, createTick = createTick, onValueChange = onNewProfileNameChange, onCreate = onCreateProfile, + onCreatingChange = { creating -> + creatingProfile = creating + if (creating) { + editingProfileId = null + activeEditor = null + menuProfileId = null + } + }, ) } } - profiles.forEach { profile -> - item { - ProfileCard( - profile = profile, - onOpen = { onOpen(profile) }, - onFavorite = { onFavorite(profile) }, - onRename = { newName -> onRename(profile, newName) }, - onIconChange = { icon -> onIconChange(profile, icon) }, - onDelete = { onDelete(profile) }, - ) - } + items(profiles, key = { "profile:${it.id}" }) { profile -> + ProfileCard( + profile = profile, + busy = busy, + editor = activeEditor.takeIf { editingProfileId == profile.id }, + menuOpen = menuProfileId == profile.id, + suggestedCloneName = nextCloneName(profile.name, existingProfileIds), + onEditorChange = { editor -> + if (editor == null) { + if (editingProfileId == profile.id) { + editingProfileId = null + activeEditor = null + } + } else { + creatingProfile = false + editingProfileId = profile.id + activeEditor = editor + menuProfileId = null + } + }, + onMenuOpenChange = { open -> + if (open) { + creatingProfile = false + menuProfileId = profile.id + editingProfileId = null + activeEditor = null + } else if (menuProfileId == profile.id) { + menuProfileId = null + } + }, + onOpen = { onOpen(profile) }, + onFavorite = { onFavorite(profile) }, + onRename = { newName, onSuccess, onError -> + onRename(profile, newName, onSuccess, onError) + }, + onClone = { newName, onSuccess, onError -> + onClone(profile, newName, onSuccess, onError) + }, + onIconChange = { icon, onSuccess, onError -> + onIconChange(profile, icon, onSuccess, onError) + }, + onDelete = { onDelete(profile) }, + onExport = { onExport(profile) }, + ) } } } +private fun nextCloneName(profileName: String, profileIds: Set): String { + val base = "$profileName copy" + if (base.lowercase() !in profileIds) return base + var suffix = 2 + while ("$base $suffix".lowercase() in profileIds) suffix++ + return "$base $suffix" +} + @Composable private fun CreateProfileCard( value: String, error: String?, busy: Boolean, + creating: Boolean, createTick: Int, onValueChange: (String) -> Unit, onCreate: () -> Unit, + onCreatingChange: (Boolean) -> Unit, ) { val interactionSource = rememberInteractionSource() val isHovered by interactionSource.collectIsHoveredAsState() val theme = LocalTheme.current val shape = theme.modCardShape - var creating by remember { mutableStateOf(false) } - LaunchedEffect(createTick) { - if (createTick > 0) creating = false + if (createTick > 0) onCreatingChange(false) } val borderColor by animateColorAsState( @@ -316,7 +559,7 @@ private fun CreateProfileCard( ), shape ) .onClick(interactionSource) { - if (!creating) creating = true + if (!creating) onCreatingChange(true) } .clip(shape) .pointerHoverIcon(PointerIcon.Hand) @@ -359,7 +602,7 @@ private fun CreateProfileCard( } ActionIcon("close", enabled = !busy, tint = theme.textColorSecondary) { onValueChange("") - creating = false + onCreatingChange(false) } } } @@ -380,10 +623,12 @@ private fun CreateProfileCard( if (creating) { ProfileTextField( value = value, - placeholder = error ?: "Profile name", - isError = error != null, + placeholder = "Profile name", + error = error, + enabled = !busy, width = 150.dp, onValueChange = onValueChange, + onSubmit = { if (!busy) onCreate() }, ) } else { Text( @@ -400,17 +645,47 @@ private fun CreateProfileCard( @Composable private fun ProfileCard( profile: UiProfile, + busy: Boolean, + editor: ProfileEditor?, + menuOpen: Boolean, + suggestedCloneName: String, + onEditorChange: (ProfileEditor?) -> Unit, + onMenuOpenChange: (Boolean) -> Unit, onOpen: () -> Unit, onFavorite: () -> Unit, - onRename: (String) -> Unit, - onIconChange: (String) -> Unit, + onRename: (String, () -> Unit, (String) -> Unit) -> Unit, + onClone: (String, () -> Unit, (String) -> Unit) -> Unit, + onIconChange: (String, () -> Unit, (String) -> Unit) -> Unit, onDelete: () -> Unit, + onExport: () -> Unit, ) { val interactionSource = rememberInteractionSource() + val isHovered by interactionSource.collectIsHoveredAsState() val theme = LocalTheme.current val shape = theme.modCardShape - var editing by remember(profile.id) { mutableStateOf(false) } var editName by remember(profile.id) { mutableStateOf(profile.name) } + var editError by remember(profile.id) { mutableStateOf(null) } + + fun closeEditor() { + onEditorChange(null) + editName = profile.name + editError = null + } + + fun submitName() { + if (busy) return + editError = null + val onSuccess = { + onEditorChange(null) + editError = null + } + val onError = { message: String -> editError = message } + when (editor) { + ProfileEditor.Rename -> onRename(editName, onSuccess, onError) + ProfileEditor.Clone -> onClone(editName, onSuccess, onError) + else -> Unit + } + } val selectionBorderColor by animateColorAsState( if (profile.active) Accent else Color.Transparent @@ -428,7 +703,7 @@ private fun ProfileCard( ), shape ) .onClick(interactionSource) { - if (!editing) onOpen() + if (editor == null && !menuOpen) onOpen() } .clip(shape) .pointerHoverIcon(PointerIcon.Hand) @@ -460,74 +735,209 @@ private fun ProfileCard( horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, ) { - if (profile.editable) { - if (editing) { - ActionIcon("tick", tint = Accent) { - onRename(editName) - editing = false - } - ActionIcon("close", tint = theme.textColorSecondary) { - editName = profile.name - editing = false - } - } else { - ActionIcon("spanner", tint = theme.textColorSecondary) { - editing = true - } - ActionIcon("trash", tint = theme.textColorSecondary) { - onDelete() - } + if (editor != null) { + if (editor == ProfileEditor.Rename || editor == ProfileEditor.Clone) { + ActionIcon("tick", enabled = !busy, tint = Accent, onClick = ::submitName) + } + ActionIcon("close", enabled = !busy, tint = theme.textColorSecondary, onClick = ::closeEditor) + } else if (profile.active || isHovered || menuOpen) { + ActionIcon("settings", enabled = !busy, tint = theme.textColorSecondary, hoveredTint = Accent) { + onMenuOpenChange(true) } } - ActionIcon( - icon = if (profile.favorite) "star-filled" else "star", - tint = if (profile.favorite) Color(0xFFFFD700) else theme.textColor.copy(0.5f), - onClick = onFavorite, - ) } + ProfileActionsMenu( + profile = profile, + expanded = menuOpen, + enabled = !busy, + onDismiss = { onMenuOpenChange(false) }, + onClone = { + editName = suggestedCloneName + editError = null + onEditorChange(ProfileEditor.Clone) + }, + onRename = { + editName = profile.name + editError = null + onEditorChange(ProfileEditor.Rename) + }, + onIconChange = { + editError = null + onEditorChange(ProfileEditor.Icon) + }, + onFavorite = onFavorite, + onExport = onExport, + onDelete = onDelete, + ) + Column( modifier = Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally, ) { - Icon(profile.icon, modifier = Modifier.size(if (editing) 42.dp else 64.dp), color = theme.textColor) - Spacer(Modifier.height(if (editing) 14.dp else 24.dp)) - if (editing) { - ProfileTextField( + Icon(profile.icon, modifier = Modifier.size(if (editor != null) 42.dp else 64.dp), color = theme.textColor) + Spacer(Modifier.height(if (editor != null) 14.dp else 24.dp)) + when (editor) { + ProfileEditor.Rename, ProfileEditor.Clone -> ProfileTextField( value = editName, placeholder = "Profile name", width = 150.dp, - onValueChange = { editName = it }, + error = editError, + enabled = !busy, + onValueChange = { + editName = it + editError = null + }, + onSubmit = ::submitName, ) - Spacer(Modifier.height(10.dp)) - ProfileIconPicker( + ProfileEditor.Icon -> ProfileIconPicker( selectedIcon = profile.icon, - onIconChange = onIconChange, - ) - } else { - BasicText( - profile.name, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 18.dp), - style = TextStyle( - color = theme.textColor, - fontSize = 18.sp, - fontFamily = theme.typography.family, - fontWeight = FontWeight.Medium, - textAlign = TextAlign.Center, - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, + enabled = !busy, + onIconChange = { + editError = null + onIconChange( + it, + { + onEditorChange(null) + editError = null + }, + { message -> editError = message }, + ) + }, ) + null -> { + BasicText( + profile.name, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp), + style = TextStyle( + color = theme.textColor, + fontSize = 18.sp, + fontFamily = theme.typography.family, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (editor == ProfileEditor.Icon && editError != null) { + ProfileError(editError!!, 150.dp) + } + } + } +} + +@Composable +private fun ProfileActionsMenu( + profile: UiProfile, + expanded: Boolean, + enabled: Boolean, + onDismiss: () -> Unit, + onClone: () -> Unit, + onRename: () -> Unit, + onIconChange: () -> Unit, + onFavorite: () -> Unit, + onExport: () -> Unit, + onDelete: () -> Unit, +) { + if (!expanded) return + val theme = LocalTheme.current + Popup( + alignment = Alignment.TopEnd, + onDismissRequest = onDismiss, + properties = PopupProperties(focusable = true), + ) { + Column( + modifier = Modifier + .width(IntrinsicSize.Max) + .background(theme.popupBackground, theme.popupShape) + .border(1.dp, theme.borderColor, theme.popupShape) + .padding(4.dp), + ) { + ProfileMenuItem("copy", "Clone", enabled = enabled) { + onDismiss() + onClone() + } + if (profile.editable) { + ProfileMenuItem("text-input", "Rename", enabled = enabled) { + onDismiss() + onRename() + } + ProfileMenuItem("paintbrush", "Change icon", enabled = enabled) { + onDismiss() + onIconChange() + } + } + ProfileMenuItem( + if (profile.favorite) "star-filled" else "star", + if (profile.favorite) "Remove favorite" else "Favorite", + enabled = enabled, + ) { + onDismiss() + onFavorite() + } + ProfileMenuItem("cloud", "Export / share", enabled = enabled) { + onDismiss() + onExport() + } + if (profile.editable) { + Spacer(Modifier.height(4.dp)) + ProfileMenuItem("trash", "Delete", danger = true, enabled = enabled) { + onDismiss() + onDelete() + } } } } } +@Composable +private fun ProfileMenuItem( + icon: String, + label: String, + danger: Boolean = false, + enabled: Boolean = true, + onClick: () -> Unit, +) { + val theme = LocalTheme.current + val interactionSource = rememberInteractionSource() + val isHovered by interactionSource.collectIsHoveredAsState() + val baseColor = if (danger) Color(0xFFE35B5B) else theme.textColor + val color = if (enabled) baseColor else theme.textColorSecondary + val shape = theme.popupShape.concentric(4.dp) + val hoverBackground by animateColorAsState( + targetValue = baseColor.copy(alpha = if (enabled && isHovered) 0.10f else 0f), + animationSpec = tween(120), + label = "profileMenuItemHover", + ) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(shape) + .background(hoverBackground, shape) + .then( + if (enabled) Modifier + .onClick(interactionSource, onClick) + .hoverable(interactionSource) + .pointerHoverIcon(PointerIcon.Hand) + else Modifier + ) + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(icon, color = color, modifier = Modifier.size(14.dp)) + Text(label, color = color, fontSize = 13.sp) + } +} + @Composable private fun ProfileIconPicker( selectedIcon: String, + enabled: Boolean, onIconChange: (String) -> Unit, ) { Column( @@ -540,6 +950,7 @@ private fun ProfileIconPicker( ProfileIconChoice( icon = icon, selected = icon == selectedIcon, + enabled = enabled, onClick = { onIconChange(icon) }, ) } @@ -552,21 +963,22 @@ private fun ProfileIconPicker( private fun ProfileIconChoice( icon: String, selected: Boolean, + enabled: Boolean, onClick: () -> Unit, ) { val interactionSource = rememberInteractionSource() val theme = LocalTheme.current val isHovered by interactionSource.collectIsHoveredAsState() val backgroundColor by animateColorAsState( - if (selected) Accent.copy(alpha = 0.22f) - else if (isHovered) theme.textColor.copy(alpha = 0.08f) + if (selected) Accent.copy(alpha = if (enabled) 0.22f else 0.10f) + else if (enabled && isHovered) theme.textColor.copy(alpha = 0.08f) else Color.Transparent ) val borderColor by animateColorAsState( - if (selected) Accent else theme.borderColor.copy(alpha = 0f) + if (selected && enabled) Accent else theme.borderColor.copy(alpha = 0f) ) val iconColor by animateColorAsState( - if (selected || isHovered) theme.textColor else theme.textColorSecondary + if (enabled && (selected || isHovered)) theme.textColor else theme.textColorSecondary ) Box( @@ -574,8 +986,12 @@ private fun ProfileIconChoice( .size(24.dp) .background(backgroundColor, theme.sideBarNavigationEntryShape) .border(1.dp, borderColor, theme.sideBarNavigationEntryShape) - .onClick(interactionSource, onClick) - .pointerHoverIcon(PointerIcon.Hand), + .then( + if (enabled) Modifier + .onClick(interactionSource, onClick) + .pointerHoverIcon(PointerIcon.Hand) + else Modifier + ), contentAlignment = Alignment.Center, ) { Icon(icon, modifier = Modifier.size(14.dp), color = iconColor) @@ -616,46 +1032,89 @@ private fun ProfileTextField( value: String, placeholder: String, width: androidx.compose.ui.unit.Dp, - isError: Boolean = false, + error: String? = null, + enabled: Boolean = true, onValueChange: (String) -> Unit, + onSubmit: () -> Unit, ) { val theme = LocalTheme.current val interactionSource = rememberInteractionSource() + val focusRequester = remember { FocusRequester() } val isFocused by interactionSource.collectIsFocusedAsState() val shape = theme.sideBarNavigationEntryShape val borderColor by animateColorAsState( - if (isError) Color(0xFFE35B5B) + if (error != null) Color(0xFFE35B5B) else if (isFocused) Accent else theme.borderColor ) - BasicTextField( - value = value, - onValueChange = onValueChange, - singleLine = true, - textStyle = TextStyle( - color = theme.textColor, - fontSize = 14.sp, - fontFamily = theme.typography.family, - ), - interactionSource = interactionSource, - cursorBrush = SolidColor(theme.textColor), - modifier = Modifier - .width(width) - .background(theme.modCardBackground, shape) - .border(1.dp, borderColor, shape) - .padding(horizontal = 12.dp, vertical = 7.dp), - decorationBox = { innerTextField -> - Box { - if (value.isEmpty()) { - Text( - placeholder, - color = if (isError) Color(0xFFE35B5B) else theme.textColorSecondary, - fontSize = 14.sp, - ) + Column( + modifier = Modifier.width(width), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + BasicTextField( + value = value, + onValueChange = onValueChange, + enabled = enabled, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { if (enabled) onSubmit() }), + textStyle = TextStyle( + color = theme.textColor, + fontSize = 14.sp, + fontFamily = theme.typography.family, + ), + interactionSource = interactionSource, + cursorBrush = SolidColor(theme.textColor), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .onPreviewKeyEvent { event -> + if (enabled && event.type == KeyEventType.KeyDown && + (event.key == Key.Enter || event.key == Key.NumPadEnter) + ) { + onSubmit() + true + } else { + false + } } - innerTextField() - } - }, + .background(theme.modCardBackground, shape) + .border(1.dp, borderColor, shape) + .padding(horizontal = 12.dp, vertical = 7.dp), + decorationBox = { innerTextField -> + Box { + if (value.isEmpty()) { + Text( + placeholder, + color = theme.textColorSecondary, + fontSize = 14.sp, + ) + } + innerTextField() + } + }, + ) + if (error != null) ProfileError(error, width) + } + + LaunchedEffect(Unit) { + runCatching { focusRequester.requestFocus() } + } +} + +@Composable +private fun ProfileError(message: String, width: androidx.compose.ui.unit.Dp) { + BasicText( + text = message, + modifier = Modifier.width(width).padding(top = 4.dp), + style = TextStyle( + color = Color(0xFFE35B5B), + fontSize = 10.sp, + fontFamily = LocalTheme.current.typography.family, + textAlign = TextAlign.Center, + ), + maxLines = 2, + overflow = TextOverflow.Ellipsis, ) } diff --git a/modules/utils/api/utils.api b/modules/utils/api/utils.api index 98df3b8a7..7a0203fcc 100644 --- a/modules/utils/api/utils.api +++ b/modules/utils/api/utils.api @@ -162,6 +162,7 @@ public abstract interface class org/polyfrost/oneconfig/api/platform/v1/ScreenPl public abstract fun guiWidth ()I public fun mcToScreenScale ()F public fun pixelRatio ()F + public fun runOnUiThread (Ljava/lang/Runnable;)V public fun screenToMcScale ()F public fun showMessage (Ljava/lang/String;)V public fun surfaceRatio ()F diff --git a/modules/utils/src/main/java/org/polyfrost/oneconfig/api/platform/v1/ScreenPlatform.java b/modules/utils/src/main/java/org/polyfrost/oneconfig/api/platform/v1/ScreenPlatform.java index 08f8a58ed..0b1c43045 100644 --- a/modules/utils/src/main/java/org/polyfrost/oneconfig/api/platform/v1/ScreenPlatform.java +++ b/modules/utils/src/main/java/org/polyfrost/oneconfig/api/platform/v1/ScreenPlatform.java @@ -30,6 +30,11 @@ public interface ScreenPlatform { + /** Runs an action on the thread which owns the game's UI. */ + default void runOnUiThread(Runnable action) { + action.run(); + } + int viewportWidth(); int viewportHeight();