feat(multiplayer): hiper.

This commit is contained in:
huanghongxun 2022-09-12 22:37:24 +08:00
parent 99be810b5e
commit f1eb2da57c
16 changed files with 393 additions and 2172 deletions

View File

@ -1,153 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2022 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.multiplayer;
import com.jfoenix.controls.JFXCheckBox;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
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.*;
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<CreationRequest> callback;
private final LocalServerDetector lanServerDetectorThread;
private final BooleanProperty allowAllJoinRequests = new SimpleBooleanProperty(true);
private LocalServerDetector.PingResponse server;
CreateMultiplayerRoomDialog(FutureCallback<CreationRequest> 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);
JFXCheckBox allowAllJoinRequestsCheckBox = new JFXCheckBox(i18n("multiplayer.session.create.join.allow"));
allowAllJoinRequestsCheckBox.selectedProperty().bindBidirectional(allowAllJoinRequests);
GridPane.setColumnSpan(allowAllJoinRequestsCheckBox, 2);
body.addRow(3, allowAllJoinRequestsCheckBox);
setValid(false);
JFXHyperlink noinLink = new JFXHyperlink();
noinLink.setText("mcer.cn");
noinLink.setOnAction(e -> FXUtils.openLink("https://mcer.cn/circle/cato"));
setActions(warningLabel, noinLink, acceptPane, cancelButton);
lanServerDetectorThread = new LocalServerDetector(3);
lanServerDetectorThread.onDetectedLanServer().register(event -> {
runInFX(() -> {
if (event.getLanServer() != null && 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"));
setValid(false);
}
server = event.getLanServer();
body.setDisable(false);
getProgressBar().setVisible(false);
});
});
}
@Override
protected void onAccept() {
setLoading();
callback.call(new CreationRequest(
Objects.requireNonNull(server),
allowAllJoinRequests.get()
), () -> {
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();
}
public static class CreationRequest {
private final LocalServerDetector.PingResponse server;
private final boolean allowAllJoinRequests;
public CreationRequest(LocalServerDetector.PingResponse server, boolean allowAllJoinRequests) {
this.server = server;
this.allowAllJoinRequests = allowAllJoinRequests;
}
public LocalServerDetector.PingResponse getServer() {
return server;
}
public boolean isAllowAllJoinRequests() {
return allowAllJoinRequests;
}
}
}

View File

@ -1,74 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.multiplayer;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.nio.charset.StandardCharsets;
import java.util.logging.Level;
import static org.jackhuang.hmcl.util.Logging.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class LocalServerBroadcaster implements Runnable {
private final int port;
private final MultiplayerManager.HiperSession session;
public LocalServerBroadcaster(int port, MultiplayerManager.HiperSession session) {
this.port = port;
this.session = session;
}
public int getPort() {
return port;
}
@Override
public void run() {
DatagramSocket socket;
InetAddress broadcastAddress;
try {
socket = new DatagramSocket();
broadcastAddress = InetAddress.getByName("224.0.2.60");
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to create datagram socket", e);
return;
}
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, broadcastAddress, 4445);
socket.send(packet);
LOG.finest("Broadcast server 0.0.0.0:" + port);
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to send motd packet", e);
}
try {
Thread.sleep(1500);
} catch (InterruptedException ignored) {
return;
}
}
socket.close();
}
}

View File

@ -1,141 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
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<DetectedLanServerEvent> onDetectedLanServer = new EventManager<>();
private final int retry;
public LocalServerDetector(int retry) {
this.retry = retry;
setName("LocalServerDetector");
setDaemon(true);
}
public EventManager<DetectedLanServerEvent> 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 " + packet.getAddress() + ":" + packet.getPort() + " 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]"))
);
}
}
}

View File

@ -1,154 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
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 final class MultiplayerChannel {
private MultiplayerChannel() {
}
@JsonType(
property = "type",
subtypes = {
@JsonSubtype(clazz = HandshakeRequest.class, name = "handshake"),
@JsonSubtype(clazz = JoinRequest.class, name = "join"),
@JsonSubtype(clazz = KeepAliveRequest.class, name = "keepalive")
}
)
public static class Request {
}
public static class HandshakeRequest extends 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 = HandshakeResponse.class, name = "handshake"),
@JsonSubtype(clazz = JoinResponse.class, name = "join"),
@JsonSubtype(clazz = KeepAliveResponse.class, name = "keepalive"),
@JsonSubtype(clazz = KickResponse.class, name = "kick")
}
)
public static class Response {
}
public static class HandshakeResponse extends Response {
}
public static class JoinResponse extends Response {
private final String sessionName;
private final int port;
public JoinResponse(String sessionName, int port) {
this.sessionName = sessionName;
this.port = port;
}
public String getSessionName() {
return sessionName;
}
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 KickResponse extends Response {
private final String msg;
public KickResponse(String msg) {
this.msg = msg;
}
public String getMsg() {
return msg;
}
public static final String VERSION_NOT_MATCHED = "version_not_matched";
public static final String KICKED = "kicked";
public static final String JOIN_ACEEPTANCE_TIMEOUT = "join_acceptance_timeout";
}
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;
}
}

View File

@ -1,227 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.multiplayer;
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.JsonUtils;
import java.io.*;
import java.net.ConnectException;
import java.net.InetAddress;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.TimerTask;
import java.util.logging.Level;
import static org.jackhuang.hmcl.ui.multiplayer.MultiplayerChannel.*;
import static org.jackhuang.hmcl.util.Logging.LOG;
public class MultiplayerClient extends Thread {
private final String id;
private final int port;
private int gamePort;
private boolean connected = false;
private final EventManager<ConnectedEvent> onConnected = new EventManager<>();
private final EventManager<Event> onDisconnected = new EventManager<>();
private final EventManager<KickEvent> onKicked = new EventManager<>();
private final EventManager<Event> onHandshake = new EventManager<>();
public MultiplayerClient(String id, int port) {
this.id = id;
this.port = port;
setName("MultiplayerClient");
setDaemon(true);
}
public synchronized void setGamePort(int gamePort) {
this.gamePort = gamePort;
}
public synchronized int getGamePort() {
return gamePort;
}
public EventManager<ConnectedEvent> onConnected() {
return onConnected;
}
public EventManager<Event> onDisconnected() {
return onDisconnected;
}
public EventManager<KickEvent> onKicked() {
return onKicked;
}
public EventManager<Event> onHandshake() {
return onHandshake;
}
@Override
public void run() {
LOG.info("Connecting to 127.0.0.1:" + port);
for (int i = 0; i < 5; i++) {
KeepAliveThread keepAliveThread = null;
try (Socket socket = new Socket(InetAddress.getLoopbackAddress(), port);
BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8))) {
MultiplayerServer.Endpoint endpoint = new MultiplayerServer.Endpoint(socket, writer);
socket.setSoTimeout(30000);
LOG.info("Connected to 127.0.0.1:" + port);
endpoint.write(new HandshakeRequest());
endpoint.write(new JoinRequest(MultiplayerManager.HIPER_VERSION, id));
LOG.fine("Sent join request with id=" + id);
keepAliveThread = new KeepAliveThread(endpoint);
keepAliveThread.start();
TimerTask task = Lang.setTimeout(() -> {
// If after 15 seconds, we didn't receive the HandshakeResponse,
// We fail to establish the connection with server.
try {
LOG.log(Level.WARNING, "Socket connection timeout, closing socket");
socket.close();
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to close socket", e);
}
}, 25 * 1000);
String line;
while ((line = reader.readLine()) != null) {
if (isInterrupted()) {
return;
}
LOG.fine("Message from server:" + line);
Response response = JsonUtils.fromNonNullJson(line, Response.class);
if (response instanceof JoinResponse) {
JoinResponse joinResponse = JsonUtils.fromNonNullJson(line, JoinResponse.class);
setGamePort(joinResponse.getPort());
connected = true;
onConnected.fireEvent(new ConnectedEvent(this, joinResponse.getSessionName(), joinResponse.getPort()));
LOG.fine("Received join response with port " + joinResponse.getPort());
} else if (response instanceof KickResponse) {
LOG.fine("Kicked by the server");
onKicked.fireEvent(new KickEvent(this, ((KickResponse) response).getMsg()));
return;
} else if (response instanceof KeepAliveResponse) {
} else if (response instanceof HandshakeResponse) {
LOG.fine("Established connection with server");
onHandshake.fireEvent(new Event(this));
task.cancel();
} else {
LOG.log(Level.WARNING, "Unrecognized packet from server:" + line);
}
}
} 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();
} finally {
if (keepAliveThread != null) {
keepAliveThread.interrupt();
}
}
}
LOG.info("Lost connection to 127.0.0.1:" + port);
onDisconnected.fireEvent(new Event(this));
}
public boolean isConnected() {
return connected;
}
private static class KeepAliveThread extends Thread {
private final MultiplayerServer.Endpoint endpoint;
public KeepAliveThread(MultiplayerServer.Endpoint endpoint) {
this.endpoint = endpoint;
}
@Override
public void run() {
while (!isInterrupted()) {
try {
endpoint.write(new KeepAliveRequest(System.currentTimeMillis()));
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to send keep alive packet", e);
break;
}
try {
Thread.sleep(1500);
} catch (InterruptedException e) {
break;
}
}
}
}
public static class ConnectedEvent extends Event {
private final String sessionName;
private final int port;
public ConnectedEvent(Object source, String sessionName, int port) {
super(source);
this.sessionName = sessionName;
this.port = port;
}
public String getSessionName() {
return sessionName;
}
public int getPort() {
return port;
}
}
public static class KickEvent extends Event {
private final String reason;
public KickEvent(Object source, String reason) {
super(source);
this.reason = reason;
}
public String getReason() {
return reason;
}
}
}

View File

