feat: show server status (players, latency) on the home card via Server List Ping
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
07ff244cd2
commit
095d71fea7
7 files changed
+264
-1
No files matched your search
@@ -37,12 +37,16 @@ import org.jackhuang.hmcl.ui.construct.PopupMenu;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
|
||||
import org.jackhuang.hmcl.ui.instances.GameListPopupMenu;
|
||||
import org.jackhuang.hmcl.ui.instances.Instances;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.ServerAddress;
|
||||
import org.jackhuang.hmcl.util.ServerStatusPing;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.javafx.BindingMapping;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.UnmodifiableView;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
@@ -124,7 +128,11 @@ public final class MainPage extends StackPane implements DecoratorPage {
|
||||
routeValue.getStyleClass().add("home-fact-value");
|
||||
VBox packFact = new VBox(3, packLabel, packValue);
|
||||
VBox routeFact = new VBox(3, routeLabel, routeValue);
|
||||
HBox facts = new HBox(34, packFact, routeFact);
|
||||
Label serverLabel = factLabel(i18n("home.server"));
|
||||
Label serverValue = new Label();
|
||||
serverValue.getStyleClass().add("home-fact-value");
|
||||
VBox serverFact = new VBox(3, serverLabel, serverValue);
|
||||
HBox facts = new HBox(34, packFact, routeFact, serverFact);
|
||||
facts.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
JFXButton joinButton = new JFXButton();
|
||||
@@ -146,6 +154,8 @@ public final class MainPage extends StackPane implements DecoratorPage {
|
||||
packValue.setText(i18n("home.not_installed"));
|
||||
routeFact.setVisible(false);
|
||||
routeFact.setManaged(false);
|
||||
serverFact.setVisible(false);
|
||||
serverFact.setManaged(false);
|
||||
} else {
|
||||
joinButton.setText(i18n("server.join"));
|
||||
FXUtils.setOnActionWithCooldown(joinButton, MainPage.this::launch);
|
||||
@@ -154,6 +164,7 @@ public final class MainPage extends StackPane implements DecoratorPage {
|
||||
routeFact.setVisible(route != null);
|
||||
routeFact.setManaged(route != null);
|
||||
routeValue.setText(route != null ? route.name() : "");
|
||||
refreshServerStatus(route, serverFact, serverValue);
|
||||
|
||||
// With more than one route (for example direct and CDN), the route can be switched.
|
||||
List<ServerEndpoint.Route> routes = ServerEndpoint.routes(currentGame.getModpackConfigurationFile());
|
||||
@@ -170,6 +181,7 @@ public final class MainPage extends StackPane implements DecoratorPage {
|
||||
items.add(new IconedMenuItem(null, candidate.name(), () -> {
|
||||
ServerEndpoint.select(currentGame.getModpackConfigurationFile(), candidate);
|
||||
routeValue.setText(candidate.name() + " ▾");
|
||||
refreshServerStatus(candidate, serverFact, serverValue);
|
||||
}, popup));
|
||||
}
|
||||
list.getContent().setAll(items);
|
||||
@@ -181,6 +193,32 @@ public final class MainPage extends StackPane implements DecoratorPage {
|
||||
getChildren().addAll(art, hero, card);
|
||||
}
|
||||
|
||||
/// Pings the server behind `route` in the background and shows the result in `serverValue`.
|
||||
///
|
||||
/// WebSocket routes cannot be pinged with plain TCP, so the status is hidden for them.
|
||||
private static void refreshServerStatus(ServerEndpoint.@Nullable Route route, Node serverFact, Label serverValue) {
|
||||
String address = route == null ? "" : route.address().trim();
|
||||
boolean pingable = !address.isEmpty() && !address.toLowerCase(java.util.Locale.ROOT).startsWith("ws");
|
||||
serverFact.setVisible(pingable);
|
||||
serverFact.setManaged(pingable);
|
||||
if (!pingable) {
|
||||
return;
|
||||
}
|
||||
serverValue.setText("…");
|
||||
Schedulers.io().execute(() -> {
|
||||
String text;
|
||||
try {
|
||||
ServerAddress parsed = ServerAddress.parse(address);
|
||||
ServerStatusPing.Status status = ServerStatusPing.ping(parsed.host(), parsed.port() >= 0 ? parsed.port() : 25565, 3000);
|
||||
text = status.online() + " / " + status.max() + " · " + status.latencyMillis() + " ms";
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
text = i18n("home.server.unreachable");
|
||||
}
|
||||
String result = text;
|
||||
FXUtils.runInFX(() -> serverValue.setText(result));
|
||||
});
|
||||
}
|
||||
|
||||
/// Creates the small caption above a fact value in the home card.
|
||||
private static Label factLabel(String text) {
|
||||
Label label = new Label(text);
|
||||
|
||||
@@ -806,6 +806,8 @@ first_run.name=Game name
|
||||
first_run.accept=Start playing
|
||||
first_run.microsoft=Sign in with Microsoft
|
||||
first_run.invalid=Use 1 to 16 letters, digits or underscores.
|
||||
home.server=Server
|
||||
home.server.unreachable=Unreachable
|
||||
server.join=Join Server
|
||||
instance.launch_and_enter_world=Play World
|
||||
instance.launch.empty=Start Game
|
||||
|
||||
@@ -626,6 +626,8 @@ first_run.name=名
|
||||
first_run.accept=始
|
||||
first_run.microsoft=登正版
|
||||
first_run.invalid=一至十六字,限英文數字底線。
|
||||
home.server=服
|
||||
home.server.unreachable=不通
|
||||
server.join=入服
|
||||
# instance.launch_and_enter_world=
|
||||
instance.launch.empty=啟戲
|
||||
|
||||
@@ -629,6 +629,8 @@ first_run.name=游戏名
|
||||
first_run.accept=开始游戏
|
||||
first_run.microsoft=登录正版账号
|
||||
first_run.invalid=只能用 1 到 16 个英文字母、数字或下划线。
|
||||
home.server=服务器
|
||||
home.server.unreachable=连接不上
|
||||
server.join=进入服务器
|
||||
instance.launch_and_enter_world=进入世界
|
||||
instance.launch.empty=开始游戏
|
||||
|
||||
@@ -623,6 +623,8 @@ first_run.name=遊戲名
|
||||
first_run.accept=開始遊戲
|
||||
first_run.microsoft=登入正版帳號
|
||||
first_run.invalid=只能使用 1 到 16 個英文字母、數字或底線。
|
||||
home.server=伺服器
|
||||
home.server.unreachable=無法連線
|
||||
server.join=進入伺服器
|
||||
instance.launch_and_enter_world=進入世界
|
||||
instance.launch.empty=開始遊戲
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.util;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParseException;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/// Queries a Minecraft server's status with the Server List Ping protocol.
|
||||
///
|
||||
/// Only direct TCP addresses are supported; WebSocket routes cannot be pinged this way.
|
||||
@NotNullByDefault
|
||||
public final class ServerStatusPing {
|
||||
private ServerStatusPing() {
|
||||
}
|
||||
|
||||
/// The result of a successful ping.
|
||||
///
|
||||
/// @param online the number of players online
|
||||
/// @param max the player limit
|
||||
/// @param latencyMillis the round-trip time of the ping packet in milliseconds
|
||||
public record Status(int online, int max, long latencyMillis) {
|
||||
}
|
||||
|
||||
/// The protocol number sent in the handshake; Minecraft 1.7.10 uses 5 and servers answer any value.
|
||||
private static final int PROTOCOL_VERSION = 5;
|
||||
|
||||
/// Pings a server.
|
||||
///
|
||||
/// @param host the server host name
|
||||
/// @param port the server port
|
||||
/// @param timeoutMillis the connect and read timeout
|
||||
/// @return the server status
|
||||
/// @throws IOException if the server cannot be reached or answers with something unexpected
|
||||
public static Status ping(String host, int port, int timeoutMillis) throws IOException {
|
||||
try (Socket socket = new Socket()) {
|
||||
socket.connect(new InetSocketAddress(host, port), timeoutMillis);
|
||||
socket.setSoTimeout(timeoutMillis);
|
||||
DataOutputStream out = new DataOutputStream(socket.getOutputStream());
|
||||
DataInputStream in = new DataInputStream(socket.getInputStream());
|
||||
|
||||
// Handshake with next state "status", then the status request.
|
||||
ByteArrayOutputStream handshake = new ByteArrayOutputStream();
|
||||
DataOutputStream h = new DataOutputStream(handshake);
|
||||
writeVarInt(h, 0x00);
|
||||
writeVarInt(h, PROTOCOL_VERSION);
|
||||
byte[] hostBytes = host.getBytes(StandardCharsets.UTF_8);
|
||||
writeVarInt(h, hostBytes.length);
|
||||
h.write(hostBytes);
|
||||
h.writeShort(port);
|
||||
writeVarInt(h, 1);
|
||||
writePacket(out, handshake.toByteArray());
|
||||
writePacket(out, new byte[]{0x00});
|
||||
|
||||
readVarInt(in); // packet length
|
||||
if (readVarInt(in) != 0x00) {
|
||||
throw new IOException("Unexpected status response packet");
|
||||
}
|
||||
int jsonLength = readVarInt(in);
|
||||
if (jsonLength < 0 || jsonLength > 1 << 20) {
|
||||
throw new IOException("Unreasonable status length: " + jsonLength);
|
||||
}
|
||||
byte[] json = new byte[jsonLength];
|
||||
in.readFully(json);
|
||||
int online;
|
||||
int max;
|
||||
try {
|
||||
JsonObject players = JsonUtils.GSON.fromJson(new String(json, StandardCharsets.UTF_8), JsonObject.class)
|
||||
.getAsJsonObject("players");
|
||||
online = players.get("online").getAsInt();
|
||||
max = players.get("max").getAsInt();
|
||||
} catch (JsonParseException | NullPointerException | ClassCastException | IllegalStateException e) {
|
||||
throw new IOException("Malformed status response", e);
|
||||
}
|
||||
|
||||
// Ping packet: the server echoes the payload back, which gives the latency.
|
||||
long start = System.nanoTime();
|
||||
ByteArrayOutputStream ping = new ByteArrayOutputStream();
|
||||
DataOutputStream p = new DataOutputStream(ping);
|
||||
writeVarInt(p, 0x01);
|
||||
p.writeLong(start);
|
||||
writePacket(out, ping.toByteArray());
|
||||
readVarInt(in); // packet length
|
||||
if (readVarInt(in) != 0x01) {
|
||||
throw new IOException("Unexpected pong packet");
|
||||
}
|
||||
in.readLong();
|
||||
return new Status(online, max, (System.nanoTime() - start) / 1_000_000);
|
||||
}
|
||||
}
|
||||
|
||||
private static void writePacket(DataOutputStream out, byte[] body) throws IOException {
|
||||
writeVarInt(out, body.length);
|
||||
out.write(body);
|
||||
out.flush();
|
||||
}
|
||||
|
||||
private static void writeVarInt(DataOutputStream out, int value) throws IOException {
|
||||
while ((value & ~0x7F) != 0) {
|
||||
out.writeByte((value & 0x7F) | 0x80);
|
||||
value >>>= 7;
|
||||
}
|
||||
out.writeByte(value);
|
||||
}
|
||||
|
||||
private static int readVarInt(DataInputStream in) throws IOException {
|
||||
int result = 0;
|
||||
for (int shift = 0; shift < 35; shift += 7) {
|
||||
int b = in.readUnsignedByte();
|
||||
result |= (b & 0x7F) << shift;
|
||||
if ((b & 0x80) == 0) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
throw new IOException("VarInt is too long");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
|
||||
package org.jackhuang.hmcl.util;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/// Tests [ServerStatusPing] against a minimal in-process status server.
|
||||
public final class ServerStatusPingTest {
|
||||
@Test
|
||||
void readsPlayersAndMeasuresLatency() throws Exception {
|
||||
try (ServerSocket server = new ServerSocket(0)) {
|
||||
Thread thread = new Thread(() -> serve(server), "fake-status-server");
|
||||
thread.setDaemon(true);
|
||||
thread.start();
|
||||
|
||||
var status = ServerStatusPing.ping("127.0.0.1", server.getLocalPort(), 3000);
|
||||
assertEquals(12, status.online());
|
||||
assertEquals(50, status.max());
|
||||
assertTrue(status.latencyMillis() >= 0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsWhenNothingListens() throws Exception {
|
||||
int port;
|
||||
try (ServerSocket socket = new ServerSocket(0)) {
|
||||
port = socket.getLocalPort();
|
||||
}
|
||||
assertThrows(IOException.class, () -> ServerStatusPing.ping("127.0.0.1", port, 500));
|
||||
}
|
||||
|
||||
private static void serve(ServerSocket server) {
|
||||
try (Socket socket = server.accept()) {
|
||||
DataInputStream in = new DataInputStream(socket.getInputStream());
|
||||
DataOutputStream out = new DataOutputStream(socket.getOutputStream());
|
||||
skipPacket(in); // handshake
|
||||
skipPacket(in); // status request
|
||||
|
||||
byte[] json = "{\"players\":{\"online\":12,\"max\":50}}".getBytes(StandardCharsets.UTF_8);
|
||||
byte[] body = concat(new byte[]{0x00}, varInt(json.length), json);
|
||||
out.write(varInt(body.length));
|
||||
out.write(body);
|
||||
|
||||
int length = readVarInt(in); // ping packet
|
||||
byte[] ping = new byte[length];
|
||||
in.readFully(ping);
|
||||
out.write(varInt(length));
|
||||
out.write(ping);
|
||||
out.flush();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private static void skipPacket(DataInputStream in) throws IOException {
|
||||
in.readFully(new byte[readVarInt(in)]);
|
||||
}
|
||||
|
||||
private static int readVarInt(DataInputStream in) throws IOException {
|
||||
int result = 0;
|
||||
for (int shift = 0; ; shift += 7) {
|
||||
int b = in.readUnsignedByte();
|
||||
result |= (b & 0x7F) << shift;
|
||||
if ((b & 0x80) == 0) return result;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] varInt(int value) {
|
||||
var out = new java.io.ByteArrayOutputStream();
|
||||
while ((value & ~0x7F) != 0) {
|
||||
out.write((value & 0x7F) | 0x80);
|
||||
value >>>= 7;
|
||||
}
|
||||
out.write(value);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
private static byte[] concat(byte[]... parts) {
|
||||
var out = new java.io.ByteArrayOutputStream();
|
||||
for (byte[] part : parts) out.writeBytes(part);
|
||||
return out.toByteArray();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user