diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/ServerEndpoint.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/ServerEndpoint.java index caa96c9530..77d15dd629 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/ServerEndpoint.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/ServerEndpoint.java @@ -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 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 routes(Path configurationFile) { + List 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 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 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; + } } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/Instances.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/Instances.java index 037e4957b2..58c457f134 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/Instances.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/Instances.java @@ -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()))); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java index 741f0d5080..70061d4213 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java @@ -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://"); + } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackManifest.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackManifest.java index 1a2010473a..c2c630e89b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackManifest.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackManifest.java @@ -31,6 +31,8 @@ public class ServerModpackManifest implements ModpackManifest, Validation { private final List 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 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 files, List 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 files, List addons, @Nullable String serverAddress, @Nullable List 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 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;