From 095d71fea7ac54a9577ec8a950b5d2226acd0ec5 Mon Sep 17 00:00:00 2001 From: Layue13 Date: Sun, 8 Nov 2026 14:40:43 +0800 Subject: [PATCH] feat: show server status (players, latency) on the home card via Server List Ping Co-Authored-By: Claude Sonnet 5.5 --- .../org/jackhuang/hmcl/ui/main/MainPage.java | 40 +++++- .../resources/assets/lang/I18N.properties | 2 + .../resources/assets/lang/I18N_lzh.properties | 2 + .../assets/lang/I18N_zh_Hans.properties | 2 + .../assets/lang/I18N_zh_Hant.properties | 2 + .../jackhuang/hmcl/util/ServerStatusPing.java | 126 ++++++++++++++++++ .../hmcl/util/ServerStatusPingTest.java | 91 +++++++++++++ 7 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/util/ServerStatusPing.java create mode 100644 HMCLCore/src/test/java/org/jackhuang/hmcl/util/ServerStatusPingTest.java diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java index 56a71b760b..01a98f55a5 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java @@ -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 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); diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index df25926b4c..c15f09d905 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -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 diff --git a/HMCL/src/main/resources/assets/lang/I18N_lzh.properties b/HMCL/src/main/resources/assets/lang/I18N_lzh.properties index e3f0351091..cb02be73b8 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_lzh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_lzh.properties @@ -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=啟戲 diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_Hans.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_Hans.properties index bc10bf1a87..b4744ecd6d 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_Hans.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_Hans.properties @@ -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=开始游戏 diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_Hant.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_Hant.properties index 060a6f6c52..a43cad2e00 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_Hant.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_Hant.properties @@ -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=開始遊戲 diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/ServerStatusPing.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/ServerStatusPing.java new file mode 100644 index 0000000000..4c00d81676 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/ServerStatusPing.java @@ -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"); + } +} diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/util/ServerStatusPingTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/ServerStatusPingTest.java new file mode 100644 index 0000000000..3cf1e6b793 --- /dev/null +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/ServerStatusPingTest.java @@ -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(); + } +}