summary refs log tree commit diff stats
path: root/src/main/java/ganarchy/friendcode/sam/I2PSamStateMachine.java
diff options
context:
space:
mode:
Diffstat (limited to 'src/main/java/ganarchy/friendcode/sam/I2PSamStateMachine.java')
-rw-r--r--src/main/java/ganarchy/friendcode/sam/I2PSamStateMachine.java220
1 files changed, 220 insertions, 0 deletions
diff --git a/src/main/java/ganarchy/friendcode/sam/I2PSamStateMachine.java b/src/main/java/ganarchy/friendcode/sam/I2PSamStateMachine.java
new file mode 100644
index 0000000..d05717d
--- /dev/null
+++ b/src/main/java/ganarchy/friendcode/sam/I2PSamStateMachine.java
@@ -0,0 +1,220 @@
+package ganarchy.friendcode.sam;
+
+import com.google.common.collect.ImmutableMap;
+import ganarchy.friendcode.FriendCode;
+import net.minecraft.util.Util;
+
+import java.io.*;
+import java.net.Socket;
+import java.net.SocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayDeque;
+import java.util.Iterator;
+import java.util.Objects;
+
+public abstract class I2PSamStateMachine implements Closeable {
+    // FIXME user/password from config
+    private static final I2PSamCommand HELLO_MESSAGE = new I2PSamCommand(
+        "HELLO", "VERSION",
+        ImmutableMap.of(
+            "MIN", "3.2",
+            "USER", "minecraft_friendcode",
+            "PASSWORD", "friendcode"
+        )
+    );
+    private Socket samSocket;
+    private PushbackReader reader;
+    private OutputStreamWriter writer;
+    private final ArrayDeque<I2PSamCommand> queue = new ArrayDeque<>();
+    private boolean started;
+    private boolean connected;
+    private String id;
+
+    protected I2PSamStateMachine() {
+    }
+
+    /**
+     * Connects to the SAM socket.
+     *
+     * @return Whether the connection was successful.
+     */
+    public abstract boolean connect();
+
+    protected boolean connect(Socket samSocket) {
+        this.samSocket = samSocket;
+        if (this.connected) {
+            throw new IllegalStateException();
+        }
+        this.connected = true;
+        return true;
+    }
+
+    /**
+     * Starts the SAM session.
+     *
+     * @return Whether we were able to start the SAM session.
+     */
+    public boolean start() {
+        if (!this.connected) {
+            throw new IllegalStateException();
+        }
+        if (this.started) {
+            throw new IllegalStateException();
+        }
+        this.started = true;
+        try {
+            // 8 KiC limit
+            this.reader = new PushbackReader(new InputStreamReader(this.samSocket.getInputStream(), StandardCharsets.UTF_8), 8192);
+            this.writer = new OutputStreamWriter(this.samSocket.getOutputStream(), StandardCharsets.UTF_8);
+
+
+            // send HELLO
+            // FIXME let the user change the credentials
+            this.sendCommand(HELLO_MESSAGE);
+            return "OK".equals(this.getCommand("HELLO", "REPLY").parameters().get("RESULT"));
+        } catch (IOException e) {
+            return false;
+        }
+    }
+
+    /**
+     * Attempts to step the SAM session.
+     *
+     * This will generally not block, unless something went wrong.
+     */
+    public void tryStep() {
+        try {
+            if (this.reader != null && this.reader.ready()) {
+                this.step();
+            }
+        } catch (IOException ignored) {
+        }
+    }
+
+    /**
+     * Steps the SAM session.
+     *
+     * @throws IOException If an I/O error occurs.
+     */
+    public void step() throws IOException {
+        this.writer.flush();
+        final String line;
+        // 8 KiC limit
+        final var buffer = new char[8192];
+        final var builder = new StringBuilder();
+        int cursor = 0;
+        while (true) {
+            final int read = this.reader.read(buffer, cursor, buffer.length - cursor);
+            if (read < 0) {
+                line = null;
+                break;
+            }
+            builder.append(buffer, cursor, read);
+            final int target;
+            if ((target = builder.indexOf("\n", cursor) + 1) > 0) {
+                this.reader.unread(buffer, target, cursor + read - target);
+                builder.setLength(target - 1);
+                line = builder.toString();
+                break;
+            }
+            cursor += read;
+        }
+        if (line == null) {
+            throw new EOFException();
+        }
+        // NOTE \r will result in fuckery, but the bridge should not generally send them
+        FriendCode.LOGGER.debug("[SAM (RAW)] {}", line);
+        final I2PSamCommand command;
+        try {
+            command = I2PSamCommand.parse(line);
+        } catch (IllegalArgumentException e) {
+            FriendCode.LOGGER.error("[SAM Error] Closing SAM bridge.", e);
+            this.samSocket.close();
+            return;
+        }
+        if (command == null) {
+            // empty line
+            return;
+        }
+        if (command.name().equals("PING")) {
+            this.sendCommand(new I2PSamCommand("PONG", command.opcode(), command.parameters()));
+        } else {
+            queue.push(command);
+        }
+    }
+
+    /**
+     * Reads a name from the SAM session.
+     *
+     * @param name The command name.
+     * @param opcode The subcommand name.
+     * @return The name line, or null if something went wrong.
+     * @throws IOException If an I/O error occurs.
+     */
+    protected I2PSamCommand getCommand(String name, String opcode) throws IOException {
+        final boolean checkPing = name.equals("PING") || name.equals("PONG");
+        while (true) {
+            // check queue for lines
+            for (Iterator<I2PSamCommand> iterator = this.queue.iterator(); iterator.hasNext(); ) {
+                I2PSamCommand queued = iterator.next();
+                // special check for PING/PONG
+                if (queued.name().equals(name) && (checkPing || Objects.equals(queued.opcode(), opcode))) {
+                    iterator.remove();
+                    return queued;
+                }
+            }
+            // if that failed, read more lines
+            this.step();
+        }
+    }
+
+    protected void sendCommand(I2PSamCommand command) throws IOException {
+        String line = command.buildCommandLine() + "\n";
+        if (line.length() >= 8192) {
+            throw new IOException("command too long - max 8 KiC");
+        }
+        this.writer.write(line);
+    }
+
+    /**
+     * Sends a PING.
+     *
+     * @return The round-trip-time in milliseconds.
+     */
+    public long sendPing() {
+        try {
+            long time = Util.getMeasuringTimeMs();
+            this.sendCommand(new I2PSamCommand("PING", "" + time));
+            return Util.getMeasuringTimeMs() - Long.parseLong(this.getCommand("PONG", null).opcode());
+        } catch (IOException e) {
+            return -1;
+        }
+    }
+
+    /**
+     * Returns the SAM bridge address.
+     * @return The SAM bridge address.
+     */
+    protected SocketAddress getSamBridgeAddress() {
+        if (!this.connected) {
+            throw new IllegalStateException("not connected");
+        }
+        return this.samSocket.getRemoteSocketAddress();
+    }
+
+    /**
+     * Closes this connection.
+     * @throws IOException As per {@link Socket#close()}.
+     */
+    public void close() throws IOException {
+        this.samSocket.close();
+    }
+
+    protected Socket unwrap() {
+        Socket socket = this.samSocket;
+        this.samSocket = null;
+        this.reader = null;
+        this.writer = null;
+        return socket;
+    }
+}