From c4baa0f38126c89aafab94adb474c7137a060d6a Mon Sep 17 00:00:00 2001 From: "Josiah (Gaming32) Glosson" Date: Wed, 19 Apr 2023 21:01:41 -0500 Subject: [PATCH] Work on protocol rewrite --- .../worldhost/DeferredToastManager.java | 57 +++++ .../github/gaming32/worldhost/WorldHost.java | 104 ++++++++ .../gaming32/worldhost/WorldHostConfig.java | 5 +- .../gaming32/worldhost/gui/FriendsScreen.java | 7 +- .../worldhost/gui/WorldHostConfigScreen.java | 14 +- .../worldhost/mixin/MixinMinecraft.java | 26 ++ .../gaming32/worldhost/protocol/JoinType.java | 25 ++ .../worldhost/protocol/ProtocolClient.java | 212 ++++++++++++++++ .../protocol/WorldHostC2SMessage.java | 127 ++++++++++ .../protocol/WorldHostS2CMessage.java | 234 ++++++++++++++++++ .../worldhost/versions/Components.java | 8 + src/main/resources/world-host.mixins.json | 3 +- version.gradle.kts | 8 - 13 files changed, 813 insertions(+), 17 deletions(-) create mode 100644 src/main/java/io/github/gaming32/worldhost/DeferredToastManager.java create mode 100644 src/main/java/io/github/gaming32/worldhost/mixin/MixinMinecraft.java create mode 100644 src/main/java/io/github/gaming32/worldhost/protocol/JoinType.java create mode 100644 src/main/java/io/github/gaming32/worldhost/protocol/ProtocolClient.java create mode 100644 src/main/java/io/github/gaming32/worldhost/protocol/WorldHostC2SMessage.java create mode 100644 src/main/java/io/github/gaming32/worldhost/protocol/WorldHostS2CMessage.java diff --git a/src/main/java/io/github/gaming32/worldhost/DeferredToastManager.java b/src/main/java/io/github/gaming32/worldhost/DeferredToastManager.java new file mode 100644 index 0000000..bf96dea --- /dev/null +++ b/src/main/java/io/github/gaming32/worldhost/DeferredToastManager.java @@ -0,0 +1,57 @@ +package io.github.gaming32.worldhost; + +import com.mojang.blaze3d.vertex.PoseStack; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.toasts.SystemToast; +import net.minecraft.network.chat.CommonComponents; +import net.minecraft.network.chat.Component; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public class DeferredToastManager { + @Nullable + public static IconRenderer queuedCustomIcon; + + @FunctionalInterface + public interface IconRenderer { + void draw(PoseStack matrices, int x, int y); + } + + private record ToastInfo(SystemToast.SystemToastIds type, IconRenderer icon, Component title, @Nullable Component description) { + } + + private static List deferredToasts = new ArrayList<>(); + + public static void show(SystemToast.SystemToastIds type, Component title, @Nullable Component description) { + show(type, null, title, description); + } + + public static void show(SystemToast.SystemToastIds type, IconRenderer icon, Component title, @Nullable Component description) { + final ToastInfo toast = new ToastInfo(type, icon, title, description); + if (deferredToasts != null) { + deferredToasts.add(toast); + } else { + show(toast); + } + } + + private static void show(ToastInfo toast) { + Minecraft.getInstance().execute(() -> { + queuedCustomIcon = toast.icon; + SystemToast.addOrUpdate( + Minecraft.getInstance().getToasts(), toast.type, toast.title, + Objects.requireNonNullElse(toast.description, CommonComponents.EMPTY) + ); + }); + } + + public static void ready() { + if (deferredToasts != null) { + deferredToasts.forEach(DeferredToastManager::show); + deferredToasts = null; + } + } +} diff --git a/src/main/java/io/github/gaming32/worldhost/WorldHost.java b/src/main/java/io/github/gaming32/worldhost/WorldHost.java index b287566..ec0a862 100644 --- a/src/main/java/io/github/gaming32/worldhost/WorldHost.java +++ b/src/main/java/io/github/gaming32/worldhost/WorldHost.java @@ -2,14 +2,23 @@ import com.mojang.authlib.GameProfile; import com.mojang.blaze3d.systems.RenderSystem; +import io.github.gaming32.worldhost.protocol.ProtocolClient; import io.github.gaming32.worldhost.upnp.Gateway; import io.github.gaming32.worldhost.upnp.GatewayFinder; +import io.github.gaming32.worldhost.versions.Components; +import io.netty.buffer.Unpooled; import it.unimi.dsi.fastutil.longs.Long2ObjectMap; import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; import net.minecraft.Util; import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiComponent; +import net.minecraft.client.gui.components.toasts.SystemToast; import net.minecraft.client.renderer.GameRenderer; import net.minecraft.client.resources.SkinManager; +import net.minecraft.client.server.IntegratedServer; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.chat.Component; +import net.minecraft.network.protocol.status.ClientboundStatusResponsePacket; import net.minecraft.network.protocol.status.ServerStatus; import net.minecraft.resources.ResourceLocation; import net.minecraft.server.players.GameProfileCache; @@ -22,6 +31,7 @@ import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.util.*; +import java.util.concurrent.Future; import java.util.function.Consumer; import java.util.function.Supplier; @@ -96,6 +106,11 @@ public class WorldHost private static GameProfileCache profileCache; + public static boolean attemptingConnection; + public static ProtocolClient protoClient; + private static long lastReconnectTime; + private static Future connectingFuture; + //#if FABRIC @Override public void onInitializeClient() { @@ -131,6 +146,8 @@ private static void init() { profileCache.setExecutor(Util.backgroundExecutor()); //#endif + reconnect(false, true); + new GatewayFinder(gateway -> { upnpGateway = gateway; LOGGER.info("Found UPnP gateway: {}", gateway.getGatewayIP()); @@ -169,6 +186,54 @@ public static void saveConfig() { } } + public static void tickHandler(Minecraft client) { + if (protoClient == null || protoClient.isClosed()) { + protoClient = null; + connectingFuture = null; + final long time = Util.getMillis(); + if (time - lastReconnectTime > 10_000) { + lastReconnectTime = time; + if (!attemptingConnection) { + reconnect(CONFIG.isEnableReconnectionToasts(), false); + } + } + } + if (connectingFuture != null && connectingFuture.isDone()) { + connectingFuture = null; + LOGGER.info("Finished authenticating with WS server. Requesting friends list."); + ONLINE_FRIENDS.clear(); + protoClient.listOnline(CONFIG.getFriends()); + final IntegratedServer server = Minecraft.getInstance().getSingleplayerServer(); + if (server != null && server.isPublished()) { + protoClient.publishedWorld(CONFIG.getFriends()); + } + } + } + + public static void reconnect(boolean successToast, boolean failureToast) { + if (protoClient != null) { + protoClient.close(); + protoClient = null; + } + final UUID uuid = Minecraft.getInstance().getUser().getProfileId(); + if (uuid == null) { + LOGGER.warn("Failed to get player UUID. Unable to use World Host."); + if (failureToast) { + DeferredToastManager.show( + SystemToast.SystemToastIds.TUTORIAL_HINT, + Components.translatable("world-host.ws_connect.not_available"), + null + ); + } + return; + } + attemptingConnection = true; + LOGGER.info("Attempting to connect to WH server at {}", CONFIG.getServerIp()); + protoClient = new ProtocolClient(CONFIG.getServerIp()); + connectingFuture = protoClient.getConnectingFuture(); + protoClient.authenticate(Minecraft.getInstance().getUser().getProfileId()); + } + public static String getName(GameProfile profile) { return getIfBlank(profile.getName(), () -> profile.getId().toString()); } @@ -238,4 +303,43 @@ public static void color(float r, float g, float b, float a) { //#endif (r, g, b, a); } + + public static boolean isFriend(UUID user) { + return CONFIG.isEnableFriends() && CONFIG.getFriends().contains(user); + } + + public static void showProfileToast(UUID user, String title, Component description) { + Util.backgroundExecutor().execute(() -> { + final GameProfile profile = Minecraft.getInstance() + .getMinecraftSessionService() + .fillProfileProperties(new GameProfile(user, null), false); + Minecraft.getInstance().execute(() -> { + final ResourceLocation skinTexture = Minecraft.getInstance().getSkinManager().getInsecureSkinLocation(profile); + DeferredToastManager.show( + SystemToast.SystemToastIds.PERIODIC_NOTIFICATION, + (matrices, x, y) -> { + RenderSystem.setShaderTexture(0, skinTexture); + RenderSystem.enableBlend(); + GuiComponent.blit(matrices, x, y, 20, 20, 8, 8, 8, 8, 64, 64); + GuiComponent.blit(matrices, x, y, 20, 20, 40, 8, 8, 8, 64, 64); + }, + Components.translatable(title, getName(profile)), + description + ); + }); + }); + } + + public static FriendlyByteBuf createByteBuf() { + return new FriendlyByteBuf(Unpooled.buffer()); + } + + public static ServerStatus parseServerStatus(FriendlyByteBuf buf) { + return new ClientboundStatusResponsePacket(buf) + //#if MC >= 11904 + .status(); + //#else + //$$ .getStatus(); + //#endif + } } diff --git a/src/main/java/io/github/gaming32/worldhost/WorldHostConfig.java b/src/main/java/io/github/gaming32/worldhost/WorldHostConfig.java index be6594a..237a737 100644 --- a/src/main/java/io/github/gaming32/worldhost/WorldHostConfig.java +++ b/src/main/java/io/github/gaming32/worldhost/WorldHostConfig.java @@ -9,7 +9,7 @@ import java.util.UUID; public class WorldHostConfig { - private String serverIp = "world-host.jemnetworks.com:9646"; + private String serverIp = "world-host.jemnetworks.com"; private boolean showOnlineStatus = true; @@ -35,6 +35,9 @@ public void read(JsonReader reader) throws IOException { continue; } serverIp = serverUri.substring(index + 3); + if (serverIp.endsWith(":9646")) { + serverIp = serverIp.substring(0, serverIp.length() - 5); + } } case "showOnlineStatus" -> showOnlineStatus = reader.nextBoolean(); case "enableFriends" -> enableFriends = reader.nextBoolean(); diff --git a/src/main/java/io/github/gaming32/worldhost/gui/FriendsScreen.java b/src/main/java/io/github/gaming32/worldhost/gui/FriendsScreen.java index 474632b..e8b09ff 100644 --- a/src/main/java/io/github/gaming32/worldhost/gui/FriendsScreen.java +++ b/src/main/java/io/github/gaming32/worldhost/gui/FriendsScreen.java @@ -13,7 +13,6 @@ import net.minecraft.client.gui.components.ObjectSelectionList; import net.minecraft.client.gui.screens.ConfirmScreen; import net.minecraft.client.gui.screens.Screen; -import net.minecraft.client.renderer.GameRenderer; import net.minecraft.network.chat.CommonComponents; import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceLocation; @@ -53,9 +52,9 @@ protected void init() { assert minecraft != null; minecraft.setScreen(new AddFriendScreen(this, ADD_FRIEND_TEXT, profile -> { addFriend(profile); -// if (WorldHost.wsClient != null) { -// WorldHost.wsClient.friendRequest(profile.getId()); -// } + if (WorldHost.protoClient != null) { + WorldHost.protoClient.friendRequest(profile.getId()); + } })); }).pos(width / 2 - 152, height - 52) .build() diff --git a/src/main/java/io/github/gaming32/worldhost/gui/WorldHostConfigScreen.java b/src/main/java/io/github/gaming32/worldhost/gui/WorldHostConfigScreen.java index ca02a88..65e8ee0 100644 --- a/src/main/java/io/github/gaming32/worldhost/gui/WorldHostConfigScreen.java +++ b/src/main/java/io/github/gaming32/worldhost/gui/WorldHostConfigScreen.java @@ -21,11 +21,13 @@ public class WorldHostConfigScreen extends WorldHostScreen { private final Screen parent; private final String oldServerIp; + private final boolean oldEnableFriends; private EditBox serverIpBox; public WorldHostConfigScreen(Screen parent) { super(TITLE); oldServerIp = WorldHost.CONFIG.getServerIp(); + oldEnableFriends = WorldHost.CONFIG.isEnableFriends(); this.parent = parent; } @@ -102,12 +104,18 @@ public void render(@NotNull PoseStack poseStack, int mouseX, int mouseY, float p } @Override - public void onClose() { - super.onClose(); + public void removed() { if (!serverIpBox.getValue().equals(oldServerIp)) { WorldHost.CONFIG.setServerIp(serverIpBox.getValue()); WorldHost.saveConfig(); - // TODO: Perform reconnect + WorldHost.reconnect(true, true); + } else if ( + oldEnableFriends && + !WorldHost.CONFIG.isEnableFriends() && + WorldHost.protoClient != null && + !WorldHost.protoClient.isClosed() + ) { + WorldHost.protoClient.closedWorld(WorldHost.CONFIG.getFriends()); } } } diff --git a/src/main/java/io/github/gaming32/worldhost/mixin/MixinMinecraft.java b/src/main/java/io/github/gaming32/worldhost/mixin/MixinMinecraft.java new file mode 100644 index 0000000..94b20e6 --- /dev/null +++ b/src/main/java/io/github/gaming32/worldhost/mixin/MixinMinecraft.java @@ -0,0 +1,26 @@ +package io.github.gaming32.worldhost.mixin; + +import io.github.gaming32.worldhost.DeferredToastManager; +import io.github.gaming32.worldhost.WorldHost; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.Overlay; +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(Minecraft.class) +public class MixinMinecraft { + @Inject(method = "setOverlay", at = @At("HEAD")) + private void deferredToastReady(Overlay loadingGui, CallbackInfo ci) { + if (loadingGui == null) { + DeferredToastManager.ready(); + } + } + + @Inject(method = "tick", at = @At("RETURN")) + private void tickEvent(CallbackInfo ci) { + //noinspection DataFlowIssue + WorldHost.tickHandler((Minecraft)(Object)this); + } +} diff --git a/src/main/java/io/github/gaming32/worldhost/protocol/JoinType.java b/src/main/java/io/github/gaming32/worldhost/protocol/JoinType.java new file mode 100644 index 0000000..6896e17 --- /dev/null +++ b/src/main/java/io/github/gaming32/worldhost/protocol/JoinType.java @@ -0,0 +1,25 @@ +package io.github.gaming32.worldhost.protocol; + +import java.io.DataOutputStream; +import java.io.IOException; + +public sealed interface JoinType { + record UPnP(int port) implements JoinType { + @Override + public void encode(DataOutputStream dos) throws IOException { + dos.writeByte(0); + dos.writeShort(port); + } + } + + enum Proxy implements JoinType { + INSTANCE; + + @Override + public void encode(DataOutputStream dos) throws IOException { + dos.writeByte(1); + } + } + + void encode(DataOutputStream dos) throws IOException; +} diff --git a/src/main/java/io/github/gaming32/worldhost/protocol/ProtocolClient.java b/src/main/java/io/github/gaming32/worldhost/protocol/ProtocolClient.java new file mode 100644 index 0000000..6a1adce --- /dev/null +++ b/src/main/java/io/github/gaming32/worldhost/protocol/ProtocolClient.java @@ -0,0 +1,212 @@ +package io.github.gaming32.worldhost.protocol; + +import com.google.common.net.HostAndPort; +import io.github.gaming32.worldhost.WorldHost; +import org.apache.commons.io.input.BoundedInputStream; + +import java.io.*; +import java.net.Socket; +import java.util.Collection; +import java.util.UUID; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; +import java.util.concurrent.LinkedBlockingQueue; + +public class ProtocolClient implements AutoCloseable { + private final Future connectingFuture = new CompletableFuture<>(); + private final BlockingQueue sendQueue = new LinkedBlockingQueue<>(); + + private BlockingQueue authUuid = new LinkedBlockingQueue<>(1); + + private boolean authenticated, closed; + + private UUID connectionId = WorldHost.CONNECTION_ID; + private String baseIp = ""; + private int basePort; + + public ProtocolClient(String ip) { + final HostAndPort target = HostAndPort.fromString(ip).withDefaultPort(9646); + final Thread connectionThread = new Thread(() -> { + Socket socket = null; + try { + socket = new Socket(target.getHost(), target.getPort()); + + final UUID userUuid = authUuid.take(); + authUuid = null; + final DataOutputStream dos = new DataOutputStream(socket.getOutputStream()); + dos.writeLong(userUuid.getMostSignificantBits()); + dos.writeLong(userUuid.getLeastSignificantBits()); + dos.writeLong(connectionId.getMostSignificantBits()); + dos.writeLong(connectionId.getLeastSignificantBits()); + dos.flush(); + } catch (Exception e) { + WorldHost.LOGGER.error("Failed to connect to {}.", target, e); + } + + if (socket == null) { + closed = true; + return; + } + final Socket fSocket = socket; + + final Thread sendThread = new Thread(() -> { + try { + final DataOutputStream dos = new DataOutputStream(fSocket.getOutputStream()); + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + final DataOutputStream tempDos = new DataOutputStream(baos); + while (!closed) { + final WorldHostC2SMessage message = sendQueue.take(); + if (message == WorldHostC2SMessage.EndMarker.INSTANCE) break; + message.encode(tempDos); + dos.writeInt(baos.size()); + dos.write(baos.toByteArray()); + baos.reset(); + dos.flush(); + } + } catch (IOException e) { + WorldHost.LOGGER.error("Disconnected from WH server in send thread", e); + } catch (Exception e) { + WorldHost.LOGGER.error("Critical error in WH send thread", e); + } + }, "WH-SendThread"); + + final Thread recvThread = new Thread(() -> { + try { + final DataInputStream dis = new DataInputStream(fSocket.getInputStream()); + while (!closed) { + final int length = dis.readInt(); + if (length < 1) { + WorldHost.LOGGER.warn("Received invalid short packet (under 1 byte) from WH server"); + dis.skipNBytes(length); + continue; + } + final BoundedInputStream bis = new BoundedInputStream(dis); + bis.setPropagateClose(false); + WorldHostS2CMessage.decode(new DataInputStream(bis)).handle(this); + } + } catch (EOFException e) { + WorldHost.LOGGER.debug("Recv thread terminated due to socket closure"); + } catch (Exception e) { + WorldHost.LOGGER.error("Critical error in WH recv thread", e); + } + }, "WH-RecvThread"); + + sendThread.start(); + recvThread.start(); + + try { + sendThread.join(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + // recvThread will terminate when the socket is closed, because it's blocking on the socket, not the sendQueue. + + try { + socket.close(); + } catch (IOException e) { + WorldHost.LOGGER.error("Failed to close WH socket.", e); + } + }, "WH-ConnectionThread"); + connectionThread.setDaemon(true); + connectionThread.start(); + } + + public void authenticate(UUID userUuid) { + authenticated = true; + if (authUuid != null) { + try { + authUuid.put(userUuid); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + } + + void enqueue(WorldHostC2SMessage message) { + if (closed) { + WorldHost.LOGGER.warn("Attempted to send over closed connection: {}", message); + return; + } + if (!authenticated) { + throw new IllegalStateException("Attempted to communicate with server before authenticating."); + } + try { + sendQueue.put(message); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + public void listOnline(Collection friends) { + enqueue(new WorldHostC2SMessage.ListOnline(friends)); + } + + public void publishedWorld(Collection friends) { + enqueue(new WorldHostC2SMessage.PublishedWorld(friends)); + } + + public void closedWorld(Collection friends) { + enqueue(new WorldHostC2SMessage.ClosedWorld(friends)); + } + + public void friendRequest(UUID friend) { + enqueue(new WorldHostC2SMessage.FriendRequest(friend)); + } + + public void queryRequest(Collection friends) { + enqueue(new WorldHostC2SMessage.QueryRequest(friends)); + } + + public void requestJoin(UUID friend) { + enqueue(new WorldHostC2SMessage.RequestJoin(friend)); + } + + public void proxyS2CPacket(long connectionId, byte[] data) { + enqueue(new WorldHostC2SMessage.ProxyS2CPacket(connectionId, data)); + } + + public void proxyDisconnect(long connectionId) { + enqueue(new WorldHostC2SMessage.ProxyDisconnect(connectionId)); + } + + public Future getConnectingFuture() { + return connectingFuture; + } + + public UUID getConnectionId() { + return connectionId; + } + + public void setConnectionId(UUID connectionId) { + this.connectionId = connectionId; + } + + public String getBaseIp() { + return baseIp; + } + + public void setBaseIp(String baseIp) { + this.baseIp = baseIp; + } + + public int getBasePort() { + return basePort; + } + + public void setBasePort(int basePort) { + this.basePort = basePort; + } + + public boolean isClosed() { + return closed; + } + + @Override + public void close() { + if (closed) return; + closed = true; + sendQueue.add(WorldHostC2SMessage.EndMarker.INSTANCE); + } +} diff --git a/src/main/java/io/github/gaming32/worldhost/protocol/WorldHostC2SMessage.java b/src/main/java/io/github/gaming32/worldhost/protocol/WorldHostC2SMessage.java new file mode 100644 index 0000000..bf7f22a --- /dev/null +++ b/src/main/java/io/github/gaming32/worldhost/protocol/WorldHostC2SMessage.java @@ -0,0 +1,127 @@ +package io.github.gaming32.worldhost.protocol; + +import io.github.gaming32.worldhost.WorldHost; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.protocol.status.ClientboundStatusResponsePacket; +import net.minecraft.network.protocol.status.ServerStatus; + +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.Collection; +import java.util.UUID; + +// Mirrors https://github.com/Gaming32/world-host-server-kotlin/blob/main/src/main/kotlin/io/github/gaming32/worldhostserver/WorldHostC2SMessage.kt +public sealed interface WorldHostC2SMessage { + record ListOnline(Collection friends) implements WorldHostC2SMessage { + @Override + public void encode(DataOutputStream dos) throws IOException { + dos.writeByte(0); + dos.writeInt(friends.size()); + for (final UUID friend : friends) { + writeUuid(dos, friend); + } + } + } + + record FriendRequest(UUID toUser) implements WorldHostC2SMessage { + @Override + public void encode(DataOutputStream dos) throws IOException { + dos.writeByte(1); + writeUuid(dos, toUser); + } + } + + record PublishedWorld(Collection friends) implements WorldHostC2SMessage { + @Override + public void encode(DataOutputStream dos) throws IOException { + dos.writeByte(2); + dos.writeInt(friends.size()); + for (final UUID friend : friends) { + writeUuid(dos, friend); + } + } + } + + record ClosedWorld(Collection friends) implements WorldHostC2SMessage { + @Override + public void encode(DataOutputStream dos) throws IOException { + dos.writeByte(3); + dos.writeInt(friends.size()); + for (final UUID friend : friends) { + writeUuid(dos, friend); + } + } + } + + record RequestJoin(UUID friend) implements WorldHostC2SMessage { + @Override + public void encode(DataOutputStream dos) throws IOException { + dos.writeByte(4); + writeUuid(dos, friend); + } + } + + record JoinGranted(UUID connectionId, JoinType joinType) implements WorldHostC2SMessage { + @Override + public void encode(DataOutputStream dos) throws IOException { + dos.writeByte(5); + writeUuid(dos, connectionId); + joinType.encode(dos); + } + } + + record QueryRequest(Collection friends) implements WorldHostC2SMessage { + @Override + public void encode(DataOutputStream dos) throws IOException { + dos.writeByte(6); + dos.writeInt(friends.size()); + for (final UUID friend : friends) { + writeUuid(dos, friend); + } + } + } + + record QueryResponse(UUID connectionId, ServerStatus metadata) implements WorldHostC2SMessage { + @Override + public void encode(DataOutputStream dos) throws IOException { + dos.writeByte(7); + writeUuid(dos, connectionId); + final FriendlyByteBuf buf = WorldHost.createByteBuf(); + new ClientboundStatusResponsePacket(metadata).write(buf); + dos.writeInt(buf.readableBytes()); + buf.readBytes(dos, buf.readableBytes()); + } + } + + record ProxyS2CPacket(long connectionId, byte[] data) implements WorldHostC2SMessage { + @Override + public void encode(DataOutputStream dos) throws IOException { + dos.writeByte(8); + dos.writeLong(connectionId); + dos.write(data); + } + } + + record ProxyDisconnect(long connectionId) implements WorldHostC2SMessage { + @Override + public void encode(DataOutputStream dos) throws IOException { + dos.writeByte(9); + dos.writeLong(connectionId); + } + } + + enum EndMarker implements WorldHostC2SMessage { + INSTANCE; + + @Override + public void encode(DataOutputStream dos) { + } + } + + void encode(DataOutputStream dos) throws IOException; + + static void writeUuid(DataOutputStream dos, UUID uuid) throws IOException { + dos.writeLong(uuid.getMostSignificantBits()); + dos.writeLong(uuid.getLeastSignificantBits()); + } +} diff --git a/src/main/java/io/github/gaming32/worldhost/protocol/WorldHostS2CMessage.java b/src/main/java/io/github/gaming32/worldhost/protocol/WorldHostS2CMessage.java new file mode 100644 index 0000000..fdc2f11 --- /dev/null +++ b/src/main/java/io/github/gaming32/worldhost/protocol/WorldHostS2CMessage.java @@ -0,0 +1,234 @@ +package io.github.gaming32.worldhost.protocol; + +import io.github.gaming32.worldhost.FriendsListUpdate; +import io.github.gaming32.worldhost.ProxyClient; +import io.github.gaming32.worldhost.WorldHost; +import io.github.gaming32.worldhost.upnp.UPnPErrors; +import io.github.gaming32.worldhost.versions.Components; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.ConnectScreen; +import net.minecraft.client.multiplayer.resolver.ServerAddress; +import net.minecraft.client.server.IntegratedServer; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.protocol.status.ServerStatus; + +import java.io.DataInputStream; +import java.io.IOException; +import java.net.InetAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.UUID; + +// Mirrors https://github.com/Gaming32/world-host-server-kotlin/blob/main/src/main/kotlin/io/github/gaming32/worldhostserver/WorldHostS2CMessage.kt +public sealed interface WorldHostS2CMessage { + record Error(String message) implements WorldHostS2CMessage { + @Override + public void handle(ProtocolClient client) { + WorldHost.LOGGER.error("Received protocol error: {}", message); + } + } + + record IsOnlineTo(UUID user) implements WorldHostS2CMessage { + @Override + public void handle(ProtocolClient client) { + if (WorldHost.isFriend(user)) { + final IntegratedServer server = Minecraft.getInstance().getSingleplayerServer(); + if (server != null && server.isPublished()) { + client.publishedWorld(List.of(user)); + } + } + } + } + + record OnlineGame(String host, int port) implements WorldHostS2CMessage { + @Override + public void handle(ProtocolClient client) { + Minecraft.getInstance().execute(() -> { + final Minecraft mcClient = Minecraft.getInstance(); + assert mcClient.screen != null; + //noinspection DataFlowIssue // IntelliJ, it's literally marked @Nullable :clown: + ConnectScreen.startConnecting(mcClient.screen, mcClient, new ServerAddress(host, port), null); + }); + } + } + + record FriendRequest(UUID fromUser) implements WorldHostS2CMessage { + @Override + public void handle(ProtocolClient client) { + if (!WorldHost.CONFIG.isEnableFriends()) return; + WorldHost.showProfileToast( + fromUser, "world-host.friend_added_you", + WorldHost.isFriend(fromUser) ? null : Components.translatable("world-host.need_add_back") + ); + } + } + + record PublishedWorld(UUID user) implements WorldHostS2CMessage { + @Override + public void handle(ProtocolClient client) { + if (!WorldHost.isFriend(user)) return; + WorldHost.ONLINE_FRIENDS.add(user); + WorldHost.ONLINE_FRIEND_UPDATES.forEach(FriendsListUpdate::friendsListUpdate); + WorldHost.showProfileToast( + user, "world-host.went_online", + Components.translatable("world-host.went_online.desc") + ); + } + } + + record ClosedWorld(UUID user) implements WorldHostS2CMessage { + @Override + public void handle(ProtocolClient client) { + WorldHost.ONLINE_FRIENDS.remove(user); + WorldHost.ONLINE_FRIEND_PINGS.remove(user); + WorldHost.ONLINE_FRIEND_UPDATES.forEach(FriendsListUpdate::friendsListUpdate); + } + } + + record RequestJoin(UUID user, UUID connectionId) implements WorldHostS2CMessage { + @Override + public void handle(ProtocolClient client) { + if (WorldHost.isFriend(user)) { + final IntegratedServer server = Minecraft.getInstance().getSingleplayerServer(); + if (server == null || !server.isPublished()) return; + if (WorldHost.upnpGateway != null) { + try { + final UPnPErrors.AddPortMappingErrors error = WorldHost.upnpGateway.openPort( + server.getPort(), 60, false + ); + if (error == null) { + client.enqueue(new WorldHostC2SMessage.JoinGranted( + connectionId, new JoinType.UPnP(server.getPort()) + )); + return; + } + WorldHost.LOGGER.info("Failed to use UPnP mode due to {}. Falling back to Proxy mode.", error); + } catch (Exception e) { + WorldHost.LOGGER.error("Failed to open UPnP due to exception", e); + } + } + client.enqueue(new WorldHostC2SMessage.JoinGranted(connectionId, JoinType.Proxy.INSTANCE)); + } + } + } + + record QueryRequest(UUID friend, UUID connectionId) implements WorldHostS2CMessage { + @Override + public void handle(ProtocolClient client) { + if (WorldHost.isFriend(friend)) { + final IntegratedServer server = Minecraft.getInstance().getSingleplayerServer(); + if (server != null) { + client.enqueue(new WorldHostC2SMessage.QueryResponse(connectionId, server.getStatus())); + } + } + } + } + + record QueryResponse(UUID friend, ServerStatus metadata) implements WorldHostS2CMessage { + @Override + public void handle(ProtocolClient client) { + if (WorldHost.isFriend(friend)) { + WorldHost.ONLINE_FRIEND_PINGS.put(friend, metadata); + } + } + } + + // TODO: Implement using a proper Netty channel to introduce packets directly to the Netty pipeline somehow. + record ProxyC2SPacket(long connectionId, byte[] data) implements WorldHostS2CMessage { + @Override + public void handle(ProtocolClient client) { + final ProxyClient proxyClient = WorldHost.CONNECTED_PROXY_CLIENTS.get(connectionId); + if (proxyClient != null) { + try { + proxyClient.getOutputStream().write(data); + } catch (IOException e) { + WorldHost.LOGGER.error("Failed to write to ProxyClient", e); + } + } + } + } + + record ProxyConnect(long connectionId, InetAddress remoteAddr) implements WorldHostS2CMessage { + @Override + public void handle(ProtocolClient client) { + final IntegratedServer server = Minecraft.getInstance().getSingleplayerServer(); + if (server == null || !server.isPublished()) { + if (client != null) { + client.proxyDisconnect(connectionId); + } + return; + } + try { + final ProxyClient proxyClient = new ProxyClient(server.getPort(), remoteAddr, connectionId); + WorldHost.CONNECTED_PROXY_CLIENTS.put(connectionId, proxyClient); + proxyClient.start(); + } catch (IOException e) { + WorldHost.LOGGER.error("Failed to start ProxyClient", e); + } + } + } + + record ProxyDisconnect(long connectionId) implements WorldHostS2CMessage { + @Override + public void handle(ProtocolClient client) { + final ProxyClient proxyClient = WorldHost.CONNECTED_PROXY_CLIENTS.remove(connectionId); + if (proxyClient != null) { + proxyClient.close(); + } + } + } + + record ConnectionInfo(UUID connectionId, String baseIp, int basePort) implements WorldHostS2CMessage { + @Override + public void handle(ProtocolClient client) { + client.setConnectionId(connectionId); + client.setBaseIp(baseIp); + client.setBasePort(basePort); + } + } + + /** + * NOTE: This method is called from the RecvThread, so it should be careful to not do anything that could + *
    + *
  1. Cause race conditions (as such, it should not call very much Minecraft code)
  2. + *
  3. Take too long, as that will delay the operation of other message processing
  4. + *
