diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java index f850c415ac..8cbaf5b870 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java @@ -101,6 +101,12 @@ public enum SVG { SCRIPT("M14,20A2,2 0 0,0 16,18V5H9A1,1 0 0,0 8,6V16H5V5A3,3 0 0,1 8,2H19A3,3 0 0,1 22,5V6H18V18L18,19A3,3 0 0,1 15,22H5A3,3 0 0,1 2,19V18H12A2,2 0 0,0 14,20Z"), // Not Material SEARCH("M19.6 21 13.3 14.7Q12.55 15.3 11.575 15.65T9.5 16Q6.775 16 4.8875 14.1125T3 9.5Q3 6.775 4.8875 4.8875T9.5 3Q12.225 3 14.1125 4.8875T16 9.5Q16 10.6 15.65 11.575T14.7 13.3L21 19.6 19.6 21ZM9.5 14Q11.375 14 12.6875 12.6875T14 9.5Q14 7.625 12.6875 6.3125T9.5 5Q7.625 5 6.3125 6.3125T5 9.5Q5 11.375 6.3125 12.6875T9.5 14Z"), SELECT_ALL("M7 17V7H17V17H7ZM9 15H15V9H9V15ZM5 19V21Q4.175 21 3.5875 20.4125T3 19H5ZM3 17V15H5V17H3ZM3 13V11H5V13H3ZM3 9V7H5V9H3ZM5 5H3Q3 4.175 3.5875 3.5875T5 3V5ZM7 21V19H9V21H7ZM7 5V3H9V5H7ZM11 21V19H13V21H11ZM11 5V3H13V5H11ZM15 21V19H17V21H15ZM15 5V3H17V5H15ZM19 21V19H21Q21 19.825 20.4125 20.4125T19 21ZM19 17V15H21V17H19ZM19 13V11H21V13H19ZM19 9V7H21V9H19ZM19 5V3Q19.825 3 20.4125 3.5875T21 5H19Z"), + SERVER_SIGNAL_0_BAR("M1 18.5v-3q0-.625.438-1.062T2.5 14t1.063.438T4 15.5v3q0 .625-.437 1.063T2.5 20t-1.062-.437T1 18.5"), // Not Material + SERVER_SIGNAL_1_BAR("M1 18.5v-3q0-.625.438-1.062T2.5 14t1.063.438T4 15.5v3q0 .625-.437 1.063T2.5 20t-1.062-.437T1 18.5 m4.75 0v-5q0-.625.438-1.062T7.25 12t1.063.438t.437 1.062v5q0 .625-.437 1.063T7.25 20t-1.062-.437T5.75 18.5"), // Not Material + SERVER_SIGNAL_2_BAR("M1 18.5v-3q0-.625.438-1.062T2.5 14t1.063.438T4 15.5v3q0 .625-.437 1.063T2.5 20t-1.062-.437T1 18.5 m4.75 0v-5q0-.625.438-1.062T7.25 12t1.063.438t.437 1.062v5q0 .625-.437 1.063T7.25 20t-1.062-.437T5.75 18.5 m4.75 0v-8q0-.625.438-1.062T12 9t1.063.438t.437 1.062v8q0 .625-.437 1.063T12 20t-1.062-.437T10.5 18.5"), // Not Material + SERVER_SIGNAL_3_BAR("M1 18.5v-3q0-.625.438-1.062T2.5 14t1.063.438T4 15.5v3q0 .625-.437 1.063T2.5 20t-1.062-.437T1 18.5 m4.75 0v-5q0-.625.438-1.062T7.25 12t1.063.438t.437 1.062v5q0 .625-.437 1.063T7.25 20t-1.062-.437T5.75 18.5 m4.75 0v-8q0-.625.438-1.062T12 9t1.063.438t.437 1.062v8q0 .625-.437 1.063T12 20t-1.062-.437T10.5 18.5 m4.75 0v-10q0-.625.438-1.062T16.75 7t1.063.438t.437 1.062v10q0 .625-.437 1.063T16.75 20t-1.062-.437t-.438-1.063"), // Not Material + SERVER_SIGNAL_FULL("M1 18.5v-3q0-.625.438-1.062T2.5 14t1.063.438T4 15.5v3q0 .625-.437 1.063T2.5 20t-1.062-.437T1 18.5 m4.75 0v-5q0-.625.438-1.062T7.25 12t1.063.438t.437 1.062v5q0 .625-.437 1.063T7.25 20t-1.062-.437T5.75 18.5 m4.75 0v-8q0-.625.438-1.062T12 9t1.063.438t.437 1.062v8q0 .625-.437 1.063T12 20t-1.062-.437T10.5 18.5 m4.75 0v-10q0-.625.438-1.062T16.75 7t1.063.438t.437 1.062v10q0 .625-.437 1.063T16.75 20t-1.062-.437t-.438-1.063m4.75 0v-13q0-.625.438-1.062T21.5 4t1.063.438T23 5.5v13q0 .625-.437 1.063T21.5 20t-1.062-.437T20 18.5"), // Not Material + SERVER_SIGNAL_ERROR("M10.5 18.5V14L1.75 7.875q-.425-.3-.513-.8t.213-.925t.812-.525t.938.2l17.6 12.35q.425.3.525.8t-.2.925t-.812.513t-.938-.213l-2.55-1.775h1.425v.075q0 .625-.437 1.063T16.75 20t-1.062-.437t-.438-1.063v-1.175L13.5 16.1v2.325q0 .675-.437 1.125T12 20t-1.062-.437T10.5 18.5m-9.5 0v-3q0-.625.438-1.062T2.5 14t1.063.438T4 15.5v3q0 .625-.437 1.063T2.5 20t-1.062-.437T1 18.5m4.75 0v-5q0-.625.438-1.062T7.25 12t1.063.438t.437 1.062v5q0 .625-.437 1.063T7.25 20t-1.062-.437T5.75 18.5m12.5-5.175l-3-2.1V8.5q0-.625.438-1.062T16.75 7t1.063.438t.437 1.062zM23 16.65l-3-2.1V5.5q0-.625.438-1.062T21.5 4t1.063.438T23 5.5z"), // Not Material SETTINGS("M19.43 12.98C19.47 12.66 19.5 12.34 19.5 12 19.5 11.66 19.47 11.34 19.43 11.02L21.54 9.37C21.73 9.22 21.78 8.95 21.66 8.73L19.66 5.27C19.57 5.11 19.4 5.02 19.22 5.02 19.16 5.02 19.1 5.03 19.05 5.05L16.56 6.05C16.04 5.65 15.48 5.32 14.87 5.07L14.49 2.42C14.46 2.18 14.25 2 14 2H10C9.75 2 9.54 2.18 9.51 2.42L9.13 5.07C8.52 5.32 7.96 5.66 7.44 6.05L4.95 5.05C4.89 5.03 4.83 5.02 4.77 5.02 4.6 5.02 4.43 5.11 4.34 5.27L2.34 8.73C2.21 8.95 2.27 9.22 2.46 9.37L4.57 11.02C4.53 11.34 4.5 11.67 4.5 12 4.5 12.33 4.53 12.66 4.57 12.98L2.46 14.63C2.27 14.78 2.22 15.05 2.34 15.27L4.34 18.73C4.43 18.89 4.6 18.98 4.78 18.98 4.84 18.98 4.9 18.97 4.95 18.95L7.44 17.95C7.96 18.35 8.52 18.68 9.13 18.93L9.51 21.58C9.54 21.82 9.75 22 10 22H14C14.25 22 14.46 21.82 14.49 21.58L14.87 18.93C15.48 18.68 16.04 18.34 16.56 17.95L19.05 18.95C19.11 18.97 19.17 18.98 19.23 18.98 19.4 18.98 19.57 18.89 19.66 18.73L21.66 15.27C21.78 15.05 21.73 14.78 21.54 14.63L19.43 12.98ZM17.45 11.27C17.49 11.58 17.5 11.79 17.5 12 17.5 12.21 17.48 12.43 17.45 12.73L17.31 13.86 18.2 14.56 19.28 15.4 18.58 16.61 17.31 16.1 16.27 15.68 15.37 16.36C14.94 16.68 14.53 16.92 14.12 17.09L13.06 17.52 12.9 18.65 12.7 20H11.3L11.11 18.65 10.95 17.52 9.89 17.09C9.46 16.91 9.06 16.68 8.66 16.38L7.75 15.68 6.69 16.11 5.42 16.62 4.72 15.41 5.8 14.57 6.69 13.87 6.55 12.74C6.52 12.43 6.5 12.2 6.5 12S6.52 11.57 6.55 11.27L6.69 10.14 5.8 9.44 4.72 8.6 5.42 7.39 6.69 7.9 7.73 8.32 8.63 7.64C9.06 7.32 9.47 7.08 9.88 6.91L10.94 6.48 11.1 5.35 11.3 4H12.69L12.88 5.35 13.04 6.48 14.1 6.91C14.53 7.09 14.93 7.32 15.33 7.62L16.24 8.32 17.3 7.89 18.57 7.38 19.27 8.59 18.2 9.44 17.31 10.14 17.45 11.27ZM12 8C9.79 8 8 9.79 8 12S9.79 16 12 16 16 14.21 16 12 14.21 8 12 8ZM12 14C10.9 14 10 13.1 10 12S10.9 10 12 10 14 10.9 14 12 13.1 14 12 14Z"), // Material Icons SETTINGS_FILL("M9.25 22l-.4-3.2q-.325-.125-.6125-.3t-.5625-.375L4.7 19.375l-2.75-4.75 2.575-1.95Q4.5 12.5 4.5 12.3375v-.675q0-.1625.025-.3375L1.95 9.375 4.7 4.625l2.975 1.25q.275-.2.575-.375t.6-.3L9.25 2h5.5l.4 3.2q.325.125.6125.3t.5625.375L19.3 4.625l2.75 4.75-2.575 1.95q.025.175.025.3375v.675q0 .1625-.05.3375l2.575 1.95-2.75 4.75-2.95-1.25q-.275.2-.575.375t-.6.3l-.4 3.2H9.25Zm2.8-6.5q1.45 0 2.475-1.025T15.55 12 14.525 9.525 12.05 8.5q-1.475 0-2.4875 1.025T8.55 12q0 1.45 1.0125 2.475T12.05 15.5Z"), // Material Icons STADIA_CONTROLLER("M4.725 20Q3.225 20 2.1625 18.925T1.05 16.325Q1.05 16.1 1.075 15.875T1.15 15.425L3.25 7.025Q3.6 5.675 4.675 4.8375T7.125 4H16.875Q18.25 4 19.325 4.8375T20.75 7.025L22.85 15.425Q22.9 15.65 22.9375 15.8875T22.975 16.35Q22.975 17.875 21.8875 18.9375T19.275 20Q18.225 20 17.325 19.45T15.975 17.95L15.275 16.5Q15.15 16.25 14.9 16.125T14.375 16H9.625Q9.35 16 9.1 16.125T8.725 16.5L8.025 17.95Q7.575 18.9 6.675 19.45T4.725 20ZM4.8 18Q5.275 18 5.6625 17.75T6.25 17.075L6.95 15.65Q7.325 14.875 8.05 14.4375T9.625 14H14.375Q15.225 14 15.95 14.45T17.075 15.65L17.775 17.075Q17.975 17.5 18.3625 17.75T19.225 18Q19.925 18 20.425 17.5375T20.95 16.375Q20.95 16.4 20.9 15.9L18.8 7.525Q18.625 6.85 18.1 6.425T16.875 6H7.125Q6.425 6 5.8875 6.425T5.2 7.525L3.1 15.9Q3.05 16.05 3.05 16.35 3.05 17.05 3.5625 17.525T4.8 18ZM13.5 11Q13.925 11 14.2125 10.7125T14.5 10Q14.5 9.575 14.2125 9.2875T13.5 9Q13.075 9 12.7875 9.2875T12.5 10Q12.5 10.425 12.7875 10.7125T13.5 11ZM15.5 9Q15.925 9 16.2125 8.7125T16.5 8Q16.5 7.575 16.2125 7.2875T15.5 7Q15.075 7 14.7875 7.2875T14.5 8Q14.5 8.425 14.7875 8.7125T15.5 9ZM15.5 13Q15.925 13 16.2125 12.7125T16.5 12Q16.5 11.575 16.2125 11.2875T15.5 11Q15.075 11 14.7875 11.2875T14.5 12Q14.5 12.425 14.7875 12.7125T15.5 13ZM17.5 11Q17.925 11 18.2125 10.7125T18.5 10Q18.5 9.575 18.2125 9.2875T17.5 9Q17.075 9 16.7875 9.2875T16.5 10Q16.5 10.425 16.7875 10.7125T17.5 11ZM8.5 12.5Q8.825 12.5 9.0375 12.2875T9.25 11.75V10.75H10.25Q10.575 10.75 10.7875 10.5375T11 10Q11 9.675 10.7875 9.4625T10.25 9.25H9.25V8.25Q9.25 7.925 9.0375 7.7125T8.5 7.5Q8.175 7.5 7.9625 7.7125T7.75 8.25V9.25H6.75Q6.425 9.25 6.2125 9.4625T6 10Q6 10.325 6.2125 10.5375T6.75 10.75H7.75V11.75Q7.75 12.075 7.9625 12.2875T8.5 12.5ZM12 12Z"), diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MinecraftServerAddressValidator.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MinecraftServerAddressValidator.java new file mode 100644 index 0000000000..8550e5f49f --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MinecraftServerAddressValidator.java @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors +// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL +package org.jackhuang.hmcl.ui.construct; + +import com.jfoenix.validation.base.ValidatorBase; +import javafx.scene.control.TextInputControl; +import org.jackhuang.hmcl.util.ServerAddress; + +import java.util.Objects; + +public class MinecraftServerAddressValidator extends ValidatorBase { + private final boolean ignoreEmpty; + + public MinecraftServerAddressValidator(String message, boolean ignoreEmpty) { + super(message); + this.ignoreEmpty = ignoreEmpty; + } + + @Override + protected void eval() { + if (srcControl.get() instanceof TextInputControl) { + evalTextInputField(); + } + } + + private void evalTextInputField() { + TextInputControl textField = ((TextInputControl) srcControl.get()); + String text = textField.getText(); + + if (text == null || text.isEmpty()) { + if (ignoreEmpty) { + hasErrors.set(false); + return; + } + } + + try { + ServerAddress.parse(Objects.requireNonNull(text)); + hasErrors.set(false); + } catch (Exception e) { + hasErrors.set(true); + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/Decorator.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/Decorator.java index 5501f922ec..0491d7aaa5 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/Decorator.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/Decorator.java @@ -12,14 +12,7 @@ import javafx.animation.Timeline; import javafx.application.Platform; import javafx.beans.InvalidationListener; -import javafx.beans.property.BooleanProperty; -import javafx.beans.property.DoubleProperty; -import javafx.beans.property.ObjectProperty; -import javafx.beans.property.ReadOnlyBooleanProperty; -import javafx.beans.property.ReadOnlyDoubleProperty; -import javafx.beans.property.SimpleBooleanProperty; -import javafx.beans.property.SimpleDoubleProperty; -import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.*; import javafx.beans.value.ChangeListener; import javafx.event.EventHandler; import javafx.geometry.Insets; @@ -31,24 +24,17 @@ import javafx.scene.Scene; import javafx.scene.effect.BlurType; import javafx.scene.effect.DropShadow; -import javafx.scene.input.DragEvent; -import javafx.scene.input.KeyCode; -import javafx.scene.input.KeyEvent; -import javafx.scene.input.MouseButton; -import javafx.scene.input.MouseEvent; +import javafx.scene.input.*; import javafx.scene.layout.Background; import javafx.scene.layout.BackgroundFill; import javafx.scene.layout.Region; import javafx.scene.layout.StackPane; import javafx.scene.paint.Color; -import javafx.stage.Screen; -import javafx.stage.Stage; -import javafx.stage.StageStyle; -import javafx.stage.Window; -import javafx.stage.WindowEvent; +import javafx.stage.*; import javafx.util.Duration; import org.jackhuang.hmcl.Launcher; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDnD; +import org.jackhuang.hmcl.server.ServerDnD; import org.jackhuang.hmcl.setting.SettingsManager; import org.jackhuang.hmcl.theme.Themes; import org.jackhuang.hmcl.ui.Controllers; @@ -61,6 +47,7 @@ import org.jackhuang.hmcl.ui.animation.TransitionPane; import org.jackhuang.hmcl.ui.construct.JFXDialogPane; import org.jackhuang.hmcl.ui.construct.Navigator; +import org.jackhuang.hmcl.ui.instances.ServerListPage; import org.jackhuang.hmcl.ui.wizard.Navigation; import org.jackhuang.hmcl.ui.wizard.Refreshable; import org.jackhuang.hmcl.ui.wizard.WizardProvider; @@ -262,6 +249,11 @@ public Decorator(Node mainPage, Consumer stageInitializer) { root.addEventFilter(DragEvent.DRAG_OVER, AuthlibInjectorDnD.dragOverHandler()); root.addEventFilter(DragEvent.DRAG_DROPPED, AuthlibInjectorDnD.dragDroppedHandler( url -> Controllers.dialog(new AddAuthlibInjectorServerPane(url)))); + + // Setup Minecraft server DnD + root.addEventFilter(DragEvent.DRAG_OVER, ServerDnD.dragOverHandler()); + root.addEventFilter(DragEvent.DRAG_DROPPED, ServerDnD.dragDroppedHandler( + server -> Controllers.dialog(new ServerListPage.ServerStatusDialog(null, server, true, null)))); } /// Returns the node that must be installed as the scene root. diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/game/GameSettingsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/game/GameSettingsPage.java index e1016ebd02..c2f3db1bc9 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/game/GameSettingsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/game/GameSettingsPage.java @@ -39,7 +39,6 @@ import org.jackhuang.hmcl.ui.instances.GameInstancePage; import org.jackhuang.hmcl.util.Holder; import org.jackhuang.hmcl.util.Pair; -import org.jackhuang.hmcl.util.ServerAddress; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.i18n.I18n; import org.jackhuang.hmcl.util.io.FileUtils; @@ -512,16 +511,7 @@ public GameSettingsPage( var noneOption = new RadioChoiceList.Choice<>(i18n("settings.game.quick_play.none"), QuickPlayType.NONE); var multiplayerTextField = new JFXTextField(); - multiplayerTextField.setValidators(new Validator(str -> { - if (StringUtils.isBlank(str)) - return true; - try { - ServerAddress.parse(str); - return true; - } catch (Exception ignored) { - return false; - } - })); + multiplayerTextField.setValidators(new MinecraftServerAddressValidator(null, true)); FXUtils.setValidateWhileTextChanged(multiplayerTextField, true); @Nullable JFXButton multiplayerInheritanceButton = !isPresetSetting ? createInheritanceButton() : null; var multiplayerOption = new EditorChoice<>( diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java index 23b6afc7b4..9620b68ea5 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java @@ -9,6 +9,7 @@ import javafx.beans.value.ChangeListener; import javafx.event.Event; import javafx.event.EventType; +import javafx.scene.Node; import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; import org.jackhuang.hmcl.game.GameInstanceID; @@ -54,6 +55,8 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator private final TabItem resourcePackTab; /// The screenshots tab bound to the current game instance. private final TabItem screenshotsTab; + /// The servers tab bound to the current game instance. + private final TabItem serverListTab; private final TransitionPane transitionPane = new TransitionPane(); private final BooleanProperty currentInstanceUpgradable = new SimpleBooleanProperty(); private final ObjectProperty instance = @@ -70,6 +73,10 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator /// Last concrete instance displayed by this page. private @Nullable GameInstanceID preferredInstanceId; + public TabItem getServerListTab() { + return serverListTab; + } + public static class WorkingDirChangedEvent extends Event { public static final EventType EVENT_TYPE = new EventType<>(Event.ANY, "WORKING_DIR_CHANGED"); @@ -87,6 +94,7 @@ public GameInstancePage() { this.worldListTab = new TabItem<>(() -> new WorldListPage(instance)); this.schematicsTab = new TabItem<>(() -> new SchematicsPage(instance)); this.screenshotsTab = new TabItem<>(() -> new ScreenshotsPage(instance)); + this.serverListTab = new TabItem<>(() -> new ServerListPage(instance)); tabController = new TabController(transitionPane, gameSettingsTab, @@ -95,7 +103,9 @@ public GameInstancePage() { resourcePackTab, worldListTab, schematicsTab, - screenshotsTab); + screenshotsTab, + serverListTab + ); tabController.selectTab(gameSettingsTab); addEventHandler(Navigator.NavigationEvent.NAVIGATED, this::onNavigated); @@ -170,6 +180,19 @@ public void showInstanceSettings() { tabController.selectTab(gameSettingsTab, false); } + public void showServerList() { + tabController.selectTab(serverListTab, false); + } + + public @Nullable Node getSelectedTab() { + TabItem selectedTab = tabController.getSelectedTab(); + if (selectedTab == null) { + return null; + } + + return selectedTab.getNode(); + } + public void setInstance(GameInstanceID instanceId, HMCLGameRepository repository) { this.instance.set(HMCLGameInstance.Optional.of(repository, instanceId)); } @@ -392,6 +415,7 @@ protected Skin(GameInstancePage control) { // Due to the sidebar height limit, we temporarily place the entries for these tabs // in the management menu; they will be moved back to the sidebar after a future UI refactor. new IconedMenuItem(SVG.SCREENSHOT_MONITOR, i18n("screenshots.manage"), () -> control.tabController.selectTab(control.screenshotsTab), managementPopup), + new IconedMenuItem(SVG.DRESSER, i18n("server.manage"), () -> control.tabController.selectTab(control.serverListTab), managementPopup), new MenuSeparator(), new IconedMenuItem(null, i18n("instance.manage.redownload_assets_index"), control::redownloadAssetIndex, managementPopup), new IconedMenuItem(null, i18n("instance.manage.remove_assets"), control::clearAssets, managementPopup), 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 ac54cc0276..fa37ab7bbf 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 @@ -15,7 +15,10 @@ import org.jackhuang.hmcl.download.game.GameDownloadTask; import org.jackhuang.hmcl.download.game.GameLibrariesTask; import org.jackhuang.hmcl.game.*; -import org.jackhuang.hmcl.setting.*; +import org.jackhuang.hmcl.setting.Accounts; +import org.jackhuang.hmcl.setting.AuthlibInjectorServers; +import org.jackhuang.hmcl.setting.GameDirectoryManager; +import org.jackhuang.hmcl.setting.SettingsManager; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; @@ -368,6 +371,16 @@ public static void generateLaunchScriptForQuickEnterWorld(HMCLGameInstance gameI launcherHelper.setQuickPlayOption(new QuickPlayOption.SinglePlayer(worldFolderName))); } + public static void launchAndEnterServer(@Nullable HMCLGameInstance gameInstance, String serverIp) { + launch(gameInstance, launcherHelper -> + launcherHelper.setQuickPlayOption(new QuickPlayOption.MultiPlayer(serverIp))); + } + + public static void generateLaunchScriptForQuickConnectServer(HMCLGameInstance gameInstance, String serverIp) { + generateLaunchScript(gameInstance, launcherHelper -> + launcherHelper.setQuickPlayOption(new QuickPlayOption.MultiPlayer(serverIp))); + } + private static void ensureSelectedAccount(Consumer action) { Account account = Accounts.getSelectedAccount(); if (SettingsManager.isNewlyCreated() && !AuthlibInjectorServers.getServers().isEmpty() && @@ -410,4 +423,9 @@ public static void modifyGameSettings(HMCLGameInstance gameInstance) { Controllers.navigate(Controllers.getGameInstancePage()); } + public static void modifyServerList(HMCLGameInstance gameInstance) { + Controllers.getGameInstancePage().setInstance(gameInstance.getId(), gameInstance.getRepository()); + Controllers.getGameInstancePage().showServerList(); + Controllers.navigate(Controllers.getGameInstancePage()); + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ServerListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ServerListPage.java new file mode 100644 index 0000000000..3816d8dae6 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ServerListPage.java @@ -0,0 +1,1557 @@ +// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors +// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL +package org.jackhuang.hmcl.ui.instances; + +import com.google.gson.JsonPrimitive; +import com.jfoenix.controls.*; +import javafx.animation.Animation; +import javafx.animation.KeyFrame; +import javafx.animation.PauseTransition; +import javafx.animation.Timeline; +import javafx.application.Platform; +import javafx.beans.binding.Bindings; +import javafx.beans.binding.BooleanBinding; +import javafx.beans.property.*; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; +import javafx.collections.ListChangeListener; +import javafx.geometry.HPos; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.*; +import javafx.scene.effect.BoxBlur; +import javafx.scene.image.Image; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.*; +import javafx.scene.shape.SVGPath; +import javafx.util.Duration; +import org.jackhuang.hmcl.game.HMCLGameInstance; +import org.jackhuang.hmcl.server.Server; +import org.jackhuang.hmcl.server.pinger.ClassicServerStatusPinger; +import org.jackhuang.hmcl.server.pinger.LegacyServerStatusPinger; +import org.jackhuang.hmcl.server.pinger.ModernServerStatusPinger; +import org.jackhuang.hmcl.server.pinger.ServerStatusPinger; +import org.jackhuang.hmcl.server.status.ServerPlayerInfo; +import org.jackhuang.hmcl.server.status.ServerStatus; +import org.jackhuang.hmcl.server.status.ServerStatusResult; +import org.jackhuang.hmcl.server.status.ServerVersionInfo; +import org.jackhuang.hmcl.server.storage.DatFileModifiedExternalException; +import org.jackhuang.hmcl.server.storage.ServerStorage; +import org.jackhuang.hmcl.setting.GameDirectoryManager; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.ui.*; +import org.jackhuang.hmcl.ui.animation.ContainerAnimations; +import org.jackhuang.hmcl.ui.animation.TransitionPane; +import org.jackhuang.hmcl.ui.construct.*; +import org.jackhuang.hmcl.util.MinecraftChatComponentUtils; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.javafx.Subscription; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.function.Predicate; + +import static org.jackhuang.hmcl.ui.FXUtils.*; +import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.createToolbarButton2; +import static org.jackhuang.hmcl.util.StringUtils.parseColorEscapes; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +public class ServerListPage extends ListPageBase { + private static final Object serversFileHandleLockObject = new Object(); + private final Map serverStorageMap = new LinkedHashMap<>(); + + private final BooleanProperty showAll = new SimpleBooleanProperty(this, "showAll", false); + private final BooleanProperty showHide = new SimpleBooleanProperty(this, "showHide", false); + private final WeakListenerHolder listenerHolder = new WeakListenerHolder(); + + private @Nullable HMCLGameInstance gameInstance; + private List serverListEntries = new ArrayList<>(); + + private int refreshCount = 0; + + public static @NotNull Image parseServerFaviconImageOrDefault(@Nullable String imageBase64String) { + if (imageBase64String != null && !imageBase64String.isEmpty()) { + try (ByteArrayInputStream bais = new ByteArrayInputStream(Base64.getDecoder().decode(imageBase64String))) { + // png format. + return FXUtils.loadImage(bais, "icon.png", 0, 0, true, true); + } catch (Exception e) { + LOG.warning("Failed to decode server icon", e); + } + } + return FXUtils.newBuiltinImage("/assets/img/unknown_server.png"); + } + + public ServerListPage(ObservableValue instanceContext) { + Objects.requireNonNull(instanceContext, "instanceContext"); + + showAll.addListener(e -> updateServerList()); + showHide.addListener(e -> updateServerList()); + + listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> { + if (current != null) { + loadInstance(current); + } + })); + } + + @Override + protected Skin createDefaultSkin() { + return new ServerListPageSkin(); + } + + public void launchAndEnterServer(ServerListItem item) { + if (gameInstance != null) { + Instances.launchAndEnterServer(gameInstance, item.getServer().ip()); + } + } + + public void showServerStatus(ServerListItem item) { + if (gameInstance != null) { + runInFX(item::openServerStatusPane); + } + } + + public void editServer(ServerListItem item) { + if (gameInstance != null) { + runInFX(() -> Controllers.dialog(new EditServerDialog(EditServerDialog.Type.EDIT, item.getServer(), (server) -> { + if (!server.equals(item.getServer())) { + Task.supplyAsync(Schedulers.io(), () -> { + ServerStorage storage = item.storageEntry.rootStorage(); + return storage.updateEntry(item.storageEntry, oldValue -> oldValue + .withIpAndName(server.ip(), server.name()) + .withIcon(server.icon()) + .withPackStatus(server.serverPackStatus()) + ); + }).whenComplete(Schedulers.javafx(), (updated, exception) -> { + if (exception != null) { + LOG.warning("Failed to save server data.", exception); + } + + if (exception instanceof DatFileModifiedExternalException e) { + showFileModifiedExternalWarnDialog(e); + return; + } + + if (updated != null && updated) { + int index = serverListEntries.indexOf(item); + if (index != -1) { + serverListEntries.set(index, new ServerListItem(item.storageEntry)); + } + updateServerList(); + } + + }).start(); + } + }))); + } + } + + private void delete(ServerListItem... items) { + Controllers.confirm( + i18n("button.remove.confirm"), + i18n("server.delete"), + () -> Task.supplyAsync(Schedulers.io(), () -> { + List result = new ArrayList<>(); + for (ServerListItem item : items) { + try { + item.storageEntry.delete(); + result.add(item); + } catch (DatFileModifiedExternalException e) { + throw e; + } catch (IOException e) { + LOG.error("Failed to delete server entry.", e); + } + } + return result; + }).whenComplete(Schedulers.javafx(), (result, exception) -> { + if (exception != null) { + LOG.warning("Failed to delete server data.", exception); + } + if (exception instanceof DatFileModifiedExternalException e) { + showFileModifiedExternalWarnDialog(e); + return; + } + if (result == null || result.isEmpty()) return; + serverListEntries.removeAll(result); + updateServerList(); + }).start(), + null + ); + } + + private void showFileModifiedExternalWarnDialog(@NotNull DatFileModifiedExternalException e) { + Controllers.dialog(new MessageDialogPane.Builder(i18n("server.manage.error.file.modified"), null, MessageDialogPane.MessageType.ERROR) + .addCancel(i18n("button.refresh"), this::refresh) + .build() + ); + } + + public void copyServerIp(ServerListItem item) { + FXUtils.copyText(item.getServer().ip(), i18n("server.manage.copy.server.ip.ok.toast")); + } + + public void copyToInstance(ServerListItem item) { + if (gameInstance != null) { + addServer(item.getServer(), gameInstance); + } + } + + public void addServer(Server server) { + if (gameInstance != null) { + addServer(server, gameInstance); + } + } + + public void addServer(Server server, @NotNull HMCLGameInstance toInstance) { + Task.supplyAsync(Schedulers.io(), () -> { + ServerStorage.ServerStorageEntry storageEntry; + synchronized (serversFileHandleLockObject) { + ServerStorage storage = serverStorageMap.get(toInstance.getServersDatFilePath()); + if (storage == null) { + storage = new ServerStorage(toInstance.getServersDatFilePath()); + storage.getHoldInstances().add(toInstance); + serverStorageMap.put(toInstance.getServersDatFilePath(), storage); + } + storageEntry = storage.add(server); + } + return storageEntry; + }).whenComplete(Schedulers.javafx(), (storageEntry, exception) -> { + if (exception != null) { + LOG.warning("Failed to save server data.", exception); + } + + if (exception instanceof DatFileModifiedExternalException e) { + showFileModifiedExternalWarnDialog(e); + return; + } + + if (storageEntry != null) { + serverListEntries.add(new ServerListItem(storageEntry)); + updateServerList(); + } + }).start(); + } + + public void generateLaunchScript(ServerListItem item) { + if (gameInstance != null) { + Instances.generateLaunchScriptForQuickConnectServer(gameInstance, item.getServer().ip()); + } + } + + public void loadInstance(HMCLGameInstance.Optional instance) { + this.gameInstance = instance.instance(); + refresh(); + } + + private void refresh() { + if (gameInstance == null) + return; + + int currentRefresh = ++refreshCount; + HMCLGameInstance gameInstance = this.gameInstance; + + setLoading(true); + Task.supplyAsync(Schedulers.io(), () -> { + synchronized (serversFileHandleLockObject) { + serverStorageMap.values().forEach(ServerStorage::invalid); + serverStorageMap.clear(); + + for (HMCLGameInstance instance : gameInstance.getRepository().getSnapshot().getInstances()) { + if (serverStorageMap.containsKey(instance.getServersDatFilePath())) { + serverStorageMap.get(instance.getServersDatFilePath()).getHoldInstances().add(instance); + } else { + try { + ServerStorage storage = new ServerStorage(instance.getServersDatFilePath()); + storage.getHoldInstances().add(instance); + serverStorageMap.put(instance.getServersDatFilePath(), storage); + } catch (IOException e) { + LOG.error("Failed to load servers from dat file: " + instance.getServersDatFilePath(), e); + } + } + } + + return serverStorageMap.values().stream().flatMap(e -> e.getStorageEntries().stream()).map(ServerListItem::new).toList(); + } + }).whenComplete(Schedulers.javafx(), (result, exception) -> { + if (refreshCount != currentRefresh) { + // A newer refresh task is running, discard this result + return; + } + setLoading(false); + + if (exception != null) { + LOG.warning("Failed to load server list page", exception); + } else { + serverListEntries = new ArrayList<>(result); + updateServerList(); + } + + }).start(); + } + + private void addServer() { + runInFX(() -> Controllers.dialog(new EditServerDialog(EditServerDialog.Type.ADD, null, this::addServer))); + } + + private void updateServerList() { + if (serverListEntries == null || gameInstance == null) { + getItems().clear(); + return; + } + + var stream = serverListEntries.stream(); + if (!showAll.get()) { + stream = stream.filter(entry -> entry.storageEntry.getServerDatFilePath().equals(gameInstance.getServersDatFilePath())); + } + if (!showHide.get()) { + stream = stream.filter(entry -> !entry.getServer().hidden()); + } + + getItems().setAll(stream.toList()); + } + + private static class ServerNetworkLatencyPane extends StackPane { + private static final SVG[] FRAMES = { + SVG.SERVER_SIGNAL_0_BAR, + SVG.SERVER_SIGNAL_1_BAR, + SVG.SERVER_SIGNAL_2_BAR, + SVG.SERVER_SIGNAL_3_BAR, + SVG.SERVER_SIGNAL_FULL, + SVG.SERVER_SIGNAL_3_BAR, + SVG.SERVER_SIGNAL_2_BAR, + SVG.SERVER_SIGNAL_1_BAR + }; + + private final SVGPath icon = SVG.NONE.createIcon(); + private final Timeline timeline = new Timeline(new KeyFrame(Duration.millis(125), e -> nextFrame())); + private int index = 0; + private State state = State.PINGING; + + public ServerNetworkLatencyPane() { + timeline.setCycleCount(Animation.INDEFINITE); + getChildren().setAll(icon); + sceneProperty().addListener((obs, o, n) -> updateTimeline()); + ping(); + } + + private void nextFrame() { + setIcon(FRAMES[index++ % FRAMES.length], "", 0.3); + } + + private void updateTimeline() { + if (state == State.PINGING && getScene() != null) { + timeline.play(); + } else { + timeline.stop(); + } + } + + public void ping() { + state = State.PINGING; + index = 0; + nextFrame(); + updateTimeline(); + } + + public void pong(long latency) { + state = State.PONG; + updateTimeline(); + + SVG svg = latency < 150 ? SVG.SERVER_SIGNAL_FULL + : latency < 300 ? SVG.SERVER_SIGNAL_3_BAR + : latency < 600 ? SVG.SERVER_SIGNAL_2_BAR + : latency < 1000 ? SVG.SERVER_SIGNAL_1_BAR + : SVG.SERVER_SIGNAL_0_BAR; + setIcon(svg, "", 1); + } + + public void error() { + state = State.ERROR; + updateTimeline(); + setIcon(SVG.SERVER_SIGNAL_ERROR, "-fx-fill: -monet-error;", 1); + } + + private void setIcon(SVG svg, String style, double opacity) { + icon.setContent(svg.getPath()); + icon.setStyle(style); + icon.setOpacity(opacity); + } + + private enum State { + PINGING, + PONG, + ERROR; + } + } + + private final class ServerListCell extends MDListCell { + + private final ServerListPage page; + + private final ImageContainer serverIcon = new ImageContainer(32); + private final TwoLineListItem contentLine1 = new TwoLineListItem(); + private final ServerAddressMaskPane contentLine2AddressMaskPane = new ServerAddressMaskPane(""); + private final ServerNetworkLatencyPane serverNetworkLatencyPane = new ServerNetworkLatencyPane(); + + private final Tooltip statusBtnTooltip = new Tooltip(); + + private Subscription serverStatusResultValueSubscription; + private Subscription serverStatusPingingValueSubscription; + + public ServerListCell(JFXListView listView, ServerListPage page) { + super(listView); + this.page = page; + + this.getStyleClass().add("server-list-cell"); + + HBox container = new HBox(8); + container.setPickOnBounds(false); + container.setAlignment(Pos.CENTER_LEFT); + VBox content = new VBox(contentLine1, contentLine2AddressMaskPane); + HBox.setHgrow(content, Priority.ALWAYS); + content.setMouseTransparent(true); + setSelectable(); + + contentLine2AddressMaskPane.labelAddStyleClass("subtitle"); + + JFXButton statusBtn = FXUtils.newToggleButton4(SVG.NONE); + statusBtn.managedProperty().bind(statusBtn.visibleProperty()); + statusBtn.setOnAction(event -> { + ServerListItem item = getItem(); + if (item != null) + page.showServerStatus(item); + }); + FXUtils.installFastTooltip(statusBtn, statusBtnTooltip); + + JFXButton editBtn = FXUtils.newToggleButton4(SVG.EDIT); + FXUtils.installFastTooltip(editBtn, i18n("server.manage.edit")); + editBtn.setOnAction(event -> { + ServerListItem item = getItem(); + if (item != null) + page.editServer(item); + }); + + JFXButton launchBtn = FXUtils.newToggleButton4(SVG.ROCKET_LAUNCH); + FXUtils.installFastTooltip(launchBtn, i18n("instance.launch")); + launchBtn.setOnAction(event -> { + ServerListItem item = getItem(); + if (item != null) { + page.launchAndEnterServer(item); + } + }); + + JFXButton btnMore = FXUtils.newToggleButton4(SVG.MORE_VERT); + btnMore.setOnAction(event -> { + ServerListItem item = getItem(); + if (item != null) { + showPopupMenu(item, JFXPopup.PopupHPosition.RIGHT, 0, getHeight()); + } + }); + + container.getChildren().setAll( + serverIcon, + content, + new StackPane(serverNetworkLatencyPane, statusBtn), + editBtn, + launchBtn, + btnMore + ); + + StackPane.setMargin(container, new Insets(8)); + getContainer().getChildren().setAll(container); + } + + @Override + protected void updateControl(ServerListItem item, boolean empty) { + if (serverStatusPingingValueSubscription != null) { + serverStatusPingingValueSubscription.unsubscribe(); + serverStatusPingingValueSubscription = null; + } + if (serverStatusResultValueSubscription != null) { + serverStatusResultValueSubscription.unsubscribe(); + serverStatusResultValueSubscription = null; + } + + this.contentLine1.getTags().clear(); + + if (empty || item == null) { + setGraphic(null); + serverIcon.setImage(null); + contentLine1.setTitle(""); +// serverNetworkLatencyPane.error(); + statusBtnTooltip.setText(""); + contentLine2AddressMaskPane.set(""); + } else { + serverIcon.setImage(item.serverFaviconImage); + contentLine1.setTitle(item.getServer().name() != null ? parseColorEscapes(item.getServer().name()) : ""); + + contentLine2AddressMaskPane.set(item.getServer().ip()); + + if (item.getServer().hidden()) { + contentLine1.addTag(i18n("server.tag.hide")); + } + + if (item.storageEntry.rootStorage().getHoldInstances().contains(page.gameInstance)) { + contentLine1.addTag(i18n("server.tag.hold.current")); + item.storageEntry.rootStorage().getHoldInstances().stream() + .filter(e -> !e.equals(page.gameInstance)) + .map(gameInstance -> gameInstance.getId().id()) + .forEach(contentLine1::addTag); + } else { + item.storageEntry.rootStorage().getHoldInstances().stream() + .map(gameInstance -> gameInstance.getId().id()) + .forEach(contentLine1::addTag); + } + + item.observableServerStatus.refreshIfNoResultAsync(false); + + applyServerStatusResult(item, item.observableServerStatus.resultProperty().get()); + serverStatusResultValueSubscription = Subscription.subscribe(item.observableServerStatus.resultProperty(), result -> applyServerStatusResult(item, result)); + + applyServerPinging(item, item.observableServerStatus.pingingProperty().get()); + serverStatusPingingValueSubscription = Subscription.subscribe(item.observableServerStatus.pingingProperty(), pinging -> applyServerPinging(item, pinging)); + } + } + + private void applyServerPinging(ServerListItem item, boolean pinging) { + if (pinging) { + serverNetworkLatencyPane.ping(); + statusBtnTooltip.setText(i18n("server.manage.status.outside.pinging")); + } + } + + private void applyServerStatusResult(ServerListItem item, ServerStatusResult result) { + if (result == null) { + // pinging..., skip + return; + } + ServerStatus serverStatus = result.getIfSucceed(); + if (serverStatus == null) { + serverNetworkLatencyPane.error(); + ServerStatusResult.FailureResult.Reason reason = result.getFailureReasonIfFailed(); + statusBtnTooltip.setText(switch (Objects.requireNonNull(reason)) { + case EXCEPTION -> i18n("server.manage.status.outside.error"); + case UNKNOWN_HOST -> i18n("server.manage.status.outside.error.unknownhost"); + case BLOCKED_BY_MOJANG -> i18n("server.manage.status.outside.error.blocked"); + }); + } else { + ServerStatusResult.SuccessResult successResult = (ServerStatusResult.SuccessResult) result; + serverNetworkLatencyPane.pong(successResult.getNetworkLatency()); + statusBtnTooltip.setText(i18n("server.manage.status.outside.pong", String.format("%,d", successResult.getNetworkLatency()))); + // update latest server icon + serverIcon.setImage(parseServerFaviconImageOrDefault(serverStatus.favicon())); + +// if (!Objects.equals(serverStatus.favicon(), item.getServer().icon())) { +// // save latest icon +// Task.supplyAsync(Schedulers.io(), () -> { +// ServerStorage storage = item.storageEntry.rootStorage(); +// return storage.updateEntry(item.storageEntry, server -> { +// if (Objects.equals( +// ServerAddress.parse(Objects.requireNonNull(server.ip())), +// ((ServerStatusResult.SuccessResult) result).getRawServerAddress()) +// ) { +// return server.withIcon(serverStatus.favicon()); +// } +// return server; +// }); +// }).whenComplete(Schedulers.javafx(), (updated, exception) -> { +// if (exception != null) { +// LOG.warning("Failed to save server data.", exception); +// } +// +// if (exception instanceof DatFileModifiedExternalException e) { +// showFileModifiedExternalWarnDialog(e); +// return; +// } +// +// if (updated != null && updated) { +// int index = serverListEntries.indexOf(item); +// if (index != -1) { +// serverListEntries.set(index, new ServerListItem(item.storageEntry, item.observableServerStatus)); +// +// updateServerList(); +// } +// } +// +// }).start(); +// } + } + } + + private void showPopupMenu(ServerListPage.ServerListItem holder, JFXPopup.PopupHPosition hPosition, double initOffsetX, double initOffsetY) { + PopupMenu popupMenu = new PopupMenu(); + JFXPopup popup = new JFXPopup(popupMenu); + + IconedMenuItem copyToInstanceMEnuItem = new IconedMenuItem(SVG.CONTENT_COPY, i18n("server.manage.copy.to.instance"), + () -> page.copyToInstance(holder), popup + ); + popupMenu.getContent().addAll( + new IconedMenuItem(SVG.EDIT, i18n("server.manage.edit"), () -> + page.editServer(holder), popup + ), + new IconedMenuItem(SVG.SERVER_SIGNAL_FULL, i18n("server.manage.status"), () -> + page.showServerStatus(holder), popup + ), + new MenuSeparator(), + new IconedMenuItem(SVG.ROCKET_LAUNCH, i18n("instance.launch_and_connect_server"), () -> + page.launchAndEnterServer(holder), popup + ), + new IconedMenuItem(SVG.SCRIPT, i18n("instance.launch_script"), () -> + page.generateLaunchScript(holder), popup + ), + new MenuSeparator(), + new IconedMenuItem(SVG.CONTENT_COPY, i18n("server.manage.copy.server.ip"), () -> + page.copyServerIp(holder), popup + ), + new MenuSeparator(), + copyToInstanceMEnuItem, + new IconedMenuItem(SVG.DELETE_FOREVER, i18n("server.delete"), () -> + page.delete(holder), popup + ) + ); + if (page.gameInstance != null) { + copyToInstanceMEnuItem.setDisable(holder.storageEntry.getServerDatFilePath().equals(page.gameInstance.getServersDatFilePath())); + } + + JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(this, popup); + popup.show(this, vPosition, hPosition, initOffsetX, vPosition == JFXPopup.PopupVPosition.TOP ? initOffsetY : -initOffsetY); + } + } + + private final class ServerListPageSkin extends SkinBase { + private final JFXListView listView = new JFXListView<>(); + private final JFXTextField searchField = new JFXTextField(); + private final BooleanProperty isSearching = new SimpleBooleanProperty(false); + private final PauseTransition searchPause = new PauseTransition(Duration.millis(100)); + + private final TransitionPane toolbarPane = new TransitionPane(); + private final HBox searchBar = new HBox(); + private final HBox toolbarNormal = new HBox(); + private final HBox toolbarSelecting = new HBox(); + + ServerListPageSkin() { + super(ServerListPage.this); + + StackPane rootStackPane = new StackPane(); + getChildren().setAll(rootStackPane); + rootStackPane.setPadding(new Insets(10)); + rootStackPane.getStyleClass().addAll("notice-pane"); + + ComponentList root = new ComponentList(); + rootStackPane.getChildren().setAll(root); + root.getStyleClass().add("no-padding"); + + listView.getStyleClass().add("no-horizontal-scrollbar"); + listView.setCellFactory(param -> new ServerListCell(listView, ServerListPage.this)); + listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); + + { + root.getContent().add(toolbarPane); + FXUtils.setOverflowHidden(toolbarPane, 8); + toolbarPane.setContent(toolbarNormal, ContainerAnimations.NONE); + + Insets toolbarPadding = new Insets(0, 5, 0, 5); + + toolbarNormal.setAlignment(Pos.CENTER_LEFT); + toolbarNormal.setPadding(toolbarPadding); + toolbarNormal.getChildren().setAll( + createCheckBox(box -> { + box.setText(i18n("server.manage.show_all")); + box.selectedProperty().bindBidirectional(ServerListPage.this.showAll); + }), + createCheckBox(box -> { + box.setText(i18n("server.manage.show_hide")); + box.selectedProperty().bindBidirectional(ServerListPage.this.showHide); + }), + createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, ServerListPage.this::refresh), + createToolbarButton2(i18n("server.manage.add"), SVG.ADD, ServerListPage.this::addServer), + createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar)) + ); + + + searchBar.setAlignment(Pos.CENTER); + searchBar.setPadding(toolbarPadding); + searchField.setPromptText(i18n("search")); + HBox.setHgrow(searchField, Priority.ALWAYS); + searchPause.setOnFinished(e -> search()); + searchField.textProperty().addListener((observable, oldValue, newValue) -> { + if (isSearching.get() || !StringUtils.isBlank(newValue)) { + searchPause.setRate(1); + searchPause.playFromStart(); + } + }); + + listView.setOnContextMenuRequested(event -> { + ServerListItem selectedItem = listView.getSelectionModel().getSelectedItem(); + if (selectedItem != null && listView.getSelectionModel().getSelectedItems().size() == 1) { + listView.getSelectionModel().clearSelection(); + selectedItem.openServerStatusPane(); + } + }); + + JFXButton closeSearchBar = createToolbarButton2(null, SVG.CLOSE, () -> { + changeToolbar(toolbarNormal); + searchField.clear(); + searchPause.stop(); + isSearching.set(false); + Bindings.bindContent(listView.getItems(), ServerListPage.this.getItems()); + }); + onEscPressed(searchField, closeSearchBar::fire); + searchBar.getChildren().setAll( + createCheckBox(box -> { + box.setText(i18n("server.manage.show_all")); + box.selectedProperty().bindBidirectional(ServerListPage.this.showAll); + }), + createCheckBox(box -> { + box.setText(i18n("server.manage.show_hide")); + box.selectedProperty().bindBidirectional(ServerListPage.this.showHide); + }), + createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, ServerListPage.this::refresh), + searchField, + closeSearchBar + ); + + var selectAll = createToolbarButton2(i18n("button.select_all"), SVG.SELECT_ALL, () -> listView.getSelectionModel().selectRange(0, listView.getItems().size())); + ListChangeListener listener = change -> { + selectAll.setDisable(!listView.getItems().isEmpty() + && listView.getSelectionModel().getSelectedItems().size() == listView.getItems().size()); + }; + listView.getSelectionModel().getSelectedItems().addListener(listener); + listView.getItems().addListener(listener); + toolbarSelecting.getChildren().setAll( + createToolbarButton2(i18n("button.remove"), SVG.DELETE_FOREVER, () -> { + ServerListPage.this.delete(listView.getSelectionModel().getSelectedItems().toArray(ServerListItem[]::new)); + }), + selectAll, + createToolbarButton2(i18n("button.cancel"), SVG.CANCEL, () -> + listView.getSelectionModel().clearSelection()) + ); + FXUtils.onChangeAndOperate(listView.getSelectionModel().selectedItemProperty(), selectedItem -> { + if (selectedItem == null) { + changeToolbar(isSearching.get() ? searchBar : toolbarNormal); + } else { + changeToolbar(toolbarSelecting); + } + }); + } + + StackPane placeholderContainer = new StackPane(); + placeholderContainer.getStyleClass().add("notice-pane"); + Label placeholderLabel = new Label(i18n("server.empty")); + placeholderLabel.textProperty().bind(Bindings.createStringBinding(() -> { + if (isSearching.get()) { + return i18n("search.no_results_found"); + } else { + return i18n("server.empty"); + } + }, isSearching)); + placeholderContainer.getChildren().add(placeholderLabel); + listView.setPlaceholder(placeholderContainer); + + Bindings.bindContent(listView.getItems(), ServerListPage.this.getItems()); + ServerListPage.this.getItems().addListener((ListChangeListener) c -> { + if (isSearching.get()) { + search(); + } + }); + + SpinnerPane center = new SpinnerPane(); + ComponentList.setVgrow(center, Priority.ALWAYS); + center.loadingProperty().bind(ServerListPage.this.loadingProperty()); + + ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE); + + center.setContent(listView); + root.getContent().add(center); + } + + private JFXCheckBox createCheckBox(Consumer consumer) { + JFXCheckBox box = new JFXCheckBox(); + // change style. + consumer.accept(box); + return box; + } + + private void changeToolbar(HBox newToolbar) { + Node oldToolbar = toolbarPane.getCurrentNode(); + if (newToolbar != oldToolbar) { + toolbarPane.setContent(newToolbar, ContainerAnimations.FADE); + if (newToolbar == searchBar) { + Platform.runLater(searchField::requestFocus); + } + } + } + + private void search() { + isSearching.set(true); + + Bindings.unbindContent(listView.getItems(), ServerListPage.this.getItems()); + + String queryString = searchField.getText(); + if (StringUtils.isBlank(queryString)) { + listView.getItems().setAll(ServerListPage.this.getItems()); + } else { + listView.getItems().clear(); + + Predicate<@Nullable String> predicate; + try { + predicate = StringUtils.compileQuery(queryString); + } catch (Throwable e) { + LOG.warning("Illegal regular expression", e); + return; + } + + for (ServerListItem item : ServerListPage.this.getItems()) { + String serverIp = item.getServer().ip(); + String serverName = item.getServer().name(); + + if (predicate.test(serverIp) || predicate.test(serverName)) { + listView.getItems().add(item); + } + } + } + } + } + + public static class ServerStatusDialog extends TransitionPane implements DialogAware { + private final Server server; + private final SpinnerPane refreshSpinner = new SpinnerPane(); + private final JFXDialogLayout rootLayout = new JFXDialogLayout(); + private final Label lblErrorMessage = new Label(); + private final ObservableServerStatus observableServerStatus; + private final WeakListenerHolder listenerHolder = new WeakListenerHolder(); + private final @Nullable HMCLGameInstance currentGameInstance; + + public ServerStatusDialog(ObservableServerStatus reference, Server server, @Nullable HMCLGameInstance gameInstance) { + this(reference, server, false, gameInstance); + } + + public ServerStatusDialog(ObservableServerStatus reference, Server server, boolean fromDnD, @Nullable HMCLGameInstance gameInstance) { + this.server = server; + if (gameInstance == null) { + this.currentGameInstance = GameDirectoryManager.getSelectedRepository().getSelectedInstance(); + } else { + this.currentGameInstance = gameInstance; + } + this.observableServerStatus = Objects.requireNonNullElseGet(reference, () -> new ObservableServerStatus(server.ip())); + + getStyleClass().add("skin-pane"); + getChildren().setAll(rootLayout); + rootLayout.setHeading(new Label(i18n("server.manage.status.head"))); + + { + JFXButton refreshBtn = new JFXButton(i18n("button.refresh")); + refreshBtn.getStyleClass().add("dialog-refresh"); + refreshBtn.setOnAction(e -> observableServerStatus.refreshAsync(true)); + refreshSpinner.getStyleClass().add("small-spinner-pane"); + refreshSpinner.setContent(refreshBtn); + } + + HBox actions = new HBox(refreshSpinner); + actions.setAlignment(Pos.CENTER_RIGHT); + + if (fromDnD) { + // add to current instance + + JFXButton addToInstallBtn = new JFXButton(i18n("server.dnd.add")); + addToInstallBtn.getStyleClass().add("dialog-accept"); + addToInstallBtn.setOnAction(ignored0 -> { + fireEvent(new DialogCloseEvent()); + // open server manager + if (currentGameInstance == null) { + JFXButton gotoDownload = new JFXButton(i18n("instance.empty.launch.goto_download")); + gotoDownload.getStyleClass().add("dialog-accept"); + gotoDownload.setOnAction(ignored1 -> Controllers.navigate(Controllers.getDownloadPage())); + + Controllers.confirmAction(i18n("server.dnd.add.instanceempty.desc"), i18n("server.dnd.add.instanceempty"), + MessageDialogPane.MessageType.ERROR, + gotoDownload, + null); + } else { + if (Controllers.getGameInstancePage().getScene() == null) { + Instances.modifyServerList(currentGameInstance); + } + + if (!(Controllers.getGameInstancePage().getSelectedTab() instanceof ServerListPage)) { + Controllers.getGameInstancePage().showServerList(); + } + + ServerListPage serverListPage = Controllers.getGameInstancePage().getServerListTab().getNode(); + if (serverListPage != null) { + serverListPage.addServer(server, currentGameInstance); + } + } + }); + actions.getChildren().add(addToInstallBtn); + } + + JFXButton launchBtn = new JFXButton(i18n("instance.launch")); + launchBtn.getStyleClass().add("dialog-accept"); + launchBtn.setOnAction(e -> { + fireEvent(new DialogCloseEvent()); + Instances.launchAndEnterServer(currentGameInstance, server.ip()); + }); + actions.getChildren().add(launchBtn); + + JFXButton cancelBtn = new JFXButton(i18n("button.cancel")); + cancelBtn.getStyleClass().add("dialog-cancel"); + cancelBtn.setOnAction(e -> onClose()); + onEscPressed(this, cancelBtn::fire); + actions.getChildren().add(cancelBtn); + + lblErrorMessage.setWrapText(true); + lblErrorMessage.setMaxWidth(400); + rootLayout.getActions().addAll(lblErrorMessage, actions); + + applyResult(observableServerStatus.resultProperty().get()); + listenerHolder.add(onWeakChangeAndOperate(observableServerStatus.resultProperty(), this::applyResult)); + + applyPinging(observableServerStatus.pingingProperty().get()); + listenerHolder.add(onWeakChangeAndOperate(observableServerStatus.pingingProperty(), this::applyPinging)); + + observableServerStatus.refreshIfNoResultOrFailedAsync(true); + } + + private static Node createDescriptionRow(String title, String content, @Nullable String tooltip, Insets inVBoxInsets) { + BorderPane row = new BorderPane(); + VBox.setMargin(row, inVBoxInsets); + row.setLeft(new Label(title)); + Label right = new Label(content); + if (tooltip != null) { + installFastTooltip(right, tooltip); + } + row.setRight(right); + return row; + } + + public void applyPinging(boolean pinging) { + if (pinging) { + lblErrorMessage.setText(""); + refreshSpinner.showSpinner(); + } else { + refreshSpinner.hideSpinner(); + } + } + + public void applyResult(ServerStatusResult statusResult) { + ServerStatus status; + + if (statusResult == null) { + // pinging..., continue + status = null; + } else { + // ping failed + status = statusResult.getIfSucceed(); + if (status == null) { + ServerStatusResult.FailureResult.Reason reason = statusResult.getFailureReasonIfFailed(); + lblErrorMessage.setText(switch (Objects.requireNonNull(reason)) { + case EXCEPTION -> i18n("server.manage.status.error"); + case UNKNOWN_HOST -> i18n("server.manage.status.error.unknownhost"); + case BLOCKED_BY_MOJANG -> i18n("server.manage.status.error.blocked"); + }); + } + } + + Image serverIcon = parseServerFaviconImageOrDefault(server.icon()); + if (status != null) { + serverIcon = parseServerFaviconImageOrDefault(status.favicon()); + } + + VBox rootContent = new VBox(); + rootContent.setSpacing(10); + rootLayout.setBody(rootContent); + + HBox titleContainer = new HBox(); + titleContainer.setSpacing(8); + titleContainer.setAlignment(Pos.CENTER_LEFT); + VBox.setMargin(titleContainer, new Insets(12, 0, 0, 0)); + rootContent.getChildren().add(titleContainer); + + ImageContainer imageView = new ImageContainer(64); + imageView.setImage(serverIcon); + titleContainer.getChildren().add(imageView); + + VBox titleContent = new VBox(); + titleContent.setAlignment(Pos.CENTER_LEFT); + titleContent.setSpacing(8); + HBox.setMargin(titleContent, new Insets(0, 0, 0, 12)); + titleContainer.getChildren().add(titleContent); + + TwoLineListItem title = new TwoLineListItem(); + title.getTitleLabel().setWrapText(true); + title.setTitle(server.name()); + titleContent.getChildren().add(title); + + ServerAddressMaskPane addressMaskPane = new ServerAddressMaskPane(server.ip()); + addressMaskPane.labelAddStyleClass("subtitle"); + titleContent.getChildren().add(addressMaskPane); + + if (status != null) { + VBox motdContentBox = new VBox(); + motdContentBox.setSpacing(5); + motdContentBox.setAlignment(Pos.CENTER_LEFT); + + VBox.setMargin(motdContentBox, new Insets(4, 0, 0, 4)); + rootContent.getChildren().add(motdContentBox); + + String[] motdLines = MinecraftChatComponentUtils + .toPlainStringFromChatComponent(status.description()) + .split("\\r?\\n"); + for (int i = 0; i < Math.min(motdLines.length, 2); i++) { + String line = motdLines[i].trim(); + if (line.length() > 80) { + line = line.substring(0, 77).trim() + "..."; + } + Label lineLabel = new Label(line); + lineLabel.setMaxWidth(560); + lineLabel.setWrapText(true); + motdContentBox.getChildren().add(lineLabel); + } + + Separator titleEndSeparator = new Separator(); + titleEndSeparator.setMaxWidth(Double.MAX_VALUE); + titleEndSeparator.setPadding(new Insets(4, 0, 0, 0)); + rootContent.getChildren().add(titleEndSeparator); + + String playersTooltip = null; + if (status.players() instanceof ServerPlayerInfo.FullServerPlayerInfo fullInfo) { + StringBuilder sb = new StringBuilder(); + sb.append(String.join("\n", fullInfo.samples().stream() + .map(sample -> { + if (sample.isAnonymous()) { + return i18n("server.anonymous_player"); + } + return MinecraftChatComponentUtils.toPlainStringFromChatComponent(new JsonPrimitive(sample.name())); + }) + .toList()) + ); + + if (fullInfo.samples().size() < fullInfo.online()) { + sb.append("\n"); + sb.append(i18n("server.and_more_players", String.valueOf(fullInfo.online() - fullInfo.samples().size()))); + } + + playersTooltip = sb.toString(); + } + + String modsTooltip = null; + int showCount = 15; + if (status.modInfo() != null && !status.modInfo().modList().isEmpty()) { + StringBuilder sb = new StringBuilder(); + sb.append(String.join("\n", status.modInfo().modList().stream() + .limit(showCount) + .map(it -> it.modId() + " - " + it.version()) + .toList()) + ); + if (showCount < status.modInfo().modList().size()) { + sb.append("\n"); + sb.append(i18n("server.and_more_mods", String.valueOf(status.modInfo().modList().size() - showCount))); + } + + modsTooltip = sb.toString(); + } + + rootContent.getChildren().add(createDescriptionRow(i18n("server.type"), status.modInfo() != null ? i18n("server.type.mod", status.modInfo().type(), status.modInfo().modList().size()) : i18n("server.type.vanilla"), modsTooltip, new Insets(0, 4, 0, 4))); + + if (status.players() instanceof ServerPlayerInfo.FullServerPlayerInfo info) { + rootContent.getChildren().add(createDescriptionRow(i18n("server.onlineplayers"), i18n("server.onlineplayers.full", info.online(), info.max()), playersTooltip, new Insets(0, 4, 0, 4))); + } else if (status.players() instanceof ServerPlayerInfo.OnlyPlayerNumberServerVersionInfo info) { + rootContent.getChildren().add(createDescriptionRow(i18n("server.onlineplayers"), i18n("server.onlineplayers.full", info.online(), info.max()), playersTooltip, new Insets(0, 4, 0, 4))); + } else if (status.players() instanceof ServerPlayerInfo.UnknownServerPlayerInfo info) { + rootContent.getChildren().add(createDescriptionRow(i18n("server.onlineplayers"), i18n("server.onlineplayers.unknown"), playersTooltip, new Insets(0, 4, 0, 4))); + } + + if (status.version() instanceof ServerVersionInfo.FullServerVersionInfo info) { + rootContent.getChildren().add(createDescriptionRow(i18n("server.playversion"), i18n("server.playversion.full", MinecraftChatComponentUtils.toPlainStringFromChatComponent(new JsonPrimitive(info.protocolName())), info.protocolVersion()), null, new Insets(0, 4, 0, 4))); + } else if (status.version() instanceof ServerVersionInfo.OnlyProtocolVersionServerVersionInfo info) { + rootContent.getChildren().add(createDescriptionRow(i18n("server.playversion"), i18n("server.playversion.onlyversion", info.protocolVersion()), null, new Insets(0, 4, 0, 4))); + } else { + rootContent.getChildren().add(createDescriptionRow(i18n("server.playversion"), i18n("server.playversion.unknown"), null, new Insets(0, 4, 0, 4))); + } + + ServerStatusPinger pinger = ((ServerStatusResult.SuccessResult) statusResult).getPinger(); + if (pinger instanceof ModernServerStatusPinger) { +// rootContent.getChildren().add(createDescriptionRow(i18n("server.pinger"), i18n("server.pinger.modern"), null, new Insets(0, 4, 0, 4))); + title.addTag(i18n("server.pinger.modern")); + } else if (pinger instanceof LegacyServerStatusPinger) { +// rootContent.getChildren().add(createDescriptionRow(i18n("server.pinger"), i18n("server.pinger.legacy"), null, new Insets(0, 4, 0, 4))); + title.addTag(i18n("server.pinger.legacy")); + } else if (pinger instanceof ClassicServerStatusPinger) { +// rootContent.getChildren().add(createDescriptionRow(i18n("server.pinger"), i18n("server.pinger.classic"), null, new Insets(0, 4, 0, 4))); + title.addTag(i18n("server.pinger.classic")); + } + + rootContent.getChildren().add(createDescriptionRow(i18n("server.latency"), String.format("%,dms", ((ServerStatusResult.SuccessResult) statusResult).getNetworkLatency()), null, new Insets(0, 4, 0, 4))); + + } + } + + private void onClose() { + fireEvent(new DialogCloseEvent()); + } + } + + public static class ServerAddressMaskPane extends HBox { + private final Label atLabel = new Label(); + private final Label realServerLabel = new Label(); + private final Label queryLabel = new Label(); + + private final Tooltip atLabelTooltip = new Tooltip(); + private final Tooltip queryLabelTooltip = new Tooltip(); + + public ServerAddressMaskPane(@Nullable String serverIP) { + getChildren().add(atLabel); + getChildren().add(realServerLabel); + getChildren().add(queryLabel); + + atLabel.setPickOnBounds(true); + queryLabel.setPickOnBounds(true); + + // FXUtils.installFastTooltip(atLabel, atLabelTooltip); + // FXUtils.installFastTooltip(queryLabel, queryLabelTooltip); + set(serverIP); + + BoxBlur blur = new BoxBlur(); + blur.setIterations(3); + + atLabel.setEffect(blur); + queryLabel.setEffect(blur); + + atLabel.addEventHandler(MouseEvent.MOUSE_PRESSED, e -> { + atLabel.setEffect(null); + }); + atLabel.addEventHandler(MouseEvent.MOUSE_RELEASED, e -> { + atLabel.setEffect(blur); + }); + + queryLabel.addEventHandler(MouseEvent.MOUSE_PRESSED, e -> { + queryLabel.setEffect(null); + }); + queryLabel.addEventHandler(MouseEvent.MOUSE_RELEASED, e -> { + queryLabel.setEffect(blur); + }); + } + + public void set(@Nullable String serverIP) { + if (serverIP == null) { + atLabel.setText(""); + realServerLabel.setText(""); + queryLabel.setText(""); + + atLabelTooltip.setText(""); + queryLabelTooltip.setText(""); + return; + } + int queryStart = serverIP.lastIndexOf('?'); + if (queryStart != -1) { + String queryStr = serverIP.substring(queryStart); + queryLabel.setText(queryStr); + atLabelTooltip.setText(queryStr); + serverIP = serverIP.substring(0, queryStart); + } else { + queryLabel.setText(""); + atLabelTooltip.setText(""); + } + + int atEnd = serverIP.indexOf('@'); + if (atEnd != -1) { + String atStr = serverIP.substring(0, atEnd + 1); + atLabel.setText(atStr); + atLabelTooltip.setText(atStr); + serverIP = serverIP.substring(atEnd + 1); + } else { + atLabel.setText(""); + atLabelTooltip.setText(""); + } + + realServerLabel.setText(serverIP); + } + + public void labelAddStyleClass(String styleClass) { + atLabel.getStyleClass().add(styleClass); + realServerLabel.getStyleClass().add(styleClass); + queryLabel.getStyleClass().add(styleClass); + } + } + + public static class ObservableServerStatus { + + private static final Semaphore PING_SEMAPHORE = new Semaphore(5); + + private final String serverIp; + private final ReadOnlyObjectWrapper resultProperty = new ReadOnlyObjectWrapper<>(); + private final ReadOnlyBooleanWrapper pingingProperty = new ReadOnlyBooleanWrapper(); + private final AtomicInteger refreshId = new AtomicInteger(); + + public ObservableServerStatus(String serverIp) { + this.serverIp = serverIp; + } + + public void refreshIfNoResultAsync(boolean urgent) { + if (resultProperty.get() == null) { + refreshAsync(urgent); + } + } + + public void refreshIfNoResultOrFailedAsync(boolean urgent) { + ServerStatusResult result = resultProperty.get(); + if (result == null || result.isFailure()) { + refreshAsync(urgent); + } + } + + public void refreshAsync(boolean urgent) { + Task.runAsync(Schedulers.javafx(), () -> { + + if (pingingProperty.get() && !urgent) { + return; + } + + int currentRefreshId = refreshId.incrementAndGet(); + pingingProperty.set(true); + + Task.supplyAsync(Schedulers.io(), () -> { + // expired. + if (currentRefreshId != refreshId.get()) return null; + + if (urgent) { + return ServerStatusPinger.getStatus(serverIp); + } + + PING_SEMAPHORE.acquireUninterruptibly(); + try { + // expired. + if (currentRefreshId != refreshId.get()) return null; + + return ServerStatusPinger.getStatus(serverIp); + } finally { + PING_SEMAPHORE.release(); + } + }).whenComplete(Schedulers.javafx(), (result, ignored) -> { + // expired. + if (currentRefreshId != refreshId.get()) return; + + try { + resultProperty.set(result); + } finally { + pingingProperty.set(false); + } + }).start(); + }).start(); + } + + public ReadOnlyBooleanProperty pingingProperty() { + return pingingProperty.getReadOnlyProperty(); + } + + public ReadOnlyObjectProperty resultProperty() { + return resultProperty.getReadOnlyProperty(); + } + } + + public static class EditServerDialog extends TransitionPane implements DialogAware { + private final Consumer handleCallback; + + private final Server reference; + private final Type type; + private final GridPane body = new GridPane(); + private final JFXTextField serverNameField = new JFXTextField(); + private final JFXTextField serverIpField = new JFXTextField(); + private final JFXComboBox serverPackStatusField = new JFXComboBox<>(); + + private final JFXButton btnAccept = new JFXButton(); + private final JFXButton btnCancel = new JFXButton(); + private final SpinnerPane spinner = new SpinnerPane(); + private final Label lblErrorLabel = new Label(); + + private final SimpleBooleanProperty stillActionProgress = new SimpleBooleanProperty(false); + + private boolean cancelled; + + public EditServerDialog(Type type, @Nullable Server reference, Consumer handleCallback) { + this.type = type; + this.reference = reference; + this.handleCallback = handleCallback; + this.cancelled = false; + + getStyleClass().add("skin-pane"); + + String referenceServerName; + String referenceServerIP; + Server.ServerPackStatus referenceServerPackStatus; + if (reference != null) { + referenceServerName = reference.name(); + referenceServerIP = reference.ip(); + referenceServerPackStatus = reference.serverPackStatus(); + } else { + referenceServerName = null; + referenceServerIP = null; + referenceServerPackStatus = null; + } + + JFXDialogLayout rootLayout = new JFXDialogLayout(); + getChildren().setAll(rootLayout); + + rootLayout.setHeading(new Label(switch (type) { + case EDIT -> i18n("server.manage.edit.head"); + case ADD -> i18n("server.manage.add.head"); + })); + + VBox bodyVbox = new VBox(); + bodyVbox.setSpacing(8); + + VBox.setMargin(body, new Insets(20, 0, 0, 0)); + body.setVgap(22); + body.setHgap(15); + body.setAlignment(Pos.CENTER); + + ColumnConstraints col0 = new ColumnConstraints(); + col0.setMinWidth(USE_PREF_SIZE); + body.getColumnConstraints().add(col0); + ColumnConstraints col1 = new ColumnConstraints(); + col1.setHgrow(Priority.ALWAYS); + body.getColumnConstraints().add(col1); + + + // server name + { + Label label = new Label(); + label.setText(i18n("server.name")); + GridPane.setHalignment(label, HPos.LEFT); + body.add(label, 0, 0); + + serverNameField.setPromptText(i18n("server.name.def")); + serverNameField.setTextFormatter(new TextFormatter<>(change -> { + if (change.getControlNewText().length() <= 32) { + return change; + } + return null; + })); + body.add(serverNameField, 1, 0); + + if (referenceServerName != null) { + serverNameField.setText(referenceServerName); + } + } + + // server ip + { + Label label = new Label(); + label.setText(i18n("server.ip")); + GridPane.setHalignment(label, HPos.LEFT); + body.add(label, 0, 1); + + serverIpField.setValidators( + new RequiredValidator(), + new MinecraftServerAddressValidator(i18n("server.address.malformed"), false) + ); + setValidateWhileTextChanged(serverIpField, true); + + body.add(serverIpField, 1, 1); + + if (referenceServerIP != null) { + serverIpField.setText(referenceServerIP); + } + + } + + // serverPackStatusField + { + Label label = new Label(); + label.setText(i18n("server.resourcepack")); + GridPane.setHalignment(label, HPos.LEFT); + body.add(label, 0, 2); + + serverPackStatusField.setConverter(stringConverter(status -> i18n("server.resourcepack." + status.name().toLowerCase(Locale.ROOT)))); + serverPackStatusField.getSelectionModel().selectedItemProperty().addListener((obs, oldVal, newVal) -> { + if (newVal == null && oldVal != null) { + serverPackStatusField.setValue(oldVal); + } + }); + serverPackStatusField.getItems().setAll(Server.ServerPackStatus.values()); + body.add(serverPackStatusField, 1, 2); + serverPackStatusField.setValue( + Objects.requireNonNullElse(referenceServerPackStatus, Server.ServerPackStatus.PROMPT) + ); + } + + bodyVbox.getChildren().add(body); + rootLayout.setBody(bodyVbox); + + btnAccept.setText(switch (type) { + case EDIT -> i18n("server.manage.edit.accept"); + case ADD -> i18n("server.manage.add.accept"); + }); + btnAccept.getStyleClass().add("dialog-accept"); + btnAccept.disableProperty().bind(new BooleanBinding() { + { + bind(serverIpField.textProperty()); + } + + @Override + protected boolean computeValue() { + return !serverIpField.validate(); + } + }); + + lblErrorLabel.setWrapText(true); + lblErrorLabel.setMaxWidth(400); + + btnCancel.setText(i18n("button.cancel")); + btnCancel.getStyleClass().add("dialog-cancel"); + onEscPressed(this, btnCancel::fire); + + spinner.getStyleClass().add("small-spinner-pane"); + spinner.setContent(btnAccept); + + HBox actions = new HBox(spinner, btnCancel); + actions.setAlignment(Pos.CENTER_RIGHT); + + rootLayout.setActions(lblErrorLabel, actions); + + btnCancel.setOnAction(e -> onCancel()); + btnAccept.setOnAction(ignored -> { + String serverName; + String serverIP = serverIpField.getText(); + Server.ServerPackStatus serverPackStatus = serverPackStatusField.getValue(); + if (serverIP == null) return; + if (serverNameField.getText() == null || serverNameField.getText().isEmpty()) { + serverName = i18n("server.name.def"); + } else { + serverName = serverNameField.getText(); + } + + if (!stillActionProgress.get()) { + onAdd(serverPackStatus, serverName, serverIP); + return; + } + + fireEvent(new DialogCloseEvent()); + if (reference != null) { + handleCallback.accept(reference + .withIpAndName(serverIP, serverName) + .withPackStatus(serverPackStatus) + ); + } else { + handleCallback.accept(new Server(serverPackStatus, false, null, serverIP, serverName)); + } + }); + + ChangeListener contentChangeListener = (ignored0, ignored1, ignored2) -> { + if (stillActionProgress.get()) { + hideStillAction(); + } + }; + // serverNameField.textProperty().addListener(contentChangeListener); + serverIpField.textProperty().addListener(contentChangeListener); + } + + private void showStillAction(String errorMessage) { + stillActionProgress.set(true); + lblErrorLabel.setText(errorMessage); + btnAccept.setText(switch (type) { + case EDIT -> i18n("server.manage.edit.still"); + case ADD -> i18n("server.manage.add.still"); + }); + } + + private void hideStillAction() { + stillActionProgress.set(false); + lblErrorLabel.setText(""); + btnAccept.setText(switch (type) { + case EDIT -> i18n("server.manage.edit.accept"); + case ADD -> i18n("server.manage.add.accept"); + }); + } + + private void onAdd(Server.ServerPackStatus packStatus, String serverName, String serverIP) { + body.setDisable(true); + spinner.showSpinner(); + + Task.supplyAsync(Schedulers.io(), () -> + ServerStatusPinger.getStatus(serverIP) + ).whenComplete(Schedulers.javafx(), (result, ignored) -> { + + ServerStatus status = result.getIfSucceed(); + if (status == null) { + spinner.hideSpinner(); + body.setDisable(false); + + ServerStatusResult.FailureResult.Reason reason = result.getFailureReasonIfFailed(); + showStillAction(switch (Objects.requireNonNull(reason)) { + case EXCEPTION -> i18n("server.manage.status.error"); + case UNKNOWN_HOST -> i18n("server.manage.status.error.unknownhost"); + case BLOCKED_BY_MOJANG -> i18n("server.manage.status.error.blocked"); + }); + } else { + if (getScene() == null || cancelled) { + return; + } + fireEvent(new DialogCloseEvent()); + if (reference != null) { + handleCallback.accept(reference + .withIpAndName(serverIP, serverName) + .withPackStatus(packStatus) + .withIcon(status.favicon()) + ); + } else { + handleCallback.accept(new Server(packStatus, false, status.favicon(), serverIP, serverName)); + } + } + }).start(); + } + + private void onCancel() { + cancelled = true; + fireEvent(new DialogCloseEvent()); + } + + @Override + public void onDialogShown() { + serverNameField.requestFocus(); + } + + public enum Type { + EDIT, + ADD + } + } + + public final class ServerListItem { + private final @NotNull ServerStorage.ServerStorageEntry storageEntry; + private final Image serverFaviconImage; + + final @NotNull ObservableServerStatus observableServerStatus; + + public ServerListItem(@NotNull ServerStorage.ServerStorageEntry storageEntry) { + this(storageEntry, new ObservableServerStatus(storageEntry.getServer().ip())); + } + + public ServerListItem(@NotNull ServerStorage.ServerStorageEntry storageEntry, @NotNull ObservableServerStatus observableServerStatus) { + this.storageEntry = storageEntry; + this.observableServerStatus = observableServerStatus; + + this.serverFaviconImage = parseServerFaviconImageOrDefault(storageEntry.getServer().icon()); + } + + public Server getServer() { + return storageEntry.getServer(); + } + + public void openServerStatusPane() { + Controllers.dialog(new ServerStatusDialog(observableServerStatus, getServer(), ServerListPage.this.gameInstance)); + } + } +} diff --git a/HMCL/src/main/resources/assets/css/root.css b/HMCL/src/main/resources/assets/css/root.css index a9bd0d1140..82daea527d 100644 --- a/HMCL/src/main/resources/assets/css/root.css +++ b/HMCL/src/main/resources/assets/css/root.css @@ -568,6 +568,11 @@ -fx-text-fill: -monet-on-surface; } +.dialog-refresh { + -fx-text-fill: -monet-primary; + -fx-padding: 0.7em 0.8em; +} + /******************************************************************************* * * * Microsoft Login Dialog * diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index bb77d3eca7..13b9ccb891 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -785,6 +785,7 @@ instance.game.support_status.untested=Untested instance.game.type=Type instance.launch=Launch Game instance.launch_and_enter_world=Play World +instance.launch_and_connect_server=Play Server instance.launch.empty=Start Game instance.launch.empty.installing=Installing Game instance.launch.empty.tooltip=Install and launch the latest official release @@ -1364,6 +1365,66 @@ selector.choose_file=Choose file selector.choose_directory=Choose directory selector.custom=Custom +server.address.malformed=Invalid server address +server.and_more_mods=... and %s more mods +server.and_more_players=... and %s more players +server.anonymous_player=Anonymous player +server.delete=Delete this server +server.dnd.add=Add to current instance +server.dnd.add.instanceempty=Add failed +server.dnd.add.instanceempty.desc=There are no games available to add.\nYou can go to the "Download" page to download a new game, or switch game directories in the "All Instances" page. +server.empty=No Servers +server.ip=Server address +server.latency=Current latency +server.manage=Servers +server.manage.add=Add Server +server.manage.add.accept=Add +server.manage.add.head=Add Server +server.manage.add.still=Still Add +server.manage.copy.server.ip=Copy address +server.manage.copy.server.ip.ok.toast=Server address copied to clipboard +server.manage.copy.to.instance=Copy to current instance +server.manage.delete=Delete +server.manage.show_all=Show All +server.manage.show_hide=Show Hidden +server.manage.edit=Edit Server +server.manage.edit.accept=Submit +server.manage.edit.head=Edit Server +server.manage.edit.still=Still Submit +server.manage.error.file.modified=The file being operated on has been externally modified +server.manage.status=Server Status +server.manage.status.error=Unable to fetch the current server status +server.manage.status.error.blocked=The current server has been blocked +server.manage.status.error.unknownhost=Unknown host +server.manage.status.head=Server Status +server.manage.status.outside.error=Server Status (Failed) +server.manage.status.outside.error.blocked=Server Status (Failed, Blocked) +server.manage.status.outside.error.unknownhost=Server Status (Failed, Unknown host) +server.manage.status.outside.pinging=Server Status (Pinging...) +server.manage.status.outside.pong=Server Status (%sms) +server.name=Server name +server.name.def=Minecraft Server +server.onlineplayers=Online +server.onlineplayers.full=%,d/%,d +server.onlineplayers.unknown=?/? +server.pinger=Pinger +server.pinger.modern=Modern +server.pinger.legacy=Legacy +server.pinger.classic=Classic +server.playversion=Version +server.playversion.full=%s(%d) +server.playversion.onlyversion=Unknown(%d) +server.playversion.unknown=Unknown +server.resourcepack=Server Resource Packs +server.resourcepack.prompt=Prompt +server.resourcepack.enabled=Enabled +server.resourcepack.disabled=Disabled +server.tag.hide=Hidden +server.tag.hold.current=Current +server.type=Server type +server.type.mod=%s (%,d mods) +server.type.vanilla=Vanilla + settings=Settings settings.advanced=Advanced Settings diff --git a/HMCL/src/main/resources/assets/lang/I18N_ar.properties b/HMCL/src/main/resources/assets/lang/I18N_ar.properties index 460d051c5c..f09aa8a966 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ar.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ar.properties @@ -786,6 +786,7 @@ instance.game.support_status.untested=غير مختبر instance.game.type=النوع instance.launch=تشغيل اللعبة instance.launch_and_enter_world=لعب في العالم +instance.launch_and_connect_server=لعب خوادم instance.launch.empty=تشغيل اللعبة instance.launch.empty.installing=جارٍ تثبيت اللعبة instance.launch.empty.tooltip=تثبيت وتشغيل أحدث إصدار رسمي @@ -1364,6 +1365,66 @@ selector.choose_file=اختر ملفاً # selector.choose_directory= selector.custom=مخصص +# server.address.malformed= +# server.and_more_mods= +# server.and_more_players= +# server.anonymous_player= +# server.delete= +# server.dnd.add= +# server.dnd.add.instanceempty= +# server.dnd.add.instanceempty.desc= +# server.empty= +# server.ip= +# server.latency= +# server.manage= +# server.manage.add= +# server.manage.add.accept= +# server.manage.add.head= +# server.manage.add.still= +# server.manage.copy.server.ip= +# server.manage.copy.server.ip.ok.toast= +# server.manage.copy.to.instance= +# server.manage.delete= +# server.manage.show_all= +# server.manage.show_hide= +# server.manage.edit= +# server.manage.edit.accept= +# server.manage.edit.head= +# server.manage.edit.still= +# server.manage.error.file.modified= +# server.manage.status= +# server.manage.status.error= +# server.manage.status.error.blocked= +# server.manage.status.error.unknownhost= +# server.manage.status.head= +# server.manage.status.outside.error= +# server.manage.status.outside.error.blocked= +# server.manage.status.outside.error.unknownhost= +# server.manage.status.outside.pinging= +# server.manage.status.outside.pong= +# server.name= +# server.name.def= +# server.onlineplayers= +# server.onlineplayers.full= +# server.onlineplayers.unknown= +# server.pinger= +# server.pinger.modern= +# server.pinger.legacy= +# server.pinger.classic= +# server.playversion= +# server.playversion.full= +# server.playversion.onlyversion= +# server.playversion.unknown= +# server.resourcepack= +# server.resourcepack.prompt= +# server.resourcepack.enabled= +# server.resourcepack.disabled= +# server.tag.hide= +# server.tag.hold.current= +# server.type= +# server.type.mod= +# server.type.vanilla= + settings=الإعدادات settings.advanced=الإعدادات المتقدمة diff --git a/HMCL/src/main/resources/assets/lang/I18N_de.properties b/HMCL/src/main/resources/assets/lang/I18N_de.properties index f522265d92..91dce6b3c8 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_de.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_de.properties @@ -784,6 +784,7 @@ instance.game.support_status.untested=Ungetestet instance.game.type=Typ instance.launch=Spiel starten instance.launch_and_enter_world=Welt spielen +instance.launch_and_connect_server=Server spielen instance.launch.empty=Spiel starten instance.launch.empty.installing=Installiere Spiel instance.launch.empty.tooltip=Die neueste offizielle Release installieren und starten @@ -1363,6 +1364,66 @@ selector.choose_file=Datei auswählen selector.choose_directory=Verzeichnis auswählen selector.custom=Benutzerdefiniert +# server.address.malformed= +# server.and_more_mods= +# server.and_more_players= +# server.anonymous_player= +# server.delete= +# server.dnd.add= +# server.dnd.add.instanceempty= +# server.dnd.add.instanceempty.desc= +# server.empty= +# server.ip= +# server.latency= +# server.manage= +# server.manage.add= +# server.manage.add.accept= +# server.manage.add.head= +# server.manage.add.still= +# server.manage.copy.server.ip= +# server.manage.copy.server.ip.ok.toast= +# server.manage.copy.to.instance= +# server.manage.delete= +# server.manage.show_all= +# server.manage.show_hide= +# server.manage.edit= +# server.manage.edit.accept= +# server.manage.edit.head= +# server.manage.edit.still= +# server.manage.error.file.modified= +# server.manage.status= +# server.manage.status.error= +# server.manage.status.error.blocked= +# server.manage.status.error.unknownhost= +# server.manage.status.head= +# server.manage.status.outside.error= +# server.manage.status.outside.error.blocked= +# server.manage.status.outside.error.unknownhost= +# server.manage.status.outside.pinging= +# server.manage.status.outside.pong= +# server.name= +# server.name.def= +# server.onlineplayers= +# server.onlineplayers.full= +# server.onlineplayers.unknown= +# server.pinger= +# server.pinger.modern= +# server.pinger.legacy= +# server.pinger.classic= +# server.playversion= +# server.playversion.full= +# server.playversion.onlyversion= +# server.playversion.unknown= +# server.resourcepack= +# server.resourcepack.prompt= +# server.resourcepack.enabled= +# server.resourcepack.disabled= +# server.tag.hide= +# server.tag.hold.current= +# server.type= +# server.type.mod= +# server.type.vanilla= + settings=Einstellungen settings.advanced=Erweiterte Einstellungen diff --git a/HMCL/src/main/resources/assets/lang/I18N_es.properties b/HMCL/src/main/resources/assets/lang/I18N_es.properties index 5be9538716..9c29d2e4fe 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_es.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_es.properties @@ -778,6 +778,7 @@ instance.game.snapshots=Snapshots instance.game.type=Tipo instance.launch=Iniciar Minecraft # instance.launch_and_enter_world= +# instance.launch_and_connect_server= instance.launch.empty=Iniciar Minecraft instance.launch.empty.installing=Instalando el juego instance.launch.empty.tooltip=Instala y ejecuta la última versión oficial del juego. @@ -1356,6 +1357,66 @@ selector.choose_file=Elegir archivo # selector.choose_directory= selector.custom=Personalizar +# server.address.malformed= +# server.and_more_mods= +# server.and_more_players= +# server.anonymous_player= +# server.delete= +# server.dnd.add= +# server.dnd.add.instanceempty= +# server.dnd.add.instanceempty.desc= +# server.empty= +# server.ip= +# server.latency= +# server.manage= +# server.manage.add= +# server.manage.add.accept= +# server.manage.add.head= +# server.manage.add.still= +# server.manage.copy.server.ip= +# server.manage.copy.server.ip.ok.toast= +# server.manage.copy.to.instance= +# server.manage.delete= +# server.manage.show_all= +# server.manage.show_hide= +# server.manage.edit= +# server.manage.edit.accept= +# server.manage.edit.head= +# server.manage.edit.still= +# server.manage.error.file.modified= +# server.manage.status= +# server.manage.status.error= +# server.manage.status.error.blocked= +# server.manage.status.error.unknownhost= +# server.manage.status.head= +# server.manage.status.outside.error= +# server.manage.status.outside.error.blocked= +# server.manage.status.outside.error.unknownhost= +# server.manage.status.outside.pinging= +# server.manage.status.outside.pong= +# server.name= +# server.name.def= +# server.onlineplayers= +# server.onlineplayers.full= +# server.onlineplayers.unknown= +# server.pinger= +# server.pinger.modern= +# server.pinger.legacy= +# server.pinger.classic= +# server.playversion= +# server.playversion.full= +# server.playversion.onlyversion= +# server.playversion.unknown= +# server.resourcepack= +# server.resourcepack.prompt= +# server.resourcepack.enabled= +# server.resourcepack.disabled= +# server.tag.hide= +# server.tag.hold.current= +# server.type= +# server.type.mod= +# server.type.vanilla= + settings=Configuración settings.advanced=Configuración avanzada diff --git a/HMCL/src/main/resources/assets/lang/I18N_ja.properties b/HMCL/src/main/resources/assets/lang/I18N_ja.properties index ed0233a364..2527ebde65 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ja.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ja.properties @@ -588,6 +588,7 @@ instance.game.snapshots=Snapshots # instance.game.type= instance.launch=プレイ # instance.launch_and_enter_world= +# instance.launch_and_connect_server= # instance.launch.empty= # instance.launch.empty.installing= # instance.launch.empty.tooltip= @@ -1160,6 +1161,66 @@ selector.choose_file=ファイルを選択します # selector.choose_directory= selector.custom=カスタム +# server.address.malformed= +# server.and_more_mods= +# server.and_more_players= +# server.anonymous_player= +# server.delete= +# server.dnd.add= +# server.dnd.add.instanceempty= +# server.dnd.add.instanceempty.desc= +# server.empty= +# server.ip= +# server.latency= +# server.manage= +# server.manage.add= +# server.manage.add.accept= +# server.manage.add.head= +# server.manage.add.still= +# server.manage.copy.server.ip= +# server.manage.copy.server.ip.ok.toast= +# server.manage.copy.to.instance= +# server.manage.delete= +# server.manage.show_all= +# server.manage.show_hide= +# server.manage.edit= +# server.manage.edit.accept= +# server.manage.edit.head= +# server.manage.edit.still= +# server.manage.error.file.modified= +# server.manage.status= +# server.manage.status.error= +# server.manage.status.error.blocked= +# server.manage.status.error.unknownhost= +# server.manage.status.head= +# server.manage.status.outside.error= +# server.manage.status.outside.error.blocked= +# server.manage.status.outside.error.unknownhost= +# server.manage.status.outside.pinging= +# server.manage.status.outside.pong= +# server.name= +# server.name.def= +# server.onlineplayers= +# server.onlineplayers.full= +# server.onlineplayers.unknown= +# server.pinger= +# server.pinger.modern= +# server.pinger.legacy= +# server.pinger.classic= +# server.playversion= +# server.playversion.full= +# server.playversion.onlyversion= +# server.playversion.unknown= +# server.resourcepack= +# server.resourcepack.prompt= +# server.resourcepack.enabled= +# server.resourcepack.disabled= +# server.tag.hide= +# server.tag.hold.current= +# server.type= +# server.type.mod= +# server.type.vanilla= + settings=設定 settings.advanced=詳細設定 diff --git a/HMCL/src/main/resources/assets/lang/I18N_lzh.properties b/HMCL/src/main/resources/assets/lang/I18N_lzh.properties index 56c3c44df7..06f58b2a2d 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_lzh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_lzh.properties @@ -600,6 +600,7 @@ instance.game.snapshots=預版 instance.game.type=版類 instance.launch=戲 # instance.launch_and_enter_world= +# instance.launch_and_connect_server= instance.launch.empty=啟戲 instance.launch.empty.installing=裝戲 instance.launch.empty.tooltip=裝啟至新之當版 @@ -1172,6 +1173,66 @@ selector.choose_file=擇案 # selector.choose_directory= selector.custom=自定 +server.address.malformed=伺服器地址不合法 +# server.and_more_mods= +# server.and_more_players= +# server.anonymous_player= +# server.delete= +# server.dnd.add= +# server.dnd.add.instanceempty= +# server.dnd.add.instanceempty.desc= +# server.empty= +# server.ip= +# server.latency= +# server.manage= +# server.manage.add= +# server.manage.add.accept= +# server.manage.add.head= +# server.manage.add.still= +# server.manage.copy.server.ip= +# server.manage.copy.server.ip.ok.toast= +# server.manage.copy.to.instance= +# server.manage.delete= +# server.manage.show_all= +# server.manage.show_hide= +# server.manage.edit= +# server.manage.edit.accept= +# server.manage.edit.head= +# server.manage.edit.still= +# server.manage.error.file.modified= +# server.manage.status= +# server.manage.status.error= +# server.manage.status.error.blocked= +# server.manage.status.error.unknownhost= +# server.manage.status.head= +# server.manage.status.outside.error= +# server.manage.status.outside.error.blocked= +# server.manage.status.outside.error.unknownhost= +# server.manage.status.outside.pinging= +# server.manage.status.outside.pong= +# server.name= +# server.name.def= +# server.onlineplayers= +# server.onlineplayers.full= +# server.onlineplayers.unknown= +# server.pinger= +# server.pinger.modern= +# server.pinger.legacy= +# server.pinger.classic= +# server.playversion= +# server.playversion.full= +# server.playversion.onlyversion= +# server.playversion.unknown= +# server.resourcepack= +# server.resourcepack.prompt= +# server.resourcepack.enabled= +# server.resourcepack.disabled= +# server.tag.hide= +# server.tag.hold.current= +# server.type= +# server.type.mod= +# server.type.vanilla= + settings=置設 settings.advanced=進階置設 diff --git a/HMCL/src/main/resources/assets/lang/I18N_ru.properties b/HMCL/src/main/resources/assets/lang/I18N_ru.properties index 6908c0067c..017e0daa2a 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ru.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ru.properties @@ -715,6 +715,7 @@ instance.game.support_status.untested=Не проверено instance.game.type=Тип instance.launch=Играть instance.launch_and_enter_world=Играть в этом мире +instance.launch_and_connect_server=Играть в этом сервере instance.launch.empty=Играть instance.launch.empty.installing=Установка игры instance.launch.empty.tooltip=Установить и запустить последнюю официальную версию игры. @@ -1296,6 +1297,66 @@ selector.choose_file=Выберите файл selector.choose_directory=Выберите папку selector.custom=Свой +# server.address.malformed= +# server.and_more_mods= +# server.and_more_players= +# server.anonymous_player= +# server.delete= +# server.dnd.add= +# server.dnd.add.instanceempty= +# server.dnd.add.instanceempty.desc= +# server.empty= +# server.ip= +# server.latency= +# server.manage= +# server.manage.add= +# server.manage.add.accept= +# server.manage.add.head= +# server.manage.add.still= +# server.manage.copy.server.ip= +# server.manage.copy.server.ip.ok.toast= +# server.manage.copy.to.instance= +# server.manage.delete= +# server.manage.show_all= +# server.manage.show_hide= +# server.manage.edit= +# server.manage.edit.accept= +# server.manage.edit.head= +# server.manage.edit.still= +# server.manage.error.file.modified= +# server.manage.status= +# server.manage.status.error= +# server.manage.status.error.blocked= +# server.manage.status.error.unknownhost= +# server.manage.status.head= +# server.manage.status.outside.error= +# server.manage.status.outside.error.blocked= +# server.manage.status.outside.error.unknownhost= +# server.manage.status.outside.pinging= +# server.manage.status.outside.pong= +# server.name= +# server.name.def= +# server.onlineplayers= +# server.onlineplayers.full= +# server.onlineplayers.unknown= +# server.pinger= +# server.pinger.modern= +# server.pinger.legacy= +# server.pinger.classic= +# server.playversion= +# server.playversion.full= +# server.playversion.onlyversion= +# server.playversion.unknown= +# server.resourcepack= +# server.resourcepack.prompt= +# server.resourcepack.enabled= +# server.resourcepack.disabled= +# server.tag.hide= +# server.tag.hold.current= +# server.type= +# server.type.mod= +# server.type.vanilla= + settings=Настройки settings.advanced=Расширенные настройки diff --git a/HMCL/src/main/resources/assets/lang/I18N_uk.properties b/HMCL/src/main/resources/assets/lang/I18N_uk.properties index 13dd2c4b0e..2fb146a30e 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_uk.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_uk.properties @@ -739,6 +739,7 @@ instance.game.support_status.untested=Нетестована instance.game.type=Тип instance.launch=Запустити гру instance.launch_and_enter_world=Грати у світ +instance.launch_and_connect_server=Грати у сервері instance.launch.empty=Почати гру instance.launch.empty.installing=Встановлення гри instance.launch.empty.tooltip=Встановити та запустити останній офіційний реліз @@ -1319,6 +1320,66 @@ selector.choose_file=Вибрати файл selector.choose_directory=Виберіть каталог selector.custom=Власний +# server.address.malformed= +# server.and_more_mods= +# server.and_more_players= +# server.anonymous_player= +# server.delete= +# server.dnd.add= +# server.dnd.add.instanceempty= +# server.dnd.add.instanceempty.desc= +# server.empty= +# server.ip= +# server.latency= +# server.manage= +# server.manage.add= +# server.manage.add.accept= +# server.manage.add.head= +# server.manage.add.still= +# server.manage.copy.server.ip= +# server.manage.copy.server.ip.ok.toast= +# server.manage.copy.to.instance= +# server.manage.delete= +# server.manage.show_all= +# server.manage.show_hide= +# server.manage.edit= +# server.manage.edit.accept= +# server.manage.edit.head= +# server.manage.edit.still= +# server.manage.error.file.modified= +# server.manage.status= +# server.manage.status.error= +# server.manage.status.error.blocked= +# server.manage.status.error.unknownhost= +# server.manage.status.head= +# server.manage.status.outside.error= +# server.manage.status.outside.error.blocked= +# server.manage.status.outside.error.unknownhost= +# server.manage.status.outside.pinging= +# server.manage.status.outside.pong= +# server.name= +# server.name.def= +# server.onlineplayers= +# server.onlineplayers.full= +# server.onlineplayers.unknown= +# server.pinger= +# server.pinger.modern= +# server.pinger.legacy= +# server.pinger.classic= +# server.playversion= +# server.playversion.full= +# server.playversion.onlyversion= +# server.playversion.unknown= +# server.resourcepack= +# server.resourcepack.prompt= +# server.resourcepack.enabled= +# server.resourcepack.disabled= +# server.tag.hide= +# server.tag.hold.current= +# server.type= +# server.type.mod= +# server.type.vanilla= + settings=Налаштування settings.advanced=Розширені налаштування 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 feef558cce..4dae365278 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_Hans.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_Hans.properties @@ -603,6 +603,7 @@ instance.game.support_status.untested=未经测试 instance.game.type=版本类型 instance.launch=启动游戏 instance.launch_and_enter_world=进入世界 +instance.launch_and_connect_server=进入服务器 instance.launch.empty=开始游戏 instance.launch.empty.installing=安装游戏 instance.launch.empty.tooltip=安装并启动最新正式版游戏 @@ -1176,6 +1177,66 @@ selector.choose_file=选择文件 selector.choose_directory=选择文件夹 selector.custom=自定义 +server.address.malformed=地址不合法 +server.and_more_mods=... 及其他 %s 个模组 +server.and_more_players=... 及其他 %s 名玩家 +server.anonymous_player=匿名玩家 +server.delete=删除此服务器 +server.dnd.add=添加到当前实例 +server.dnd.add.instanceempty=添加失败 +server.dnd.add.instanceempty.desc=没有可添加的游戏。\n你可以前往“下载”页面下载新游戏,或在“实例列表”中切换游戏文件夹。 +server.empty=没有服务器 +server.ip=服务器地址 +server.latency=当前延迟 +server.manage=服务器管理 +server.manage.add=添加服务器 +server.manage.add.accept=添加 +server.manage.add.head=添加服务器 +server.manage.add.still=依然添加 +server.manage.copy.server.ip=复制地址 +server.manage.copy.server.ip.ok.toast=已将服务器地址复制到剪贴板 +server.manage.copy.to.instance=复制到此实例 +server.manage.delete=删除 +server.manage.show_all=显示全部 +server.manage.show_hide=显示隐藏 +server.manage.edit=编辑服务器 +server.manage.edit.accept=提交 +server.manage.edit.head=编辑服务器 +server.manage.edit.still=依然提交 +server.manage.error.file.modified=所操作的文件已被外部修改 +server.manage.status=服务器状态 +server.manage.status.error=无法获取目标服务器状态 +server.manage.status.error.blocked=目标服务器已被禁止 +server.manage.status.error.unknownhost=未知的主机 +server.manage.status.head=服务器状态 +server.manage.status.outside.error=服务器状态 (失败) +server.manage.status.outside.error.blocked=服务器状态 (失败,被禁止) +server.manage.status.outside.error.unknownhost=服务器状态 (失败,未知的主机) +server.manage.status.outside.pinging=服务器状态 (检测中...) +server.manage.status.outside.pong=服务器状态 (%sms) +server.name=服务器名字 +server.name.def=Minecraft 服务器 +server.onlineplayers=在线人数 +server.onlineplayers.full=%,d/%,d +server.onlineplayers.unknown=?/? +server.pinger=探测器 +server.pinger.modern=现代 +server.pinger.legacy=旧的 +server.pinger.classic=远古 +server.playversion=版本 +server.playversion.full=%s(%d) +server.playversion.onlyversion=未知(%d) +server.playversion.unknown=未知 +server.resourcepack=服务器资源包 +server.resourcepack.prompt=询问 +server.resourcepack.enabled=启用 +server.resourcepack.disabled=禁用 +server.tag.hide=隐藏 +server.tag.hold.current=当前实例 +server.type=服务器类型 +server.type.mod=%s (%,d 个模组) +server.type.vanilla=原版 + settings=设置 settings.advanced=高级设置 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 cef80a6ca7..0f2e5b5ead 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_Hant.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_Hant.properties @@ -597,6 +597,7 @@ instance.game.support_status.untested=未經測試 instance.game.type=版本類型 instance.launch=啟動遊戲 instance.launch_and_enter_world=進入世界 +instance.launch_and_connect_server=進入伺服器 instance.launch.empty=開始遊戲 instance.launch.empty.installing=安裝遊戲 instance.launch.empty.tooltip=安裝並啟動最新正式版遊戲 @@ -1170,6 +1171,66 @@ selector.choose_file=選取檔案 selector.choose_directory=選取目錄 selector.custom=自訂 +server.address.malformed=伺服器位址無效 +server.and_more_mods=... 及其他 %s 個模組 +server.and_more_players=... 及其他 %s 名玩家 +server.anonymous_player=匿名玩家 +server.delete=刪除此伺服器 +server.dnd.add=新增到目前實例 +server.dnd.add.instanceempty=新增失敗 +server.dnd.add.instanceempty.desc=沒有可新增的遊戲。\n你可以前往「下載」頁面下載新遊戲,或在「實例清單」中切換遊戲目錄。 +server.empty=沒有伺服器 +server.ip=伺服器位址 +server.latency=當前延遲 +server.manage=伺服器管理 +server.manage.add=新增伺服器 +server.manage.add.accept=新增 +server.manage.add.head=新增伺服器 +server.manage.add.still=依然新增 +server.manage.copy.server.ip=複製位址 +server.manage.copy.server.ip.ok.toast=已將伺服器位址複製到剪貼簿 +server.manage.copy.to.instance=複製到此實例 +server.manage.delete=刪除 +server.manage.show_all=顯示全部 +server.manage.show_hide=顯示隱藏 +server.manage.edit=編輯伺服器 +server.manage.edit.accept=提交 +server.manage.edit.head=編輯伺服器 +server.manage.edit.still=仍然提交 +server.manage.error.file.modified=所操作的檔案已被外部修改 +server.manage.status=伺服器狀態 +server.manage.status.error=無法取得目標伺服器狀態 +server.manage.status.error.blocked=目標伺服器已被封鎖 +server.manage.status.error.unknownhost=未知主機 +server.manage.status.head=伺服器狀態 +server.manage.status.outside.error=伺服器狀態 (失敗) +server.manage.status.outside.error.blocked=伺服器狀態 (失敗,已封鎖) +server.manage.status.outside.error.unknownhost=伺服器狀態 (失敗,未知主機) +server.manage.status.outside.pinging=伺服器狀態 (Pinging...) +server.manage.status.outside.pong=伺服器狀態 (%sms) +server.name=伺服器名稱 +server.name.def=Minecraft 伺服器 +server.onlineplayers=線上人數 +server.onlineplayers.full=%,d/%,d +server.onlineplayers.unknown=?/? +server.pinger=探測器 +server.pinger.modern=現代 +server.pinger.legacy=舊的 +server.pinger.classic=遠古 +server.playversion=版本 +server.playversion.full=%s(%d) +server.playversion.onlyversion=未知(%d) +server.playversion.unknown=未知 +server.resourcepack=伺服器資源包 +server.resourcepack.prompt=詢問 +server.resourcepack.enabled=啟用 +server.resourcepack.disabled=停用 +server.tag.hide=隱藏 +server.tag.hold.current=目前實例 +server.type=伺服器類型 +server.type.mod=%s (%,d 個模組) +server.type.vanilla=原版 + settings=設定 settings.advanced=進階設定 diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java index b17993cc66..b495b0f9cc 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java @@ -133,6 +133,13 @@ default Path getResourcePackDirectory() { return getRunDirectory().resolve("resourcepacks"); } + /// Returns the server dat file held by this instance. + /// + /// @return the server dat file below the held + default Path getServersDatFilePath() { + return getRunDirectory().resolve("servers.dat"); + } + /// Returns the directory containing saved worlds used by this instance. /// /// @return the saves directory below the run directory diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java index 52f81d1f7d..2b646c5b31 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/World.java @@ -221,6 +221,10 @@ public static boolean supportQuickPlay(GameVersionNumber gameVersionNumber) { return gameVersionNumber != null && gameVersionNumber.isAtLeast("1.20", "23w14a"); } + public static boolean supportQueryArgServerAddress(GameVersionNumber gameVersionNumber) { + return gameVersionNumber != null && gameVersionNumber.compareTo("26.4-snapshot-1") >= 0; + } + private void loadAndCheckWorldData() throws IOException { loadAndCheckLevelData(levelDataPath); loadOtherData(); 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 0df025f8cc..355208da05 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java @@ -359,12 +359,23 @@ private Command generateCommandLine(Path nativeFolder) throws IOException { ServerAddress parsed = ServerAddress.parse(address); if (World.supportQuickPlay(GameVersionNumber.asGameVersion(gameVersion))) { res.add("--quickPlayMultiplayer"); - res.add(parsed.port() >= 0 ? address : parsed.host() + ":25565"); + if (World.supportQueryArgServerAddress(GameVersionNumber.asGameVersion(gameVersion))) { + res.add(parsed.toServerIp(true)); + } else { + res.add(parsed.toServerIp(false)); + if (!parsed.queryProperties().isEmpty()) { + LOG.warning("Ignore the query parameters in the server address."); + } + } } else { res.add("--server"); - res.add(parsed.host()); + res.add(parsed.hostAndPort().host()); res.add("--port"); - res.add(parsed.port() >= 0 ? String.valueOf(parsed.port()) : "25565"); + res.add(String.valueOf(parsed.hostAndPort().port())); + + if (!parsed.queryProperties().isEmpty()) { + LOG.warning("Ignore the query parameters in the server address."); + } } } catch (IllegalArgumentException e) { LOG.warning("Invalid server address: " + address, e); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/server/Server.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/server/Server.java new file mode 100644 index 0000000000..9f850fb1fc --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/server/Server.java @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors +// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL +package org.jackhuang.hmcl.server; + +import org.glavo.nbt.io.NBTCodec; +import org.glavo.nbt.tag.*; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; + +public record Server( + @NotNull ServerPackStatus serverPackStatus, + boolean hidden, + @Nullable String icon, + @Nullable String ip, + @Nullable String name +) { + public Server(@NotNull ServerPackStatus serverPackStatus, boolean hidden, @Nullable String icon, @Nullable String ip, @Nullable String name) { + this.serverPackStatus = serverPackStatus; + this.hidden = hidden; + this.icon = icon; + this.ip = ip; + this.name = name; + } + + public static Server loadSingle(@NotNull CompoundTag tag) { + return new Server( + tag.get("acceptTextures") instanceof ByteTag bt ? (bt.getValue() == 0 ? ServerPackStatus.DISABLED : ServerPackStatus.ENABLED) : ServerPackStatus.PROMPT, + tag.get("hidden") instanceof ByteTag bt && bt.getValue() != 0, + tag.get("icon") instanceof StringTag stg ? stg.getValue() : null, + tag.get("ip") instanceof StringTag stg ? stg.getValue() : null, + tag.get("name") instanceof StringTag stg ? stg.getValue() : null + ); + } + + public static List loadFromServersDat(byte[] data) throws IOException { + List servers = new ArrayList<>(); + CompoundTag root = NBTCodec.of().readTag(data, TagType.COMPOUND); + Tag serversTag = root.get("servers"); + if (serversTag == null) { + throw new IOException("servers tag not found"); + } else if (!(serversTag instanceof ListTag)) { + throw new IOException("servers tag is not a ListTag"); + } + + for (Tag serverEntryTag : ((ListTag) serversTag)) { + if (serverEntryTag instanceof CompoundTag serverEntryTagCompound) { + servers.add(loadSingle(serverEntryTagCompound)); + } else { + throw new IOException("server entry tag is not a CompoundTag"); + } + } + + return servers; + } + + public static void saveServersDat(@NotNull List servers, OutputStream outputStream) throws IOException { + ListTag tag = new ListTag<>(); + for (Server server : servers) { + CompoundTag serverTag = new CompoundTag(); + server.writeToCompoundTag(serverTag); + tag.addTag(serverTag); + } + + CompoundTag root = new CompoundTag(); + root.addTag("servers", tag); + + NBTCodec.of().writeTag(outputStream, root); + } + + public void writeToCompoundTag(CompoundTag tag) { + switch (serverPackStatus) { + case ENABLED -> tag.addByte("acceptTextures", (byte) 1); + case DISABLED -> tag.addByte("acceptTextures", (byte) 0); + case PROMPT -> { + } + } + tag.addByte("hidden", (byte) (hidden ? 1 : 0)); + if (icon != null) tag.addString("icon", icon); + if (ip != null) tag.addString("ip", ip); + if (name != null) tag.addString("name", name); + } + + public enum ServerPackStatus { + ENABLED(), + DISABLED(), + PROMPT(); + } + + public @NotNull Server withIcon(@Nullable String icon) { + return new Server(serverPackStatus, hidden, icon, ip, name); + } + + public @NotNull Server withIpAndName(@Nullable String ip, @Nullable String name) { + return new Server(serverPackStatus, hidden, icon, ip, name); + } + + public @NotNull Server withPackStatus(@NotNull ServerPackStatus serverPackStatus) { + return new Server(serverPackStatus, hidden, icon, ip, name); + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/server/ServerBlockedByMojangException.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/server/ServerBlockedByMojangException.java new file mode 100644 index 0000000000..1407b0f00a --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/server/ServerBlockedByMojangException.java @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors +// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL +package org.jackhuang.hmcl.server; + +import java.io.IOException; + +public class ServerBlockedByMojangException extends IOException { + public ServerBlockedByMojangException(String message) { + super(message); + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/server/ServerDnD.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/server/ServerDnD.java new file mode 100644 index 0000000000..cd9b822567 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/server/ServerDnD.java @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors +// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL +package org.jackhuang.hmcl.server; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import javafx.event.EventHandler; +import javafx.scene.input.DragEvent; +import javafx.scene.input.Dragboard; +import javafx.scene.input.TransferMode; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Optional; +import java.util.function.Consumer; + +/** + * Drag-and-drop payload for a Minecraft server definition. + *

+ * Format: {@code minecraft-server:Base64(JSON)} + * + *

+ * Required fields: + *

    + *
  • {@code name}: the display name of the server.
  • + *
  • {@code address}: the server address, such as + * "...@server.ip.example.com:25565?_id=xxx&key=value...".
  • + *
+ * + *

+ * Optional fields: + *

    + *
  • {@code favicon}: the server icon. This value should be the raw PNG base64 payload only, without + * the {@code data:image/png;base64,} prefix. The parser in {@link org.jackhuang.hmcl.server.pinger.ServerStatusPinger} accepts the + * Minecraft status format where the remote payload may include a data URL, but it strips the prefix and + * keeps only the base64 bytes. This drag-and-drop payload therefore stores the base64 text directly, + * not the full data URL.
  • + *
+ * + * Example payload: + *
+ * {
+ *   "name": "server name",
+ *   "address": "...@server.ip.example.com:25565?_id=xxx&key=value...",
+ *   "favicon": "iVBORw0KGgoAAAANSUhEUgAA..."
+ * }
+ * 
+ */ +public final class ServerDnD { + private static final String SCHEME = "minecraft-server"; + + private ServerDnD() { + } + + public static Optional parseUrlFromDragboard(Dragboard dragboard) { + String input = dragboard.getString(); + if (input == null) return Optional.empty(); + if (input.length() > 32768) return Optional.empty(); + + int index = input.indexOf(":"); + if (index == -1) return Optional.empty(); + if (!SCHEME.equals(input.substring(0, index))) return Optional.empty(); + try { + JsonObject object = JsonParser.parseString(new String(Base64.getDecoder().decode(input.substring(index + 1)), StandardCharsets.UTF_8)).getAsJsonObject(); + String favicon = null; + if (object.has("favicon")) { + favicon = object.get("favicon").getAsString(); + } + String address = object.get("address").getAsString(); + String name = object.get("name").getAsString(); + return Optional.of(new Server( + Server.ServerPackStatus.PROMPT, + false, + favicon, + address, + name + )); + } catch (Exception ignored) { + } + return Optional.empty(); + } + + public static EventHandler dragOverHandler() { + return event -> parseUrlFromDragboard(event.getDragboard()).ifPresent(server -> { + event.acceptTransferModes(TransferMode.COPY); + event.consume(); + }); + } + + public static EventHandler dragDroppedHandler(Consumer onServerTransfered) { + return event -> parseUrlFromDragboard(event.getDragboard()).ifPresent(server -> { + event.setDropCompleted(true); + event.consume(); + onServerTransfered.accept(server); + }); + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/server/pinger/ClassicServerStatusPinger.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/server/pinger/ClassicServerStatusPinger.java new file mode 100644 index 0000000000..c1b0ab5042 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/server/pinger/ClassicServerStatusPinger.java @@ -0,0 +1,257 @@ +// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors +// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL +package org.jackhuang.hmcl.server.pinger; + +import com.google.gson.JsonArray; +import com.google.gson.JsonPrimitive; +import org.jackhuang.hmcl.server.resolver.ServerAddressResolveResult; +import org.jackhuang.hmcl.server.status.ServerPlayerInfo; +import org.jackhuang.hmcl.server.status.ServerStatus; +import org.jackhuang.hmcl.server.status.ServerStatusResult; +import org.jackhuang.hmcl.server.status.ServerVersionInfo; +import org.jetbrains.annotations.NotNull; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.net.Socket; +import java.net.StandardSocketOptions; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; + +// Classic Multiplayer Test Version. +public final class ClassicServerStatusPinger implements ServerStatusPinger { + static ClassicServerStatusPinger instance = new ClassicServerStatusPinger(); + + private ClassicServerStatusPinger() { + + } + + private static String generateFakeName() { + return UUID.randomUUID().toString().replace("-", "").substring( + ThreadLocalRandom.current().nextInt(3, 17) + ); + } + + @SuppressWarnings("unchecked") + private static T readClassicData(final DataInputStream in, Class type) throws IOException { + if (type == Long.TYPE || type == Long.class) { + return (T) (Object) in.readLong(); + } else if (type == Integer.TYPE || type == Integer.class) { + return (T) (Object) in.readInt(); + } else if (type == Short.TYPE || type == Short.class) { + return (T) (Object) in.readShort(); + } else if (type == Byte.TYPE || type == Byte.class) { + return (T) (Object) in.readByte(); + } else if (type == Double.TYPE || type == Double.class) { + return (T) (Object) in.readDouble(); + } else if (type == Float.TYPE || type == Float.class) { + return (T) (Object) in.readFloat(); + } else if (type == String.class) { + byte[] input = new byte[64]; + in.readFully(input); + return (T) new String(input, StandardCharsets.UTF_8).trim(); + } else if (type == byte[].class) { + byte[] input = new byte[1024]; + in.readFully(input); + return (T) input; + } + throw new IllegalArgumentException("Unknown type: " + type); + } + + private static void writeClassicData(final DataOutputStream out, Object param) throws IOException { + if (param instanceof Long value) { + out.writeLong(value); + } else if (param instanceof Integer value) { + out.writeInt(value); + } else if (param instanceof Short value) { + out.writeShort(value); + } else if (param instanceof Byte value) { + out.writeByte(value); + } else if (param instanceof Double value) { + out.writeDouble(value); + } else if (param instanceof Float value) { + out.writeFloat(value); + } else if (param instanceof String value) { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + if (bytes.length > 64) { + throw new IllegalArgumentException("String is too large"); + } + if (bytes.length == 64) { + out.write(bytes); + } else { + byte[] writeBytes = new byte[64]; + Arrays.fill(writeBytes, (byte) 32); + System.arraycopy(bytes, 0, writeBytes, 0, bytes.length); + out.write(writeBytes); + } + } else if (param instanceof byte[] value) { + if (value.length < 1024) { + out.write(Arrays.copyOf(value, 1024)); + } else if (value.length == 1024) { + out.write(value); + } else { + throw new IllegalArgumentException("Invalid byte array length"); + } + } else { + throw new IllegalArgumentException("Unknown parameter type: " + param.getClass()); + } + } + + @Override + public @NotNull ServerStatusResult getStatus(@NotNull ServerAddressResolveResult.SuccessResult successResult) { + IOException statusError = new IOException("Failed to get server status"); + for (ClassicVersion version : ClassicVersion.values()) { + try (Socket socket = new Socket()) { + socket.setOption(StandardSocketOptions.TCP_NODELAY, true); + socket.connect(successResult.getConnectAddress(), 7_000); + socket.setSoTimeout(7_000); + + try (DataOutputStream out = new DataOutputStream(socket.getOutputStream()); + DataInputStream in = new DataInputStream(socket.getInputStream())) { + long sendTime = System.currentTimeMillis(); + + version.writePlayerIdentificationPacket(out); + return version.processResponse(successResult, in, sendTime); + } + } catch (Exception e) { + statusError.addSuppressed(e); + } + } + return ServerStatusResult.failure(ServerStatusResult.FailureResult.Reason.EXCEPTION, statusError); + } + + @Override + public @NotNull String getName() { + return "Classic Server Status Pinger"; + } + + enum ClassicVersion { + // reference: https://minecraft.wiki/w/Classic_server_protocol#Client_%E2%86%92_server_packets + // Untested: Can't find a version to test. + PROTOCOL_0X07 { + @Override + byte getProtocolVersion() { + return 0x07; + } + + @Override + void writePlayerIdentificationPacket(DataOutputStream out) throws IOException { + super.writePlayerIdentificationPacket(out); + writeClassicData(out, (byte) 0); // Not sure if it's int or byte + } + }, + + // Tested Versions: + // * Minecraft Classic Server 1.8.2 + PROTOCOL_0X06 { + @Override + byte getProtocolVersion() { + return 0x06; + } + + @Override + void writePlayerIdentificationPacket(DataOutputStream out) throws IOException { + super.writePlayerIdentificationPacket(out); + writeClassicData(out, (byte) 0); // Not sure if it's int or byte + } + }, + + // Tested Versions: + // * Minecraft Classic Server 1.6 + PROTOCOL_0X05 { + @Override + byte getProtocolVersion() { + return 0x05; + } + }, + + // Tested Versions: + // * Minecraft Classic Server 1.3 + // * Minecraft Classic Server 1.4 + // * Minecraft Classic Server 1.4.1 + PROTOCOL_0X04 { + @Override + byte getProtocolVersion() { + return 0x04; + } + }, + + // Tested Versions: + // * Minecraft Classic Server 1.2 + PROTOCOL_0X03 { + @Override + byte getProtocolVersion() { + return 0x03; + } + }, + + // reference: https://github.com/Lenni0451/MCPing/blob/a7b4fb83822407166f1b66652733520d0137a677/src/main/java/net/lenni0451/mcping/pings/impl/ClassicPing.java#L80 + // Untested: Can't find a version to test. + PROTOCOL_HEADLESS { + @Override + byte getProtocolVersion() { + // Never called. + return -0x01; + } + + @Override + ServerVersionInfo getVersionInfo() { + return ServerVersionInfo.of(); + } + + @Override + void writePlayerIdentificationPacket(DataOutputStream out) throws IOException { + out.writeByte(0x00); // packet id + writeClassicData(out, generateFakeName()); + } + }; + + abstract byte getProtocolVersion(); + + ServerVersionInfo getVersionInfo() { + return ServerVersionInfo.of(getProtocolVersion()); + } + + void writePlayerIdentificationPacket(DataOutputStream out) throws IOException { + out.writeByte(0x00); // packet id + + writeClassicData(out, getProtocolVersion()); + writeClassicData(out, generateFakeName()); + writeClassicData(out, "--"); + } + + @NotNull ServerStatusResult.SuccessResult processResponse( + @NotNull ServerAddressResolveResult.SuccessResult successResult, + DataInputStream in, + long packSendTime + ) throws IOException { + byte packetID = in.readByte(); + if (packetID == 0x0E) { + String kickMessage = readClassicData(in, String.class); + throw new IllegalArgumentException("Invalid response: " + kickMessage); + } + + if (packetID != 0x00) { + throw new IllegalArgumentException("Invalid server identification packet"); + } + readClassicData(in, Byte.TYPE); // skip protocol number. + + ServerVersionInfo versionInfo = getVersionInfo(); + JsonArray description = new JsonArray(); + description.add(new JsonPrimitive(readClassicData(in, String.class))); + description.add(new JsonPrimitive("\n")); + description.add(new JsonPrimitive(readClassicData(in, String.class))); + + long networkLatency = System.currentTimeMillis() - packSendTime; + return ServerStatusResult.SuccessResult.success( + successResult.getRawAddress(), ClassicServerStatusPinger.instance, networkLatency, new ServerStatus( + versionInfo, ServerPlayerInfo.of(), description, null, false, null + ) + ); + } + + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/server/pinger/LegacyServerStatusPinger.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/server/pinger/LegacyServerStatusPinger.java new file mode 100644 index 0000000000..165a7a0ac2 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/server/pinger/LegacyServerStatusPinger.java @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors +// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL +package org.jackhuang.hmcl.server.pinger; + +import com.google.gson.JsonPrimitive; +import org.jackhuang.hmcl.server.resolver.ServerAddressResolveResult; +import org.jackhuang.hmcl.server.status.ServerPlayerInfo; +import org.jackhuang.hmcl.server.status.ServerStatus; +import org.jackhuang.hmcl.server.status.ServerStatusResult; +import org.jackhuang.hmcl.server.status.ServerVersionInfo; +import org.jetbrains.annotations.NotNull; + +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.net.Socket; +import java.net.StandardSocketOptions; + +// https://minecraft.wiki/w/Java_Edition_protocol/Server_List_Ping#1.6 +// Minecraft Beta 1.8 to Minecraft 1.6 +public final class LegacyServerStatusPinger implements ServerStatusPinger { + static LegacyServerStatusPinger instance = new LegacyServerStatusPinger(); + + private LegacyServerStatusPinger() { + + } + + @Override + public @NotNull ServerStatusResult getStatus(@NotNull ServerAddressResolveResult.SuccessResult successResult) { + try (Socket socket = new Socket()) { + socket.setOption(StandardSocketOptions.TCP_NODELAY, true); + socket.connect(successResult.getConnectAddress(), 7_000); + socket.setSoTimeout(7_000); + + try (DataOutputStream out = new DataOutputStream(socket.getOutputStream()); + DataInputStream in = new DataInputStream(socket.getInputStream())) { + long sendTime = System.currentTimeMillis(); + + // only send these 3 bytes and all legacy servers(<=1.6) will respond correspondingly + out.writeByte(0xfe); // packet id + out.writeByte(0x01); // payload(always 1) + out.writeByte(0xfa); // plugin message + +// writeString(out, "MC|PingHost"); // magic +// +// byte[] remainBytes = toByteArray(remainOut -> { +// remainOut.writeByte(0x7f); // protocol version +// writeString(remainOut, successResult.getRawAddress().getHostAndPort().host()); // raw host +// remainOut.writeInt(successResult.getRawAddress().getHostAndPort().port()); // raw port +// }); +// +// out.writeShort(remainBytes.length); // remain data length +// out.write(remainBytes); // remain data + + int receivePacketId = in.readUnsignedByte(); + if (receivePacketId != 0xff) { + throw new IOException("Invalid packet id " + receivePacketId + ", expected 0xff(Kick Packet)."); + } + + String response = readString(in); + long networkLatency = System.currentTimeMillis() - sendTime; + return ServerStatusResult.success(successResult.getRawAddress(), this, networkLatency, parseResult(response)); + } + } catch (Exception e) { + return ServerStatusResult.failure(ServerStatusResult.FailureResult.Reason.EXCEPTION, e); + } + } + + @Override + public @NotNull String getName() { + return "Legacy Server Status Pinger"; + } + + private @NotNull ServerStatus parseResult(@NotNull String input) throws IOException { + ServerVersionInfo version; + String motd; + ServerPlayerInfo players; + try { + if (input.startsWith("§")) { + String[] strings = input.split("\0"); + + if (strings[0].equals("§1")) { + version = ServerVersionInfo.of( + parseIntOrDefault(strings[1], 0), + strings[2] + ); + motd = strings[3]; + + players = ServerPlayerInfo.of( + parseIntOrDefault(strings[5], -1), + parseIntOrDefault(strings[4], -1) + ); + } else { + throw new IOException("Invalid header: " + strings[0]); + } + } else { + String[] strings = input.split("§"); + motd = strings[0]; + + version = ServerVersionInfo.of(); + players = ServerPlayerInfo.of( + parseIntOrDefault(strings[2], -1), + parseIntOrDefault(strings[1], -1) + ); + } + + return new ServerStatus( + version, + players, + new JsonPrimitive(motd), null, false, null); + } catch (Exception e) { + throw new IOException("Failed to parse legacy server status: " + input, e); + } + } + + private int parseIntOrDefault(@NotNull String input, int defaultValue) { + try { + return Integer.parseInt(input); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + private void writeString(final DataOutputStream out, final String str) throws IOException { + out.writeShort(str.length()); + out.writeChars(str); + } + + private String readString(final DataInputStream in) throws IOException { + int length = in.readShort(); + + char[] chars = new char[length]; + for (int index = 0; index < length; index++) { + chars[index] = in.readChar(); + } + + return new String(chars); + } + + private byte[] toByteArray(@NotNull DataWriter writer) throws IOException { + try (ByteArrayOutputStream out = new ByteArrayOutputStream(); + DataOutputStream dataOut = new DataOutputStream(out)) { + writer.write(dataOut); + return out.toByteArray(); + } + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/server/pinger/ModernServerStatusPinger.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/server/pinger/ModernServerStatusPinger.java new file mode 100644 index 0000000000..63636f1dd0 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/server/pinger/ModernServerStatusPinger.java @@ -0,0 +1,277 @@ +// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors +// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL +package org.jackhuang.hmcl.server.pinger; + +import com.google.gson.*; +import org.jackhuang.hmcl.server.resolver.ServerAddressResolveResult; +import org.jackhuang.hmcl.server.status.ServerPlayerInfo; +import org.jackhuang.hmcl.server.status.ServerStatus; +import org.jackhuang.hmcl.server.status.ServerStatusResult; +import org.jackhuang.hmcl.server.status.ServerVersionInfo; +import org.jetbrains.annotations.NotNull; + +import java.io.*; +import java.net.Socket; +import java.net.StandardSocketOptions; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Objects; +import java.util.UUID; + +// https://minecraft.wiki/w/Java_Edition_protocol/Server_List_Ping +// https://minecraft.wiki/w/Minecraft_Wiki:Projects/wiki.vg_merge/Minecraft_Forge_Handshake +// Minecraft 1.7+ +public final class ModernServerStatusPinger implements ServerStatusPinger { + static ModernServerStatusPinger instance = new ModernServerStatusPinger(); + + private ModernServerStatusPinger() { + + } + + public @NotNull ServerStatusResult getStatus(@NotNull ServerAddressResolveResult.SuccessResult successResult) { + try (Socket socket = new Socket()) { + socket.setOption(StandardSocketOptions.TCP_NODELAY, true); + socket.connect(successResult.getConnectAddress(), 7_000); + socket.setSoTimeout(7_000); + + try (DataOutputStream out = new DataOutputStream(socket.getOutputStream()); + DataInputStream in = new DataInputStream(socket.getInputStream())) { + + sendHandshakeStatusPacket(out, + successResult.getPackerHandshakeAddress(), successResult.getPackerHandshakePort() + ); + sendStatusRequestPacket(out); + var status = readStatusResponsePacket(in); + + long sendTime = System.currentTimeMillis(); + sendPingRequestPacket(out); + var pong = readPongResponsePacket(in); + long networkLatency = System.currentTimeMillis() - sendTime; + + JsonObject rootStatus = JsonParser.parseString(status).getAsJsonObject(); + + ServerStatus parseServerStatus = parseServerStatus(rootStatus); + return ServerStatusResult.success(successResult.getRawAddress(), this, networkLatency, parseServerStatus); + } + } catch (Exception e) { + return ServerStatusResult.failure(ServerStatusResult.FailureResult.Reason.EXCEPTION, e); + } + } + + @Override + public @NotNull String getName() { + return "Modern Server Status Pinger"; + } + + private ServerStatus parseServerStatus(@NotNull JsonObject rootStatus) { + JsonObject versionJsonObject = rootStatus.getAsJsonObject("version"); + + ServerVersionInfo version = ServerVersionInfo.of(versionJsonObject.get("protocol").getAsInt(), versionJsonObject.get("name").getAsString()); + + JsonObject playersJsonObject = rootStatus.getAsJsonObject("players"); + + ServerPlayerInfo players; + JsonElement playersSampleJsonElement = playersJsonObject.get("sample"); + if (playersSampleJsonElement instanceof JsonArray jArray) { + ArrayList samples = new ArrayList<>(); + players = ServerPlayerInfo.of(playersJsonObject.get("max").getAsInt(), playersJsonObject.get("online").getAsInt(), samples); + + for (JsonElement element : jArray) { + JsonObject jsonObject = (JsonObject) element; + samples.add(new ServerPlayerInfo.FullServerPlayerInfo.Sample( + jsonObject.get("name").getAsString(), + UUID.fromString(jsonObject.get("id").getAsString()) + )); + } + } else { + players = ServerPlayerInfo.of(playersJsonObject.get("max").getAsInt(), playersJsonObject.get("online").getAsInt()); + } + + String favicon; + if (rootStatus.get("favicon") != null) { + String fetchedFavicon = rootStatus.get("favicon").getAsString(); + if (fetchedFavicon.startsWith("data:image/png;base64,")) { + favicon = fetchedFavicon.substring("data:image/png;base64,".length()); + } else { + favicon = null; + } + } else { + favicon = null; + } + + boolean enforcesSecureChat; + if (rootStatus.get("enforcesSecureChat") != null) { + enforcesSecureChat = rootStatus.get("enforcesSecureChat").getAsBoolean(); + } else { + enforcesSecureChat = false; + } + + ServerStatus.ServerModInfo modInfo; + if (rootStatus.get("modinfo") != null) { + JsonObject modinfoJsonObject = rootStatus.get("modinfo").getAsJsonObject(); + ArrayList modList = new ArrayList<>(); + modInfo = new ServerStatus.ServerModInfo( + modinfoJsonObject.get("type").getAsString(), + modList + ); + if (modinfoJsonObject.get("modList") instanceof JsonArray jArray) { + for (JsonElement element : jArray) { + JsonObject jsonObject = (JsonObject) element; + modList.add(new ServerStatus.ServerModInfo.Mod( + jsonObject.get("modid").getAsString(), + jsonObject.get("version").getAsString() + )); + } + } + } else { + modInfo = null; + } + + return new ServerStatus( + version, + players, + Objects.requireNonNullElse(rootStatus.get("description"), JsonNull.INSTANCE), + favicon, + enforcesSecureChat, + modInfo + ); + } + + private long readPongResponsePacket(DataInputStream in) throws IOException { + return readPacket(in, packetIn -> { + int packetId = readVarInt(packetIn); + if (packetId != 0x01) + throw new IOException("Invalid packet id " + packetId + ", expected 0x01(Pong Response)."); + return readLong(packetIn); + }); + } + + private T readPacket(DataInputStream in, DataReader reader) throws IOException { + int length = readVarInt(in); + if (length > 2097151) throw new IOException("Packet length too large: " + length); + byte[] packetData = new byte[length]; + in.readFully(packetData); + + try (ByteArrayInputStream bais = new ByteArrayInputStream(packetData); + DataInputStream packetIn = new DataInputStream(bais)) { + return reader.read(packetIn); + } + } + + private String readStatusResponsePacket(DataInputStream in) throws IOException { + return readPacket(in, packetIn -> { + int packetId = readVarInt(packetIn); + if (packetId != 0x00) + throw new IOException("Invalid packet id " + packetId + ", expected 0x00(Status Response)."); + + return readVarString(packetIn); // Status Json + }); + } + + private void sendStatusRequestPacket(DataOutputStream sendTarget) throws IOException { + sendPacket(sendTarget, out -> { + out.writeByte(0x00); // Packet ID + }); + } + + private void sendPacket(DataOutputStream target, DataWriter dataWriter) throws IOException { + try (ByteArrayOutputStream packetOut = new ByteArrayOutputStream(); + DataOutputStream dataPacketOut = new DataOutputStream(packetOut)) { + dataWriter.write(dataPacketOut); + + writeVarInt(target, packetOut.size()); // Packet Size + target.write(packetOut.toByteArray()); // Packet Contents + } + } + + private void sendHandshakeStatusPacket(DataOutputStream sendTarget, String address, int port) throws IOException { + sendPacket(sendTarget, out -> { + // Packet ID + out.write(0x00); + + // Protocol Version + writeVarInt(out, 777); + + // Server Host + writeVarString(out, address); + + // Server Port + out.writeShort(port); + + // Next State Status Request + writeVarInt(out, 1); + }); + } + + private void sendPingRequestPacket(DataOutputStream sendTarget) throws IOException { + sendPacket(sendTarget, out -> { + out.write(0x01); // packet id + long time = System.currentTimeMillis(); + + writeLong(out, time); // time + }); + } + + private void writeVarInt(OutputStream out, int value) throws IOException { + while ((value & ~0x7F) != 0) { + out.write((value & 0x7F) | 0x80); + + value >>>= 7; + } + + out.write(value); + } + + private String readVarString(DataInputStream in) throws IOException { + int length = readVarInt(in); + if (length > 32768) throw new IOException("Packet string length too large: " + length); + byte[] bytes = new byte[length]; + in.readFully(bytes); + return new String(bytes, StandardCharsets.UTF_8); + } + + private int readVarInt(InputStream in) throws IOException { + int value = 0; + int position = 0; + int currentByte; + + while (position < 32) { + currentByte = in.read(); + if (currentByte == -1) { + throw new EOFException(""); + } + + value |= (currentByte & 0x7F) << position; + + if ((currentByte & 0x80) == 0) + return value; + position += 7; + } + + throw new IOException("VarInt too big"); + } + + private void writeLong(OutputStream out, long value) throws IOException { + for (int i = 7; i >= 0; i--) { + out.write((int) ((value >>> (i * 8)) & 0xFF)); + } + } + + private void writeVarString(OutputStream out, String string) throws IOException { + byte[] bytes = string.getBytes(StandardCharsets.UTF_8); + writeVarInt(out, bytes.length); + out.write(bytes); + } + + private long readLong(InputStream in) throws IOException { + long value = 0; + for (int i = 0; i < 8; i++) { + int b = in.read(); + if (b == -1) { + throw new EOFException(""); + } + value = (value << 8) | (b & 0xFF); + } + return value; + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/server/pinger/ServerStatusPinger.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/server/pinger/ServerStatusPinger.java new file mode 100644 index 0000000000..f899dd8e7c --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/server/pinger/ServerStatusPinger.java @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors +// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL +package org.jackhuang.hmcl.server.pinger; + +import org.jackhuang.hmcl.server.resolver.ServerAddressResolveResult; +import org.jackhuang.hmcl.server.status.ServerStatusResult; +import org.jackhuang.hmcl.util.ServerAddress; +import org.jetbrains.annotations.NotNull; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +public sealed interface ServerStatusPinger permits ClassicServerStatusPinger, LegacyServerStatusPinger, ModernServerStatusPinger { + List PINGERS = List.of( + ModernServerStatusPinger.instance, + LegacyServerStatusPinger.instance, + ClassicServerStatusPinger.instance + ); + + static @NotNull ServerStatusResult getStatus(@NotNull String serverIp) throws IOException { + try { + ServerAddress rawAddress = ServerAddress.parse(serverIp); + ServerAddressResolveResult resolveResult = rawAddress.resolve(); + + if (resolveResult instanceof ServerAddressResolveResult.FailureResult failureResult) { + return ServerStatusResult.failure(failureResult.getReason().getStatusReason(), failureResult.getException()); + } + + ServerAddressResolveResult.SuccessResult successResult = (ServerAddressResolveResult.SuccessResult) resolveResult; + List failures = new ArrayList<>(); + for (ServerStatusPinger pinger : PINGERS) { + ServerStatusResult statusResult = pinger.getStatus(successResult); + if (statusResult.isSuccess()) { + return statusResult; + } else { + failures.add((ServerStatusResult.FailureResult) statusResult); + } + } + + Exception collectException = new IOException("Failed to get server status"); + for (ServerStatusResult.FailureResult failureResult : failures) { + collectException.addSuppressed(failureResult.getException()); + } + LOG.error("Failed to get the status of server " + serverIp, collectException); + return ServerStatusResult.failure(ServerStatusResult.FailureResult.Reason.EXCEPTION, collectException); + } catch (Exception e) { + LOG.error("Failed to get the status of server " + serverIp, e); + return ServerStatusResult.failure(ServerStatusResult.FailureResult.Reason.EXCEPTION, e); + } + } + + @NotNull ServerStatusResult getStatus(@NotNull ServerAddressResolveResult.SuccessResult successResult); + + @NotNull String getName(); + + @FunctionalInterface + interface DataWriter { + void write(DataOutputStream out) throws IOException; + } + + @FunctionalInterface + interface DataReader { + T read(DataInputStream in) throws IOException; + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/server/resolver/MojangBlockServerChecker.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/server/resolver/MojangBlockServerChecker.java new file mode 100644 index 0000000000..477b3aaed2 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/server/resolver/MojangBlockServerChecker.java @@ -0,0 +1,170 @@ +// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors +// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL +package org.jackhuang.hmcl.server.resolver; + +import org.jackhuang.hmcl.util.HostAndPort; +import org.jetbrains.annotations.NotNull; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.InetSocketAddress; +import java.net.URL; +import java.net.URLConnection; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.*; +import java.util.stream.Collectors; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +public final class MojangBlockServerChecker { + private static volatile Checker checker; + + private MojangBlockServerChecker() { + + } + + public static boolean isBlocking(HostAndPort hostAndPort) { + if (checker == null) { + synchronized (MojangBlockServerChecker.class) { + if (checker == null) { + checker = createChecker(); + } + return checker.isBlocking(hostAndPort); + } + } else { + return checker.isBlocking(hostAndPort); + } + } + + public static boolean isBlocking(InetSocketAddress inetSocketAddress) { + if (checker == null) { + synchronized (MojangBlockServerChecker.class) { + if (checker == null) { + checker = createChecker(); + } + return checker.isBlocking(inetSocketAddress); + } + } else { + return checker.isBlocking(inetSocketAddress); + } + } + + private static @NotNull Checker createChecker() { + try { + URLConnection urlConnection = (new URL("https://sessionserver.mojang.com/blockedservers")).openConnection(); + urlConnection.setConnectTimeout(7_000); + urlConnection.setReadTimeout(7_000); + + try (InputStream is = urlConnection.getInputStream(); + BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.ISO_8859_1)) + ) { + Set collected = reader.lines().collect(Collectors.toUnmodifiableSet()); + MessageDigest sha1MessageDigest = MessageDigest.getInstance("SHA-1"); + + return new Checker() { + + private boolean isBlockedServerHostName(String server) { + byte[] serverBytes = server.toLowerCase(Locale.ROOT).getBytes(StandardCharsets.ISO_8859_1); + byte[] hash; + synchronized (sha1MessageDigest) { + hash = sha1MessageDigest.digest(serverBytes); + } + StringBuilder hexString = new StringBuilder(); + for (byte b : hash) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return collected.contains(hexString.toString()); + } + + private boolean isBlocking(@NotNull String server) { + if (server.startsWith("_minecraft._tcp.")) { + server = server.substring("_minecraft._tcp.".length()); + } + + while (server.charAt(server.length() - 1) == '.') { + server = server.substring(0, server.length() - 1); + } + + + if (isBlockedServerHostName(server)) { + return true; + } else { + List parts = new ArrayList<>(Arrays.asList(server.split("\\."))); + boolean isIPV4 = isIPv4(parts); + if (!isIPV4 && isBlockedServerHostName("*." + server)) { + return true; + } + while (parts.size() > 1) { + parts.remove(isIPV4 ? parts.size() - 1 : 0); + String starredPart = isIPV4 ? String.join(".", parts) + ".*" : "*." + String.join(".", parts); + if (isBlockedServerHostName(starredPart)) { + return true; + } + } + + return false; + } + } + + private boolean isIPv4(List address) { + if (address.size() != 4) { + return false; + } + + for (String s : address) { + try { + int part = Integer.parseInt(s); + if (part < 0 || part > 255) { + return false; + } + } catch (NumberFormatException ignore) { + return false; + } + } + return true; + } + + @Override + public boolean isBlocking(HostAndPort hostAndPort) { + + return isBlocking(hostAndPort.host()); + } + + @Override + public boolean isBlocking(InetSocketAddress address) { + if (isBlocking(address.getAddress().getHostAddress())) { + return true; + } + return isBlocking(address.getAddress().getHostName()); + } + }; + } + + } catch (Exception e) { + LOG.error("Failed to initialize block server checker", e); + return new Checker() { + @Override + public boolean isBlocking(HostAndPort hostAndPort) { + return false; + } + + @Override + public boolean isBlocking(InetSocketAddress address) { + return false; + } + }; + } + } + + private interface Checker { + boolean isBlocking(HostAndPort hostAndPort); + + boolean isBlocking(InetSocketAddress address); + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/server/resolver/ServerAddressResolveResult.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/server/resolver/ServerAddressResolveResult.java new file mode 100644 index 0000000000..a9c36a7483 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/server/resolver/ServerAddressResolveResult.java @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors +// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL +package org.jackhuang.hmcl.server.resolver; + +import org.jackhuang.hmcl.server.status.ServerStatusResult; +import org.jackhuang.hmcl.util.ServerAddress; +import org.jetbrains.annotations.NotNull; + +import java.net.InetSocketAddress; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +public abstract sealed class ServerAddressResolveResult { + private final ServerAddress rawAddress; + + private ServerAddressResolveResult(ServerAddress rawAddress) { + this.rawAddress = rawAddress; + } + + public static @NotNull ServerAddressResolveResult.FailureResult failed(ServerAddress rawAddress, FailureResult.Reason reason, Exception exception) { + return new FailureResult(rawAddress, reason, exception); + } + + public static SuccessResult succeed(ServerAddress rawAddress, InetSocketAddress resolvedAddress) { + Map queryProperties = new LinkedHashMap<>(rawAddress.queryProperties()); + boolean isSame = rawAddress.hostAndPort().equals(resolvedAddress.getHostName(), resolvedAddress.getPort()); + + if (!isSame || queryProperties.get("_o") != null) { + queryProperties.put("_o", rawAddress.hostAndPort().host() + ":" + rawAddress.hostAndPort().port()); + } + + StringBuilder queryArgBuilder = new StringBuilder(); + boolean first = true; + + for (Map.Entry entry : queryProperties.entrySet()) { + String key = entry.getKey(); + if (!first) { + queryArgBuilder.append("&"); + } + first = false; + + queryArgBuilder.append(URLEncoder.encode(key, StandardCharsets.UTF_8)); + queryArgBuilder.append("="); + queryArgBuilder.append(URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8)); + } + + // include port + if (queryArgBuilder.isEmpty()) { + queryArgBuilder.insert(0, resolvedAddress.getHostName()); + } else { + queryArgBuilder.insert(0, resolvedAddress.getHostName() + "?"); + } + return new SuccessResult(rawAddress, resolvedAddress, queryArgBuilder.toString(), resolvedAddress.getPort()); + } + + public ServerAddress getRawAddress() { + return rawAddress; + } + + public static final class FailureResult extends ServerAddressResolveResult { + private final Reason reason; + private final Exception exception; + + public FailureResult(ServerAddress rawAddress, Reason reason, Exception exception) { + super(rawAddress); + this.reason = reason; + this.exception = exception; + } + + public Exception getException() { + return exception; + } + + public Reason getReason() { + return reason; + } + + public enum Reason { + UNKNOWN_HOST, + BLOCKED_BY_MOJANG; + + public ServerStatusResult.FailureResult.Reason getStatusReason() { + return switch (this) { + case UNKNOWN_HOST -> ServerStatusResult.FailureResult.Reason.UNKNOWN_HOST; + case BLOCKED_BY_MOJANG -> ServerStatusResult.FailureResult.Reason.BLOCKED_BY_MOJANG; + }; + } + } + } + + public static final class SuccessResult extends ServerAddressResolveResult { + private final InetSocketAddress connectAddress; + private final String packerHandshakeAddress; + private final int packerHandshakePort; + + public SuccessResult(ServerAddress rawAddress, InetSocketAddress connectAddress, String packerHandshakeAddress, int packerHandshakePort) { + super(rawAddress); + this.connectAddress = connectAddress; + this.packerHandshakeAddress = packerHandshakeAddress; + this.packerHandshakePort = packerHandshakePort; + } + + public InetSocketAddress getConnectAddress() { + return connectAddress; + } + + public String getPackerHandshakeAddress() { + return packerHandshakeAddress; + } + + public int getPackerHandshakePort() { + return packerHandshakePort; + } + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/server/resolver/ServerDnsSrvRedirector.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/server/resolver/ServerDnsSrvRedirector.java new file mode 100644 index 0000000000..3ad944ebd0 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/server/resolver/ServerDnsSrvRedirector.java @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors +// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL +package org.jackhuang.hmcl.server.resolver; + +import org.jackhuang.hmcl.util.HostAndPort; +import org.jackhuang.hmcl.util.ServerAddress; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import javax.naming.directory.Attribute; +import javax.naming.directory.Attributes; +import javax.naming.directory.DirContext; +import javax.naming.directory.InitialDirContext; +import java.util.Hashtable; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +public final class ServerDnsSrvRedirector { + private static volatile DnsSrvRedirector redirector; + + private ServerDnsSrvRedirector() { + + } + + public static @Nullable HostAndPort lookup(ServerAddress address0) { + if (redirector == null) { + synchronized (ServerDnsSrvRedirector.class) { + if (redirector == null) { + + try { + Class.forName("com.sun.jndi.dns.DnsContextFactory"); + Hashtable env = new Hashtable<>(); + env.put("java.naming.factory.initial", "com.sun.jndi.dns.DnsContextFactory"); + env.put("java.naming.provider.url", "dns:"); + env.put("com.sun.jndi.dns.timeout.retries", "1"); + DirContext context = new InitialDirContext(env); + + redirector = (address) -> { + if (address.hostAndPort().port() == 25565) { + try { + Attributes attributes = context.getAttributes("_minecraft._tcp." + address.hostAndPort().host(), new String[]{"SRV"}); + Attribute srvAttribute = attributes.get("srv"); + if (srvAttribute != null) { + String[] arguments = srvAttribute.get().toString().split(" ", 4); + + int port = 25565; + try { + port = Integer.parseInt(arguments[2]); + } catch (Exception ignored) { + } + + return new HostAndPort(arguments[3], port); + } + } catch (Throwable ignored) { + } + } + return null; + }; + } catch (Throwable e) { + LOG.error("Failed to initialize SRV redirect resolved, some servers might not work", e); + redirector = (address) -> null; + } + } + } + } + return redirector.lookup(address0); + } + + @FunctionalInterface + private interface DnsSrvRedirector { + @Nullable HostAndPort lookup(@NotNull ServerAddress address); + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/server/status/ServerPlayerInfo.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/server/status/ServerPlayerInfo.java new file mode 100644 index 0000000000..6988ba0048 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/server/status/ServerPlayerInfo.java @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors +// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL +package org.jackhuang.hmcl.server.status; + +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.UUID; + +public sealed interface ServerPlayerInfo { + + static FullServerPlayerInfo of(int max, int online, @NotNull List samples) { + return new FullServerPlayerInfo(max, online, samples); + } + + static OnlyPlayerNumberServerVersionInfo of(int max, int online) { + return new OnlyPlayerNumberServerVersionInfo(max, online); + } + + static UnknownServerPlayerInfo of() { + return UnknownServerPlayerInfo.instance; + } + + final class UnknownServerPlayerInfo implements ServerPlayerInfo { + static UnknownServerPlayerInfo instance = new UnknownServerPlayerInfo(); + + private UnknownServerPlayerInfo() { + } + } + + record OnlyPlayerNumberServerVersionInfo( + int max, + int online + ) implements ServerPlayerInfo { + + } + + record FullServerPlayerInfo( + int max, + int online, + @NotNull List<@NotNull Sample> samples + ) implements ServerPlayerInfo { + public record Sample( + @NotNull String name, + @NotNull UUID id + ) { + + public boolean isAnonymous() { + if (name.equals("Anonymous Player")) { + return id.getMostSignificantBits() == 0 && id.getLeastSignificantBits() == 0; + } + return false; + } + } + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/server/status/ServerStatus.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/server/status/ServerStatus.java new file mode 100644 index 0000000000..aa063953f1 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/server/status/ServerStatus.java @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors +// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL +package org.jackhuang.hmcl.server.status; + +import com.google.gson.JsonElement; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +public record ServerStatus( + @NotNull ServerVersionInfo version, + @NotNull ServerPlayerInfo players, + @NotNull JsonElement description, + @Nullable String favicon, + boolean enforcesSecureChat, + @Nullable ServerModInfo modInfo +) { + + public record ServerModInfo( + @NotNull String type, + @NotNull List modList + ) { + public record Mod( + @NotNull String modId, + @NotNull String version + ) { + + } + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/server/status/ServerStatusResult.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/server/status/ServerStatusResult.java new file mode 100644 index 0000000000..43ab2e82f3 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/server/status/ServerStatusResult.java @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors +// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL +package org.jackhuang.hmcl.server.status; + +import org.jackhuang.hmcl.server.pinger.ServerStatusPinger; +import org.jackhuang.hmcl.util.ServerAddress; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public abstract sealed class ServerStatusResult { + public static SuccessResult success(@NotNull ServerAddress rawServerAddress, @NotNull ServerStatusPinger pinger, long networkLatency, @NotNull ServerStatus status) { + return new SuccessResult(rawServerAddress, pinger, networkLatency, status); + } + + public static FailureResult failure(FailureResult.Reason reason, Exception exception) { + return new FailureResult(reason, exception); + } + + public @Nullable ServerStatus getIfSucceed() { + if (this instanceof SuccessResult success) { + return success.status; + } + return null; + } + + public @Nullable ServerStatusResult.FailureResult.Reason getFailureReasonIfFailed() { + if (this instanceof FailureResult failure) { + return failure.reason; + } + return null; + } + + public boolean isSuccess() { + return this instanceof SuccessResult; + } + + public boolean isFailure() { + return this instanceof FailureResult; + } + + public static final class SuccessResult extends ServerStatusResult { + private final ServerAddress rawServerAddress; + private final ServerStatusPinger pinger; + private final long networkLatency; + private final ServerStatus status; + + private SuccessResult(ServerAddress rawServerAddress, ServerStatusPinger pinger, long networkLatency, ServerStatus status) { + this.rawServerAddress = rawServerAddress; + this.pinger = pinger; + this.networkLatency = networkLatency; + this.status = status; + } + + public ServerAddress getRawServerAddress() { + return rawServerAddress; + } + + public ServerStatusPinger getPinger() { + return pinger; + } + + public ServerStatus getServerStatus() { + return status; + } + + public long getNetworkLatency() { + return networkLatency; + } + } + + public static final class FailureResult extends ServerStatusResult { + private final Reason reason; + private final Exception exception; + + private FailureResult(Reason reason, Exception exception) { + this.reason = reason; + this.exception = exception; + } + + public Reason getReason() { + return reason; + } + + public Exception getException() { + return exception; + } + + public enum Reason { + EXCEPTION, + UNKNOWN_HOST, + BLOCKED_BY_MOJANG + } + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/server/status/ServerVersionInfo.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/server/status/ServerVersionInfo.java new file mode 100644 index 0000000000..0c505b5432 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/server/status/ServerVersionInfo.java @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors +// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL +package org.jackhuang.hmcl.server.status; + +import org.jetbrains.annotations.NotNull; + +public sealed interface ServerVersionInfo { + + static FullServerVersionInfo of(int protocolVersion, @NotNull String protocolName) { + return new FullServerVersionInfo(protocolVersion, protocolName); + } + + static OnlyProtocolVersionServerVersionInfo of(int protocolVersion) { + return new OnlyProtocolVersionServerVersionInfo(protocolVersion); + } + + static UnknownServerVersionInfo of() { + return UnknownServerVersionInfo.instance; + } + + final class UnknownServerVersionInfo implements ServerVersionInfo { + static UnknownServerVersionInfo instance = new UnknownServerVersionInfo(); + + private UnknownServerVersionInfo() { + } + } + + record OnlyProtocolVersionServerVersionInfo(int protocolVersion) implements ServerVersionInfo { + } + + record FullServerVersionInfo(int protocolVersion, @NotNull String protocolName) implements ServerVersionInfo { + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/server/storage/DatFileModifiedExternalException.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/server/storage/DatFileModifiedExternalException.java new file mode 100644 index 0000000000..bab5b921d2 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/server/storage/DatFileModifiedExternalException.java @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors +// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL +package org.jackhuang.hmcl.server.storage; + +import java.io.IOException; + +public class DatFileModifiedExternalException extends IOException { + private final ServerStorage storage; + + public DatFileModifiedExternalException(ServerStorage storage, String message) { + super(message); + this.storage = storage; + } + + public ServerStorage getStorage() { + return storage; + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/server/storage/ServerStorage.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/server/storage/ServerStorage.java new file mode 100644 index 0000000000..e9b43beddc --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/server/storage/ServerStorage.java @@ -0,0 +1,229 @@ +// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors +// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL +package org.jackhuang.hmcl.server.storage; + +import org.jackhuang.hmcl.game.GameInstance; +import org.jackhuang.hmcl.server.Server; +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.UnaryOperator; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +public class ServerStorage { + private final Lock handleLock = new ReentrantLock(); + private final List holdInstances = new CopyOnWriteArrayList<>(); + private final List storageEntries = new CopyOnWriteArrayList<>(); + private final Path serverDatFilePath; + private static volatile HashCalcer hashCalcer; + private volatile boolean storageValid = true; + private byte[] currentFileHash = new byte[0]; + + public ServerStorage(Path serverDatFilePath) throws IOException { + this.serverDatFilePath = serverDatFilePath; + + handleLock.lock(); + try { + storageEntries.clear(); + if (Files.exists(serverDatFilePath)) { + byte[] bytes = Files.readAllBytes(serverDatFilePath); + this.currentFileHash = calcServerDatFileHash(bytes); + List servers = Server.loadFromServersDat(bytes); + for (Server server : servers) { + ServerStorageEntry entry = new ServerStorageEntry(server); + storageEntries.add(entry); + } + } + } finally { + handleLock.unlock(); + } + } + + private HashCalcer createMd5Calcer() { + try { + MessageDigest md5MessageDigest = MessageDigest.getInstance("MD5"); + return input -> { + byte[] hash; + synchronized (md5MessageDigest) { + hash = md5MessageDigest.digest(input); + } + return hash; + }; + } catch (NoSuchAlgorithmException e) { + LOG.error("Failed to create md5 calcer.", e); + return bytes -> new byte[0]; + } + } + + private byte[] calcServerDatFileHash(byte[] data) throws IOException { + if (hashCalcer == null) { + synchronized (ServerStorage.class) { + if (hashCalcer == null) { + hashCalcer = createMd5Calcer(); + } + } + } + return hashCalcer.calc(data); + } + + public List getHoldInstances() { + return holdInstances; + } + + public List getStorageEntries() { + return storageEntries; + } + + private void save() throws IOException { + handleLock.lock(); + try { + if (!storageValid) return; + + checkHash(); + + try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + Server.saveServersDat(storageEntries.stream().map(e -> e.server).toList(), outputStream); + byte[] bytes = outputStream.toByteArray(); + + FileUtils.saveSafely(serverDatFilePath, op -> + op.write(bytes) + ); + + currentFileHash = calcServerDatFileHash(bytes); + } + + } finally { + handleLock.unlock(); + } + } + + public void invalid() { + handleLock.lock(); + try { + storageValid = false; + } finally { + handleLock.unlock(); + } + } + + private void deleteEntry(ServerStorageEntry entry) throws IOException { + handleLock.lock(); + try { + if (!storageValid) return; + + int index = storageEntries.indexOf(entry); + if (index != -1) { + ServerStorageEntry removed = storageEntries.remove(index); + try { + save(); + } catch (IOException e) { + storageEntries.add(index, removed); + throw e; + } + } + } finally { + handleLock.unlock(); + } + } + + public boolean updateEntry(ServerStorageEntry entry, UnaryOperator<@NotNull Server> updater) throws IOException { + handleLock.lock(); + try { + if (!storageValid) return false; + + int index = storageEntries.indexOf(entry); + if (index == -1) return false; + + Server oldValue = entry.server; + Server newValue = updater.apply(oldValue); + + if (oldValue.equals(newValue)) return false; + + try { + entry.server = newValue; + save(); + } catch (IOException e) { + entry.server = oldValue; + throw e; + } + + return true; + } finally { + handleLock.unlock(); + } + } + + public @Nullable ServerStorageEntry add(Server server) throws IOException { + handleLock.lock(); + try { + if (!storageValid) return null; + ServerStorageEntry entry = new ServerStorageEntry(server); + + try { + storageEntries.add(entry); + save(); + } catch (IOException e) { + storageEntries.remove(entry); + throw e; + } + + return entry; + } finally { + handleLock.unlock(); + } + } + + public void checkHash() throws IOException { + if (Files.exists(serverDatFilePath)) { + if (!Arrays.equals(currentFileHash, calcServerDatFileHash(Files.readAllBytes(serverDatFilePath)))) { + throw new DatFileModifiedExternalException(this, serverDatFilePath.toAbsolutePath().toString()); + } + } else { + if (!Arrays.equals(currentFileHash, new byte[0])) { + throw new DatFileModifiedExternalException(this, serverDatFilePath.toAbsolutePath().toString()); + } + } + } + + public class ServerStorageEntry { + private volatile Server server; + + public ServerStorageEntry(Server server) { + this.server = server; + } + + public Server getServer() { + return server; + } + + public void delete() throws IOException { + ServerStorage.this.deleteEntry(this); + } + + public ServerStorage rootStorage() { + return ServerStorage.this; + } + + public Path getServerDatFilePath() { + return serverDatFilePath; + } + } + + @FunctionalInterface + private interface HashCalcer { + byte[] calc(byte[] input) throws IOException; + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/HostAndPort.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/HostAndPort.java new file mode 100644 index 0000000000..95f2a2b269 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/HostAndPort.java @@ -0,0 +1,98 @@ +// 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.jetbrains.annotations.NotNull; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.UnknownHostException; + +public record HostAndPort( + @NotNull String host, + int port +) { + + public HostAndPort { + if (port < 0 || port > 65535) { + throw new IllegalArgumentException("Invalid server address: " + port); + } + } + + public static @NotNull HostAndPort parseHostAndPort(@NotNull String input, int defaultPort) { + String host; + String portString; + + if (!input.startsWith("[")) { + int colonPos = input.indexOf(':'); + if (colonPos >= 0 && input.indexOf(':', colonPos + 1) == -1) { + host = input.substring(0, colonPos); + portString = input.substring(colonPos + 1); + } else { + host = input; + portString = null; + } + } else { + int colonIndex = input.indexOf(':'); + int closeBracketIndex = input.lastIndexOf(']'); + if (!(colonIndex > -1 && closeBracketIndex > colonIndex)) { + throw new IllegalArgumentException("Invalid server address: " + input); + } + + if (closeBracketIndex + 1 == input.length()) { + host = input.substring(1, closeBracketIndex); + portString = null; + } else { + host = input.substring(1, closeBracketIndex); + if (!(input.charAt(closeBracketIndex + 1) == ':')) { + throw new IllegalArgumentException("Invalid server address: " + input); + } + for (int i = closeBracketIndex + 2; i < input.length(); ++i) { + if (!Character.isDigit(input.charAt(i))) { + throw new IllegalArgumentException("Invalid server address: " + input); + } + } + portString = input.substring(closeBracketIndex + 2); + } + } + + int port; + if (portString != null && !(portString = portString.trim()).isEmpty()) { + try { + port = Integer.parseInt(portString); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid server address: " + input, e); + } + } else { + port = defaultPort; + } + if (host.trim().isEmpty()) { + throw new IllegalArgumentException("Invalid server address: " + input); + } + + return new HostAndPort(host, port); + } + + @NotNull InetSocketAddress createInetSocketAddress() throws UnknownHostException { + return new InetSocketAddress(InetAddress.getByName(host), port); + } + + public boolean equals(@NotNull String host, int port) { + return host.equals(this.host) && port == this.port; + } + + @Override + public @NotNull String toString() { + StringBuilder builder = new StringBuilder(); + + if (host.indexOf(':') >= 0) { + builder.append('[').append(host).append(']'); + } else { + builder.append(host); + } + builder.append(':').append(port); + + return builder.toString(); + } + +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/MinecraftChatComponentUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/MinecraftChatComponentUtils.java new file mode 100644 index 0000000000..e6a1d5c320 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/MinecraftChatComponentUtils.java @@ -0,0 +1,123 @@ +// 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.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import java.util.regex.Pattern; + +public final class MinecraftChatComponentUtils { + + private static final Pattern FORMATTING_CODE_PATTERN = Pattern.compile("§.?"); + + private MinecraftChatComponentUtils() { + } + + public static String toPlainStringFromChatComponent(JsonElement chat) { + if (chat == null || chat.isJsonNull()) { + return ""; + } + + StringBuilder sb = new StringBuilder(); + extractText(chat, sb); + return FORMATTING_CODE_PATTERN.matcher(sb).replaceAll(""); + } + + private static void extractText(JsonPrimitive prim, StringBuilder sb) { + if (prim == null || prim.isJsonNull()) { + return; + } + sb.append(prim.getAsString()); + } + + private static void extractText(JsonArray array, StringBuilder sb) { + if (array == null) { + return; + } + for (JsonElement child : array) { + extractText(child, sb); + } + } + + private static boolean hasJsonArray(JsonObject obj, String key) { + JsonElement element = obj.get(key); + return element instanceof JsonArray; + } + + private static boolean hasJsonObject(JsonObject obj, String key) { + JsonElement element = obj.get(key); + return element instanceof JsonObject; + } + + private static boolean hasJsonPrimitive(JsonObject obj, String key) { + JsonElement element = obj.get(key); + return element instanceof JsonPrimitive; + } + + private static void extractText(JsonElement chat, StringBuilder sb) { + if (chat == null || chat.isJsonNull()) { + return; + } + + if (chat.isJsonPrimitive()) { + extractText(chat.getAsJsonPrimitive(), sb); + return; + } + + if (chat.isJsonArray()) { + extractText(chat.getAsJsonArray(), sb); + return; + } + + if (chat.isJsonObject()) { + JsonObject obj = chat.getAsJsonObject(); + + if (hasJsonPrimitive(obj, "text")) { + extractText(obj.getAsJsonPrimitive("text"), sb); + } else if (hasJsonPrimitive(obj, "translate")) { + extractTranslate(obj, sb); + } else if (hasJsonObject(obj, "score")) { + extractScore(obj.getAsJsonObject("score"), sb); + } else if (hasJsonPrimitive(obj, "selector")) { + extractText(obj.getAsJsonPrimitive("selector"), sb); + } else if (hasJsonPrimitive(obj, "keybind")) { + extractText(obj.getAsJsonPrimitive("keybind"), sb); + } else if (hasJsonPrimitive(obj, "nbt")) { + extractText(obj.getAsJsonPrimitive("nbt"), sb); + } + + if (hasJsonArray(obj, "extra")) { + extractText(obj.getAsJsonArray("extra"), sb); + } + + if (hasJsonArray(obj, "rawtext")) { + extractText(obj.getAsJsonArray("rawtext"), sb); + } + } + } + + private static void extractTranslate(JsonObject obj, StringBuilder sb) { + if (hasJsonPrimitive(obj, "fallback")) { + extractText(obj.getAsJsonPrimitive("fallback"), sb); + } else { + extractText(obj.getAsJsonPrimitive("translate"), sb); + } + + if (hasJsonArray(obj, "with")) { + extractText(obj.getAsJsonArray("with"), sb); + } + } + + private static void extractScore(JsonObject scoreObj, StringBuilder sb) { + if (hasJsonPrimitive(scoreObj, "name")) { + extractText(scoreObj.getAsJsonPrimitive("name"), sb); + } + + if (hasJsonPrimitive(scoreObj, "objective")) { + sb.append(':').append(scoreObj.get("objective").getAsString()); + } + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/ServerAddress.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/ServerAddress.java index 23738e87c0..6f86a8ebbc 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/ServerAddress.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/ServerAddress.java @@ -2,86 +2,146 @@ // SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL package org.jackhuang.hmcl.util; +import org.jackhuang.hmcl.server.ServerBlockedByMojangException; +import org.jackhuang.hmcl.server.resolver.MojangBlockServerChecker; +import org.jackhuang.hmcl.server.resolver.ServerAddressResolveResult; +import org.jackhuang.hmcl.server.resolver.ServerDnsSrvRedirector; import org.jetbrains.annotations.NotNull; -import java.util.Objects; +import java.net.InetSocketAddress; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.net.UnknownHostException; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; /** * @author Glavo */ -public record ServerAddress(String host, int port) { +public record ServerAddress( + HostAndPort hostAndPort, + Map queryProperties +) { - private static final int UNKNOWN_PORT = -1; + private static final int DEFAULT_SERVER_PORT = 25565; + + public ServerAddress(@NotNull String host) { + this(host, DEFAULT_SERVER_PORT); + } private static IllegalArgumentException illegalAddress(String address) { return new IllegalArgumentException("Invalid server address: " + address); } + public ServerAddress(@NotNull String host, int port) { + this(new HostAndPort(host, port), new LinkedHashMap<>()); + } + /** - * @throws IllegalArgumentException if the address is not a valid server address + * @throws IllegalArgumentException if the input is not a valid server address */ - public static @NotNull ServerAddress parse(@NotNull String address) { - Objects.requireNonNull(address); - - if (!address.startsWith("[")) { - int colonPos = address.indexOf(':'); - if (colonPos >= 0) { - if (colonPos == address.length() - 1) - throw illegalAddress(address); - - String host = address.substring(0, colonPos); - int port; - try { - port = Integer.parseInt(address.substring(colonPos + 1)); - } catch (NumberFormatException e) { - throw illegalAddress(address); + public static @NotNull ServerAddress parse(@NotNull final String raw) { + String input = raw; + try { + input = input.trim(); + + Map queryProperties = new LinkedHashMap<>(); + + // parse query properties + int queryStart = input.lastIndexOf('?'); + if (queryStart != -1) { + String queryPropertyStr = input.substring(queryStart + 1); + String[] args = queryPropertyStr.split("&"); + for (String arg : args) { + int separator = arg.indexOf('='); + + String key; + String value; + if (separator >= 0) { + key = URLDecoder.decode(arg.substring(0, separator), StandardCharsets.UTF_8); + value = URLDecoder.decode(arg.substring(separator + 1), StandardCharsets.UTF_8); + } else { + key = URLDecoder.decode(arg, StandardCharsets.UTF_8); + value = ""; + } + queryProperties.put(key, value); } - if (port < 0 || port > 0xFFFF) - throw illegalAddress(address); - return new ServerAddress(host, port); - } else { - return new ServerAddress(address); + input = input.substring(0, queryStart); + } + int atEnd = input.indexOf('@'); + if (atEnd != -1) { + queryProperties.put("_id", input.substring(0, atEnd)); + input = input.substring(atEnd + 1); } - } else { - // Parse IPv6 address - int colonIndex = address.indexOf(':'); - int closeBracketIndex = address.lastIndexOf(']'); - - if (colonIndex < 0 || closeBracketIndex < colonIndex) - throw illegalAddress(address); - String host = address.substring(1, closeBracketIndex); - if (closeBracketIndex == address.length() - 1) - return new ServerAddress(host); + return new ServerAddress(HostAndPort.parseHostAndPort(input, 25565), queryProperties); + } catch (Exception e) { + throw new IllegalArgumentException("Failed to parse URL " + raw, e); + } + } - if (address.length() < closeBracketIndex + 3 || address.charAt(closeBracketIndex + 1) != ':') - throw illegalAddress(address); + public ServerAddressResolveResult resolve() { + InetSocketAddress resolvedAddress = null; + UnknownHostException originalResolveException = null; + try { + resolvedAddress = hostAndPort.createInetSocketAddress(); + } catch (UnknownHostException e) { + originalResolveException = e; + } - int port; - try { - port = Integer.parseInt(address.substring(closeBracketIndex + 2)); - } catch (NumberFormatException e) { - throw illegalAddress(address); + if (resolvedAddress != null) { + if (MojangBlockServerChecker.isBlocking(resolvedAddress)) { + return ServerAddressResolveResult.failed(this, ServerAddressResolveResult.FailureResult.Reason.BLOCKED_BY_MOJANG, new ServerBlockedByMojangException("blocked by mojang block server")); } + } - if (port < 0 || port > 0xFFFF) - throw illegalAddress(address); - - return new ServerAddress(host, port); + if (MojangBlockServerChecker.isBlocking(this.hostAndPort())) { + return ServerAddressResolveResult.failed(this, ServerAddressResolveResult.FailureResult.Reason.BLOCKED_BY_MOJANG, new ServerBlockedByMojangException("blocked by mojang block server")); } - } - public ServerAddress(@NotNull String host) { - this(host, UNKNOWN_PORT); - } + try { + HostAndPort lookupDnsResultHostAndPort = ServerDnsSrvRedirector.lookup(this); + if (lookupDnsResultHostAndPort != null) { + InetSocketAddress redirectResolvedAddress = lookupDnsResultHostAndPort.createInetSocketAddress(); + if (MojangBlockServerChecker.isBlocking(redirectResolvedAddress)) { + return ServerAddressResolveResult.failed(this, ServerAddressResolveResult.FailureResult.Reason.BLOCKED_BY_MOJANG, new ServerBlockedByMojangException("blocked by mojang block server")); + } + return ServerAddressResolveResult.succeed(this, redirectResolvedAddress); + } + } catch (UnknownHostException e) { + return ServerAddressResolveResult.failed(this, ServerAddressResolveResult.FailureResult.Reason.UNKNOWN_HOST, e); + } - public ServerAddress(@NotNull String host, int port) { - this.host = Objects.requireNonNull(host); - this.port = port; + if (resolvedAddress != null) { + return ServerAddressResolveResult.succeed(this, resolvedAddress); + } else { + return ServerAddressResolveResult.failed(this, ServerAddressResolveResult.FailureResult.Reason.UNKNOWN_HOST, originalResolveException); + } } - @Override - public @NotNull String toString() { - return String.format("ServerAddress[host='%s', port=%d]", host, port); + public String toServerIp(boolean containQueryArgs) { + StringBuilder sb = new StringBuilder(hostAndPort.toString()); + if (containQueryArgs) { + if (!queryProperties.isEmpty()) { + StringBuilder queryArgBuilder = new StringBuilder(); + boolean first = true; + + for (Map.Entry entry : queryProperties.entrySet()) { + String key = entry.getKey(); + if (!first) { + queryArgBuilder.append("&"); + } + first = false; + + queryArgBuilder.append(URLEncoder.encode(key, StandardCharsets.UTF_8)); + queryArgBuilder.append("="); + queryArgBuilder.append(URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8)); + } + sb.append("?"); + sb.append(queryArgBuilder); + } + } + return sb.toString(); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/javafx/Subscription.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/javafx/Subscription.java index 21962a5275..8cabfc23cc 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/javafx/Subscription.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/javafx/Subscription.java @@ -4,9 +4,12 @@ import javafx.beans.InvalidationListener; import javafx.beans.Observable; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; import java.util.List; import java.util.Objects; +import java.util.function.Consumer; /// Alternate for JavaFX version below 21 @FunctionalInterface @@ -23,6 +26,16 @@ static Subscription subscribe(Observable observable, Runnable invalidationSubscr return () -> observable.removeListener(listener); } + static Subscription subscribe(ObservableValue observableValue, Consumer valueSubscriber) { + Objects.requireNonNull(valueSubscriber, "valueSubscriber cannot be null"); + ChangeListener listener = (obs, old, current) -> valueSubscriber.accept(current); + + observableValue.addListener(listener); + valueSubscriber.accept(observableValue.getValue()); + + return () -> observableValue.removeListener(listener); + } + static Subscription combine(Subscription... subscriptions) { List list = List.of(subscriptions); diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/util/HostAndPortTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/HostAndPortTest.java new file mode 100644 index 0000000000..1e7655f9f6 --- /dev/null +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/HostAndPortTest.java @@ -0,0 +1,47 @@ +// 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 static org.junit.jupiter.api.Assertions.*; + +/** + * @author Glavo + */ +public final class HostAndPortTest { + + @Test + public void testParseHostAndPort() { + assertEquals(new HostAndPort("example.com", 25565), HostAndPort.parseHostAndPort("example.com", 25565)); + assertEquals(new HostAndPort("example.com", 12345), HostAndPort.parseHostAndPort("example.com:12345", 25565)); + assertEquals(new HostAndPort("example.com", 25565), HostAndPort.parseHostAndPort("example.com:", 25565)); + assertEquals(new HostAndPort("127.0.0.1", 0), HostAndPort.parseHostAndPort("127.0.0.1:0", 25565)); + + assertEquals(new HostAndPort("::1", 25565), HostAndPort.parseHostAndPort("[::1]", 25565)); + assertEquals(new HostAndPort("::1", 12345), HostAndPort.parseHostAndPort("[::1]:12345", 25565)); + assertEquals(new HostAndPort("2001:db8::1", 0), HostAndPort.parseHostAndPort("[2001:db8::1]:0", 25565)); + + assertThrows(IllegalArgumentException.class, () -> HostAndPort.parseHostAndPort("[", 25565)); + assertThrows(IllegalArgumentException.class, () -> HostAndPort.parseHostAndPort("[]:0", 25565)); + assertThrows(IllegalArgumentException.class, () -> HostAndPort.parseHostAndPort("[::1]|0", 25565)); + assertThrows(IllegalArgumentException.class, () -> HostAndPort.parseHostAndPort("[::1]:a", 25565)); + assertThrows(IllegalArgumentException.class, () -> HostAndPort.parseHostAndPort("[::1]:65536", 25565)); + assertThrows(IllegalArgumentException.class, () -> HostAndPort.parseHostAndPort("example.com:a", 25565)); + assertThrows(IllegalArgumentException.class, () -> HostAndPort.parseHostAndPort("example.com:-1", 25565)); + assertThrows(IllegalArgumentException.class, () -> HostAndPort.parseHostAndPort("example.com:65536", 25565)); + assertThrows(IllegalArgumentException.class, () -> HostAndPort.parseHostAndPort(" ", 25565)); + } + + @Test + public void testToStringAndEquals() { + HostAndPort hostAndPort = new HostAndPort("example.com", 25565); + assertEquals("example.com:25565", hostAndPort.toString()); + assertEquals(new HostAndPort("example.com", 25565), hostAndPort); + assertTrue(hostAndPort.equals("example.com", 25565)); + + HostAndPort ipv6 = new HostAndPort("::1", 12345); + assertEquals("[::1]:12345", ipv6.toString()); + assertTrue(ipv6.equals("::1", 12345)); + } +} diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/util/MinecraftChatComponentUtilsTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/MinecraftChatComponentUtilsTest.java new file mode 100644 index 0000000000..dd12f7ca04 --- /dev/null +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/MinecraftChatComponentUtilsTest.java @@ -0,0 +1,49 @@ +// 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.JsonParser; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public final class MinecraftChatComponentUtilsTest { + + @Test + public void testPrimitiveAndExtra() { + assertEquals("", MinecraftChatComponentUtils.toPlainStringFromChatComponent(null)); + + assertEquals("Hello world", + MinecraftChatComponentUtils.toPlainStringFromChatComponent(JsonParser.parseString("\"Hello world\""))); + + assertEquals("Hello world", + MinecraftChatComponentUtils.toPlainStringFromChatComponent(JsonParser.parseString("{\"text\":\"Hello \",\"extra\":[{\"text\":\"world\"}]}"))); + + assertEquals("Hello world", + MinecraftChatComponentUtils.toPlainStringFromChatComponent(JsonParser.parseString("{\"text\":\"§aHello \",\"extra\":[{\"text\":\"§lworld\"}]}"))); + } + + @Test + public void testTranslateAndScore() { + assertEquals("Server restart in 10 seconds", + MinecraftChatComponentUtils.toPlainStringFromChatComponent( + JsonParser.parseString("{\"translate\":\"multiplayer.player.joined.renamed\",\"fallback\":\"Server restart in 10 seconds\"}"))); + + assertEquals("multiplayer.player.joined.renamedAliceBob", + MinecraftChatComponentUtils.toPlainStringFromChatComponent( + JsonParser.parseString("{\"translate\":\"multiplayer.player.joined.renamed\",\"with\":[\"Alice\",\"Bob\"]}"))); + + assertEquals("Alice:test", + MinecraftChatComponentUtils.toPlainStringFromChatComponent( + JsonParser.parseString("{\"score\":{\"name\":\"Alice\",\"objective\":\"test\"}}"))); + } + + @Test + public void testRawText() { + assertEquals("FirstSecond", + MinecraftChatComponentUtils.toPlainStringFromChatComponent(JsonParser.parseString("{\"rawtext\":[{\"text\":\"First\"},{\"text\":\"Second\"}]}"))); + + assertEquals("Selected item: @p diamond", + MinecraftChatComponentUtils.toPlainStringFromChatComponent(JsonParser.parseString("{\"text\":\"Selected item: \",\"extra\":[{\"selector\":\"@p\"},{\"text\":\" diamond\"}]}"))); + } +} diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/util/ServerAddressTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/ServerAddressTest.java index ad5ace2774..df6731619c 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/util/ServerAddressTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/ServerAddressTest.java @@ -1,11 +1,12 @@ -// SPDX-FileCopyrightText: Copyright (C) 2025 The HMCL Authors +// 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.util.Map; + import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; /** * @author Glavo @@ -14,34 +15,28 @@ public final class ServerAddressTest { @Test public void testParse() { - assertEquals(new ServerAddress("example.com"), ServerAddress.parse("example.com")); - assertEquals(new ServerAddress("example.com", 25565), ServerAddress.parse("example.com:25565")); - - assertEquals(new ServerAddress("127.0.0.0"), ServerAddress.parse("127.0.0.0")); - assertEquals(new ServerAddress("127.0.0.0", 0), ServerAddress.parse("127.0.0.0:0")); - assertEquals(new ServerAddress("127.0.0.0", 12345), ServerAddress.parse("127.0.0.0:12345")); - - assertEquals(new ServerAddress("::1"), ServerAddress.parse("[::1]")); - assertEquals(new ServerAddress("::1", 0), ServerAddress.parse("[::1]:0")); - assertEquals(new ServerAddress("::1", 12345), ServerAddress.parse("[::1]:12345")); - assertEquals(new ServerAddress("2001:db8::1"), ServerAddress.parse("[2001:db8::1]")); - assertEquals(new ServerAddress("2001:db8::1", 0), ServerAddress.parse("[2001:db8::1]:0")); - assertEquals(new ServerAddress("2001:db8::1", 12345), ServerAddress.parse("[2001:db8::1]:12345")); - - assertThrows(IllegalArgumentException.class, () -> ServerAddress.parse("[")); - assertThrows(IllegalArgumentException.class, () -> ServerAddress.parse("[]]")); - assertThrows(IllegalArgumentException.class, () -> ServerAddress.parse("[]:0")); - assertThrows(IllegalArgumentException.class, () -> ServerAddress.parse("[::1]:")); - assertThrows(IllegalArgumentException.class, () -> ServerAddress.parse("[::1]|")); - assertThrows(IllegalArgumentException.class, () -> ServerAddress.parse("[::1]|0")); - assertThrows(IllegalArgumentException.class, () -> ServerAddress.parse("[::1]:a")); - assertThrows(IllegalArgumentException.class, () -> ServerAddress.parse("[::1]:65536")); - assertThrows(IllegalArgumentException.class, () -> ServerAddress.parse("[::1]:-1")); - assertThrows(IllegalArgumentException.class, () -> ServerAddress.parse("[ ]:-1")); - assertThrows(IllegalArgumentException.class, () -> ServerAddress.parse("[-]:-1")); - assertThrows(IllegalArgumentException.class, () -> ServerAddress.parse("example.com:")); - assertThrows(IllegalArgumentException.class, () -> ServerAddress.parse("example.com:a")); - assertThrows(IllegalArgumentException.class, () -> ServerAddress.parse("example.com:65536")); - assertThrows(IllegalArgumentException.class, () -> ServerAddress.parse("example.com:-1")); + ServerAddress address = ServerAddress.parse("override@example.com:12345?mode=survival&name=hello+world%21&_id=testId"); + assertEquals(new ServerAddress(new HostAndPort("example.com", 12345), Map.of( + "mode", "survival", + "name", "hello world!", + "_id", "override" + )), address); + + assertEquals(new ServerAddress(new HostAndPort("example.com", 25565), Map.of("key", "value", "empty", "")), + ServerAddress.parse("example.com?key=value&empty")); + assertEquals(new ServerAddress(new HostAndPort("example.com", 25565), Map.of("_id", "testId")), + ServerAddress.parse("testId@example.com")); + assertEquals(new ServerAddress(new HostAndPort("example.com", 25565), Map.of("k", "v", "x", "1")), + ServerAddress.parse("example.com?x=1&k=v")); + } + + @Test + public void testToServerIp() { + ServerAddress address = ServerAddress.parse("serverId@example.com:12345?mode=survival&name=hello+world%21"); + assertEquals("example.com:12345?mode=survival&name=hello+world%21&_id=serverId", address.toServerIp(true)); + assertEquals("example.com:12345", address.toServerIp(false)); + + assertEquals("[::1]:25565?key=value", ServerAddress.parse("[::1]?key=value").toServerIp(true)); + assertEquals("[::1]:25565", ServerAddress.parse("[::1]").toServerIp(false)); } }