From 67a65c45d3f5530b31fa4ca587d339a77018d4c9 Mon Sep 17 00:00:00 2001
From: Camotoy <20743703+Camotoy@users.noreply.github.com>
Date: Sun, 21 Aug 2022 21:22:15 -0400
Subject: [PATCH] Implement update notifications for Geyser

Geyser installations will now get notified when a new Bedrock release is out and Geyser must be updated. The system works similarly to ViaVersion where OPs get a notification of an update when they join. The permission node for players to see update notifications is `geyser.update` and the backing JSON that controls this can be found at https://github.com/GeyserMC/GeyserSite/blob/gh-pages/versions.json. There is also a config option to disable update checking.

This update also fixes modern Paper installations not being able to see colored text logged from Geyser in the console.
---
 bootstrap/bungeecord/pom.xml                  |   6 +
 .../bungeecord/GeyserBungeePlugin.java        |   2 +
 .../GeyserBungeeUpdateListener.java           |  48 ++++++
 .../command/BungeeCommandSender.java          |  23 ++-
 bootstrap/pom.xml                             |   4 +
 bootstrap/spigot/pom.xml                      |  11 +-
 .../platform/spigot/GeyserPaperLogger.java    |  59 +++++++
 .../platform/spigot/GeyserSpigotPlugin.java   |  23 ++-
 .../spigot/GeyserSpigotUpdateListener.java    |  48 ++++++
 .../platform/spigot/PaperAdventure.java       | 154 ++++++++++++++++++
 .../spigot/command/SpigotCommandSender.java   |  13 ++
 .../manager/GeyserSpigotWorldManager.java     |   8 +-
 .../standalone/GeyserStandaloneLogger.java    |  23 +--
 .../velocity/GeyserVelocityPlugin.java        |   2 +
 .../GeyserVelocityUpdateListener.java         |  47 ++++++
 .../command/VelocityCommandSender.java        |   7 +
 .../java/org/geysermc/geyser/Constants.java   |   3 +
 .../java/org/geysermc/geyser/GeyserImpl.java  |  10 +-
 .../org/geysermc/geyser/GeyserLogger.java     |  34 +++-
 .../geyser/command/CommandSender.java         |   6 +
 .../configuration/GeyserConfiguration.java    |   2 +
 .../GeyserJacksonConfiguration.java           |   3 +
 .../geyser/session/cache/WorldCache.java      |   2 +
 .../geyser/util/VersionCheckUtils.java        |  47 ++++++
 .../org/geysermc/geyser/util/WebUtils.java    |   2 +
 core/src/main/resources/config.yml            |   5 +
 26 files changed, 558 insertions(+), 34 deletions(-)
 create mode 100644 bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/GeyserBungeeUpdateListener.java
 create mode 100644 bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserPaperLogger.java
 create mode 100644 bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotUpdateListener.java
 create mode 100644 bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/PaperAdventure.java
 create mode 100644 bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/GeyserVelocityUpdateListener.java

diff --git a/bootstrap/bungeecord/pom.xml b/bootstrap/bungeecord/pom.xml
index 5a1e8e262..d71a20f42 100644
--- a/bootstrap/bungeecord/pom.xml
+++ b/bootstrap/bungeecord/pom.xml
@@ -24,6 +24,12 @@
             <version>a7c6ede</version>
             <scope>provided</scope>
         </dependency>
+        <dependency>
+            <groupId>net.kyori</groupId>
+            <artifactId>adventure-text-serializer-bungeecord</artifactId>
+            <version>${adventure-platform.version}</version>
+            <scope>compile</scope>
+        </dependency>
     </dependencies>
     <build>
         <finalName>${outputName}-BungeeCord</finalName>
diff --git a/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/GeyserBungeePlugin.java b/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/GeyserBungeePlugin.java
index 0883c5ff0..e8d44b02f 100644
--- a/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/GeyserBungeePlugin.java
+++ b/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/GeyserBungeePlugin.java
@@ -149,6 +149,8 @@ public class GeyserBungeePlugin extends Plugin implements GeyserBootstrap {
         }
 
         this.getProxy().getPluginManager().registerCommand(this, new GeyserBungeeCommandExecutor(geyser));
+
+        this.getProxy().getPluginManager().registerListener(this, new GeyserBungeeUpdateListener());
     }
 
     @Override
diff --git a/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/GeyserBungeeUpdateListener.java b/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/GeyserBungeeUpdateListener.java
new file mode 100644
index 000000000..bbde8771e
--- /dev/null
+++ b/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/GeyserBungeeUpdateListener.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2019-2022 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.platform.bungeecord;
+
+import net.md_5.bungee.api.connection.ProxiedPlayer;
+import net.md_5.bungee.api.event.PostLoginEvent;
+import net.md_5.bungee.api.plugin.Listener;
+import net.md_5.bungee.event.EventHandler;
+import org.geysermc.geyser.Constants;
+import org.geysermc.geyser.GeyserImpl;
+import org.geysermc.geyser.platform.bungeecord.command.BungeeCommandSender;
+import org.geysermc.geyser.util.VersionCheckUtils;
+
+public final class GeyserBungeeUpdateListener implements Listener {
+
+    @EventHandler
+    public void onPlayerJoin(final PostLoginEvent event) {
+        if (GeyserImpl.getInstance().getConfig().isNotifyOnNewBedrockUpdate()) {
+            final ProxiedPlayer player = event.getPlayer();
+            if (player.hasPermission(Constants.UPDATE_PERMISSION)) {
+                VersionCheckUtils.checkForGeyserUpdate(() -> new BungeeCommandSender(player));
+            }
+        }
+    }
+}
diff --git a/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/command/BungeeCommandSender.java b/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/command/BungeeCommandSender.java
index 05df8ba97..dcf5bd689 100644
--- a/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/command/BungeeCommandSender.java
+++ b/bootstrap/bungeecord/src/main/java/org/geysermc/geyser/platform/bungeecord/command/BungeeCommandSender.java
@@ -25,11 +25,15 @@
 
 package org.geysermc.geyser.platform.bungeecord.command;
 
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.serializer.bungeecord.BungeeComponentSerializer;
 import net.md_5.bungee.api.chat.TextComponent;
 import net.md_5.bungee.api.connection.ProxiedPlayer;
 import org.geysermc.geyser.command.CommandSender;
 import org.geysermc.geyser.text.GeyserLocale;
 
