diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerChannel.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerChannel.java new file mode 100644 index 000000000..1bec2dd15 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerChannel.java @@ -0,0 +1,120 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2021 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.multiplayer; + +import org.jackhuang.hmcl.event.Event; +import org.jackhuang.hmcl.util.gson.JsonSubtype; +import org.jackhuang.hmcl.util.gson.JsonType; + +public class MultiplayerChannel { + + @JsonType( + property = "type", + subtypes = { + @JsonSubtype(clazz = JoinRequest.class, name = "join"), + @JsonSubtype(clazz = KeepAliveRequest.class, name = "keepalive") + } + ) + public static class Request { + } + + public static class JoinRequest extends Request { + private final String clientVersion; + private final String username; + + public JoinRequest(String clientVersion, String username) { + this.clientVersion = clientVersion; + this.username = username; + } + + public String getClientVersion() { + return clientVersion; + } + + public String getUsername() { + return username; + } + } + + public static class KeepAliveRequest extends Request { + private final long timestamp; + + public KeepAliveRequest(long timestamp) { + this.timestamp = timestamp; + } + + public long getTimestamp() { + return timestamp; + } + } + + @JsonType( + property = "type", + subtypes = { + @JsonSubtype(clazz = JoinResponse.class, name = "join"), + @JsonSubtype(clazz = KeepAliveResponse.class, name = "keepalive") + } + ) + public static class Response { + + } + + public static class JoinResponse extends Response { + private final int port; + + public JoinResponse(int port) { + this.port = port; + } + + public int getPort() { + return port; + } + } + + public static class KeepAliveResponse extends Response { + private final long timestamp; + + public KeepAliveResponse(long timestamp) { + this.timestamp = timestamp; + } + + public long getTimestamp() { + return timestamp; + } + } + + public static class CatoClient extends Event { + private final String username; + + public CatoClient(Object source, String username) { + super(source); + this.username = username; + } + + public String getUsername() { + return username; + } + } + + public static String verifyJson(String jsonString) { + if (jsonString.indexOf('\r') >= 0 || jsonString.indexOf('\n') >= 0) { + throw new IllegalArgumentException(); + } + return jsonString; + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerClient.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerClient.java index 94e1bb35e..901a79fcd 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerClient.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerClient.java @@ -23,9 +23,11 @@ import org.jackhuang.hmcl.event.EventManager; import org.jackhuang.hmcl.util.gson.JsonUtils; import java.io.*; +import java.net.ConnectException; import java.net.InetAddress; import java.net.Socket; +import static org.jackhuang.hmcl.ui.multiplayer.MultiplayerChannel.*; import static org.jackhuang.hmcl.util.Logging.LOG; public class MultiplayerClient extends Thread { @@ -64,42 +66,56 @@ public class MultiplayerClient extends Thread { @Override public void run() { LOG.info("Connecting to 127.0.0.1:" + port); - try (Socket socket = new Socket(InetAddress.getLoopbackAddress(), port); - BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream())); - BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()))) { + for (int i = 0; i < 5; i++) { + try (Socket socket = new Socket(InetAddress.getLoopbackAddress(), port); + BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream())); + BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()))) { + LOG.info("Connected to 127.0.0.1:" + port); - writer.write(JsonUtils.GSON.toJson(new MultiplayerServer.JoinRequest(MultiplayerManager.CATO_VERSION, id))); - writer.write("\n"); + writer.write(JsonUtils.UGLY_GSON.toJson(new JoinRequest(MultiplayerManager.CATO_VERSION, id))); + writer.newLine(); + writer.flush(); - LOG.fine("Send join request with id=" + id); + LOG.fine("Sent join request with id=" + id); - String line = reader.readLine(); - if (line == null) { - return; - } - - MultiplayerServer.JoinResponse response = JsonUtils.fromNonNullJson(line, MultiplayerServer.JoinResponse.class); - setGamePort(response.getPort()); - onConnected.fireEvent(new ConnectedEvent(this, response.getPort())); - - LOG.fine("Received join response with port " + response.getPort()); - - while (!isInterrupted()) { - writer.write(JsonUtils.GSON.toJson(new MultiplayerServer.KeepAliveResponse(System.currentTimeMillis()))); - writer.write("\n"); - - try { - Thread.sleep(1500); - } catch (InterruptedException ignored) { + String line = reader.readLine(); + if (line == null) { + return; } - } - } catch (IOException | JsonParseException e) { - e.printStackTrace(); - } finally { - LOG.info("Lost connection to 127.0.0.1:" + port); - onDisconnected.fireEvent(new Event(this)); + JoinResponse response = JsonUtils.fromNonNullJson(line, JoinResponse.class); + setGamePort(response.getPort()); + onConnected.fireEvent(new ConnectedEvent(this, response.getPort())); + + LOG.fine("Received join response with port " + response.getPort()); + + while (!isInterrupted()) { + writer.write(verifyJson(JsonUtils.UGLY_GSON.toJson(new KeepAliveResponse(System.currentTimeMillis())))); + writer.newLine(); + writer.flush(); + + try { + Thread.sleep(1500); + } catch (InterruptedException ignored) { + LOG.warning("MultiplayerClient interrupted"); + return; + } + } + } catch (ConnectException e) { + LOG.info("Failed to connect to 127.0.0.1:" + port + ", tried " + i + " time(s)"); + try { + Thread.sleep(2000); + } catch (InterruptedException ex) { + LOG.warning("MultiplayerClient interrupted"); + return; + } + continue; + } catch (IOException | JsonParseException e) { + e.printStackTrace(); + } } + LOG.info("Lost connection to 127.0.0.1:" + port); + onDisconnected.fireEvent(new Event(this)); } public static class ConnectedEvent extends Event { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerManager.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerManager.java index e9d9e0522..3d75822e7 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerManager.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerManager.java @@ -65,6 +65,7 @@ public final class MultiplayerManager { private static final String REMOTE_ADDRESS = "127.0.0.1"; private static final String LOCAL_ADDRESS = "127.0.0.1"; + private static final String MODE = "p2p"; private MultiplayerManager() { } @@ -101,7 +102,8 @@ public final class MultiplayerManager { "--token", StringUtils.isBlank(token) ? "new" : token, "--id", peer, "--local", String.format("%s:%d", LOCAL_ADDRESS, localPort), - "--remote", String.format("%s:%d", REMOTE_ADDRESS, remotePort)}; + "--remote", String.format("%s:%d", REMOTE_ADDRESS, remotePort), + "--mode", MODE}; Process process; try { process = new ProcessBuilder() @@ -133,7 +135,11 @@ public final class MultiplayerManager { client.onConnected().register(connectedEvent -> { try { int port = findAvailablePort(); - writer.write(String.format("net add %s %s:%d %s:%d p2p\n", peer, LOCAL_ADDRESS, port, REMOTE_ADDRESS, connectedEvent.getPort())); + String command = String.format("net add %s %s:%d %s:%d %s\n", peer, LOCAL_ADDRESS, port, REMOTE_ADDRESS, connectedEvent.getPort(), MODE); + LOG.info("Invoking cato: " + command); + writer.write(command); + writer.newLine(); + writer.flush(); future.complete(session); } catch (IOException e) { future.completeExceptionally(e); @@ -157,7 +163,8 @@ public final class MultiplayerManager { String[] commands = new String[]{exe.toString(), "--token", StringUtils.isBlank(token) ? "new" : token, - "--allows", String.format("%s:%d/%s:%d", REMOTE_ADDRESS, server.getPort(), REMOTE_ADDRESS, gamePort)}; + "--allows", String.format("%s:%d/%s:%d", REMOTE_ADDRESS, server.getPort(), REMOTE_ADDRESS, gamePort), + "--mode", MODE}; Process process = new ProcessBuilder() .command(commands) .start(); @@ -214,6 +221,7 @@ public final class MultiplayerManager { private final String name; private final State type; private String id; + private boolean peerConnected = false; private MultiplayerClient client; private MultiplayerServer server; @@ -254,14 +262,15 @@ public final class MultiplayerManager { if (id == null) { Matcher matcher = TEMP_TOKEN_PATTERN.matcher(log); if (matcher.find()) { - id = "mix" + matcher.group("id"); + id = matcher.group("id"); onIdGenerated.fireEvent(new CatoIdEvent(this, id)); } } - { + if (!peerConnected) { Matcher matcher = PEER_CONNECTED_PATTERN.matcher(log); if (matcher.find()) { + peerConnected = true; onPeerConnected.fireEvent(new Event(this)); } } @@ -315,8 +324,8 @@ public final class MultiplayerManager { return onPeerConnected; } - private static final Pattern TEMP_TOKEN_PATTERN = Pattern.compile("id\\(mix(?\\w+)\\)"); - private static final Pattern PEER_CONNECTED_PATTERN = Pattern.compile("Connection established"); + private static final Pattern TEMP_TOKEN_PATTERN = Pattern.compile("id\\((?\\w+)\\)"); + private static final Pattern PEER_CONNECTED_PATTERN = Pattern.compile("Connected to main net"); private static final Pattern LOG_PATTERN = Pattern.compile("(\\[\\d+])\\s+(\\w+)\\s+(\\w+-{0,1}\\w+):\\s(.*)"); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerPage.java index f36413b23..8d63fbe87 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerPage.java @@ -53,7 +53,7 @@ public class MultiplayerPage extends Control implements DecoratorPage, PageAware private final ReadOnlyObjectWrapper natState = new ReadOnlyObjectWrapper<>(); private final ReadOnlyIntegerWrapper gamePort = new ReadOnlyIntegerWrapper(-1); private final ReadOnlyObjectWrapper session = new ReadOnlyObjectWrapper<>(); - private final ObservableList clients = FXCollections.observableArrayList(); + private final ObservableList clients = FXCollections.observableArrayList(); private Consumer onExit; private Consumer onIdGenerated; @@ -73,7 +73,7 @@ public class MultiplayerPage extends Control implements DecoratorPage, PageAware return new MultiplayerPageSkin(this); } - public ObservableList getClients() { + public ObservableList getClients() { return clients; } @@ -236,7 +236,7 @@ public class MultiplayerPage extends Control implements DecoratorPage, PageAware }); gamePort.set(session.getClient().getGamePort()); - setMultiplayerState(MultiplayerManager.State.CONNECTING); + setMultiplayerState(MultiplayerManager.State.SLAVE); resolve.run(); }, Platform::runLater).exceptionally(throwable -> { LOG.log(Level.WARNING, "Failed to join sessoin"); @@ -314,6 +314,9 @@ public class MultiplayerPage extends Control implements DecoratorPage, PageAware Controllers.dialog(i18n("multiplayer.exit.timeout")); } break; + case -1: + // do nothing + break; default: if (!((MultiplayerManager.CatoSession) event.getSource()).isReady()) { Controllers.dialog(i18n("multiplayer.exit.before_ready", event.getExitCode())); @@ -335,7 +338,7 @@ public class MultiplayerPage extends Control implements DecoratorPage, PageAware private void onCatoIdGenerated(MultiplayerManager.CatoIdEvent event) { runInFX(() -> { token.set(event.getId()); - multiplayerState.set(((MultiplayerManager.CatoSession) event.getSource()).getType()); + setMultiplayerState(((MultiplayerManager.CatoSession) event.getSource()).getType()); }); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerPageSkin.java index d62a9753d..9467be464 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerPageSkin.java @@ -301,7 +301,7 @@ public class MultiplayerPageSkin extends SkinBase { } private static class ClientItem extends StackPane { - ClientItem(MultiplayerServer.CatoClient client) { + ClientItem(MultiplayerChannel.CatoClient client) { BorderPane pane = new BorderPane(); pane.setLeft(new Label(client.getUsername())); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerServer.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerServer.java index 1ffed5d51..035da0d1b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerServer.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerServer.java @@ -21,21 +21,22 @@ import com.google.gson.JsonParseException; import org.jackhuang.hmcl.event.Event; import org.jackhuang.hmcl.event.EventManager; import org.jackhuang.hmcl.util.Lang; -import org.jackhuang.hmcl.util.gson.JsonSubtype; -import org.jackhuang.hmcl.util.gson.JsonType; import org.jackhuang.hmcl.util.gson.JsonUtils; import java.io.*; import java.net.ServerSocket; import java.net.Socket; +import java.util.logging.Level; +import static org.jackhuang.hmcl.ui.multiplayer.MultiplayerChannel.*; import static org.jackhuang.hmcl.util.Logging.LOG; public class MultiplayerServer extends Thread { private ServerSocket socket; private final int gamePort; - private final EventManager onClientAdded = new EventManager(); + private final EventManager onClientAdded = new EventManager<>(); + private final EventManager onKeepAlive = new EventManager<>(); public MultiplayerServer(int gamePort) { this.gamePort = gamePort; @@ -44,15 +45,23 @@ public class MultiplayerServer extends Thread { setDaemon(true); } - public EventManager onClientAdded() { + public EventManager onClientAdded() { return onClientAdded; } + public EventManager onKeepAlive() { + return onKeepAlive; + } + public void startServer() throws IOException { + startServer(0); + } + + public void startServer(int port) throws IOException { if (socket != null) { throw new IllegalStateException("MultiplayerServer already started"); } - socket = new ServerSocket(0); + socket = new ServerSocket(port); start(); } @@ -79,120 +88,42 @@ public class MultiplayerServer extends Thread { } private void handleClient(Socket targetSocket) { + LOG.info("Accepted client " + targetSocket.getRemoteSocketAddress()); try (Socket clientSocket = targetSocket; BufferedReader reader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream())); BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(clientSocket.getOutputStream()))) { String line; while ((line = reader.readLine()) != null) { - Request request = JsonUtils.fromNonNullJson(line, Request.class); - request.process(this, writer); + if (isInterrupted()) { + return; + } + + LOG.fine("Message from client " + targetSocket.getRemoteSocketAddress() + ":" + line); + MultiplayerChannel.Request request = JsonUtils.fromNonNullJson(line, MultiplayerChannel.Request.class); + + if (request instanceof JoinRequest) { + JoinRequest joinRequest = (JoinRequest) request; + LOG.info("Received join request with clientVersion=" + joinRequest.getClientVersion() + ", id=" + joinRequest.getUsername()); + + writer.write(verifyJson(JsonUtils.UGLY_GSON.toJson(new JoinResponse(gamePort)))); + writer.newLine(); + writer.flush(); + + onClientAdded.fireEvent(new CatoClient(this, joinRequest.getUsername())); + } else if (request instanceof KeepAliveRequest) { + writer.write(JsonUtils.UGLY_GSON.toJson(new KeepAliveResponse(System.currentTimeMillis()))); + writer.newLine(); + writer.flush(); + + onKeepAlive.fireEvent(new Event(this)); + } else { + LOG.log(Level.WARNING, "Unrecognized packet from client " + targetSocket.getRemoteSocketAddress() + ":" + line); + } } - } catch (IOException | JsonParseException ignored) { - } - } - - @JsonType( - property = "type", - subtypes = { - @JsonSubtype(clazz = JoinRequest.class, name = "join"), - @JsonSubtype(clazz = KeepAliveRequest.class, name = "keepalive") - } - ) - public static class Request { - - public void process(MultiplayerServer server, BufferedWriter writer) throws IOException, JsonParseException { - } - } - - public static class JoinRequest extends Request { - private final String clientVersion; - private final String username; - - public JoinRequest(String clientVersion, String username) { - this.clientVersion = clientVersion; - this.username = username; - } - - public String getClientVersion() { - return clientVersion; - } - - public String getUsername() { - return username; - } - - @Override - public void process(MultiplayerServer server, BufferedWriter writer) throws IOException, JsonParseException { - LOG.fine("Received join request with clientVersion=" + clientVersion + ", id=" + username); - - writer.write(JsonUtils.GSON.toJson(new JoinResponse(server.gamePort))); - - server.onClientAdded.fireEvent(new CatoClient(server, username)); - } - } - - public static class KeepAliveRequest extends Request { - private final long timestamp; - - public KeepAliveRequest(long timestamp) { - this.timestamp = timestamp; - } - - public long getTimestamp() { - return timestamp; - } - - @Override - public void process(MultiplayerServer server, BufferedWriter writer) throws IOException, JsonParseException { - writer.write(JsonUtils.GSON.toJson(new KeepAliveResponse(System.currentTimeMillis()))); - } - } - - @JsonType( - property = "type", - subtypes = { - @JsonSubtype(clazz = JoinResponse.class, name = "join"), - @JsonSubtype(clazz = KeepAliveResponse.class, name = "keepalive") - } - ) - public static class Response { - - } - - public static class JoinResponse extends Response { - private final int port; - - public JoinResponse(int port) { - this.port = port; - } - - public int getPort() { - return port; - } - } - - public static class KeepAliveResponse extends Response { - private final long timestamp; - - public KeepAliveResponse(long timestamp) { - this.timestamp = timestamp; - } - - public long getTimestamp() { - return timestamp; - } - } - - public static class CatoClient extends Event { - private final String username; - - public CatoClient(Object source, String username) { - super(source); - this.username = username; - } - - public String getUsername() { - return username; + } catch (IOException e) { + LOG.log(Level.WARNING, "Failed to handle client socket.", e); + } catch (JsonParseException e) { + LOG.log(Level.SEVERE, "Failed to parse client request. This should not happen.", e); } } } diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerClientServerTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerClientServerTest.java new file mode 100644 index 000000000..e1d4022f5 --- /dev/null +++ b/HMCL/src/test/java/org/jackhuang/hmcl/ui/multiplayer/MultiplayerClientServerTest.java @@ -0,0 +1,43 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2021 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.multiplayer; + +import org.jackhuang.hmcl.util.Logging; +import org.junit.Ignore; +import org.junit.Test; + +public class MultiplayerClientServerTest { + + @Test + @Ignore + public void startServer() throws Exception { + Logging.initForTest(); + MultiplayerServer server = new MultiplayerServer(1000); + server.startServer(44444); + + MultiplayerClient client = new MultiplayerClient("username", 44444); + client.start(); + + server.onKeepAlive().register(event -> { + client.interrupt(); + server.interrupt(); + }); + + server.join(); + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/gson/JsonUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/gson/JsonUtils.java index a40d08675..cf0516ad8 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/gson/JsonUtils.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/gson/JsonUtils.java @@ -35,6 +35,12 @@ public final class JsonUtils { public static final Gson GSON = defaultGsonBuilder().create(); + public static final Gson UGLY_GSON = new GsonBuilder() + .registerTypeAdapterFactory(JsonTypeAdapterFactory.INSTANCE) + .registerTypeAdapterFactory(ValidationTypeAdapterFactory.INSTANCE) + .registerTypeAdapterFactory(LowerCaseEnumTypeAdapterFactory.INSTANCE) + .create(); + private JsonUtils() { }