diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java index 3bc843e9c..eeacf7fe6 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java @@ -37,6 +37,7 @@ import art.arcane.iris.world.PendingWorldDeleteQueue; import art.arcane.iris.world.PendingWorldReplacementManager; import art.arcane.iris.world.WorldRefusalReporter; import art.arcane.iris.configuration.SettingsHotloadWatch; +import art.arcane.iris.update.IrisUpdateNotifier; import art.arcane.iris.pack.datapack.ServerConfigurator; import art.arcane.iris.pack.datapack.DatapackIngestService; import art.arcane.iris.pack.datapack.DatapackIngestService.StartupValidationOutcome; @@ -215,6 +216,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { private final PendingWorldDeleteQueue pendingWorldDeletes = new PendingWorldDeleteQueue(this); private final PendingWorldReplacementManager pendingWorldReplacements = new PendingWorldReplacementManager(this); private BukkitLanguageSwitcher languageSwitcher; + private IrisUpdateNotifier updateNotifier; private BukkitDebugDump debugDump; private volatile SettingsHotloadWatch settingsHotloadWatch; private volatile Thread serverLifecycleThread; @@ -637,6 +639,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { languageSwitcher = BukkitLanguageSwitcher.register(this, IrisLanguage.selections(), new BukkitLanguageSwitcher.Options("iris", "iris.all", DirectorMiniMenu.Theme.irisGreen(), IrisLanguage.directorResolver(), IrisLanguage.editorOptions())); + updateNotifier = new IrisUpdateNotifier(this); PaperLibBootstrap.install(); SimdSupport.install(); timings.mark("bootstrap"); @@ -974,6 +977,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } public void onDisable() { + if (updateNotifier != null) { + updateNotifier.close(); + updateNotifier = null; + } if (debugDump != null) { debugDump.close(); debugDump = null; diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/update/IrisUpdateNotifier.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/update/IrisUpdateNotifier.java new file mode 100644 index 000000000..cf738e11d --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/update/IrisUpdateNotifier.java @@ -0,0 +1,37 @@ +package art.arcane.iris.update; + +import art.arcane.iris.configuration.IrisSettings; +import art.arcane.volmlib.util.update.BukkitUpdateService; +import org.bukkit.plugin.Plugin; + +import java.util.function.Consumer; + +public final class IrisUpdateNotifier implements AutoCloseable { + private final Consumer settingsListener = this::reconfigure; + private final BukkitUpdateService updates; + private volatile boolean enabled; + private boolean closed; + + public IrisUpdateNotifier(Plugin plugin) { + enabled = IrisSettings.get().getGeneral().isUpdateNotifications(); + updates = BukkitUpdateService.register(plugin, new BukkitUpdateService.Options( + "VolmitSoftware", "Iris", "iris.update", () -> enabled)); + IrisSettings.addSettingsListener(settingsListener); + reconfigure(IrisSettings.get()); + } + + private synchronized void reconfigure(IrisSettings settings) { + if (closed) { + return; + } + enabled = settings.getGeneral().isUpdateNotifications(); + updates.reconfigure(); + } + + @Override + public synchronized void close() { + closed = true; + IrisSettings.removeSettingsListener(settingsListener); + updates.close(); + } +} diff --git a/adapters/bukkit/plugin/src/main/resources/paper-plugin.yml b/adapters/bukkit/plugin/src/main/resources/paper-plugin.yml index e19c61888..7e8138ace 100644 --- a/adapters/bukkit/plugin/src/main/resources/paper-plugin.yml +++ b/adapters/bukkit/plugin/src/main/resources/paper-plugin.yml @@ -10,6 +10,9 @@ authors: [ cyberpwn, NextdoorPsycho, Vatuu ] website: VolmitSoftware.com description: More than a Dimension! permissions: + iris.update: + description: Allows viewing Iris updates in VolmLib's combined update reports. + default: op iris.debugdump: description: Allows creating and optionally uploading diagnostic reports. default: op diff --git a/adapters/bukkit/plugin/src/main/resources/plugin.yml b/adapters/bukkit/plugin/src/main/resources/plugin.yml index b70dbf69a..ad1509e1d 100644 --- a/adapters/bukkit/plugin/src/main/resources/plugin.yml +++ b/adapters/bukkit/plugin/src/main/resources/plugin.yml @@ -23,6 +23,9 @@ commands: iris: aliases: [ ir, irs ] permissions: + iris.update: + description: Allows viewing Iris updates in VolmLib's combined update reports. + default: op iris.debugdump: description: Allows creating and optionally uploading diagnostic reports. default: op diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/update/IrisUpdateNotifierTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/update/IrisUpdateNotifierTest.java new file mode 100644 index 000000000..aa2835c0b --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/update/IrisUpdateNotifierTest.java @@ -0,0 +1,61 @@ +package art.arcane.iris.update; + +import art.arcane.iris.configuration.IrisSettings; +import art.arcane.volmlib.util.update.BukkitUpdateService; +import org.bukkit.plugin.Plugin; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +public class IrisUpdateNotifierTest { + @Test + public void acceptedSettingsReconfigureChecksAndClosingRemovesTheListener() { + IrisSettings previous = IrisSettings.settings; + IrisSettings.settings = new IrisSettings(); + Plugin plugin = mock(Plugin.class); + BukkitUpdateService service = mock(BukkitUpdateService.class); + try (MockedStatic registration = mockStatic(BukkitUpdateService.class)) { + registration.when(() -> BukkitUpdateService.register(eq(plugin), any())).thenReturn(service); + IrisUpdateNotifier notifier = new IrisUpdateNotifier(plugin); + try { + ArgumentCaptor options = ArgumentCaptor.forClass(BukkitUpdateService.Options.class); + registration.verify(() -> BukkitUpdateService.register(eq(plugin), options.capture())); + assertEquals("VolmitSoftware", options.getValue().owner()); + assertEquals("Iris", options.getValue().repository()); + assertEquals("iris.update", options.getValue().permission()); + assertTrue(options.getValue().enabled().getAsBoolean()); + + clearInvocations(service); + assertFalse(IrisSettings.applyHotloadSnapshot( + "{\"general\":{\"updateNotifications\":false}}", candidate -> false)); + assertTrue(options.getValue().enabled().getAsBoolean()); + verifyNoInteractions(service); + + IrisSettings.installHotloadSnapshot("{\"general\":{\"updateNotifications\":false}}"); + assertFalse(options.getValue().enabled().getAsBoolean()); + verify(service).reconfigure(); + IrisSettings.settings.getGeneral().setUpdateNotifications(true); + assertFalse(options.getValue().enabled().getAsBoolean()); + } finally { + notifier.close(); + } + verify(service).close(); + clearInvocations(service); + IrisSettings.installHotloadSnapshot("{}"); + verifyNoInteractions(service); + } finally { + IrisSettings.settings = previous; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/configuration/IrisSettings.java b/core/src/main/java/art/arcane/iris/configuration/IrisSettings.java index ab140edab..9a11ca66e 100644 --- a/core/src/main/java/art/arcane/iris/configuration/IrisSettings.java +++ b/core/src/main/java/art/arcane/iris/configuration/IrisSettings.java @@ -33,11 +33,14 @@ import lombok.Data; import java.io.File; import java.io.IOException; import java.util.Objects; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; import java.util.function.Predicate; @Data public class IrisSettings { private static final Object SETTINGS_LOCK = new Object(); + private static final CopyOnWriteArrayList> LISTENERS = new CopyOnWriteArrayList<>(); private static final IrisSettings BOOTSTRAP_DEFAULTS = new IrisSettings(); public static volatile IrisSettings settings; private IrisSettingsGeneral general = new IrisSettingsGeneral(); @@ -80,6 +83,7 @@ public class IrisSettings { current = read(); settings = current; + notifySettingsChanged(current); return current; } } @@ -130,6 +134,7 @@ public class IrisSettings { IrisSettings parsed = parseHotloadSnapshot(rawJson); synchronized (SETTINGS_LOCK) { settings = parsed; + notifySettingsChanged(parsed); } return parsed; } @@ -142,6 +147,7 @@ public class IrisSettings { } synchronized (SETTINGS_LOCK) { settings = parsed; + notifySettingsChanged(parsed); } return true; } @@ -165,6 +171,20 @@ public class IrisSettings { return parsed; } + public static void addSettingsListener(Consumer listener) { + LISTENERS.add(Objects.requireNonNull(listener)); + } + + public static void removeSettingsListener(Consumer listener) { + LISTENERS.remove(listener); + } + + private static void notifySettingsChanged(IrisSettings current) { + for (Consumer listener : LISTENERS) { + listener.accept(current); + } + } + public void forceSave() { File s = IrisPlatforms.get().dataFile("iris.json"); @@ -310,6 +330,7 @@ public class IrisSettings { public static class IrisSettingsGeneral { public String language = "en_US"; public boolean metrics = true; + public boolean updateNotifications = true; public boolean commandSounds = true; public boolean debug = false; public boolean dumpMantleOnError = false; diff --git a/core/src/test/java/art/arcane/iris/configuration/IrisSettingsDefaultsTest.java b/core/src/test/java/art/arcane/iris/configuration/IrisSettingsDefaultsTest.java index 42e19ec60..497421b08 100644 --- a/core/src/test/java/art/arcane/iris/configuration/IrisSettingsDefaultsTest.java +++ b/core/src/test/java/art/arcane/iris/configuration/IrisSettingsDefaultsTest.java @@ -12,6 +12,16 @@ import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertThrows; public class IrisSettingsDefaultsTest { + @Test + public void updateChecksDefaultOnAndPersistAnExplicitOptOut() { + assertTrue(IrisSettings.parseHotloadSnapshot("{}").getGeneral().isUpdateNotifications()); + IrisSettings optedOut = IrisSettings.parseHotloadSnapshot( + "{\"general\":{\"updateNotifications\":false}}"); + assertFalse(optedOut.getGeneral().isUpdateNotifications()); + assertFalse(new Gson().fromJson(new Gson().toJson(optedOut), IrisSettings.class) + .getGeneral().isUpdateNotifications()); + } + @Test public void regionBudgetLeavesHeapForHydrologyAndServerChunks() { IrisSettings.IrisSettingsPregen settings = new IrisSettings.IrisSettingsPregen();