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 c5b9ee01e..34d0a1dff 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 @@ -17,9 +17,88 @@ */ package org.jackhuang.hmcl.ui.multiplayer; -public class MultiplayerClient { +import com.google.gson.JsonParseException; +import org.jackhuang.hmcl.event.Event; +import org.jackhuang.hmcl.event.EventManager; +import org.jackhuang.hmcl.util.gson.JsonUtils; - public MultiplayerClient() { +import java.io.*; +import java.net.InetAddress; +import java.net.Socket; +import static org.jackhuang.hmcl.util.Logging.LOG; + +public class MultiplayerClient extends Thread { + private final String id; + private final int port; + + private final EventManager onConnected = new EventManager<>(); + private final EventManager onDisconnected = new EventManager<>(); + + public MultiplayerClient(String id, int port) { + this.id = id; + this.port = port; + + setName("MultiplayerClient"); + setDaemon(true); + } + + public EventManager onConnected() { + return onConnected; + } + + public EventManager onDisconnected() { + return onDisconnected; + } + + @Override + public void run() { + try (Socket socket = new Socket(InetAddress.getLoopbackAddress(), port); + BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream())); + BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()))) { + + writer.write(JsonUtils.GSON.toJson(new MultiplayerServer.JoinRequest(MultiplayerManager.CATO_VERSION, id))); + writer.write("\n"); + + LOG.fine("Send join request with id=" + id); + + String line = reader.readLine(); + if (line == null) { + return; + } + + MultiplayerServer.JoinResponse response = JsonUtils.fromNonNullJson(line, MultiplayerServer.JoinResponse.class); + 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) { + } + } + + } catch (IOException | JsonParseException e) { + e.printStackTrace(); + } finally { + onDisconnected.fireEvent(new Event(this)); + } + } + + public static class ConnectedEvent extends Event { + private final int port; + + public ConnectedEvent(Object source, int port) { + super(source); + this.port = port; + } + + public int getPort() { + return port; + } } } 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 040d66bac..c82b2e242 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 @@ -34,7 +34,10 @@ import org.jackhuang.hmcl.util.platform.ManagedProcess; import org.jackhuang.hmcl.util.platform.OperatingSystem; import org.jetbrains.annotations.Nullable; +import java.io.BufferedWriter; import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.UncheckedIOException; import java.net.ServerSocket; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -44,6 +47,8 @@ import java.util.Arrays; import java.util.Base64; import java.util.List; import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Level; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -54,7 +59,7 @@ import static org.jackhuang.hmcl.util.Logging.LOG; */ public final class MultiplayerManager { private static final String CATO_DOWNLOAD_URL = "https://files.huangyuhui.net/maven/"; - private static final String CATO_VERSION = "1.0.8"; + static final String CATO_VERSION = "1.0.8"; private static final String CATO_PATH = getCatoPath(); private MultiplayerManager() { @@ -77,7 +82,7 @@ public final class MultiplayerManager { return Metadata.HMCL_DIRECTORY.resolve("libraries").resolve(CATO_PATH); } - public static CatoSession joinSession(String token, String version, String sessionName, String peer, int remotePort, int localPort) throws IOException, IncompatibleCatoVersionException { + public static CompletableFuture joinSession(String token, String version, String sessionName, String peer, int remotePort, int localPort) throws IncompatibleCatoVersionException { if (!CATO_VERSION.equals(version)) { throw new IncompatibleCatoVersionException(version, CATO_VERSION); } @@ -86,19 +91,56 @@ public final class MultiplayerManager { if (!Files.isRegularFile(exe)) { throw new IllegalStateException("Cato file not found"); } - String[] commands = new String[]{exe.toString(), - "--token", StringUtils.isBlank(token) ? "new" : token, - "--peer", peer, - "--local", String.format("127.0.0.1:%d", localPort), - "--remote", String.format("127.0.0.1:%d", remotePort), - "--mode", "relay"}; - Process process = new ProcessBuilder() - .command(commands) - .start(); - CatoSession session = new CatoSession(sessionName, State.SLAVE, process, Arrays.asList(commands)); - session.addRelatedThread(Lang.thread(new LocalServerBroadcaster(localPort, session), "LocalServerBroadcaster", true)); - return session; + return CompletableFuture.completedFuture(null).thenComposeAsync(unused -> { + String[] commands = new String[]{exe.toString(), + "--token", StringUtils.isBlank(token) ? "new" : token, + "--id", peer, + "--local", String.format("127.0.0.1:%d", localPort), + "--remote", String.format("127.0.0.1:%d", remotePort), + "--mode", "relay"}; + Process process; + try { + process = new ProcessBuilder() + .command(commands) + .start(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + + CatoSession session = new CatoSession(sessionName, State.SLAVE, process, Arrays.asList(commands)); + session.addRelatedThread(Lang.thread(new LocalServerBroadcaster(localPort, session), "LocalServerBroadcaster", true)); + + CompletableFuture future = new CompletableFuture<>(); + + BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(process.getOutputStream())); + + session.onExit().register(() -> { + try { + writer.close(); + } catch (IOException e) { + LOG.log(Level.WARNING, "Failed to close cato session stdin writer", e); + } + }); + + session.onPeerConnected.register(event -> { + MultiplayerClient client = new MultiplayerClient(session.getId(), localPort); + session.addRelatedThread(client); + session.setClient(client); + client.onConnected().register(connectedEvent -> { + try { + int port = findAvailablePort(); + writer.write(String.format("net add %s 127.0.0.1:%d 127.0.0.1:%d p2p\n", peer, port, connectedEvent.getPort())); + future.complete(session); + } catch (IOException e) { + future.completeExceptionally(e); + } + }); + client.start(); + }); + + return future; + }); } public static CatoSession createSession(String token, String sessionName, int port) throws IOException { @@ -106,19 +148,22 @@ public final class MultiplayerManager { if (!Files.isRegularFile(exe)) { throw new IllegalStateException("Cato file not found"); } -// -// MultiplayerServer server = new MultiplayerServer(port); -// server.start(); + + MultiplayerServer server = new MultiplayerServer(port); + server.startServer(); String[] commands = new String[]{exe.toString(), "--token", StringUtils.isBlank(token) ? "new" : token, - "--allows", String.format("127.0.0.1:%d", port), + "--allows", String.format("127.0.0.1:%d,127.0.0.1:%d", port, server.getPort()), "--mode", "relay"}; Process process = new ProcessBuilder() .command(commands) .start(); - return new CatoSession(sessionName, State.MASTER, process, Arrays.asList(commands)); + CatoSession session = new CatoSession(sessionName, State.MASTER, process, Arrays.asList(commands)); + session.setServer(server); + session.addRelatedThread(server); + return session; } public static Invitation parseInvitationCode(String invitationCode) throws JsonParseException { @@ -167,10 +212,14 @@ public final class MultiplayerManager { private final String name; private final State type; private String id; + private MultiplayerClient client; + private MultiplayerServer server; CatoSession(String name, State type, Process process, List commands) { super(process, commands); + Runtime.getRuntime().addShutdownHook(Lang.thread(this::stop)); + LOG.info("Started cato with command: " + new CommandBuilder().addAll(commands).toString()); this.name = name; @@ -180,6 +229,24 @@ public final class MultiplayerManager { addRelatedThread(Lang.thread(new StreamPump(process.getErrorStream(), this::checkCatoLog), "CatoErrorStreamPump", true)); } + public MultiplayerClient getClient() { + return client; + } + + public CatoSession setClient(MultiplayerClient client) { + this.client = client; + return this; + } + + public MultiplayerServer getServer() { + return server; + } + + public CatoSession setServer(MultiplayerServer server) { + this.server = server; + return this; + } + private void checkCatoLog(String log) { LOG.info("Cato: " + log); if (id == null) { @@ -206,6 +273,7 @@ public final class MultiplayerManager { } catch (InterruptedException e) { onExit.fireEvent(new CatoExitEvent(this, CatoExitEvent.EXIT_CODE_INTERRUPTED)); } + destroyRelatedThreads(); } public boolean isReady() { 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 a9752a7c1..8cb41855e 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 @@ -19,6 +19,7 @@ package org.jackhuang.hmcl.ui.multiplayer; import de.javawi.jstun.test.DiscoveryInfo; import de.javawi.jstun.test.DiscoveryTest; +import javafx.application.Platform; import javafx.beans.property.*; import javafx.scene.control.Control; import javafx.scene.control.Skin; @@ -210,21 +211,31 @@ public class MultiplayerPage extends Control implements DecoratorPage, PageAware } try { - initCatoSession(MultiplayerManager.joinSession(token, invitation.getVersion(), invitation.getSessionName(), invitation.getId(), invitation.getGamePort(), localPort)); + MultiplayerManager.joinSession(token, invitation.getVersion(), invitation.getSessionName(), invitation.getId(), invitation.getGamePort(), localPort) + .thenAcceptAsync(session -> { + initCatoSession(session); + + session.getClient().onDisconnected().register(() -> { + runInFX(() -> { + stopCatoSession(); + Controllers.dialog(i18n("multiplayer.session.join.lost_connection")); + }); + }); + + port.set(localPort); + setMultiplayerState(MultiplayerManager.State.CONNECTING); + resolve.run(); + }, Platform::runLater).exceptionally(throwable -> { + LOG.log(Level.WARNING, "Failed to join sessoin"); + reject.accept(i18n("multiplayer.session.error")); + return null; + }); } catch (MultiplayerManager.IncompatibleCatoVersionException e) { reject.accept(i18n("multiplayer.session.join.invitation_code.version")); - return; - } catch (Exception e) { - reject.accept(i18n("multiplayer.session.error")); - return; } - - port.set(localPort); - setMultiplayerState(MultiplayerManager.State.CONNECTING); - resolve.run(); }) .addQuestion(new PromptDialogPane.Builder.HintQuestion(i18n("multiplayer.session.join.hint"))) - .addQuestion(new PromptDialogPane.Builder.StringQuestion(i18n("multiplayer.session.create.token"), "")) + .addQuestion(new PromptDialogPane.Builder.StringQuestion(i18n("multiplayer.session.create.token"), "").setPromptText(i18n("multiplayer.session.create.token.prompt"))) .addQuestion(new PromptDialogPane.Builder.StringQuestion(i18n("multiplayer.session.join.invitation_code"), "", new RequiredValidator()))); } 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 1499f6b09..b841ab532 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 @@ -27,21 +27,26 @@ import java.io.*; import java.net.ServerSocket; import java.net.Socket; -public class MultiplayerServer { +import static org.jackhuang.hmcl.util.Logging.LOG; + +public class MultiplayerServer extends Thread { private ServerSocket socket; private final int gamePort; public MultiplayerServer(int gamePort) { this.gamePort = gamePort; + + setName("MultiplayerServer"); + setDaemon(true); } - public void start() throws IOException { + public void startServer() throws IOException { if (socket != null) { throw new IllegalStateException("MultiplayerServer already started"); } socket = new ServerSocket(0); - Lang.thread(this::run, "MultiplayerServer", true); + start(); } public int getPort() { @@ -52,10 +57,12 @@ public class MultiplayerServer { return socket.getLocalPort(); } - private void run() { + @Override + public void run() { try { - while (true) { + while (!isInterrupted()) { Socket clientSocket = socket.accept(); + clientSocket.setSoTimeout(10000); Lang.thread(() -> handleClient(clientSocket), "MultiplayerServerClientThread", true); } } catch (IOException ignored) { @@ -78,7 +85,8 @@ public class MultiplayerServer { @JsonType( property = "type", subtypes = { - @JsonSubtype(clazz = JoinRequest.class, name = "join") + @JsonSubtype(clazz = JoinRequest.class, name = "join"), + @JsonSubtype(clazz = KeepAliveRequest.class, name = "keepalive") } ) public static class Request { @@ -106,14 +114,34 @@ public class MultiplayerServer { @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))); } } + 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 = JoinResponse.class, name = "join"), + @JsonSubtype(clazz = KeepAliveResponse.class, name = "keepalive") } ) public static class Response { @@ -131,4 +159,16 @@ public class MultiplayerServer { return port; } } + + public static class KeepAliveResponse extends Response { + private final long timestamp; + + public KeepAliveResponse(long timestamp) { + this.timestamp = timestamp; + } + + public long getTimestamp() { + return timestamp; + } + } } diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 1f776fddc..b2aee909c 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -617,6 +617,9 @@ multiplayer.session.copy_room_code=Copy Invitation Code multiplayer.session.create=Create Room multiplayer.session.create.error=Failed to create multiplayer room. multiplayer.session.create.hint=Before creating multiplayer room, you must click "Open LAN Server" in running game, and type the port displayed in game in the blank below. +multiplayer.session.create.members=Members +multiplayer.session.create.members.kick=Kick +multiplayer.session.create.members.kick.prompt=After kicking this player, the established connection with this player will be cut up. multiplayer.session.create.name=Session Name multiplayer.session.create.port=Port multiplayer.session.create.port.error=Cannot detect game port, you must click "Open LAN Server" in game to enable multiplayer functionality. @@ -629,6 +632,7 @@ multiplayer.session.join.hint=You must obtain the invitation code from the gamer multiplayer.session.join.invitation_code=Invitation code multiplayer.session.join.invitation_code.error=Incorrect invitation code. Please obtain invitation code from the player who creates the multiplayer room. multiplayer.session.join.invitation_code.version=Versions of multiplayer functionalities are not the same among you. +multiplayer.session.join.lost_connection=Lost connection with the multiplayer session. Maybe the session is destroyed by the creator, or you cannot establish connection with this session. multiplayer.session.join.port.error=Cannot find available local network port for listening. Please ensure that HMCL has the permission to listen on a port. multiplayer.session.members=Room Members multiplayer.session.quit=Quit Room diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 3d3bb3506..2b2b2a647 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -617,6 +617,9 @@ multiplayer.session.copy_room_code=複製邀請碼 multiplayer.session.create=創建房間 multiplayer.session.create.error=創建聯機房間失敗。 multiplayer.session.create.hint=創建聯機房間前,你需要先在正在運行的遊戲內的遊戲菜單中選擇 對區域網路開放 選項,然後在下方的輸入框中輸入遊戲內提示的埠號(通常是 5 位的數字) +multiplayer.session.create.members=成員 +multiplayer.session.create.members.kick=踢出房間 +multiplayer.session.create.members.kick.prompt=是否踢出該玩家?踢出後該玩家不能再參與該聯機房間。 multiplayer.session.create.name=房間名稱 multiplayer.session.create.port=埠號 multiplayer.session.create.port.error=無法檢測遊戲埠號,你必須先啟動遊戲並在遊戲內打開對區域網路開放選項後才能啟動聯機。 @@ -628,6 +631,7 @@ multiplayer.session.join.hint=你需要向已經創建好房間的玩家索要 multiplayer.session.join.invitation_code=邀請碼 multiplayer.session.join.invitation_code.error=邀請碼不正確,請向開服玩家獲取邀請碼 multiplayer.session.join.invitation_code.version=多人聯機功能版本號不一致,請保證連接多人聯機功能版本號一致。 +multiplayer.session.join.lost_connection=你已與房間失去連接。這可能意味著房主已經解散房間,或者你無法連接至房間。 multiplayer.session.join.port.error=無法找到可用的本地網路埠,請確保 HMCL 擁有綁定本地埠的權限。 multiplayer.session.members=房間成員 multiplayer.session.quit=退出房間 diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index 6de795655..d28b24616 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -617,6 +617,9 @@ multiplayer.session.copy_room_code=复制邀请码 multiplayer.session.create=创建房间 multiplayer.session.create.error=创建联机房间失败。 multiplayer.session.create.hint=创建联机房间前,你需要先在正在运行的游戏内的游戏菜单中选择 对局域网开放 选项,然后在下方的输入框中确认游戏内提示的端口号(通常是 5 位的数字) +multiplayer.session.create.members=成员 +multiplayer.session.create.members.kick=踢出房间 +multiplayer.session.create.members.kick.prompt=是否踢出该玩家?踢出后该玩家不能再参与该联机房间。 multiplayer.session.create.name=房间名称 multiplayer.session.create.port=端口号 multiplayer.session.create.port.error=无法检测游戏端口号,你必须先启动游戏并在游戏内打开对局域网开放选项后才能启动联机。 @@ -628,6 +631,7 @@ multiplayer.session.join.hint=你需要向已经创建好房间的玩家索要 multiplayer.session.join.invitation_code=邀请码 multiplayer.session.join.invitation_code.error=邀请码不正确,请向开服玩家获取邀请码 multiplayer.session.join.invitation_code.version=多人联机功能版本号不一致,请保证连接多人联机功能版本号一致。 +multiplayer.session.join.lost_connection=你已与房间失去连接。这可能意味着房主已经解散房间,或者你无法连接至房间。 multiplayer.session.join.port.error=无法找到可用的本地网络端口,请确保 HMCL 拥有绑定本地端口的权限。 multiplayer.session.members=房间成员 multiplayer.session.quit=退出房间 diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ManagedProcess.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ManagedProcess.java index ad3207307..d3ee492fd 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ManagedProcess.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ManagedProcess.java @@ -119,6 +119,10 @@ public class ManagedProcess { */ public void stop() { process.destroy(); + destroyRelatedThreads(); + } + + public void destroyRelatedThreads() { relatedThreads.forEach(Thread::interrupt); }