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 {
private Socket samSocket;
private PushbackReader reader;
private OutputStreamWriter writer;
private final ArrayDeque<I2PSamCommand> queue = new ArrayDeque<>();
private boolean started;
private boolean connected;
protected I2PSamStateMachine() {
}
/**
* Connects to the SAM socket.
*
* @return Whether the connection was successful.
*/
public abstract boolean connect();
protected boolean connect(Socket samSocket) {
if (this.connected) {
throw new IllegalStateException();
}
this.samSocket = samSocket;
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
var auth = I2PSamAuthUtil.getAuthPair();
this.sendCommand(new I2PSamCommand(
"HELLO", "VERSION",
ImmutableMap.of(
"MIN", "3.2",
"USER", auth.user(),
"PASSWORD", auth.password()
)
));
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 {
if (this.connected) {
this.samSocket.close();
}
}
protected Socket unwrap() {
Socket socket = this.samSocket;
this.samSocket = null;
this.reader = null;
this.writer = null;
return socket;
}
}