feat: multiple server routes, ws(s) addresses passed verbatim to --server

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
layue13andClaude Sonnet 5.5 committed 2026-11-08 05:43:00 +08:00
1 parent 441ce7220f
commit 7b79945391
4 files changed
+148 -21

No files matched your search

@@ -9,39 +9,114 @@ import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Resolves the server that the one-click join button connects to.
/// Resolves the routes that the one-click join button can connect through.
@NotNullByDefault
public final class ServerEndpoint {
private ServerEndpoint() {
}
/// Finds the server address for an instance.
/// One selectable connection route.
///
/// The address stored in the installed modpack manifest wins, so it changes with modpack updates.
/// [Metadata#getDefaultServerAddress()] is used as the fallback.
/// @param name the display name
/// @param address the `host[:port]` or `ws(s)://` address handed to the game
public record Route(String name, String address) {
}
/// Lists the routes configured for an instance.
///
/// Routes in the installed modpack manifest win, so they change with modpack updates. Without
/// them the manifest's single `serverAddress` and then [Metadata#getDefaultServerAddress()] are
/// used, each as a single route.
///
/// @param configurationFile the instance's `modpack.json` path
/// @return the `host[:port]` address, or `null` when none is configured
public static @Nullable String resolve(Path configurationFile) {
if (Files.isRegularFile(configurationFile)) {
try {
ModpackConfiguration<ServerModpackManifest> configuration =
JsonUtils.fromJsonFile(configurationFile, ModpackConfiguration.typeOf(ServerModpackManifest.class));
String address = configuration != null ? configuration.getManifest().getServerAddress() : null;
if (StringUtils.isNotBlank(address)) {
return address.trim();
/// @return the routes in display order; empty when nothing is configured
public static @Unmodifiable List<Route> routes(Path configurationFile) {
List<Route> result = new ArrayList<>();
@Nullable ServerModpackManifest manifest = readManifest(configurationFile);
if (manifest != null) {
for (ServerModpackManifest.Route route : manifest.getRoutes()) {
if (StringUtils.isNotBlank(route.getAddress())) {
result.add(new Route(StringUtils.isBlank(route.getName()) ? route.getAddress().trim() : route.getName(), route.getAddress().trim()));
}
} catch (Exception e) {
LOG.warning("Unable to read server address from " + configurationFile, e);
}
if (result.isEmpty() && StringUtils.isNotBlank(manifest.getServerAddress())) {
result.add(new Route("default", manifest.getServerAddress().trim()));
}
}
String fallback = Metadata.getDefaultServerAddress();
return fallback.isEmpty() ? null : fallback;
if (result.isEmpty()) {
String fallback = Metadata.getDefaultServerAddress();
if (!fallback.isEmpty()) {
result.add(new Route("default", fallback));
}
}
return List.copyOf(result);
}
/// Picks the route to join: the one the player chose last if it still exists, otherwise the first.
///
/// @param configurationFile the instance's `modpack.json` path
/// @return the route to connect through, or `null` when nothing is configured
public static @Nullable Route selected(Path configurationFile) {
List<Route> routes = routes(configurationFile);
if (routes.isEmpty()) {
return null;
}
@Nullable String saved = readSelectedName(configurationFile);
for (Route route : routes) {
if (route.name().equals(saved)) {
return route;
}
}
return routes.get(0);
}
/// Remembers the player's route choice next to the instance's modpack configuration.
///
/// @param configurationFile the instance's `modpack.json` path
/// @param route the chosen route
public static void select(Path configurationFile, Route route) {
try {
Files.writeString(selectionFile(configurationFile), route.name(), StandardCharsets.UTF_8);
} catch (IOException e) {
LOG.warning("Unable to save the selected route", e);
}
}
private static Path selectionFile(Path configurationFile) {
return configurationFile.resolveSibling("server-route.txt");
}
private static @Nullable String readSelectedName(Path configurationFile) {
try {
Path file = selectionFile(configurationFile);
return Files.isRegularFile(file) ? Files.readString(file, StandardCharsets.UTF_8).trim() : null;
} catch (IOException e) {
return null;
}
}
private static @Nullable ServerModpackManifest readManifest(Path configurationFile) {
if (!Files.isRegularFile(configurationFile)) {
return null;
}
try {
ModpackConfiguration<ServerModpackManifest> configuration =
JsonUtils.fromJsonFile(configurationFile, ModpackConfiguration.typeOf(ServerModpackManifest.class));
return configuration != null ? configuration.getManifest() : null;
} catch (Exception e) {
LOG.warning("Unable to read server routes from " + configurationFile, e);
return null;
}
}
}
@@ -339,11 +339,11 @@ public final class Instances {
launch(null);
return;
}
@Nullable String address = ServerEndpoint.resolve(gameInstance.getModpackConfigurationFile());
if (address == null) {
ServerEndpoint.@Nullable Route route = ServerEndpoint.selected(gameInstance.getModpackConfigurationFile());
if (route == null) {
launch(gameInstance);
} else {
launch(gameInstance, helper -> helper.setQuickPlayOption(new QuickPlayOption.MultiPlayer(address)));
launch(gameInstance, helper -> helper.setQuickPlayOption(new QuickPlayOption.MultiPlayer(route.address())));
}
}
@@ -356,7 +356,11 @@ public class DefaultLauncher extends Launcher {
if (options.getQuickPlayOption() instanceof QuickPlayOption.MultiPlayer multiPlayer) {
String address = multiPlayer.serverIP();
try {
if (isWebSocketAddress(address)) {
// WebSocket routes are resolved by the client-side WebSocket mod; hand the URL over untouched.
res.add("--server");
res.add(address);
} else try {
ServerAddress parsed = ServerAddress.parse(address);
if (World.supportQuickPlay(GameVersionNumber.asGameVersion(gameVersion))) {
res.add("--quickPlayMultiplayer");
@@ -1049,4 +1053,13 @@ public class DefaultLauncher extends Launcher {
Path javaNativeFolder,
Charset encoding) {
}
/// Returns whether the address is a `ws://` or `wss://` URL rather than a `host[:port]` pair.
///
/// @param address the configured server address
/// @return `true` for WebSocket URLs
private static boolean isWebSocketAddress(String address) {
String lower = address.trim().toLowerCase(java.util.Locale.ROOT);
return lower.startsWith("ws://") || lower.startsWith("wss://");
}
}
@@ -31,6 +31,8 @@ public class ServerModpackManifest implements ModpackManifest, Validation {
private final List<Addon> addons;
/// The address players are connected to on launch, or `null` when the manifest does not specify one.
private final @Nullable String serverAddress;
/// Alternative connection routes (for example direct and CDN), or `null` when only [#serverAddress] is used.
private final @Nullable List<Route> routes;
public ServerModpackManifest() {
this("", "", "", "", "", Collections.emptyList(), Collections.emptyList());
@@ -41,6 +43,11 @@ public class ServerModpackManifest implements ModpackManifest, Validation {
}
public ServerModpackManifest(String name, String author, String version, String description, String fileApi, List<ModpackConfiguration.FileInformation> files, List<Addon> addons, @Nullable String serverAddress) {
this(name, author, version, description, fileApi, files, addons, serverAddress, null);
}
public ServerModpackManifest(String name, String author, String version, String description, String fileApi, List<ModpackConfiguration.FileInformation> files, List<Addon> addons, @Nullable String serverAddress, @Nullable List<Route> routes) {
this.routes = routes;
this.serverAddress = serverAddress;
this.name = name;
this.author = author;
@@ -74,6 +81,13 @@ public class ServerModpackManifest implements ModpackManifest, Validation {
return serverAddress;
}
/// Returns the connection routes offered to players.
///
/// @return the routes in display order, or an empty list when the manifest defines none
public List<Route> getRoutes() {
return routes == null ? Collections.emptyList() : routes;
}
public String getFileApi() {
return fileApi;
}
@@ -99,6 +113,31 @@ public class ServerModpackManifest implements ModpackManifest, Validation {
throw new JsonParseException("ServerModpackManifest.files cannot be null");
}
/// A named way to reach the server, such as a direct address or a CDN WebSocket URL.
public static final class Route {
private final String name;
private final String address;
public Route() {
this("", "");
}
public Route(String name, String address) {
this.name = name;
this.address = address;
}
/// Returns the display name shown to players.
public String getName() {
return name;
}
/// Returns the `host[:port]` or `ws(s)://` address to connect to.
public String getAddress() {
return address;
}
}
public static final class Addon {
private final String id;
private final String version;