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:
layue13andClaude Sonnet 5.5 committed 2026-11-08 14:40:43 +08:00
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();
}
}