From 54954f276bffd018ddec0a432f91f5ee4eba747b Mon Sep 17 00:00:00 2001 From: huanghongxun Date: Sun, 26 Sep 2021 00:02:48 +0800 Subject: [PATCH] feat(multiplayer): detect game port when creating multiplayer room. Update cato to 1.0.8. --- .../hmcl/ui/construct/DialogAware.java | 3 + .../hmcl/ui/construct/DialogPane.java | 118 +++++++++++++++ .../hmcl/ui/construct/Navigator.java | 7 +- .../hmcl/ui/construct/PageAware.java | 26 ++++ .../hmcl/ui/construct/PromptDialogPane.java | 88 +++++------ .../ui/decorator/DecoratorController.java | 4 + .../CreateMultiplayerRoomDialog.java | 119 +++++++++++++++ .../multiplayer/LocalServerBroadcaster.java | 10 +- .../ui/multiplayer/LocalServerDetector.java | 141 ++++++++++++++++++ .../ui/multiplayer/MultiplayerManager.java | 43 +++++- .../hmcl/ui/multiplayer/MultiplayerPage.java | 30 ++-- .../jackhuang/hmcl/ui/versions/Versions.java | 3 +- .../resources/assets/lang/I18N.properties | 3 +- .../resources/assets/lang/I18N_zh.properties | 3 +- .../assets/lang/I18N_zh_CN.properties | 3 +- 15 files changed, 523 insertions(+), 78 deletions(-) create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/DialogPane.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/PageAware.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/CreateMultiplayerRoomDialog.java create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/LocalServerDetector.java diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/DialogAware.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/DialogAware.java index f04cf5a52..4ab176c29 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/DialogAware.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/DialogAware.java @@ -28,4 +28,7 @@ public interface DialogAware { default void onDialogShown() { } + default void onDialogClosed() { + } + } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/DialogPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/DialogPane.java new file mode 100644 index 000000000..2dac53f36 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/DialogPane.java @@ -0,0 +1,118 @@ +/* + * 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.construct; + +import com.jfoenix.controls.JFXButton; +import com.jfoenix.controls.JFXDialogLayout; +import com.jfoenix.controls.JFXProgressBar; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Label; +import javafx.scene.layout.StackPane; + +import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; + +public class DialogPane extends JFXDialogLayout { + private final StringProperty title = new SimpleStringProperty(); + private final BooleanProperty valid = new SimpleBooleanProperty(); + private final SpinnerPane acceptPane = new SpinnerPane(); + private final Label warningLabel = new Label(); + private final JFXProgressBar progressBar = new JFXProgressBar(); + + public DialogPane() { + Label titleLabel = new Label(); + titleLabel.textProperty().bind(title); + setHeading(titleLabel); + getChildren().add(progressBar); + + progressBar.setVisible(false); + StackPane.setMargin(progressBar, new Insets(-24.0D, -24.0D, -16.0D, -24.0D)); + StackPane.setAlignment(progressBar, Pos.TOP_CENTER); + progressBar.setMaxWidth(Double.MAX_VALUE); + + JFXButton acceptButton = new JFXButton(i18n("button.ok")); + acceptButton.setOnAction(e -> onAccept()); + acceptButton.disableProperty().bind(valid.not()); + acceptButton.getStyleClass().add("dialog-accept"); + acceptPane.getStyleClass().add("small-spinner-pane"); + acceptPane.setContent(acceptButton); + + JFXButton cancelButton = new JFXButton(i18n("button.cancel")); + cancelButton.setOnAction(e -> onCancel()); + cancelButton.getStyleClass().add("dialog-cancel"); + onEscPressed(this, cancelButton::fire); + + setActions(warningLabel, acceptPane, cancelButton); + } + + protected JFXProgressBar getProgressBar() { + return progressBar; + } + + public String getTitle() { + return title.get(); + } + + public StringProperty titleProperty() { + return title; + } + + public void setTitle(String title) { + this.title.set(title); + } + + public boolean isValid() { + return valid.get(); + } + + public BooleanProperty validProperty() { + return valid; + } + + public void setValid(boolean valid) { + this.valid.set(valid); + } + + protected void onCancel() { + fireEvent(new DialogCloseEvent()); + } + + protected void onAccept() { + fireEvent(new DialogCloseEvent()); + } + + protected void setLoading() { + acceptPane.showSpinner(); + warningLabel.setText(""); + } + + protected void onSuccess() { + acceptPane.hideSpinner(); + fireEvent(new DialogCloseEvent()); + } + + protected void onFailure(String msg) { + acceptPane.hideSpinner(); + warningLabel.setText(msg); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/Navigator.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/Navigator.java index d2c9a20ba..f1de4f579 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/Navigator.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/Navigator.java @@ -50,6 +50,7 @@ public class Navigator extends TransitionPane { getChildren().setAll(init); fireEvent(new NavigationEvent(this, init, Navigation.NavigationDirection.START, NavigationEvent.NAVIGATED)); + if (init instanceof PageAware) ((PageAware) init).onPageShown(); initialized = true; } @@ -78,6 +79,7 @@ public class Navigator extends TransitionPane { NavigationEvent navigated = new NavigationEvent(this, node, Navigation.NavigationDirection.NEXT, NavigationEvent.NAVIGATED); node.fireEvent(navigated); + if (node instanceof PageAware) ((PageAware) node).onPageShown(); EventHandler handler = event -> close(node); node.getProperties().put(PROPERTY_DIALOG_CLOSE_HANDLER, handler); @@ -108,7 +110,9 @@ public class Navigator extends TransitionPane { Logging.LOG.info("Closed page " + from); - stack.pop(); + Node poppedNode = stack.pop(); + if (poppedNode instanceof PageAware) ((PageAware) poppedNode).onPageHidden(); + backable.set(canGoBack()); Node node = stack.peek(); @@ -202,6 +206,7 @@ public class Navigator extends TransitionPane { }; public static class NavigationEvent extends Event { + public static final EventType EXITED = new EventType<>("EXITED"); public static final EventType NAVIGATED = new EventType<>("NAVIGATED"); public static final EventType NAVIGATING = new EventType<>("NAVIGATING"); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/PageAware.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/PageAware.java new file mode 100644 index 000000000..a0d2a7a7f --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/PageAware.java @@ -0,0 +1,26 @@ +/* + * 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.construct; + +public interface PageAware { + default void onPageShown() { + } + + default void onPageHidden() { + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/PromptDialogPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/PromptDialogPane.java index 1a3758d89..c9913f6c8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/PromptDialogPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/PromptDialogPane.java @@ -17,52 +17,44 @@ */ package org.jackhuang.hmcl.ui.construct; -import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXCheckBox; import com.jfoenix.controls.JFXComboBox; import com.jfoenix.controls.JFXTextField; import com.jfoenix.validation.base.ValidatorBase; import javafx.beans.binding.Bindings; import javafx.beans.binding.BooleanBinding; -import javafx.fxml.FXML; import javafx.geometry.Insets; import javafx.scene.control.Label; +import javafx.scene.layout.ColumnConstraints; +import javafx.scene.layout.GridPane; import javafx.scene.layout.HBox; -import javafx.scene.layout.StackPane; -import javafx.scene.layout.VBox; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.util.FutureCallback; import org.jackhuang.hmcl.util.StringUtils; -import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; -import static org.jackhuang.hmcl.ui.FXUtils.runInFX; - import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.concurrent.CompletableFuture; -public class PromptDialogPane extends StackPane { +import static org.jackhuang.hmcl.ui.FXUtils.runInFX; + +public class PromptDialogPane extends DialogPane { private final CompletableFuture>> future = new CompletableFuture<>(); - @FXML - private JFXButton acceptButton; - @FXML - private JFXButton cancelButton; - @FXML - private VBox vbox; - @FXML - private Label title; - @FXML - private Label lblCreationWarning; - @FXML - private SpinnerPane acceptPane; + private final Builder builder; public PromptDialogPane(Builder builder) { - FXUtils.loadFXML(this, "/assets/fxml/input-dialog.fxml"); - this.title.setText(builder.title); + this.builder = builder; + setTitle(builder.title); + GridPane body = new GridPane(); + body.setVgap(8); + body.setHgap(16); + body.getColumnConstraints().setAll(new ColumnConstraints(), FXUtils.getColumnHgrowing()); + setBody(body); List bindings = new ArrayList<>(); + int rowIndex = 0; for (Builder.Question question : builder.questions) { if (question instanceof Builder.StringQuestion) { Builder.StringQuestion stringQuestion = (Builder.StringQuestion) question; @@ -76,62 +68,58 @@ public class PromptDialogPane extends StackPane { bindings.add(Bindings.createBooleanBinding(textField::validate, textField.textProperty())); if (StringUtils.isNotBlank(question.question)) { - vbox.getChildren().add(new Label(question.question)); + body.addRow(rowIndex++, new Label(question.question), textField); + } else { + GridPane.setColumnSpan(textField, 2); + body.addRow(rowIndex++, textField); } - VBox.setMargin(textField, new Insets(0, 0, 20, 0)); - vbox.getChildren().add(textField); + GridPane.setMargin(textField, new Insets(0, 0, 20, 0)); } else if (question instanceof Builder.BooleanQuestion) { HBox hBox = new HBox(); + GridPane.setColumnSpan(hBox, 2); JFXCheckBox checkBox = new JFXCheckBox(); hBox.getChildren().setAll(checkBox); HBox.setMargin(checkBox, new Insets(0, 0, 0, -10)); checkBox.setSelected(((Builder.BooleanQuestion) question).value); checkBox.selectedProperty().addListener((a, b, newValue) -> ((Builder.BooleanQuestion) question).value = newValue); checkBox.setText(question.question); - vbox.getChildren().add(hBox); + body.addRow(rowIndex++, hBox); } else if (question instanceof Builder.CandidatesQuestion) { - HBox hBox = new HBox(); JFXComboBox comboBox = new JFXComboBox<>(); - hBox.getChildren().setAll(comboBox); comboBox.getItems().setAll(((Builder.CandidatesQuestion) question).candidates); comboBox.getSelectionModel().selectedIndexProperty().addListener((a, b, newValue) -> ((Builder.CandidatesQuestion) question).value = newValue.intValue()); comboBox.getSelectionModel().select(0); if (StringUtils.isNotBlank(question.question)) { - vbox.getChildren().add(new Label(question.question)); + body.addRow(rowIndex++, new Label(question.question), comboBox); + } else { + GridPane.setColumnSpan(comboBox, 2); + body.addRow(rowIndex++, comboBox); } - vbox.getChildren().add(hBox); } else if (question instanceof Builder.HintQuestion) { HintPane pane = new HintPane(); + GridPane.setColumnSpan(pane, 2); pane.setText(question.question); - vbox.getChildren().add(pane); + body.addRow(rowIndex++, pane); } } - cancelButton.setOnMouseClicked(e -> fireEvent(new DialogCloseEvent())); - acceptButton.disableProperty().bind(Bindings.createBooleanBinding( - () -> bindings.stream().map(BooleanBinding::get).anyMatch(x -> !x), + validProperty().bind(Bindings.createBooleanBinding( + () -> bindings.stream().allMatch(BooleanBinding::get), bindings.toArray(new BooleanBinding[0]) )); + } - acceptButton.setOnMouseClicked(e -> { - acceptPane.showSpinner(); + @Override + protected void onAccept() { + setLoading(); - builder.callback.call(builder.questions, () -> { - future.complete(builder.questions); - runInFX(() -> { - acceptPane.hideSpinner(); - fireEvent(new DialogCloseEvent()); - }); - }, msg -> { - runInFX(() -> { - acceptPane.hideSpinner(); - lblCreationWarning.setText(msg); - }); - }); + builder.callback.call(builder.questions, () -> { + future.complete(builder.questions); + runInFX(this::onSuccess); + }, msg -> { + runInFX(() -> onFailure(msg)); }); - - onEscPressed(this, cancelButton::fire); } public CompletableFuture>> getCompletableFuture() { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/DecoratorController.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/DecoratorController.java index d31ca4ba6..872a2e210 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/DecoratorController.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/DecoratorController.java @@ -360,6 +360,10 @@ public class DecoratorController { if (dialog != null) { dialogPane.pop(node); + if (node instanceof DialogAware) { + ((DialogAware) node).onDialogClosed(); + } + if (dialogPane.getChildren().isEmpty()) { dialog.close(); dialog = null; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/CreateMultiplayerRoomDialog.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/CreateMultiplayerRoomDialog.java new file mode 100644 index 000000000..7366b8323 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/CreateMultiplayerRoomDialog.java @@ -0,0 +1,119 @@ +/* + * 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 javafx.scene.control.Label; +import javafx.scene.control.ProgressIndicator; +import javafx.scene.layout.ColumnConstraints; +import javafx.scene.layout.GridPane; +import org.jackhuang.hmcl.auth.Account; +import org.jackhuang.hmcl.setting.Accounts; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.ui.construct.DialogAware; +import org.jackhuang.hmcl.ui.construct.DialogPane; +import org.jackhuang.hmcl.ui.construct.HintPane; +import org.jackhuang.hmcl.ui.construct.MessageDialogPane; +import org.jackhuang.hmcl.util.FutureCallback; + +import java.util.Objects; +import java.util.Optional; + +import static org.jackhuang.hmcl.ui.FXUtils.runInFX; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; + +public class CreateMultiplayerRoomDialog extends DialogPane implements DialogAware { + + private final FutureCallback callback; + private final LocalServerDetector lanServerDetectorThread; + + private LocalServerDetector.PingResponse server; + + CreateMultiplayerRoomDialog(FutureCallback callback) { + this.callback = callback; + + setTitle(i18n("multiplayer.session.create")); + + GridPane body = new GridPane(); + body.setMaxWidth(500); + body.getColumnConstraints().addAll(new ColumnConstraints(), FXUtils.getColumnHgrowing()); + body.setVgap(8); + body.setHgap(16); + body.setDisable(true); + setBody(body); + + HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO); + hintPane.setText(i18n("multiplayer.session.create.hint")); + GridPane.setColumnSpan(hintPane, 2); + + body.addRow(0, hintPane); + + Label nameField = new Label(); + nameField.setText(Optional.ofNullable(Accounts.getSelectedAccount()) + .map(Account::getUsername) + .map(username -> i18n("multiplayer.session.name.format", username)) + .orElse("")); + body.addRow(1, new Label(i18n("multiplayer.session.create.name")), nameField); + + Label portLabel = new Label(i18n("multiplayer.nat.testing")); + portLabel.setText(i18n("multiplayer.nat.testing")); + body.addRow(2, new Label(i18n("multiplayer.session.create.port")), portLabel); + + setValid(false); + + lanServerDetectorThread = new LocalServerDetector(3); + lanServerDetectorThread.onDetectedLanServer().register(event -> { + runInFX(() -> { + if (event.getLanServer().isValid()) { + nameField.setText(event.getLanServer().getMotd()); + portLabel.setText(event.getLanServer().getAd().toString()); + setValid(true); + } else { + nameField.setText(""); + portLabel.setText(""); + onFailure(i18n("multiplayer.session.create.port.error")); + } + server = event.getLanServer(); + body.setDisable(false); + getProgressBar().setVisible(false); + }); + }); + } + + @Override + protected void onAccept() { + setLoading(); + + callback.call(Objects.requireNonNull(server), () -> { + runInFX(this::onSuccess); + }, msg -> { + runInFX(() -> onFailure(msg)); + }); + } + + @Override + public void onDialogShown() { + getProgressBar().setVisible(true); + getProgressBar().setProgress(ProgressIndicator.INDETERMINATE_PROGRESS); + lanServerDetectorThread.start(); + } + + @Override + public void onDialogClosed() { + lanServerDetectorThread.interrupt(); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/LocalServerBroadcaster.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/LocalServerBroadcaster.java index 8a7c2bd72..9323c5e88 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/LocalServerBroadcaster.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/LocalServerBroadcaster.java @@ -21,7 +21,6 @@ import java.io.IOException; import java.net.DatagramPacket; import java.net.DatagramSocket; import java.net.InetAddress; -import java.net.SocketException; import java.nio.charset.StandardCharsets; import java.util.logging.Level; @@ -44,9 +43,11 @@ public class LocalServerBroadcaster implements Runnable { @Override public void run() { DatagramSocket socket; + InetAddress broadcastAddress; try { socket = new DatagramSocket(); - } catch (SocketException e) { + broadcastAddress = InetAddress.getByName("224.0.2.60"); + } catch (IOException e) { LOG.log(Level.WARNING, "Failed to create datagram socket", e); return; } @@ -54,8 +55,9 @@ public class LocalServerBroadcaster implements Runnable { while (session.isRunning()) { try { byte[] data = String.format("[MOTD]%s[/MOTD][AD]%d[/AD]", i18n("multiplayer.session.name.motd", session.getName()), port).getBytes(StandardCharsets.UTF_8); - DatagramPacket packet = new DatagramPacket(data, 0, data.length, InetAddress.getByName("224.0.2.60"), 4445); + DatagramPacket packet = new DatagramPacket(data, 0, data.length, broadcastAddress, 4445); socket.send(packet); + LOG.fine("Broadcast server 127.0.0.1:" + port); } catch (IOException e) { LOG.log(Level.WARNING, "Failed to send motd packet", e); } @@ -66,5 +68,7 @@ public class LocalServerBroadcaster implements Runnable { return; } } + + socket.close(); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/LocalServerDetector.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/LocalServerDetector.java new file mode 100644 index 000000000..8006e5c39 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/multiplayer/LocalServerDetector.java @@ -0,0 +1,141 @@ +/* + * 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.event.EventManager; +import org.jackhuang.hmcl.util.Lang; +import org.jackhuang.hmcl.util.StringUtils; + +import java.io.IOException; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.util.logging.Level; + +import static org.jackhuang.hmcl.util.Logging.LOG; + +public class LocalServerDetector extends Thread { + + private final EventManager onDetectedLanServer = new EventManager<>(); + private final int retry; + + public LocalServerDetector(int retry) { + this.retry = retry; + + setName("LocalServerDetector"); + setDaemon(true); + } + + public EventManager onDetectedLanServer() { + return onDetectedLanServer; + } + + @Override + public void run() { + MulticastSocket socket; + InetAddress broadcastAddress; + try { + socket = new MulticastSocket(4445); + socket.setSoTimeout(5000); + socket.joinGroup(broadcastAddress = InetAddress.getByName("224.0.2.60")); + } catch (IOException e) { + LOG.log(Level.WARNING, "Failed to create datagram socket", e); + return; + } + + byte[] buf = new byte[1024]; + + int tried = 0; + while (!isInterrupted()) { + DatagramPacket packet = new DatagramPacket(buf, 1024); + + try { + socket.receive(packet); + } catch (SocketTimeoutException e) { + if (tried++ > retry) { + onDetectedLanServer.fireEvent(new DetectedLanServerEvent(this, null)); + break; + } + + continue; + } catch (IOException e) { + LOG.log(Level.WARNING, "Failed to detect lan server", e); + break; + } + + String response = new String(packet.getData(), packet.getOffset(), packet.getLength(), StandardCharsets.UTF_8); + LOG.fine("Local server broadcast message: " + response); + onDetectedLanServer.fireEvent(new DetectedLanServerEvent(this, PingResponse.parsePingResponse(response))); + break; + } + + try { + socket.leaveGroup(broadcastAddress); + } catch (IOException e) { + LOG.log(Level.WARNING, "Failed to leave multicast listening group", e); + } + + socket.close(); + } + + public static class DetectedLanServerEvent extends Event { + private final PingResponse lanServer; + + public DetectedLanServerEvent(Object source, PingResponse lanServer) { + super(source); + this.lanServer = lanServer; + } + + public PingResponse getLanServer() { + return lanServer; + } + } + + public static class PingResponse { + private final String motd; + private final Integer ad; + + public PingResponse(String motd, Integer ad) { + this.motd = motd; + this.ad = ad; + } + + public String getMotd() { + return motd; + } + + public Integer getAd() { + return ad; + } + + public boolean isValid() { + return ad != null; + } + + public static PingResponse parsePingResponse(String message) { + return new PingResponse( + StringUtils.substringBefore( + StringUtils.substringAfter(message, "[MOTD]"), + "[/MOTD]"), + Lang.toIntOrNull(StringUtils.substringBefore( + StringUtils.substringAfter(message, "[AD]"), + "[/AD]")) + ); + } + } +} 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 012276d20..213fc9784 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 @@ -21,7 +21,6 @@ import com.google.gson.JsonParseException; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.event.Event; import org.jackhuang.hmcl.event.EventManager; -import org.jackhuang.hmcl.game.Artifact; import org.jackhuang.hmcl.launch.StreamPump; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.Task; @@ -40,7 +39,10 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.attribute.PosixFilePermission; -import java.util.*; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -51,17 +53,15 @@ 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 = "2021-09-25"; - private static final Artifact CATO_ARTIFACT = new Artifact("cato", "cato", CATO_VERSION, - OperatingSystem.CURRENT_OS.getCheckedName() + "-" + Architecture.CURRENT.name().toLowerCase(Locale.ROOT), - OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? "exe" : null); + private static final String CATO_VERSION = "1.0.8"; + private static final String CATO_PATH = getCatoPath(); private MultiplayerManager() { } public static Task downloadCato() { return new FileDownloadTask( - NetworkUtils.toURL(CATO_DOWNLOAD_URL + CATO_ARTIFACT.getPath()), + NetworkUtils.toURL(CATO_DOWNLOAD_URL + CATO_PATH), getCatoExecutable().toFile() ).thenRunAsync(() -> { if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX || OperatingSystem.CURRENT_OS == OperatingSystem.OSX) { @@ -73,7 +73,7 @@ public final class MultiplayerManager { } public static Path getCatoExecutable() { - return CATO_ARTIFACT.getPath(Metadata.HMCL_DIRECTORY.resolve("libraries")); + return Metadata.HMCL_DIRECTORY.resolve("libraries").resolve(CATO_PATH); } public static CatoSession joinSession(String version, String sessionName, String peer, int remotePort, int localPort) throws IOException, IncompatibleCatoVersionException { @@ -123,6 +123,33 @@ public final class MultiplayerManager { } } + public static String getCatoPath() { + switch (OperatingSystem.CURRENT_OS) { + case WINDOWS: + if (Architecture.CURRENT == Architecture.X86_64) { + return "cato/cato/" + MultiplayerManager.CATO_VERSION + "/cato-windows-amd64.exe"; + } else { + return ""; + } + case OSX: + if (Architecture.CURRENT == Architecture.X86_64) { + return "cato/cato/" + MultiplayerManager.CATO_VERSION + "/cato-darwin-amd64"; + } else { + return ""; + } + case LINUX: + if (Architecture.CURRENT == Architecture.X86_64) { + return "cato/cato/" + MultiplayerManager.CATO_VERSION + "/cato-linux-amd64"; + } else if (Architecture.CURRENT == Architecture.ARM || Architecture.CURRENT == Architecture.ARM64) { + return "cato/cato/" + MultiplayerManager.CATO_VERSION + "/cato-linux-arm7"; + } else { + return ""; + } + default: + return ""; + } + } + public static class CatoSession extends ManagedProcess { private final EventManager onExit = new EventManager<>(); private final EventManager onIdGenerated = new EventManager<>(); 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 9f26d896b..728099c6a 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 @@ -29,11 +29,9 @@ import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.task.TaskExecutor; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; -import org.jackhuang.hmcl.ui.construct.MessageDialogPane; -import org.jackhuang.hmcl.ui.construct.NumberValidator; -import org.jackhuang.hmcl.ui.construct.PromptDialogPane; -import org.jackhuang.hmcl.ui.construct.RequiredValidator; +import org.jackhuang.hmcl.ui.construct.*; import org.jackhuang.hmcl.ui.decorator.DecoratorPage; +import org.jackhuang.hmcl.util.StringUtils; import java.util.concurrent.CancellationException; import java.util.function.Consumer; @@ -43,7 +41,7 @@ import static org.jackhuang.hmcl.ui.FXUtils.runInFX; import static org.jackhuang.hmcl.util.Logging.LOG; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; -public class MultiplayerPage extends Control implements DecoratorPage { +public class MultiplayerPage extends Control implements DecoratorPage, PageAware { private final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("multiplayer"), -1)); private final ObjectProperty multiplayerState = new SimpleObjectProperty<>(MultiplayerManager.State.DISCONNECTED); @@ -58,6 +56,10 @@ public class MultiplayerPage extends Control implements DecoratorPage { public MultiplayerPage() { testNAT(); + } + + @Override + public void onPageShown() { downloadCatoIfNecessary(); } @@ -124,6 +126,12 @@ public class MultiplayerPage extends Control implements DecoratorPage { } private void downloadCatoIfNecessary() { + if (StringUtils.isBlank(MultiplayerManager.getCatoPath())) { + Controllers.dialog(i18n("multiplayer.download."), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR); + fireEvent(new PageCloseEvent()); + return; + } + if (!MultiplayerManager.getCatoExecutable().toFile().exists()) { setDisabled(true); TaskExecutor executor = MultiplayerManager.downloadCato() @@ -134,6 +142,7 @@ public class MultiplayerPage extends Control implements DecoratorPage { Controllers.showToast(i18n("message.cancelled")); } else { Controllers.dialog(DownloadProviders.localizeErrorMessage(exception), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR); + fireEvent(new PageCloseEvent()); } } else { Controllers.showToast(i18n("multiplayer.download.success")); @@ -159,10 +168,10 @@ public class MultiplayerPage extends Control implements DecoratorPage { throw new IllegalStateException("CatoSession already ready"); } - Controllers.prompt(new PromptDialogPane.Builder(i18n("multiplayer.session.create"), (result, resolve, reject) -> { - int port = Integer.parseInt(((PromptDialogPane.Builder.StringQuestion) result.get(2)).getValue()); + Controllers.dialog(new CreateMultiplayerRoomDialog((result, resolve, reject) -> { + int port = result.getAd(); try { - initCatoSession(MultiplayerManager.createSession(((PromptDialogPane.Builder.StringQuestion) result.get(1)).getValue(), port)); + initCatoSession(MultiplayerManager.createSession(result.getMotd(), port)); } catch (Exception e) { LOG.log(Level.WARNING, "Failed to create session", e); reject.accept(i18n("multiplayer.session.create.error")); @@ -172,10 +181,7 @@ public class MultiplayerPage extends Control implements DecoratorPage { this.port.set(port); setMultiplayerState(MultiplayerManager.State.CONNECTING); resolve.run(); - }) - .addQuestion(new PromptDialogPane.Builder.HintQuestion(i18n("multiplayer.session.create.hint"))) - .addQuestion(new PromptDialogPane.Builder.StringQuestion(i18n("multiplayer.session.create.name"), "", new RequiredValidator())) - .addQuestion(new PromptDialogPane.Builder.StringQuestion(i18n("multiplayer.session.create.port"), "", new NumberValidator()))); + })); } public void joinRoom() { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/Versions.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/Versions.java index 56ed24f02..d991d1ad1 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/Versions.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/Versions.java @@ -165,7 +165,8 @@ public final class Versions { } }).start(); }) - .addQuestion(new PromptDialogPane.Builder.StringQuestion(i18n("version.manage.duplicate.confirm"), version, + .addQuestion(new PromptDialogPane.Builder.HintQuestion(i18n("version.manage.duplicate.confirm"))) + .addQuestion(new PromptDialogPane.Builder.StringQuestion(null, version, new Validator(i18n("install.new_game.already_exists"), newVersionName -> !profile.getRepository().hasVersion(newVersionName)))) .addQuestion(new PromptDialogPane.Builder.BooleanQuestion(i18n("version.manage.duplicate.duplicate_save"), false))); } diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 1b2057a81..14ebf82ce 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -584,8 +584,9 @@ mods.url=Official Page multiplayer=Multiplayer multiplayer.download=Downloading dependencies for multiplayer -multiplayer.download.success=Dependencies initialization succeeded multiplayer.download.failed=Failed to initialize multiplayer, some files cannot be downloaded +multiplayer.download.success=Dependencies initialization succeeded +multiplayer.download.unsupported=Current operating system or architecure is unsupported. multiplayer.exit.after_ready=Multiplayer session broken. cato exitcode %d multiplayer.exit.before_ready=Multiplayer session failed to create. cato exitcode %d multiplayer.exit.timeout=Failed to connect to multiplayer server. diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 784df1dbf..bc894521e 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -584,8 +584,9 @@ mods.url=官方頁面 multiplayer=多人聯機 multiplayer.download=正在下載相依元件 -multiplayer.download.success=多人聯機初始化完成 multiplayer.download.failed=初始化失敗,部分文件未能完成下載 +multiplayer.download.success=多人聯機初始化完成 +multiplayer.download.unsupported=多人聯機依賴不支持當前系統或平台 multiplayer.exit.after_ready=多人聯機會話意外退出,退出碼 %d multiplayer.exit.before_ready=多人聯機房間創建失敗,cato 退出碼 %d multiplayer.exit.timeout=無法連接多人聯機服務 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 b7a09c0ab..3ded5ccc6 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -584,8 +584,9 @@ mods.url=官方页面 multiplayer=多人联机 multiplayer.download=正在下载依赖 -multiplayer.download.success=多人联机初始化完成 multiplayer.download.failed=初始化失败,部分文件未能完成下载 +multiplayer.download.success=多人联机初始化完成 +multiplayer.download.unsupported=多人联机依赖不支持当前系统或平台 multiplayer.exit.after_ready=多人联机会话意外退出,退出码 %d multiplayer.exit.before_ready=多人联机房间创建失败,cato 退出码 %d multiplayer.exit.timeout=无法连接多人联机服务