+import java.util.Locale;
+
 public class BungeeCommandSender implements CommandSender {
 
     private final net.md_5.bungee.api.CommandSender handle;
@@ -50,6 +54,18 @@ public class BungeeCommandSender implements CommandSender {
         handle.sendMessage(TextComponent.fromLegacyText(message));
     }
 
+    private static final int PROTOCOL_HEX_COLOR = 713; // Added 20w17a
+
+    @Override
+    public void sendMessage(Component message) {
+        if (handle instanceof ProxiedPlayer player && player.getPendingConnection().getVersion() >= PROTOCOL_HEX_COLOR) {
+            // Include hex colors
+            handle.sendMessage(BungeeComponentSerializer.get().serialize(message));
+            return;
+        }
+        handle.sendMessage(BungeeComponentSerializer.legacy().serialize(message));
+    }
+
     @Override
     public boolean isConsole() {
         return !(handle instanceof ProxiedPlayer);
@@ -58,8 +74,11 @@ public class BungeeCommandSender implements CommandSender {
     @Override
     public String getLocale() {
         if (handle instanceof ProxiedPlayer player) {
-            String locale = player.getLocale().getLanguage() + "_" + player.getLocale().getCountry();
-            return GeyserLocale.formatLocale(locale);
+            Locale locale = player.getLocale();
+            if (locale != null) {
+                // Locale can be null early on in the conneciton
+                return GeyserLocale.formatLocale(locale.getLanguage() + "_" + locale.getCountry());
+            }
         }
         return GeyserLocale.getDefaultLocale();
     }
diff --git a/bootstrap/pom.xml b/bootstrap/pom.xml
index 371ed9bca..35ec15abe 100644
--- a/bootstrap/pom.xml
+++ b/bootstrap/pom.xml
@@ -11,6 +11,10 @@
     <artifactId>bootstrap-parent</artifactId>
     <packaging>pom</packaging>
 
+    <properties>
+        <adventure-platform.version>4.1.2</adventure-platform.version>
+    </properties>
+
     <repositories>
         <repository>
             <id>spigot-public</id>
diff --git a/bootstrap/spigot/pom.xml b/bootstrap/spigot/pom.xml
index 5142d2bc3..ad4b58fe2 100644
--- a/bootstrap/spigot/pom.xml
+++ b/bootstrap/spigot/pom.xml
@@ -59,7 +59,13 @@
         <dependency>
             <groupId>me.lucko</groupId>
             <artifactId>commodore</artifactId>
-            <version>1.13</version>
+            <version>2.2</version>
+            <scope>compile</scope>
+        </dependency>
+        <dependency>
+            <groupId>net.kyori</groupId>
+            <artifactId>adventure-text-serializer-bungeecord</artifactId>
+            <version>${adventure-platform.version}</version>
             <scope>compile</scope>
         </dependency>
     </dependencies>
@@ -107,6 +113,9 @@
                                 <relocation>
                                     <pattern>net.kyori</pattern>
                                     <shadedPattern>org.geysermc.geyser.platform.spigot.shaded.kyori</shadedPattern>
+                                    <excludes>
+                                        <exclude>net.kyori.adventure.text.logger.slf4j.ComponentLogger</exclude>
+                                    </excludes>
                                 </relocation>
                                 <relocation>
                                     <pattern>org.objectweb.asm</pattern>
diff --git a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserPaperLogger.java b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserPaperLogger.java
new file mode 100644
index 000000000..930f84cec
--- /dev/null
+++ b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserPaperLogger.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2019-2022 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.platform.spigot;
+
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.logger.slf4j.ComponentLogger;
+import org.bukkit.plugin.Plugin;
+
+import java.util.logging.Logger;
+
+public final class GeyserPaperLogger extends GeyserSpigotLogger {
+    private final ComponentLogger componentLogger;
+
+    public GeyserPaperLogger(Plugin plugin, Logger logger, boolean debug) {
+        super(logger, debug);
+        componentLogger = plugin.getComponentLogger();
+    }
+
+    /**
+     * Since 1.18.2 this is required so legacy format symbols don't show up in the console for colors
+     */
+    @Override
+    public void sendMessage(Component message) {
+        // Done like this so the native component object field isn't relocated
+        componentLogger.info("{}", PaperAdventure.toNativeComponent(message));
+    }
+
+    static boolean supported() {
+        try {
+            Plugin.class.getMethod("getComponentLogger");
+            return true;
+        } catch (NoSuchMethodException e) {
+            return false;
+        }
+    }
+}
diff --git a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotPlugin.java b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotPlugin.java
index 21c54308d..a1d9245e8 100644
--- a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotPlugin.java
+++ b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotPlugin.java
@@ -123,6 +123,22 @@ public class GeyserSpigotPlugin extends JavaPlugin implements GeyserBootstrap {
             return;
         }
 
+        try {
+            Class.forName("net.md_5.bungee.chat.ComponentSerializer");
+        } catch (ClassNotFoundException e) {
+            if (!PaperAdventure.canSendMessageUsingComponent()) { // Prepare for Paper eventually removing Bungee chat
+                getLogger().severe("*********************************************");
+                getLogger().severe("");
+                getLogger().severe(GeyserLocale.getLocaleStringLog("geyser.bootstrap.unsupported_server_type.header", getServer().getName()));
+                getLogger().severe(GeyserLocale.getLocaleStringLog("geyser.bootstrap.unsupported_server_type.message", "Paper"));
+                getLogger().severe("");
+                getLogger().severe("*********************************************");
+
+                Bukkit.getPluginManager().disablePlugin(this);
+                return;
+            }
+        }
+
         // By default this should be localhost but may need to be changed in some circumstances
         if (this.geyserConfig.getRemote().getAddress().equalsIgnoreCase("auto")) {
             geyserConfig.setAutoconfiguredRemote(true);
@@ -137,7 +153,8 @@ public class GeyserSpigotPlugin extends JavaPlugin implements GeyserBootstrap {
             geyserConfig.getBedrock().setPort(Bukkit.getPort());
         }
 
-        this.geyserLogger = new GeyserSpigotLogger(getLogger(), geyserConfig.isDebugMode());
+        this.geyserLogger = GeyserPaperLogger.supported() ? new GeyserPaperLogger(this, getLogger(), geyserConfig.isDebugMode())
+                : new GeyserSpigotLogger(getLogger(), geyserConfig.isDebugMode());
         GeyserConfiguration.checkGeyserConfiguration(geyserConfig, geyserLogger);
 
         // Remove this in like a year
@@ -266,12 +283,16 @@ public class GeyserSpigotPlugin extends JavaPlugin implements GeyserBootstrap {
                         GeyserLocale.getLocaleStringLog(command.getDescription()),
                         command.isSuggestedOpOnly() ? PermissionDefault.OP : PermissionDefault.TRUE));
             }
+            Bukkit.getPluginManager().addPermission(new Permission(Constants.UPDATE_PERMISSION,
+                    "Whether update notifications can be seen", PermissionDefault.OP));
 
             // Events cannot be unregistered - re-registering results in duplicate firings
             GeyserSpigotBlockPlaceListener blockPlaceListener = new GeyserSpigotBlockPlaceListener(geyser, this.geyserWorldManager);
             Bukkit.getServer().getPluginManager().registerEvents(blockPlaceListener, this);
 
             Bukkit.getServer().getPluginManager().registerEvents(new GeyserPistonListener(geyser, this.geyserWorldManager), this);
+
+            Bukkit.getServer().getPluginManager().registerEvents(new GeyserSpigotUpdateListener(), this);
         }
 
         boolean brigadierSupported = CommodoreProvider.isSupported();
diff --git a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotUpdateListener.java b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotUpdateListener.java
new file mode 100644
index 000000000..02f5367b3
--- /dev/null
+++ b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/GeyserSpigotUpdateListener.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2019-2022 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.platform.spigot;
+
+import org.bukkit.entity.Player;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.Listener;
+import org.bukkit.event.player.PlayerJoinEvent;
+import org.geysermc.geyser.Constants;
+import org.geysermc.geyser.GeyserImpl;
+import org.geysermc.geyser.platform.spigot.command.SpigotCommandSender;
+import org.geysermc.geyser.util.VersionCheckUtils;
+
+public final class GeyserSpigotUpdateListener implements Listener {
+
+    @EventHandler
+    public void onPlayerJoin(final PlayerJoinEvent event) {
+        if (GeyserImpl.getInstance().getConfig().isNotifyOnNewBedrockUpdate()) {
+            final Player player = event.getPlayer();
+            if (player.hasPermission(Constants.UPDATE_PERMISSION)) {
+                VersionCheckUtils.checkForGeyserUpdate(() -> new SpigotCommandSender(player));
+            }
+        }
+    }
+}
diff --git a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/PaperAdventure.java b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/PaperAdventure.java
new file mode 100644
index 000000000..5dd16da33
--- /dev/null
+++ b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/PaperAdventure.java
@@ -0,0 +1,154 @@
+/*
+ * Copyright (c) 2019-2022 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.platform.spigot;
+
+import com.github.steveice10.mc.protocol.data.DefaultComponentSerializer;
+import net.kyori.adventure.text.Component;
+import org.bukkit.command.CommandSender;
+import org.geysermc.geyser.GeyserImpl;
+import org.jetbrains.annotations.Nullable;
+
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodType;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+
+/**
+ * Utility class for converting our shaded Adventure into the Adventure bundled in Paper.
+ *
+ * Code mostly taken from https://github.com/KyoriPowered/adventure-platform/blob/94d5821f2e755170f42bd8a5fe1d5bf6f66d04ad/platform-bukkit/src/main/java/net/kyori/adventure/platform/bukkit/PaperFacet.java#L46
+ * and the MinecraftReflection class.
+ */
+public final class PaperAdventure {
+    private static final MethodHandle NATIVE_GSON_COMPONENT_SERIALIZER_DESERIALIZE_METHOD_BOUND;
+    private static final Method SEND_MESSAGE_COMPONENT;
+
+    static {
+        final MethodHandles.Lookup lookup = MethodHandles.lookup();
+
+        MethodHandle nativeGsonComponentSerializerDeserializeMethodBound = null;
+
+        // String.join because otherwise the class name will be relocated
+        final Class<?> nativeGsonComponentSerializerClass = findClass(String.join(".",
+                "net", "kyori", "adventure", "text", "serializer", "gson", "GsonComponentSerializer"));
+        final Class<?> nativeGsonComponentSerializerImplClass = findClass(String.join(".",
+                "net", "kyori", "adventure", "text", "serializer", "gson", "GsonComponentSerializerImpl"));
+        if (nativeGsonComponentSerializerClass != null && nativeGsonComponentSerializerImplClass != null) {
+            MethodHandle nativeGsonComponentSerializerGsonGetter = null;
+            try {
+                nativeGsonComponentSerializerGsonGetter = lookup.findStatic(nativeGsonComponentSerializerClass,
+                        "gson", MethodType.methodType(nativeGsonComponentSerializerClass));
+            } catch (final NoSuchMethodException | IllegalAccessException ignored) {
+            }
+
+            MethodHandle nativeGsonComponentSerializerDeserializeMethod = null;
+            try {
+                final Method method = nativeGsonComponentSerializerImplClass.getDeclaredMethod("deserialize", String.class);
+                method.setAccessible(true);
+                nativeGsonComponentSerializerDeserializeMethod = lookup.unreflect(method);
+            } catch (final NoSuchMethodException | IllegalAccessException ignored) {
+            }
+
+            if (nativeGsonComponentSerializerGsonGetter != null) {
+                if (nativeGsonComponentSerializerDeserializeMethod != null) {
+                    try {
+                        nativeGsonComponentSerializerDeserializeMethodBound = nativeGsonComponentSerializerDeserializeMethod
+                                .bindTo(nativeGsonComponentSerializerGsonGetter.invoke());
+                    } catch (final Throwable throwable) {
+                        GeyserImpl.getInstance().getLogger().error("Failed to access native GsonComponentSerializer", throwable);
+                    }
+                }
+            }
+        }
+
+        NATIVE_GSON_COMPONENT_SERIALIZER_DESERIALIZE_METHOD_BOUND = nativeGsonComponentSerializerDeserializeMethodBound;
+
+        Method playerComponentSendMessage = null;
+        final Class<?> nativeComponentClass = findClass(String.join(".",
+                "net", "kyori", "adventure", "text", "Component"));
+        if (nativeComponentClass != null) {
+            try {
+                playerComponentSendMessage = CommandSender.class.getMethod("sendMessage", nativeComponentClass);
+            } catch (final NoSuchMethodException e) {
+                if (GeyserImpl.getInstance().getLogger().isDebug()) {
+                    e.printStackTrace();
+                }
+            }
+        }
+        SEND_MESSAGE_COMPONENT = playerComponentSendMessage;
+    }
+
+    public static Object toNativeComponent(final Component component) {
+        if (NATIVE_GSON_COMPONENT_SERIALIZER_DESERIALIZE_METHOD_BOUND == null) {
+            GeyserImpl.getInstance().getLogger().error("Illegal state where Component serialization was called when it wasn't available!");
+            return null;
+        }
+
+        try {
+            return NATIVE_GSON_COMPONENT_SERIALIZER_DESERIALIZE_METHOD_BOUND.invoke(DefaultComponentSerializer.get().serialize(component));
+        } catch (final Throwable throwable) {
+            GeyserImpl.getInstance().getLogger().error("Failed to create native Component message", throwable);
+            return null;
+        }
+    }
+
+    public static void sendMessage(final CommandSender sender, final Component component) {
+        if (SEND_MESSAGE_COMPONENT == null) {
+            GeyserImpl.getInstance().getLogger().error("Illegal state where Component sendMessage was called when it wasn't available!");
+            return;
+        }
+
+        final Object nativeComponent = toNativeComponent(component);
+        if (nativeComponent != null) {
+            try {
+                SEND_MESSAGE_COMPONENT.invoke(sender, nativeComponent);
+            } catch (final InvocationTargetException | IllegalAccessException e) {
+                GeyserImpl.getInstance().getLogger().error("Failed to send native Component message", e);
+            }
+        }
+    }
+
+    public static boolean canSendMessageUsingComponent() {
+        return SEND_MESSAGE_COMPONENT != null;
+    }
+
+    /**
+     * Gets a class by the first name available.
+     *
+     * @return a class or {@code null} if not found
+     */
+    private static @Nullable Class<?> findClass(final String className) {
+        try {
+            return Class.forName(className);
+        } catch (final ClassNotFoundException ignored) {
+        }
+        return null;
+    }
+
+    private PaperAdventure() {
+    }
+}
diff --git a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/command/SpigotCommandSender.java b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/command/SpigotCommandSender.java
index a05a6ebe0..c6314ced5 100644
--- a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/command/SpigotCommandSender.java
+++ b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/command/SpigotCommandSender.java
@@ -25,10 +25,13 @@
 
 package org.geysermc.geyser.platform.spigot.command;
 
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.serializer.bungeecord.BungeeComponentSerializer;
 import org.bukkit.command.ConsoleCommandSender;
 import org.bukkit.entity.Player;
 import org.geysermc.geyser.GeyserImpl;
 import org.geysermc.geyser.command.CommandSender;
+import org.geysermc.geyser.platform.spigot.PaperAdventure;
 import org.geysermc.geyser.text.GeyserLocale;
 
 import java.lang.reflect.InvocationTargetException;
@@ -63,6 +66,16 @@ public class SpigotCommandSender implements CommandSender {
         handle.sendMessage(message);
     }
 
+    @Override
+    public void sendMessage(Component message) {
+        if (PaperAdventure.canSendMessageUsingComponent()) {
+            PaperAdventure.sendMessage(handle, message);
+            return;
+        }
+
+        handle.sendMessage(BungeeComponentSerializer.get().serialize(message));
+    }
+
     @Override
     public boolean isConsole() {
         return handle instanceof ConsoleCommandSender;
diff --git a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/manager/GeyserSpigotWorldManager.java b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/manager/GeyserSpigotWorldManager.java
index a03549444..0a6117b43 100644
--- a/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/manager/GeyserSpigotWorldManager.java
+++ b/bootstrap/spigot/src/main/java/org/geysermc/geyser/platform/spigot/world/manager/GeyserSpigotWorldManager.java
@@ -38,14 +38,14 @@ import org.bukkit.entity.Player;
 import org.bukkit.inventory.ItemStack;
 import org.bukkit.inventory.meta.BookMeta;
 import org.bukkit.plugin.Plugin;
-import org.geysermc.geyser.network.MinecraftProtocol;
-import org.geysermc.geyser.session.GeyserSession;
-import org.geysermc.geyser.translator.inventory.LecternInventoryTranslator;
+import org.geysermc.geyser.level.GameRule;
 import org.geysermc.geyser.level.GeyserWorldManager;
 import org.geysermc.geyser.level.block.BlockStateValues;
+import org.geysermc.geyser.network.MinecraftProtocol;
 import org.geysermc.geyser.registry.BlockRegistries;
+import org.geysermc.geyser.session.GeyserSession;
+import org.geysermc.geyser.translator.inventory.LecternInventoryTranslator;
 import org.geysermc.geyser.util.BlockEntityUtils;
-import org.geysermc.geyser.level.GameRule;
 
 import java.util.ArrayList;
 import java.util.List;
diff --git a/bootstrap/standalone/src/main/java/org/geysermc/geyser/platform/standalone/GeyserStandaloneLogger.java b/bootstrap/standalone/src/main/java/org/geysermc/geyser/platform/standalone/GeyserStandaloneLogger.java
index 3bd2a3960..78e603d7c 100644
--- a/bootstrap/standalone/src/main/java/org/geysermc/geyser/platform/standalone/GeyserStandaloneLogger.java
+++ b/bootstrap/standalone/src/main/java/org/geysermc/geyser/platform/standalone/GeyserStandaloneLogger.java
@@ -31,11 +31,10 @@ import org.apache.logging.log4j.Level;
 import org.apache.logging.log4j.core.config.Configurator;
 import org.geysermc.geyser.GeyserImpl;
 import org.geysermc.geyser.GeyserLogger;
-import org.geysermc.geyser.command.CommandSender;
 import org.geysermc.geyser.text.ChatColor;
 
 @Log4j2
-public class GeyserStandaloneLogger extends SimpleTerminalConsole implements GeyserLogger, CommandSender {
+public class GeyserStandaloneLogger extends SimpleTerminalConsole implements GeyserLogger {
 
     @Override
     protected boolean isRunning() {
@@ -95,24 +94,4 @@ public class GeyserStandaloneLogger extends SimpleTerminalConsole implements Gey
     public boolean isDebug() {
         return log.isDebugEnabled();
     }
-
-    @Override
-    public String name() {
-        return "CONSOLE";
-    }
-
-    @Override
-    public void sendMessage(String message) {
-        info(message);
-    }
-
-    @Override
-    public boolean isConsole() {
-        return true;
-    }
-
-    @Override
-    public boolean hasPermission(String permission) {
-        return true;
-    }
 }
diff --git a/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/GeyserVelocityPlugin.java b/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/GeyserVelocityPlugin.java
index 4a8a50da8..13a07121e 100644
--- a/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/GeyserVelocityPlugin.java
+++ b/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/GeyserVelocityPlugin.java
@@ -161,6 +161,8 @@ public class GeyserVelocityPlugin implements GeyserBootstrap {
         } else {
             this.geyserPingPassthrough = new GeyserVelocityPingPassthrough(proxyServer);
         }
+
+        proxyServer.getEventManager().register(this, new GeyserVelocityUpdateListener());
     }
 
     @Override
diff --git a/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/GeyserVelocityUpdateListener.java b/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/GeyserVelocityUpdateListener.java
new file mode 100644
index 000000000..506dfff71
--- /dev/null
+++ b/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/GeyserVelocityUpdateListener.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright (c) 2019-2022 GeyserMC. http://geysermc.org
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @author GeyserMC
+ * @link https://github.com/GeyserMC/Geyser
+ */
+
+package org.geysermc.geyser.platform.velocity;
+
+import com.velocitypowered.api.event.Subscribe;
+import com.velocitypowered.api.event.connection.PostLoginEvent;
+import com.velocitypowered.api.proxy.Player;
+import org.geysermc.geyser.Constants;
+import org.geysermc.geyser.GeyserImpl;
+import org.geysermc.geyser.platform.velocity.command.VelocityCommandSender;
+import org.geysermc.geyser.util.VersionCheckUtils;
+
+public final class GeyserVelocityUpdateListener {
+
+    @Subscribe
+    public void onPlayerJoin(PostLoginEvent event) {
+        if (GeyserImpl.getInstance().getConfig().isNotifyOnNewBedrockUpdate()) {
+            final Player player = event.getPlayer();
+            if (player.hasPermission(Constants.UPDATE_PERMISSION)) {
+                VersionCheckUtils.checkForGeyserUpdate(() -> new VelocityCommandSender(player));
+            }
+        }
+    }
+}
diff --git a/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/command/VelocityCommandSender.java b/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/command/VelocityCommandSender.java
index d5e4804ee..a5474c3e0 100644
--- a/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/command/VelocityCommandSender.java
+++ b/bootstrap/velocity/src/main/java/org/geysermc/geyser/platform/velocity/command/VelocityCommandSender.java
@@ -28,6 +28,7 @@ package org.geysermc.geyser.platform.velocity.command;
 import com.velocitypowered.api.command.CommandSource;
 import com.velocitypowered.api.proxy.ConsoleCommandSource;
 import com.velocitypowered.api.proxy.Player;
+import net.kyori.adventure.text.Component;
 import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
 import org.geysermc.geyser.command.CommandSender;
 import org.geysermc.geyser.text.GeyserLocale;
@@ -59,6 +60,12 @@ public class VelocityCommandSender implements CommandSender {
         handle.sendMessage(LegacyComponentSerializer.legacy('ยง').deserialize(message));
     }
 
+    @Override
+    public void sendMessage(Component message) {
+        // Be careful that we don't shade in Adventure!!
+        handle.sendMessage(message);
+    }
+
     @Override
     public boolean isConsole() {
         return handle instanceof ConsoleCommandSource;
diff --git a/core/src/main/java/org/geysermc/geyser/Constants.java b/core/src/main/java/org/geysermc/geyser/Constants.java
index 23fb76d16..6a53c37de 100644
--- a/core/src/main/java/org/geysermc/geyser/Constants.java
+++ b/core/src/main/java/org/geysermc/geyser/Constants.java
@@ -37,6 +37,9 @@ public final class Constants {
 
     public static final String FLOODGATE_DOWNLOAD_LOCATION = "https://ci.opencollab.dev/job/GeyserMC/job/Floodgate/job/master/";
 
+    public static final String GEYSER_DOWNLOAD_LOCATION = "https://ci.geysermc.org";
+    public static final String UPDATE_PERMISSION = "geyser.update";
+
     static final String SAVED_REFRESH_TOKEN_FILE = "saved-refresh-tokens.json";
 
     static {
diff --git a/core/src/main/java/org/geysermc/geyser/GeyserImpl.java b/core/src/main/java/org/geysermc/geyser/GeyserImpl.java
index 4322dde59..d9f4d8a15 100644
--- a/core/src/main/java/org/geysermc/geyser/GeyserImpl.java
+++ b/core/src/main/java/org/geysermc/geyser/GeyserImpl.java
@@ -41,6 +41,8 @@ import io.netty.util.internal.SystemPropertyUtil;
 import lombok.AccessLevel;
 import lombok.Getter;
 import lombok.Setter;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.format.NamedTextColor;
 import org.checkerframework.checker.nullness.qual.NonNull;
 import org.checkerframework.checker.nullness.qual.Nullable;
 import org.geysermc.api.Geyser;
@@ -66,7 +68,6 @@ import org.geysermc.geyser.session.SessionManager;
 import org.geysermc.geyser.session.auth.AuthType;
 import org.geysermc.geyser.skin.FloodgateSkinUploader;
 import org.geysermc.geyser.skin.SkinProvider;
-import org.geysermc.geyser.text.ChatColor;
 import org.geysermc.geyser.text.GeyserLocale;
 import org.geysermc.geyser.text.MinecraftLocale;
 import org.geysermc.geyser.translator.inventory.item.ItemTranslator;
@@ -303,8 +304,8 @@ public class GeyserImpl implements GeyserApi {
                     int port = config.getBedrock().getPort();
                     logger.severe(GeyserLocale.getLocaleStringLog("geyser.core.fail", address, String.valueOf(port)));
                     if (!"0.0.0.0".equals(address)) {
-                        logger.info(ChatColor.GREEN + "Suggestion: try setting `address` under `bedrock` in the Geyser config back to 0.0.0.0");
-                        logger.info(ChatColor.GREEN + "Then, restart this server.");
+                        logger.info(Component.text("Suggestion: try setting `address` under `bedrock` in the Geyser config back to 0.0.0.0", NamedTextColor.GREEN));
+                        logger.info(Component.text("Then, restart this server.", NamedTextColor.GREEN));
                     }
                 }
             }).join();
@@ -454,6 +455,9 @@ public class GeyserImpl implements GeyserApi {
         }
 
         newsHandler.handleNews(null, NewsItemAction.ON_SERVER_STARTED);
+        if (config.isNotifyOnNewBedrockUpdate()) {
+            VersionCheckUtils.checkForGeyserUpdate(this::getLogger);
+        }
     }
 
     @Override
diff --git a/core/src/main/java/org/geysermc/geyser/GeyserLogger.java b/core/src/main/java/org/geysermc/geyser/GeyserLogger.java
index b47801cb5..197a031dd 100644
--- a/core/src/main/java/org/geysermc/geyser/GeyserLogger.java
+++ b/core/src/main/java/org/geysermc/geyser/GeyserLogger.java
@@ -25,9 +25,12 @@
 
 package org.geysermc.geyser;
 
+import net.kyori.adventure.text.Component;
+import org.geysermc.geyser.command.CommandSender;
+
 import javax.annotation.Nullable;
 
-public interface GeyserLogger {
+public interface GeyserLogger extends CommandSender {
 
     /**
      * Logs a severe message to console
@@ -73,6 +76,15 @@ public interface GeyserLogger {
      */
     void info(String message);
 
+    /**
+     * Logs an info component to console
+     *
+     * @param message the message to log
+     */
+    default void info(Component message) {
+        sendMessage(message);
+    }
+
     /**
      * Logs a debug message to console
      *
@@ -100,4 +112,24 @@ public interface GeyserLogger {
      * If debug is enabled for this logger
      */
     boolean isDebug();
+
+    @Override
+    default String name() {
+        return "CONSOLE";
+    }
+
+    @Override
+    default void sendMessage(String message) {
+        info(message);
+    }
+
+    @Override
+    default boolean isConsole() {
+        return true;
+    }
+
+    @Override
+    default boolean hasPermission(String permission) {
+        return true;
+    }
 }
diff --git a/core/src/main/java/org/geysermc/geyser/command/CommandSender.java b/core/src/main/java/org/geysermc/geyser/command/CommandSender.java
index d9d1bcfbc..61adad717 100644
--- a/core/src/main/java/org/geysermc/geyser/command/CommandSender.java
+++ b/core/src/main/java/org/geysermc/geyser/command/CommandSender.java
@@ -25,6 +25,8 @@
 
 package org.geysermc.geyser.command;
 
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
 import org.geysermc.geyser.text.GeyserLocale;
 
 /**
@@ -43,6 +45,10 @@ public interface CommandSender {
 
     void sendMessage(String message);
 
+    default void sendMessage(Component message) {
+        sendMessage(LegacyComponentSerializer.legacySection().serialize(message));
+    }
+
     /**
      * @return true if the specified sender is from the console.
      */
diff --git a/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfiguration.java b/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfiguration.java
index 1f188cf40..f605ad103 100644
--- a/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfiguration.java
+++ b/core/src/main/java/org/geysermc/geyser/configuration/GeyserConfiguration.java
@@ -105,6 +105,8 @@ public interface GeyserConfiguration {
 
     int getCustomSkullRenderDistance();
 
+    boolean isNotifyOnNewBedrockUpdate();
+
     IMetricsInfo getMetrics();
 
     int getPendingAuthenticationTimeout();
diff --git a/core/src/main/java/org/geysermc/geyser/configuration/GeyserJacksonConfiguration.java b/core/src/main/java/org/geysermc/geyser/configuration/GeyserJacksonConfiguration.java
index 30a947e53..80fa22ede 100644
--- a/core/src/main/java/org/geysermc/geyser/configuration/GeyserJacksonConfiguration.java
+++ b/core/src/main/java/org/geysermc/geyser/configuration/GeyserJacksonConfiguration.java
@@ -148,6 +148,9 @@ public abstract class GeyserJacksonConfiguration implements GeyserConfiguration
     @JsonProperty("xbox-achievements-enabled")
     private boolean xboxAchievementsEnabled = false;
 
+    @JsonProperty("notify-on-new-bedrock-update")
+    private boolean notifyOnNewBedrockUpdate = true;
+
     private MetricsInfo metrics = new MetricsInfo();
 
     @JsonProperty("pending-authentication-timeout")
diff --git a/core/src/main/java/org/geysermc/geyser/session/cache/WorldCache.java b/core/src/main/java/org/geysermc/geyser/session/cache/WorldCache.java
index 239f5c865..7996d1188 100644
--- a/core/src/main/java/org/geysermc/geyser/session/cache/WorldCache.java
+++ b/core/src/main/java/org/geysermc/geyser/session/cache/WorldCache.java
@@ -31,6 +31,7 @@ import com.nukkitx.protocol.bedrock.packet.SetTitlePacket;
 import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap;
 import lombok.Getter;
 import lombok.Setter;
+import org.geysermc.geyser.registry.BlockRegistries;
 import org.geysermc.geyser.scoreboard.Scoreboard;
 import org.geysermc.geyser.scoreboard.ScoreboardUpdater.ScoreboardSession;
 import org.geysermc.geyser.session.GeyserSession;
@@ -172,6 +173,7 @@ public final class WorldCache {
             if (serverVerifiedState.sequence <= sequence) {
                 // This block may be out of sync with the server
                 // In 1.19.0 Java, you can verify this by trying to mine in spawn protection
+                System.out.println("Resetting " + entry.getKey() + " to " + BlockRegistries.JAVA_BLOCKS.get(serverVerifiedState.blockState).getJavaIdentifier());
                 ChunkUtils.updateBlockClientSide(session, serverVerifiedState.blockState, entry.getKey());
                 it.remove();
             }
diff --git a/core/src/main/java/org/geysermc/geyser/util/VersionCheckUtils.java b/core/src/main/java/org/geysermc/geyser/util/VersionCheckUtils.java
index 934680ce1..b1f97989f 100644
--- a/core/src/main/java/org/geysermc/geyser/util/VersionCheckUtils.java
+++ b/core/src/main/java/org/geysermc/geyser/util/VersionCheckUtils.java
@@ -25,10 +25,22 @@
 
 package org.geysermc.geyser.util;
 
+import com.fasterxml.jackson.databind.JsonNode;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.TextReplacementConfig;
+import net.kyori.adventure.text.event.ClickEvent;
+import net.kyori.adventure.text.format.NamedTextColor;
+import net.kyori.adventure.text.format.TextDecoration;
 import org.geysermc.geyser.Constants;
+import org.geysermc.geyser.GeyserImpl;
 import org.geysermc.geyser.GeyserLogger;
+import org.geysermc.geyser.command.CommandSender;
+import org.geysermc.geyser.network.MinecraftProtocol;
 import org.geysermc.geyser.text.GeyserLocale;
 
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Supplier;
+
 public final class VersionCheckUtils {
 
     public static void checkForOutdatedFloodgate(GeyserLogger logger) {
@@ -42,6 +54,41 @@ public final class VersionCheckUtils {
         }
     }
 
+    public static void checkForGeyserUpdate(Supplier<CommandSender> recipient) {
+        CompletableFuture.runAsync(() -> {
+            try {
+                JsonNode json = WebUtils.getJson("https://api.geysermc.org/v2/versions/geyser");
+                JsonNode bedrock = json.get("bedrock").get("protocol");
+                int protocolVersion = bedrock.get("id").asInt();
+                if (MinecraftProtocol.getBedrockCodec(protocolVersion) != null) {
+                    // We support the latest version! No need to print a message.
+                    return;
+                }
+
+                final String newBedrockVersion = bedrock.get("name").asText();
+
+                // Delayed for two reasons: save unnecessary processing, and wait to load locale if this is on join.
+                CommandSender sender = recipient.get();
+
+                // Overarching component is green - geyser.version.new component cannot be green or else the link blue is overshadowed
+                Component message = Component.text().color(NamedTextColor.GREEN)
+                        .append(Component.text(GeyserLocale.getPlayerLocaleString("geyser.version.new", sender.getLocale(), newBedrockVersion))
+                                .replaceText(TextReplacementConfig.builder()
+                                        .match("\\{1\\}") // Replace "Download here: {1}" so we can use fancy text component yesyes
+                                        .replacement(Component.text()
+                                                .content(Constants.GEYSER_DOWNLOAD_LOCATION)
+                                                .color(NamedTextColor.BLUE)
+                                                .decoration(TextDecoration.UNDERLINED, TextDecoration.State.TRUE)
+                                                .clickEvent(ClickEvent.openUrl(Constants.GEYSER_DOWNLOAD_LOCATION)))
+                                        .build()))
+                        .build();
+                sender.sendMessage(message);
+            } catch (Exception e) {
+                GeyserImpl.getInstance().getLogger().error("Error whilst checking for Geyser update!", e);
+            }
+        });
+    }
+
     private VersionCheckUtils() {
     }
 }
diff --git a/core/src/main/java/org/geysermc/geyser/util/WebUtils.java b/core/src/main/java/org/geysermc/geyser/util/WebUtils.java
index f9574f08b..c0889f1c5 100644
--- a/core/src/main/java/org/geysermc/geyser/util/WebUtils.java
+++ b/core/src/main/java/org/geysermc/geyser/util/WebUtils.java
@@ -73,6 +73,8 @@ public class WebUtils {
     public static JsonNode getJson(String reqURL) throws IOException {
         HttpURLConnection con = (HttpURLConnection) new URL(reqURL).openConnection();
         con.setRequestProperty("User-Agent", "Geyser-" + GeyserImpl.getInstance().getPlatformType().toString() + "/" + GeyserImpl.VERSION);
+        con.setConnectTimeout(10000);
+        con.setReadTimeout(10000);
         return GeyserImpl.JSON_MAPPER.readTree(con.getInputStream());
     }
 
diff --git a/core/src/main/resources/config.yml b/core/src/main/resources/config.yml
index c331a7e62..5a32a6599 100644
--- a/core/src/main/resources/config.yml
+++ b/core/src/main/resources/config.yml
@@ -175,6 +175,11 @@ force-resource-packs: true
 # THIS DISABLES ALL COMMANDS FROM SUCCESSFULLY RUNNING FOR BEDROCK IN-GAME, as otherwise Bedrock thinks you are cheating.
 xbox-achievements-enabled: false
 
+# Whether to alert the console and operators that a new Geyser version is available that supports a Bedrock version
+# that this Geyser version does not support. It's recommended to keep this option enabled, as many Bedrock platforms
+# auto-update.
+notify-on-new-bedrock-update: true
+
 # bStats is a stat tracker that is entirely anonymous and tracks only basic information
 # about Geyser, such as how many people are online, how many servers are using Geyser,
 # what OS is being used, etc. You can learn more about bStats here: https://bstats.org/.