summary refs log tree commit diff stats
path: root/src/main/java/ganarchy/friendcode/client
diff options
context:
space:
mode:
authorSoniEx2 <endermoneymod@gmail.com>2022-07-03 23:12:06 -0300
committerSoniEx2 <endermoneymod@gmail.com>2022-07-03 23:12:06 -0300
commitbfb981cd49a6bbcd15482dceeb4ab121c0408157 (patch)
tree54f1fca2884e68f5a8e6205bf34a01cc62c8a953 /src/main/java/ganarchy/friendcode/client
parent0c1c11065062c745ce49529e9bee48b05aa4bc41 (diff)
[Project] Friend Code
A Minecraft mod which adds friend codes, an easy way to play in the same
world with remote friends.
Diffstat (limited to 'src/main/java/ganarchy/friendcode/client')
-rw-r--r--src/main/java/ganarchy/friendcode/client/FriendCodeScreen.java141
-rw-r--r--src/main/java/ganarchy/friendcode/client/FriendConnectScreen.java111
-rw-r--r--src/main/java/ganarchy/friendcode/client/FriendConnectingScreen.java110
-rw-r--r--src/main/java/ganarchy/friendcode/client/I2PSamPinger.java113
-rw-r--r--src/main/java/ganarchy/friendcode/client/LanSendPing.java6
-rw-r--r--src/main/java/ganarchy/friendcode/client/SamProxyThread.java184
-rw-r--r--src/main/java/ganarchy/friendcode/client/WaitingForFriendCodeScreen.java93
7 files changed, 758 insertions, 0 deletions
diff --git a/src/main/java/ganarchy/friendcode/client/FriendCodeScreen.java b/src/main/java/ganarchy/friendcode/client/FriendCodeScreen.java
new file mode 100644
index 0000000..38703a5
--- /dev/null
+++ b/src/main/java/ganarchy/friendcode/client/FriendCodeScreen.java
@@ -0,0 +1,141 @@
+package ganarchy.friendcode.client;
+
+import ganarchy.friendcode.FriendCode;
+import ganarchy.friendcode.mixin.FriendCodeIntegratedServerExt;
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.gui.screen.OpenToLanScreen;
+import net.minecraft.client.gui.screen.Screen;
+import net.minecraft.client.gui.widget.ButtonWidget;
+import net.minecraft.client.gui.widget.CyclingButtonWidget;
+import net.minecraft.client.util.NetworkUtils;
+import net.minecraft.client.util.math.MatrixStack;
+import net.minecraft.screen.ScreenTexts;
+import net.minecraft.server.network.ServerPlayerEntity;
+import net.minecraft.text.MutableText;
+import net.minecraft.text.Text;
+import net.minecraft.world.GameMode;
+
+import java.io.IOException;
+
+@Environment(EnvType.CLIENT)
+public class FriendCodeScreen extends Screen {
+    private static final Text ALLOW_COMMANDS_TEXT = Text.translatable("selectWorld.allowCommands");
+    private static final Text GAME_MODE_TEXT = Text.translatable("selectWorld.gameMode");
+    private static final Text OTHER_PLAYERS_TEXT = Text.translatable("lanServer.otherPlayers");
+    private final Screen parent;
+    private GameMode gameMode = GameMode.SURVIVAL;
+    private boolean allowCommands;
+
+    private static final Text START_SHARING = Text.translatable("friendcode.start");
+
+    public FriendCodeScreen(Screen parent) {
+        super(Text.translatable("friendcode.title"));
+        this.parent = parent;
+    }
+
+    @Override
+    protected void init() {
+        // title button allows going back to LAN screen
+        this.addDrawableChild(new ButtonWidget(
+            this.width / 2 - 155,
+            50,
+            310,
+            20,
+            this.title,
+            button -> this.client.setScreen(new OpenToLanScreen(this.parent))
+        ));
+
+        // game setting buttons
+        this.addDrawableChild(
+            CyclingButtonWidget
+            .builder(GameMode::getSimpleTranslatableName)
+            .values(GameMode.SURVIVAL, GameMode.SPECTATOR, GameMode.CREATIVE, GameMode.ADVENTURE)
+            .initially(this.gameMode)
+            .build(
+                this.width / 2 - 155,
+                100,
+                150,
+                20,
+                GAME_MODE_TEXT,
+                (button, gameMode) -> {
+                    this.gameMode = gameMode;
+                }
+            )
+        );
+        this.addDrawableChild(
+            CyclingButtonWidget
+            .onOffBuilder(this.allowCommands)
+            .build(
+                this.width / 2 + 5,
+                100,
+                150,
+                20,
+                ALLOW_COMMANDS_TEXT,
+                (button, allowCommands) -> {
+                    this.allowCommands = allowCommands;
+                }
+            )
+        );
+
+        // start sharing
+        this.addDrawableChild(new ButtonWidget(
+            this.width / 2 - 155,
+            this.height - 28,
+            150,
+            20,
+            START_SHARING,
+            button -> {
+                // FIXME
+                int i = NetworkUtils.findLocalPort();
+                var samPinger = openToFriends(this.client, this.gameMode, this.allowCommands, i);
+                MutableText text = samPinger != null ? Text.translatable("commands.publish.started", i) : Text.translatable("commands.publish.failed");
+                this.client.inGameHud.getChatHud().addMessage(text);
+                this.client.updateWindowTitle();
+                this.client.setScreen(new WaitingForFriendCodeScreen(samPinger));
+            }
+        ));
+
+        // go back to options
+        this.addDrawableChild(new ButtonWidget(
+            this.width / 2 + 5,
+            this.height - 28,
+            150,
+            20,
+            ScreenTexts.CANCEL,
+            button -> this.client.setScreen(this.parent)
+        ));
+    }
+
+    private static I2PSamPinger openToFriends(MinecraftClient client, GameMode gameMode, boolean allowCommands, int port) {
+        try {
+            client.loadBlockList();
+            client.getServer().getNetworkIo().bind(null, port);
+            FriendCode.LOGGER.info("Started serving on {}", port);
+            ((FriendCodeIntegratedServerExt) client.getServer()).lanPort(port);
+            // reuse LAN pinger machinery instead of rolling our own
+            var lanPinger = new I2PSamPinger(client.getServer().getServerMotd(), "" + port);
+            ((FriendCodeIntegratedServerExt) client.getServer()).lanPinger(lanPinger);
+            lanPinger.start();
+            ((FriendCodeIntegratedServerExt) client.getServer()).forcedGameMode(gameMode);
+            client.getServer().getPlayerManager().setCheatsAllowed(allowCommands);
+            int i = client.getServer().getPermissionLevel(client.player.getGameProfile());
+            client.player.setClientPermissionLevel(i);
+            for (ServerPlayerEntity serverPlayerEntity : client.getServer().getPlayerManager().getPlayerList()) {
+                client.getServer().getCommandManager().sendCommandTree(serverPlayerEntity);
+            }
+            return lanPinger;
+        }
+        catch (IOException iOException) {
+            return null;
+        }
+    }
+
+    @Override
+    public void render(MatrixStack matrices, int mouseX, int mouseY, float delta) {
+        this.renderBackground(matrices);
+        FriendCodeScreen.drawCenteredText(matrices, this.textRenderer, OTHER_PLAYERS_TEXT, this.width / 2, 82, 0xFFFFFF);
+        super.render(matrices, mouseX, mouseY, delta);
+    }
+}
diff --git a/src/main/java/ganarchy/friendcode/client/FriendConnectScreen.java b/src/main/java/ganarchy/friendcode/client/FriendConnectScreen.java
new file mode 100644
index 0000000..53282d7
--- /dev/null
+++ b/src/main/java/ganarchy/friendcode/client/FriendConnectScreen.java
@@ -0,0 +1,111 @@
+package ganarchy.friendcode.client;
+
+import it.unimi.dsi.fastutil.booleans.BooleanConsumer;
+import net.minecraft.client.MinecraftClient;
+import net.minecraft.client.gui.screen.DirectConnectScreen;
+import net.minecraft.client.gui.screen.Screen;
+import net.minecraft.client.gui.widget.ButtonWidget;
+import net.minecraft.client.gui.widget.TextFieldWidget;
+import net.minecraft.client.network.ServerInfo;
+import net.minecraft.client.util.math.MatrixStack;
+import net.minecraft.screen.ScreenTexts;
+import net.minecraft.text.Text;
+import org.lwjgl.glfw.GLFW;
+
+import java.util.regex.Pattern;
+
+public class FriendConnectScreen extends Screen {
+    private static final Text ENTER_CODE_TEXT = Text.translatable("friendcode.enter_code");
+    /**
+     * Pattern for old-style b32 addresses.
+     */
+    // FIXME? maybe add support for b33 addresses?
+    private static final Pattern B32_PATTERN = Pattern.compile("^[A-Za-z2-7]{52}\\.b32\\.i2p$");
+    private final Screen parent;
+    private final ServerInfo serverEntry;
+    private final BooleanConsumer callback;
+    private TextFieldWidget addressField;
+    private ButtonWidget selectServerButton;
+
+    public FriendConnectScreen(Screen parent, BooleanConsumer callback, ServerInfo server) {
+        super(Text.translatable("friendcode.connect"));
+        this.parent = parent;
+        this.serverEntry = server;
+        this.callback = callback;
+    }
+
+    @Override
+    public void tick() {
+        this.addressField.tick();
+    }
+
+    @Override
+    public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
+        if (this.selectServerButton.active && this.getFocused() == this.addressField && (keyCode == GLFW.GLFW_KEY_ENTER || keyCode == GLFW.GLFW_KEY_KP_ENTER)) {
+            this.saveAndClose();
+            return true;
+        }
+        return super.keyPressed(keyCode, scanCode, modifiers);
+    }
+
+    @Override
+    protected void init() {
+        this.addDrawableChild(new ButtonWidget(
+                this.width / 2 - 100,
+                20,
+                200,
+                20,
+                this.title,
+                button -> this.client.setScreen(new DirectConnectScreen(this.parent, this.callback, this.serverEntry))
+        ));
+        this.client.keyboard.setRepeatEvents(true);
+        this.selectServerButton = this.addDrawableChild(new ButtonWidget(this.width / 2 - 100, this.height / 4 + 96 + 12, 200, 20, Text.translatable("friendcode.select"), button -> this.saveAndClose()));
+        this.addDrawableChild(new ButtonWidget(this.width / 2 - 100, this.height / 4 + 120 + 12, 200, 20, ScreenTexts.CANCEL, button -> this.callback.accept(false)));
+        this.addressField = new TextFieldWidget(this.textRenderer, this.width / 2 - 100, 116, 200, 20, ENTER_CODE_TEXT);
+        this.addressField.setMaxLength(128);
+        this.addressField.setTextFieldFocused(true);
+        //this.addressField.setText(this.client.options.lastServer);
+        this.addressField.setChangedListener(text -> this.onAddressFieldChanged());
+        this.addSelectableChild(this.addressField);
+        this.setInitialFocus(this.addressField);
+        this.onAddressFieldChanged();
+    }
+
+    @Override
+    public void resize(MinecraftClient client, int width, int height) {
+        String string = this.addressField.getText();
+        this.init(client, width, height);
+        this.addressField.setText(string);
+    }
+
+    private void saveAndClose() {
+        this.client.setScreen(new FriendConnectingScreen(this.serverEntry, this.callback, this.addressField.getText()));
+//        this.serverEntry.address = this.addressField.getText();
+//        this.callback.accept(true);
+    }
+
+    @Override
+    public void close() {
+        this.client.setScreen(this.parent);
+    }
+
+    @Override
+    public void removed() {
+        this.client.keyboard.setRepeatEvents(false);
+        // FIXME store this.addressField.getText() somewhere.
+//        this.client.options.lastServer = this.addressField.getText();
+//        this.client.options.write();
+    }
+
+    private void onAddressFieldChanged() {
+        this.selectServerButton.active = B32_PATTERN.matcher(this.addressField.getText()).matches();
+    }
+
+    @Override
+    public void render(MatrixStack matrices, int mouseX, int mouseY, float delta) {
+        this.renderBackground(matrices);
+        FriendConnectScreen.drawTextWithShadow(matrices, this.textRenderer, ENTER_CODE_TEXT, this.width / 2 - 100, 100, 0xA0A0A0);
+        this.addressField.render(matrices, mouseX, mouseY, delta);
+        super.render(matrices, mouseX, mouseY, delta);
+    }
+}
diff --git a/src/main/java/ganarchy/friendcode/client/FriendConnectingScreen.java b/src/main/java/ganarchy/friendcode/client/FriendConnectingScreen.java
new file mode 100644
index 0000000..748801c
--- /dev/null
+++ b/src/main/java/ganarchy/friendcode/client/FriendConnectingScreen.java
@@ -0,0 +1,110 @@
+package ganarchy.friendcode.client;
+
+import it.unimi.dsi.fastutil.booleans.BooleanConsumer;
+import net.minecraft.client.gui.screen.LoadingDisplay;
+import net.minecraft.client.gui.screen.Screen;
+import net.minecraft.client.gui.widget.ButtonWidget;
+import net.minecraft.client.network.ServerInfo;
+import net.minecraft.client.util.math.MatrixStack;
+import net.minecraft.screen.ScreenTexts;
+import net.minecraft.text.MutableText;
+import net.minecraft.text.Text;
+import net.minecraft.util.Util;
+import org.apache.commons.codec.binary.Base32;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Base64;
+import java.util.Locale;
+
+public class FriendConnectingScreen extends Screen {
+    private static final Text PATIENCE_TEXT = Text.translatable("friendcode.patience");
+    private final ServerInfo serverEntry;
+    private final BooleanConsumer callback;
+    private final SamProxyThread proxyThread;
+    private long startTimeMs;
+    private Text renderText = null;
+
+    public FriendConnectingScreen(ServerInfo serverEntry, BooleanConsumer callback, String text) {
+        super(Text.translatable("friendcode.connecting"));
+        this.serverEntry = serverEntry;
+        this.callback = callback;
+        this.startTimeMs = Util.getMeasuringTimeMs();
+        this.proxyThread = new SamProxyThread(text);
+    }
+
+    @Override
+    protected void init() {
+        try {
+            this.proxyThread.start();
+        } catch (IllegalThreadStateException ignored) {
+        }
+        // cancel friend code.
+        this.addDrawableChild(new ButtonWidget(
+                this.width / 2 - 155,
+                this.height - 28,
+                310,
+                20,
+                ScreenTexts.CANCEL,
+                button -> this.close()
+        ));
+    }
+
+
+    @Override
+    public void close() {
+        super.close();
+        if (this.proxyThread.status() != SamProxyThread.Status.RUNNING) {
+            this.proxyThread.stopProxy();
+        }
+    }
+
+    @Override
+    public void render(MatrixStack matrices, int mouseX, int mouseY, float delta) {
+        this.renderBackground(matrices);
+        FriendConnectingScreen.drawCenteredText(matrices, this.textRenderer, this.title, this.width / 2, 50, 0xFFFFFF);
+        if (this.renderText != null) {
+            FriendConnectingScreen.drawCenteredText(matrices, this.textRenderer, this.renderText, this.width / 2, 82, 0xFFFFFF);
+        } else {
+            FriendConnectingScreen.drawCenteredText(matrices, this.textRenderer, PATIENCE_TEXT, this.width / 2, 82, 0xFFFFFF);
+            final String string = LoadingDisplay.get(Util.getMeasuringTimeMs());
+            this.client.textRenderer.draw(matrices, string, (float) (this.client.currentScreen.width / 2 - this.client.textRenderer.getWidth(string) / 2), (float) (this.client.currentScreen.height / 2 + this.client.textRenderer.fontHeight), 0x808080);
+        }
+        super.render(matrices, mouseX, mouseY, delta);
+    }
+
+    @Override
+    public void tick() {
+        super.tick();
+        if (proxyThread.status() != SamProxyThread.Status.IDLE) {
+            switch (proxyThread.status()) {
+                case CONNECTION_FAILED -> {
+                    this.renderText = Text.translatable("friendcode.connect.no_i2p");
+                    break;
+                }
+                case SETUP_FAILED -> {
+                    this.renderText = Text.translatable("friendcode.connect.failed");
+                    break;
+                }
+                case RESOLUTION_FAILED -> {
+                    this.renderText = Text.translatable("friendcode.connect.no_such_code");
+                    break;
+                }
+                case RUNNING -> {
+                    this.serverEntry.address = "[::1]:" + this.proxyThread.port();
+                    this.callback.accept(true);
+                    break;
+                }
+            }
+        }
+        if (!this.proxyThread.isConnecting()) {
+            startTimeMs = Util.getMeasuringTimeMs();
+        }
+        if (Util.getMeasuringTimeMs() - startTimeMs > 20 * 1000) {
+            // keep it from getting stuck.
+            // FIXME this is wrong.
+            //this.proxyThread.interrupt();
+        }
+    }
+}
diff --git a/src/main/java/ganarchy/friendcode/client/I2PSamPinger.java b/src/main/java/ganarchy/friendcode/client/I2PSamPinger.java
new file mode 100644
index 0000000..96448db
--- /dev/null
+++ b/src/main/java/ganarchy/friendcode/client/I2PSamPinger.java
@@ -0,0 +1,113 @@
+package ganarchy.friendcode.client;
+
+import ganarchy.friendcode.FriendCode;
+import ganarchy.friendcode.sam.I2PSamControl;
+import ganarchy.friendcode.sam.I2PSamStreamForwarder;
+import net.minecraft.client.network.LanServerPinger;
+
+import java.io.*;
+
+public class I2PSamPinger extends LanServerPinger implements LanSendPing {
+    private final I2PSamControl sam;
+    private I2PSamStreamForwarder stream;
+    private final String port;
+    private volatile Status status = Status.IDLE;
+    private boolean hasWarned;
+    private volatile boolean stopSam;
+
+    public I2PSamPinger(String motd, String port) throws IOException {
+        super(motd, port);
+        // cba to mixin
+        this.port = port;
+        this.sam = new I2PSamControl(true);
+    }
+
+    @Override
+    public void run() {
+        try (final I2PSamControl sam = this.sam) {
+            if (!sam.connect()) {
+                this.status = Status.CONNECTION_FAILED;
+                // fall back to LAN server.
+                super.run();
+                return;
+            }
+
+            if (!sam.start()) {
+                this.status = Status.SETUP_FAILED;
+                // fall back to LAN server.
+                super.run();
+                return;
+            }
+
+            try (final I2PSamStreamForwarder stream = this.stream = sam.forwardStream(this.port)) {
+                if (!stream.connect()) {
+                    this.status = Status.CONNECTION_FAILED;
+                    super.run();
+                    return;
+                }
+
+                if (!stream.start()) {
+                    this.status = Status.SETUP_FAILED;
+                    super.run();
+                    return;
+                }
+
+                this.status = Status.RUNNING;
+                super.run();
+            } catch (IOException ignored) {
+            }
+        } catch (IOException ignored) {
+        }
+    }
+
+    @Override
+    public void friendcodeSendPing() {
+        if (this.stopSam) {
+            if (this.stream != null) {
+                try {
+                    this.stream.close();
+                } catch (IOException ignored) {
+                }
+            }
+            try {
+                this.sam.close();
+            } catch (IOException ignored) {
+            }
+            this.status = Status.CONNECTION_CLOSED;
+            this.hasWarned = true;
+            return;
+        }
+        if (this.sam.sendPing() < 0) {
+            this.status = Status.CONNECTION_CLOSED;
+        }
+        if (this.stream != null) {
+            if (this.stream.sendPing() < 0) {
+                this.status = Status.CONNECTION_CLOSED;
+            }
+        }
+        if (this.status == Status.CONNECTION_CLOSED && !this.hasWarned) {
+            this.hasWarned = true;
+            FriendCode.LOGGER.warn("Friend Code connection closed!");
+        }
+    }
+
+    public Status status() {
+        return this.status;
+    }
+
+    public String pubkey() {
+        return this.sam.pubkey();
+    }
+
+    public void stopSam() {
+        this.stopSam = true;
+    }
+
+    public enum Status {
+        IDLE,
+        RUNNING,
+        CONNECTION_FAILED,
+        SETUP_FAILED,
+        CONNECTION_CLOSED;
+    }
+}
diff --git a/src/main/java/ganarchy/friendcode/client/LanSendPing.java b/src/main/java/ganarchy/friendcode/client/LanSendPing.java
new file mode 100644
index 0000000..914a8d5
--- /dev/null
+++ b/src/main/java/ganarchy/friendcode/client/LanSendPing.java
@@ -0,0 +1,6 @@
+package ganarchy.friendcode.client;
+
+public interface LanSendPing {
+
+    void friendcodeSendPing();
+}
diff --git a/src/main/java/ganarchy/friendcode/client/SamProxyThread.java b/src/main/java/ganarchy/friendcode/client/SamProxyThread.java
new file mode 100644
index 0000000..c7db41f
--- /dev/null
+++ b/src/main/java/ganarchy/friendcode/client/SamProxyThread.java
@@ -0,0 +1,184 @@
+package ganarchy.friendcode.client;
+
+import ganarchy.friendcode.FriendCode;
+import ganarchy.friendcode.sam.I2PSamControl;
+import ganarchy.friendcode.sam.I2PSamStreamConnector;
+
+import java.io.IOException;
+import java.net.Inet6Address;
+import java.net.ServerSocket;
+import java.net.Socket;
+
+public class SamProxyThread extends Thread {
+    private final String target;
+    private volatile int localPort;
+    private volatile Status status = Status.IDLE;
+    private volatile boolean running = true;
+    private volatile boolean isConnecting = false;
+
+    public SamProxyThread(String target) {
+        this.target = target;
+        this.setDaemon(true);
+        this.setName("SAM Proxy Control Thread");
+    }
+
+    @Override
+    public void run() {
+        if (this.running) {
+            tryRun(true);
+        }
+        if (this.running) {
+            tryRun(false);
+        }
+        this.status = Status.SETUP_FAILED;
+    }
+
+    private void tryRun(final boolean zeroHop) {
+        // this is all very inefficient but we don't particularly care
+        // who cares if you have 3 threads just to connect to a minecraft server/friend code
+        // it's only 3 threads
+        // the "server" is far more efficient, only requiring one
+        try (final I2PSamControl control = new I2PSamControl(zeroHop)) {
+            if (!control.connect()) {
+                this.running = false;
+                this.status = Status.CONNECTION_FAILED;
+                return;
+            }
+            if (!control.start()) {
+                this.running = false;
+                this.status = Status.SETUP_FAILED;
+                return;
+            }
+            switch (control.checkName(this.target)) {
+                case UNKNOWN, INVALID -> {
+                    this.running = false;
+                    this.status = Status.RESOLUTION_FAILED;
+                    return;
+                }
+                case FAILED -> {
+                    return;
+                }
+            }
+            // we can't distinguish if we should retry the connection
+            // (e.g. because building tunnels took too long, destination is known but gateway/peer is not, etc)
+            // vs if we should try with 1hop (because we can't reach peer from our connection,
+            // e.g. ipv4-only client vs ipv6-only friend, symmetric NAT, etc)
+            // so we just try to "warm up" the connection and hope it works.
+            I2PSamStreamConnector warmup = control.connectStream(this.target);
+            if (!warmup.connect()) {
+                this.running = false;
+                this.status = Status.CONNECTION_FAILED;
+                return;
+            }
+            warmup.start();
+            warmup.close();
+            I2PSamStreamConnector stream = control.connectStream(this.target);
+            if (!stream.connect()) {
+                this.running = false;
+                this.status = Status.CONNECTION_FAILED;
+                return;
+            }
+            this.isConnecting = true;
+            if (!stream.start()) {
+                FriendCode.LOGGER.warn("[SAM error] {}", stream.getStatus());
+                this.isConnecting = false;
+                if (!zeroHop) {
+                    this.running = false;
+                    this.status = Status.SETUP_FAILED;
+                }
+                return;
+            }
+            try (
+                    Socket socket = stream.unwrap();
+                    ServerSocket server = new ServerSocket(0, 8, Inet6Address.getByAddress("localhost", new byte[]{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}, 0));
+            ) {
+                this.localPort = server.getLocalPort();
+                server.setSoTimeout(3000);
+                this.status = Status.RUNNING;
+                try (Socket single = server.accept()) {
+                    socket.setTcpNoDelay(true);
+                    single.setTcpNoDelay(true);
+                    Thread clientThread = new Thread("SAM Proxy CTS Thread") {
+                        @Override
+                        public void run() {
+                            try (
+                                var clientRead = single.getInputStream();
+                                var serverWrite = socket.getOutputStream();
+                            ) {
+                                byte[] buffer = new byte[128*1024];
+                                while (SamProxyThread.this.running) {
+                                    int read = clientRead.read(buffer);
+                                    if (read > 0) {
+                                        serverWrite.write(buffer, 0, read);
+                                    }
+                                }
+                            } catch (IOException ignored) {
+                            }
+                        }
+                    };
+                    Thread serverThread = new Thread("SAM Proxy STC Thread") {
+                        @Override
+                        public void run() {
+                            try (
+                                var serverRead = socket.getInputStream();
+                                var clientWrite = single.getOutputStream();
+                            ) {
+                                byte[] buffer = new byte[128*1024];
+                                while (SamProxyThread.this.running) {
+                                    int read = serverRead.read(buffer);
+                                    if (read > 0) {
+                                        clientWrite.write(buffer, 0, read);
+                                    }
+                                }
+                            } catch (IOException ignored) {
+                            }
+                        }
+                    };
+                    clientThread.setDaemon(true);
+                    clientThread.start();
+                    serverThread.setDaemon(true);
+                    serverThread.start();
+                    while (!this.isInterrupted() && this.running) {
+                        if (control.sendPing() < 0) {
+                            this.running = false;
+                        }
+                        try {
+                            Thread.sleep(1500L);
+                        } catch (InterruptedException ignored) {
+                        }
+                    }
+                } finally {
+                    this.running = false;
+                }
+            }
+        } catch (IOException e) {
+            // don't really need to do anything
+        }
+    }
+
+    public void stopProxy() {
+        this.running = false;
+        // FIXME this is wrong, need to actually close sockets.
+        this.interrupt();
+    }
+
+    public Status status() {
+        return this.status;
+    }
+
+    public int port() {
+        return this.localPort;
+    }
+
+    public boolean isConnecting() {
+        return this.isConnecting;
+    }
+
+    public enum Status {
+        IDLE,
+        RUNNING,
+        CONNECTION_FAILED,
+        SETUP_FAILED,
+        RESOLUTION_FAILED;
+    }
+}
diff --git a/src/main/java/ganarchy/friendcode/client/WaitingForFriendCodeScreen.java b/src/main/java/ganarchy/friendcode/client/WaitingForFriendCodeScreen.java
new file mode 100644
index 0000000..8429a41
--- /dev/null
+++ b/src/main/java/ganarchy/friendcode/client/WaitingForFriendCodeScreen.java
@@ -0,0 +1,93 @@
+package ganarchy.friendcode.client;
+
+import net.minecraft.client.gui.screen.LoadingDisplay;
+import net.minecraft.client.gui.screen.Screen;
+import net.minecraft.client.gui.widget.ButtonWidget;
+import net.minecraft.client.util.math.MatrixStack;
+import net.minecraft.screen.ScreenTexts;
+import net.minecraft.text.MutableText;
+import net.minecraft.text.Text;
+import net.minecraft.util.Util;
+import org.apache.commons.codec.binary.Base32;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Base64;
+import java.util.Locale;
+
+public class WaitingForFriendCodeScreen extends Screen {
+    private final I2PSamPinger samPinger;
+
+    public WaitingForFriendCodeScreen(I2PSamPinger samPinger) {
+        super(Text.translatable("friendcode.opening"));
+        this.samPinger = samPinger;
+    }
+
+    @Override
+    protected void init() {
+        // cancel friend code.
+        this.addDrawableChild(new ButtonWidget(
+                this.width / 2 - 155,
+                this.height - 28,
+                310,
+                20,
+                ScreenTexts.CANCEL,
+                button -> this.close()
+        ));
+    }
+
+    @Override
+    public void render(MatrixStack matrices, int mouseX, int mouseY, float delta) {
+        this.renderBackground(matrices);
+        WaitingForFriendCodeScreen.drawCenteredText(matrices, this.textRenderer, this.title, this.width / 2, 50, 0xFFFFFF);
+        final String string = LoadingDisplay.get(Util.getMeasuringTimeMs());
+        this.client.textRenderer.draw(matrices, string, (float)(this.client.currentScreen.width / 2 - this.client.textRenderer.getWidth(string) / 2), (float)(this.client.currentScreen.height / 2 + this.client.textRenderer.fontHeight), 0x808080);
+        super.render(matrices, mouseX, mouseY, delta);
+    }
+
+    @Override
+    public void close() {
+        super.close();
+        this.samPinger.stopSam();
+        final MutableText text = Text.translatable("friendcode.publish.stopped");
+        this.client.inGameHud.getChatHud().addMessage(text);
+    }
+
+    @Override
+    public void tick() {
+        super.tick();
+        if (samPinger.status() != I2PSamPinger.Status.IDLE) {
+            this.client.setScreen(null);
+            switch (samPinger.status()) {
+                case CONNECTION_FAILED -> {
+                    final MutableText text = Text.translatable("friendcode.publish.no_i2p");
+                    this.client.inGameHud.getChatHud().addMessage(text);
+                    break;
+                }
+                case SETUP_FAILED, CONNECTION_CLOSED -> {
+                    final MutableText text = Text.translatable("friendcode.publish.failed");
+                    this.client.inGameHud.getChatHud().addMessage(text);
+                    break;
+                }
+                case RUNNING -> {
+                    // convert from I2P base64 to URL-safe base64 so we don't have to write our own Base64 decoder
+                    final String pubkey_b64 = samPinger.pubkey().replace('~', '_');
+                    final byte[] pubkey = Base64.getUrlDecoder().decode(pubkey_b64.getBytes(StandardCharsets.UTF_8));
+                    // a .b32.i2p is just a Base32-encoded SHA256
+                    final String b32;
+                    try {
+                        b32 = new Base32().encodeAsString(MessageDigest.getInstance("SHA-256").digest(pubkey));
+                    } catch (NoSuchAlgorithmException e) {
+                        throw new RuntimeException(e);
+                    }
+                    // but we wanna massage it a little
+                    final String b32_i2p = b32.replaceFirst("=+$", "").toLowerCase(Locale.ROOT) + ".b32.i2p";
+                    final MutableText text = Text.translatable("friendcode.publish.started", b32_i2p);
+                    this.client.inGameHud.getChatHud().addMessage(text);
+                    break;
+                }
+            }
+        }
+    }
+}