@ -19,7 +19,6 @@ package org.jackhuang.hmcl.ui.multiplayer;
import com.google.gson.JsonParseException;
import com.google.gson.annotations.SerializedName;
import javafx.application.Platform;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.event.Event;
import org.jackhuang.hmcl.event.EventManager;
@ -27,10 +26,11 @@ import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.HttpRequest;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.CommandBuilder;
import org.jackhuang.hmcl.util.platform.ManagedProcess;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
@ -39,20 +39,21 @@ import org.jetbrains.annotations.Nullable;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.net.ServerSocket;
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.concurrent.CompletableFuture;
import java.util.function.Consumer;
import java.util.function.BiFunction;
import java.util.logging.Level;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.jackhuang.hmcl.util.Lang.mapOf;
import static org.jackhuang.hmcl.util.Lang.wrap;
import static org.jackhuang.hmcl.util.Logging.LOG;
import static org.jackhuang.hmcl.util.Pair.pair;
/**
* Cato Management.
@ -61,17 +62,47 @@ public final class MultiplayerManager {
static final String HIPER_VERSION = "1.2.2";
private static final String HIPER_DOWNLOAD_URL = "https://gitcode.net/to/hiper/-/raw/master/";
private static final String HIPER_PACKAGES_URL = HIPER_DOWNLOAD_URL + "packages.sha1";
private static final String HIPER_POINTS_URL = "https://cert.mcer.cn/point.yml";
private static final Path HIPER_CONFIG_PATH = Metadata.HMCL_DIRECTORY.resolve("hiper.yml");
private static final String HIPER_PATH = getHiperPath();
public static final Path HIPER_PATH = getHiperLocalDirectory().resolve(getHiperFileName());
public static final int HIPER_AGREEMENT_VERSION = 2;
private static final String REMOTE_ADDRESS = "127.0.0.1";
private static final String LOCAL_ADDRESS = "0.0.0.0";
private static final Map<Architecture, String> archMap = mapOf(
pair(Architecture.ARM32, "arm-7"),
pair(Architecture.ARM64, "arm64"),
pair(Architecture.X86, "386"),
pair(Architecture.X86_64, "amd64"),
pair(Architecture.LOONGARCH64, "loong64"),
pair(Architecture.MIPS, "mips"),
pair(Architecture.MIPS64, "mips64"),
pair(Architecture.MIPS64EL, "mips64le"),
pair(Architecture.PPC64LE, "ppc64le"),
pair(Architecture.RISCV, "riscv64"),
pair(Architecture.MIPSEL, "mipsle")
);
private static final Map<OperatingSystem, String> osMap = mapOf(
pair(OperatingSystem.LINUX, "linux"),
pair(OperatingSystem.WINDOWS, "windows"),
pair(OperatingSystem.OSX, "darwin")
);
private static final String HIPER_TARGET_NAME = String.format("%s-%s",
osMap.getOrDefault(OperatingSystem.CURRENT_OS, "windows"),
archMap.getOrDefault(Architecture.CURRENT_ARCH, "amd64"));
private static CompletableFuture<Map<String, String>> HASH;
private MultiplayerManager() {
}
public static void clearConfiguration() {
HIPER_CONFIG_PATH.toFile().delete();
}
private static CompletableFuture<Map<String, String>> getPackagesHash() {
FXUtils.checkFxUserThread();
if (HASH == null) {
@ -93,204 +124,86 @@ public final class MultiplayerManager {
}
public static Task<Void> downloadHiper() {
return Task.fromCompletableFuture(getPackagesHash()).thenComposeAsync(packagesHash ->
new FileDownloadTask(
NetworkUtils.toURL(HIPER_DOWNLOAD_URL + getHiperFileName()),
getHiperExecutable().toFile(),
packagesHash.get(getHiperFileName()) == null ? null : new FileDownloadTask.IntegrityCheck("SHA-1", packagesHash.get(getHiperFileName()))
).thenRunAsync(() -> {
return Task.fromCompletableFuture(getPackagesHash()).thenComposeAsync(packagesHash -> {
BiFunction<String, String, FileDownloadTask> getFileDownloadTask = (String remotePath, String localFileName) -> {
String hash = packagesHash.get(remotePath);
return new FileDownloadTask(
NetworkUtils.toURL(String.format("%s%s", HIPER_DOWNLOAD_URL, remotePath)),
getHiperLocalDirectory().resolve(localFileName).toFile(),
hash == null ? null : new FileDownloadTask.IntegrityCheck("SHA-1", hash));
};
List<Task<?>> tasks;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
if (!packagesHash.containsKey(String.format("%s/hiper.exe", HIPER_TARGET_NAME))) {
throw new HiperUnsupportedPlatformException();
}
tasks = Arrays.asList(
getFileDownloadTask.apply(String.format("%s/hiper.exe", HIPER_TARGET_NAME), "hiper.exe"),
getFileDownloadTask.apply(String.format("%s/wintun.dll", HIPER_TARGET_NAME), "wintun.dll"),
getFileDownloadTask.apply("tap-windows-9.21.2.exe", "tap-windows-9.21.2.exe")
);
} else {
if (!packagesHash.containsKey(String.format("%s/hiper", HIPER_TARGET_NAME))) {
throw new HiperUnsupportedPlatformException();
}
tasks = Collections.singletonList(getFileDownloadTask.apply(String.format("%s/hiper", HIPER_TARGET_NAME), "hiper"));
}
return Task.allOf(tasks).thenRunAsync(() -> {
if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX || OperatingSystem.CURRENT_OS == OperatingSystem.OSX) {
Set<PosixFilePermission> perm = Files.getPosixFilePermissions(getHiperExecutable());
Set<PosixFilePermission> perm = Files.getPosixFilePermissions(HIPER_PATH);
perm.add(PosixFilePermission.OWNER_EXECUTE);
Files.setPosixFilePermissions(getHiperExecutable(), perm);
Files.setPosixFilePermissions(HIPER_PATH, perm);
}
}));
}
public static Path getHiperExecutable() {
return Metadata.HMCL_DIRECTORY.resolve("libraries").resolve(HIPER_PATH);
}
private static CompletableFuture<HiperSession> startHiper(String token, State state) {
return getPackagesHash().thenApplyAsync(wrap(packagesHash -> {
Path exe = getHiperExecutable();
if (!Files.isRegularFile(exe)) {
throw new HiperNotExistsException(exe);
});
});
}
private static void verifyChecksumAndDeleteIfNotMatched(Path file, @Nullable String expectedChecksum) throws IOException {
try {
String hash = packagesHash.get(getHiperFileName());
if (hash != null) {
ChecksumMismatchException.verifyChecksum(exe, "SHA-1", hash);
if (expectedChecksum != null) {
ChecksumMismatchException.verifyChecksum(file, "SHA-1", expectedChecksum);
}
} catch (IOException e) {
Files.deleteIfExists(exe);
Files.deleteIfExists(file);
throw e;
}
}
String[] commands = StringUtils.isBlank(token)
? new String[]{exe.toString()}
: new String[]{exe.toString(), "-auth.token", token};
public static CompletableFuture<HiperSession> startHiper(String token) {
return getPackagesHash().thenApplyAsync(wrap(packagesHash -> {
if (!Files.isRegularFile(HIPER_PATH)) {
throw new HiperNotExistsException(HIPER_PATH);
}
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
verifyChecksumAndDeleteIfNotMatched(getHiperLocalDirectory().resolve("hiper.exe"), packagesHash.get(String.format("%s/hiper.exe", HIPER_TARGET_NAME)));
verifyChecksumAndDeleteIfNotMatched(getHiperLocalDirectory().resolve("wintun.dll"), packagesHash.get(String.format("%s/wintun.dll", HIPER_TARGET_NAME)));
verifyChecksumAndDeleteIfNotMatched(getHiperLocalDirectory().resolve("tap-windows-9.21.2.exe"), packagesHash.get("tap-windows-9.21.2.exe"));
} else {
verifyChecksumAndDeleteIfNotMatched(getHiperLocalDirectory().resolve("hiper"), packagesHash.get(String.format("%s/hiper", HIPER_TARGET_NAME)));
}
// 下载 HiPer 配置文件
String certFileContent;
try {
certFileContent = HttpRequest.GET(String.format("https://cert.mcer.cn/%s.yml", token)).getString();
} catch (IOException e) {
throw new HiperInvalidTokenException();
}
FileUtils.writeText(HIPER_CONFIG_PATH, certFileContent);
String[] commands = new String[]{HIPER_PATH.toString(), "-config", HIPER_CONFIG_PATH.toString()};
Process process = new ProcessBuilder()
.command(commands)
.start();
return new HiperSession(state, process, Arrays.asList(commands));
return new HiperSession(process, Arrays.asList(commands));
}));
}
public static CompletableFuture<HiperSession> joinSession(String token, String peer, Mode mode, int remotePort, int localPort, JoinSessionHandler handler) throws IncompatibleHiperVersionException {
LOG.info(String.format("Joining session (token=%s,peer=%s,mode=%s,remotePort=%d,localPort=%d)", token, peer, mode, remotePort, localPort));
return startHiper(token, State.SLAVE).thenComposeAsync(wrap(session -> {
CompletableFuture<HiperSession> future = new CompletableFuture<>();
session.forwardPort(peer, LOCAL_ADDRESS, localPort, REMOTE_ADDRESS, remotePort, mode);
Consumer<HiperExitEvent> onExit = event -> {
boolean ready = session.isReady();
switch (event.getExitCode()) {
case 1:
if (!ready) {
future.completeExceptionally(new HiperExitTimeoutException());
}
break;
}
future.completeExceptionally(new HiperExitException(event.getExitCode(), ready));
};
session.onExit.register(onExit);
TimerTask peerConnectionTimeoutTask = Lang.setTimeout(() -> {
future.completeExceptionally(new PeerConnectionTimeoutException());
session.stop();
}, 15 * 1000);
session.onPeerConnected.register(event -> {
peerConnectionTimeoutTask.cancel();
MultiplayerClient client = new MultiplayerClient(session.getId(), localPort);
session.addRelatedThread(client);
session.setClient(client);
TimerTask task = Lang.setTimeout(() -> {
Platform.runLater(() -> future.completeExceptionally(new JoinRequestTimeoutException()));
session.stop();
}, 30 * 1000);
client.onConnected().register(connectedEvent -> {
try {
int port = findAvailablePort();
session.forwardPort(peer, LOCAL_ADDRESS, port, REMOTE_ADDRESS, connectedEvent.getPort(), mode);
session.addRelatedThread(Lang.thread(new LocalServerBroadcaster(port, session), "LocalServerBroadcaster", true));
session.setName(connectedEvent.getSessionName());
client.setGamePort(port);
Platform.runLater(() -> {
session.onExit.unregister(onExit);
future.complete(session);
});
} catch (IOException e) {
session.stop();
Platform.runLater(() -> future.completeExceptionally(e));
}
task.cancel();
});
client.onKicked().register(kickedEvent -> {
session.stop();
task.cancel();
Platform.runLater(() -> {
future.completeExceptionally(new KickedException(kickedEvent.getReason()));
});
});
client.onDisconnected().register(disconnectedEvent -> {
Platform.runLater(() -> {
if (!client.isConnected()) {
// We fail to establish connection with server
future.completeExceptionally(new ConnectionErrorException());
}
});
});
client.onHandshake().register(handshakeEvent -> {
if (handler != null) {
handler.onWaitingForJoinResponse();
}
});
client.start();
});
return future;
}));
}
public static CompletableFuture<HiperSession> createSession(String token, String sessionName, int gamePort, boolean allowAllJoinRequests) {
LOG.info(String.format("Creating session (token=%s,sessionName=%s,gamePort=%d)", token, sessionName, gamePort));
return startHiper(token, State.MASTER).thenComposeAsync(wrap(session -> {
CompletableFuture<HiperSession> future = new CompletableFuture<>();
MultiplayerServer server = new MultiplayerServer(sessionName, gamePort, allowAllJoinRequests);
server.startServer();
session.setName(sessionName);
session.allowForwardingAddress(REMOTE_ADDRESS, server.getPort());
session.allowForwardingAddress(REMOTE_ADDRESS, gamePort);
session.showAllowedAddress();
Consumer<HiperExitEvent> onExit = event -> {
boolean ready = session.isReady();
switch (event.getExitCode()) {
case 1:
if (!ready) {
future.completeExceptionally(new HiperExitTimeoutException());
}
break;
}
future.completeExceptionally(new HiperExitException(event.getExitCode(), ready));
};
session.onExit.register(onExit);
session.setServer(server);
session.addRelatedThread(server);
TimerTask peerConnectionTimeoutTask = Lang.setTimeout(() -> {
future.completeExceptionally(new PeerConnectionTimeoutException());
session.stop();
}, 15 * 1000);
session.onPeerConnected.register(event -> {
peerConnectionTimeoutTask.cancel();
Platform.runLater(() -> {
session.onExit.unregister(onExit);
future.complete(session);
});
});
return future;
}));
}
public static final Pattern INVITATION_CODE_PATTERN = Pattern.compile("^(?<id>.*?)#(?<port>\\d{2,5})$");
public static Invitation parseInvitationCode(String invitationCode) throws JsonParseException {
Matcher matcher = INVITATION_CODE_PATTERN.matcher(invitationCode);
if (!matcher.find()) throw new IllegalArgumentException("Invalid invitation code");
return new Invitation(matcher.group("id"), Integer.parseInt(matcher.group("port")));
}
public static int findAvailablePort() throws IOException {
try (ServerSocket socket = new ServerSocket(0)) {
return socket.getLocalPort();
}
}
public static boolean isPortAvailable(int port) {
try (ServerSocket socket = new ServerSocket(port)) {
return true;
} catch (IOException e) {
return false;
}
}
private static String getHiperFileName() {
public static String getHiperFileName() {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
return "hiper.exe";
} else {
@ -298,74 +211,33 @@ public final class MultiplayerManager {
}
}
public static String getHiperPath() {
String name = getHiperFileName();
if (StringUtils.isBlank(name)) return "";
return "hiper/hiper/" + MultiplayerManager.HIPER_VERSION + "/" + name;
public static Path getHiperLocalDirectory() {
return Metadata.HMCL_DIRECTORY.resolve("libraries").resolve("hiper").resolve("hiper").resolve(HIPER_VERSION);
}
public static class HiperSession extends ManagedProcess {
private final EventManager<HiperExitEvent> onExit = new EventManager<>();
private final EventManager<CatoIdEvent> onIdGenerated = new EventManager<>();
private final EventManager<Event> onPeerConnected = new EventManager<>();
private String name;
private final State type;
private String id;
private boolean peerConnected = false;
private MultiplayerClient client;
private MultiplayerServer server;
private final BufferedWriter writer;
HiperSession(State type, Process process, List<String> commands) {
HiperSession(Process process, List<String> commands) {
super(process, commands);
Runtime.getRuntime().addShutdownHook(new Thread(this::stop));
LOG.info("Started hiper with command: " + new CommandBuilder().addAll(commands));
this.type = type;
addRelatedThread(Lang.thread(this::waitFor, "HiperExitWaiter", true));
pumpInputStream(this::checkCatoLog);
pumpErrorStream(this::checkCatoLog);
pumpInputStream(this::onLog);
pumpErrorStream(this::onLog);
writer = new BufferedWriter(new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8));
}
public synchronized MultiplayerClient getClient() {
return client;
}
public synchronized HiperSession setClient(MultiplayerClient client) {
this.client = client;
return this;
}
public MultiplayerServer getServer() {
return server;
}
public HiperSession setServer(MultiplayerServer server) {
this.server = server;
return this;
}
private void checkCatoLog(String log) {
private void onLog(String log) {
LOG.info("[Hiper] " + log);
if (id == null) {
Matcher matcher = TEMP_TOKEN_PATTERN.matcher(log);
if (matcher.find()) {
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));
}
if (log.contains("IP")) {
// TODO
}
}
@ -375,7 +247,7 @@ public final class MultiplayerManager {
LOG.info("Hiper exited with exitcode " + exitCode);
onExit.fireEvent(new HiperExitEvent(this, exitCode));
} catch (InterruptedException e) {
onExit.fireEvent(new HiperExitEvent(this, HiperExitEvent.EXIT_CODE_INTERRUPTED));
onExit.fireEvent(new HiperExitEvent(this, HiperExitEvent.INTERRUPTED));
} finally {
try {
if (writer != null)
@ -387,68 +259,9 @@ public final class MultiplayerManager {
destroyRelatedThreads();
}
public boolean isReady() {
return id != null;
}
public synchronized String getName() {
return name;
}
public synchronized void setName(String name) {
this.name = name;
}
public State getType() {
return type;
}
@Nullable
public String getId() {
return id;
}
public String generateInvitationCode(int serverPort) {
if (id == null) {
throw new IllegalStateException("id not generated");
}
return id + "#" + serverPort;
}
public synchronized void invokeCommand(String command) throws IOException {
LOG.info("Invoking hiper: " + command);
writer.write(command);
writer.newLine();
writer.flush();
}
public void forwardPort(String peerId, String localAddress, int localPort, String remoteAddress, int remotePort, Mode mode) throws IOException {
invokeCommand(String.format("net add %s %s:%d %s:%d %s", peerId, localAddress, localPort, remoteAddress, remotePort, mode.getName()));
}
public void allowForwardingAddress(String address, int port) throws IOException {
invokeCommand(String.format("ufw net open %s:%d", address, port));
}
public void showAllowedAddress() throws IOException {
invokeCommand("ufw net whitelist");
}
public EventManager<HiperExitEvent> onExit() {
return onExit;
}
public EventManager<CatoIdEvent> onIdGenerated() {
return onIdGenerated;
}
public EventManager<Event> onPeerConnected() {
return onPeerConnected;
}
private static final Pattern TEMP_TOKEN_PATTERN = Pattern.compile("id\\((?<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(.*)");
}
public static class HiperExitEvent extends Event {
@ -463,78 +276,10 @@ public final class MultiplayerManager {
return exitCode;
}
public static final int EXIT_CODE_INTERRUPTED = -1;
public static final int EXIT_CODE_SESSION_EXPIRED = 10;
}
public static final int INTERRUPTED = -1;
public static class CatoIdEvent extends Event {
private final String id;
public CatoIdEvent(Object source, String id) {
super(source);
this.id = id;
}
public String getId() {
return id;
}
}
enum State {
DISCONNECTED,
CONNECTING,
MASTER,
SLAVE
}
public static class Invitation {
private final String id;
@SerializedName("p")
private final int channelPort;
public Invitation(String id, int channelPort) {
this.id = id;
this.channelPort = channelPort;
}
public String getId() {
return id;
}
public int getChannelPort() {
return channelPort;
}
}
public interface JoinSessionHandler {
void onWaitingForJoinResponse();
}
public static class IncompatibleHiperVersionException extends Exception {
private final String expected;
private final String actual;
public IncompatibleHiperVersionException(String expected, String actual) {
this.expected = expected;
this.actual = actual;
}
public String getExpected() {
return expected;
}
public String getActual() {
return actual;
}
}
public enum Mode {
P2P,
BRIDGE;
String getName() {
return name().toLowerCase(Locale.ROOT);
}
public static final int INVALID_CONFIGURATION = 1;
public static final int CERTIFICATE_EXPIRED = 11;
}
public static class HiperExitException extends RuntimeException {
@ -558,10 +303,10 @@ public final class MultiplayerManager {
public static class HiperExitTimeoutException extends RuntimeException {
}
public static class HiperSessionExpiredException extends RuntimeException {
public static class HiperSessionExpiredException extends HiperInvalidConfigurationException {
}
public static class HiperAlreadyStartedException extends RuntimeException {
public static class HiperInvalidConfigurationException extends RuntimeException {
}
public static class JoinRequestTimeoutException extends RuntimeException {
@ -596,4 +341,11 @@ public final class MultiplayerManager {
return file;
}
}
public static class HiperInvalidTokenException extends RuntimeException {
}
public static class HiperUnsupportedPlatformException extends RuntimeException {
}
}

View File

@ -21,38 +21,29 @@ import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXDialogLayout;
import de.javawi.jstun.test.DiscoveryInfo;
import de.javawi.jstun.test.DiscoveryTest;
import javafx.application.Platform;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.control.Label;
import javafx.scene.control.Skin;
import org.jackhuang.hmcl.event.Event;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.task.Schedulers;
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.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.HMCLService;
import org.jackhuang.hmcl.util.Result;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
import org.jetbrains.annotations.Nullable;
import java.util.concurrent.CancellationException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.logging.Level;
import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.ui.multiplayer.MultiplayerChannel.KickResponse.*;
import static org.jackhuang.hmcl.util.Lang.resolveException;
import static org.jackhuang.hmcl.util.Logging.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@ -60,16 +51,12 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class MultiplayerPage extends DecoratorAnimatedPage implements DecoratorPage, PageAware {
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("multiplayer")));
private final ObjectProperty<MultiplayerManager.State> multiplayerState = new SimpleObjectProperty<>(MultiplayerManager.State.DISCONNECTED);
private final ReadOnlyStringWrapper token = new ReadOnlyStringWrapper();
private final ReadOnlyObjectWrapper<@Nullable Result<DiscoveryInfo>> natState = new ReadOnlyObjectWrapper<>();
private final ReadOnlyIntegerWrapper gamePort = new ReadOnlyIntegerWrapper(-1);
private final ReadOnlyObjectWrapper<MultiplayerManager.HiperSession> session = new ReadOnlyObjectWrapper<>();
private final ObservableList<MultiplayerChannel.CatoClient> clients = FXCollections.observableArrayList();
private final IntegerProperty port = new SimpleIntegerProperty();
private final StringProperty address = new SimpleStringProperty();
private Consumer<MultiplayerManager.HiperExitEvent> onExit;
private Consumer<MultiplayerManager.CatoIdEvent> onIdGenerated;
private Consumer<Event> onPeerConnected;
public MultiplayerPage() {
testNAT();
@ -77,7 +64,7 @@ public class MultiplayerPage extends DecoratorAnimatedPage implements DecoratorP
@Override
public void onPageShown() {
checkAgreement(this::downloadCatoIfNecessary);
checkAgreement(this::downloadHiPerIfNecessary);
}
@Override
@ -85,22 +72,6 @@ public class MultiplayerPage extends DecoratorAnimatedPage implements DecoratorP
return new MultiplayerPageSkin(this);
}
public ObservableList<MultiplayerChannel.CatoClient> getClients() {
return clients;
}
public MultiplayerManager.State getMultiplayerState() {
return multiplayerState.get();
}
public ObjectProperty<MultiplayerManager.State> multiplayerStateProperty() {
return multiplayerState;
}
public void setMultiplayerState(MultiplayerManager.State multiplayerState) {
this.multiplayerState.set(multiplayerState);
}
public Result<DiscoveryInfo> getNatState() {
return natState.get();
}
@ -109,20 +80,28 @@ public class MultiplayerPage extends DecoratorAnimatedPage implements DecoratorP
return natState.getReadOnlyProperty();
}
public String getToken() {
return token.get();
public int getPort() {
return port.get();
}
public ReadOnlyStringProperty tokenProperty() {
return token.getReadOnlyProperty();
public IntegerProperty portProperty() {
return port;
}
public int getGamePort() {
return gamePort.get();
public void setPort(int port) {
this.port.set(port);
}
public ReadOnlyIntegerProperty gamePortProperty() {
return gamePort.getReadOnlyProperty();
public String getAddress() {
return address.get();
}
public StringProperty addressProperty() {
return address;
}
public void setAddress(String address) {
this.address.set(address);
}
public MultiplayerManager.HiperSession getSession() {
@ -174,21 +153,18 @@ public class MultiplayerPage extends DecoratorAnimatedPage implements DecoratorP
}
}
private void downloadCatoIfNecessary() {
if (StringUtils.isBlank(MultiplayerManager.getHiperPath())) {
Controllers.dialog(i18n("multiplayer.download.unsupported"), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR);
fireEvent(new PageCloseEvent());
return;
}
if (!MultiplayerManager.getHiperExecutable().toFile().exists()) {
private void downloadHiPerIfNecessary() {
if (!MultiplayerManager.HIPER_PATH.toFile().exists()) {
setDisabled(true);
TaskExecutor executor = MultiplayerManager.downloadHiper()
Controllers.taskDialog(MultiplayerManager.downloadHiper()
.whenComplete(Schedulers.javafx(), exception -> {
setDisabled(false);
if (exception != null) {
if (exception instanceof CancellationException) {
Controllers.showToast(i18n("message.cancelled"));
} else if (exception instanceof MultiplayerManager.HiperUnsupportedPlatformException) {
Controllers.dialog(i18n("multiplayer.download.unsupported"), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR);
fireEvent(new PageCloseEvent());
} else {
Controllers.dialog(DownloadProviders.localizeErrorMessage(exception), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR);
fireEvent(new PageCloseEvent());
@ -196,316 +172,79 @@ public class MultiplayerPage extends DecoratorAnimatedPage implements DecoratorP
} else {
Controllers.showToast(i18n("multiplayer.download.success"));
}
}).executor();
Controllers.taskDialog(executor, i18n("multiplayer.download"), TaskCancellationAction.NORMAL);
executor.start();
}), i18n("multiplayer.download"), TaskCancellationAction.NORMAL);
} else {
setDisabled(false);
}
}
public void copyInvitationCode() {
if (getSession() == null || !getSession().isReady() || gamePort.get() < 0 || getMultiplayerState() != MultiplayerManager.State.MASTER) {
throw new IllegalStateException("CatoSession not ready");
}
FXUtils.copyText(getSession().generateInvitationCode(getSession().getServer().getPort()));
}
public void createRoom() {
if (getSession() != null || getMultiplayerState() != MultiplayerManager.State.DISCONNECTED) {
throw new IllegalStateException("CatoSession already ready");
}
Controllers.dialog(new CreateMultiplayerRoomDialog((result, resolve, reject) -> {
int gamePort = result.getServer().getAd();
boolean isStaticToken = StringUtils.isNotBlank(globalConfig().getMultiplayerToken());
MultiplayerManager.createSession(globalConfig().getMultiplayerToken(), result.getServer().getMotd(), gamePort, result.isAllowAllJoinRequests())
.thenAcceptAsync(session -> {
session.getServer().setOnClientAdding((client, resolveClient, rejectClient) -> {
runInFX(() -> {
Controllers.dialog(new MessageDialogPane.Builder(i18n("multiplayer.session.create.join.prompt", client.getUsername()), i18n("multiplayer.session.create.join"), MessageDialogPane.MessageType.INFO)
.yesOrNo(resolveClient, () -> rejectClient.accept(MultiplayerChannel.KickResponse.JOIN_ACEEPTANCE_TIMEOUT))
.cancelOnTimeout(30 * 1000)
.build());
});
});
session.getServer().onClientAdded().register(event -> {
runInFX(() -> {
clients.add(event);
});
});
session.getServer().onClientDisconnected().register(event -> {
runInFX(() -> {
clients.remove(event);
});
});
initCatoSession(session);
this.gamePort.set(gamePort);
setMultiplayerState(MultiplayerManager.State.MASTER);
resolve.run();
}, Platform::runLater)
.exceptionally(throwable -> {
reject.accept(localizeCreateErrorMessage(throwable, isStaticToken));
return null;
});
}));
}
public void joinRoom() {
if (getSession() != null || getMultiplayerState() != MultiplayerManager.State.DISCONNECTED) {
throw new IllegalStateException("CatoSession already ready");
}
Controllers.prompt(new PromptDialogPane.Builder(i18n("multiplayer.session.join"), (result, resolve, reject) -> {
PromptDialogPane.Builder.HintQuestion hintQuestion = (PromptDialogPane.Builder.HintQuestion) result.get(0);
boolean isStaticToken = StringUtils.isNotBlank(globalConfig().getMultiplayerToken());
String invitationCode = ((PromptDialogPane.Builder.StringQuestion) result.get(1)).getValue();
MultiplayerManager.Invitation invitation;
try {
invitation = MultiplayerManager.parseInvitationCode(invitationCode);
} catch (Exception e) {
LOG.log(Level.WARNING, "Failed to join session", e);
reject.accept(i18n("multiplayer.session.join.invitation_code.error"));
return;
}
int localPort; // invitation channel
try {
localPort = MultiplayerManager.findAvailablePort();
} catch (Exception e) {
reject.accept(i18n("multiplayer.session.join.port.error"));
return;
}
try {
MultiplayerManager.joinSession(
globalConfig().getMultiplayerToken(),
invitation.getId(),
globalConfig().isMultiplayerRelay() && (StringUtils.isNotBlank(globalConfig().getMultiplayerToken()) || StringUtils.isNotBlank(System.getProperty("hmcl.multiplayer.relay")))
? MultiplayerManager.Mode.BRIDGE
: MultiplayerManager.Mode.P2P,
invitation.getChannelPort(),
localPort, new MultiplayerManager.JoinSessionHandler() {
@Override
public void onWaitingForJoinResponse() {
runInFX(() -> {
hintQuestion.setQuestion(i18n("multiplayer.session.join.wait"));
});
}
})
.thenAcceptAsync(session -> {
initCatoSession(session);
AtomicBoolean kicked = new AtomicBoolean();
session.getClient().onDisconnected().register(() -> {
runInFX(() -> {
stopCatoSession();
if (!kicked.get()) {
Controllers.dialog(i18n("multiplayer.session.join.lost_connection"));
}
});
});
session.getClient().onKicked().register(kickedEvent -> {
runInFX(() -> {
kicked.set(true);
Controllers.dialog(i18n("multiplayer.session.join.kicked", localizeKickMessage(kickedEvent.getReason())));
});
});
gamePort.set(session.getClient().getGamePort());
setMultiplayerState(MultiplayerManager.State.SLAVE);
resolve.run();
}, Platform::runLater)
.exceptionally(throwable -> {
reject.accept(localizeJoinErrorMessage(throwable, isStaticToken));
return null;
});
} catch (MultiplayerManager.IncompatibleHiperVersionException e) {
reject.accept(i18n("multiplayer.session.join.invitation_code.version"));
}
})
.addQuestion(new PromptDialogPane.Builder.HintQuestion(i18n("multiplayer.session.join.hint")))
.addQuestion(new PromptDialogPane.Builder.StringQuestion(i18n("multiplayer.session.join.invitation_code"), "", new RequiredValidator())));
}
private String localizeKickMessage(String message) {
if (VERSION_NOT_MATCHED.equals(message)) {
return i18n("multiplayer.session.join.kicked.version_not_matched");
} else if (KICKED.equals(message)) {
return i18n("multiplayer.session.join.kicked.kicked");
} else if (JOIN_ACEEPTANCE_TIMEOUT.equals(message)) {
return i18n("multiplayer.session.join.kicked.join_acceptance_timeout");
} else {
return message;
}
}
private String localizeErrorMessage(Throwable t, boolean isStaticToken, Function<Throwable, String> fallback) {
private String localizeErrorMessage(Throwable t) {
Throwable e = resolveException(t);
if (e instanceof CancellationException) {
LOG.info("Connection rejected by the server");
return i18n("message.cancelled");
} else if (e instanceof MultiplayerManager.KickedException) {
LOG.info("Kicked by server");
return i18n("multiplayer.session.join.kicked", localizeKickMessage(((MultiplayerManager.KickedException) e).getReason()));
} else if (e instanceof MultiplayerManager.HiperAlreadyStartedException) {
LOG.info("Cato already started");
return i18n("multiplayer.session.error.already_started");
} else if (e instanceof MultiplayerManager.HiperInvalidConfigurationException) {
LOG.info("Hiper invalid configuration");
return i18n("multiplayer.token.malformed");
} else if (e instanceof MultiplayerManager.HiperNotExistsException) {
LOG.log(Level.WARNING, "Cato not found " + ((MultiplayerManager.HiperNotExistsException) e).getFile(), e);
return i18n("multiplayer.session.error.file_not_found");
} else if (e instanceof MultiplayerManager.JoinRequestTimeoutException) {
LOG.info("Cato already started");
return i18n("multiplayer.session.join.wait_timeout");
} else if (e instanceof MultiplayerManager.ConnectionErrorException) {
LOG.info("Failed to establish connection with server");
return i18n("multiplayer.session.join.error.connection");
} else if (e instanceof MultiplayerManager.HiperExitTimeoutException) {
LOG.info("Cato failed to connect to main net");
if (isStaticToken) {
return i18n("multiplayer.exit.timeout.static_token");
} else {
return i18n("multiplayer.exit.timeout.dynamic_token");
}
LOG.log(Level.WARNING, "Hiper not found " + ((MultiplayerManager.HiperNotExistsException) e).getFile(), e);
return i18n("multiplayer.error.file_not_found");
} else if (e instanceof MultiplayerManager.HiperExitException) {
LOG.info("Cato exited accidentally");
LOG.info("HiPer exited accidentally");
int exitCode = ((MultiplayerManager.HiperExitException) e).getExitCode();
if (!((MultiplayerManager.HiperExitException) e).isReady()) {
return i18n("multiplayer.exit.before_ready", exitCode);
} else {
return i18n("multiplayer.exit.after_ready", exitCode);
}
return i18n("multiplayer.exit", exitCode);
} else if (e instanceof MultiplayerManager.HiperInvalidTokenException) {
LOG.info("invalid token");
return i18n("multiplayer.token.invalid");
} else if (e instanceof ChecksumMismatchException) {
return i18n("exception.artifact_malformed");
} else {
return fallback.apply(e);
return e.getLocalizedMessage();
}
}
private String localizeCreateErrorMessage(Throwable t, boolean isStaticToken) {
return localizeErrorMessage(t, isStaticToken, e -> {
LOG.log(Level.WARNING, "Failed to create session", e);
if (isStaticToken) {
return i18n("multiplayer.session.create.error.static_token") + e.getLocalizedMessage();
} else {
return i18n("multiplayer.session.create.error.dynamic_token") + e.getLocalizedMessage();
}
public void start() {
MultiplayerManager.startHiper(globalConfig().getMultiplayerToken())
.thenAcceptAsync(this.session::set, Schedulers.javafx())
.exceptionally(throwable -> {
runInFX(() -> Controllers.dialog(localizeErrorMessage(throwable), null, MessageDialogPane.MessageType.ERROR));
return null;
});
}
private String localizeJoinErrorMessage(Throwable t, boolean isStaticToken) {
return localizeErrorMessage(t, isStaticToken, e -> {
LOG.log(Level.WARNING, "Failed to join session", e);
return i18n("multiplayer.session.join.error");
});
}
public void kickPlayer(MultiplayerChannel.CatoClient client) {
if (getSession() == null || !getSession().isReady() || getMultiplayerState() != MultiplayerManager.State.MASTER) {
throw new IllegalStateException("CatoSession not ready");
}
Controllers.confirm(i18n("multiplayer.session.create.members.kick.prompt"), i18n("multiplayer.session.create.members.kick"), MessageDialogPane.MessageType.WARNING,
() -> {
getSession().getServer().kickPlayer(client);
}, null);
}
public void closeRoom() {
if (getSession() == null || !getSession().isReady() || getMultiplayerState() != MultiplayerManager.State.MASTER) {
throw new IllegalStateException("CatoSession not ready");
}
Controllers.confirm(i18n("multiplayer.session.close.warning"), i18n("message.warning"), MessageDialogPane.MessageType.WARNING,
this::stopCatoSession, null);
}
public void quitRoom() {
if (getSession() == null || !getSession().isReady() || getMultiplayerState() != MultiplayerManager.State.SLAVE) {
throw new IllegalStateException("CatoSession not ready");
}
Controllers.confirm(i18n("multiplayer.session.quit.warning"), i18n("message.warning"), MessageDialogPane.MessageType.WARNING,
this::stopCatoSession, null);
}
public void cancelRoom() {
if (getSession() == null || getSession().isReady() || getMultiplayerState() != MultiplayerManager.State.CONNECTING) {
throw new IllegalStateException("CatoSession not existing or already ready");
}
stopCatoSession();
}
private void initCatoSession(MultiplayerManager.HiperSession session) {
runInFX(() -> {
onExit = session.onExit().registerWeak(this::onCatoExit);
onIdGenerated = session.onIdGenerated().registerWeak(this::onCatoIdGenerated);
onPeerConnected = session.onPeerConnected().registerWeak(this::onCatoPeerConnected);
this.clients.clear();
this.session.set(session);
});
}
private void stopCatoSession() {
public void stop() {
if (getSession() != null) {
getSession().stop();
}
clearCatoSession();
clearSession();
}
private void clearCatoSession() {
private void clearSession() {
this.session.set(null);
this.token.set(null);
this.gamePort.set(-1);
this.multiplayerState.set(MultiplayerManager.State.DISCONNECTED);
}
private void onCatoExit(MultiplayerManager.HiperExitEvent event) {
private void onExit(MultiplayerManager.HiperExitEvent event) {
runInFX(() -> {
boolean ready = ((MultiplayerManager.HiperSession) event.getSource()).isReady();
switch (event.getExitCode()) {
case 0:
break;
case MultiplayerManager.HiperExitEvent.EXIT_CODE_SESSION_EXPIRED:
Controllers.dialog(i18n("multiplayer.session.expired"));
case MultiplayerManager.HiperExitEvent.CERTIFICATE_EXPIRED:
MultiplayerManager.clearConfiguration();
Controllers.dialog(i18n("multiplayer.token.expired"));
break;
case 1:
if (!ready) {
Controllers.dialog(i18n("multiplayer.exit.timeout"));
}
case MultiplayerManager.HiperExitEvent.INVALID_CONFIGURATION:
MultiplayerManager.clearConfiguration();
Controllers.dialog(i18n("multiplayer.token.malformed"));
break;
case -1:
case MultiplayerManager.HiperExitEvent.INTERRUPTED:
// do nothing
break;
default:
if (!((MultiplayerManager.HiperSession) event.getSource()).isReady()) {
Controllers.dialog(i18n("multiplayer.exit.before_ready", event.getExitCode()));
} else {
Controllers.dialog(i18n("multiplayer.exit.after_ready", event.getExitCode()));
}
Controllers.dialog(i18n("multiplayer.exit", event.getExitCode()));
break;
}
clearCatoSession();
});
}
private void onCatoPeerConnected(Event event) {
runInFX(() -> {
});
}
private void onCatoIdGenerated(MultiplayerManager.CatoIdEvent event) {
runInFX(() -> {
token.set(event.getId());
setMultiplayerState(((MultiplayerManager.HiperSession) event.getSource()).getType());
clearSession();
});
}

View File

@ -19,6 +19,7 @@ package org.jackhuang.hmcl.ui.multiplayer;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXPasswordField;
import com.jfoenix.controls.JFXTextField;
import de.javawi.jstun.test.DiscoveryInfo;
import javafx.beans.binding.Bindings;
import javafx.collections.ObservableList;
@ -28,6 +29,7 @@ import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.*;
import javafx.util.StringConverter;
import org.jackhuang.hmcl.game.LauncherHelper;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.setting.Profiles;
@ -41,12 +43,14 @@ import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.versions.Versions;
import org.jackhuang.hmcl.util.HMCLService;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.Result;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
import org.jetbrains.annotations.Nullable;
import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig;
import static org.jackhuang.hmcl.ui.FXUtils.stringConverter;
import static org.jackhuang.hmcl.ui.versions.VersionPage.wrap;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@ -63,57 +67,6 @@ public class MultiplayerPageSkin extends DecoratorAnimatedPage.DecoratorAnimated
super(control);
{
VBox roomPane = new VBox();
{
AdvancedListItem createRoomItem = new AdvancedListItem();
createRoomItem.setTitle(i18n("multiplayer.session.create"));
createRoomItem.setLeftGraphic(wrap(SVG::plusCircleOutline));
createRoomItem.setActionButtonVisible(false);
createRoomItem.setOnAction(e -> control.createRoom());
AdvancedListItem joinRoomItem = new AdvancedListItem();
joinRoomItem.setTitle(i18n("multiplayer.session.join"));
joinRoomItem.setLeftGraphic(wrap(SVG::accountArrowRightOutline));
joinRoomItem.setActionButtonVisible(false);
joinRoomItem.setOnAction(e -> control.joinRoom());
AdvancedListItem copyLinkItem = new AdvancedListItem();
copyLinkItem.setTitle(i18n("multiplayer.session.copy_room_code"));
copyLinkItem.setLeftGraphic(wrap(SVG::accountArrowRightOutline));
copyLinkItem.setActionButtonVisible(false);
copyLinkItem.setOnAction(e -> control.copyInvitationCode());
AdvancedListItem cancelItem = new AdvancedListItem();
cancelItem.setTitle(i18n("button.cancel"));
cancelItem.setLeftGraphic(wrap(SVG::closeCircle));
cancelItem.setActionButtonVisible(false);
cancelItem.setOnAction(e -> control.cancelRoom());
AdvancedListItem quitItem = new AdvancedListItem();
quitItem.setTitle(i18n("multiplayer.session.quit"));
quitItem.setLeftGraphic(wrap(SVG::closeCircle));
quitItem.setActionButtonVisible(false);
quitItem.setOnAction(e -> control.quitRoom());
AdvancedListItem closeRoomItem = new AdvancedListItem();
closeRoomItem.setTitle(i18n("multiplayer.session.close"));
closeRoomItem.setLeftGraphic(wrap(SVG::closeCircle));
closeRoomItem.setActionButtonVisible(false);
closeRoomItem.setOnAction(e -> control.closeRoom());
FXUtils.onChangeAndOperate(getSkinnable().multiplayerStateProperty(), state -> {
if (state == MultiplayerManager.State.DISCONNECTED) {
roomPane.getChildren().setAll(createRoomItem, joinRoomItem);
} else if (state == MultiplayerManager.State.CONNECTING) {
roomPane.getChildren().setAll(cancelItem);
} else if (state == MultiplayerManager.State.MASTER) {
roomPane.getChildren().setAll(copyLinkItem, closeRoomItem);
} else if (state == MultiplayerManager.State.SLAVE) {
roomPane.getChildren().setAll(quitItem);
}
});
}
AdvancedListBox sideBar = new AdvancedListBox()
.addNavigationDrawerItem(item -> {
item.setTitle(i18n("version.launch"));
@ -123,8 +76,6 @@ public class MultiplayerPageSkin extends DecoratorAnimatedPage.DecoratorAnimated
Versions.launch(profile, profile.getSelectedVersion(), LauncherHelper::setKeep);
});
})
.startCategory(i18n("multiplayer.session"))
.add(roomPane)
.startCategory(i18n("help"))
.addNavigationDrawerItem(item -> {
item.setTitle(i18n("help"));
@ -148,74 +99,119 @@ public class MultiplayerPageSkin extends DecoratorAnimatedPage.DecoratorAnimated
scrollPane.setFitToWidth(true);
setCenter(scrollPane);
ComponentList roomPane = new ComponentList();
VBox mainPane = new VBox(16);
{
TransitionPane transitionPane = new TransitionPane();
roomPane.getContent().setAll(transitionPane);
VBox disconnectedPane = new VBox(8);
ComponentList offPane = new ComponentList();
{
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO);
hintPane.setText(i18n("multiplayer.state.disconnected.hint"));
hintPane.setText(i18n("multiplayer.off.hint"));
Label label = new Label(i18n("multiplayer.state.disconnected"));
disconnectedPane.getChildren().setAll(hintPane, label);
}
VBox connectingPane = new VBox(8);
BorderPane tokenPane = new BorderPane();
{
Label label = new Label(i18n("multiplayer.state.connecting"));
Label tokenTitle = new Label(i18n("multiplayer.token"));
BorderPane.setAlignment(tokenTitle, Pos.CENTER_LEFT);
tokenPane.setLeft(tokenTitle);
// Token acts like password, we hide it here preventing users from accidentally leaking their token when taking screenshots.
JFXPasswordField tokenField = new JFXPasswordField();
BorderPane.setAlignment(tokenField, Pos.CENTER_LEFT);
BorderPane.setMargin(tokenField, new Insets(0, 8, 0, 8));
tokenPane.setCenter(tokenField);
tokenField.textProperty().bindBidirectional(globalConfig().multiplayerTokenProperty());
tokenField.setPromptText(i18n("multiplayer.token.prompt"));
connectingPane.getChildren().setAll(label);
JFXHyperlink applyLink = new JFXHyperlink(i18n("multiplayer.token.apply"));
BorderPane.setAlignment(applyLink, Pos.CENTER_RIGHT);
applyLink.setOnAction(e -> HMCLService.openRedirectLink("multiplayer-static-token"));
tokenPane.setRight(applyLink);
}
VBox masterPane = new VBox(8);
HBox startPane = new HBox();
{
HintPane masterHintPane = new HintPane();
masterHintPane.setText(i18n("multiplayer.state.master.hint"));
JFXButton startButton = new JFXButton(i18n("multiplayer.off.start"));
startButton.getStyleClass().add("jfx-button-raised");
startButton.setButtonType(JFXButton.ButtonType.RAISED);
startButton.setOnMouseClicked(e -> control.start());
Label label = new Label(i18n("multiplayer.state.master"));
label.textProperty().bind(Bindings.createStringBinding(() ->
i18n("multiplayer.state.master", control.getSession() == null ? "" : control.getSession().getName(), control.getGamePort()),
control.gamePortProperty(), control.sessionProperty()));
Label membersLabel = new Label(i18n("multiplayer.session.create.members"));
VBox clientsPane = new VBox(8);
clients = MappedObservableList.create(control.getClients(), ClientItem::new);
Bindings.bindContent(clientsPane.getChildren(), clients);
masterPane.getChildren().setAll(masterHintPane, label, membersLabel, clientsPane);
startPane.getChildren().setAll(startButton);
startPane.setAlignment(Pos.CENTER_RIGHT);
}
BorderPane slavePane = new BorderPane();
offPane.getContent().setAll(hintPane, tokenPane, startPane);
}
ComponentList onPane = new ComponentList();
{
HintPane slaveHintPane = new HintPane();
slaveHintPane.setText(i18n("multiplayer.state.slave.hint"));
slavePane.setTop(slaveHintPane);
GridPane masterPane = new GridPane();
masterPane.setVgap(8);
masterPane.setHgap(16);
ColumnConstraints titleColumn = new ColumnConstraints();
ColumnConstraints valueColumn = new ColumnConstraints();
ColumnConstraints rightColumn = new ColumnConstraints();
masterPane.getColumnConstraints().setAll(titleColumn, valueColumn, rightColumn);
valueColumn.setFillWidth(true);
valueColumn.setHgrow(Priority.ALWAYS);
{
Label title = new Label(i18n("multiplayer.master"));
GridPane.setColumnSpan(title, 3);
masterPane.addRow(0, title);
Label label = new Label();
label.textProperty().bind(Bindings.createStringBinding(() ->
i18n("multiplayer.state.slave", control.getSession() == null ? "" : control.getSession().getName(), "0.0.0.0:" + control.getGamePort()),
control.sessionProperty(), control.gamePortProperty()));
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
slavePane.setCenter(label);
HintPane masterHintPane = new HintPane(MessageDialogPane.MessageType.INFO);
GridPane.setColumnSpan(masterHintPane, 3);
masterHintPane.setText(i18n("multiplayer.master.hint"));
masterPane.addRow(1, masterHintPane);
JFXButton copyButton = new JFXButton(i18n("multiplayer.state.slave.copy"));
copyButton.setOnAction(e -> FXUtils.copyText("0.0.0.0:" + control.getGamePort()));
slavePane.setRight(copyButton);
Label portTitle = new Label(i18n("multiplayer.master.port"));
BorderPane.setAlignment(portTitle, Pos.CENTER_LEFT);
JFXTextField portTextField = new JFXTextField();
GridPane.setColumnSpan(portTextField, 2);
FXUtils.setValidateWhileTextChanged(portTextField, true);
portTextField.getValidators().add(new Validator(i18n("multiplayer.master.port.validate"), (text) -> {
Integer value = Lang.toIntOrNull(text);
return value != null && 0 <= value && value <= 65535;
}));
portTextField.textProperty().bindBidirectional(control.portProperty(), new StringConverter<Number>() {
@Override
public String toString(Number object) {
return Integer.toString(object.intValue());
}
FXUtils.onChangeAndOperate(getSkinnable().multiplayerStateProperty(), state -> {
if (state == MultiplayerManager.State.DISCONNECTED) {
transitionPane.setContent(disconnectedPane, ContainerAnimations.NONE.getAnimationProducer());
} else if (state == MultiplayerManager.State.CONNECTING) {
transitionPane.setContent(connectingPane, ContainerAnimations.NONE.getAnimationProducer());
} else if (state == MultiplayerManager.State.MASTER) {
transitionPane.setContent(masterPane, ContainerAnimations.NONE.getAnimationProducer());
} else if (state == MultiplayerManager.State.SLAVE) {
transitionPane.setContent(slavePane, ContainerAnimations.NONE.getAnimationProducer());
@Override
public Number fromString(String string) {
return Lang.parseInt(string, 0);
}
});
masterPane.addRow(2, portTitle, portTextField);
Label serverAddressTitle = new Label(i18n("multiplayer.master.server_address"));
BorderPane.setAlignment(serverAddressTitle, Pos.CENTER_LEFT);
Label serverAddressLabel = new Label();
BorderPane.setAlignment(serverAddressLabel, Pos.CENTER_LEFT);
serverAddressLabel.textProperty().bind(Bindings.createStringBinding(() -> {
return (control.getAddress() == null ? "" : control.getAddress()) + ":" + control.getPort();
}, control.addressProperty(), control.portProperty()));
JFXButton copyButton = new JFXButton(i18n("multiplayer.master.server_address.copy"));
copyButton.setOnAction(e -> FXUtils.copyText(serverAddressLabel.getText()));
masterPane.addRow(3, serverAddressTitle, serverAddressLabel, copyButton);
}
VBox slavePane = new VBox(8);
{
HintPane slaveHintPane = new HintPane(MessageDialogPane.MessageType.INFO);
slaveHintPane.setText(i18n("multiplayer.slave.hint"));
slavePane.getChildren().setAll(new Label(i18n("multiplayer.slave")), slaveHintPane);
}
onPane.getContent().setAll(masterPane, slavePane);
}
FXUtils.onChangeAndOperate(getSkinnable().sessionProperty(), session -> {
if (session == null) {
mainPane.getChildren().setAll(ComponentList.createComponentListTitle(i18n("multiplayer.off")),
offPane);
} else {
mainPane.getChildren().setAll(ComponentList.createComponentListTitle(i18n("multiplayer.on")),
onPane);
}
});
}
@ -246,27 +242,6 @@ public class MultiplayerPageSkin extends DecoratorAnimatedPage.DecoratorAnimated
ComponentList thanksPane = new ComponentList();
{
GridPane gridPane = new GridPane();
gridPane.getColumnConstraints().setAll(new ColumnConstraints(), FXUtils.getColumnHgrowing(), new ColumnConstraints());
gridPane.setVgap(8);
gridPane.setHgap(16);
// Token acts like password, we hide it here preventing users from accidentally leaking their token when taking screenshots.
JFXPasswordField tokenField = new JFXPasswordField();
tokenField.textProperty().bindBidirectional(globalConfig().multiplayerTokenProperty());
tokenField.setPromptText(i18n("multiplayer.session.create.token.prompt"));
JFXHyperlink applyLink = new JFXHyperlink(i18n("multiplayer.session.create.token.apply"));
applyLink.setOnAction(e -> HMCLService.openRedirectLink("multiplayer-static-token"));
gridPane.addRow(0, new Label(i18n("multiplayer.session.create.token")), tokenField, applyLink);
OptionToggleButton relay = new OptionToggleButton();
relay.disableProperty().bind(tokenField.textProperty().isEmpty());
relay.selectedProperty().bindBidirectional(globalConfig().multiplayerRelayProperty());
relay.setTitle(i18n("multiplayer.relay"));
relay.setSubtitle(i18n("multiplayer.relay.hint"));
HBox pane = new HBox();
pane.setAlignment(Pos.CENTER_LEFT);
@ -277,20 +252,19 @@ public class MultiplayerPageSkin extends DecoratorAnimatedPage.DecoratorAnimated
HBox.setHgrow(placeholder, Priority.ALWAYS);
pane.getChildren().setAll(
new Label("Based on Cato"),
new Label("Based on HiPer"),
aboutLink,
placeholder,
FXUtils.segmentToTextFlow(i18n("multiplayer.powered_by"), Controllers::onHyperlinkAction));
thanksPane.getContent().addAll(gridPane, relay, pane);
thanksPane.getContent().addAll(pane);
}
content.getChildren().setAll(
ComponentList.createComponentListTitle(i18n("multiplayer.session")),
roomPane,
mainPane,
ComponentList.createComponentListTitle(i18n("multiplayer.nat")),
natDetectionPane,
ComponentList.createComponentListTitle(i18n("settings")),
ComponentList.createComponentListTitle(i18n("about")),
thanksPane
);
}
@ -320,21 +294,4 @@ public class MultiplayerPageSkin extends DecoratorAnimatedPage.DecoratorAnimated
}
}
private class ClientItem extends StackPane {
ClientItem(MultiplayerChannel.CatoClient client) {
BorderPane pane = new BorderPane();
pane.setPadding(new Insets(8));
pane.setLeft(new Label(client.getUsername()));
JFXButton kickButton = new JFXButton();
kickButton.setGraphic(SVG.close(Theme.blackFillBinding(), 16, 16));
kickButton.getStyleClass().add("toggle-icon-tiny");
kickButton.setOnAction(e -> getSkinnable().kickPlayer(client));
pane.setRight(kickButton);
RipplerContainer container = new RipplerContainer(pane);
getChildren().setAll(container);
getStyleClass().add("md-list-cell");
}
}
}

View File

@ -1,238 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.multiplayer;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.event.Event;
import org.jackhuang.hmcl.event.EventManager;
import org.jackhuang.hmcl.util.FutureCallback;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
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 String sessionName;
private final int gamePort;
private final boolean allowAllJoinRequests;
private FutureCallback<CatoClient> onClientAdding;
private final EventManager<MultiplayerChannel.CatoClient> onClientAdded = new EventManager<>();
private final EventManager<MultiplayerChannel.CatoClient> onClientDisconnected = new EventManager<>();
private final EventManager<Event> onKeepAlive = new EventManager<>();
private final EventManager<Event> onHandshake = new EventManager<>();
private final Map<String, Endpoint> clients = new ConcurrentHashMap<>();
private final Map<String, Endpoint> nameClientMap = new ConcurrentHashMap<>();
public MultiplayerServer(String sessionName, int gamePort, boolean allowAllJoinRequests) {
this.sessionName = sessionName;
this.gamePort = gamePort;
this.allowAllJoinRequests = allowAllJoinRequests;
setName("MultiplayerServer");
setDaemon(true);
}
public void setOnClientAdding(FutureCallback<CatoClient> callback) {
onClientAdding = callback;
}
public EventManager<MultiplayerChannel.CatoClient> onClientAdded() {
return onClientAdded;
}
public EventManager<MultiplayerChannel.CatoClient> onClientDisconnected() {
return onClientDisconnected;
}
public EventManager<Event> onKeepAlive() {
return onKeepAlive;
}
public EventManager<Event> onHandshake() {
return onHandshake;
}
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(port);
start();
}
public int getPort() {
if (socket == null) {
throw new IllegalStateException("MultiplayerServer not started");
}
return socket.getLocalPort();
}
@Override
public void run() {
LOG.info("Multiplayer Server listening 127.0.0.1:" + socket.getLocalPort());
try {
while (!isInterrupted()) {
Socket clientSocket = socket.accept();
clientSocket.setSoTimeout(10000);
Lang.thread(() -> handleClient(clientSocket), "MultiplayerServerClientThread", true);
}
} catch (IOException ignored) {
}
}
public void kickPlayer(CatoClient player) {
Endpoint client = nameClientMap.get(player.getUsername());
if (client == null) return;
try {
if (client.socket.isConnected()) {
client.write(new KickResponse(KickResponse.KICKED));
client.socket.close();
}
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to kick player " + player.getUsername() + ". Maybe already disconnected?", e);
}
}
private void handleClient(Socket targetSocket) {
String address = targetSocket.getRemoteSocketAddress().toString();
String clientName = null;
LOG.info("Accepted client " + address);
try (Socket clientSocket = targetSocket;
BufferedReader reader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream(), StandardCharsets.UTF_8));
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(clientSocket.getOutputStream(), StandardCharsets.UTF_8))) {
clientSocket.setKeepAlive(true);
Endpoint endpoint = new Endpoint(clientSocket, writer);
clients.put(address, endpoint);
String line;
while ((line = reader.readLine()) != null) {
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());
clientName = joinRequest.getUsername();
if (!Objects.equals(MultiplayerManager.HIPER_VERSION, joinRequest.getClientVersion())) {
try {
endpoint.write(new KickResponse(KickResponse.VERSION_NOT_MATCHED));
LOG.info("Rejected join request from id=" + joinRequest.getUsername());
socket.close();
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to send kick response.", e);
return;
}
}
CatoClient catoClient = new CatoClient(this, clientName);
nameClientMap.put(clientName, endpoint);
onClientAdded.fireEvent(catoClient);
if (onClientAdding != null && !allowAllJoinRequests) {
onClientAdding.call(catoClient, () -> {
try {
endpoint.write(new JoinResponse(sessionName, gamePort));
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to send join response.", e);
try {
socket.close();
} catch (IOException ioException) {
LOG.log(Level.WARNING, "Failed to close socket caused by join response sending failure.", e);
this.interrupt();
}
}
}, msg -> {
try {
endpoint.write(new KickResponse(msg));
LOG.info("Rejected join request from id=" + joinRequest.getUsername());
socket.close();
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to send kick response.", e);
}
});
} else {
// Allow all join requests.
endpoint.write(new JoinResponse(sessionName, gamePort));
}
} else if (request instanceof KeepAliveRequest) {
endpoint.write(new KeepAliveResponse(System.currentTimeMillis()));
onKeepAlive.fireEvent(new Event(this));
} else if (request instanceof HandshakeRequest) {
endpoint.write(new HandshakeResponse());
onHandshake.fireEvent(new Event(this));
} else {
LOG.log(Level.WARNING, "Unrecognized packet from client " + targetSocket.getRemoteSocketAddress() + ":" + line);
}
}
} 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);
} finally {
if (clientName != null) {
onClientDisconnected.fireEvent(new CatoClient(this, clientName));
}
clients.remove(address);
if (clientName != null) nameClientMap.remove(clientName);
}
}
public static class Endpoint {
public final Socket socket;
public final BufferedWriter writer;
public Endpoint(Socket socket, BufferedWriter writer) {
this.socket = socket;
this.writer = writer;
}
public synchronized void write(Object object) throws IOException {
writer.write(verifyJson(JsonUtils.UGLY_GSON.toJson(object)));
writer.newLine();
writer.flush();
}
}
}

View File

@ -843,13 +843,7 @@ multiplayer.download=Downloading Dependencies
multiplayer.download.failed=Failed to initialize multiplayer feature, some files cannot be downloaded.
multiplayer.download.success=Dependencies initialization completed
multiplayer.download.unsupported=The current operating system or architecture is unsupported.
multiplayer.exit.after_ready=The multiplayer session exited abnormally, cato exitcode %d
multiplayer.exit.before_ready=Unable to create a multiplayer session, cato exitcode %d
multiplayer.exit.timeout.static_token=Failed to connect to the multiplayer provider. Your token may not be working, you can try to clear it and then try again.\n\
Or, you can wait for a few minutes. If the feature still won't work, please refer to the administrators for more help.
multiplayer.exit.timeout.dynamic_token=Unable to connect to the multiplayer service, authentication servers may have too many requests at the time. Please wait until the next half past to allow the servers to clear some sessions.\n\
\n\
You can also buy some multiplayer tokens to get a stable multiplayer experience.
multiplayer.exit=HiPer exited abnormally, exitcode %d
multiplayer.hint=The multiplayer feature is currently in the alpha stage. Please provide your feedback at mcer.cn.
multiplayer.nat=Network Detection
multiplayer.nat.failed=Unable to detect the network type.
@ -871,10 +865,6 @@ multiplayer.nat.type.symmetric=Bad (Symmetric)
multiplayer.nat.type.symmetric_udp_firewall=Bad (Symmetric with UDP Firewall)
multiplayer.nat.type.unknown=Unknown
multiplayer.powered_by=Multiplayer service is provided by <a href\="https\://mcer.cn">mcer.cn</a>. <a href\="https\://hmcl.huangyuhui.net/api/redirect/multiplayer-agreement">Terms of Service</a>
multiplayer.relay=Relay Mode
multiplayer.relay.hint=The relay mode provides a smooth multiplayer experience for users with a poor Internet connection.\n\
\n\
You can enable this option after getting some tokens on mcer.cn.
multiplayer.report=Report
multiplayer.session=Session
multiplayer.session.name.format=%1$s's Session
@ -895,39 +885,13 @@ multiplayer.session.create.members.kick.prompt=Do you want to ban this player?
multiplayer.session.create.name=Name
multiplayer.session.create.port=Port
multiplayer.session.create.port.error=Unable to detect the game port, you must click on "Open to Lan" in the game's pause menu to allow others to join your world.
multiplayer.session.create.token=Token
multiplayer.session.create.token.apply=Apply for a Static Token
multiplayer.session.create.token.prompt=Random token as default, you can click on 'Apply for a Static Token' for more information.
multiplayer.session.error.already_started=Cato service is already running, please check if there are any other HMCL instances running, or terminate cato process before continuing.
multiplayer.session.error.file_not_found=Unable to find cato executable. This program should already be downloaded after your enter this page, please reopen the launcher and try again.\n\
Also, please check if your anti-virus software marked it as malware. If so, please release it from your anti-virus software's quarantine.
multiplayer.session.expired=The multiplayer session has exceeded the maximum duration of 3 hours. Please recreate or rejoin a session.
multiplayer.session.join=Join
multiplayer.session.join.error=Unable to join the multiplayer session. You can try to use the relay mode instead.
multiplayer.session.join.error.connection=Unable to join the multiplayer session. Please make sure you are using the same HMCL version as the session host.
multiplayer.session.join.hint=You must first obtain the session code from the host to join it.
multiplayer.session.join.invitation_code=Invitation Code
multiplayer.session.join.invitation_code.error=Invalid invitation code. Please obtain one from the multiplayer session host, and make sure you are running the same HMCL version.
multiplayer.session.join.kicked=Lost connection to session\: %s.
multiplayer.session.join.kicked.join_acceptance_timeout=The host did not accept your request in time.
multiplayer.session.join.kicked.kicked=You have been kicked by the host.
multiplayer.session.join.kicked.version_not_matched=Inconsistent versions of multiplayer libraries detected, please make sure you are using the same as the session host.
multiplayer.session.join.lost_connection=Lost connection to the multiplayer session. The host might have ended it, or the server shut it down because it exceeds the time limit, or there are some issues with your Internet connection.
multiplayer.session.join.port.error=Unable to bind an available local network port for listening. Please make sure that HMCL has the privileges to listen on a port.
multiplayer.session.join.rejected=The host rejected your connection request.
multiplayer.session.join.wait=Waiting for the host to accept...
multiplayer.session.members=Members
multiplayer.session.quit=Leave
multiplayer.session.quit.warning=You will be disconnected from the server if you quit, continue?
multiplayer.session.username=Username
multiplayer.state.connecting=Connecting
multiplayer.state.disconnected=Not in a Session
multiplayer.state.disconnected.hint=The host should create a session first before others can join their world.
multiplayer.state.master=Session %1$s created, port\: %2$d
multiplayer.state.master.hint=After creating a session, you can now share your invitation code with your peers. They can now join your session after entering the code on the HMCL multiplayer page.
multiplayer.state.slave=Joined session %1$s, address\: %2$s
multiplayer.state.slave.copy=Copy Address
multiplayer.state.slave.hint=After joining a multiplayer session, you can now join the multiplayer by joining the "HMCL Multiplayer Session" server shown in the Minecraft multiplayer server list or manually connect to the below address.
multiplayer.token=Token
multiplayer.token.apply=Apply for a Static Token
multiplayer.token.expired=HiPer certificate was expired. Please restart HMCL and retry.
multiplayer.token.malformed=HiPer configuration file is malformed. Please restart HMCL and retry.
multiplayer.token.prompt=You must get a token to use multiplayer service.
multiplayer.file_not_found=Unable to find cato executable. This program should already be downloaded after your enter this page, please reopen the launcher and try again.\n\
Also, please check if your antivirus software marked it as malware. If so, please release it from your anti-virus software's quarantine.
datapack=Datapacks
datapack.add=Install datapack

View File

@ -690,14 +690,11 @@ multiplayer.download=正在下載相依元件
multiplayer.download.failed=初始化失敗,部分檔案未能完成下載
multiplayer.download.success=多人聯機初始化完成
multiplayer.download.unsupported=多人聯機依賴不支持當前系統或平台
multiplayer.exit.after_ready=多人聯機會話意外退出,退出碼 %d
multiplayer.exit.before_ready=多人聯機房間創建失敗cato 退出碼 %d
multiplayer.exit.timeout.static_token=無法連接多人聯機服務,你的憑證可能無法正常工作,你可以清空憑證再試。\n你可以聯系在線管理員請問鑑權伺服器是否正常工作或者耐心等待十分鍾。
multiplayer.exit.timeout.dynamic_token=無法連接多人聯機服務,你可以在多人聯機頁面的回饋中回饋問題。
multiplayer.hint=多人聯機功能處於實驗階段,如果有問題請回饋。
multiplayer.exit=HiPer 意外退出,退出碼 %d
multiplayer.hint=多人聯機功能處於實驗階段,如果有問題請前往 mcer.cn 回饋
multiplayer.nat=網路檢測
multiplayer.nat.failed=檢測失敗,但你仍然可以繼續使用聯機功能。
multiplayer.nat.hint=執行網路檢測可以讓你更清楚你的網路狀況是否符合聯機功能的需求。不符合聯機功能運行條件的網路狀況將可能導致聯機失敗。
multiplayer.nat.failed=檢測失敗
multiplayer.nat.hint=執行網路檢測可以讓你更清楚你的網路狀況是否符合聯機功能的需求\n若檢測結果為 差 或 檢測失敗 的網路可能導致聯機失敗!
multiplayer.nat.latency=延遲
multiplayer.nat.not_yet_tested=尚未檢測
multiplayer.nat.packet_loss_ratio=丟包率
@ -711,61 +708,26 @@ multiplayer.nat.type.restricted_cone=中(受限圓錐型)
multiplayer.nat.type.symmetric=差(對稱型)
multiplayer.nat.type.symmetric_udp_firewall=差(對稱型+防火牆)
multiplayer.nat.type.unknown=未知
multiplayer.powered_by=多人聯機服務由(<a href="https://mcer.cn">mcer.cn</a>) 提供。<a href="https://hmcl.huangyuhui.net/api/redirect/multiplayer-agreement">用戶協議與免責聲明</a>
multiplayer.powered_by=多人聯機服務由 (<a href="https://mcer.cn">mcer.cn</a>) 提供。<a href="https://hmcl.huangyuhui.net/api/redirect/multiplayer-agreement">用戶協議與免責聲明</a>
multiplayer.report=違法違規檢舉
multiplayer.relay=橋接服務
multiplayer.relay.hint=Cato 面向較差網路提供的網路質量增強最佳化解決方案|若無法聯機請到 mcer.cn 的個人中心兌換憑證後啟用
multiplayer.session=房間
multiplayer.session.name.format=%1$s 的房間
multiplayer.session.name.motd=HMCL 多人聯機房間 - %s
multiplayer.session.close=關閉房間
multiplayer.session.close.warning=關閉房間後,已經加入聯機房間的玩家將會斷開連接,是否繼續?
multiplayer.session.copy_room_code=複製邀請碼
multiplayer.session.create=創建房間
multiplayer.session.create.error.dynamic_token=創建聯機房間失敗,請稍後再試。
multiplayer.session.create.error.static_token=創建聯機房間失敗,你的憑證可能無法正常工作,你可以清空憑證再試。\n你可以聯系在線管理員請問鑑權伺服器是否正常工作或者耐心等待十分鍾。
multiplayer.session.create.hint=創建聯機房間前,你需要先在正在運行的遊戲內的遊戲菜單中選擇 對區域網路開放 選項,然後在下方的輸入框中輸入遊戲內提示的埠號(通常是 5 位的數字)
multiplayer.session.create.join=連接申請
multiplayer.session.create.join.allow=自動接受所有連接申請(不啟用此選項時,你需要手動同意申請,以避免不相關人士誤連你的伺服器)
multiplayer.session.create.join.prompt=玩家 %s 申請加入多人聯機房間,是否接受?
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=無法檢測遊戲埠號,你必須先啟動遊戲並在遊戲內打開對區域網路開放選項後才能啟動聯機。
multiplayer.session.create.token=憑證
multiplayer.session.create.token.apply=申請憑證
multiplayer.session.create.token.prompt=預設為無憑證。你可以點擊旁邊的“申請憑證”獲得詳情
multiplayer.session.error.already_started=本地已經開啟 cato 服務,請檢查是否有其他 HMCL 正在運行聯機服務。或者你可以在任務管理器裡殺掉 cato 進程以繼續。
multiplayer.session.error.file_not_found=找不到 cato 程序。該程序應該在進入多人聯機頁面時完成下載。請檢查你電腦的防毒軟體是否將 cato 標記為病毒,如果是,請恢復 cato。
multiplayer.session.expired=聯機會話連續使用時間超過了 3 小時,你需要重新創建/加入房間以繼續聯機。
multiplayer.session.join=加入房間
multiplayer.session.join.error=加入房間失敗
multiplayer.session.join.error.connection=加入房間失敗。無法與對方建立連接。如果你或對方的網路類型是差(對稱型),可能無法使用聯機功能。
multiplayer.session.join.hint=你需要向已經創建好房間的玩家索要邀請碼以便加入多人聯機房間
multiplayer.session.join.invitation_code=邀請碼
multiplayer.session.join.invitation_code.error=邀請碼不正確,請向開服玩家獲取邀請碼
multiplayer.session.join.kicked=你與房間失去連接:%s。
multiplayer.session.join.kicked.join_acceptance_timeout=對方未能即時同意你的加入申請
multiplayer.session.join.kicked.kicked=你被房主踢出房間。
multiplayer.session.join.kicked.version_not_matched=多人聯機功能版本號不一致,請保證連接多人聯機功能版本號一致。
multiplayer.session.join.lost_connection=你已與房間失去連接。這可能意味著房主已經解散房間,或者你無法連接至房間。
multiplayer.session.join.port.error=無法找到可用的本地網路埠,請確保 HMCL 擁有綁定本地埠的權限。
multiplayer.session.join.rejected=你被房主拒絕連接。
multiplayer.session.join.wait=等待對方同意加入申請。
multiplayer.session.members=房間成員
multiplayer.session.quit=退出房間
multiplayer.session.quit.warning=退出房間後,你將會與伺服器斷開連接,是否繼續?
multiplayer.session.username=使用者名稱
multiplayer.state.connecting=連接中
multiplayer.state.disconnected=未創建/加入房間
multiplayer.state.disconnected.hint=多人聯機功能需要先有一位玩家創建房間後,其他玩家加入房間後繼續遊戲。
multiplayer.state.master=你已創建房間:%1$s埠號 %2$d
multiplayer.state.master.hint=創建房間後,你可以點擊複製邀請碼,並發送給你希望加入聯機的玩家。這些玩家在進入 HMCL 多人聯機頁面後,點擊加入房間並黏貼邀請碼即可加入聯機房間。
multiplayer.state.slave=你已加入房間: %1$s位址為 %2$s
multiplayer.state.slave.copy=拷貝位址
multiplayer.state.slave.hint=加入房間後,你需要在 Minecraft 的多人遊戲頁面選擇 HMCL 多人聯機房間伺服器,或者手動添加下方的地址的伺服器。
multiplayer.token=憑證
multiplayer.token.apply=申請憑證
multiplayer.token.expired=HiPer 證書過期,請重新啟動 HMCL 再試。
multiplayer.token.malformed=HiPer 配置檔案無法解析,請重新啟動 HMCL 再試。
multiplayer.token.prompt=你需要憑證才能使用多人聯機服務。點擊旁邊的“申請憑證”查看詳情
multiplayer.session.error.file_not_found=找不到 HiPer 程式。該程式應該在進入多人聯機頁面時完成下載。請重啟 HMCL 再試。\n請檢查你電腦的防毒軟體是否將 HiPer 標記為病毒,如果是,請恢復 HiPer。
multiplayer.off=啟動 HiPer
multiplayer.off.hint=多人聯機服務由 HiPer 提供,你需要先啟動 HiPer 以使用多人聯機服務。
multiplayer.off.start=啟動 HiPer
multiplayer.on=關閉 HiPer
multiplayer.master=開服者提示
multiplayer.master.hint=多人聯機需要有一位玩家先啟動遊戲,並選擇單人遊戲模式進入一個存檔後,在遊戲選項菜單內選擇"對區域網路開放"選項。之後你可以在遊戲聊天框內看到遊戲提示的埠號(通常是 5 位數字)。點擊下方的生成伺服器地址按鈕,輸入埠號,你就可以獲得你的伺服器地址了。該地址需要提供給其他需要加入伺服器的玩家用於添加伺服器。
multiplayer.master.server_address=生成伺服器地址
multiplayer.master.server_address.copy=拷貝
multiplayer.master.port=埠號
multiplayer.master.port.validate=在遊戲聊天框中出現的埠號 (0~65535)
multiplayer.slave=參與者提示
multiplayer.slave.hint=如果你想加入其他玩家的遊戲存檔進行遊戲,你需要要求那個玩家參照開服者提示的操作開啟對區域網路開放模式。然後啟動遊戲,並選擇多人遊戲模式,選擇添加伺服器。遊戲會要求你輸入伺服器地址,你只需要向開服玩家索要伺服器地址並輸入,並進入伺服器即可。
datapack=資料包
datapack.add=加入資料包

View File

@ -690,10 +690,8 @@ multiplayer.download=正在下载依赖
multiplayer.download.failed=初始化失败,部分文件未能完成下载
multiplayer.download.success=多人联机初始化完成
multiplayer.download.unsupported=多人联机依赖不支持当前系统或平台
multiplayer.exit.after_ready=多人联机会话意外退出,退出码 %d
multiplayer.exit.before_ready=多人联机房间创建失败cato 退出码 %d
multiplayer.exit.timeout.static_token=无法连接多人联机服务,你的凭证可能无法正常工作,你可以清空凭证再试。\n你可以联系在线管理员询问鉴权服务器是否正常工作或者耐心等待十分钟。
multiplayer.exit.timeout.dynamic_token=无法连接多人联机服务,可能是鉴权服务器被人占满,请等待每个半点!鉴权服务器会自动刷新一次\n没有耐心且有闲钱的可以考虑购买联机凭证不受鉴权服务器影响
multiplayer.error.file_not_found=找不到 HiPer 程序。该程序应该在进入多人联机页面时完成下载。请重启 HMCL 再试。\n请检查你电脑的杀毒软件是否将 HiPer 标记为病毒,如果是,请恢复 HiPer。
multiplayer.exit=HiPer 意外退出,退出码 %d
multiplayer.hint=多人联机功能处于实验阶段,如果有问题请前往 mcer.cn 反馈
multiplayer.nat=网络检测
multiplayer.nat.failed=检测失败
@ -711,61 +709,26 @@ multiplayer.nat.type.restricted_cone=中(受限圆锥型)
multiplayer.nat.type.symmetric=差(对称型)
multiplayer.nat.type.symmetric_udp_firewall=差(对称型+防火墙)
multiplayer.nat.type.unknown=未知
multiplayer.powered_by=多人联机服务由 (<a href="https://mcer.cn">mcer.cn</a>) 提供 <a href="https://hmcl.huangyuhui.net/api/redirect/multiplayer-agreement">用户协议与免责声明</a>
multiplayer.relay=桥接服务
multiplayer.relay.hint=Cato 面向较差网络提供的联机质量优化解决方案|无法联机时可在 mcer.cn 兑换凭证后启用
multiplayer.powered_by=多人联机服务由 (<a href="https://mcer.cn">mcer.cn</a>) 提供。<a href="https://hmcl.huangyuhui.net/api/redirect/multiplayer-agreement">用户协议与免责声明</a>
multiplayer.report=违法违规举报
multiplayer.session=房间
multiplayer.session.name.format=%1$s 的房间
multiplayer.session.name.motd=HMCL 多人联机房间 - %s
multiplayer.session.close=关闭房间
multiplayer.session.close.warning=关闭房间后,已经加入联机房间的玩家将会断开连接,是否继续?
multiplayer.session.copy_room_code=拷贝邀请码
multiplayer.session.create=创建房间
multiplayer.session.create.error.dynamic_token=创建联机房间失败,可能是服务器出现问题,请稍后再试……
multiplayer.session.create.error.static_token=创建联机房间失败,你的凭证可能无法正常工作,你可以清空凭证再试……\n你可以联系在线管理员询问鉴权服务器是否正常工作或者耐心等待十分钟。
multiplayer.session.create.hint=创建联机房间前,你需要先在正在运行的游戏内的游戏菜单中选择 对局域网开放 选项,然后在下方的输入框中确认游戏内提示的端口号(通常是 5 位的数字)
multiplayer.session.create.join=连接申请
multiplayer.session.create.join.allow=自动接受所有连接申请(不启用时,需要手动同意申请,以免无关人士误连服务器)
multiplayer.session.create.join.prompt=玩家 %s 申请加入多人联机房间,是否接受?
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=无法检测游戏端口号,你须启动游戏并在游戏内 打开对局域网 开放选项方能启动联机!
multiplayer.session.create.token=凭证
multiplayer.session.create.token.apply=申请凭证
multiplayer.session.create.token.prompt=默认为无凭证。你可以点击旁边的“申请凭证”获得详情
multiplayer.session.error.already_started=本地已经开启 cato 服务,请检查是否有其他 HMCL 正在运行联机服务。或者你可以在任务管理器里杀掉 cato 进程以继续。
multiplayer.session.error.file_not_found=找不到 cato 程序。该程序应该在进入多人联机页面时完成下载。请重启HMCL再试。\n请检查你电脑的杀毒软件是否将 cato 标记为病毒,如果是,请恢复 cato。
multiplayer.session.expired=联机会话连续使用时间超过了 3 小时,你需要重新创建/加入房间以继续联机!
multiplayer.session.join=加入房间
multiplayer.session.join.error=加入房间失败。若你或对方网络类型是 差(对称型)或 检测失败,可能无法使用联机功能,你可以使用凭证开启桥接模式后再试……
multiplayer.session.join.error.connection=加入房间失败。无法与对方建立连接。请检查你和对方的HMCL版本是否一致。\n如果你或对方的网络类型是差对称型可能无法使用联机功能。
multiplayer.session.join.hint=你需要向已经创建好房间的玩家索要邀请码以便加入多人联机房间
multiplayer.session.join.invitation_code=邀请码
multiplayer.session.join.invitation_code.error=邀请码格式不合法!请向房主获取邀请码,且检查你和对方使用的 HMCL 版本是否一致!
multiplayer.session.join.kicked=你与房间失去连接:%s。
multiplayer.session.join.kicked.join_acceptance_timeout=对方未能即时同意你的加入申请
multiplayer.session.join.kicked.kicked=你被房主踢出房间。
multiplayer.session.join.kicked.version_not_matched=多人联机功能版本号不一致,请保证连接多人联机功能版本号一致。
multiplayer.session.join.lost_connection=你已与房间失去连接。这可能意味着房主已经解散房间(非桥接服务的房间每个半点服务器自动刷新),或者你无法连接至房间……
multiplayer.session.join.port.error=无法找到可用的本地网络端口,请确保 HMCL 拥有绑定本地端口的权限!
multiplayer.session.join.rejected=你被房主拒绝连接,或房主忘记同意……
multiplayer.session.join.wait=等待对方同意加入申请……
multiplayer.session.members=房间成员
multiplayer.session.quit=退出房间
multiplayer.session.quit.warning=退出房间后,你将会与服务器断开连接,是否继续?
multiplayer.session.username=用户名
multiplayer.state.connecting=连接中
multiplayer.state.disconnected=未创建/加入房间
multiplayer.state.disconnected.hint=多人联机功能需要先有一位玩家创建房间后,其他玩家加入房间后继续游戏。
multiplayer.state.master=你已创建房间:%1$s端口号 %2$d
multiplayer.state.master.hint=创建房间后,你可以点击拷贝邀请码,并发送给你希望加入联机的玩家。这些玩家在进入 HMCL 多人联机页面后,点击加入房间并粘贴邀请码即可加入联机房间。
multiplayer.state.slave=你已加入房间: %1$s地址为 %2$s
multiplayer.state.slave.copy=拷贝地址
multiplayer.state.slave.hint=加入房间后,你需要在 Minecraft 的多人游戏页面选择 HMCL 多人联机房间服务器,或者手动添加下方的地址的服务器。
multiplayer.token=凭证
multiplayer.token.apply=申请凭证
multiplayer.token.expired=HiPer 证书过期,请重新启动 HMCL 再试。
multiplayer.token.invalid=凭证不正确。
multiplayer.token.malformed=HiPer 配置文件无法解析,请重新启动 HMCL 再试。
multiplayer.token.prompt=你需要凭证才能使用多人联机服务。点击旁边的“申请凭证”查看详情
multiplayer.off=启动 HiPer
multiplayer.off.hint=多人联机服务由 HiPer 提供,你需要先启动 HiPer 以使用多人联机服务。
multiplayer.off.start=启动 HiPer
multiplayer.on=关闭 HiPer
multiplayer.master=开服者提示
multiplayer.master.hint=多人联机需要有一位玩家先启动游戏,并选择单人游戏模式进入一个存档后,在游戏选项菜单内选择"对局域网开放"选项。之后你可以在游戏聊天框内看到游戏提示的端口号(通常是 5 位数字)。点击下方的生成服务器地址按钮,输入端口号,你就可以获得你的服务器地址了。该地址需要提供给其他需要加入服务器的玩家用于添加服务器。
multiplayer.master.server_address=生成服务器地址
multiplayer.master.server_address.copy=拷贝
multiplayer.master.port=端口号
multiplayer.master.port.validate=在游戏聊天框中出现的端口号 (0~65535)
multiplayer.slave=参与者提示
multiplayer.slave.hint=如果你想加入其他玩家的游戏存档进行游戏,你需要要求那个玩家参照开服者提示的操作开启对局域网开放模式。然后启动游戏,并选择多人游戏模式,选择添加服务器。游戏会要求你输入服务器地址,你只需要向开服玩家索要服务器地址并输入,并进入服务器即可。
datapack=数据包
datapack.add=添加数据包

View File

@ -1,69 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.multiplayer;
import org.junit.Ignore;
import org.junit.Test;
import java.io.IOException;
import java.net.*;
import java.nio.charset.StandardCharsets;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class LocalServerBroadcastTest {
@Test
@Ignore("for manually testing")
public void test() {
int port = 12345;
DatagramSocket socket;
InetAddress broadcastAddress;
try {
socket = new DatagramSocket();
broadcastAddress = InetAddress.getByName("224.0.2.60");
} catch (IOException e) {
e.printStackTrace();
return;
}
while (true) {
try {
byte[] data = String.format("[MOTD]%s[/MOTD][AD]%d[/AD]", i18n("multiplayer.session.name.motd", "Test server"), port).getBytes(StandardCharsets.UTF_8);
DatagramPacket packet = new DatagramPacket(data, 0, data.length, broadcastAddress, 4445);
socket.send(packet);
System.out.println("Broadcast server 127.0.0.1:" + port);
} catch (IOException e) {
e.printStackTrace();
}
try {
Thread.sleep(1500);
} catch (InterruptedException ignored) {
return;
}
}
}
@Test
@Ignore
public void printLocalAddress() throws IOException {
DatagramSocket socket = new DatagramSocket(new InetSocketAddress((InetAddress) null, 4444));
System.out.println(socket.getLocalAddress());
}
}

View File

@ -1,49 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.multiplayer;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.Logging;
import org.junit.Ignore;
import org.junit.Test;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.net.SocketException;
public class LocalServerDetectorTest {
@Test
@Ignore("for manually testing")
public void test() {
try {
for (NetworkInterface networkInterface : Lang.toIterable(NetworkInterface.getNetworkInterfaces())) {
System.out.println(networkInterface.getName());
for (InetAddress address : Lang.toIterable(networkInterface.getInetAddresses())) {
System.out.println(address);
}
}
} catch (SocketException e) {
e.printStackTrace();
}
Logging.initForTest();
LocalServerDetector detector = new LocalServerDetector(3);
detector.run();
}
}

View File

@ -1,55 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.multiplayer;
import org.jackhuang.hmcl.util.Logging;
import org.junit.Assert;
import org.junit.Ignore;
import org.junit.Test;
import java.util.concurrent.atomic.AtomicBoolean;
public class MultiplayerClientServerTest {
@Test
@Ignore
public void startServer() throws Exception {
Logging.initForTest();
int localPort = MultiplayerManager.findAvailablePort();
MultiplayerServer server = new MultiplayerServer("SessionName", 1000, true);
server.startServer(localPort);
MultiplayerClient client = new MultiplayerClient("username", localPort);
client.start();
AtomicBoolean handshakeReceived = new AtomicBoolean(false);
server.onHandshake().register(event -> {
handshakeReceived.set(true);
});
server.onKeepAlive().register(event -> {
client.interrupt();
server.interrupt();
});
server.join();
Assert.assertTrue(handshakeReceived.get());
}
}

View File

@ -141,6 +141,21 @@ public final class FileUtils {
writeText(file, text, UTF_8);
}
/**
* Write plain text to file. Characters are encoded into bytes using UTF-8.
* <p>
* We don't care about platform difference of line separator. Because readText accept all possibilities of line separator.
* It will create the file if it does not exist, or truncate the existing file to empty for rewriting.
* All characters in text will be written into the file in binary format. Existing data will be erased.
*
* @param file the path to the file
* @param text the text being written to file
* @throws IOException if an I/O error occurs
*/
public static void writeText(Path file, String text) throws IOException {
writeText(file, text, UTF_8);
}
/**
* Write plain text to file.
* <p>
@ -157,18 +172,47 @@ public final class FileUtils {
writeBytes(file, text.getBytes(charset));
}
/**
* Write plain text to file.
* <p>
* We don't care about platform difference of line separator. Because readText accept all possibilities of line separator.
* It will create the file if it does not exist, or truncate the existing file to empty for rewriting.
* All characters in text will be written into the file in binary format. Existing data will be erased.
*
* @param file the path to the file
* @param text the text being written to file
* @param charset the charset to use for encoding
* @throws IOException if an I/O error occurs
*/
public static void writeText(Path file, String text, Charset charset) throws IOException {
writeBytes(file, text.getBytes(charset));
}
/**
* Write byte array to file.
* It will create the file if it does not exist, or truncate the existing file to empty for rewriting.
* All bytes in byte array will be written into the file in binary format. Existing data will be erased.
*
* @param file the path to the file
* @param array the data being written to file
* @param data the data being written to file
* @throws IOException if an I/O error occurs
*/
public static void writeBytes(File file, byte[] array) throws IOException {
Files.createDirectories(file.toPath().getParent());
Files.write(file.toPath(), array);
public static void writeBytes(File file, byte[] data) throws IOException {
writeBytes(file.toPath(), data);
}
/**
* Write byte array to file.
* It will create the file if it does not exist, or truncate the existing file to empty for rewriting.
* All bytes in byte array will be written into the file in binary format. Existing data will be erased.
*
* @param file the path to the file
* @param data the data being written to file
* @throws IOException if an I/O error occurs
*/
public static void writeBytes(Path file, byte[] data) throws IOException {
Files.createDirectories(file.getParent());
Files.write(file, data);
}
public static void deleteDirectory(File directory)