+ * Anything that would violate the above should be wrapped in a {@link Minecraft#execute} call. + */ + void handle(ProtocolClient client); + + static WorldHostS2CMessage decode(DataInputStream dis) throws IOException { + final int typeId = dis.readUnsignedByte(); + return switch (typeId) { + case 0 -> new Error(readString(dis)); + case 1 -> new IsOnlineTo(readUuid(dis)); + case 2 -> new OnlineGame(readString(dis), dis.readUnsignedShort()); + case 3 -> new FriendRequest(readUuid(dis)); + case 4 -> new PublishedWorld(readUuid(dis)); + case 5 -> new ClosedWorld(readUuid(dis)); + case 6 -> new RequestJoin(readUuid(dis), readUuid(dis)); + case 7 -> new QueryRequest(readUuid(dis), readUuid(dis)); + case 8 -> { + final UUID friend = readUuid(dis); + final FriendlyByteBuf buf = WorldHost.createByteBuf(); + buf.writeBytes(dis, dis.readInt()); + yield new QueryResponse(friend, WorldHost.parseServerStatus(buf)); + } + case 9 -> new ProxyC2SPacket(dis.readLong(), dis.readAllBytes()); + case 10 -> new ProxyConnect(dis.readLong(), InetAddress.getByAddress(dis.readNBytes(dis.readUnsignedByte()))); + case 11 -> new ProxyDisconnect(dis.readLong()); + case 12 -> new ConnectionInfo(readUuid(dis), readString(dis), dis.readUnsignedShort()); + default -> new Error("Received packet with unknown type_id from server (outdated client?): " + typeId); + }; + } + + static UUID readUuid(DataInputStream dis) throws IOException { + return new UUID(dis.readLong(), dis.readLong()); + } + + static String readString(DataInputStream dis) throws IOException { + final byte[] buf = new byte[dis.readUnsignedShort()]; + dis.readFully(buf); + return new String(buf, StandardCharsets.UTF_8); + } +} diff --git a/src/main/java/io/github/gaming32/worldhost/versions/Components.java b/src/main/java/io/github/gaming32/worldhost/versions/Components.java index 7839054..6a53bc4 100644 --- a/src/main/java/io/github/gaming32/worldhost/versions/Components.java +++ b/src/main/java/io/github/gaming32/worldhost/versions/Components.java @@ -18,6 +18,14 @@ public static MutableComponent translatable(String key) { //#endif } + public static MutableComponent translatable(String key, Object... args) { + //#if MC >= 11901 + return Component.translatable(key, args); + //#else + //$$ return new TranslatableComponent(key, args); + //#endif + } + public static Component immutable(String text) { return Component.nullToEmpty(text); } diff --git a/src/main/resources/world-host.mixins.json b/src/main/resources/world-host.mixins.json index 3675dad..e27d1df 100644 --- a/src/main/resources/world-host.mixins.json +++ b/src/main/resources/world-host.mixins.json @@ -6,7 +6,8 @@ "mixins": [ ], "client": [ - "MinecraftAccessor" + "MinecraftAccessor", + "MixinMinecraft" ], "injectors": { "defaultRequire": 1 diff --git a/version.gradle.kts b/version.gradle.kts index 9bb3b43..6bfb272 100644 --- a/version.gradle.kts +++ b/version.gradle.kts @@ -81,11 +81,3 @@ loom { preprocess { patternAnnotation.set("io.github.gaming32.worldhost.versions.Pattern") } - -tasks.jar { - archiveBaseName.set(rootProject.name) -} - -tasks.remapJar { - archiveBaseName.set(rootProject.name) -}