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:
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;
|
||||
|
||||
Reference in new issue
Block a user