diff options
Diffstat (limited to 'src/main/java/dev/plutorocks')
| -rw-r--r-- | src/main/java/dev/plutorocks/BridgeClient.java | 257 | ||||
| -rw-r--r-- | src/main/java/dev/plutorocks/PlutoBridge.java | 248 | ||||
| -rw-r--r-- | src/main/java/dev/plutorocks/mixin/ChatScreenMixin.java | 103 |
3 files changed, 608 insertions, 0 deletions
diff --git a/src/main/java/dev/plutorocks/BridgeClient.java b/src/main/java/dev/plutorocks/BridgeClient.java new file mode 100644 index 0000000..9126f20 --- /dev/null +++ b/src/main/java/dev/plutorocks/BridgeClient.java @@ -0,0 +1,257 @@ +package dev.plutorocks; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; + +import net.minecraft.client.MinecraftClient; +import net.minecraft.text.MutableText; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.WebSocket; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicBoolean; + +public class BridgeClient { + private static final Logger LOGGER = LoggerFactory.getLogger("PlutoBridgeClient"); + private static final Gson GSON = new Gson(); + + private final String host; + private final String token; + private volatile String nick; + private final String serverId = "default"; + + private final AtomicBoolean running = new AtomicBoolean(false); + private volatile boolean connected = false; + + private WebSocket webSocket; + + public BridgeClient(String host, String token, String nick) { + this.host = host; + this.token = token; + this.nick = (nick == null || nick.isEmpty()) ? "Player" : nick; + } + + public boolean isConnected() { + return connected; + } + + public void connect() { + if (running.getAndSet(true)) { + return; // already running + } + + try { + String uriStr; + + // if user already gave us a full WebSocket URL, trust it. + if (host.startsWith("ws://") || host.startsWith("wss://")) { + uriStr = host; + } else if (host.startsWith("http://") || host.startsWith("https://")) { + // convert http(s) -> ws(s) + boolean secure = host.startsWith("https://"); + String rest = host.substring(secure ? "https://".length() : "http://".length()); + uriStr = (secure ? "wss://" : "ws://") + rest; + + // if there's no path after the host, add /ws + if (!rest.contains("/")) { + uriStr = uriStr + "/ws"; + } + } else { + // bare hostname: assume HTTPS/WSS + /ws + // this matches typical reverse-proxy setup (TLS on 443). + uriStr = "wss://" + host; + if (!host.contains("/")) { + uriStr = uriStr + "/ws"; + } + } + + LOGGER.info("Connecting to bridge at {}", uriStr); + + HttpClient client = HttpClient.newHttpClient(); + WebSocket.Builder builder = client.newWebSocketBuilder(); + CompletableFuture<WebSocket> future = builder.buildAsync(URI.create(uriStr), new BridgeListener()); + future.whenComplete((ws, throwable) -> { + if (throwable != null) { + LOGGER.error("Failed to connect to bridge", throwable); + running.set(false); + String msg = throwable.getMessage(); + if (msg == null) msg = throwable.getClass().getSimpleName(); + sendClientChat("Failed to connect to bridge: " + msg); + return; + } + this.webSocket = ws; + sendAuth(); + }); + } catch (Exception e) { + LOGGER.error("Error starting WebSocket connection", e); + running.set(false); + sendClientChat("Bridge connection error: " + e.getMessage()); + } + } + + public void disconnect() { + running.set(false); + connected = false; + if (webSocket != null) { + try { + webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "client disconnect"); + } catch (Exception ignored) {} + } + } + + public void sendChatMessage(String message) { + if (!connected || webSocket == null) { + sendClientChat("Bridge is not connected."); + return; + } + + JsonObject obj = new JsonObject(); + obj.addProperty("type", "chat"); + obj.addProperty("message", message); + + String json = GSON.toJson(obj); + webSocket.sendText(json, true); + } + + public void updateNick(String newNick) { + this.nick = newNick; + if (!connected || webSocket == null) { + return; + } + + JsonObject obj = new JsonObject(); + obj.addProperty("type", "nick"); + obj.addProperty("nick", newNick); + + String json = GSON.toJson(obj); + webSocket.sendText(json, true); + } + + private void sendAuth() { + if (webSocket == null) { + return; + } + + JsonObject auth = new JsonObject(); + auth.addProperty("type", "auth"); + auth.addProperty("token", token); + auth.addProperty("nick", nick); + auth.addProperty("serverId", serverId); + // playerUuid optional + + String json = GSON.toJson(auth); + webSocket.sendText(json, true); + } + + private void sendClientChat(String message) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client == null || client.inGameHud == null) { + return; + } + MutableText prefix = Text.literal("[Bridge] ").formatted(Formatting.AQUA); + MutableText body = Text.literal(message).formatted(Formatting.GRAY); + client.execute(() -> client.inGameHud.getChatHud().addMessage(prefix.append(body))); + } + + private void handleIncomingChat(JsonObject obj) { + String fromNick = obj.has("fromNick") ? obj.get("fromNick").getAsString() : "?"; + String msg = obj.has("message") ? obj.get("message").getAsString() : ""; + + MinecraftClient client = MinecraftClient.getInstance(); + if (client == null || client.inGameHud == null) { + return; + } + + MutableText line = Text.literal("[Bridge] ") + .formatted(Formatting.AQUA) + .append(Text.literal("<" + fromNick + "> ").formatted(Formatting.GRAY)) + .append(Text.literal(msg).formatted(Formatting.WHITE)); + + client.execute(() -> client.inGameHud.getChatHud().addMessage(line)); + } + + private class BridgeListener implements WebSocket.Listener { + + private final StringBuilder textBuffer = new StringBuilder(); + + @Override + public void onOpen(WebSocket webSocket) { + LOGGER.info("Bridge WebSocket opened"); + connected = true; + sendClientChat("Connected to bridge."); + WebSocket.Listener.super.onOpen(webSocket); + } + + @Override + public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) { + textBuffer.append(data); + if (last) { + String full = textBuffer.toString(); + textBuffer.setLength(0); + + try { + JsonObject obj = GSON.fromJson(full, JsonObject.class); + if (obj != null && obj.has("type")) { + String type = obj.get("type").getAsString(); + if ("chat".equals(type)) { + handleIncomingChat(obj); + } + } + } catch (Exception e) { + LOGGER.warn("Failed to parse bridge message: {}", full, e); + } + } + return WebSocket.Listener.super.onText(webSocket, data, last); + } + + @Override + public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) { + LOGGER.info("Bridge WebSocket closed: {} {}", statusCode, reason); + connected = false; + running.set(false); + + String reasonText = (reason == null ? "" : reason).toLowerCase(); + + // 1008 = Policy Violation -> we use this for "token revoked" + if (statusCode == 1008 && reasonText.contains("token revoked")) { + sendClientChat("Disconnected from bridge: token revoked by server."); + } else { + sendClientChat("Disconnected from bridge (" + statusCode + "): " + reason); + } + + return CompletableFuture.completedFuture(null); + } + + @Override + public void onError(WebSocket webSocket, Throwable error) { + LOGGER.error("Bridge WebSocket error", error); + connected = false; + running.set(false); + sendClientChat("Bridge connection error: " + error.getMessage()); + } + + @Override + public CompletionStage<?> onBinary(WebSocket webSocket, java.nio.ByteBuffer data, boolean last) { + return WebSocket.Listener.super.onBinary(webSocket, data, last); + } + + @Override + public CompletionStage<?> onPong(WebSocket webSocket, java.nio.ByteBuffer message) { + return WebSocket.Listener.super.onPong(webSocket, message); + } + + @Override + public CompletionStage<?> onPing(WebSocket webSocket, java.nio.ByteBuffer message) { + webSocket.sendPong(java.nio.ByteBuffer.wrap("pong".getBytes(StandardCharsets.UTF_8))); + return WebSocket.Listener.super.onPing(webSocket, message); + } + } +}
\ No newline at end of file diff --git a/src/main/java/dev/plutorocks/PlutoBridge.java b/src/main/java/dev/plutorocks/PlutoBridge.java new file mode 100644 index 0000000..aa3438f --- /dev/null +++ b/src/main/java/dev/plutorocks/PlutoBridge.java @@ -0,0 +1,248 @@ +package dev.plutorocks; + +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.context.CommandContext; + +import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback; +import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource; +import net.fabricmc.loader.api.FabricLoader; + +import net.minecraft.client.MinecraftClient; +import net.minecraft.text.MutableText; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.nio.file.Files; +import java.nio.file.Path; + +import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.argument; +import static net.fabricmc.fabric.api.client.command.v2.ClientCommandManager.literal; + +public class PlutoBridge implements ClientModInitializer { + public static final String MOD_ID = "PlutoBridge"; + public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID); + + private static final Gson GSON = new Gson(); + private static final Path CONFIG_PATH = FabricLoader.getInstance().getConfigDir().resolve("bridge.json"); + + public static final int MAX_NICK_LEN = 15; + public static final int MAX_MESSAGE_LEN = 400; // must match server + + public static String bridgeHost = "bridge.plutorocks.dev"; + public static String bridgeToken = ""; + public static String bridgeNick; // will be set from config/session + + public static BridgeClient BRIDGE_CLIENT; + + // false = normal chat default, true = bridge default + public static boolean bridgeChatDefault = false; + + @Override + public void onInitializeClient() { + loadConfig(); + + // Auto-connect if we already have a token + if (bridgeToken != null && !bridgeToken.isEmpty()) { + BRIDGE_CLIENT = new BridgeClient(bridgeHost, bridgeToken, bridgeNick); + BRIDGE_CLIENT.connect(); + } + + ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> { + dispatcher.register( + literal("bridge") + .then(literal("connect").executes(this::handleConnect)) + .then(literal("disconnect").executes(this::handleDisconnect)) + .then(literal("status").executes(this::handleStatus)) + .then(literal("host") + .then(argument("host", StringArgumentType.word()) + .executes(this::handleSetHost))) + .then(literal("token") + .then(argument("token", StringArgumentType.word()) + .executes(this::handleSetToken))) + .then(literal("nick") + .then(argument("nick", StringArgumentType.word()) + .executes(this::handleSetNick))) + .then(literal("toggle") + .executes(this::handleToggle)) + ); + }); + + LOGGER.info("Bridge client loaded!"); + } + + private static void loadConfig() { + if (!Files.exists(CONFIG_PATH)) { + applyDefaultsFromSession(); + saveConfig(); + return; + } + + try (Reader reader = Files.newBufferedReader(CONFIG_PATH)) { + Config cfg = GSON.fromJson(reader, Config.class); + if (cfg == null) { + applyDefaultsFromSession(); + saveConfig(); + return; + } + + if (cfg.host != null && !cfg.host.isEmpty()) { + bridgeHost = cfg.host; + } else { + bridgeHost = "bridge.plutorocks.dev"; + } + + if (cfg.token != null && !cfg.token.isEmpty()) { + bridgeToken = cfg.token; + } else { + bridgeToken = ""; + } + + if (cfg.nick != null && !cfg.nick.isEmpty()) { + bridgeNick = cfg.nick; + } else { + bridgeNick = defaultNickFromSession(); + } + } catch (IOException | JsonSyntaxException e) { + LOGGER.warn("Failed to read bridge config, using defaults", e); + applyDefaultsFromSession(); + } + } + + private static void saveConfig() { + Config cfg = new Config(); + cfg.host = bridgeHost; + cfg.token = bridgeToken; + cfg.nick = bridgeNick; + + try (Writer writer = Files.newBufferedWriter(CONFIG_PATH)) { + GSON.toJson(cfg, writer); + } catch (IOException e) { + LOGGER.warn("Failed to save bridge config", e); + } + } + + private static void applyDefaultsFromSession() { + bridgeHost = "bridge.plutorocks.dev"; + bridgeToken = ""; + bridgeNick = defaultNickFromSession(); + } + + private static String defaultNickFromSession() { + MinecraftClient client = MinecraftClient.getInstance(); + if (client != null && client.getSession() != null) { + String name = client.getSession().getUsername(); + if (name != null && !name.isEmpty()) { + return name; + } + } + return "Player"; + } + + private int handleConnect(CommandContext<FabricClientCommandSource> ctx) { + if (bridgeToken == null || bridgeToken.isEmpty()) { + sendCmdFeedback(ctx, "Token is empty. Set it with /bridge token <token> first."); + return 1; + } + + if (BRIDGE_CLIENT != null && BRIDGE_CLIENT.isConnected()) { + sendCmdFeedback(ctx, "Already connected."); + return 1; + } + + if (BRIDGE_CLIENT != null) { + BRIDGE_CLIENT.disconnect(); + } + + BRIDGE_CLIENT = new BridgeClient(bridgeHost, bridgeToken, bridgeNick); + + sendCmdFeedback(ctx, "Connecting to " + bridgeHost + " as " + bridgeNick + "..."); + BRIDGE_CLIENT.connect(); + return 1; + } + + private int handleDisconnect(CommandContext<FabricClientCommandSource> ctx) { + if (BRIDGE_CLIENT == null || !BRIDGE_CLIENT.isConnected()) { + sendCmdFeedback(ctx, "Already disconnected."); + return 1; + } + + BRIDGE_CLIENT.disconnect(); + sendCmdFeedback(ctx, "Disconnected."); + return 1; + } + + private int handleStatus(CommandContext<FabricClientCommandSource> ctx) { + String state = (BRIDGE_CLIENT != null && BRIDGE_CLIENT.isConnected()) ? "Connected" : "Not connected"; + sendCmdFeedback(ctx, state + ". Host=" + bridgeHost + " Nick=" + bridgeNick + + " Mode=" + (bridgeChatDefault ? "BRIDGE" : "CHAT")); + return 1; + } + + private int handleToggle(CommandContext<FabricClientCommandSource> ctx) { + bridgeChatDefault = !bridgeChatDefault; + + if (bridgeChatDefault) { + sendCmdFeedback(ctx, "Bridge mode ON. Messages go to the bridge by default; prefix with ! to send to normal chat."); + } else { + sendCmdFeedback(ctx, "Bridge mode OFF. Messages go to normal chat by default; prefix with ! to send to the bridge."); + } + + return 1; + } + + private int handleSetHost(CommandContext<FabricClientCommandSource> ctx) { + bridgeHost = StringArgumentType.getString(ctx, "host"); + saveConfig(); + sendCmdFeedback(ctx, "Host set to " + bridgeHost + ". Use /bridge connect to apply."); + return 1; + } + + private int handleSetToken(CommandContext<FabricClientCommandSource> ctx) { + bridgeToken = StringArgumentType.getString(ctx, "token"); + saveConfig(); + sendCmdFeedback(ctx, "Token set. Use /bridge connect to apply."); + return 1; + } + + private int handleSetNick(CommandContext<FabricClientCommandSource> ctx) { + String requested = StringArgumentType.getString(ctx, "nick"); + + int nickLength = requested.codePointCount(0, requested.length()); + if (nickLength > MAX_NICK_LEN) { + sendCmdFeedback(ctx, "Nick too long (max " + MAX_NICK_LEN + " characters)."); + return 1; + } + + bridgeNick = requested; + saveConfig(); + + if (PlutoBridge.BRIDGE_CLIENT != null && PlutoBridge.BRIDGE_CLIENT.isConnected()) { + PlutoBridge.BRIDGE_CLIENT.updateNick(bridgeNick); + sendCmdFeedback(ctx, "Nick set to " + bridgeNick + " (updated live)."); + } else { + sendCmdFeedback(ctx, "Nick set to " + bridgeNick + ". Use /bridge connect to apply."); + } + return 1; + } + + private void sendCmdFeedback(CommandContext<FabricClientCommandSource> ctx, String grayMessage) { + MutableText prefix = Text.literal("[Bridge] ").formatted(Formatting.AQUA); + MutableText body = Text.literal(grayMessage).formatted(Formatting.GRAY); + ctx.getSource().sendFeedback(prefix.append(body)); + } + + private static class Config { + String host; + String token; + String nick; + } +}
\ No newline at end of file diff --git a/src/main/java/dev/plutorocks/mixin/ChatScreenMixin.java b/src/main/java/dev/plutorocks/mixin/ChatScreenMixin.java new file mode 100644 index 0000000..6ea5e64 --- /dev/null +++ b/src/main/java/dev/plutorocks/mixin/ChatScreenMixin.java @@ -0,0 +1,103 @@ +package dev.plutorocks.mixin; + +import dev.plutorocks.PlutoBridge; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.screen.ChatScreen; +import net.minecraft.text.MutableText; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; +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(ChatScreen.class) +public abstract class ChatScreenMixin { + + @Inject(method = "sendMessage", at = @At("HEAD"), cancellable = true) + private void bridge$interceptBridgeMessages(String chatText, boolean addToHistory, CallbackInfo ci) { + if (chatText == null) { + return; + } + + String trimmed = chatText.trim(); + if (trimmed.isEmpty()) { + return; + } + + if (trimmed.startsWith("/")) { + return; + } + + boolean startsWithBang = trimmed.startsWith("!"); + boolean bridgeDefault = PlutoBridge.bridgeChatDefault; + + boolean wantsBridge; + if (!bridgeDefault) { + if (!startsWithBang) { + return; + } + wantsBridge = true; + } else { + wantsBridge = !startsWithBang; + } + + MinecraftClient client = MinecraftClient.getInstance(); + if (client == null || client.player == null) { + return; + } + + if (PlutoBridge.BRIDGE_CLIENT == null || !PlutoBridge.BRIDGE_CLIENT.isConnected()) { + if (wantsBridge) { + if (client.inGameHud != null) { + MutableText prefix = Text.literal("[Bridge] ").formatted(Formatting.AQUA); + MutableText body = Text.literal("Not connected. Use /bridge connect first.") + .formatted(Formatting.GRAY); + client.inGameHud.getChatHud().addMessage(prefix.append(body)); + } + ci.cancel(); + } + return; + } + + // at this point we are connected. + if (bridgeDefault && startsWithBang && !wantsBridge) { + // bridge mode ON and user prefixed with "!" => send to normal chat. + String normalMessage = trimmed.substring(1); + if (!normalMessage.isEmpty()) { + client.player.networkHandler.sendChatMessage(normalMessage); + } + } else { + // send to the bridge. + String bridgeMessage; + if (!bridgeDefault) { + // default mode: strip the leading "!" for bridge messages. + bridgeMessage = trimmed.substring(1); + } else { + // bridge mode: already without prefix for bridge messages. + bridgeMessage = trimmed; + } + + // enforce max message length client-side to match server. + int len = bridgeMessage.codePointCount(0, bridgeMessage.length()); + if (len > PlutoBridge.MAX_MESSAGE_LEN) { + if (client.inGameHud != null) { + MutableText prefix = Text.literal("[Bridge] ").formatted(Formatting.AQUA); + MutableText body = Text.literal( + "Message too long (max " + PlutoBridge.MAX_MESSAGE_LEN + " characters)." + ).formatted(Formatting.GRAY); + client.inGameHud.getChatHud().addMessage(prefix.append(body)); + } + ci.cancel(); + return; + } + + if (!bridgeMessage.isEmpty()) { + PlutoBridge.BRIDGE_CLIENT.sendChatMessage(bridgeMessage); + } + } + + // prevent this message from going to normal MC chat + ci.cancel(); + } +}
\ No newline at end of file |
