diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/EntryPoint.java b/HMCL/src/main/java/org/jackhuang/hmcl/EntryPoint.java index 1bf9ee07ac7..1082fc7e3c0 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/EntryPoint.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/EntryPoint.java @@ -48,7 +48,7 @@ public static void main(String[] args) { System.getProperties().putIfAbsent("http.agent", "HMCL/" + Metadata.VERSION); createHMCLDirectories(); - LOG.start(Metadata.HMCL_CURRENT_DIRECTORY.resolve("logs")); + LOG.start(Metadata.HMCL_LOCAL_HOME.resolve("logs")); checkWine(); @@ -140,29 +140,29 @@ private static void setupJavaFXVMOptions() { } private static void createHMCLDirectories() { - if (!Files.isDirectory(Metadata.HMCL_CURRENT_DIRECTORY)) { + if (!Files.isDirectory(Metadata.HMCL_LOCAL_HOME)) { try { - Files.createDirectories(Metadata.HMCL_CURRENT_DIRECTORY); + Files.createDirectories(Metadata.HMCL_LOCAL_HOME); if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { try { - Files.setAttribute(Metadata.HMCL_CURRENT_DIRECTORY, "dos:hidden", true); + Files.setAttribute(Metadata.HMCL_LOCAL_HOME, "dos:hidden", true); } catch (IOException e) { - LOG.warning("Failed to set hidden attribute of " + Metadata.HMCL_CURRENT_DIRECTORY, e); + LOG.warning("Failed to set hidden attribute of " + Metadata.HMCL_LOCAL_HOME, e); } } } catch (IOException e) { // Logger has not been started yet, so print directly to System.err - System.err.println("Failed to create HMCL directory: " + Metadata.HMCL_CURRENT_DIRECTORY); + System.err.println("Failed to create HMCL directory: " + Metadata.HMCL_LOCAL_HOME); e.printStackTrace(System.err); - showErrorAndExit(i18n("fatal.create_hmcl_current_directory_failure", Metadata.HMCL_CURRENT_DIRECTORY)); + showErrorAndExit(i18n("fatal.create_hmcl_current_directory_failure", Metadata.HMCL_LOCAL_HOME)); } } - if (!Files.isDirectory(Metadata.HMCL_GLOBAL_DIRECTORY)) { + if (!Files.isDirectory(Metadata.HMCL_USER_HOME)) { try { - Files.createDirectories(Metadata.HMCL_GLOBAL_DIRECTORY); + Files.createDirectories(Metadata.HMCL_USER_HOME); } catch (IOException e) { - LOG.warning("Failed to create HMCL global directory " + Metadata.HMCL_GLOBAL_DIRECTORY, e); + LOG.warning("Failed to create HMCL user home " + Metadata.HMCL_USER_HOME, e); } } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java b/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java index a6ded4b33b5..232a76893f5 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java @@ -22,6 +22,7 @@ import javafx.animation.Timeline; import javafx.application.Application; import javafx.application.Platform; +import javafx.beans.binding.Bindings; import javafx.beans.value.ObservableBooleanValue; import javafx.geometry.Rectangle2D; import javafx.scene.control.Alert; @@ -33,16 +34,25 @@ import javafx.stage.Screen; import javafx.stage.Stage; import javafx.util.Duration; -import org.jackhuang.hmcl.setting.ConfigHolder; +import org.jackhuang.hmcl.game.HMCLCacheRepository; +import org.jackhuang.hmcl.setting.Accounts; +import org.jackhuang.hmcl.setting.AuthlibInjectorServers; +import org.jackhuang.hmcl.setting.DownloadProviders; +import org.jackhuang.hmcl.setting.LauncherSettings; +import org.jackhuang.hmcl.setting.Profiles; +import org.jackhuang.hmcl.setting.ProxyManager; +import org.jackhuang.hmcl.setting.SettingsManager; import org.jackhuang.hmcl.setting.SambaException; import org.jackhuang.hmcl.task.AsyncTaskExecutor; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.ui.animation.AnimationUtils; import org.jackhuang.hmcl.theme.Themes; import org.jackhuang.hmcl.upgrade.UpdateChecker; import org.jackhuang.hmcl.upgrade.UpdateHandler; import org.jackhuang.hmcl.util.*; +import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.io.JarUtils; import org.jackhuang.hmcl.util.platform.*; @@ -95,20 +105,21 @@ public void start(Stage primaryStage) { try { try { - ConfigHolder.init(); + SettingsManager.init(); + initializeSettingsRuntime(); } catch (SambaException e) { showAlert(AlertType.WARNING, i18n("fatal.samba")); } catch (IOException e) { LOG.error("Failed to load config", e); checkConfigInTempDir(); checkConfigOwner(); - showAlert(AlertType.ERROR, i18n("fatal.config_loading_failure", ConfigHolder.configLocation().getParent())); + showAlert(AlertType.ERROR, i18n("fatal.config_loading_failure", SettingsManager.configLocation().getParent())); EntryPoint.exit(1); } // https://lapcatsoftware.com/articles/app-translocation.html if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS - && ConfigHolder.isNewlyCreated() + && SettingsManager.isNewlyCreated() && System.getProperty("user.dir").startsWith("/private/var/folders/")) { if (!confirmWithCountdown(AlertType.WARNING, i18n("fatal.mac_app_translocation"), 5)) return; @@ -116,20 +127,20 @@ public void start(Stage primaryStage) { checkConfigInTempDir(); } - if (ConfigHolder.isOwnerChanged()) { + if (SettingsManager.isOwnerChanged()) { if (showAlert(AlertType.WARNING, i18n("fatal.config_change_owner_root"), ButtonType.YES, ButtonType.NO) == ButtonType.NO) return; } - if (ConfigHolder.isUnsupportedVersion()) { + if (SettingsManager.isUnsupportedVersion()) { showAlert(AlertType.WARNING, i18n("fatal.config_unsupported_version")); } - if (Metadata.HMCL_CURRENT_DIRECTORY.toString().indexOf('=') >= 0) { + if (Metadata.HMCL_LOCAL_HOME.toString().indexOf('=') >= 0) { showAlert(AlertType.WARNING, i18n("fatal.illegal_char")); } - // runLater to ensure ConfigHolder.init() finished initialization + // runLater to ensure SettingsManager.init() finished initialization Platform.runLater(() -> { // When launcher visibility is set to "hide and reopen" without Platform.implicitExit = false, // Stage.show() cannot work again because JavaFX Toolkit have already shut down. @@ -148,6 +159,26 @@ public void start(Stage primaryStage) { } } + /// Initializes modules and runtime services that depend on loaded settings. + private static void initializeSettingsRuntime() { + DownloadProviders.init(); + ProxyManager.init(); + Accounts.init(); + Profiles.init(); + AuthlibInjectorServers.init(); + AnimationUtils.init(); + + CacheRepository.setInstance(HMCLCacheRepository.REPOSITORY); + HMCLCacheRepository.REPOSITORY.directoryProperty().bind(Bindings.createStringBinding(() -> { + String commonDirectory = SettingsManager.settings().getResolvedCommonDirectory(); + if (commonDirectory != null && FileUtils.canCreateDirectory(commonDirectory)) { + return commonDirectory; + } else { + return LauncherSettings.getDefaultCommonDirectory(); + } + }, SettingsManager.settings().commonDirectoryProperty(), SettingsManager.settings().commonDirectoryTypeProperty())); + } + private static void appendScreen(StringBuilder builder, Screen screen) { Rectangle2D bounds = screen.getBounds(); double scale = screen.getOutputScaleX(); @@ -206,7 +237,7 @@ private static boolean confirmWithCountdown(Alert.AlertType alertType, String co } private static boolean isConfigInTempDir() { - String configPath = ConfigHolder.configLocation().toString(); + String configPath = SettingsManager.configLocation().toString(); String tmpdir = System.getProperty("java.io.tmpdir"); if (StringUtils.isNotBlank(tmpdir) && configPath.startsWith(tmpdir)) @@ -243,7 +274,7 @@ private static boolean isConfigInTempDir() { } private static void checkConfigInTempDir() { - if (ConfigHolder.isNewlyCreated() && isConfigInTempDir() + if (SettingsManager.isNewlyCreated() && isConfigInTempDir() && !confirmWithCountdown(AlertType.WARNING, i18n("fatal.config_in_temp_dir"), 5)) { EntryPoint.exit(0); } @@ -256,21 +287,21 @@ private static void checkConfigOwner() { String userName = System.getProperty("user.name"); String owner; try { - owner = Files.getOwner(ConfigHolder.configLocation()).getName(); + owner = Files.getOwner(SettingsManager.configLocation()).getName(); } catch (IOException ioe) { LOG.warning("Failed to get file owner", ioe); return; } - if (Files.isWritable(ConfigHolder.configLocation()) || userName.equals("root") || userName.equals(owner)) + if (Files.isWritable(SettingsManager.configLocation()) || userName.equals("root") || userName.equals(owner)) return; ArrayList files = new ArrayList<>(); - files.add(ConfigHolder.configLocation().toString()); - if (Files.exists(Metadata.HMCL_GLOBAL_DIRECTORY)) - files.add(Metadata.HMCL_GLOBAL_DIRECTORY.toString()); - if (Files.exists(Metadata.HMCL_CURRENT_DIRECTORY)) - files.add(Metadata.HMCL_CURRENT_DIRECTORY.toString()); + files.add(SettingsManager.configLocation().toString()); + if (Files.exists(Metadata.HMCL_USER_HOME)) + files.add(Metadata.HMCL_USER_HOME.toString()); + if (Files.exists(Metadata.HMCL_LOCAL_HOME)) + files.add(Metadata.HMCL_LOCAL_HOME.toString()); Path mcDir = Paths.get(".minecraft").toAbsolutePath().normalize(); if (Files.exists(mcDir)) @@ -323,8 +354,8 @@ public static void main(String[] args) { LOG.info("Java VM Version: " + System.getProperty("java.vm.name") + " (" + System.getProperty("java.vm.info") + "), " + System.getProperty("java.vm.vendor")); LOG.info("Java Home: " + System.getProperty("java.home")); LOG.info("Current Directory: " + Metadata.CURRENT_DIRECTORY); - LOG.info("HMCL Global Directory: " + Metadata.HMCL_GLOBAL_DIRECTORY); - LOG.info("HMCL Current Directory: " + Metadata.HMCL_CURRENT_DIRECTORY); + LOG.info("HMCL User Home: " + Metadata.HMCL_USER_HOME); + LOG.info("HMCL Local Home: " + Metadata.HMCL_LOCAL_HOME); LOG.info("HMCL Jar Path: " + Lang.requireNonNullElse(JarUtils.thisJarPath(), "Not Found")); LOG.info("HMCL Log File: " + Lang.requireNonNullElse(LOG.getLogFile(), "In Memory")); LOG.info("JVM Max Memory: " + MEGABYTES.formatBytes(Runtime.getRuntime().maxMemory())); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java b/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java index c9d9cd8e784..c2cd7afa23c 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java @@ -60,8 +60,8 @@ private Metadata() { public static final Path CURRENT_DIRECTORY = Path.of(System.getProperty("user.dir")).toAbsolutePath().normalize(); public static final Path MINECRAFT_DIRECTORY = OperatingSystem.getWorkingDirectory("minecraft"); - public static final Path HMCL_GLOBAL_DIRECTORY; - public static final Path HMCL_CURRENT_DIRECTORY; + public static final Path HMCL_USER_HOME; + public static final Path HMCL_LOCAL_HOME; public static final Path DEPENDENCIES_DIRECTORY; static { @@ -70,26 +70,26 @@ private Metadata() { if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) { String xdgData = System.getenv("XDG_DATA_HOME"); if (StringUtils.isNotBlank(xdgData)) { - HMCL_GLOBAL_DIRECTORY = Path.of(xdgData, "hmcl").toAbsolutePath().normalize(); + HMCL_USER_HOME = Path.of(xdgData, "hmcl").toAbsolutePath().normalize(); } else { - HMCL_GLOBAL_DIRECTORY = Path.of(System.getProperty("user.home"), ".local", "share", "hmcl").toAbsolutePath().normalize(); + HMCL_USER_HOME = Path.of(System.getProperty("user.home"), ".local", "share", "hmcl").toAbsolutePath().normalize(); } } else { - HMCL_GLOBAL_DIRECTORY = OperatingSystem.getWorkingDirectory("hmcl"); + HMCL_USER_HOME = OperatingSystem.getWorkingDirectory("hmcl"); } } else { - HMCL_GLOBAL_DIRECTORY = Path.of(hmclHome).toAbsolutePath().normalize(); + HMCL_USER_HOME = Path.of(hmclHome).toAbsolutePath().normalize(); } String hmclCurrentDir = System.getProperty("hmcl.dir", System.getenv("HMCL_LOCAL_HOME")); - HMCL_CURRENT_DIRECTORY = StringUtils.isNotBlank(hmclCurrentDir) + HMCL_LOCAL_HOME = StringUtils.isNotBlank(hmclCurrentDir) ? Path.of(hmclCurrentDir).toAbsolutePath().normalize() : CURRENT_DIRECTORY.resolve(".hmcl"); String hmclDependencies = System.getProperty("hmcl.dependencies.dir", System.getenv("HMCL_DEPENDENCIES_DIR")); DEPENDENCIES_DIRECTORY = StringUtils.isNotBlank(hmclDependencies) ? Path.of(hmclDependencies).toAbsolutePath().normalize() - : HMCL_CURRENT_DIRECTORY.resolve("dependencies"); + : HMCL_LOCAL_HOME.resolve("dependencies"); } public static boolean isStable() { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java index d9b1e9c54de..75fba035457 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java @@ -35,7 +35,6 @@ import java.util.*; import java.util.stream.Stream; -import static org.jackhuang.hmcl.setting.ConfigHolder.config; import static org.jackhuang.hmcl.util.logging.Logger.LOG; /** @@ -64,7 +63,7 @@ protected Map getConfigurations() { } private void generateOptionsTxt() { - if (config().isDisableAutoGameOptions()) + if (options.isDisableAutoGameOptions()) return; Path runDir = repository.getRunDirectory(version.getId()); @@ -159,7 +158,7 @@ public void makeLaunchScript(Path scriptFile) throws IOException { protected void appendJvmArgs(CommandBuilder result) { super.appendJvmArgs(result); - if (config().getAllowAutoAgent() + if (options.isAllowAutoAgent() && !options.isNoGeneratedJVMArgs() && !options.isNoGeneratedOptimizingJVMArgs() && NativePatcher.needPatchMemoryUtil(version, options.getJava().getParsedVersion())) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java index df605c10c01..2c9bf3e4bb3 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -17,9 +17,9 @@ */ package org.jackhuang.hmcl.game; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; +import com.github.f4b6a3.uuid.alt.GUID; import com.google.gson.JsonParseException; +import com.google.gson.reflect.TypeToken; import javafx.scene.image.Image; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.download.LibraryAnalyzer; @@ -30,9 +30,14 @@ import org.jackhuang.hmcl.mod.Modpack; import org.jackhuang.hmcl.mod.ModpackConfiguration; import org.jackhuang.hmcl.mod.ModpackProvider; +import org.jackhuang.hmcl.setting.LauncherSettings; +import org.jackhuang.hmcl.setting.SettingsManager; +import org.jackhuang.hmcl.setting.DefaultIsolationType; +import org.jackhuang.hmcl.setting.GameSettings; +import org.jackhuang.hmcl.setting.GameWindowType; +import org.jackhuang.hmcl.setting.LegacyGameSettingsMigrator; import org.jackhuang.hmcl.setting.Profile; import org.jackhuang.hmcl.setting.VersionIconType; -import org.jackhuang.hmcl.setting.VersionSetting; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.util.FileSaver; import org.jackhuang.hmcl.util.Lang; @@ -43,6 +48,7 @@ import org.jackhuang.hmcl.util.platform.SystemInfo; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jackhuang.hmcl.util.versioning.VersionNumber; +import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; import java.io.IOException; @@ -55,15 +61,23 @@ import java.util.stream.Collectors; import java.util.stream.Stream; -import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.setting.SettingsManager.settings; import static org.jackhuang.hmcl.util.Pair.pair; import static org.jackhuang.hmcl.util.logging.Logger.LOG; +/// HMCL game repository implementation backed by a profile and per-instance game settings. +@NotNullByDefault public final class HMCLGameRepository extends DefaultGameRepository { + /// Directory under the version root that stores HMCL instance metadata. + private static final String LOCAL_GAME_SETTINGS_DIRECTORY = ".hmcl"; + + /// Current file name for instance-specific game settings. + private static final String LOCAL_GAME_SETTINGS_FILENAME = "instance-game-settings.json"; + private final Profile profile; - // local version settings - private final Map localVersionSettings = new HashMap<>(); + // local game settings + private final Map localGameSettings = new HashMap<>(); private final Set beingModpackVersions = new HashSet<>(); public final EventManager onVersionIconChanged = new EventManager<>(); @@ -78,30 +92,40 @@ public Profile getProfile() { } @Override - public GameDirectoryType getGameDirectoryType(String id) { - if (beingModpackVersions.contains(id) || isModpack(id)) { - return GameDirectoryType.VERSION_FOLDER; - } else { - return getVersionSetting(id).getGameDirType(); + public Path getRunDirectory(String id) { + GameSettings.Instance localSetting = getLocalGameSettings(id); + boolean useInstanceRunningDirectory = beingModpackVersions.contains(id) + || isModpack(id) + || (localSetting != null && localSetting.getOverrideProperties().contains(GameSettings.PROPERTY_RUNNING_DIR)); + + String runningDirectory = getSelectedRunningDirectory(localSetting, useInstanceRunningDirectory); + if (StringUtils.isBlank(runningDirectory)) { + return useInstanceRunningDirectory ? getVersionRoot(id) : super.getRunDirectory(id); + } + + try { + return Path.of(runningDirectory); + } catch (InvalidPathException ignored) { + return getVersionRoot(id); } } - @Override - public Path getRunDirectory(String id) { - switch (getGameDirectoryType(id)) { - case VERSION_FOLDER: - return getVersionRoot(id); - case ROOT_FOLDER: - return super.getRunDirectory(id); - case CUSTOM: - try { - return Path.of(getVersionSetting(id).getGameDir()); - } catch (InvalidPathException ignored) { - return getVersionRoot(id); - } - default: - throw new AssertionError("Unreachable"); + /// Returns the running directory string selected by the current source. + private String getSelectedRunningDirectory( + @Nullable GameSettings.Instance localSetting, + boolean useInstanceRunningDirectory) { + if (useInstanceRunningDirectory) { + if (localSetting == null) { + return ""; + } + + //noinspection DataFlowIssue + return Objects.requireNonNullElse(localSetting.runningDirProperty().getValue(), ""); } + + GameSettings.Preset parent = getParentGameSettings(localSetting); + //noinspection DataFlowIssue + return Objects.requireNonNullElse(parent.runningDirProperty().getValue(), ""); } public Stream getDisplayVersions() { @@ -113,14 +137,9 @@ public Stream getDisplayVersions() { @Override protected void refreshVersionsImpl() { - localVersionSettings.clear(); + localGameSettings.clear(); super.refreshVersionsImpl(); - versions.keySet().forEach(this::loadLocalVersionSetting); - versions.keySet().forEach(version -> { - if (isModpack(version)) { - specializeVersionSetting(version); - } - }); + versions.keySet().forEach(this::loadLocalGameSettings); try { Path file = getBaseDirectory().resolve("launcher_profiles.json"); @@ -177,89 +196,162 @@ public void duplicateVersion(String srcId, String dstId, boolean copySaves) thro JsonUtils.writeToJsonFile(toJson, fromVersion.setId(dstId).setJar(dstId)); - VersionSetting oldVersionSetting = getVersionSetting(srcId).clone(); - GameDirectoryType originalGameDirType = oldVersionSetting.getGameDirType(); - oldVersionSetting.setUsesGlobal(false); - oldVersionSetting.setGameDirType(GameDirectoryType.VERSION_FOLDER); - VersionSetting newVersionSetting = initLocalVersionSetting(dstId, oldVersionSetting); - saveVersionSetting(dstId); + boolean copyOriginalGameDir; + try { + copyOriginalGameDir = !Files.isSameFile(getRunDirectory(srcId), getVersionRoot(srcId)); + } catch (IOException e) { + copyOriginalGameDir = true; + } Path srcGameDir = getRunDirectory(srcId); + + GameSettings.Instance newGameSettings = copyLocalGameSettings(srcId); + newGameSettings.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIR); + newGameSettings.runningDirProperty().setValue(""); + initLocalGameSettings(dstId, newGameSettings); + saveGameSettings(dstId); + Path dstGameDir = getRunDirectory(dstId); - if (originalGameDirType != GameDirectoryType.VERSION_FOLDER) + if (copyOriginalGameDir) FileUtils.copyDirectory(srcGameDir, dstGameDir, path -> Modpack.acceptFile(path, blackList, null)); } - private Path getLocalVersionSettingFile(String id) { - return getVersionRoot(id).resolve("hmclversion.cfg"); + private GameSettings.Instance copyLocalGameSettings(String id) { + GameSettings.Instance setting = getLocalGameSettings(id); + if (setting != null) { + return JsonUtils.clone(LauncherSettings.SETTINGS_GSON, setting, TypeToken.get(GameSettings.Instance.class)); + } + + GameSettings.Instance copied = new GameSettings.Instance(); + copied.parentProperty().setValue(getEffectiveGameSettings(id).getPreset().idProperty().getValue()); + return copied; } - private void loadLocalVersionSetting(String id) { - Path file = getLocalVersionSettingFile(id); - if (Files.exists(file)) - try { - VersionSetting versionSetting = JsonUtils.fromJsonFile(file, VersionSetting.class); - initLocalVersionSetting(id, versionSetting); - } catch (Exception ex) { - // If [JsonParseException], [IOException] or [NullPointerException] happens, the json file is malformed and needed to be recreated. - initLocalVersionSetting(id, new VersionSetting()); - } + /// Returns the current local game settings path under the version root metadata directory. + private Path getLocalGameSettingsFile(String id) { + return getVersionRoot(id).resolve(LOCAL_GAME_SETTINGS_DIRECTORY).resolve(LOCAL_GAME_SETTINGS_FILENAME); } - /** - * Create new version setting if version id has no version setting. - * - * @param id the version id. - * @return new version setting, null if given version does not exist. - */ - public VersionSetting createLocalVersionSetting(String id) { - if (!hasVersion(id)) + private void loadLocalGameSettings(String id) { + GameSettings.Instance setting = loadGameSettingsFile(getLocalGameSettingsFile(id)); + if (setting != null) { + initLocalGameSettings(id, setting); + return; + } + + GameSettings.Instance legacySetting = LegacyGameSettingsMigrator.migrateInstanceGameSettings( + getVersionRoot(id), + getBaseDirectory(), + getParentGameSettings(null).idProperty().getValue()); + if (legacySetting != null) { + initLocalGameSettings(id, legacySetting); + saveGameSettings(id); + return; + } + + GameSettings.Preset profilePreset = getProfileGameSettingsPreset(); + if (profilePreset != null && profilePreset.defaultIsolationTypeProperty().getValue() == DefaultIsolationType.ALWAYS) { + setting = new GameSettings.Instance(); + setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIR); + initLocalGameSettings(id, setting); + saveGameSettings(id); + } + } + + /// Loads a new-format instance game settings file. + private @Nullable GameSettings.Instance loadGameSettingsFile(Path file) { + if (!Files.exists(file)) { + return null; + } + + try { + return JsonUtils.fromJsonFile(LauncherSettings.SETTINGS_GSON, file, GameSettings.Instance.class); + } catch (Exception ex) { + LOG.warning("Failed to load game setting " + file, ex); return null; - if (localVersionSettings.containsKey(id)) - return getLocalVersionSetting(id); - else - return initLocalVersionSetting(id, new VersionSetting()); + } } - private VersionSetting initLocalVersionSetting(String id, VersionSetting vs) { - localVersionSettings.put(id, vs); - vs.addListener(a -> saveVersionSetting(id)); - return vs; + private GameSettings.@Nullable Preset getProfileGameSettingsPreset() { + return SettingsManager.getGameSettings(profile.getLegacyGameSettings()); } - /** - * Get the version setting for version id. - * - * @param id version id - * @return corresponding version setting, null if the version has no its own version setting. - */ - @Nullable - public VersionSetting getLocalVersionSetting(String id) { - if (!localVersionSettings.containsKey(id)) - loadLocalVersionSetting(id); - VersionSetting setting = localVersionSettings.get(id); - if (setting != null && isModpack(id)) - setting.setGameDirType(GameDirectoryType.VERSION_FOLDER); + public @Nullable GameSettings.Instance createLocalGameSettings(String id) { + if (!hasVersion(id)) { + return null; + } + if (localGameSettings.containsKey(id)) { + return getLocalGameSettings(id); + } + + GameSettings.Instance setting = new GameSettings.Instance(); + return initLocalGameSettings(id, setting); + } + + private GameSettings.Instance initLocalGameSettings(String id, GameSettings.Instance setting) { + normalizeRunningDirectoryOverride(setting); + localGameSettings.put(id, setting); + setting.addListener(a -> saveGameSettings(id)); return setting; } + /// Keeps old local custom running directories effective under the new source-selection model. + private void normalizeRunningDirectoryOverride(GameSettings.Instance setting) { + if (StringUtils.isNotBlank(setting.runningDirProperty().getValue())) { + setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIR); + } + } + @Nullable - public VersionSetting getLocalVersionSettingOrCreate(String id) { - VersionSetting vs = getLocalVersionSetting(id); - if (vs == null) { - vs = createLocalVersionSetting(id); + public GameSettings.Instance getLocalGameSettings(String id) { + if (!localGameSettings.containsKey(id)) { + loadLocalGameSettings(id); } - return vs; + return localGameSettings.get(id); + } + + @Nullable + public GameSettings.Instance getLocalGameSettingsOrCreate(String id) { + GameSettings.Instance setting = getLocalGameSettings(id); + if (setting == null) { + setting = createLocalGameSettings(id); + } + return setting; + } + + public GameSettings.Preset getParentGameSettings(@Nullable GameSettings.Instance instance) { + @Nullable GUID parent = instance != null && instance.parentProperty().getValue() != null + ? instance.parentProperty().getValue() + : profile.getLegacyGameSettings(); + GameSettings.Preset parentSetting = SettingsManager.getGameSettings(parent); + return parentSetting != null ? parentSetting : SettingsManager.getDefaultGameSettingsPresetOrCreate(); + } + + public GameSettings.Effective getEffectiveGameSettings(String id) { + GameSettings.Instance instance = getLocalGameSettings(id); + return GameSettings.resolve(getParentGameSettings(instance), instance); } - public VersionSetting getVersionSetting(String id) { - VersionSetting vs = getLocalVersionSetting(id); - if (vs == null || vs.isUsesGlobal()) { - profile.getGlobal().setUsesGlobal(true); - return profile.getGlobal(); - } else - return vs; + public void applyDefaultIsolationSetting(String id) { + if (!hasVersion(id)) { + return; + } + + GameSettings.Preset preset = getParentGameSettings(null); + DefaultIsolationType type = Lang.requireNonNullElse(preset.defaultIsolationTypeProperty().getValue(), DefaultIsolationType.MODED); + boolean isolated = switch (type) { + case NEVER -> false; + case ALWAYS -> true; + case MODED -> LibraryAnalyzer.isModded(this, getVersion(id).resolve(this)); + }; + + if (isolated) { + GameSettings.Instance setting = getLocalGameSettingsOrCreate(id); + if (setting != null) { + setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIR); + } + } } public Optional getVersionIconFile(String id) { @@ -298,12 +390,12 @@ public void deleteIconFile(String id) { } } - public Image getVersionIconImage(String id) { + public Image getVersionIconImage(@Nullable String id) { if (id == null || !isLoaded()) return VersionIconType.DEFAULT.getIcon(); - VersionSetting vs = getLocalVersionSettingOrCreate(id); - VersionIconType iconType = vs != null ? Lang.requireNonNullElse(vs.getVersionIcon(), VersionIconType.DEFAULT) : VersionIconType.DEFAULT; + GameSettings.Instance setting = getLocalGameSettings(id); + VersionIconType iconType = setting != null ? Lang.requireNonNullElse(setting.iconProperty().getValue(), VersionIconType.DEFAULT) : VersionIconType.DEFAULT; if (iconType == VersionIconType.DEFAULT) { Version version = getVersion(id).resolve(this); @@ -353,45 +445,21 @@ else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.OPTIFINE)) } } - public void saveVersionSetting(String id) { - if (!localVersionSettings.containsKey(id)) + public void saveGameSettings(String id) { + if (!localGameSettings.containsKey(id)) return; - Path file = getLocalVersionSettingFile(id).toAbsolutePath().normalize(); + Path file = getLocalGameSettingsFile(id).toAbsolutePath().normalize(); try { Files.createDirectories(file.getParent()); } catch (IOException e) { LOG.warning("Failed to create directory: " + file.getParent(), e); } - FileSaver.save(file, GSON.toJson(localVersionSettings.get(id))); - } - - /** - * Make version use self version settings instead of the global one. - * - * @param id the version id. - * @return specialized version setting, null if given version does not exist. - */ - public VersionSetting specializeVersionSetting(String id) { - VersionSetting vs = getLocalVersionSetting(id); - if (vs == null) - vs = createLocalVersionSetting(id); - if (vs == null) - return null; - if (vs.isUsesGlobal()) { - vs.setUsesGlobal(false); - } - return vs; - } - - public void globalizeVersionSetting(String id) { - VersionSetting vs = getLocalVersionSetting(id); - if (vs != null) - vs.setUsesGlobal(true); + FileSaver.save(file, LauncherSettings.SETTINGS_GSON.toJson(localGameSettings.get(id))); } public LaunchOptions.Builder getLaunchOptions(String version, JavaRuntime javaVersion, Path gameDir, List javaAgents, List javaArguments, boolean makeLaunchScript) { - VersionSetting vs = getVersionSetting(version); + GameSettings.Effective vs = getEffectiveGameSettings(version); LaunchOptions.Builder builder = new LaunchOptions.Builder() .setGameDir(gameDir) @@ -399,9 +467,9 @@ public LaunchOptions.Builder getLaunchOptions(String version, JavaRuntime javaVe .setVersionType(Metadata.TITLE) .setVersionName(version) .setProfileName(Metadata.TITLE) - .setGameArguments(StringUtils.tokenize(vs.getMinecraftArgs())) - .setOverrideJavaArguments(StringUtils.tokenize(vs.getJavaArgs())) - .setMaxMemory(vs.isNoJVMArgs() && vs.isAutoMemory() ? null : (int) (getAllocatedMemory( + .setGameArguments(StringUtils.tokenize(vs.getGameArgs())) + .setOverrideJavaArguments(StringUtils.tokenize(vs.getJVMOptions())) + .setMaxMemory(vs.isNoJVMOptions() && vs.isAutoMemory() ? null : (int) (getAllocatedMemory( vs.getMaxMemory() * 1024L * 1024L, SystemInfo.getPhysicalMemoryStatus().getAvailable(), vs.isAutoMemory() @@ -420,27 +488,30 @@ public LaunchOptions.Builder getLaunchOptions(String version, JavaRuntime javaVe ) .setWidth(vs.getWidth()) .setHeight(vs.getHeight()) - .setFullscreen(vs.isFullscreen()) - .setWrapper(vs.getWrapper()) + .setFullscreen(vs.getWindowType() == GameWindowType.FULLSCREEN) + .setWrapper(vs.getCommandWrapper()) .setProxyOption(getProxyOption()) .setPreLaunchCommand(vs.getPreLaunchCommand()) .setPostExitCommand(vs.getPostExitCommand()) - .setNoGeneratedJVMArgs(vs.isNoJVMArgs()) - .setNoGeneratedOptimizingJVMArgs(vs.isNoOptimizingJVMArgs()) + .setNoGeneratedJVMArgs(vs.isNoJVMOptions()) + .setNoGeneratedOptimizingJVMArgs(vs.isNoOptimizingJVMOptions()) .setNativesDirType(vs.getNativesDirType()) .setNativesDir(vs.getNativesDir()) .setProcessPriority(vs.getProcessPriority()) .setGraphicsBackend(vs.getGraphicsBackend()) .setRenderer(vs.getRenderer()) .setEnableDebugLogOutput(vs.isEnableDebugLogOutput()) + .setAllowAutoAgent(vs.isAllowAutoAgent()) + .setDisableAutoGameOptions(vs.isDisableAutoGameOptions()) .setUseNativeGLFW(vs.isUseNativeGLFW()) .setUseNativeOpenAL(vs.isUseNativeOpenAL()) .setDaemon(!makeLaunchScript && vs.getLauncherVisibility().isDaemon()) .setJavaAgents(javaAgents) .setJavaArguments(javaArguments); - if (StringUtils.isNotBlank(vs.getServerIp())) { - builder.setQuickPlayOption(new QuickPlayOption.MultiPlayer(vs.getServerIp())); + QuickPlayOption quickPlayOption = vs.getQuickPlayOption(); + if (quickPlayOption != null) { + builder.setQuickPlayOption(quickPlayOption); } Path json = getModpackConfiguration(version); @@ -496,10 +567,6 @@ public boolean unmarkVersionLaunchedAbnormally(String id) { } } - private static final Gson GSON = new GsonBuilder() - .setPrettyPrinting() - .create(); - private static final String PROFILE = "{\"selectedProfile\": \"(Default)\",\"profiles\": {\"(Default)\": {\"name\": \"(Default)\"}},\"clientToken\": \"88888888-8888-8888-8888-888888888888\"}"; @@ -554,22 +621,22 @@ public static long getAllocatedMemory(long minimum, long available, boolean auto } public static ProxyOption getProxyOption() { - if (!config().hasProxy() || config().getProxyType() == null) { + if (!settings().hasProxyProperty().get() || settings().proxyTypeProperty().get() == null) { return ProxyOption.Default.INSTANCE; } - return switch (config().getProxyType()) { + return switch (settings().proxyTypeProperty().get()) { case DIRECT -> ProxyOption.Direct.INSTANCE; case HTTP, SOCKS -> { - String proxyHost = config().getProxyHost(); - int proxyPort = config().getProxyPort(); + String proxyHost = settings().proxyHostProperty().get(); + int proxyPort = settings().proxyPortProperty().get(); if (StringUtils.isBlank(proxyHost) || proxyPort < 0 || proxyPort > 0xFFFF) { yield ProxyOption.Default.INSTANCE; } - String proxyUser = config().getProxyUser(); - String proxyPass = config().getProxyPass(); + String proxyUser = settings().proxyUserProperty().get(); + String proxyPass = settings().proxyPasswordProperty().get(); if (StringUtils.isBlank(proxyUser)) { proxyUser = null; @@ -578,7 +645,7 @@ public static ProxyOption getProxyOption() { proxyPass = ""; } - if (config().getProxyType() == Proxy.Type.HTTP) { + if (settings().proxyTypeProperty().get() == Proxy.Type.HTTP) { yield new ProxyOption.Http(proxyHost, proxyPort, proxyUser, proxyPass); } else { yield new ProxyOption.Socks(proxyHost, proxyPort, proxyUser, proxyPass); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java index 4800d1ad645..b49db05e39f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -65,7 +65,7 @@ import static javafx.application.Platform.runLater; import static javafx.application.Platform.setImplicitExit; -import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.setting.SettingsManager.state; import static org.jackhuang.hmcl.ui.FXUtils.runInFX; import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES; import static org.jackhuang.hmcl.util.Lang.resolveException; @@ -81,7 +81,7 @@ public final class LauncherHelper { private Account account; private final String selectedVersion; private Path scriptFile; - private final VersionSetting setting; + private final GameSettings.Effective setting; private LauncherVisibility launcherVisibility; private boolean showLogs; private QuickPlayOption quickPlayOption; @@ -91,7 +91,7 @@ public LauncherHelper(Profile profile, Account account, String selectedVersion) this.profile = Objects.requireNonNull(profile); this.account = Objects.requireNonNull(account); this.selectedVersion = Objects.requireNonNull(selectedVersion); - this.setting = profile.getVersionSetting(selectedVersion); + this.setting = profile.getRepository().getEffectiveGameSettings(selectedVersion); this.launcherVisibility = setting.getLauncherVisibility(); this.showLogs = setting.isShowLogs(); this.launchingStepsPane.setTitle(i18n("version.launch")); @@ -200,21 +200,21 @@ private void launch0() { }).withStage("launch.state.dependencies") .thenComposeAsync(() -> gameVersion.map(s -> new GameVerificationFixTask(dependencyManager, s, version.get())).orElse(null)) .thenComposeAsync(() -> { - if (config().getAllowAutoAgent() - || setting.isNoJVMArgs() - || setting.isNoOptimizingJVMArgs() - || Boolean.TRUE.equals(config().getShownTips().get(LWJGL_3_4_1_TIP)) + if (setting.isAllowAutoAgent() + || setting.isNoJVMOptions() + || setting.isNoOptimizingJVMOptions() + || Boolean.TRUE.equals(state().getShownTips().get(LWJGL_3_4_1_TIP)) || !NativePatcher.needPatchMemoryUtil(version.get(), javaVersionRef.get().getParsedVersion())) { return Task.completed(null); } else { CompletableFuture future = new CompletableFuture<>(); runInFX(() -> { Controllers.confirm(i18n("launch.advice.lwjgl_3_4_1"), i18n("launch.advice.lwjgl_3_4_1.title"), MessageType.QUESTION, () -> { - config().getShownTips().put(LWJGL_3_4_1_TIP, true); - config().setAllowAutoAgent(true); + state().getShownTips().put(LWJGL_3_4_1_TIP, true); + enableAutoAgentForCurrentSetting(); future.complete(null); }, () -> { - config().getShownTips().put(LWJGL_3_4_1_TIP, true); + state().getShownTips().put(LWJGL_3_4_1_TIP, true); future.complete(null); }); }); @@ -224,7 +224,7 @@ private void launch0() { .thenComposeAsync(() -> logIn(account).withStage("launch.state.logging_in")) .thenComposeAsync(authInfo -> Task.supplyAsync(() -> { LaunchOptions.Builder launchOptionsBuilder = repository.getLaunchOptions( - selectedVersion, javaVersionRef.get(), profile.getGameDir(), javaAgents, javaArguments, scriptFile != null); + selectedVersion, javaVersionRef.get(), profile.getPath().toPath(), javaAgents, javaArguments, scriptFile != null); if (disableOfflineSkin) { launchOptionsBuilder.setDaemon(false); } @@ -382,7 +382,7 @@ public void onStop(boolean success, TaskExecutor executor) { executor.start(); } - private static Task checkGameState(Profile profile, VersionSetting setting, Version version) { + private static Task checkGameState(Profile profile, GameSettings.Effective setting, Version version) { LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(version, profile.getRepository().getGameVersion(version).orElse(null)); GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(analyzer.getVersion(LibraryAnalyzer.LibraryType.MINECRAFT)); @@ -774,6 +774,16 @@ private void checkExit() { } } + private void enableAutoAgentForCurrentSetting() { + GameSettings.Instance instance = setting.getInstance(); + if (instance != null + && instance.getOverrideProperties().contains(GameSettings.PROPERTY_ALLOW_AUTO_AGENT)) { + instance.allowAutoAgentProperty().setValue(true); + } else { + setting.getPreset().allowAutoAgentProperty().setValue(true); + } + } + /** * The managed process listener. * Guarantee that one [JavaProcess], one [HMCLProcessListener]. diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/Log.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/Log.java index 099f1ed0744..f3a210a9fb2 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/Log.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/Log.java @@ -19,13 +19,13 @@ import org.jackhuang.hmcl.util.Log4jLevel; -import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.setting.SettingsManager.settings; public final class Log { public static final int DEFAULT_LOG_LINES = 2000; public static int getLogLines() { - Integer lines = config().getLogLines(); + Integer lines = settings().logLinesProperty().get(); return lines != null && lines > 0 ? lines : DEFAULT_LOG_LINES; } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/ModpackHelper.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/ModpackHelper.java index 109e9514209..0f7e8d59486 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/ModpackHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/ModpackHelper.java @@ -31,17 +31,21 @@ import org.jackhuang.hmcl.mod.server.ServerModpackManifest; import org.jackhuang.hmcl.mod.server.ServerModpackProvider; import org.jackhuang.hmcl.mod.server.ServerModpackRemoteInstallTask; +import org.jackhuang.hmcl.setting.GameSettings; +import org.jackhuang.hmcl.setting.GameWindowType; +import org.jackhuang.hmcl.setting.JavaVersionType; import org.jackhuang.hmcl.setting.Profile; import org.jackhuang.hmcl.setting.Profiles; -import org.jackhuang.hmcl.setting.VersionSetting; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.Lang; +import org.jackhuang.hmcl.util.PortablePath; import org.jackhuang.hmcl.util.function.ExceptionalConsumer; import org.jackhuang.hmcl.util.function.ExceptionalRunnable; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.CompressingUtils; import org.jackhuang.hmcl.util.io.FileUtils; +import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; import java.io.FileNotFoundException; @@ -61,6 +65,8 @@ import static org.jackhuang.hmcl.util.Lang.toIterable; import static org.jackhuang.hmcl.util.Pair.pair; +/// Utilities for reading, installing, and applying modpack-specific game settings. +@NotNullByDefault public final class ModpackHelper { private ModpackHelper() { } @@ -155,10 +161,11 @@ public static Task getInstallTask(Profile profile, ServerModpackManifest mani ExceptionalRunnable success = () -> { HMCLGameRepository repository = profile.getRepository(); repository.refreshVersions(); - VersionSetting vs = repository.specializeVersionSetting(name); + GameSettings.Instance setting = repository.getLocalGameSettingsOrCreate(name); repository.undoMark(name); - if (vs != null) - vs.setGameDirType(GameDirectoryType.VERSION_FOLDER); + if (setting != null) { + setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIR); + } }; ExceptionalConsumer failure = ex -> { @@ -184,8 +191,7 @@ public static Task getInstallManuallyCreatedModpackTask(Profile profile, Path return new ManuallyCreatedModpackInstallTask(profile, zipFile, charset, name) .thenAcceptAsync(Schedulers.javafx(), location -> { - Profile newProfile = new Profile(name, location); - newProfile.setUseRelativePath(true); + Profile newProfile = new Profile(Profiles.newProfileId(), name, PortablePath.fromPath(location)); Profiles.getProfiles().add(newProfile); Profiles.setSelectedProfile(newProfile); }); @@ -197,10 +203,11 @@ public static Task getInstallTask(Profile profile, Path zipFile, String name, ExceptionalRunnable success = () -> { HMCLGameRepository repository = profile.getRepository(); repository.refreshVersions(); - VersionSetting vs = repository.specializeVersionSetting(name); + GameSettings.Instance setting = repository.getLocalGameSettingsOrCreate(name); repository.undoMark(name); - if (vs != null) - vs.setGameDirType(GameDirectoryType.VERSION_FOLDER); + if (setting != null) { + setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIR); + } }; ExceptionalConsumer failure = ex -> { @@ -252,73 +259,108 @@ public static Task getUpdateTask(Profile profile, Path zipFile, Charset chars .thenComposeAsync(profile.getRepository().refreshVersionsAsync()); } - public static void toVersionSetting(MultiMCInstanceConfiguration c, VersionSetting vs) { - vs.setUsesGlobal(false); - vs.setGameDirType(GameDirectoryType.VERSION_FOLDER); + public static void toGameSettings(MultiMCInstanceConfiguration c, GameSettings.Instance setting) { + setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIR); if (c.isOverrideJavaLocation()) { - vs.setJavaDir(Lang.nonNull(c.getJavaPath(), "")); + setting.getOverrideProperties().add(GameSettings.PROPERTY_JAVA_TYPE); + setting.javaTypeProperty().setValue(JavaVersionType.CUSTOM); + setting.customJavaPathProperty().setValue(Objects.requireNonNullElse(c.getJavaPath(), "")); } if (c.isOverrideMemory()) { - vs.setPermSize(Optional.ofNullable(c.getPermGen()).map(Object::toString).orElse("")); + setting.getOverrideProperties().addAll(List.of( + GameSettings.PROPERTY_AUTO_MEMORY, + GameSettings.PROPERTY_PERM_SIZE, + GameSettings.PROPERTY_MAX_MEMORY, + GameSettings.PROPERTY_MIN_MEMORY + )); + setting.permSizeProperty().setValue(Optional.ofNullable(c.getPermGen()).map(Object::toString).orElse("")); if (c.getMaxMemory() != null) - vs.setMaxMemory(c.getMaxMemory()); - vs.setMinMemory(c.getMinMemory()); + setting.maxMemoryProperty().setValue(c.getMaxMemory()); + setting.minMemoryProperty().setValue(c.getMinMemory()); } if (c.isOverrideCommands()) { - vs.setWrapper(Lang.nonNull(c.getWrapperCommand(), "")); - vs.setPreLaunchCommand(Lang.nonNull(c.getPreLaunchCommand(), "")); + setting.getOverrideProperties().addAll(List.of( + GameSettings.PROPERTY_COMMAND_WRAPPER, + GameSettings.PROPERTY_PRE_LAUNCH_COMMAND + )); + setting.commandWrapperProperty().setValue(Objects.requireNonNullElse(c.getWrapperCommand(), "")); + setting.preLaunchCommandProperty().setValue(Objects.requireNonNullElse(c.getPreLaunchCommand(), "")); } if (c.isOverrideJavaArgs()) { - vs.setJavaArgs(Lang.nonNull(c.getJvmArgs(), "")); + setting.getOverrideProperties().add(GameSettings.PROPERTY_JVM_OPTIONS); + setting.jvmOptionsProperty().setValue(Objects.requireNonNullElse(c.getJvmArgs(), "")); } if (c.isOverrideConsole()) { - vs.setShowLogs(c.isShowConsole()); + setting.getOverrideProperties().add(GameSettings.PROPERTY_SHOW_LOGS); + setting.showLogsProperty().setValue(c.isShowConsole()); } if (c.isOverrideWindow()) { - vs.setFullscreen(c.isFullscreen()); + setting.getOverrideProperties().addAll(List.of( + GameSettings.PROPERTY_WINDOW_TYPE, + GameSettings.PROPERTY_WIDTH, + GameSettings.PROPERTY_HEIGHT + )); + setting.windowTypeProperty().setValue(c.isFullscreen() ? GameWindowType.FULLSCREEN : GameWindowType.WINDOWED); if (c.getWidth() != null) - vs.setWidth(c.getWidth()); + setting.widthProperty().setValue(c.getWidth().doubleValue()); if (c.getHeight() != null) - vs.setHeight(c.getHeight()); + setting.heightProperty().setValue(c.getHeight().doubleValue()); } } - private static void applyCommandAndJvmSettings(MultiMCInstanceConfiguration c, VersionSetting vs) { + private static void applyCommandAndJvmSettings(MultiMCInstanceConfiguration c, GameSettings.Instance setting) { if (c.isOverrideCommands()) { - vs.setWrapper(Lang.nonNull(c.getWrapperCommand(), "")); - vs.setPreLaunchCommand(Lang.nonNull(c.getPreLaunchCommand(), "")); + setting.getOverrideProperties().addAll(List.of( + GameSettings.PROPERTY_COMMAND_WRAPPER, + GameSettings.PROPERTY_PRE_LAUNCH_COMMAND + )); + setting.commandWrapperProperty().setValue(Lang.nonNull(c.getWrapperCommand(), "")); + setting.preLaunchCommandProperty().setValue(Lang.nonNull(c.getPreLaunchCommand(), "")); } if (c.isOverrideJavaArgs()) { - vs.setJavaArgs(Lang.nonNull(c.getJvmArgs(), "")); + setting.getOverrideProperties().add(GameSettings.PROPERTY_JVM_OPTIONS); + setting.jvmOptionsProperty().setValue(Lang.nonNull(c.getJvmArgs(), "")); } } private static Task createMultiMCPostUpdateTask(Profile profile, MultiMCInstanceConfiguration manifest, String version) { return Task.runAsync(Schedulers.javafx(), () -> { - VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version)); - ModpackHelper.applyCommandAndJvmSettings(manifest, vs); + GameSettings.Instance setting = Objects.requireNonNull(profile.getRepository().getLocalGameSettingsOrCreate(version)); + ModpackHelper.applyCommandAndJvmSettings(manifest, setting); }); } private static Task createMultiMCPostInstallTask(Profile profile, MultiMCInstanceConfiguration manifest, String version) { return Task.runAsync(Schedulers.javafx(), () -> { - VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version)); - ModpackHelper.toVersionSetting(manifest, vs); + GameSettings.Instance setting = Objects.requireNonNull(profile.getRepository().getLocalGameSettingsOrCreate(version)); + ModpackHelper.toGameSettings(manifest, setting); }); } private static Task createMcbbsPostInstallTask(Profile profile, McbbsModpackManifest manifest, String version) { return Task.runAsync(Schedulers.javafx(), () -> { - VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version)); - if (manifest.getLaunchInfo().getMinMemory() > vs.getMaxMemory()) - vs.setMaxMemory(manifest.getLaunchInfo().getMinMemory()); + HMCLGameRepository repository = profile.getRepository(); + GameSettings.Effective effective = repository.getEffectiveGameSettings(version); + if (manifest.getLaunchInfo().getMinMemory() > effective.getMaxMemory()) { + GameSettings.Instance setting = Objects.requireNonNull(repository.getLocalGameSettingsOrCreate(version)); + setting.getOverrideProperties().addAll(List.of( + GameSettings.PROPERTY_AUTO_MEMORY, + GameSettings.PROPERTY_MIN_MEMORY, + GameSettings.PROPERTY_MAX_MEMORY, + GameSettings.PROPERTY_PERM_SIZE + )); + setting.autoMemoryProperty().setValue(effective.isAutoMemory()); + setting.minMemoryProperty().setValue(effective.getMinMemory()); + setting.maxMemoryProperty().setValue(manifest.getLaunchInfo().getMinMemory()); + setting.permSizeProperty().setValue(effective.getPermSize()); + } }); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/TexturesLoader.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/TexturesLoader.java index ab0c15dd72a..cd7657150a9 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/TexturesLoader.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/TexturesLoader.java @@ -84,7 +84,7 @@ public Map getMetadata() { } private static final ThreadPoolExecutor POOL = threadPool("TexturesDownload", true, 2, 10, TimeUnit.SECONDS); - private static final Path TEXTURES_DIR = Metadata.HMCL_GLOBAL_DIRECTORY.resolve("skins"); + private static final Path TEXTURES_DIR = Metadata.HMCL_USER_HOME.resolve("skins"); private static Path getTexturePath(Texture texture) { String url = texture.getUrl(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/java/JavaManager.java b/HMCL/src/main/java/org/jackhuang/hmcl/java/JavaManager.java index 1f33ac073ad..8c2dff64b53 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/java/JavaManager.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/java/JavaManager.java @@ -27,7 +27,7 @@ import org.jackhuang.hmcl.game.GameJavaVersion; import org.jackhuang.hmcl.game.JavaVersionConstraint; import org.jackhuang.hmcl.game.Version; -import org.jackhuang.hmcl.setting.ConfigHolder; +import org.jackhuang.hmcl.setting.SettingsManager; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.FXUtils; @@ -74,8 +74,8 @@ private JavaManager() { "Semeru" }; - public static final HMCLJavaRepository REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_GLOBAL_DIRECTORY.resolve("java")); - public static final HMCLJavaRepository LOCAL_REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_CURRENT_DIRECTORY.resolve("java")); + public static final HMCLJavaRepository REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_USER_HOME.resolve("java")); + public static final HMCLJavaRepository LOCAL_REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_LOCAL_HOME.resolve("java")); public static String getMojangJavaPlatform(Platform platform) { if (platform.getOperatingSystem() == OperatingSystem.WINDOWS) { @@ -213,8 +213,8 @@ public static Task getAddJavaTask(Path binary) { String pathString = javaRuntime.getBinary().toString(); - ConfigHolder.globalConfig().getDisabledJava().remove(pathString); - if (ConfigHolder.globalConfig().getUserJava().add(pathString)) { + SettingsManager.userSettings().getDisabledJava().remove(pathString); + if (SettingsManager.userSettings().getUserJava().add(pathString)) { addJava(javaRuntime); } return javaRuntime; @@ -365,7 +365,7 @@ public static void initialize() { // search java private static Map searchPotentialJavaExecutables(boolean useCache) { - Searcher searcher = new Searcher(Metadata.HMCL_GLOBAL_DIRECTORY.resolve("javaCache.json")); + Searcher searcher = new Searcher(Metadata.HMCL_USER_HOME.resolve("javaCache.json")); if (useCache) searcher.loadCache(); @@ -468,7 +468,7 @@ private static Map searchPotentialJavaExecutables(boolean use searcher.searchAllJavaInDirectory(Path.of(System.getProperty("user.home"), ".jdks")); - for (String javaPath : ConfigHolder.globalConfig().getUserJava()) { + for (String javaPath : SettingsManager.userSettings().getUserJava()) { try { searcher.tryAddJavaExecutable(Path.of(javaPath)); } catch (InvalidPathException e) { @@ -479,7 +479,7 @@ private static Map searchPotentialJavaExecutables(boolean use JavaRuntime currentJava = JavaRuntime.CURRENT_JAVA; if (currentJava != null && !searcher.javaRuntimes.containsKey(currentJava.getBinary()) - && !ConfigHolder.globalConfig().getDisabledJava().contains(currentJava.getBinary().toString())) { + && !SettingsManager.userSettings().getDisabledJava().contains(currentJava.getBinary().toString())) { searcher.addResult(currentJava.getBinary(), currentJava); } @@ -685,7 +685,7 @@ void tryAddJavaExecutable(Path executable, boolean isManaged) { if (javaRuntimes.containsKey(executable) || failed.contains(executable) - || ConfigHolder.globalConfig().getDisabledJava().contains(executable.toString())) { + || SettingsManager.userSettings().getDisabledJava().contains(executable.toString())) { return; } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/AccountStorages.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/AccountStorages.java new file mode 100644 index 00000000000..02544ed2d81 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/AccountStorages.java @@ -0,0 +1,101 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.setting; + +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import org.jackhuang.hmcl.util.gson.JsonSchema; +import org.jackhuang.hmcl.util.gson.JsonSerializable; +import org.jackhuang.hmcl.util.gson.ObservableSetting; +import org.jetbrains.annotations.NotNullByDefault; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/// Stores account storage maps in a detached JSON file. +/// +/// The JSON representation is saved as `game-accounts.json` and stores account entries in the `accounts` list. +@JsonAdapter(AccountStorages.Adapter.class) +@NotNullByDefault +@JsonSerializable +final class AccountStorages extends ObservableSetting implements JsonSchemaSetting { + /// The JSON schema supported by this account storage list. + static final JsonSchema CURRENT_SCHEMA = + new JsonSchema("game-accounts", new JsonSchema.Version(1, 0, 0)); + + /// Creates an empty account storage list. + AccountStorages() { + tracker.markDirty(schema); + tracker.markDirty(accounts); + register(); + } + + /// Creates an account storage list from already serialized account entries. + /// + /// @param accounts the serialized account entries + /// @return an account storage list containing the given entries + static AccountStorages fromAccounts(List> accounts) { + AccountStorages result = new AccountStorages(); + result.getAccounts().setAll(accounts); + return result; + } + + /// The schema used by this account storage list file. + @SerializedName(JsonSchema.PROPERTY_SCHEMA) + private final ObjectProperty schema = new SimpleObjectProperty<>(CURRENT_SCHEMA); + + /// Returns the schema property. + public ObjectProperty schemaProperty() { + return schema; + } + + /// Returns the schema used by this account storage list file. + @Override + public JsonSchema getSchema() { + return schema.get(); + } + + /// Sets the schema used by this account storage list file. + @Override + public void setSchema(JsonSchema schema) { + this.schema.set(Objects.requireNonNull(schema)); + } + + /// Serialized account entries. + @SerializedName("accounts") + private final ObservableList> accounts = FXCollections.observableArrayList(); + + /// Returns serialized account entries. + public ObservableList> getAccounts() { + return accounts; + } + + /// JSON adapter for [AccountStorages]. + static final class Adapter extends ObservableSetting.Adapter { + /// Creates an empty account storage list for deserialization. + @Override + protected AccountStorages createInstance() { + return new AccountStorages(); + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java index 2aee028f1fa..d6e67ff9424 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java @@ -17,11 +17,11 @@ */ package org.jackhuang.hmcl.setting; +import com.google.gson.JsonObject; import javafx.beans.InvalidationListener; import javafx.beans.Observable; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; -import javafx.collections.FXCollections; import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; import org.jackhuang.hmcl.Metadata; @@ -35,9 +35,10 @@ import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException; import org.jackhuang.hmcl.game.OAuthServer; import org.jackhuang.hmcl.task.Schedulers; -import org.jackhuang.hmcl.util.FileSaver; +import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.JarUtils; import org.jackhuang.hmcl.util.skin.InvalidSkinException; +import org.jetbrains.annotations.Nullable; import javax.net.ssl.SSLException; import java.io.IOException; @@ -49,8 +50,10 @@ import static java.util.stream.Collectors.toList; import static javafx.collections.FXCollections.observableArrayList; -import static org.jackhuang.hmcl.setting.ConfigHolder.config; -import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig; +import static org.jackhuang.hmcl.setting.SettingsManager.settings; +import static org.jackhuang.hmcl.setting.SettingsManager.getAccountStorages; +import static org.jackhuang.hmcl.setting.SettingsManager.getAuthlibInjectorServers; +import static org.jackhuang.hmcl.setting.SettingsManager.userSettings; import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating; import static org.jackhuang.hmcl.util.Lang.immutableListOf; import static org.jackhuang.hmcl.util.Lang.mapOf; @@ -120,8 +123,21 @@ else if (account instanceof MicrosoftAccount) throw new IllegalArgumentException("Failed to determine account type: " + account); } - private static final String GLOBAL_PREFIX = "$GLOBAL:"; - private static final ObservableList> globalAccountStorages = FXCollections.observableArrayList(); + private static final String SELECTED_ACCOUNT_STORAGE = "storage"; + private static final String SELECTED_ACCOUNT_STORAGE_LOCAL = "local"; + private static final String SELECTED_ACCOUNT_STORAGE_USER = "user"; + private static final String SELECTED_ACCOUNT_TYPE = "type"; + private static final Path GLOBAL_GAME_ACCOUNTS_LOCATION = + Metadata.HMCL_USER_HOME.resolve("user-game-accounts.json"); + private static final Path LEGACY_GLOBAL_ACCOUNTS_LOCATION = + Metadata.HMCL_USER_HOME.resolve("accounts.json"); + private static final JsonSettingFile GLOBAL_GAME_ACCOUNTS_FILE = new JsonSettingFile<>( + GLOBAL_GAME_ACCOUNTS_LOCATION, + "user game accounts", + AccountStorages.class, + AccountStorages.CURRENT_SCHEMA, + AccountStorages::new); + private static @Nullable AccountStorages globalAccounts; private static final ObservableList accounts = observableArrayList(account -> new Observable[]{account}); private static final ObjectProperty selectedAccount = new SimpleObjectProperty<>(Accounts.class, "selectedAccount"); @@ -137,6 +153,31 @@ private static Map getAccountStorage(Account account) { return storage; } + /// Creates the structured selected-account reference stored in launcher settings. + private static JsonObject toSelectedAccountReference(Account account) { + JsonObject reference = new JsonObject(); + reference.addProperty(SELECTED_ACCOUNT_STORAGE, + account.isPortable() ? SELECTED_ACCOUNT_STORAGE_LOCAL : SELECTED_ACCOUNT_STORAGE_USER); + reference.addProperty(SELECTED_ACCOUNT_TYPE, getLoginType(getAccountFactory(account))); + account.toIdentifier(reference); + return reference; + } + + /// Returns whether the given account is identified by a selected-account reference. + private static boolean matchesSelectedAccountReference(Account account, JsonObject reference) { + String storage = account.isPortable() ? SELECTED_ACCOUNT_STORAGE_LOCAL : SELECTED_ACCOUNT_STORAGE_USER; + if (!storage.equals(JsonUtils.getString(reference, SELECTED_ACCOUNT_STORAGE))) { + return false; + } + + String type = getLoginType(getAccountFactory(account)); + if (!type.equals(JsonUtils.getString(reference, SELECTED_ACCOUNT_TYPE))) { + return false; + } + + return account.matchIdentifier(reference); + } + private static void updateAccountStorages() { // don't update the underlying storage before data loading is completed // otherwise it might cause data loss @@ -155,24 +196,66 @@ private static void updateAccountStorages() { global.add(storage); } - if (!global.equals(globalAccountStorages)) - globalAccountStorages.setAll(global); - if (!portable.equals(config().getAccountStorages())) - config().getAccountStorages().setAll(portable); + ObservableList> globalStorages = globalAccountStorages(); + if (!global.equals(globalStorages)) + globalStorages.setAll(global); + if (!portable.equals(getAccountStorages())) + getAccountStorages().setAll(portable); } private static void loadGlobalAccountStorages() { - Path globalAccountsFile = Metadata.HMCL_GLOBAL_DIRECTORY.resolve("accounts.json"); - if (Files.exists(globalAccountsFile)) { - try (Reader reader = Files.newBufferedReader(globalAccountsFile)) { - globalAccountStorages.setAll(Config.CONFIG_GSON.fromJson(reader, listTypeOf(mapTypeOf(Object.class, Object.class)))); - } catch (Throwable e) { - LOG.warning("Failed to load global accounts", e); + if (globalAccounts != null) { + throw new IllegalStateException("Global accounts are already loaded"); + } + + LOG.info("User game accounts location: " + GLOBAL_GAME_ACCOUNTS_LOCATION); + + boolean newlyCreated = !Files.exists(GLOBAL_GAME_ACCOUNTS_LOCATION); + @Nullable AccountStorages migrated = newlyCreated ? loadLegacyGlobalAccountStorages() : null; + try { + JsonSettingFile.LoadResult result = GLOBAL_GAME_ACCOUNTS_FILE.load(migrated); + globalAccounts = result.value(); + if (result.allowSave()) { + GLOBAL_GAME_ACCOUNTS_FILE.installAutoSave(globalAccounts); } + + if (newlyCreated && result.allowSave()) { + LOG.info("Creating user game accounts file " + GLOBAL_GAME_ACCOUNTS_LOCATION); + GLOBAL_GAME_ACCOUNTS_FILE.save(globalAccounts); + } + } catch (IOException e) { + LOG.warning("Failed to load user game accounts", e); + globalAccounts = migrated != null ? migrated : new AccountStorages(); + GLOBAL_GAME_ACCOUNTS_FILE.installAutoSave(globalAccounts); } + } - globalAccountStorages.addListener(onInvalidating(() -> - FileSaver.save(globalAccountsFile, Config.CONFIG_GSON.toJson(globalAccountStorages)))); + private static @Nullable AccountStorages loadLegacyGlobalAccountStorages() { + if (!Files.exists(LEGACY_GLOBAL_ACCOUNTS_LOCATION)) { + return null; + } + + try (Reader reader = Files.newBufferedReader(LEGACY_GLOBAL_ACCOUNTS_LOCATION)) { + List> accounts = + LauncherSettings.SETTINGS_GSON.fromJson(reader, listTypeOf(mapTypeOf(Object.class, Object.class))); + if (accounts == null) { + return null; + } + + LOG.info("Migrating user accounts from " + LEGACY_GLOBAL_ACCOUNTS_LOCATION + + " to " + GLOBAL_GAME_ACCOUNTS_LOCATION); + return AccountStorages.fromAccounts(accounts); + } catch (Throwable e) { + LOG.warning("Failed to load legacy user accounts", e); + return null; + } + } + + private static ObservableList> globalAccountStorages() { + if (globalAccounts == null) { + throw new IllegalStateException("Global accounts haven't been loaded"); + } + return globalAccounts.getAccounts(); } private static Account parseAccount(Map storage) { @@ -190,28 +273,16 @@ private static Account parseAccount(Map storage) { } } - /** - * Called when it's ready to load accounts from {@link ConfigHolder#config()}. - */ - static void init() { + /// Called when it's ready to load accounts from [SettingsManager#settings()]. + public static void init() { if (initialized) throw new IllegalStateException("Already initialized"); - if (!config().isAddedLittleSkin()) { - AuthlibInjectorServer littleSkin = new AuthlibInjectorServer("https://littleskin.cn/api/yggdrasil/"); - - if (config().getAuthlibInjectorServers().stream().noneMatch(it -> littleSkin.getUrl().equals(it.getUrl()))) { - config().getAuthlibInjectorServers().add(0, littleSkin); - } - - config().setAddedLittleSkin(true); - } - loadGlobalAccountStorages(); // load accounts Account selected = null; - for (Map storage : config().getAccountStorages()) { + for (Map storage : getAccountStorages()) { Account account = parseAccount(storage); if (account != null) { account.setPortable(true); @@ -222,29 +293,19 @@ static void init() { } } - for (Map storage : globalAccountStorages) { + for (Map storage : globalAccountStorages()) { Account account = parseAccount(storage); if (account != null) { accounts.add(account); } } - String selectedAccountIdentifier = config().getSelectedAccount(); - if (selected == null && selectedAccountIdentifier != null) { - boolean portable = true; - if (selectedAccountIdentifier.startsWith(GLOBAL_PREFIX)) { - portable = false; - selectedAccountIdentifier = selectedAccountIdentifier.substring(GLOBAL_PREFIX.length()); - } - + JsonObject selectedAccountReference = settings().selectedAccountProperty().get(); + if (selected == null && selectedAccountReference != null) { for (Account account : accounts) { - if (selectedAccountIdentifier.equals(account.getIdentifier())) { - if (portable == account.isPortable()) { - selected = account; - break; - } else if (selected == null) { - selected = account; - } + if (matchesSelectedAccountReference(account, selectedAccountReference)) { + selected = account; + break; } } } @@ -253,15 +314,16 @@ static void init() { selected = accounts.get(0); } - if (!globalConfig().isEnableOfflineAccount()) + if (!SettingsManager.userSettings().enableOfflineAccountProperty().get()) for (Account account : accounts) { if (account instanceof MicrosoftAccount) { - globalConfig().setEnableOfflineAccount(true); + UserSettings userSettings = userSettings(); + userSettings.enableOfflineAccountProperty().set(true); break; } } - if (!globalConfig().isEnableOfflineAccount()) + if (!SettingsManager.userSettings().enableOfflineAccountProperty().get()) accounts.addListener(new ListChangeListener() { @Override public void onChanged(Change change) { @@ -269,7 +331,8 @@ public void onChanged(Change change) { for (Account account : change.getAddedSubList()) { if (account instanceof MicrosoftAccount) { accounts.removeListener(this); - globalConfig().setEnableOfflineAccount(true); + UserSettings userSettings = userSettings(); + userSettings.enableOfflineAccountProperty().set(true); return; } } @@ -304,16 +367,16 @@ public void onChanged(Change change) { selectedAccount.addListener(onInvalidating(() -> { Account account = selectedAccount.get(); if (account != null) - config().setSelectedAccount(account.isPortable() ? account.getIdentifier() : GLOBAL_PREFIX + account.getIdentifier()); + settings().selectedAccountProperty().set(toSelectedAccountReference(account)); else - config().setSelectedAccount(null); + settings().selectedAccountProperty().set(null); })); accounts.addListener(listener); accounts.addListener(onInvalidating(Accounts::updateAccountStorages)); initialized = true; - config().getAuthlibInjectorServers().addListener(onInvalidating(Accounts::removeDanglingAuthlibInjectorAccounts)); + getAuthlibInjectorServers().addListener(onInvalidating(Accounts::removeDanglingAuthlibInjectorAccounts)); if (selected != null) { Account finalSelected = selected; @@ -326,7 +389,7 @@ public void onChanged(Change change) { }); } - for (AuthlibInjectorServer server : config().getAuthlibInjectorServers()) { + for (AuthlibInjectorServer server : getAuthlibInjectorServers()) { if (selected instanceof AuthlibInjectorAccount && ((AuthlibInjectorAccount) selected).getServer() == server) continue; Schedulers.io().execute(() -> { @@ -373,12 +436,12 @@ private static AuthlibInjectorArtifactProvider createAuthlibInjectorArtifactProv } private static AuthlibInjectorServer getOrCreateAuthlibInjectorServer(String url) { - return config().getAuthlibInjectorServers().stream() + return getAuthlibInjectorServers().stream() .filter(server -> url.equals(server.getUrl())) .findFirst() .orElseGet(() -> { AuthlibInjectorServer server = new AuthlibInjectorServer(url); - config().getAuthlibInjectorServers().add(server); + getAuthlibInjectorServers().add(server); return server; }); } @@ -391,7 +454,7 @@ private static void removeDanglingAuthlibInjectorAccounts() { accounts.stream() .filter(AuthlibInjectorAccount.class::isInstance) .map(AuthlibInjectorAccount.class::cast) - .filter(it -> !config().getAuthlibInjectorServers().contains(it.getServer())) + .filter(it -> !getAuthlibInjectorServers().contains(it.getServer())) .collect(toList()) .forEach(accounts::remove); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/AuthlibInjectorServerList.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/AuthlibInjectorServerList.java new file mode 100644 index 00000000000..227ae00d37f --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/AuthlibInjectorServerList.java @@ -0,0 +1,110 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.setting; + +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +import javafx.beans.Observable; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; +import org.jackhuang.hmcl.util.gson.JsonSchema; +import org.jackhuang.hmcl.util.gson.JsonSerializable; +import org.jackhuang.hmcl.util.gson.ObservableSetting; +import org.jetbrains.annotations.NotNullByDefault; + +import java.util.Objects; + +/// Stores per-workspace authlib-injector authentication servers. +/// +/// The JSON representation is saved as `authlib-injector-servers.json` under the current HMCL directory. +/// +/// @author Glavo +@JsonAdapter(AuthlibInjectorServerList.Adapter.class) +@NotNullByDefault +@JsonSerializable +public final class AuthlibInjectorServerList extends ObservableSetting implements JsonSchemaSetting { + /// The LittleSkin Yggdrasil API endpoint bundled into newly created server lists. + public static final String LITTLE_SKIN_URL = "https://littleskin.cn/api/yggdrasil/"; + + /// The JSON schema supported by this authlib-injector server list. + public static final JsonSchema CURRENT_SCHEMA = + new JsonSchema("authlib-injector-servers", new JsonSchema.Version(1, 0, 0)); + + /// Creates an empty authlib-injector server list. + public AuthlibInjectorServerList() { + tracker.markDirty(schema); + register(); + } + + /// Creates the default authlib-injector server list for a newly created file. + public static AuthlibInjectorServerList createDefault() { + AuthlibInjectorServerList result = new AuthlibInjectorServerList(); + result.addLittleSkinIfAbsent(); + return result; + } + + /// Adds the bundled LittleSkin server when it is not already present. + public void addLittleSkinIfAbsent() { + if (getServers().stream().noneMatch(server -> LITTLE_SKIN_URL.equals(server.getUrl()))) { + getServers().add(new AuthlibInjectorServer(LITTLE_SKIN_URL)); + } + } + + /// The schema used by this authlib-injector server list file. + @SerializedName(JsonSchema.PROPERTY_SCHEMA) + private final ObjectProperty schema = new SimpleObjectProperty<>(CURRENT_SCHEMA); + + /// Returns the schema property. + public ObjectProperty schemaProperty() { + return schema; + } + + /// Returns the schema used by this authlib-injector server list file. + @Override + public JsonSchema getSchema() { + return schema.get(); + } + + /// Sets the schema used by this authlib-injector server list file. + @Override + public void setSchema(JsonSchema schema) { + this.schema.set(Objects.requireNonNull(schema)); + } + + /// Authlib-injector authentication servers available for account login. + @SerializedName("servers") + private final ObservableList servers = + FXCollections.observableArrayList(server -> new Observable[]{server}); + + /// Returns authlib-injector authentication servers available for account login. + public ObservableList getServers() { + return servers; + } + + /// JSON adapter for [AuthlibInjectorServerList]. + public static final class Adapter extends ObservableSetting.Adapter { + /// Creates an empty authlib-injector server list for deserialization. + @Override + protected AuthlibInjectorServerList createInstance() { + return new AuthlibInjectorServerList(); + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/AuthlibInjectorServers.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/AuthlibInjectorServers.java index e5e7f6edce0..c8cad063f7f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/AuthlibInjectorServers.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/AuthlibInjectorServers.java @@ -35,7 +35,8 @@ import java.util.Set; import java.util.concurrent.CopyOnWriteArraySet; -import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.setting.SettingsManager.settings; +import static org.jackhuang.hmcl.setting.SettingsManager.getAuthlibInjectorServers; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @JsonSerializable @@ -71,7 +72,7 @@ public static void init() { configLocation = Paths.get(CONFIG_FILENAME); } - if (ConfigHolder.isNewlyCreated() && Files.exists(configLocation)) { + if (SettingsManager.isNewlyCreated() && Files.exists(configLocation)) { AuthlibInjectorServers configInstance; try { configInstance = JsonUtils.fromJsonFile(configLocation, AuthlibInjectorServers.class); @@ -81,11 +82,11 @@ public static void init() { } if (!configInstance.urls.isEmpty()) { - config().setPreferredLoginType(Accounts.getLoginType(Accounts.FACTORY_AUTHLIB_INJECTOR)); + settings().preferredLoginTypeProperty().set(Accounts.getLoginType(Accounts.FACTORY_AUTHLIB_INJECTOR)); for (String url : configInstance.urls) { Task.supplyAsync(Schedulers.io(), () -> AuthlibInjectorServer.locateServer(url)) .thenAcceptAsync(Schedulers.javafx(), server -> { - config().getAuthlibInjectorServers().add(server); + getAuthlibInjectorServers().add(server); servers.add(server); }) .start(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java deleted file mode 100644 index 0a845929b50..00000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java +++ /dev/null @@ -1,816 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2021 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.setting; - -import com.google.gson.*; -import com.google.gson.annotations.JsonAdapter; -import com.google.gson.annotations.SerializedName; -import javafx.beans.Observable; -import javafx.beans.property.*; -import javafx.collections.FXCollections; -import javafx.collections.ObservableList; -import javafx.collections.ObservableMap; -import javafx.collections.ObservableSet; -import javafx.scene.paint.Paint; -import org.hildan.fxgson.creators.ObservableListCreator; -import org.hildan.fxgson.creators.ObservableMapCreator; -import org.hildan.fxgson.creators.ObservableSetCreator; -import org.hildan.fxgson.factories.JavaFxPropertyTypeAdapterFactory; -import org.jackhuang.hmcl.Metadata; -import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; -import org.jackhuang.hmcl.java.JavaRuntime; -import org.jackhuang.hmcl.theme.ThemeColor; -import org.jackhuang.hmcl.ui.FXUtils; -import org.jackhuang.hmcl.util.gson.*; -import org.jackhuang.hmcl.util.i18n.SupportedLocale; -import org.jetbrains.annotations.Nullable; - -import java.net.Proxy; -import java.nio.file.Path; -import java.util.*; - -@JsonAdapter(value = Config.Adapter.class) -public final class Config extends ObservableSetting { - - public static final int CURRENT_VERSION = 2; - public static final int CURRENT_UI_VERSION = 0; - - public static final Gson CONFIG_GSON = new GsonBuilder() - .registerTypeAdapter(Path.class, PathTypeAdapter.INSTANCE) - .registerTypeAdapter(ObservableList.class, new ObservableListCreator()) - .registerTypeAdapter(ObservableSet.class, new ObservableSetCreator()) - .registerTypeAdapter(ObservableMap.class, new ObservableMapCreator()) - .registerTypeAdapterFactory(new JavaFxPropertyTypeAdapterFactory(true, true)) - .registerTypeAdapter(EnumBackgroundImage.class, new EnumOrdinalDeserializer<>(EnumBackgroundImage.class)) // backward compatibility for backgroundType - .registerTypeAdapter(Proxy.Type.class, new EnumOrdinalDeserializer<>(Proxy.Type.class)) // backward compatibility for hasProxy - .registerTypeAdapter(Paint.class, new PaintAdapter()) - .setPrettyPrinting() - .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) - .create(); - - @Nullable - public static Config fromJson(String json) throws JsonParseException { - return CONFIG_GSON.fromJson(json, Config.class); - } - - public Config() { - tracker.markDirty(configVersion); - tracker.markDirty(uiVersion); - register(); - } - - public String toJson() { - return CONFIG_GSON.toJson(this); - } - - // Properties - - @SerializedName("_version") - private final IntegerProperty configVersion = new SimpleIntegerProperty(CURRENT_VERSION); - - public IntegerProperty configVersionProperty() { - return configVersion; - } - - public int getConfigVersion() { - return configVersion.get(); - } - - public void setConfigVersion(int configVersion) { - this.configVersion.set(configVersion); - } - - /** - * The version of UI that the user have last used. - * If there is a major change in UI, {@link Config#CURRENT_UI_VERSION} should be increased. - * When {@link #CURRENT_UI_VERSION} is higher than the property, the user guide should be shown, - * then this property is set to the same value as {@link #CURRENT_UI_VERSION}. - * In particular, the property is default to 0, so that whoever open the application for the first time will see the guide. - */ - @SerializedName("uiVersion") - private final IntegerProperty uiVersion = new SimpleIntegerProperty(CURRENT_UI_VERSION); - - public IntegerProperty uiVersionProperty() { - return uiVersion; - } - - public int getUiVersion() { - return uiVersion.get(); - } - - public void setUiVersion(int uiVersion) { - this.uiVersion.set(uiVersion); - } - - @SerializedName("x") - private final DoubleProperty x = new SimpleDoubleProperty(); - - public DoubleProperty xProperty() { - return x; - } - - public double getX() { - return x.get(); - } - - public void setX(double x) { - this.x.set(x); - } - - @SerializedName("y") - private final DoubleProperty y = new SimpleDoubleProperty(); - - public DoubleProperty yProperty() { - return y; - } - - public double getY() { - return y.get(); - } - - public void setY(double y) { - this.y.set(y); - } - - @SerializedName("width") - private final DoubleProperty width = new SimpleDoubleProperty(); - - public DoubleProperty widthProperty() { - return width; - } - - public double getWidth() { - return width.get(); - } - - public void setWidth(double width) { - this.width.set(width); - } - - @SerializedName("height") - private final DoubleProperty height = new SimpleDoubleProperty(); - - public DoubleProperty heightProperty() { - return height; - } - - public double getHeight() { - return height.get(); - } - - public void setHeight(double height) { - this.height.set(height); - } - - @SerializedName("localization") - private final ObjectProperty localization = new SimpleObjectProperty<>(SupportedLocale.DEFAULT); - - public ObjectProperty localizationProperty() { - return localization; - } - - public SupportedLocale getLocalization() { - return localization.get(); - } - - public void setLocalization(SupportedLocale localization) { - this.localization.set(localization); - } - - @SerializedName("promptedVersion") - private final StringProperty promptedVersion = new SimpleStringProperty(); - - public String getPromptedVersion() { - return promptedVersion.get(); - } - - public StringProperty promptedVersionProperty() { - return promptedVersion; - } - - public void setPromptedVersion(String promptedVersion) { - this.promptedVersion.set(promptedVersion); - } - - @SerializedName("acceptPreviewUpdate") - private final BooleanProperty acceptPreviewUpdate = new SimpleBooleanProperty(false); - - public BooleanProperty acceptPreviewUpdateProperty() { - return acceptPreviewUpdate; - } - - public boolean isAcceptPreviewUpdate() { - return acceptPreviewUpdate.get(); - } - - public void setAcceptPreviewUpdate(boolean acceptPreviewUpdate) { - this.acceptPreviewUpdate.set(acceptPreviewUpdate); - } - - @SerializedName("disableAutoShowUpdateDialog") - private final BooleanProperty disableAutoShowUpdateDialog = new SimpleBooleanProperty(false); - - public BooleanProperty disableAutoShowUpdateDialogProperty() { - return disableAutoShowUpdateDialog; - } - - public boolean isDisableAutoShowUpdateDialog() { - return disableAutoShowUpdateDialog.get(); - } - - public void setDisableAutoShowUpdateDialog(boolean disableAutoShowUpdateDialog) { - this.disableAutoShowUpdateDialog.set(disableAutoShowUpdateDialog); - } - - @SerializedName("disableAprilFools") - private final BooleanProperty disableAprilFools = new SimpleBooleanProperty(false); - - public BooleanProperty disableAprilFoolsProperty() { - return disableAprilFools; - } - - public boolean isDisableAprilFools() { - return disableAprilFools.get(); - } - - public void setDisableAprilFools(boolean disableAprilFools) { - this.disableAprilFools.set(disableAprilFools); - } - - @SerializedName("shownTips") - private final ObservableMap shownTips = FXCollections.observableHashMap(); - - public ObservableMap getShownTips() { - return shownTips; - } - - @SerializedName("commonDirType") - private final ObjectProperty commonDirType = new RawPreservingObjectProperty<>(EnumCommonDirectory.DEFAULT); - - public ObjectProperty commonDirTypeProperty() { - return commonDirType; - } - - public EnumCommonDirectory getCommonDirType() { - return commonDirType.get(); - } - - public void setCommonDirType(EnumCommonDirectory commonDirType) { - this.commonDirType.set(commonDirType); - } - - @SerializedName("commonpath") - private final StringProperty commonDirectory = new SimpleStringProperty(Metadata.MINECRAFT_DIRECTORY.toString()); - - public StringProperty commonDirectoryProperty() { - return commonDirectory; - } - - public String getCommonDirectory() { - return commonDirectory.get(); - } - - public void setCommonDirectory(String commonDirectory) { - this.commonDirectory.set(commonDirectory); - } - - @SerializedName("logLines") - private final ObjectProperty logLines = new SimpleObjectProperty<>(); - - public ObjectProperty logLinesProperty() { - return logLines; - } - - public Integer getLogLines() { - return logLines.get(); - } - - public void setLogLines(Integer logLines) { - this.logLines.set(logLines); - } - - // UI - - @SerializedName("themeBrightness") - private final StringProperty themeBrightness = new SimpleStringProperty("light"); - - public StringProperty themeBrightnessProperty() { - return themeBrightness; - } - - public String getThemeBrightness() { - return themeBrightness.get(); - } - - public void setThemeBrightness(String themeBrightness) { - this.themeBrightness.set(themeBrightness); - } - - @SerializedName("theme") - private final ObjectProperty themeColor = new SimpleObjectProperty<>(ThemeColor.DEFAULT); - - public ObjectProperty themeColorProperty() { - return themeColor; - } - - public ThemeColor getThemeColor() { - return themeColor.get(); - } - - public void setThemeColor(ThemeColor themeColor) { - this.themeColor.set(themeColor); - } - - @SerializedName("fontFamily") - private final StringProperty fontFamily = new SimpleStringProperty(); - - public StringProperty fontFamilyProperty() { - return fontFamily; - } - - public String getFontFamily() { - return fontFamily.get(); - } - - public void setFontFamily(String fontFamily) { - this.fontFamily.set(fontFamily); - } - - @SerializedName("fontSize") - private final DoubleProperty fontSize = new SimpleDoubleProperty(12); - - public DoubleProperty fontSizeProperty() { - return fontSize; - } - - public double getFontSize() { - return fontSize.get(); - } - - public void setFontSize(double fontSize) { - this.fontSize.set(fontSize); - } - - @SerializedName("launcherFontFamily") - private final StringProperty launcherFontFamily = new SimpleStringProperty(); - - public StringProperty launcherFontFamilyProperty() { - return launcherFontFamily; - } - - public String getLauncherFontFamily() { - return launcherFontFamily.get(); - } - - public void setLauncherFontFamily(String launcherFontFamily) { - this.launcherFontFamily.set(launcherFontFamily); - } - - @SerializedName("animationDisabled") - private final BooleanProperty animationDisabled = new SimpleBooleanProperty( - FXUtils.REDUCED_MOTION == Boolean.TRUE - || !JavaRuntime.CURRENT_JIT_ENABLED - || !FXUtils.GPU_ACCELERATION_ENABLED - ); - - public BooleanProperty animationDisabledProperty() { - return animationDisabled; - } - - public boolean isAnimationDisabled() { - return animationDisabled.get(); - } - - public void setAnimationDisabled(boolean animationDisabled) { - this.animationDisabled.set(animationDisabled); - } - - @SerializedName("titleTransparent") - private final BooleanProperty titleTransparent = new SimpleBooleanProperty(false); - - public BooleanProperty titleTransparentProperty() { - return titleTransparent; - } - - public boolean isTitleTransparent() { - return titleTransparent.get(); - } - - public void setTitleTransparent(boolean titleTransparent) { - this.titleTransparent.set(titleTransparent); - } - - @SerializedName("backgroundType") - private final ObjectProperty backgroundImageType = new RawPreservingObjectProperty<>(EnumBackgroundImage.DEFAULT); - - public ObjectProperty backgroundImageTypeProperty() { - return backgroundImageType; - } - - public EnumBackgroundImage getBackgroundImageType() { - return backgroundImageType.get(); - } - - public void setBackgroundImageType(EnumBackgroundImage backgroundImageType) { - this.backgroundImageType.set(backgroundImageType); - } - - @SerializedName("bgpath") - private final StringProperty backgroundImage = new SimpleStringProperty(); - - public StringProperty backgroundImageProperty() { - return backgroundImage; - } - - public String getBackgroundImage() { - return backgroundImage.get(); - } - - public void setBackgroundImage(String backgroundImage) { - this.backgroundImage.set(backgroundImage); - } - - @SerializedName("bgurl") - private final StringProperty backgroundImageUrl = new SimpleStringProperty(); - - public StringProperty backgroundImageUrlProperty() { - return backgroundImageUrl; - } - - public String getBackgroundImageUrl() { - return backgroundImageUrl.get(); - } - - public void setBackgroundImageUrl(String backgroundImageUrl) { - this.backgroundImageUrl.set(backgroundImageUrl); - } - - @SerializedName("bgpaint") - private final ObjectProperty backgroundPaint = new SimpleObjectProperty<>(); - - public Paint getBackgroundPaint() { - return backgroundPaint.get(); - } - - public ObjectProperty backgroundPaintProperty() { - return backgroundPaint; - } - - public void setBackgroundPaint(Paint backgroundPaint) { - this.backgroundPaint.set(backgroundPaint); - } - - @SerializedName("bgImageOpacity") - private final IntegerProperty backgroundImageOpacity = new SimpleIntegerProperty(100); - - public IntegerProperty backgroundImageOpacityProperty() { - return backgroundImageOpacity; - } - - public int getBackgroundImageOpacity() { - return backgroundImageOpacity.get(); - } - - public void setBackgroundImageOpacity(int backgroundImageOpacity) { - this.backgroundImageOpacity.set(backgroundImageOpacity); - } - - // Networks - - @SerializedName("autoDownloadThreads") - private final BooleanProperty autoDownloadThreads = new SimpleBooleanProperty(true); - - public BooleanProperty autoDownloadThreadsProperty() { - return autoDownloadThreads; - } - - public boolean getAutoDownloadThreads() { - return autoDownloadThreads.get(); - } - - public void setAutoDownloadThreads(boolean autoDownloadThreads) { - this.autoDownloadThreads.set(autoDownloadThreads); - } - - @SerializedName("downloadThreads") - private final IntegerProperty downloadThreads = new SimpleIntegerProperty(64); - - public IntegerProperty downloadThreadsProperty() { - return downloadThreads; - } - - public int getDownloadThreads() { - return downloadThreads.get(); - } - - public void setDownloadThreads(int downloadThreads) { - this.downloadThreads.set(downloadThreads); - } - - @SerializedName("downloadType") - private final StringProperty downloadType = new SimpleStringProperty(DownloadProviders.DEFAULT_DIRECT_PROVIDER_ID); - - public StringProperty downloadTypeProperty() { - return downloadType; - } - - public String getDownloadType() { - return downloadType.get(); - } - - public void setDownloadType(String downloadType) { - this.downloadType.set(downloadType); - } - - @SerializedName("autoChooseDownloadType") - private final BooleanProperty autoChooseDownloadType = new SimpleBooleanProperty(true); - - public BooleanProperty autoChooseDownloadTypeProperty() { - return autoChooseDownloadType; - } - - public boolean isAutoChooseDownloadType() { - return autoChooseDownloadType.get(); - } - - public void setAutoChooseDownloadType(boolean autoChooseDownloadType) { - this.autoChooseDownloadType.set(autoChooseDownloadType); - } - - @SerializedName("versionListSource") - private final StringProperty versionListSource = new SimpleStringProperty(DownloadProviders.DEFAULT_AUTO_PROVIDER_ID); - - public StringProperty versionListSourceProperty() { - return versionListSource; - } - - public String getVersionListSource() { - return versionListSource.get(); - } - - public void setVersionListSource(String versionListSource) { - this.versionListSource.set(versionListSource); - } - - @SerializedName("defaultAddonSource") - private final StringProperty defaultAddonSource = new SimpleStringProperty("modrinth"); - - public StringProperty defaultAddonSourceProperty() { - return defaultAddonSource; - } - - public String getDefaultAddonSource() { - return defaultAddonSource.get(); - } - - public void setDefaultAddonSource(String defaultAddonSource) { - this.defaultAddonSource.set(defaultAddonSource); - } - - @SerializedName("hasProxy") - private final BooleanProperty hasProxy = new SimpleBooleanProperty(); - - public BooleanProperty hasProxyProperty() { - return hasProxy; - } - - public boolean hasProxy() { - return hasProxy.get(); - } - - public void setHasProxy(boolean hasProxy) { - this.hasProxy.set(hasProxy); - } - - @SerializedName("hasProxyAuth") - private final BooleanProperty hasProxyAuth = new SimpleBooleanProperty(); - - public BooleanProperty hasProxyAuthProperty() { - return hasProxyAuth; - } - - public boolean hasProxyAuth() { - return hasProxyAuth.get(); - } - - public void setHasProxyAuth(boolean hasProxyAuth) { - this.hasProxyAuth.set(hasProxyAuth); - } - - @SerializedName("proxyType") - private final ObjectProperty proxyType = new SimpleObjectProperty<>(Proxy.Type.HTTP); - - public ObjectProperty proxyTypeProperty() { - return proxyType; - } - - public Proxy.Type getProxyType() { - return proxyType.get(); - } - - public void setProxyType(Proxy.Type proxyType) { - this.proxyType.set(proxyType); - } - - @SerializedName("proxyHost") - private final StringProperty proxyHost = new SimpleStringProperty(); - - public StringProperty proxyHostProperty() { - return proxyHost; - } - - public String getProxyHost() { - return proxyHost.get(); - } - - public void setProxyHost(String proxyHost) { - this.proxyHost.set(proxyHost); - } - - @SerializedName("proxyPort") - private final IntegerProperty proxyPort = new SimpleIntegerProperty(); - - public IntegerProperty proxyPortProperty() { - return proxyPort; - } - - public int getProxyPort() { - return proxyPort.get(); - } - - public void setProxyPort(int proxyPort) { - this.proxyPort.set(proxyPort); - } - - @SerializedName("proxyUserName") - private final StringProperty proxyUser = new SimpleStringProperty(); - - public StringProperty proxyUserProperty() { - return proxyUser; - } - - public String getProxyUser() { - return proxyUser.get(); - } - - public void setProxyUser(String proxyUser) { - this.proxyUser.set(proxyUser); - } - - @SerializedName("proxyPassword") - private final StringProperty proxyPass = new SimpleStringProperty(); - - public StringProperty proxyPassProperty() { - return proxyPass; - } - - public String getProxyPass() { - return proxyPass.get(); - } - - public void setProxyPass(String proxyPass) { - this.proxyPass.set(proxyPass); - } - - // Game - - @SerializedName("disableAutoGameOptions") - private final BooleanProperty disableAutoGameOptions = new SimpleBooleanProperty(false); - - public BooleanProperty disableAutoGameOptionsProperty() { - return disableAutoGameOptions; - } - - public boolean isDisableAutoGameOptions() { - return disableAutoGameOptions.get(); - } - - public void setDisableAutoGameOptions(boolean disableAutoGameOptions) { - this.disableAutoGameOptions.set(disableAutoGameOptions); - } - - @SerializedName("allowAutoAgent") - private final BooleanProperty allowAutoAgent = new SimpleBooleanProperty(false); - - public BooleanProperty allowAutoAgentProperty() { - return allowAutoAgent; - } - - public boolean getAllowAutoAgent() { - return allowAutoAgent.get(); - } - - public void setAllowAutoAgent(boolean allowAutoAgent) { - this.allowAutoAgent.set(allowAutoAgent); - } - - // Accounts - - @SerializedName("authlibInjectorServers") - private final ObservableList authlibInjectorServers = FXCollections.observableArrayList(server -> new Observable[]{server}); - - public ObservableList getAuthlibInjectorServers() { - return authlibInjectorServers; - } - - @SerializedName("addedLittleSkin") - private final BooleanProperty addedLittleSkin = new SimpleBooleanProperty(false); - - public BooleanProperty addedLittleSkinProperty() { - return addedLittleSkin; - } - - public boolean isAddedLittleSkin() { - return addedLittleSkin.get(); - } - - public void setAddedLittleSkin(boolean addedLittleSkin) { - this.addedLittleSkin.set(addedLittleSkin); - } - - /** - * The preferred login type to use when the user wants to add an account. - */ - @SerializedName("preferredLoginType") - private final StringProperty preferredLoginType = new SimpleStringProperty(); - - public StringProperty preferredLoginTypeProperty() { - return preferredLoginType; - } - - public String getPreferredLoginType() { - return preferredLoginType.get(); - } - - public void setPreferredLoginType(String preferredLoginType) { - this.preferredLoginType.set(preferredLoginType); - } - - @SerializedName("selectedAccount") - private final StringProperty selectedAccount = new SimpleStringProperty(); - - public StringProperty selectedAccountProperty() { - return selectedAccount; - } - - public String getSelectedAccount() { - return selectedAccount.get(); - } - - public void setSelectedAccount(String selectedAccount) { - this.selectedAccount.set(selectedAccount); - } - - @SerializedName("accounts") - private final ObservableList> accountStorages = FXCollections.observableArrayList(); - - public ObservableList> getAccountStorages() { - return accountStorages; - } - - // Configurations - - @SerializedName("last") - private final StringProperty selectedProfile = new SimpleStringProperty(""); - - public StringProperty selectedProfileProperty() { - return selectedProfile; - } - - public String getSelectedProfile() { - return selectedProfile.get(); - } - - public void setSelectedProfile(String selectedProfile) { - this.selectedProfile.set(selectedProfile); - } - - @SerializedName("configurations") - private final SimpleMapProperty configurations = new SimpleMapProperty<>(FXCollections.observableMap(new TreeMap<>())); - - public MapProperty getConfigurations() { - return configurations; - } - - public static final class Adapter extends ObservableSetting.Adapter { - @Override - protected Config createInstance() { - return new Config(); - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/ConfigHolder.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/ConfigHolder.java deleted file mode 100644 index 2854207c33d..00000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/ConfigHolder.java +++ /dev/null @@ -1,212 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.setting; - -import com.google.gson.JsonParseException; -import org.jackhuang.hmcl.Metadata; -import org.jackhuang.hmcl.util.FileSaver; -import org.jackhuang.hmcl.util.i18n.I18n; -import org.jackhuang.hmcl.util.io.FileUtils; -import org.jackhuang.hmcl.util.io.JarUtils; -import org.jackhuang.hmcl.util.platform.OperatingSystem; - -import java.io.IOException; -import java.nio.file.*; -import java.util.Locale; - -import static org.jackhuang.hmcl.util.logging.Logger.LOG; - -public final class ConfigHolder { - - private ConfigHolder() { - } - - public static final String CONFIG_FILENAME = "hmcl.json"; - public static final String CONFIG_FILENAME_LINUX = ".hmcl.json"; - public static final Path GLOBAL_CONFIG_PATH = Metadata.HMCL_GLOBAL_DIRECTORY.resolve("config.json"); - - private static Path configLocation; - private static Config configInstance; - private static GlobalConfig globalConfigInstance; - private static boolean newlyCreated; - private static boolean ownerChanged = false; - private static boolean unsupportedVersion = false; - - public static Config config() { - if (configInstance == null) { - throw new IllegalStateException("Configuration hasn't been loaded"); - } - return configInstance; - } - - public static GlobalConfig globalConfig() { - if (globalConfigInstance == null) { - throw new IllegalStateException("Configuration hasn't been loaded"); - } - return globalConfigInstance; - } - - public static Path configLocation() { - return configLocation; - } - - public static boolean isNewlyCreated() { - return newlyCreated; - } - - public static boolean isOwnerChanged() { - return ownerChanged; - } - - public static boolean isUnsupportedVersion() { - return unsupportedVersion; - } - - public static void init() throws IOException { - if (configInstance != null) { - throw new IllegalStateException("Configuration is already loaded"); - } - - configLocation = locateConfig(); - - LOG.info("Config location: " + configLocation); - - configInstance = loadConfig(); - if (!unsupportedVersion) - configInstance.addListener(source -> FileSaver.save(configLocation, configInstance.toJson())); - - globalConfigInstance = loadGlobalConfig(); - globalConfigInstance.addListener(source -> FileSaver.save(GLOBAL_CONFIG_PATH, globalConfigInstance.toJson())); - - Locale.setDefault(config().getLocalization().getLocale()); - I18n.setLocale(configInstance.getLocalization()); - LOG.setLogRetention(globalConfig().getLogRetention()); - Settings.init(); - - if (newlyCreated) { - LOG.info("Creating config file " + configLocation); - FileUtils.saveSafely(configLocation, configInstance.toJson()); - } - - if (!Files.isWritable(configLocation)) { - if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS - && configLocation.getFileSystem() == FileSystems.getDefault() - && configLocation.toFile().canWrite()) { - LOG.warning("Config at " + configLocation + " is not writable, but it seems to be a Samba share or OpenJDK bug"); - // There are some serious problems with the implementation of Samba or OpenJDK - throw new SambaException(); - } else { - // the config cannot be saved - // throw up the error now to prevent further data loss - throw new IOException("Config at " + configLocation + " is not writable"); - } - } - } - - private static Path locateConfig() { - Path defaultConfigFile = Metadata.HMCL_CURRENT_DIRECTORY.resolve(CONFIG_FILENAME); - if (Files.isRegularFile(defaultConfigFile)) - return defaultConfigFile; - - try { - Path jarPath = JarUtils.thisJarPath(); - if (jarPath != null && Files.isRegularFile(jarPath) && Files.isWritable(jarPath)) { - jarPath = jarPath.getParent(); - - Path config = jarPath.resolve(CONFIG_FILENAME); - if (Files.isRegularFile(config)) - return config; - - Path dotConfig = jarPath.resolve(CONFIG_FILENAME_LINUX); - if (Files.isRegularFile(dotConfig)) - return dotConfig; - } - - } catch (Throwable ignore) { - } - - Path config = Paths.get(CONFIG_FILENAME); - if (Files.isRegularFile(config)) - return config; - - Path dotConfig = Paths.get(CONFIG_FILENAME_LINUX); - if (Files.isRegularFile(dotConfig)) - return dotConfig; - - // create new - return defaultConfigFile; - } - - private static Config loadConfig() throws IOException { - if (Files.exists(configLocation)) { - try { - if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS - && "root".equals(System.getProperty("user.name")) - && !"root".equals(Files.getOwner(configLocation).getName())) { - ownerChanged = true; - } - } catch (IOException e1) { - LOG.warning("Failed to get owner"); - } - try { - String content = Files.readString(configLocation); - Config deserialized = Config.fromJson(content); - if (deserialized == null) { - LOG.info("Config is empty"); - } else { - int configVersion = deserialized.getConfigVersion(); - if (configVersion < Config.CURRENT_VERSION) { - ConfigUpgrader.upgradeConfig(deserialized, content); - } else if (configVersion > Config.CURRENT_VERSION) { - unsupportedVersion = true; - LOG.warning(String.format("Current HMCL only support the configuration version up to %d. However, the version now is %d.", Config.CURRENT_VERSION, configVersion)); - } - - return deserialized; - } - } catch (JsonParseException e) { - LOG.warning("Malformed config.", e); - } - } - - newlyCreated = true; - return new Config(); - } - - // Global Config - - private static GlobalConfig loadGlobalConfig() throws IOException { - if (Files.exists(GLOBAL_CONFIG_PATH)) { - try { - String content = Files.readString(GLOBAL_CONFIG_PATH); - GlobalConfig deserialized = GlobalConfig.fromJson(content); - if (deserialized == null) { - LOG.info("Config is empty"); - } else { - return deserialized; - } - } catch (JsonParseException e) { - LOG.warning("Malformed config.", e); - } - } - - LOG.info("Creating an empty global config"); - return new GlobalConfig(); - } - -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/ConfigUpgrader.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/ConfigUpgrader.java deleted file mode 100644 index 2eded388fd9..00000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/ConfigUpgrader.java +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.setting; - -import com.google.gson.Gson; -import org.jackhuang.hmcl.util.StringUtils; - -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; - -import static org.jackhuang.hmcl.util.Lang.tryCast; -import static org.jackhuang.hmcl.util.logging.Logger.LOG; - -final class ConfigUpgrader { - private ConfigUpgrader() { - } - - /** - * This method is for the compatibility with old HMCL versions. - * - * @param deserialized deserialized config settings - * @param rawContent raw json content of the config settings without modification - */ - static void upgradeConfig(Config deserialized, String rawContent) { - int configVersion = deserialized.getConfigVersion(); - - if (configVersion >= Config.CURRENT_VERSION) - return; - - LOG.info(String.format("Updating configuration from %d to %d.", configVersion, Config.CURRENT_VERSION)); - Map rawJson = Collections.unmodifiableMap(new Gson().>fromJson(rawContent, Map.class)); - - if (configVersion < 1) { - // Upgrade configuration of HMCL 2.x: Convert OfflineAccounts whose stored uuid is important. - tryCast(rawJson.get("auth"), Map.class).ifPresent(auth -> { - tryCast(auth.get("offline"), Map.class).ifPresent(offline -> { - String selected = rawJson.containsKey("selectedAccount") ? null - : tryCast(offline.get("IAuthenticator_UserName"), String.class).orElse(null); - - tryCast(offline.get("uuidMap"), Map.class).ifPresent(uuidMap -> { - ((Map) uuidMap).forEach((key, value) -> { - Map storage = new HashMap<>(); - storage.put("type", "offline"); - storage.put("username", key); - storage.put("uuid", value); - if (key.equals(selected)) { - storage.put("selected", true); - } - deserialized.getAccountStorages().add(storage); - }); - }); - }); - }); - - // Upgrade configuration of HMCL earlier than 3.1.70 - if (!rawJson.containsKey("commonDirType")) - deserialized.setCommonDirType(deserialized.getCommonDirectory().equals(Settings.getDefaultCommonDirectory()) ? EnumCommonDirectory.DEFAULT : EnumCommonDirectory.CUSTOM); - if (!rawJson.containsKey("backgroundType")) - deserialized.setBackgroundImageType(StringUtils.isNotBlank(deserialized.getBackgroundImage()) ? EnumBackgroundImage.CUSTOM : EnumBackgroundImage.DEFAULT); - if (!rawJson.containsKey("hasProxy")) - deserialized.setHasProxy(StringUtils.isNotBlank(deserialized.getProxyHost())); - if (!rawJson.containsKey("hasProxyAuth")) - deserialized.setHasProxyAuth(StringUtils.isNotBlank(deserialized.getProxyUser())); - - if (!rawJson.containsKey("downloadType")) { - tryCast(rawJson.get("downloadtype"), Number.class) - .map(Number::intValue) - .ifPresent(id -> { - if (id == 0) { - deserialized.setDownloadType("mojang"); - } else if (id == 1) { - deserialized.setDownloadType("bmclapi"); - } - }); - } - } - - deserialized.setConfigVersion(Config.CURRENT_VERSION); - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/DefaultIsolationType.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/DefaultIsolationType.java new file mode 100644 index 00000000000..f72a00e750a --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/DefaultIsolationType.java @@ -0,0 +1,30 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.setting; + +/// @author Glavo +public enum DefaultIsolationType { + /// No instances are isolated by default. + NEVER, + + /// Default to isolate all instances. + ALWAYS, + + /// Default to isolate instances with any modloader. + MODED, +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/DownloadProviders.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/DownloadProviders.java index 796b9372a13..a070303d7b4 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/DownloadProviders.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/DownloadProviders.java @@ -21,7 +21,6 @@ import org.jackhuang.hmcl.download.*; import org.jackhuang.hmcl.task.DownloadException; import org.jackhuang.hmcl.task.FetchTask; -import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.i18n.I18n; import org.jackhuang.hmcl.util.i18n.LocaleUtils; @@ -33,82 +32,70 @@ import java.net.URI; import java.nio.file.AccessDeniedException; import java.util.List; -import java.util.Map; import java.util.concurrent.CancellationException; -import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.setting.SettingsManager.settings; import static org.jackhuang.hmcl.task.FetchTask.DEFAULT_CONCURRENCY; -import static org.jackhuang.hmcl.util.Pair.pair; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public final class DownloadProviders { private DownloadProviders() { } - public static final String DEFAULT_AUTO_PROVIDER_ID = "balanced"; - public static final String DEFAULT_DIRECT_PROVIDER_ID = "mojang"; - private static final DownloadProviderWrapper PROVIDER_WRAPPER; + private static final DownloadProvider MOJANG_PROVIDER; + private static final BMCLAPIDownloadProvider BMCLAPI_PROVIDER; private static final DownloadProvider DEFAULT_PROVIDER; - public static final Map DIRECT_PROVIDERS; - public static final Map AUTO_PROVIDERS; static { String bmclapiRoot = System.getProperty("hmcl.bmclapi.override", "https://bmclapi2.bangbang93.com"); - BMCLAPIDownloadProvider bmclapiRaw = new BMCLAPIDownloadProvider(bmclapiRoot); - - DownloadProvider mojang = new MojangDownloadProvider(); - DownloadProvider bmclapi = new AutoDownloadProvider(bmclapiRaw, mojang); - - DEFAULT_PROVIDER = mojang; - DIRECT_PROVIDERS = Lang.mapOf( - pair("mojang", mojang), - pair("bmclapi", bmclapi) - ); - - AUTO_PROVIDERS = Lang.mapOf( - pair("balanced", LocaleUtils.IS_CHINA_MAINLAND ? bmclapi : mojang), - pair("official", LocaleUtils.IS_CHINA_MAINLAND ? new AutoDownloadProvider( - List.of(mojang, bmclapiRaw), - List.of(bmclapiRaw, mojang) - ) : mojang), - pair("mirror", bmclapi) - ); - + BMCLAPI_PROVIDER = new BMCLAPIDownloadProvider(bmclapiRoot); + MOJANG_PROVIDER = new MojangDownloadProvider(); + DEFAULT_PROVIDER = createDownloadProvider(DownloadSource.DEFAULT, DownloadSource.DEFAULT); PROVIDER_WRAPPER = new DownloadProviderWrapper(DEFAULT_PROVIDER); } - static void init() { + /// Initializes download provider settings and synchronizes download thread settings. + public static void init() { InvalidationListener onChangeDownloadThreads = observable -> { - FetchTask.setDownloadExecutorConcurrency(config().getAutoDownloadThreads() + FetchTask.setDownloadExecutorConcurrency(settings().autoDownloadThreadsProperty().get() ? DEFAULT_CONCURRENCY - : config().getDownloadThreads()); + : settings().downloadThreadsProperty().get()); }; - config().autoDownloadThreadsProperty().addListener(onChangeDownloadThreads); - config().downloadThreadsProperty().addListener(onChangeDownloadThreads); + settings().autoDownloadThreadsProperty().addListener(onChangeDownloadThreads); + settings().downloadThreadsProperty().addListener(onChangeDownloadThreads); onChangeDownloadThreads.invalidated(null); InvalidationListener onChangeDownloadSource = observable -> { - if (config().isAutoChooseDownloadType()) { - String versionListSource = config().getVersionListSource(); - DownloadProvider downloadProvider = versionListSource != null - ? AUTO_PROVIDERS.getOrDefault(versionListSource, DEFAULT_PROVIDER) - : DEFAULT_PROVIDER; - PROVIDER_WRAPPER.setProvider(downloadProvider); - } else { - String downloadType = config().getDownloadType(); - PROVIDER_WRAPPER.setProvider(downloadType != null - ? DIRECT_PROVIDERS.getOrDefault(downloadType, DEFAULT_PROVIDER) - : DEFAULT_PROVIDER); - } + PROVIDER_WRAPPER.setProvider(createDownloadProvider( + settings().versionListSourceProperty().get(), + settings().fileDownloadSourceProperty().get())); }; - config().versionListSourceProperty().addListener(onChangeDownloadSource); - config().autoChooseDownloadTypeProperty().addListener(onChangeDownloadSource); - config().downloadTypeProperty().addListener(onChangeDownloadSource); + settings().versionListSourceProperty().addListener(onChangeDownloadSource); + settings().fileDownloadSourceProperty().addListener(onChangeDownloadSource); onChangeDownloadSource.invalidated(null); } + /// Creates a download provider with independent version-list and file download preferences. + private static DownloadProvider createDownloadProvider(DownloadSource versionListSource, DownloadSource fileDownloadSource) { + return new AutoDownloadProvider( + getCandidates(versionListSource), + getCandidates(fileDownloadSource)); + } + + /// Returns provider candidates ordered by the given source preference. + private static List getCandidates(DownloadSource source) { + DownloadSource normalized = source != null ? source : DownloadSource.DEFAULT; + return switch (normalized) { + case DEFAULT -> LocaleUtils.IS_CHINA_MAINLAND + ? List.of(BMCLAPI_PROVIDER, MOJANG_PROVIDER) + : List.of(MOJANG_PROVIDER, BMCLAPI_PROVIDER); + case OFFICIAL -> List.of(MOJANG_PROVIDER, BMCLAPI_PROVIDER); + case MIRROR -> List.of(BMCLAPI_PROVIDER, MOJANG_PROVIDER); + }; + } + /** * Get current primary preferred download provider */ diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/DownloadSource.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/DownloadSource.java new file mode 100644 index 00000000000..aade2ef0cec --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/DownloadSource.java @@ -0,0 +1,33 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.setting; + +import org.jetbrains.annotations.NotNullByDefault; + +/// Preferred source selection for download providers. +@NotNullByDefault +public enum DownloadSource { + /// Automatically chooses the best source for the current environment. + DEFAULT, + + /// Prefers official download sources. + OFFICIAL, + + /// Prefers mirror download sources. + MIRROR, +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/FontManager.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/FontManager.java index cdda72f11a1..c2836a0423f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/FontManager.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/FontManager.java @@ -37,7 +37,7 @@ import java.nio.file.Paths; import java.util.*; -import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.setting.SettingsManager.settings; import static org.jackhuang.hmcl.util.logging.Logger.LOG; /** @@ -56,17 +56,17 @@ public final class FontManager { // Recommended - font = tryLoadLocalizedFont(Metadata.HMCL_CURRENT_DIRECTORY.resolve("font")); + font = tryLoadLocalizedFont(Metadata.HMCL_LOCAL_HOME.resolve("font")); if (font != null) return font; - font = tryLoadLocalizedFont(Metadata.HMCL_GLOBAL_DIRECTORY.resolve("font")); + font = tryLoadLocalizedFont(Metadata.HMCL_USER_HOME.resolve("font")); if (font != null) return font; // Legacy - font = tryLoadDefaultFont(Metadata.HMCL_CURRENT_DIRECTORY); + font = tryLoadDefaultFont(Metadata.HMCL_LOCAL_HOME); if (font != null) return font; @@ -74,7 +74,7 @@ public final class FontManager { if (font != null) return font; - font = tryLoadDefaultFont(Metadata.HMCL_GLOBAL_DIRECTORY); + font = tryLoadDefaultFont(Metadata.HMCL_USER_HOME); if (font != null) return font; @@ -103,7 +103,7 @@ public final class FontManager { } private static void updateFont() { - String fontFamily = config().getLauncherFontFamily(); + String fontFamily = settings().launcherFontFamilyProperty().get(); if (fontFamily == null) fontFamily = System.getProperty("hmcl.font.override"); if (fontFamily == null) @@ -236,7 +236,7 @@ public static FontReference getFont() { } public static void setFontFamily(String fontFamily) { - config().setLauncherFontFamily(fontFamily); + settings().launcherFontFamilyProperty().set(fontFamily); updateFont(); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectories.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectories.java new file mode 100644 index 00000000000..eaf3b37006b --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectories.java @@ -0,0 +1,107 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.setting; + +import com.google.gson.*; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +import javafx.beans.Observable; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import org.jackhuang.hmcl.util.gson.JsonSchema; +import org.jackhuang.hmcl.util.gson.JsonSerializable; +import org.jackhuang.hmcl.util.gson.ObservableSetting; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.lang.reflect.Type; +import java.util.Objects; + +/// Stores game directories independently from the main config file. +/// +/// The JSON representation is saved as `game-directories.json` under either the current +/// HMCL directory or the global HMCL directory. +/// +/// @author Glavo +@JsonAdapter(GameDirectories.Adapter.class) +@NotNullByDefault +@JsonSerializable +public final class GameDirectories extends ObservableSetting implements JsonSchemaSetting { + /// The JSON schema supported by this game directory store. + public static final JsonSchema CURRENT_SCHEMA = + new JsonSchema("game-directories", new JsonSchema.Version(1, 0, 0)); + + /// Creates an empty game directory store. + public GameDirectories() { + tracker.markDirty(schema); + register(); + } + + /// The schema used by this game directory store file. + @SerializedName(JsonSchema.PROPERTY_SCHEMA) + private final ObjectProperty schema = new SimpleObjectProperty<>(CURRENT_SCHEMA); + + /// Returns the schema property. + public ObjectProperty schemaProperty() { + return schema; + } + + /// Returns the schema used by this game directory store file. + public JsonSchema getSchema() { + return schema.get(); + } + + /// Sets the schema used by this game directory store file. + public void setSchema(JsonSchema schema) { + this.schema.set(Objects.requireNonNull(schema)); + } + + /// Game directories stored in this file. + @SerializedName("directories") + private final ObservableList gameDirectories = + FXCollections.observableArrayList(profile -> new Observable[] { profile }); + + /// Returns the game directories stored in this file. + public ObservableList getGameDirectories() { + return gameDirectories; + } + + /// JSON adapter for [GameDirectories]. + public static final class Adapter extends ObservableSetting.Adapter<@Nullable GameDirectories> { + /// Creates an empty game directory store for deserialization. + @Override + protected GameDirectories createInstance() { + return new GameDirectories(); + } + + /// Deserializes game directories and drops the workspace-level selected directory. + @Override + public @Nullable GameDirectories deserialize( + JsonElement json, + Type typeOfT, + JsonDeserializationContext context) throws JsonParseException { + @Nullable GameDirectories result = super.deserialize(json, typeOfT, context); + if (result != null) { + result.unknownFields.remove(LauncherSettings.PROPERTY_SELECTED_GAME_DIRECTORY); + } + return result; + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameSettings.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameSettings.java new file mode 100644 index 00000000000..ecd04026627 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameSettings.java @@ -0,0 +1,1055 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.setting; + +import com.github.f4b6a3.uuid.alt.GUID; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +import javafx.collections.FXCollections; +import javafx.collections.ObservableSet; +import org.jackhuang.hmcl.game.*; +import org.jackhuang.hmcl.java.JavaManager; +import org.jackhuang.hmcl.java.JavaRuntime; +import org.jackhuang.hmcl.setting.property.InheritableProperty; +import org.jackhuang.hmcl.setting.property.SettingProperty; +import org.jackhuang.hmcl.setting.property.SimpleInheritableProperty; +import org.jackhuang.hmcl.setting.property.SimpleSettingProperty; +import org.jackhuang.hmcl.util.Lang; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.gson.JsonSerializable; +import org.jackhuang.hmcl.util.gson.ObservableSetting; +import org.jackhuang.hmcl.util.platform.SystemInfo; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.UnknownNullability; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.Collection; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// Game launch settings shared by presets and instance-specific overrides. +/// +/// @author Glavo +@NotNullByDefault +public sealed abstract class GameSettings extends ObservableSetting { + /// Suggested maximum heap memory in MiB. + static final int SUGGESTED_MEMORY; + + static { + double totalMemoryMB = MEGABYTES.convertFromBytes(SystemInfo.getTotalMemorySize()); + SUGGESTED_MEMORY = totalMemoryMB >= 32768 + ? 8192 + : Integer.max((int) (Math.round(totalMemoryMB / 4.0 / 128.0) * 128), 256); + } + + /// Instance-specific game setting. + @JsonAdapter(Instance.Adapter.class) + @JsonSerializable + public static final class Instance extends GameSettings { + /// Creates an empty instance setting. + public Instance() { + register(); + } + + /// The parent preset ID. + @SerializedName("parent") + private final SettingProperty<@Nullable GUID> parent = newSettingProperty("parent"); + + /// Returns the parent preset ID property. + public SettingProperty<@Nullable GUID> parentProperty() { + return parent; + } + + /// The icon of the instance. + @SerializedName("icon") + private final SettingProperty icon = newSettingProperty("icon", VersionIconType.DEFAULT); + + /// Returns the instance icon property. + public SettingProperty iconProperty() { + return icon; + } + + /// Setting property names overridden by this instance. + @SerializedName("overrideProperties") + private final ObservableSet overrideProperties = FXCollections.observableSet(); + + /// Returns the overridden setting property names. + public ObservableSet getOverrideProperties() { + return overrideProperties; + } + + /// JSON adapter for instance settings. + public static final class Adapter extends ObservableSetting.Adapter { + @Override + protected Instance createInstance() { + return new Instance(); + } + } + } + + /// Reusable game setting preset. + @JsonAdapter(Preset.Adapter.class) + @JsonSerializable + public static final class Preset extends GameSettings { + /// Creates a preset with the given identity. + public Preset(GUID id) { + register(); + this.id.setValue(Objects.requireNonNull(id)); + } + + /// Creates a preset with the given identity. + private Preset() { + register(); + } + + /// The stable preset ID. + @SerializedName("id") + private final SettingProperty id = newSettingProperty("id", GUID.NIL); + + /// Returns the preset ID property. + public SettingProperty idProperty() { + return id; + } + + /// The display name of this preset. + @SerializedName("name") + private final SettingProperty name = newSettingProperty("name", ""); + + /// Returns the display name property. + public SettingProperty nameProperty() { + return name; + } + + /// Whether to enable the version isolation strategy when installing a new instance. + @SerializedName("defaultIsolationType") + private final SettingProperty defaultIsolationType = newSettingProperty("defaultIsolationType", DefaultIsolationType.MODED); + + /// Returns the default isolation type property. + public SettingProperty defaultIsolationTypeProperty() { + return defaultIsolationType; + } + + /// JSON adapter for presets. + public static final class Adapter extends ObservableSetting.Adapter<@Nullable Preset> { + @Override + protected Preset createInstance() { + return new Preset(); + } + + @Override + public @Nullable Preset deserialize( + JsonElement json, + Type typeOfT, + JsonDeserializationContext context) throws JsonParseException { + @Nullable Preset result = super.deserialize(json, typeOfT, context); + if (result != null && GUID.NIL.equals(result.idProperty().getValue())) { + throw new JsonParseException("Preset ID cannot be nil"); + } + return result; + } + } + } + + /// Creates a new setting property without a default value. + protected final SettingProperty<@Nullable T> newSettingProperty(String name) { + return new SimpleSettingProperty<>(this, name, null); + } + + /// Creates a new setting property with the given default value. + protected final SettingProperty newSettingProperty(String name, T defaultValue) { + return new SimpleSettingProperty<>(this, name, defaultValue); + } + + /// Creates a new inheritable property. + protected final InheritableProperty newInheritableProperty(String name, T defaultValue) { + return new SimpleInheritableProperty<>(this, name, defaultValue); + } + + /// Property name for the Java selection mode. + public static final String PROPERTY_JAVA_TYPE = "javaType"; + + /// The Java selection mode. + @SerializedName(PROPERTY_JAVA_TYPE) + private final InheritableProperty javaType = newInheritableProperty(PROPERTY_JAVA_TYPE, JavaVersionType.AUTO); + + /// Returns the Java selection mode property. + public InheritableProperty javaTypeProperty() { + return javaType; + } + + /// The custom or detected Java version string. + @SerializedName("javaVersion") + private final SettingProperty javaVersion = newSettingProperty("javaVersion", ""); + + /// Returns the Java version property. + public SettingProperty javaVersionProperty() { + return javaVersion; + } + + /// User customized Java executable path. + @SerializedName("customJavaPath") + private final SettingProperty customJavaPath = newSettingProperty("customJavaPath", ""); + + /// Returns the custom Java executable path property. + public SettingProperty customJavaPathProperty() { + return customJavaPath; + } + + /// Resolved Java executable path used to disambiguate detected Java runtimes. + @SerializedName("defaultJavaPath") + private final SettingProperty defaultJavaPath = newSettingProperty("defaultJavaPath", ""); + + /// Returns the default Java executable path property. + public SettingProperty defaultJavaPathProperty() { + return defaultJavaPath; + } + + /// Property name for customized JVM options. + public static final String PROPERTY_JVM_OPTIONS = "jvmOptions"; + + /// User customized JVM options. + @SerializedName(PROPERTY_JVM_OPTIONS) + private final SettingProperty jvmOptions = newSettingProperty(PROPERTY_JVM_OPTIONS, ""); + + /// Returns the user customized JVM options property. + public SettingProperty jvmOptionsProperty() { + return jvmOptions; + } + + /// Property name for disabling generated JVM options. + public static final String PROPERTY_NO_JVM_OPTIONS = "noJVMOptions"; + + /// If `true`, HMCL will not use default JVM arguments. + @SerializedName(PROPERTY_NO_JVM_OPTIONS) + private final InheritableProperty noJVMOptions = newInheritableProperty(PROPERTY_NO_JVM_OPTIONS, false); + + /// Returns the no generated JVM options property. + public InheritableProperty noJVMOptionsProperty() { + return noJVMOptions; + } + + /// Property name for disabling generated optimizing JVM options. + public static final String PROPERTY_NO_OPTIMIZING_JVM_OPTIONS = "noOptimizingJVMOptions"; + + /// If `true`, HMCL will not use the default optimizing JVM options. + @SerializedName(PROPERTY_NO_OPTIMIZING_JVM_OPTIONS) + private final InheritableProperty noOptimizingJVMOptions = newInheritableProperty(PROPERTY_NO_OPTIMIZING_JVM_OPTIONS, false); + + /// Returns the no optimizing JVM options property. + public InheritableProperty noOptimizingJVMOptionsProperty() { + return noOptimizingJVMOptions; + } + + /// Property name for disabling JVM validity checks. + public static final String PROPERTY_NOT_CHECK_JVM = "notCheckJVM"; + + /// If `true`, HMCL does not check JVM validity. + @SerializedName(PROPERTY_NOT_CHECK_JVM) + private final InheritableProperty notCheckJVM = newInheritableProperty(PROPERTY_NOT_CHECK_JVM, false); + + /// Returns the JVM validity check property. + public InheritableProperty notCheckJVMProperty() { + return notCheckJVM; + } + + /// Property name for disabling game completeness checks. + public static final String PROPERTY_NOT_CHECK_GAME = "notCheckGame"; + + /// If `true`, HMCL does not check game completeness. + @SerializedName(PROPERTY_NOT_CHECK_GAME) + private final InheritableProperty notCheckGame = newInheritableProperty(PROPERTY_NOT_CHECK_GAME, false); + + /// Returns the game completeness check property. + public InheritableProperty notCheckGameProperty() { + return notCheckGame; + } + + /// Property name for automatic memory allocation. + public static final String PROPERTY_AUTO_MEMORY = "autoMemory"; + + /// If `true`, HMCL will automatically adjust memory allocation. + @SerializedName(PROPERTY_AUTO_MEMORY) + private final SettingProperty autoMemory = newSettingProperty(PROPERTY_AUTO_MEMORY, true); + + /// Returns the automatic memory allocation property. + public SettingProperty autoMemoryProperty() { + return autoMemory; + } + + /// Property name for the minimum heap memory. + public static final String PROPERTY_MIN_MEMORY = "minMemory"; + + /// The minimum heap memory in MiB. + @SerializedName(PROPERTY_MIN_MEMORY) + private final SettingProperty<@Nullable Integer> minMemory = newSettingProperty(PROPERTY_MIN_MEMORY); + + /// Returns the minimum heap memory property. + public SettingProperty<@Nullable Integer> minMemoryProperty() { + return minMemory; + } + + /// Property name for the maximum heap memory. + public static final String PROPERTY_MAX_MEMORY = "maxMemory"; + + /// The maximum heap memory in MiB. + @SerializedName(PROPERTY_MAX_MEMORY) + private final SettingProperty<@Nullable Integer> maxMemory = newSettingProperty(PROPERTY_MAX_MEMORY, SUGGESTED_MEMORY); + + /// Returns the maximum heap memory property. + public SettingProperty<@Nullable Integer> maxMemoryProperty() { + return maxMemory; + } + + /// Property name for the permanent generation or metaspace size. + public static final String PROPERTY_PERM_SIZE = "permSize"; + + /// The permanent generation or metaspace size in MiB. + @SerializedName(PROPERTY_PERM_SIZE) + private final SettingProperty permSize = newSettingProperty(PROPERTY_PERM_SIZE, ""); + + /// Returns the permanent generation or metaspace size property. + public SettingProperty permSizeProperty() { + return permSize; + } + + /// Property name for the initial game window mode. + public static final String PROPERTY_WINDOW_TYPE = "windowType"; + + /// The initial game window mode. + @SerializedName(PROPERTY_WINDOW_TYPE) + private final InheritableProperty windowType = newInheritableProperty(PROPERTY_WINDOW_TYPE, GameWindowType.WINDOWED); + + /// Returns the game window mode property. + public InheritableProperty windowTypeProperty() { + return windowType; + } + + /// Property name for the game window width. + public static final String PROPERTY_WIDTH = "width"; + + /// The width of the game window. + @SerializedName(PROPERTY_WIDTH) + private final InheritableProperty width = newInheritableProperty(PROPERTY_WIDTH, 854.0); + + /// Returns the game window width property. + public InheritableProperty widthProperty() { + return width; + } + + /// Property name for the game window height. + public static final String PROPERTY_HEIGHT = "height"; + + /// The height of the game window. + @SerializedName(PROPERTY_HEIGHT) + private final InheritableProperty height = newInheritableProperty(PROPERTY_HEIGHT, 480.0); + + /// Returns the game window height property. + public InheritableProperty heightProperty() { + return height; + } + + /// Property name for the custom run directory. + public static final String PROPERTY_RUNNING_DIR = "runningDir"; + + /// The custom run directory. + @SerializedName(PROPERTY_RUNNING_DIR) + private final InheritableProperty runningDir = newInheritableProperty(PROPERTY_RUNNING_DIR, ""); + + /// Returns the custom run directory property. + public InheritableProperty runningDirProperty() { + return runningDir; + } + + /// Property name for the process priority. + public static final String PROPERTY_PROCESS_PRIORITY = "processPriority"; + + /// The process priority of the game. + @SerializedName(PROPERTY_PROCESS_PRIORITY) + private final InheritableProperty processPriority = newInheritableProperty(PROPERTY_PROCESS_PRIORITY, ProcessPriority.NORMAL); + + /// Returns the process priority property. + public InheritableProperty processPriorityProperty() { + return processPriority; + } + + /// Property name for launcher behavior after game start. + public static final String PROPERTY_LAUNCHER_VISIBILITY = "launcherVisibility"; + + /// Launcher behavior after the game starts. + @SerializedName(PROPERTY_LAUNCHER_VISIBILITY) + private final InheritableProperty launcherVisibility = newInheritableProperty(PROPERTY_LAUNCHER_VISIBILITY, LauncherVisibility.KEEP); + + /// Returns the launcher visibility property. + public InheritableProperty launcherVisibilityProperty() { + return launcherVisibility; + } + + /// Property name for allowing HMCL to modify the game with Java agents. + public static final String PROPERTY_ALLOW_AUTO_AGENT = "allowAutoAgent"; + + /// If `true`, HMCL may attach Java agents to improve the game experience. + @SerializedName(PROPERTY_ALLOW_AUTO_AGENT) + private final InheritableProperty allowAutoAgent = newInheritableProperty(PROPERTY_ALLOW_AUTO_AGENT, false); + + /// Returns the automatic Java agent permission property. + public InheritableProperty allowAutoAgentProperty() { + return allowAutoAgent; + } + + /// Property name for disabling automatic game options generation. + public static final String PROPERTY_DISABLE_AUTO_GAME_OPTIONS = "disableAutoGameOptions"; + + /// If `true`, HMCL will not generate game options automatically. + @SerializedName(PROPERTY_DISABLE_AUTO_GAME_OPTIONS) + private final InheritableProperty disableAutoGameOptions = + newInheritableProperty(PROPERTY_DISABLE_AUTO_GAME_OPTIONS, false); + + /// Returns the automatic game options generation disable property. + public InheritableProperty disableAutoGameOptionsProperty() { + return disableAutoGameOptions; + } + + /// Property name for customized game arguments. + public static final String PROPERTY_GAME_ARGS = "gameArgs"; + + /// User customized arguments passed to the game. + @SerializedName(PROPERTY_GAME_ARGS) + private final SettingProperty gameArgs = newSettingProperty(PROPERTY_GAME_ARGS, ""); + + /// Returns the customized game arguments property. + public SettingProperty gameArgsProperty() { + return gameArgs; + } + + /// Property name for the requested graphics API. + public static final String PROPERTY_GRAPHICS_BACKEND = "graphicsBackend"; + + /// Graphics API requested by the launcher. + @SerializedName(PROPERTY_GRAPHICS_BACKEND) + private final InheritableProperty graphicsBackend = newInheritableProperty(PROPERTY_GRAPHICS_BACKEND, GraphicsAPI.DEFAULT); + + /// Returns the graphics API property. + public InheritableProperty graphicsBackendProperty() { + return graphicsBackend; + } + + /// Property name for the OpenGL renderer. + public static final String PROPERTY_OPENGL_RENDERER = "openGLRenderer"; + + /// The OpenGL renderer used by the game. + @SerializedName(PROPERTY_OPENGL_RENDERER) + private final InheritableProperty openGLRenderer = + newInheritableProperty(PROPERTY_OPENGL_RENDERER, Renderer.DEFAULT); + + /// Returns the OpenGL renderer property. + public InheritableProperty openGLRendererProperty() { + return openGLRenderer; + } + + /// Property name for the Vulkan renderer. + public static final String PROPERTY_VULKAN_RENDERER = "vulkanRenderer"; + + /// The Vulkan renderer used by the game. + @SerializedName(PROPERTY_VULKAN_RENDERER) + private final InheritableProperty vulkanRenderer = + newInheritableProperty(PROPERTY_VULKAN_RENDERER, Renderer.DEFAULT); + + /// Returns the Vulkan renderer property. + public InheritableProperty vulkanRendererProperty() { + return vulkanRenderer; + } + + /// Property name for customized environment variables. + public static final String PROPERTY_ENVIRONMENT_VARIABLES = "environmentVariables"; + + /// User customized environment variables passed to the game. + @SerializedName(PROPERTY_ENVIRONMENT_VARIABLES) + private final SettingProperty environmentVariables = newSettingProperty(PROPERTY_ENVIRONMENT_VARIABLES, ""); + + /// Returns the customized environment variables property. + public SettingProperty environmentVariablesProperty() { + return environmentVariables; + } + + /// Property name for the command wrapper. + public static final String PROPERTY_COMMAND_WRAPPER = "commandWrapper"; + + /// The command wrapper for launching Minecraft. + @SerializedName(PROPERTY_COMMAND_WRAPPER) + private final InheritableProperty commandWrapper = newInheritableProperty(PROPERTY_COMMAND_WRAPPER, ""); + + /// Returns the command wrapper property. + public InheritableProperty commandWrapperProperty() { + return commandWrapper; + } + + /// Property name for the pre-launch command. + public static final String PROPERTY_PRE_LAUNCH_COMMAND = "preLaunchCommand"; + + /// The command that will be executed before launching the game. + @SerializedName(PROPERTY_PRE_LAUNCH_COMMAND) + private final InheritableProperty preLaunchCommand = newInheritableProperty(PROPERTY_PRE_LAUNCH_COMMAND, ""); + + /// Returns the pre-launch command property. + public InheritableProperty preLaunchCommandProperty() { + return preLaunchCommand; + } + + /// Property name for the post-exit command. + public static final String PROPERTY_POST_EXIT_COMMAND = "postExitCommand"; + + /// The command that will be executed after the game exits. + @SerializedName(PROPERTY_POST_EXIT_COMMAND) + private final InheritableProperty postExitCommand = newInheritableProperty(PROPERTY_POST_EXIT_COMMAND, ""); + + /// Returns the post-exit command property. + public InheritableProperty postExitCommandProperty() { + return postExitCommand; + } + + /// Property name for the quick play target type. + public static final String PROPERTY_QUICK_PLAY = "quickPlay"; + + /// The quick play target type. + @SerializedName(PROPERTY_QUICK_PLAY) + private final InheritableProperty quickPlay = newInheritableProperty(PROPERTY_QUICK_PLAY, QuickPlayType.NONE); + + /// Returns the quick play target type property. + public InheritableProperty quickPlayProperty() { + return quickPlay; + } + + /// Property name for the multiplayer quick play target. + public static final String PROPERTY_QUICK_PLAY_MULTIPLAYER = "quickPlayMultiplayer"; + + /// The server address for multiplayer quick play. + @SerializedName(PROPERTY_QUICK_PLAY_MULTIPLAYER) + private final InheritableProperty quickPlayMultiplayer = newInheritableProperty(PROPERTY_QUICK_PLAY_MULTIPLAYER, ""); + + /// Returns the multiplayer quick play target property. + public InheritableProperty quickPlayMultiplayerProperty() { + return quickPlayMultiplayer; + } + + /// Property name for the singleplayer quick play target. + public static final String PROPERTY_QUICK_PLAY_SINGLEPLAYER = "quickPlaySingleplayer"; + + /// The world folder name for singleplayer quick play. + @SerializedName(PROPERTY_QUICK_PLAY_SINGLEPLAYER) + private final InheritableProperty quickPlaySingleplayer = newInheritableProperty(PROPERTY_QUICK_PLAY_SINGLEPLAYER, ""); + + /// Returns the singleplayer quick play target property. + public InheritableProperty quickPlaySingleplayerProperty() { + return quickPlaySingleplayer; + } + + /// Property name for the Realms quick play target. + public static final String PROPERTY_QUICK_PLAY_REALMS = "quickPlayRealms"; + + /// The realm ID for Realms quick play. + @SerializedName(PROPERTY_QUICK_PLAY_REALMS) + private final InheritableProperty quickPlayRealms = newInheritableProperty(PROPERTY_QUICK_PLAY_REALMS, ""); + + /// Returns the Realms quick play target property. + public InheritableProperty quickPlayRealmsProperty() { + return quickPlayRealms; + } + + /// Property name for showing logs after game start. + public static final String PROPERTY_SHOW_LOGS = "showLogs"; + + /// If `true`, show the logs after game launched. + @SerializedName(PROPERTY_SHOW_LOGS) + private final InheritableProperty showLogs = newInheritableProperty(PROPERTY_SHOW_LOGS, false); + + /// Returns the show logs property. + public InheritableProperty showLogsProperty() { + return showLogs; + } + + /// Property name for enabling debug log output. + public static final String PROPERTY_ENABLE_DEBUG_LOG_OUTPUT = "enableDebugLogOutput"; + + /// If `true`, enable debug log output. + @SerializedName(PROPERTY_ENABLE_DEBUG_LOG_OUTPUT) + private final InheritableProperty enableDebugLogOutput = newInheritableProperty(PROPERTY_ENABLE_DEBUG_LOG_OUTPUT, false); + + /// Returns the debug log output property. + public InheritableProperty enableDebugLogOutputProperty() { + return enableDebugLogOutput; + } + + /// Property name for disabling native library patching. + public static final String PROPERTY_NOT_PATCH_NATIVES = "notPatchNatives"; + + /// If `true`, HMCL does not patch native libraries. + @SerializedName(PROPERTY_NOT_PATCH_NATIVES) + private final SettingProperty notPatchNatives = newSettingProperty(PROPERTY_NOT_PATCH_NATIVES, false); + + /// Returns the native library patching property. + public SettingProperty notPatchNativesProperty() { + return notPatchNatives; + } + + /// Property name for the native library directory mode. + public static final String PROPERTY_NATIVES_DIR_TYPE = "nativesDirType"; + + /// The native library directory mode. + @SerializedName(PROPERTY_NATIVES_DIR_TYPE) + private final SettingProperty nativesDirType = newSettingProperty(PROPERTY_NATIVES_DIR_TYPE, NativesDirectoryType.VERSION_FOLDER); + + /// Returns the native library directory mode property. + public SettingProperty nativesDirTypeProperty() { + return nativesDirType; + } + + /// Property name for the native library directory path. + public static final String PROPERTY_NATIVES_DIR = "nativesDir"; + + /// The path to the native library directory. + @SerializedName(PROPERTY_NATIVES_DIR) + private final SettingProperty nativesDir = newSettingProperty(PROPERTY_NATIVES_DIR, ""); + + /// Returns the native library directory property. + public SettingProperty nativesDirProperty() { + return nativesDir; + } + + /// Property name for using native GLFW. + public static final String PROPERTY_USE_NATIVE_GLFW = "useNativeGLFW"; + + /// If `true`, HMCL will use native GLFW. + @SerializedName(PROPERTY_USE_NATIVE_GLFW) + private final SettingProperty useNativeGLFW = newSettingProperty(PROPERTY_USE_NATIVE_GLFW, false); + + /// Returns the native GLFW property. + public SettingProperty useNativeGLFWProperty() { + return useNativeGLFW; + } + + /// Property name for using native OpenAL. + public static final String PROPERTY_USE_NATIVE_OPENAL = "useNativeOpenAL"; + + /// If `true`, HMCL will use native OpenAL. + @SerializedName(PROPERTY_USE_NATIVE_OPENAL) + private final SettingProperty useNativeOpenAL = newSettingProperty(PROPERTY_USE_NATIVE_OPENAL, false); + + /// Returns the native OpenAL property. + public SettingProperty useNativeOpenALProperty() { + return useNativeOpenAL; + } + + static void setRendererForApi(GameSettings setting, Renderer renderer, @Nullable GraphicsAPI fallbackApi) { + if (renderer instanceof Renderer.Driver driver) { + rendererPropertyForApi(setting, driver.api()).setValue(renderer); + if (fallbackApi == null || fallbackApi == GraphicsAPI.DEFAULT) { + setting.graphicsBackendProperty().setValue(driver.api()); + } + return; + } + + if (fallbackApi == GraphicsAPI.OPENGL || fallbackApi == GraphicsAPI.VULKAN) { + rendererPropertyForApi(setting, fallbackApi).setValue(renderer); + } else { + setting.openGLRendererProperty().setValue(renderer); + setting.vulkanRendererProperty().setValue(renderer); + } + } + + private static InheritableProperty rendererPropertyForApi(GameSettings setting, GraphicsAPI api) { + return switch (api) { + case OPENGL -> setting.openGLRendererProperty(); + case VULKAN -> setting.vulkanRendererProperty(); + case DEFAULT -> throw new IllegalArgumentException("The default graphics API has no renderer property"); + }; + } + + private static Renderer selectRenderer(GraphicsAPI api, @Nullable Renderer renderer) { + if (renderer instanceof Renderer.Driver driver && driver.api() != api) { + return Renderer.DEFAULT; + } + + return renderer != null ? renderer : Renderer.DEFAULT; + } + + /// Resolves a preset and an optional instance setting into launch-time values. + public static Effective resolve(Preset preset, @Nullable Instance instance) { + return new Effective(preset, instance); + } + + private static T inherited(Preset preset, @Nullable Instance instance, Function> propertyGetter) { + if (instance != null) { + SettingProperty property = propertyGetter.apply(instance); + if (isOverridden(instance, property)) { + return property.getValue(); + } + } + return propertyGetter.apply(preset).getValue(); + } + + private static T inheritable(Preset preset, @Nullable Instance instance, Function> propertyGetter) { + if (instance != null) { + InheritableProperty property = propertyGetter.apply(instance); + if (isOverridden(instance, property)) { + return getDirectValue(property); + } + } + + return getDirectValue(propertyGetter.apply(preset)); + } + + private static boolean isOverridden(Instance instance, SettingProperty property) { + return instance.getOverrideProperties().contains(property.getName()); + } + + private static T getDirectValue(SettingProperty property) { + T value = property.getValue(); + return value != null ? value : property.defaultValue(); + } + + /// Launch-time effective game setting. + public static final class Effective { + private final Preset preset; + private final @Nullable Instance instance; + + private Effective(Preset preset, @Nullable Instance instance) { + this.preset = Objects.requireNonNull(preset); + this.instance = instance; + } + + /// Returns the parent preset. + public Preset getPreset() { + return preset; + } + + /// Returns the instance setting, if one exists. + public @Nullable Instance getInstance() { + return instance; + } + + /// Returns the effective Java selection mode. + public JavaVersionType getJavaVersionType() { + return inheritable(preset, instance, GameSettings::javaTypeProperty); + } + + /// Returns the effective Java version text. + public String getJavaVersion() { + if (instance != null && isOverridden(instance, instance.javaTypeProperty())) { + return instance.javaVersionProperty().getValue(); + } + return preset.javaVersionProperty().getValue(); + } + + /// Returns the effective custom Java executable path. + public String getCustomJavaPath() { + if (instance != null && isOverridden(instance, instance.javaTypeProperty())) { + return Objects.requireNonNullElse(instance.customJavaPathProperty().getValue(), ""); + } + return Objects.requireNonNullElse(preset.customJavaPathProperty().getValue(), ""); + } + + /// Returns the effective default Java executable path. + public String getDefaultJavaPath() { + if (instance != null && isOverridden(instance, instance.javaTypeProperty())) { + return Objects.requireNonNullElse(instance.defaultJavaPathProperty().getValue(), ""); + } + return Objects.requireNonNullElse(preset.defaultJavaPathProperty().getValue(), ""); + } + + /// Switches the effective Java selection back to automatic mode. + public void setJavaAutoSelected() { + GameSettings target = instance != null && isOverridden(instance, instance.javaTypeProperty()) ? instance : preset; + target.javaTypeProperty().setValue(JavaVersionType.AUTO); + target.javaVersionProperty().setValue(""); + target.customJavaPathProperty().setValue(""); + target.defaultJavaPathProperty().setValue(""); + } + + /// Finds the effective Java runtime. + public @Nullable JavaRuntime getJava(@Nullable GameVersionNumber gameVersion, @Nullable Version version) throws InterruptedException { + switch (getJavaVersionType()) { + case DEFAULT: + return JavaRuntime.getDefault(); + case AUTO: + return JavaManager.findSuitableJava(gameVersion, version); + case CUSTOM: + try { + return JavaManager.getJava(Path.of(getCustomJavaPath())); + } catch (IOException | InvalidPathException e) { + return null; + } + case VERSION: { + String javaVersion = getJavaVersion(); + if (StringUtils.isBlank(javaVersion)) { + return JavaManager.findSuitableJava(gameVersion, version); + } + + int majorVersion = -1; + try { + majorVersion = Integer.parseInt(javaVersion); + } catch (NumberFormatException ignored) { + } + + if (majorVersion < 0) { + LOG.warning("Invalid Java version: " + javaVersion); + return null; + } + + final int finalMajorVersion = majorVersion; + Collection allJava = JavaManager.getAllJava().stream() + .filter(it -> it.getParsedVersion() == finalMajorVersion) + .collect(Collectors.toList()); + return JavaManager.findSuitableJava(allJava, gameVersion, version); + } + case DETECTED: { + String javaVersion = getJavaVersion(); + if (StringUtils.isBlank(javaVersion)) { + return JavaManager.findSuitableJava(gameVersion, version); + } + + try { + String defaultJavaPath = getDefaultJavaPath(); + if (StringUtils.isNotBlank(defaultJavaPath)) { + JavaRuntime java = JavaManager.getJava(Path.of(defaultJavaPath).toRealPath()); + if (java.getVersion().equals(javaVersion)) { + return java; + } + } + } catch (IOException | InvalidPathException ignored) { + } + + for (JavaRuntime java : JavaManager.getAllJava()) { + if (java.getVersion().equals(javaVersion)) { + return java; + } + } + + return null; + } + default: + throw new AssertionError("Java Type: " + getJavaVersionType()); + } + } + + /// Returns the effective JVM option text. + public String getJVMOptions() { + return Objects.requireNonNullElse(inherited(preset, instance, GameSettings::jvmOptionsProperty), ""); + } + + /// Returns whether generated JVM options are disabled. + public boolean isNoJVMOptions() { + return inheritable(preset, instance, GameSettings::noJVMOptionsProperty); + } + + /// Returns whether generated optimizing JVM options are disabled. + public boolean isNoOptimizingJVMOptions() { + return inheritable(preset, instance, GameSettings::noOptimizingJVMOptionsProperty); + } + + /// Returns whether JVM validity checks are disabled. + public boolean isNotCheckJVM() { + return inheritable(preset, instance, GameSettings::notCheckJVMProperty); + } + + /// Returns whether game completeness checks are disabled. + public boolean isNotCheckGame() { + return inheritable(preset, instance, GameSettings::notCheckGameProperty); + } + + /// Returns whether automatic memory allocation is enabled. + public boolean isAutoMemory() { + return inherited(preset, instance, GameSettings::autoMemoryProperty); + } + + /// Returns the effective minimum heap memory in MiB. + public @Nullable Integer getMinMemory() { + return inherited(preset, instance, GameSettings::minMemoryProperty); + } + + /// Returns the effective maximum heap memory in MiB. + public int getMaxMemory() { + Integer value = inherited(preset, instance, GameSettings::maxMemoryProperty); + return value != null && value > 0 ? value : SUGGESTED_MEMORY; + } + + /// Returns the effective permanent generation or metaspace size text. + public String getPermSize() { + return Objects.requireNonNullElse(inherited(preset, instance, GameSettings::permSizeProperty), ""); + } + + /// Returns the effective game window mode. + public GameWindowType getWindowType() { + return inheritable(preset, instance, GameSettings::windowTypeProperty); + } + + /// Returns the effective game window width. + public int getWidth() { + return Math.max(0, Lang.parseInt(String.valueOf(Math.round(inheritable(preset, instance, GameSettings::widthProperty))), 0)); + } + + /// Returns the effective game window height. + public int getHeight() { + return Math.max(0, Lang.parseInt(String.valueOf(Math.round(inheritable(preset, instance, GameSettings::heightProperty))), 0)); + } + + /// Returns the effective custom run directory. + public String getRunningDir() { + return Objects.requireNonNullElse(inheritable(preset, instance, GameSettings::runningDirProperty), ""); + } + + /// Returns the effective process priority. + public ProcessPriority getProcessPriority() { + return inheritable(preset, instance, GameSettings::processPriorityProperty); + } + + /// Returns the effective launcher visibility. + public LauncherVisibility getLauncherVisibility() { + return inheritable(preset, instance, GameSettings::launcherVisibilityProperty); + } + + /// Returns whether HMCL may attach Java agents to improve the game experience. + public boolean isAllowAutoAgent() { + return inheritable(preset, instance, GameSettings::allowAutoAgentProperty); + } + + /// Returns whether automatic game options generation is disabled. + public boolean isDisableAutoGameOptions() { + return inheritable(preset, instance, GameSettings::disableAutoGameOptionsProperty); + } + + /// Returns the effective game arguments. + public String getGameArgs() { + return Objects.requireNonNullElse(inherited(preset, instance, GameSettings::gameArgsProperty), ""); + } + + /// Returns the effective graphics API. + public GraphicsAPI getGraphicsBackend() { + return inheritable(preset, instance, GameSettings::graphicsBackendProperty); + } + + /// Returns the effective OpenGL renderer. + public Renderer getOpenGLRenderer() { + return selectRenderer(GraphicsAPI.OPENGL, inheritable(preset, instance, GameSettings::openGLRendererProperty)); + } + + /// Returns the effective Vulkan renderer. + public Renderer getVulkanRenderer() { + return selectRenderer(GraphicsAPI.VULKAN, inheritable(preset, instance, GameSettings::vulkanRendererProperty)); + } + + /// Returns the effective renderer. + public Renderer getRenderer() { + return switch (getGraphicsBackend()) { + case OPENGL -> getOpenGLRenderer(); + case VULKAN -> getVulkanRenderer(); + case DEFAULT -> Renderer.DEFAULT; + }; + } + + /// Returns the effective environment variables. + public String getEnvironmentVariables() { + return Objects.requireNonNullElse(inherited(preset, instance, GameSettings::environmentVariablesProperty), ""); + } + + /// Returns the effective command wrapper. + public String getCommandWrapper() { + return Objects.requireNonNullElse(inheritable(preset, instance, GameSettings::commandWrapperProperty), ""); + } + + /// Returns the effective pre-launch command. + public String getPreLaunchCommand() { + return Objects.requireNonNullElse(inheritable(preset, instance, GameSettings::preLaunchCommandProperty), ""); + } + + /// Returns the effective post-exit command. + public String getPostExitCommand() { + return Objects.requireNonNullElse(inheritable(preset, instance, GameSettings::postExitCommandProperty), ""); + } + + /// Returns the effective quick play type. + public QuickPlayType getQuickPlay() { + return inheritable(preset, instance, GameSettings::quickPlayProperty); + } + + /// Returns the effective quick play option. + public @Nullable QuickPlayOption getQuickPlayOption() { + return switch (getQuickPlay()) { + case NONE -> null; + case MULTIPLAYER -> { + String server = Objects.requireNonNullElse(inheritable(preset, instance, GameSettings::quickPlayMultiplayerProperty), ""); + yield StringUtils.isBlank(server) ? null : new QuickPlayOption.MultiPlayer(server); + } + case SINGLEPLAYER -> { + String world = Objects.requireNonNullElse(inheritable(preset, instance, GameSettings::quickPlaySingleplayerProperty), ""); + yield StringUtils.isBlank(world) ? null : new QuickPlayOption.SinglePlayer(world); + } + case REALMS -> { + String realm = Objects.requireNonNullElse(inheritable(preset, instance, GameSettings::quickPlayRealmsProperty), ""); + yield StringUtils.isBlank(realm) ? null : new QuickPlayOption.Realm(realm); + } + }; + } + + /// Returns whether logs should be shown after launch. + public boolean isShowLogs() { + return inheritable(preset, instance, GameSettings::showLogsProperty); + } + + /// Returns whether debug log output is enabled. + public boolean isEnableDebugLogOutput() { + return inheritable(preset, instance, GameSettings::enableDebugLogOutputProperty); + } + + /// Returns whether native library patching is disabled. + public boolean isNotPatchNatives() { + return inherited(preset, instance, GameSettings::notPatchNativesProperty); + } + + /// Returns the effective native directory mode. + public NativesDirectoryType getNativesDirType() { + return inherited(preset, instance, GameSettings::nativesDirTypeProperty); + } + + /// Returns the effective native directory. + public String getNativesDir() { + return Objects.requireNonNullElse(inherited(preset, instance, GameSettings::nativesDirProperty), ""); + } + + /// Returns whether native GLFW should be used. + public boolean isUseNativeGLFW() { + return inherited(preset, instance, GameSettings::useNativeGLFWProperty); + } + + /// Returns whether native OpenAL should be used. + public boolean isUseNativeOpenAL() { + return inherited(preset, instance, GameSettings::useNativeOpenALProperty); + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameSettingsPresets.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameSettingsPresets.java new file mode 100644 index 00000000000..b3c04c23e53 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameSettingsPresets.java @@ -0,0 +1,142 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.setting; + +import com.github.f4b6a3.uuid.alt.GUID; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +import javafx.beans.Observable; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import org.jackhuang.hmcl.util.gson.JsonSchema; +import org.jackhuang.hmcl.util.gson.JsonSerializable; +import org.jackhuang.hmcl.util.gson.ObservableSetting; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.lang.reflect.Type; +import java.util.Objects; + +/// Stores reusable game settings presets independently from the main config file. +/// +/// The JSON representation is saved as `game-settings.json` under the current HMCL +/// directory. +/// +/// @author Glavo +@JsonAdapter(GameSettingsPresets.Adapter.class) +@NotNullByDefault +@JsonSerializable +public final class GameSettingsPresets extends ObservableSetting implements JsonSchemaSetting { + /// The JSON schema supported by this game settings preset store. + public static final JsonSchema CURRENT_SCHEMA = + new JsonSchema("game-settings", new JsonSchema.Version(1, 0, 0)); + + /// Creates an empty game settings preset store. + public GameSettingsPresets() { + tracker.markDirty(schema); + register(); + } + + /// Copies another preset store into this instance. + void copyFrom(GameSettingsPresets source) { + if (source == this) { + return; + } + + presets.setAll(source.getPresets()); + } + + /// The schema used by this game settings preset store file. + @SerializedName(JsonSchema.PROPERTY_SCHEMA) + private final ObjectProperty schema = new SimpleObjectProperty<>(CURRENT_SCHEMA); + + /// Returns the schema property. + public ObjectProperty schemaProperty() { + return schema; + } + + /// Returns the schema used by this game settings preset store file. + public JsonSchema getSchema() { + return schema.get(); + } + + /// Sets the schema used by this game settings preset store file. + public void setSchema(JsonSchema schema) { + this.schema.set(Objects.requireNonNull(schema)); + } + + /// Reusable game setting presets. + @SerializedName("presets") + private final ObservableList presets = + FXCollections.observableArrayList(setting -> new Observable[] { setting }); + + /// Returns the reusable game setting presets. + public ObservableList getPresets() { + return presets; + } + + /// Creates a preset ID that does not collide with existing presets. + public GUID newPresetId() { + GUID id; + do { + id = GUID.v7(); + } while (getPreset(id) != null); + return id; + } + + /// Returns the preset with the given ID. + public GameSettings.@Nullable Preset getPreset(@Nullable GUID id) { + if (id == null) { + return null; + } + + for (GameSettings.Preset setting : presets) { + if (id.equals(setting.idProperty().getValue())) { + return setting; + } + } + return null; + } + + /// JSON adapter for [GameSettingsPresets]. + public static final class Adapter extends ObservableSetting.Adapter { + /// Creates an empty preset store for deserialization. + @Override + protected GameSettingsPresets createInstance() { + return new GameSettingsPresets(); + } + + /// Deserializes presets and drops the workspace-level default preset selection. + @Override + public @Nullable GameSettingsPresets deserialize( + JsonElement json, + Type typeOfT, + JsonDeserializationContext context) throws JsonParseException { + @Nullable GameSettingsPresets result = super.deserialize(json, typeOfT, context); + if (result != null) { + result.unknownFields.remove(LauncherSettings.PROPERTY_DEFAULT_GAME_SETTINGS_PRESET); + } + return result; + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameWindowType.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameWindowType.java new file mode 100644 index 00000000000..d46126d0150 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameWindowType.java @@ -0,0 +1,32 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.setting; + +/// The initial display mode of the game window. +/// +/// @author Glavo +public enum GameWindowType { + /// Start the game in a normal window. + WINDOWED, + + /// Start the game in fullscreen mode. + FULLSCREEN, + + /// Start the game in a maximized window. + MAXIMIZED, +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/JsonSchemaPolicy.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/JsonSchemaPolicy.java new file mode 100644 index 00000000000..f7366165683 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/JsonSchemaPolicy.java @@ -0,0 +1,94 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.setting; + +import com.google.gson.JsonObject; +import org.jackhuang.hmcl.util.gson.JsonSchema; +import org.jetbrains.annotations.NotNullByDefault; + +import java.nio.file.Path; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// Applies the common compatibility policy for JSON files with a [JsonSchema] marker. +/// +/// @author Glavo +@NotNullByDefault +final class JsonSchemaPolicy { + /// Prevents instantiation. + private JsonSchemaPolicy() { + } + + /// Checks the schema marker of a JSON file. + /// + /// @param location the file location used in logs + /// @param displayName the human-readable file type name used in logs + /// @param object the JSON object that contains the schema marker + /// @param expected the JSON schema supported by the current code + /// @return the compatibility result + static Result check(Path location, String displayName, JsonObject object, JsonSchema expected) { + JsonSchema.CheckResult schema = JsonSchema.check(object, expected); + if (schema.isMissing()) { + LOG.warning("Missing schema in " + displayName + ": " + location); + return Result.UNREADABLE; + } else if (schema.isInvalid()) { + LOG.warning("Invalid schema in " + displayName + ": " + + location + ", Actual: " + schema.invalidValue()); + return Result.UNREADABLE; + } else if (schema.isUnparseable()) { + LOG.warning("Unparseable schema in " + displayName + ": " + + location + ", Actual: " + schema.actual()); + return Result.UNREADABLE; + } else if (schema.isUnexpectedId()) { + LOG.warning("Unexpected " + displayName + " schema. Expected: " + + expected + ", Actual: " + schema.actual()); + return Result.UNREADABLE; + } else if (schema.hasUnsupportedMajorVersion()) { + LOG.warning("Unsupported " + displayName + " schema. Expected: " + + expected + ", Actual: " + schema.actual()); + return Result.UNREADABLE; + } else if (schema.hasNewerMinorVersion()) { + LOG.warning("Unsupported " + displayName + " schema. Expected: " + + expected + ", Actual: " + schema.actual()); + return Result.READ_ONLY_PRESERVE_SCHEMA; + } else if (schema.hasSameMajorAndMinorVersion()) { + return Result.READ_WRITE_PRESERVE_SCHEMA; + } else { + return Result.READ_WRITE; + } + } + + /// Result of checking whether a JSON file can be read and safely saved. + /// + /// @param readable whether the file may be deserialized + /// @param allowSave whether the file may be overwritten + /// @param preserveSchema whether saving should keep the original schema value + record Result(boolean readable, boolean allowSave, boolean preserveSchema) { + /// Result used when a file is compatible, may be saved, and should be upgraded to the expected schema. + private static final Result READ_WRITE = new Result(true, true, false); + + /// Result used when a file is compatible, may be saved, and should preserve the original schema and unknown members. + private static final Result READ_WRITE_PRESERVE_SCHEMA = new Result(true, true, true); + + /// Result used when a file is readable but must not be overwritten. + private static final Result READ_ONLY_PRESERVE_SCHEMA = new Result(true, false, true); + + /// Result used when a file must not be read or overwritten. + private static final Result UNREADABLE = new Result(false, false, false); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/JsonSchemaSetting.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/JsonSchemaSetting.java new file mode 100644 index 00000000000..3b61c78007b --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/JsonSchemaSetting.java @@ -0,0 +1,35 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.setting; + +import org.jackhuang.hmcl.util.gson.JsonSchema; +import org.jetbrains.annotations.NotNullByDefault; + +/// Marks a detached settings object that stores its own JSON schema URL. +/// +/// @author Glavo +@NotNullByDefault +interface JsonSchemaSetting { + /// Returns the schema used by this JSON settings file. + JsonSchema getSchema(); + + /// Sets the schema used by this JSON settings file. + /// + /// @param schema the schema to store + void setSchema(JsonSchema schema); +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/JsonSettingFile.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/JsonSettingFile.java new file mode 100644 index 00000000000..c0fc21f0621 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/JsonSettingFile.java @@ -0,0 +1,138 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.setting; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import org.jackhuang.hmcl.util.FileSaver; +import org.jackhuang.hmcl.util.gson.JsonSchema; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.gson.ObservableSetting; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; +import java.util.function.Supplier; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// Loads, saves, and validates a detached JSON settings file with a [JsonSchema] marker. +/// +/// @param the settings object type +/// @author Glavo +@NotNullByDefault +final class JsonSettingFile { + /// The settings file location. + private final Path location; + + /// The human-readable file type name used in logs. + private final String displayName; + + /// The settings object type. + private final Class type; + + /// The JSON schema supported by the current code. + private final JsonSchema expectedSchema; + + /// Creates a default settings object. + private final Supplier createDefault; + + /// Creates a detached JSON settings file helper. + /// + /// @param location the settings file location + /// @param displayName the human-readable file type name used in logs + /// @param type the settings object type + /// @param expectedSchema the JSON schema supported by the current code + /// @param createDefault creates a default settings object + JsonSettingFile( + Path location, + String displayName, + Class type, + JsonSchema expectedSchema, + Supplier createDefault) { + this.location = Objects.requireNonNull(location); + this.displayName = Objects.requireNonNull(displayName); + this.type = Objects.requireNonNull(type); + this.expectedSchema = Objects.requireNonNull(expectedSchema); + this.createDefault = Objects.requireNonNull(createDefault); + } + + /// Loads the settings file, falling back to migrated data or a default object when absent. + /// + /// @param migrated migrated settings data used when the file is absent + /// @return the loaded settings object and whether it may be saved + /// @throws IOException if reading the file fails + LoadResult load(@Nullable T migrated) throws IOException { + if (Files.exists(location)) { + try { + JsonObject jsonObject = JsonUtils.fromJsonFile(location, JsonObject.class); + if (jsonObject == null) { + LOG.info(displayName + " are empty: " + location); + } else { + JsonSchemaPolicy.Result schema = + JsonSchemaPolicy.check(location, displayName, jsonObject, expectedSchema); + if (!schema.readable()) { + return new LoadResult<>(createDefault.get(), false); + } + + @Nullable T deserialized = JsonUtils.GSON.fromJson(jsonObject, type); + if (deserialized != null) { + // Patch-compatible files keep their original schema because unknown members are preserved. + if (!schema.preserveSchema() && !expectedSchema.equals(deserialized.getSchema())) { + deserialized.setSchema(expectedSchema); + } + + return new LoadResult<>(deserialized, schema.allowSave()); + } + + LOG.info(displayName + " are empty: " + location); + } + } catch (JsonParseException e) { + LOG.warning("Malformed " + displayName + ".", e); + } + + return new LoadResult<>(createDefault.get(), true); + } + + return new LoadResult<>(migrated != null ? migrated : createDefault.get(), true); + } + + /// Installs an automatic save listener on a settings object. + /// + /// @param value the settings object to observe + void installAutoSave(T value) { + value.addListener(source -> save(value)); + } + + /// Saves a settings object. + /// + /// @param value the settings object to save + void save(T value) { + FileSaver.save(location, JsonUtils.GSON.toJson(value, type)); + } + + /// Result of loading a detached JSON settings file. + /// + /// @param value the loaded settings object + /// @param allowSave whether the file may be overwritten + record LoadResult(T value, boolean allowSave) { + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/LauncherSettings.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/LauncherSettings.java new file mode 100644 index 00000000000..1e1bab51758 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/LauncherSettings.java @@ -0,0 +1,493 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2021 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.setting; + +import com.github.f4b6a3.uuid.alt.GUID; +import com.google.gson.*; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +import javafx.beans.property.*; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.collections.ObservableMap; +import javafx.collections.ObservableSet; +import javafx.scene.paint.Paint; +import org.hildan.fxgson.creators.ObservableListCreator; +import org.hildan.fxgson.creators.ObservableMapCreator; +import org.hildan.fxgson.creators.ObservableSetCreator; +import org.hildan.fxgson.factories.JavaFxPropertyTypeAdapterFactory; +import org.jackhuang.hmcl.Metadata; +import org.jackhuang.hmcl.java.JavaRuntime; +import org.jackhuang.hmcl.theme.ThemeColor; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.gson.*; +import org.jackhuang.hmcl.util.i18n.SupportedLocale; +import org.jetbrains.annotations.Nullable; + +import java.net.Proxy; +import java.nio.file.Path; +import java.util.*; + +/// Stores the current workspace's main launcher settings. +/// +/// This file keeps launcher-level choices such as UI preferences, network settings, selected game directory, +/// selected instances, and account selection. Larger domain-specific stores, such as game directories, +/// game settings presets, accounts, launcher state, and authlib-injector servers, are persisted in detached +/// JSON files managed by [SettingsManager]. +@JsonAdapter(value = LauncherSettings.Adapter.class) +public final class LauncherSettings extends ObservableSetting { + + /// The JSON schema supported by this launcher settings class. + public static final JsonSchema CURRENT_SCHEMA = new JsonSchema("settings", new JsonSchema.Version(1, 0, 0)); + + /// The JSON property name for the default game setting preset ID. + static final String PROPERTY_DEFAULT_GAME_SETTINGS_PRESET = "defaultGameSettingsPreset"; + + /// The JSON property name for the selected game directory ID. + static final String PROPERTY_SELECTED_GAME_DIRECTORY = "selectedGameDirectory"; + + /// The JSON property name for selected instance IDs keyed by game directory ID. + static final String PROPERTY_SELECTED_INSTANCE = "selectedInstance"; + + /// Gson instance used for launcher settings and related settings objects that depend on JavaFX properties. + public static final Gson SETTINGS_GSON = new GsonBuilder() + .registerTypeAdapter(Path.class, PathTypeAdapter.INSTANCE) + .registerTypeAdapter(UUID.class, UUIDTypeAdapter.INSTANCE) + .registerTypeAdapter(GUID.class, GUIDTypeAdapter.INSTANCE) + .registerTypeAdapter(ObservableList.class, new ObservableListCreator()) + .registerTypeAdapter(ObservableSet.class, new ObservableSetCreator()) + .registerTypeAdapter(ObservableMap.class, new ObservableMapCreator()) + .registerTypeAdapterFactory(new JavaFxPropertyTypeAdapterFactory(true, true)) + .registerTypeAdapter(Paint.class, new PaintAdapter()) + .setPrettyPrinting() + .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) + .create(); + + /// Deserializes launcher settings from JSON. + /// + /// @param json the JSON object to read + /// @return the deserialized launcher settings, or `null` if the JSON represents `null` + /// @throws JsonParseException if the JSON cannot be deserialized as launcher settings + @Nullable + public static LauncherSettings fromJson(JsonObject json) throws JsonParseException { + return SETTINGS_GSON.fromJson(json, LauncherSettings.class); + } + + /// Creates empty launcher settings using current defaults. + public LauncherSettings() { + tracker.markDirty(schema); + register(); + } + + /// Serializes these launcher settings to formatted JSON. + public String toJson() { + return SETTINGS_GSON.toJson(this); + } + + // Properties + + /// The schema used by this launcher settings file. + @SerializedName(JsonSchema.PROPERTY_SCHEMA) + private final ObjectProperty schema = new SimpleObjectProperty<>(CURRENT_SCHEMA); + + /// Returns the schema property. + public ObjectProperty schemaProperty() { + return schema; + } + + /// The launcher UI language. + @SerializedName("language") + private final ObjectProperty language = new SimpleObjectProperty<>(SupportedLocale.DEFAULT); + + /// Returns the launcher UI language property. + public ObjectProperty languageProperty() { + return language; + } + + /// Whether preview builds are accepted by update checks. + @SerializedName("acceptPreviewUpdate") + private final BooleanProperty acceptPreviewUpdate = new SimpleBooleanProperty(false); + + /// Returns the preview update opt-in property. + public BooleanProperty acceptPreviewUpdateProperty() { + return acceptPreviewUpdate; + } + + /// Whether automatic update dialogs are disabled. + @SerializedName("disableAutoShowUpdateDialog") + private final BooleanProperty disableAutoShowUpdateDialog = new SimpleBooleanProperty(false); + + /// Returns the automatic update dialog disable property. + public BooleanProperty disableAutoShowUpdateDialogProperty() { + return disableAutoShowUpdateDialog; + } + + /// Whether April Fools features are disabled. + @SerializedName("disableAprilFools") + private final BooleanProperty disableAprilFools = new SimpleBooleanProperty(false); + + /// Returns the April Fools disable property. + public BooleanProperty disableAprilFoolsProperty() { + return disableAprilFools; + } + + /// The common Minecraft directory selection mode. + @SerializedName("commonDirectoryType") + private final ObjectProperty commonDirectoryType = new RawPreservingObjectProperty<>(EnumCommonDirectory.DEFAULT); + + /// Returns the common Minecraft directory selection mode property. + public ObjectProperty commonDirectoryTypeProperty() { + return commonDirectoryType; + } + + /// The custom common Minecraft directory path. + @SerializedName("commonDirectory") + private final StringProperty commonDirectory = new SimpleStringProperty(); + + /// Returns the custom common Minecraft directory property. + public StringProperty commonDirectoryProperty() { + return commonDirectory; + } + + /// Returns the default common Minecraft directory path. + public static String getDefaultCommonDirectory() { + return Metadata.MINECRAFT_DIRECTORY.toString(); + } + + /// Resolves the effective common Minecraft directory from the current directory settings. + /// + /// @return the effective directory path, or `null` when the configured mode is not recognized + public String getResolvedCommonDirectory() { + EnumCommonDirectory type = commonDirectoryType.get(); + String customPath = commonDirectory.get(); + + return type == EnumCommonDirectory.CUSTOM && StringUtils.isNotBlank(customPath) + ? customPath + : getDefaultCommonDirectory(); + } + + /// The maximum number of log lines kept in log views. + @SerializedName("logLines") + private final ObjectProperty<@Nullable Integer> logLines = new SimpleObjectProperty<>(); + + /// Returns the log line limit property. + public ObjectProperty<@Nullable Integer> logLinesProperty() { + return logLines; + } + + // UI + + /// The configured theme brightness identifier. + @SerializedName("themeBrightness") + private final StringProperty themeBrightness = new SimpleStringProperty("light"); + + /// Returns the theme brightness property. + public StringProperty themeBrightnessProperty() { + return themeBrightness; + } + + /// The selected launcher theme color. + @SerializedName("theme") + private final ObjectProperty themeColor = new SimpleObjectProperty<>(ThemeColor.DEFAULT); + + /// Returns the launcher theme color property. + public ObjectProperty themeColorProperty() { + return themeColor; + } + + /// The font family used by launcher content. + @SerializedName("fontFamily") + private final StringProperty fontFamily = new SimpleStringProperty(); + + /// Returns the launcher content font family property. + public StringProperty fontFamilyProperty() { + return fontFamily; + } + + /// The launcher UI font size. + @SerializedName("fontSize") + private final DoubleProperty fontSize = new SimpleDoubleProperty(12); + + /// Returns the launcher UI font size property. + public DoubleProperty fontSizeProperty() { + return fontSize; + } + + /// The font family used by launcher chrome. + @SerializedName("launcherFontFamily") + private final StringProperty launcherFontFamily = new SimpleStringProperty(); + + /// Returns the launcher chrome font family property. + public StringProperty launcherFontFamilyProperty() { + return launcherFontFamily; + } + + /// Whether UI animations are disabled. + @SerializedName("animationDisabled") + private final BooleanProperty animationDisabled = new SimpleBooleanProperty( + FXUtils.REDUCED_MOTION == Boolean.TRUE + || !JavaRuntime.CURRENT_JIT_ENABLED + || !FXUtils.GPU_ACCELERATION_ENABLED + ); + + /// Returns the UI animation disable property. + public BooleanProperty animationDisabledProperty() { + return animationDisabled; + } + + /// Whether the launcher title area is transparent. + @SerializedName("titleTransparent") + private final BooleanProperty titleTransparent = new SimpleBooleanProperty(false); + + /// Returns the transparent title area property. + public BooleanProperty titleTransparentProperty() { + return titleTransparent; + } + + /// The launcher background image source type. + @SerializedName("backgroundType") + private final ObjectProperty backgroundImageType = new RawPreservingObjectProperty<>(EnumBackgroundImage.DEFAULT); + + /// Returns the launcher background image source type property. + public ObjectProperty backgroundImageTypeProperty() { + return backgroundImageType; + } + + /// The local launcher background image path. + @SerializedName("bgpath") + private final StringProperty backgroundImage = new SimpleStringProperty(); + + /// Returns the local launcher background image path property. + public StringProperty backgroundImageProperty() { + return backgroundImage; + } + + /// The remote launcher background image URL. + @SerializedName("bgurl") + private final StringProperty backgroundImageUrl = new SimpleStringProperty(); + + /// Returns the remote launcher background image URL property. + public StringProperty backgroundImageUrlProperty() { + return backgroundImageUrl; + } + + /// The launcher background paint. + @SerializedName("bgpaint") + private final ObjectProperty backgroundPaint = new SimpleObjectProperty<>(); + + /// Returns the launcher background paint property. + public ObjectProperty backgroundPaintProperty() { + return backgroundPaint; + } + + /// The launcher background image opacity percentage. + @SerializedName("bgImageOpacity") + private final IntegerProperty backgroundImageOpacity = new SimpleIntegerProperty(100); + + /// Returns the launcher background image opacity property. + public IntegerProperty backgroundImageOpacityProperty() { + return backgroundImageOpacity; + } + + // Networks + + /// Whether HMCL automatically selects the number of download threads. + @SerializedName("autoDownloadThreads") + private final BooleanProperty autoDownloadThreads = new SimpleBooleanProperty(true); + + /// Returns the automatic download thread count property. + public BooleanProperty autoDownloadThreadsProperty() { + return autoDownloadThreads; + } + + /// The configured number of download threads. + @SerializedName("downloadThreads") + private final IntegerProperty downloadThreads = new SimpleIntegerProperty(64); + + /// Returns the download thread count property. + public IntegerProperty downloadThreadsProperty() { + return downloadThreads; + } + + /// The selected game version list download source. + @SerializedName("versionListSource") + private final ObjectProperty versionListSource = new RawPreservingObjectProperty<>(DownloadSource.DEFAULT); + + /// Returns the selected game version list download source property. + public ObjectProperty versionListSourceProperty() { + return versionListSource; + } + + /// The selected file download source. + @SerializedName("fileDownloadSource") + private final ObjectProperty fileDownloadSource = new RawPreservingObjectProperty<>(DownloadSource.DEFAULT); + + /// Returns the selected file download source property. + public ObjectProperty fileDownloadSourceProperty() { + return fileDownloadSource; + } + + /// The selected default add-on source ID. + @SerializedName("defaultAddonSource") + private final StringProperty defaultAddonSource = new SimpleStringProperty("modrinth"); + + /// Returns the selected default add-on source ID property. + public StringProperty defaultAddonSourceProperty() { + return defaultAddonSource; + } + + /// Whether a network proxy is enabled. + @SerializedName("hasProxy") + private final BooleanProperty hasProxy = new SimpleBooleanProperty(); + + /// Returns the network proxy enable property. + public BooleanProperty hasProxyProperty() { + return hasProxy; + } + + /// Whether proxy authentication is enabled. + @SerializedName("hasProxyAuth") + private final BooleanProperty hasProxyAuth = new SimpleBooleanProperty(); + + /// Returns the proxy authentication enable property. + public BooleanProperty hasProxyAuthProperty() { + return hasProxyAuth; + } + + /// The configured network proxy type. + @SerializedName("proxyType") + private final ObjectProperty proxyType = new SimpleObjectProperty<>(Proxy.Type.HTTP); + + /// Returns the network proxy type property. + public ObjectProperty proxyTypeProperty() { + return proxyType; + } + + /// The configured network proxy host. + @SerializedName("proxyHost") + private final StringProperty proxyHost = new SimpleStringProperty(); + + /// Returns the network proxy host property. + public StringProperty proxyHostProperty() { + return proxyHost; + } + + /// The configured network proxy port. + @SerializedName("proxyPort") + private final IntegerProperty proxyPort = new SimpleIntegerProperty(); + + /// Returns the network proxy port property. + public IntegerProperty proxyPortProperty() { + return proxyPort; + } + + /// The configured proxy authentication username. + @SerializedName("proxyUserName") + private final StringProperty proxyUser = new SimpleStringProperty(); + + /// Returns the proxy authentication username property. + public StringProperty proxyUserProperty() { + return proxyUser; + } + + /// The configured proxy authentication password. + @SerializedName("proxyPassword") + private final StringProperty proxyPassword = new SimpleStringProperty(); + + /// Returns the proxy authentication password property. + public StringProperty proxyPasswordProperty() { + return proxyPassword; + } + + /// The selected game directory ID. + @SerializedName(PROPERTY_SELECTED_GAME_DIRECTORY) + private final ObjectProperty<@Nullable GUID> selectedGameDirectory = + new SimpleObjectProperty<>(this, PROPERTY_SELECTED_GAME_DIRECTORY); + + /// Returns the selected game directory ID property. + public ObjectProperty<@Nullable GUID> selectedGameDirectoryProperty() { + return selectedGameDirectory; + } + + /// The default game setting preset ID. + @SerializedName(PROPERTY_DEFAULT_GAME_SETTINGS_PRESET) + private final ObjectProperty<@Nullable GUID> defaultGameSettingsPreset = + new SimpleObjectProperty<>(this, PROPERTY_DEFAULT_GAME_SETTINGS_PRESET); + + /// Returns the default game setting preset ID property. + public ObjectProperty<@Nullable GUID> defaultGameSettingsPresetProperty() { + return defaultGameSettingsPreset; + } + + /// Selected instance IDs keyed by game directory ID. + @SerializedName(PROPERTY_SELECTED_INSTANCE) + private final ObservableMap selectedInstance = FXCollections.observableHashMap(); + + /// Returns selected instance IDs keyed by game directory ID. + public ObservableMap getSelectedInstance() { + return selectedInstance; + } + + /// Returns the selected instance ID for the given game directory ID. + public @Nullable String getSelectedInstance(@Nullable GUID gameDirectoryId) { + return gameDirectoryId != null ? selectedInstance.get(gameDirectoryId) : null; + } + + /// Sets the selected instance ID for the given game directory ID. + public void setSelectedInstance(@Nullable GUID gameDirectoryId, @Nullable String selectedInstance) { + if (gameDirectoryId == null) { + return; + } + + if (StringUtils.isBlank(selectedInstance)) { + this.selectedInstance.remove(gameDirectoryId); + } else { + this.selectedInstance.put(gameDirectoryId, selectedInstance); + } + } + + // Accounts + + /// The preferred login type to use when the user wants to add an account. + @SerializedName("preferredLoginType") + private final StringProperty preferredLoginType = new SimpleStringProperty(); + + /// Returns the preferred login type property. + public StringProperty preferredLoginTypeProperty() { + return preferredLoginType; + } + + /// The selected account reference. + @SerializedName("selectedAccount") + private final ObjectProperty<@Nullable JsonObject> selectedAccount = new SimpleObjectProperty<>(); + + /// Returns the selected account reference property. + public ObjectProperty<@Nullable JsonObject> selectedAccountProperty() { + return selectedAccount; + } + + /// JSON adapter for [LauncherSettings]. + public static final class Adapter extends ObservableSetting.Adapter { + /// Creates empty launcher settings for deserialization. + @Override + protected LauncherSettings createInstance() { + return new LauncherSettings(); + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/LauncherState.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/LauncherState.java new file mode 100644 index 00000000000..dbdb41c25e0 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/LauncherState.java @@ -0,0 +1,188 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.setting; + +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +import javafx.beans.property.DoubleProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleDoubleProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableMap; +import org.jackhuang.hmcl.util.gson.JsonSchema; +import org.jackhuang.hmcl.util.gson.JsonSerializable; +import org.jackhuang.hmcl.util.gson.ObservableSetting; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; + +/// Stores per-workspace launcher runtime state independently from the main settings file. +/// +/// The JSON representation is saved as `launcher-state.json` under the current HMCL directory. +/// +/// @author Glavo +@JsonAdapter(LauncherState.Adapter.class) +@NotNullByDefault +@JsonSerializable +public final class LauncherState extends ObservableSetting implements JsonSchemaSetting { + /// The JSON schema supported by this launcher state store. + public static final JsonSchema CURRENT_SCHEMA = + new JsonSchema("launcher-state", new JsonSchema.Version(1, 0, 0)); + + /// Creates an empty launcher state store. + public LauncherState() { + tracker.markDirty(schema); + register(); + } + + /// The schema used by this launcher state file. + @SerializedName(JsonSchema.PROPERTY_SCHEMA) + private final ObjectProperty schema = new SimpleObjectProperty<>(CURRENT_SCHEMA); + + /// Returns the schema property. + public ObjectProperty schemaProperty() { + return schema; + } + + /// Returns the schema used by this launcher state file. + public JsonSchema getSchema() { + return schema.get(); + } + + /// Sets the schema used by this launcher state file. + public void setSchema(JsonSchema schema) { + this.schema.set(Objects.requireNonNull(schema)); + } + + /// The normalized launcher window X position. + @SerializedName("x") + private final DoubleProperty x = new SimpleDoubleProperty(); + + /// Returns the normalized launcher window X position property. + public DoubleProperty xProperty() { + return x; + } + + /// Returns the normalized launcher window X position. + public double getX() { + return x.get(); + } + + /// Sets the normalized launcher window X position. + public void setX(double x) { + this.x.set(x); + } + + /// The normalized launcher window Y position. + @SerializedName("y") + private final DoubleProperty y = new SimpleDoubleProperty(); + + /// Returns the normalized launcher window Y position property. + public DoubleProperty yProperty() { + return y; + } + + /// Returns the normalized launcher window Y position. + public double getY() { + return y.get(); + } + + /// Sets the normalized launcher window Y position. + public void setY(double y) { + this.y.set(y); + } + + /// The launcher window width. + @SerializedName("width") + private final DoubleProperty width = new SimpleDoubleProperty(); + + /// Returns the launcher window width property. + public DoubleProperty widthProperty() { + return width; + } + + /// Returns the launcher window width. + public double getWidth() { + return width.get(); + } + + /// Sets the launcher window width. + public void setWidth(double width) { + this.width.set(width); + } + + /// The launcher window height. + @SerializedName("height") + private final DoubleProperty height = new SimpleDoubleProperty(); + + /// Returns the launcher window height property. + public DoubleProperty heightProperty() { + return height; + } + + /// Returns the launcher window height. + public double getHeight() { + return height.get(); + } + + /// Sets the launcher window height. + public void setHeight(double height) { + this.height.set(height); + } + + /// The latest update version that has already prompted the user. + @SerializedName("promptedVersion") + private final StringProperty promptedVersion = new SimpleStringProperty(); + + /// Returns the latest prompted update version. + public @Nullable String getPromptedVersion() { + return promptedVersion.get(); + } + + /// Returns the latest prompted update version property. + public StringProperty promptedVersionProperty() { + return promptedVersion; + } + + /// Sets the latest prompted update version. + public void setPromptedVersion(@Nullable String promptedVersion) { + this.promptedVersion.set(promptedVersion); + } + + /// Tip markers that prevent repeated prompts. + @SerializedName("shownTips") + private final ObservableMap shownTips = FXCollections.observableHashMap(); + + /// Returns tip markers that prevent repeated prompts. + public ObservableMap getShownTips() { + return shownTips; + } + + /// JSON adapter for [LauncherState]. + public static final class Adapter extends ObservableSetting.Adapter { + /// Creates an empty launcher state store for deserialization. + @Override + protected LauncherState createInstance() { + return new LauncherState(); + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/LegacyConfigMigrator.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/LegacyConfigMigrator.java new file mode 100644 index 00000000000..f0c17785a7d --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/LegacyConfigMigrator.java @@ -0,0 +1,932 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.setting; + +import com.github.f4b6a3.uuid.alt.GUID; +import com.google.gson.*; +import org.jackhuang.hmcl.Metadata; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.gson.JsonSchema; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter; +import org.jackhuang.hmcl.util.io.JarUtils; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// Migrates legacy per-workspace config files into the current settings.json file. +/// +/// HMCL used hmcl.json and .hmcl.json as the main per-workspace config files through HMCL 3.15.0.345. +/// Those files are now legacy inputs only: migration reads them, writes a new settings.json, and leaves the original files unchanged. +/// +/// @author Glavo +@NotNullByDefault +public final class LegacyConfigMigrator { + /// The last numeric config version used by the legacy hmcl.json and .hmcl.json files. + private static final int LEGACY_CURRENT_CONFIG_VERSION = 2; + + /// Namespace used to generate stable IDs for legacy profiles. + private static final GUID LEGACY_PROFILE_ID_NAMESPACE = GUID.v5(GUID.NAMESPACE_URL, "hmcl:legacy-profile"); + + /// Namespace used to generate stable IDs for profile-level game settings migrated from legacy profiles. + private static final GUID LEGACY_GAME_SETTINGS_ID_NAMESPACE = GUID.v5(GUID.NAMESPACE_URL, "hmcl:legacy-game-settings"); + + /// The legacy built-in profile name for the current workspace game directory. + private static final String LEGACY_DEFAULT_PROFILE = "Default"; + + /// The legacy built-in profile name for the user-home game directory. + private static final String LEGACY_HOME_PROFILE = "Home"; + + /// The legacy built-in current-workspace profile ID. + private static final GUID LEGACY_DEFAULT_PROFILE_ID = getLegacyProfileId(LEGACY_DEFAULT_PROFILE); + + /// The legacy built-in user-home profile ID. + private static final GUID LEGACY_HOME_PROFILE_ID = getLegacyProfileId(LEGACY_HOME_PROFILE); + + /// The legacy Windows and portable configuration file name used through HMCL 3.15.0.345. + private static final String LEGACY_CONFIG_FILENAME = "hmcl.json"; + + /// The legacy Linux configuration file name used through HMCL 3.15.0.345. + private static final String LEGACY_CONFIG_FILENAME_LINUX = ".hmcl.json"; + + /// The legacy user settings path shared by all workspaces. + private static final Path LEGACY_USER_SETTINGS_LOCATION = Metadata.HMCL_USER_HOME.resolve("config.json"); + + /// The legacy user account storage path shared by all workspaces. + private static final Path LEGACY_USER_ACCOUNTS_LOCATION = Metadata.HMCL_USER_HOME.resolve("accounts.json"); + + /// Legacy ordinal order for `EnumBackgroundImage` in upstream/main configs. + private static final String[] LEGACY_BACKGROUND_IMAGE_TYPES = { + "DEFAULT", + "CUSTOM", + "CLASSIC", + "NETWORK", + "TRANSLUCENT", + "PAINT" + }; + + /// Legacy ordinal order for `Proxy.Type` in upstream/main configs. + private static final String[] LEGACY_PROXY_TYPES = { + "DIRECT", + "HTTP", + "SOCKS" + }; + + /// Prevents instantiation. + private LegacyConfigMigrator() { + } + + /// Returns the stable profile ID for a migrated legacy profile. + static GUID getLegacyProfileId(String profileName) { + return GUID.v5(LEGACY_PROFILE_ID_NAMESPACE, profileName); + } + + /// Returns the stable game settings preset ID for a migrated legacy profile. + static GUID getLegacyGameSettingsId(String profileName) { + return GUID.v5(LEGACY_GAME_SETTINGS_ID_NAMESPACE, profileName); + } + + /// Looks for a legacy config file and prepares it for writing as the new config file. + static @Nullable MigrationResult migrateLegacyConfig() throws IOException { + @Nullable Path path = locateLegacyConfig(); + if (path == null) { + return null; + } + + try { + JsonObject jsonObject = JsonUtils.fromJsonFile(path, JsonObject.class); + if (jsonObject == null) { + LOG.info("Legacy config file is empty"); + return null; + } + + // _version belongs to the legacy file format only. The current settings.json format will use + // a separate versioning scheme and must not depend on this numeric value. + // Older configs may not contain _version; historically those should be treated as the last + // pre-settings.json schema unless older-field probes below prove they need extra upgrades. + int configVersion = jsonObject.remove("_version") instanceof JsonPrimitive version && version.isNumber() + ? version.getAsInt() + : 0; + + if (configVersion > LEGACY_CURRENT_CONFIG_VERSION) { + LOG.warning("Unsupported legacy config version: " + configVersion); + return null; + } + + if (configVersion < LEGACY_CURRENT_CONFIG_VERSION) { + upgradeConfig(jsonObject, configVersion); + } + + @Nullable JsonObject legacyConfigurations = jsonObject.get("configurations") instanceof JsonObject configurations + ? configurations.deepCopy() + : null; + + LauncherState launcherState = extractLauncherState(jsonObject); + AuthlibInjectorServerList authlibInjectorServers = extractAuthlibInjectorServers(jsonObject); + AccountStorages accountStorages = Objects.requireNonNullElseGet( + extractAccountStorages(jsonObject), + AccountStorages::new); + migrateLegacySelectedAccount(jsonObject, accountStorages); + JsonElement legacyAllowAutoAgent = jsonObject.remove("allowAutoAgent"); + JsonElement legacyDisableAutoGameOptions = jsonObject.remove("disableAutoGameOptions"); + migrateLegacyEnumOrdinals(jsonObject); + migrateLegacyDownloadSources(jsonObject); + migrateLegacyCommonDirectoryType(jsonObject); + migrateLegacyCommonDirectory(jsonObject); + migrateLegacyLanguage(jsonObject); + migrateLegacySelectedVersions(jsonObject); + @Nullable GameDirectories migratedGameDirectories = extractGameDirectoriesFromConfigJson(jsonObject); + GameDirectories gameDirectories = migratedGameDirectories != null + ? migratedGameDirectories + : new GameDirectories(); + migrateLegacySelectedGameDirectory(jsonObject); + + LauncherSettings deserialized = LauncherSettings.fromJson(jsonObject); + if (deserialized == null) { + return null; + } + + GameSettingsPresets gameSettingsPresets = new GameSettingsPresets(); + migrateLegacyPresetSettings(gameDirectories, gameSettingsPresets, legacyConfigurations); + migrateLegacyAllowAutoAgent(deserialized, gameSettingsPresets, legacyAllowAutoAgent); + migrateLegacyDisableAutoGameOptions(deserialized, gameSettingsPresets, legacyDisableAutoGameOptions); + DetachedSettings detachedSettings = new DetachedSettings(gameDirectories, gameSettingsPresets, + launcherState, authlibInjectorServers, accountStorages); + return new MigrationResult(path, deserialized, detachedSettings, deserialized.toJson()); + } catch (JsonParseException e) { + LOG.warning("Malformed legacy config file: " + path, e); + return null; + } + } + + /// Extracts detached settings data that may still be embedded in a current settings JSON object. + /// + /// @param json the current settings JSON object + /// @return the extracted detached settings and whether the JSON object was changed + static CurrentSettingsMigration migrateCurrentSettings(JsonObject json) { + Objects.requireNonNull(json); + + @Nullable AccountStorages accountStorages = extractAccountStorages(json); + if (accountStorages == null) { + return new CurrentSettingsMigration(DetachedSettings.empty(), false); + } + + return new CurrentSettingsMigration( + new DetachedSettings(null, null, null, null, accountStorages), + true); + } + + /// Migrates user settings from the legacy global config file. + /// + /// @param targetLocation the current user settings path used for logging + /// @return the migrated user settings, or `null` when no legacy user settings can be used + static @Nullable UserSettings migrateLegacyUserSettings(Path targetLocation) throws IOException { + Objects.requireNonNull(targetLocation); + + if (!Files.exists(LEGACY_USER_SETTINGS_LOCATION)) { + return null; + } + + try { + String content = Files.readString(LEGACY_USER_SETTINGS_LOCATION); + UserSettings deserialized = UserSettings.fromJson(content); + if (deserialized == null) { + LOG.info("Legacy user settings file is empty: " + LEGACY_USER_SETTINGS_LOCATION); + return null; + } + + LOG.info("Migrating user settings from " + LEGACY_USER_SETTINGS_LOCATION + " to " + targetLocation); + return deserialized; + } catch (JsonParseException e) { + LOG.warning("Malformed legacy user settings: " + LEGACY_USER_SETTINGS_LOCATION, e); + return null; + } + } + + /// Extracts launcher state from a legacy config JSON object and removes those members. + static LauncherState extractLauncherState(JsonObject json) { + Objects.requireNonNull(json); + + JsonObject state = new JsonObject(); + state.add(JsonSchema.PROPERTY_SCHEMA, JsonUtils.GSON.toJsonTree(LauncherState.CURRENT_SCHEMA, JsonSchema.class)); + moveMember(json, state, "x"); + moveMember(json, state, "y"); + moveMember(json, state, "width"); + moveMember(json, state, "height"); + moveMember(json, state, "promptedVersion"); + moveMember(json, state, "shownTips"); + + LauncherState result = JsonUtils.GSON.fromJson(state, LauncherState.class); + return result != null ? result : new LauncherState(); + } + + /// Extracts authlib-injector servers from a legacy config JSON object and removes those members. + static AuthlibInjectorServerList extractAuthlibInjectorServers(JsonObject json) { + Objects.requireNonNull(json); + + JsonObject servers = new JsonObject(); + servers.add(JsonSchema.PROPERTY_SCHEMA, + JsonUtils.GSON.toJsonTree(AuthlibInjectorServerList.CURRENT_SCHEMA, JsonSchema.class)); + JsonElement authlibInjectorServers = json.remove("authlibInjectorServers"); + if (authlibInjectorServers != null) { + servers.add("servers", authlibInjectorServers); + } + JsonElement addedLittleSkin = json.remove("addedLittleSkin"); + boolean shouldAddLittleSkin = !(addedLittleSkin instanceof JsonPrimitive primitive + && primitive.isBoolean() + && primitive.getAsBoolean()); + + AuthlibInjectorServerList result = JsonUtils.GSON.fromJson(servers, AuthlibInjectorServerList.class); + if (result == null) { + result = new AuthlibInjectorServerList(); + } + if (shouldAddLittleSkin) { + result.addLittleSkinIfAbsent(); + } + return result; + } + + /// Extracts account storages from a config JSON object and removes the legacy member. + static @Nullable AccountStorages extractAccountStorages(JsonObject json) { + Objects.requireNonNull(json); + + JsonElement accounts = json.remove("accounts"); + if (accounts == null) { + return null; + } + + JsonObject object = new JsonObject(); + object.add(JsonSchema.PROPERTY_SCHEMA, + JsonUtils.GSON.toJsonTree(AccountStorages.CURRENT_SCHEMA, JsonSchema.class)); + if (accounts instanceof JsonArray) { + object.add("accounts", accounts); + } + + AccountStorages result = JsonUtils.GSON.fromJson(object, AccountStorages.class); + return result != null ? result : new AccountStorages(); + } + + /// Migrates the legacy selected account string into a structured selected account reference. + static boolean migrateLegacySelectedAccount(JsonObject json, AccountStorages localAccounts) { + Objects.requireNonNull(json); + Objects.requireNonNull(localAccounts); + + JsonElement selectedAccount = json.get("selectedAccount"); + if (selectedAccount == null || selectedAccount instanceof JsonObject) { + return false; + } + + @Nullable String legacyIdentifier = JsonUtils.getString(selectedAccount); + if (StringUtils.isBlank(legacyIdentifier)) { + json.remove("selectedAccount"); + return true; + } + + @Nullable JsonObject reference = findLegacySelectedAccountReference(legacyIdentifier, localAccounts, false); + if (reference == null) { + AccountStorages userAccounts = loadLegacyUserAccountStoragesForSelectedAccount(); + if (userAccounts != null) { + reference = findLegacySelectedAccountReference(legacyIdentifier, userAccounts, true); + } + } + + if (reference != null) { + json.add("selectedAccount", reference); + } else { + json.remove("selectedAccount"); + } + return true; + } + + /// Loads legacy user account storages only for resolving a selected account reference during migration. + private static @Nullable AccountStorages loadLegacyUserAccountStoragesForSelectedAccount() { + if (!Files.exists(LEGACY_USER_ACCOUNTS_LOCATION)) { + return null; + } + + try { + List> accounts = JsonUtils.fromJsonFile( + LEGACY_USER_ACCOUNTS_LOCATION, + JsonUtils.listTypeOf(JsonUtils.mapTypeOf(Object.class, Object.class))); + return accounts != null ? AccountStorages.fromAccounts(accounts) : null; + } catch (Exception e) { + LOG.warning("Failed to load legacy user accounts for selected account migration", e); + return null; + } + } + + /// Finds the structured selected account reference matching a legacy selected account string. + private static @Nullable JsonObject findLegacySelectedAccountReference( + String legacyIdentifier, + AccountStorages accounts, + boolean userStorage) { + String identifier = legacyIdentifier; + boolean selectedUserStorage = false; + if (identifier.startsWith("$GLOBAL:")) { + selectedUserStorage = true; + identifier = identifier.substring("$GLOBAL:".length()); + } + if (selectedUserStorage != userStorage) { + return null; + } + + for (Map account : accounts.getAccounts()) { + if (matchesLegacySelectedAccountIdentifier(identifier, account)) { + return createSelectedAccountReference(account, userStorage); + } + } + return null; + } + + /// Returns whether a serialized account entry matches a legacy selected account string. + private static boolean matchesLegacySelectedAccountIdentifier(String identifier, Map account) { + @Nullable String legacyIdentifier = getLegacyAccountIdentifier(account, false); + @Nullable String compactLegacyIdentifier = getLegacyAccountIdentifier(account, true); + if (Objects.equals(identifier, legacyIdentifier) + || Objects.equals(identifier, compactLegacyIdentifier)) { + return true; + } + + // Older legacy configs may store only the username for offline and Yggdrasil accounts. + return Objects.equals(identifier, JsonUtils.getString(account, "username")); + } + + /// Creates the structured selected account reference for a serialized account entry. + private static @Nullable JsonObject createSelectedAccountReference(Map account, boolean userStorage) { + @Nullable String type = JsonUtils.getString(account, "type"); + if (type == null) { + return null; + } + + JsonObject reference = new JsonObject(); + reference.addProperty("storage", userStorage ? "user" : "local"); + reference.addProperty("type", type); + + switch (type) { + case "offline" -> { + @Nullable String username = JsonUtils.getString(account, "username"); + if (username == null) { + return null; + } + reference.addProperty("username", username); + } + case "microsoft" -> { + @Nullable String uuid = JsonUtils.getString(account, "uuid"); + if (uuid == null) { + return null; + } + reference.addProperty("uuid", uuid); + @Nullable String userId = JsonUtils.getString(account, "userid"); + if (userId != null) { + reference.addProperty("userid", userId); + } + } + case "authlibInjector" -> { + @Nullable String serverBaseURL = JsonUtils.getString(account, "serverBaseURL"); + @Nullable String username = JsonUtils.getString(account, "username"); + @Nullable String uuid = JsonUtils.getString(account, "uuid"); + if (serverBaseURL == null || username == null || uuid == null) { + return null; + } + reference.addProperty("serverBaseURL", serverBaseURL); + reference.addProperty("username", username); + reference.addProperty("uuid", uuid); + } + default -> { + return null; + } + } + return reference; + } + + /// Returns the legacy string identifier for a serialized account entry. + private static @Nullable String getLegacyAccountIdentifier(Map account, boolean compactUuid) { + @Nullable String type = JsonUtils.getString(account, "type"); + if (type == null) { + return null; + } + + return switch (type) { + case "offline" -> { + @Nullable String username = JsonUtils.getString(account, "username"); + yield username != null ? username + ":" + username : null; + } + case "microsoft" -> { + @Nullable String uuid = JsonUtils.getString(account, "uuid"); + yield uuid != null ? "microsoft:" + formatLegacyUUID(uuid, compactUuid) : null; + } + case "authlibInjector" -> { + @Nullable String serverBaseURL = JsonUtils.getString(account, "serverBaseURL"); + @Nullable String username = JsonUtils.getString(account, "username"); + @Nullable String uuid = JsonUtils.getString(account, "uuid"); + yield serverBaseURL != null && username != null && uuid != null + ? serverBaseURL + ":" + username + ":" + formatLegacyUUID(uuid, compactUuid) + : null; + } + default -> null; + }; + } + + /// Formats a stored UUID the same way legacy account identifiers did. + private static String formatLegacyUUID(String uuid, boolean compact) { + if (compact) { + return uuid; + } + + try { + return UUIDTypeAdapter.fromString(uuid).toString(); + } catch (IllegalArgumentException ignored) { + return uuid; + } + } + + /// Moves one JSON member from the source object to the target object. + private static void moveMember(JsonObject source, JsonObject target, String name) { + JsonElement element = source.remove(name); + if (element != null) { + target.add(name, element); + } + } + + /// Migrates the legacy `localization` field into the current `language` field. + static void migrateLegacyLanguage(JsonObject json) { + Objects.requireNonNull(json); + + JsonElement legacyLanguage = json.remove("localization"); + if (json.has("language") || !(legacyLanguage instanceof JsonPrimitive primitive) || !primitive.isString()) { + return; + } + + json.addProperty("language", switch (primitive.getAsString()) { + case "zh_CN" -> "zh-Hans"; + case "zh" -> "zh-Hant"; + default -> primitive.getAsString(); + }); + } + + /// Migrates the legacy `commonpath` field into the current `commonDirectory` field. + static void migrateLegacyCommonDirectory(JsonObject json) { + Objects.requireNonNull(json); + + JsonElement legacyCommonDirectory = json.remove("commonpath"); + if (json.has("commonDirectory") + || !(legacyCommonDirectory instanceof JsonPrimitive primitive) + || !primitive.isString()) { + return; + } + + json.addProperty("commonDirectory", primitive.getAsString()); + } + + /// Migrates the legacy `commonDirType` field into the current `commonDirectoryType` field. + static void migrateLegacyCommonDirectoryType(JsonObject json) { + Objects.requireNonNull(json); + + JsonElement legacyCommonDirectoryType = json.remove("commonDirType"); + if (json.has("commonDirectoryType") || legacyCommonDirectoryType == null) { + return; + } + + json.add("commonDirectoryType", legacyCommonDirectoryType); + } + + /// Migrates legacy enum ordinal fields into stable enum names. + static void migrateLegacyEnumOrdinals(JsonObject json) { + Objects.requireNonNull(json); + + migrateLegacyEnumOrdinal(json, "backgroundType", LEGACY_BACKGROUND_IMAGE_TYPES); + migrateLegacyEnumOrdinal(json, "proxyType", LEGACY_PROXY_TYPES); + } + + /// Migrates one legacy enum ordinal field into a stable enum name. + private static void migrateLegacyEnumOrdinal(JsonObject json, String propertyName, String[] legacyNames) { + @Nullable Integer ordinal = JsonUtils.getInteger(json.get(propertyName)); + if (ordinal == null || ordinal < 0 || ordinal >= legacyNames.length) { + return; + } + + json.addProperty(propertyName, legacyNames[ordinal]); + } + + /// Migrates legacy download source fields into `versionListSource` and `fileDownloadSource`. + static void migrateLegacyDownloadSources(JsonObject json) { + Objects.requireNonNull(json); + + JsonElement autoChooseDownloadType = json.remove("autoChooseDownloadType"); + JsonElement legacyDownloadType = json.remove("downloadType"); + JsonElement legacyVersionListSource = json.remove("versionListSource"); + if (autoChooseDownloadType == null && legacyDownloadType == null && legacyVersionListSource == null) { + return; + } + + DownloadSource source = JsonUtils.getBoolean(autoChooseDownloadType, true) + ? parseLegacyDownloadSource(legacyVersionListSource, DownloadSource.DEFAULT) + : parseLegacyDownloadSource(legacyDownloadType, DownloadSource.DEFAULT); + + if (!json.has("versionListSource")) { + json.addProperty("versionListSource", source.name()); + } + if (!json.has("fileDownloadSource")) { + json.addProperty("fileDownloadSource", source.name()); + } + } + + /// Parses an old download source identifier. + private static DownloadSource parseLegacyDownloadSource(@Nullable JsonElement element, DownloadSource defaultValue) { + @Nullable String value = JsonUtils.getString(element); + if (value == null) { + return defaultValue; + } + + return switch (value.toLowerCase(Locale.ROOT)) { + case "default", "balanced" -> DownloadSource.DEFAULT; + case "official", "mojang" -> DownloadSource.OFFICIAL; + case "mirror", "bmclapi" -> DownloadSource.MIRROR; + default -> { + try { + yield DownloadSource.valueOf(value.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + yield defaultValue; + } + } + }; + } + + /// Migrates the legacy workspace-wide automatic Java agent permission into game setting presets. + static void migrateLegacyAllowAutoAgent( + LauncherSettings launcherSettings, + GameSettingsPresets gameSettingsPresets, + @Nullable JsonElement legacyAllowAutoAgent) { + Objects.requireNonNull(launcherSettings); + Objects.requireNonNull(gameSettingsPresets); + + if (!(legacyAllowAutoAgent instanceof JsonPrimitive primitive) + || !primitive.isBoolean() + || !primitive.getAsBoolean()) { + return; + } + + ensureDefaultGameSettingsPreset(launcherSettings, gameSettingsPresets); + for (GameSettings.Preset preset : gameSettingsPresets.getPresets()) { + preset.allowAutoAgentProperty().setValue(true); + } + } + + /// Migrates the legacy workspace-wide automatic game options switch into game setting presets. + static void migrateLegacyDisableAutoGameOptions( + LauncherSettings launcherSettings, + GameSettingsPresets gameSettingsPresets, + @Nullable JsonElement legacyDisableAutoGameOptions) { + Objects.requireNonNull(launcherSettings); + Objects.requireNonNull(gameSettingsPresets); + + if (!(legacyDisableAutoGameOptions instanceof JsonPrimitive primitive) + || !primitive.isBoolean() + || !primitive.getAsBoolean()) { + return; + } + + ensureDefaultGameSettingsPreset(launcherSettings, gameSettingsPresets); + for (GameSettings.Preset preset : gameSettingsPresets.getPresets()) { + preset.disableAutoGameOptionsProperty().setValue(true); + } + } + + /// Ensures there is a default preset to receive migrated workspace-wide game settings. + private static void ensureDefaultGameSettingsPreset(LauncherSettings launcherSettings, GameSettingsPresets gameSettingsPresets) { + if (gameSettingsPresets.getPresets().isEmpty()) { + GameSettings.Preset preset = new GameSettings.Preset(gameSettingsPresets.newPresetId()); + preset.nameProperty().setValue("Default"); + gameSettingsPresets.getPresets().add(preset); + launcherSettings.defaultGameSettingsPresetProperty().set(preset.idProperty().getValue()); + } + } + + /// Extracts game directory data from a legacy config JSON object and removes the legacy members. + /// + /// This supports migrating the upstream/main `configurations` map into `game-directories.json`. + /// + /// @param json the legacy config JSON object + /// @return the extracted game directory store, or `null` when the object contains no game directory data + static @Nullable GameDirectories extractGameDirectoriesFromConfigJson(JsonObject json) { + Objects.requireNonNull(json); + + @Nullable JsonElement configurationsElement = json.remove("configurations"); + + @Nullable JsonArray profiles = null; + if (configurationsElement instanceof JsonObject configurations) { + profiles = migrateConfigurationMap(configurations); + } + + if (profiles == null) { + return null; + } + + JsonObject object = new JsonObject(); + object.add(JsonSchema.PROPERTY_SCHEMA, JsonUtils.GSON.toJsonTree(GameDirectories.CURRENT_SCHEMA, JsonSchema.class)); + object.add("directories", profiles); + + return JsonUtils.GSON.fromJson(object, GameDirectories.class); + } + + /// Converts a legacy profile map into game directory JSON. + private static JsonArray migrateConfigurationMap(JsonObject configurations) { + JsonArray result = new JsonArray(); + for (Map.Entry entry : configurations.entrySet()) { + if (!(entry.getValue() instanceof JsonObject profile)) { + continue; + } + + JsonObject migrated = profile.deepCopy(); + String name = entry.getKey(); + if (isBuiltInProfileName(name)) { + migrated.remove("name"); + } else { + migrated.addProperty("name", name); + } + migrated.addProperty("id", getLegacyProfileId(name).toString()); + if (profile.get("global") instanceof JsonObject) { + migrated.addProperty("legacyGameSettings", getLegacyGameSettingsId(name).toString()); + } + result.add(migrated); + } + return result; + } + + /// Returns whether the given legacy profile name belongs to a built-in profile. + private static boolean isBuiltInProfileName(@Nullable String name) { + return LEGACY_DEFAULT_PROFILE.equals(name) || LEGACY_HOME_PROFILE.equals(name); + } + + /// Finds a legacy config file with the same precedence as old HMCL versions. + private static @Nullable Path locateLegacyConfig() { + // Keep this order aligned with old HMCL versions so the same legacy file wins during migration. + Path defaultConfigFile = Metadata.HMCL_LOCAL_HOME.resolve(LEGACY_CONFIG_FILENAME); + if (Files.isRegularFile(defaultConfigFile)) { + return defaultConfigFile; + } + + try { + @Nullable Path jarPath = JarUtils.thisJarPath(); + if (jarPath != null && Files.isRegularFile(jarPath) && Files.isWritable(jarPath)) { + Path jarDirectory = jarPath.getParent(); + + Path config = jarDirectory.resolve(LEGACY_CONFIG_FILENAME); + if (Files.isRegularFile(config)) { + return config; + } + + Path dotConfig = jarDirectory.resolve(LEGACY_CONFIG_FILENAME_LINUX); + if (Files.isRegularFile(dotConfig)) { + return dotConfig; + } + } + } catch (Throwable ignore) { + } + + Path config = Paths.get(LEGACY_CONFIG_FILENAME); + if (Files.isRegularFile(config)) { + return config; + } + + Path dotConfig = Paths.get(LEGACY_CONFIG_FILENAME_LINUX); + if (Files.isRegularFile(dotConfig)) { + return dotConfig; + } + + return null; + } + + /// Upgrades old config fields in the raw JSON object to the current schema. + private static void upgradeConfig(JsonObject jsonObject, int configVersion) { + LOG.info(String.format("Updating legacy configuration from %d to %d.", configVersion, LEGACY_CURRENT_CONFIG_VERSION)); + if (configVersion < 1) { + // Upgrade configuration of HMCL 2.x: Convert OfflineAccounts whose stored uuid is important. + if (jsonObject.get("auth") instanceof JsonObject auth + && auth.get("offline") instanceof JsonObject offline + && offline.get("uuidMap") instanceof JsonObject uuidMap) { + + String selected = jsonObject.has("selectedAccount") + ? null + : JsonUtils.getString(offline, "IAuthenticator_UserName", null); + JsonArray accounts = new JsonArray(); + for (Map.Entry entry : uuidMap.entrySet()) { + JsonObject storage = new JsonObject(); + storage.addProperty("type", "offline"); + storage.addProperty("username", entry.getKey()); + storage.add("uuid", entry.getValue()); + if (entry.getKey().equals(selected)) { + storage.addProperty("selected", true); + } + accounts.add(storage); + } + jsonObject.add("accounts", accounts); + } + + + // Upgrade configuration of HMCL earlier than 3.1.70. + if (!jsonObject.has("commonDirType") && !jsonObject.has("commonDirectoryType")) { + String commonDirectory = JsonUtils.getString(jsonObject, "commonpath", LauncherSettings.getDefaultCommonDirectory()); + jsonObject.addProperty("commonDirectoryType", commonDirectory.equals(LauncherSettings.getDefaultCommonDirectory()) + ? EnumCommonDirectory.DEFAULT.name() + : EnumCommonDirectory.CUSTOM.name()); + } + if (!jsonObject.has("backgroundType")) { + String backgroundImage = JsonUtils.getString(jsonObject, "bgpath", ""); + jsonObject.addProperty("backgroundType", StringUtils.isNotBlank(backgroundImage) + ? EnumBackgroundImage.CUSTOM.name() + : EnumBackgroundImage.DEFAULT.name()); + } + if (!jsonObject.has("hasProxy")) { + jsonObject.addProperty("hasProxy", StringUtils.isNotBlank(JsonUtils.getString(jsonObject, "proxyHost", ""))); + } + if (!jsonObject.has("hasProxyAuth")) { + jsonObject.addProperty("hasProxyAuth", StringUtils.isNotBlank(JsonUtils.getString(jsonObject, "proxyUserName", ""))); + } + + if (!jsonObject.has("downloadType")) { + JsonElement legacyDownloadType = jsonObject.get("downloadtype"); + if (legacyDownloadType != null && legacyDownloadType.isJsonPrimitive() + && legacyDownloadType.getAsJsonPrimitive().isNumber()) { + int id = legacyDownloadType.getAsInt(); + if (id == 0) { + jsonObject.addProperty("downloadType", "mojang"); + } else if (id == 1) { + jsonObject.addProperty("downloadType", "bmclapi"); + } + } + } + } + } + + /// Migrates the legacy selected profile name into the current selected game directory ID. + /// + /// @param json the legacy config JSON object + /// @return whether the JSON object was changed + static boolean migrateLegacySelectedGameDirectory(JsonObject json) { + Objects.requireNonNull(json); + + @Nullable JsonElement lastElement = json.remove("last"); + if (lastElement == null) { + return false; + } + + if (json.has(LauncherSettings.PROPERTY_SELECTED_GAME_DIRECTORY)) { + return true; + } + + @Nullable String selectedName = JsonUtils.getString(lastElement); + if (selectedName != null) { + json.add(LauncherSettings.PROPERTY_SELECTED_GAME_DIRECTORY, + JsonUtils.GSON.toJsonTree(getLegacyProfileId(selectedName), GUID.class)); + } + return true; + } + + /// Migrates legacy per-profile selected versions into the current selected version map. + /// + /// @param json the legacy config JSON object + /// @return whether the JSON object was changed + static boolean migrateLegacySelectedVersions(JsonObject json) { + Objects.requireNonNull(json); + + if (!(json.get("configurations") instanceof JsonObject configurations)) { + return false; + } + + JsonObject selectedInstance = json.get(LauncherSettings.PROPERTY_SELECTED_INSTANCE) instanceof JsonObject existingSelectedInstance + ? existingSelectedInstance + : new JsonObject(); + boolean changed = false; + + for (Map.Entry entry : configurations.entrySet()) { + if (!(entry.getValue() instanceof JsonObject profile)) { + continue; + } + + @Nullable String selectedVersion = JsonUtils.getString(profile.get("selectedMinecraftVersion")); + if (StringUtils.isBlank(selectedVersion)) { + continue; + } + + String id = getLegacyProfileId(entry.getKey()).toString(); + if (!selectedInstance.has(id)) { + selectedInstance.addProperty(id, selectedVersion); + changed = true; + } + } + + if (changed && !json.has(LauncherSettings.PROPERTY_SELECTED_INSTANCE)) { + json.add(LauncherSettings.PROPERTY_SELECTED_INSTANCE, selectedInstance); + } + return changed; + } + + /// Migrates profile-global game settings from HMCL 3.15.0.345 and older config files. + static void migrateLegacyPresetSettings( + GameDirectories gameDirectories, + GameSettingsPresets gameSettingsPresets, + @Nullable JsonObject configurations) { + if (configurations == null) { + return; + } + + for (Profile profile : gameDirectories.getGameDirectories()) { + @Nullable GUID legacyGameSettings = profile.getLegacyGameSettings(); + if (legacyGameSettings == null) { + continue; + } + + GameSettings.Preset legacyParent = gameSettingsPresets.getPreset(legacyGameSettings); + if (legacyParent == null) { + @Nullable String profileName = getLegacyProfileName(profile); + if (profileName == null) { + continue; + } + JsonObject profileObject = configurations.get(profileName) instanceof JsonObject profileJson ? profileJson : null; + JsonObject legacySettingObject = profileObject != null && profileObject.get("global") instanceof JsonObject legacyJson ? legacyJson : null; + if (legacySettingObject == null) { + continue; + } + + legacyParent = LegacyGameSettingsMigrator.toPreset(legacyGameSettings, profileName, legacySettingObject); + gameSettingsPresets.getPresets().add(legacyParent); + } + } + } + + /// Returns the legacy profile name used in `configurations`. + private static @Nullable String getLegacyProfileName(Profile profile) { + if (LEGACY_DEFAULT_PROFILE_ID.equals(profile.getId())) { + return LEGACY_DEFAULT_PROFILE; + } + if (LEGACY_HOME_PROFILE_ID.equals(profile.getId())) { + return LEGACY_HOME_PROFILE; + } + return profile.getName(); + } + + /// Detached settings migrated out of an old config file. + /// + /// @param gameDirectories the detached game directory store, or `null` when none was migrated + /// @param gameSettingsPresets the detached preset store, or `null` when none was migrated + /// @param launcherState the detached launcher state, or `null` when none was migrated + /// @param authlibInjectorServers the detached authlib-injector servers, or `null` when none was migrated + /// @param accountStorages the detached account storages, or `null` when none was migrated + record DetachedSettings( + @Nullable GameDirectories gameDirectories, + @Nullable GameSettingsPresets gameSettingsPresets, + @Nullable LauncherState launcherState, + @Nullable AuthlibInjectorServerList authlibInjectorServers, + @Nullable AccountStorages accountStorages) { + /// Returns an empty detached settings migration result. + static DetachedSettings empty() { + return new DetachedSettings(null, null, null, null, null); + } + } + + /// Result of migrating detached data out of an existing settings file. + /// + /// @param detachedSettings the detached settings migrated out of the settings JSON object + /// @param changed whether the settings JSON object was changed + record CurrentSettingsMigration(DetachedSettings detachedSettings, boolean changed) { + } + + /// Result of locating and loading a legacy config file without modifying it. + /// + /// @param path the legacy config path + /// @param launcherSettings the parsed launcher settings + /// @param detachedSettings the detached settings migrated from legacy config fields + /// @param contentForMigration the content to save when migrating to settings.json + record MigrationResult( + Path path, + LauncherSettings launcherSettings, + DetachedSettings detachedSettings, + String contentForMigration) { + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/LegacyGameSettingsMigrator.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/LegacyGameSettingsMigrator.java new file mode 100644 index 00000000000..689dc1cafdd --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/LegacyGameSettingsMigrator.java @@ -0,0 +1,395 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.setting; + +import com.github.f4b6a3.uuid.alt.GUID; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import org.jackhuang.hmcl.game.GraphicsAPI; +import org.jackhuang.hmcl.game.NativesDirectoryType; +import org.jackhuang.hmcl.game.ProcessPriority; +import org.jackhuang.hmcl.game.QuickPlayType; +import org.jackhuang.hmcl.game.Renderer; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// Converts legacy game settings JSON into `GameSettings` models. +@NotNullByDefault +public final class LegacyGameSettingsMigrator { + /// Legacy file name used by old per-version `VersionSetting` data. + private static final String LEGACY_INSTANCE_SETTINGS_FILENAME = "hmclversion.cfg"; + + /// Legacy game directory modes stored by old configuration files. + private enum GameDirectoryType { + /// Use the root `.minecraft` folder. + ROOT_FOLDER, + + /// Use the version-specific folder. + VERSION_FOLDER, + + /// Use a custom game directory. + CUSTOM + } + + /// Legacy `VersionIconType` ordinal order used by old local settings. + private static final VersionIconType @Unmodifiable [] LEGACY_VERSION_ICON_TYPES = { + VersionIconType.DEFAULT, + VersionIconType.GRASS, + VersionIconType.CHEST, + VersionIconType.CHICKEN, + VersionIconType.COMMAND, + VersionIconType.OPTIFINE, + VersionIconType.CRAFT_TABLE, + VersionIconType.FABRIC, + VersionIconType.FORGE, + VersionIconType.NEO_FORGE, + VersionIconType.FURNACE, + VersionIconType.QUILT, + VersionIconType.APRIL_FOOLS, + VersionIconType.CLEANROOM, + VersionIconType.LEGACY_FABRIC + }; + + /// Prevents instantiation. + private LegacyGameSettingsMigrator() { + } + + /// Converts a legacy profile-level setting JSON object into a preset with the given ID. + public static GameSettings.Preset toPreset(GUID id, String name, @Nullable JsonObject source) { + GameSettings.Preset target = new GameSettings.Preset(id); + target.nameProperty().setValue(name); + if (getLegacyGameDirType(source, GameDirectoryType.ROOT_FOLDER) == GameDirectoryType.VERSION_FOLDER) { + target.defaultIsolationTypeProperty().setValue(DefaultIsolationType.ALWAYS); + } + if (source != null) { + copyCommonProperties(source, target); + } + return target; + } + + /// Migrates a legacy per-version game setting file into an instance setting. + /// + /// @param versionRoot the root directory of the version being migrated + /// @param baseDirectory the profile game directory used by legacy `ROOT_FOLDER` settings + /// @param parent the migrated parent preset ID for the profile + /// @return the migrated instance setting, or `null` when no legacy file can be migrated + public static @Nullable GameSettings.Instance migrateInstanceGameSettings( + Path versionRoot, + Path baseDirectory, + @Nullable GUID parent) { + Objects.requireNonNull(versionRoot); + Objects.requireNonNull(baseDirectory); + + Path file = versionRoot.resolve(LEGACY_INSTANCE_SETTINGS_FILENAME); + if (!Files.exists(file)) { + return null; + } + + try { + JsonObject legacySettingJson = JsonUtils.fromJsonFile(file, JsonObject.class); + if (legacySettingJson == null) { + return null; + } + + boolean inheritsLegacyParent = JsonUtils.getBoolean(legacySettingJson, "usesGlobal", false); + GameSettings.Instance setting = toInstance(parent, legacySettingJson, !inheritsLegacyParent); + if (inheritsLegacyParent) { + preserveInheritedRunningDirectory(setting, parent); + } else { + preserveLocalRootRunningDirectory(setting, legacySettingJson, baseDirectory, parent); + } + return setting; + } catch (Exception ex) { + LOG.warning("Failed to migrate legacy version setting " + file, ex); + return null; + } + } + + /// Converts a legacy local setting JSON object into an instance game setting. + public static GameSettings.Instance toInstance(@Nullable GUID parent, @Nullable JsonObject source, boolean copyValues) { + GameSettings.Instance target = new GameSettings.Instance(); + target.parentProperty().setValue(parent); + target.iconProperty().setValue(parseLegacyVersionIconType(source)); + if (source != null && copyValues) { + copyCommonProperties(source, target); + if (getLegacyGameDirType(source, GameDirectoryType.ROOT_FOLDER) != GameDirectoryType.ROOT_FOLDER) { + target.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIR); + } + target.getOverrideProperties().addAll(List.of( + GameSettings.PROPERTY_JAVA_TYPE, + GameSettings.PROPERTY_JVM_OPTIONS, + GameSettings.PROPERTY_NO_JVM_OPTIONS, + GameSettings.PROPERTY_NO_OPTIMIZING_JVM_OPTIONS, + GameSettings.PROPERTY_NOT_CHECK_JVM, + GameSettings.PROPERTY_NOT_CHECK_GAME, + GameSettings.PROPERTY_AUTO_MEMORY, + GameSettings.PROPERTY_MIN_MEMORY, + GameSettings.PROPERTY_MAX_MEMORY, + GameSettings.PROPERTY_PERM_SIZE, + GameSettings.PROPERTY_WINDOW_TYPE, + GameSettings.PROPERTY_WIDTH, + GameSettings.PROPERTY_HEIGHT, + GameSettings.PROPERTY_PROCESS_PRIORITY, + GameSettings.PROPERTY_LAUNCHER_VISIBILITY, + GameSettings.PROPERTY_GAME_ARGS, + GameSettings.PROPERTY_GRAPHICS_BACKEND, + GameSettings.PROPERTY_OPENGL_RENDERER, + GameSettings.PROPERTY_VULKAN_RENDERER, + GameSettings.PROPERTY_ENVIRONMENT_VARIABLES, + GameSettings.PROPERTY_COMMAND_WRAPPER, + GameSettings.PROPERTY_PRE_LAUNCH_COMMAND, + GameSettings.PROPERTY_POST_EXIT_COMMAND, + GameSettings.PROPERTY_QUICK_PLAY, + GameSettings.PROPERTY_QUICK_PLAY_MULTIPLAYER, + GameSettings.PROPERTY_QUICK_PLAY_SINGLEPLAYER, + GameSettings.PROPERTY_QUICK_PLAY_REALMS, + GameSettings.PROPERTY_SHOW_LOGS, + GameSettings.PROPERTY_ENABLE_DEBUG_LOG_OUTPUT, + GameSettings.PROPERTY_NOT_PATCH_NATIVES, + GameSettings.PROPERTY_NATIVES_DIR_TYPE, + GameSettings.PROPERTY_NATIVES_DIR, + GameSettings.PROPERTY_USE_NATIVE_GLFW, + GameSettings.PROPERTY_USE_NATIVE_OPENAL + )); + } + return target; + } + + /// Preserves inherited legacy `VERSION_FOLDER` semantics for local settings that inherit parent values. + private static void preserveInheritedRunningDirectory(GameSettings.Instance setting, @Nullable GUID parent) { + GameSettings.Preset parentSetting = SettingsManager.getGameSettings(parent); + if (parentSetting != null && parentSetting.defaultIsolationTypeProperty().getValue() == DefaultIsolationType.ALWAYS) { + setting.runningDirProperty().setValue(""); + setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIR); + } + } + + /// Preserves explicit legacy `ROOT_FOLDER` local settings when the parent uses a custom directory. + private static void preserveLocalRootRunningDirectory( + GameSettings.Instance setting, + JsonObject legacySettingJson, + Path baseDirectory, + @Nullable GUID parent) { + GameSettings.Preset parentSetting = SettingsManager.getGameSettings(parent); + if (parentSetting != null + && getLegacyGameDirType(legacySettingJson, GameDirectoryType.ROOT_FOLDER) == GameDirectoryType.ROOT_FOLDER + && StringUtils.isNotBlank(parentSetting.runningDirProperty().getValue())) { + setting.runningDirProperty().setValue(baseDirectory.toString()); + setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIR); + } + } + + /// Returns the legacy game directory type from a setting JSON object. + private static GameDirectoryType getLegacyGameDirType(@Nullable JsonObject source, GameDirectoryType defaultValue) { + return parseEnum(source, "gameDirType", GameDirectoryType.class, defaultValue); + } + + /// Copies shared legacy properties into the target setting. + private static void copyCommonProperties(JsonObject source, GameSettings target) { + JavaVersionType javaVersionType = parseLegacyJavaVersionType(source); + target.javaTypeProperty().setValue(javaVersionType); + target.javaVersionProperty().setValue(empty(parseLegacyJavaVersion(source))); + target.customJavaPathProperty().setValue(JsonUtils.getString(source, "javaDir", "")); + target.defaultJavaPathProperty().setValue(JsonUtils.getString(source, "defaultJavaPath", "")); + + target.jvmOptionsProperty().setValue(JsonUtils.getString(source, "javaArgs", "")); + target.noJVMOptionsProperty().setValue(JsonUtils.getBoolean(source, "noJVMArgs", false)); + target.noOptimizingJVMOptionsProperty().setValue(JsonUtils.getBoolean(source, "noOptimizingJVMArgs", false)); + target.notCheckJVMProperty().setValue(JsonUtils.getBoolean(source, "notCheckJVM", false)); + target.notCheckGameProperty().setValue(JsonUtils.getBoolean(source, "notCheckGame", false)); + + int maxMemory = JsonUtils.getInt(source, "maxMemory", GameSettings.SUGGESTED_MEMORY); + target.autoMemoryProperty().setValue(JsonUtils.getBoolean(source, "autoMemory", true)); + target.minMemoryProperty().setValue(JsonUtils.getNullableInt(source, "minMemory")); + target.maxMemoryProperty().setValue(maxMemory > 0 ? maxMemory : GameSettings.SUGGESTED_MEMORY); + target.permSizeProperty().setValue(JsonUtils.getString(source, "permSize", "")); + + target.windowTypeProperty().setValue(JsonUtils.getBoolean(source, "fullscreen", false) ? GameWindowType.FULLSCREEN : GameWindowType.WINDOWED); + target.widthProperty().setValue((double) JsonUtils.getInt(source, "width", 0)); + target.heightProperty().setValue((double) JsonUtils.getInt(source, "height", 0)); + GameDirectoryType legacyGameDirType = getLegacyGameDirType(source, GameDirectoryType.ROOT_FOLDER); + target.runningDirProperty().setValue(legacyGameDirType == GameDirectoryType.CUSTOM ? JsonUtils.getString(source, "gameDir", "") : ""); + + target.processPriorityProperty().setValue(parseEnum(source, "processPriority", ProcessPriority.class, ProcessPriority.NORMAL)); + target.launcherVisibilityProperty().setValue(parseEnum(source, "launcherVisibility", LauncherVisibility.class, LauncherVisibility.HIDE)); + target.gameArgsProperty().setValue(JsonUtils.getString(source, "minecraftArgs", "")); + Renderer renderer = parseLegacyRenderer(source); + GraphicsAPI graphicsBackend = parseGraphicsBackend(source, renderer); + target.graphicsBackendProperty().setValue(graphicsBackend); + GameSettings.setRendererForApi(target, renderer, graphicsBackend); + target.environmentVariablesProperty().setValue(JsonUtils.getString(source, "environmentVariables", "")); + target.commandWrapperProperty().setValue(JsonUtils.getString(source, "wrapper", "")); + target.preLaunchCommandProperty().setValue(JsonUtils.getString(source, "precalledCommand", "")); + target.postExitCommandProperty().setValue(JsonUtils.getString(source, "postExitCommand", "")); + + String serverIp = JsonUtils.getString(source, "serverIp", ""); + if (StringUtils.isBlank(serverIp)) { + target.quickPlayProperty().setValue(QuickPlayType.NONE); + } else { + target.quickPlayProperty().setValue(QuickPlayType.MULTIPLAYER); + target.quickPlayMultiplayerProperty().setValue(serverIp); + } + + target.showLogsProperty().setValue(JsonUtils.getBoolean(source, "showLogs", false)); + target.enableDebugLogOutputProperty().setValue(JsonUtils.getBoolean(source, "enableDebugLogOutput", false)); + target.notPatchNativesProperty().setValue(JsonUtils.getBoolean(source, "notPatchNatives", false)); + target.nativesDirTypeProperty().setValue(parseEnum(source, "nativesDirType", NativesDirectoryType.class, NativesDirectoryType.VERSION_FOLDER)); + target.nativesDirProperty().setValue(JsonUtils.getString(source, "nativesDir", "")); + target.useNativeGLFWProperty().setValue(JsonUtils.getBoolean(source, "useNativeGLFW", false)); + target.useNativeOpenALProperty().setValue(JsonUtils.getBoolean(source, "useNativeOpenAL", false)); + } + + /// Parses the legacy Java selection mode. + private static JavaVersionType parseLegacyJavaVersionType(JsonObject source) { + if (source.has("javaVersionType")) { + return parseEnum(source, "javaVersionType", JavaVersionType.class, JavaVersionType.AUTO); + } + + return switch (JsonUtils.getString(source, "java", "")) { + case "Default" -> JavaVersionType.DEFAULT; + case "Auto" -> JavaVersionType.AUTO; + case "Custom" -> JavaVersionType.CUSTOM; + default -> JavaVersionType.AUTO; + }; + } + + /// Parses the legacy Java version value. + private static @Nullable String parseLegacyJavaVersion(JsonObject source) { + if (source.has("javaVersionType")) { + return JsonUtils.getString(source, "java", null); + } + + String java = JsonUtils.getString(source, "java", ""); + return switch (java) { + case "Default", "Auto", "Custom" -> ""; + default -> java; + }; + } + + /// Parses the legacy renderer selection. + private static Renderer parseLegacyRenderer(JsonObject source) { + JsonElement renderer = source.get("renderer"); + if (renderer != null && !renderer.isJsonNull()) { + Renderer parsed = parseLegacyRenderer(renderer); + if (parsed != null) { + return parsed; + } + } + + return JsonUtils.getBoolean(source, "useSoftwareRenderer", false) ? Renderer.OpenGL.LLVMPIPE : Renderer.DEFAULT; + } + + /// Parses a renderer from a legacy JSON element. + private static @Nullable Renderer parseLegacyRenderer(JsonElement element) { + if (element instanceof JsonPrimitive primitive && primitive.isString()) { + return Renderer.of(primitive.getAsString()); + } + + if (element.isJsonObject()) { + JsonElement name = element.getAsJsonObject().get("name"); + if (name instanceof JsonPrimitive primitive && primitive.isString()) { + return Renderer.of(primitive.getAsString()); + } + } + + return null; + } + + /// Parses the graphics API selection with renderer-derived fallback. + private static GraphicsAPI parseGraphicsBackend(JsonObject source, Renderer renderer) { + String name = JsonUtils.getString(source, "graphicsBackend", null); + if (name != null) { + try { + return GraphicsAPI.valueOf(name.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ignored) { + } + } + + return renderer instanceof Renderer.Driver driver ? driver.api() : GraphicsAPI.DEFAULT; + } + + /// Parses the legacy icon selection with frozen ordinal order. + private static VersionIconType parseLegacyVersionIconType(@Nullable JsonObject source) { + JsonPrimitive primitive = JsonUtils.getPrimitive(source, "versionIcon"); + if (primitive == null) { + return VersionIconType.DEFAULT; + } + + try { + if (primitive.isNumber()) { + int index = primitive.getAsInt(); + return index >= 0 && index < LEGACY_VERSION_ICON_TYPES.length + ? LEGACY_VERSION_ICON_TYPES[index] + : VersionIconType.DEFAULT; + } + + String value = primitive.getAsString(); + for (VersionIconType iconType : LEGACY_VERSION_ICON_TYPES) { + if (iconType.name().equalsIgnoreCase(value)) { + return iconType; + } + } + } catch (RuntimeException ignored) { + } + + return VersionIconType.DEFAULT; + } + + /// Reads an enum property from either ordinal or name. + private static > E parseEnum(@Nullable JsonObject source, String name, Class type, E defaultValue) { + JsonPrimitive primitive = JsonUtils.getPrimitive(source, name); + if (primitive == null) { + return defaultValue; + } + + E[] constants = type.getEnumConstants(); + try { + if (primitive.isNumber()) { + int index = primitive.getAsInt(); + return index >= 0 && index < constants.length ? constants[index] : defaultValue; + } + + String value = primitive.getAsString(); + for (E constant : constants) { + if (constant.name().equalsIgnoreCase(value)) { + return constant; + } + } + } catch (RuntimeException ignored) { + } + + return defaultValue; + } + + /// Returns an empty string for `null` values. + private static String empty(@Nullable String value) { + return value != null ? value : ""; + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Profile.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Profile.java index d37d1787b18..1888fa10f58 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Profile.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Profile.java @@ -17,147 +17,121 @@ */ package org.jackhuang.hmcl.setting; +import com.github.f4b6a3.uuid.alt.GUID; import com.google.gson.*; import com.google.gson.annotations.JsonAdapter; import javafx.application.Platform; import javafx.beans.InvalidationListener; import javafx.beans.Observable; -import javafx.beans.property.*; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.event.EventBus; -import org.jackhuang.hmcl.event.EventPriority; -import org.jackhuang.hmcl.event.RefreshedVersionsEvent; import org.jackhuang.hmcl.game.HMCLCacheRepository; import org.jackhuang.hmcl.game.HMCLGameRepository; -import org.jackhuang.hmcl.game.Version; -import org.jackhuang.hmcl.ui.WeakListenerHolder; +import org.jackhuang.hmcl.util.PortablePath; import org.jackhuang.hmcl.util.ToStringBuilder; import org.jackhuang.hmcl.util.javafx.ObservableHelper; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; import java.lang.reflect.Type; -import java.nio.file.Path; +import java.util.Objects; import java.util.Optional; import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating; -import static org.jackhuang.hmcl.ui.FXUtils.runInFX; /** * * @author huangyuhui */ @JsonAdapter(Profile.Serializer.class) +@NotNullByDefault public final class Profile implements Observable { - private final WeakListenerHolder listenerHolder = new WeakListenerHolder(); private final HMCLGameRepository repository; - private final StringProperty selectedVersion = new SimpleStringProperty(); + /// The stable profile ID. + private final GUID id; - public StringProperty selectedVersionProperty() { - return selectedVersion; + /// Returns the stable profile ID. + public GUID getId() { + return id; } - public String getSelectedVersion() { - return selectedVersion.get(); - } - - public void setSelectedVersion(String selectedVersion) { - this.selectedVersion.set(selectedVersion); - } - - private final ObjectProperty gameDir; - - public ObjectProperty gameDirProperty() { - return gameDir; - } - - public Path getGameDir() { - return gameDir.get(); - } + /// The game directory path. + private final ObjectProperty path; - public void setGameDir(Path gameDir) { - this.gameDir.set(gameDir); + /// Returns the game directory path property. + public ObjectProperty pathProperty() { + return path; } - private final ReadOnlyObjectWrapper global = new ReadOnlyObjectWrapper<>(this, "global"); - - public ReadOnlyObjectProperty globalProperty() { - return global.getReadOnlyProperty(); + /// Returns the game directory path. + public PortablePath getPath() { + return path.get(); } - public VersionSetting getGlobal() { - return global.get(); + /// Sets the game directory path. + public void setPath(PortablePath path) { + this.path.set(Objects.requireNonNull(path)); } + /// The custom profile name, or `null` for profiles without a stored name. private final SimpleStringProperty name; + /// Returns the custom profile name property. public StringProperty nameProperty() { return name; } - public String getName() { + /// Returns the custom profile name, or `null` when no name is stored. + public @Nullable String getName() { return name.get(); } - public void setName(String name) { + /// Sets the custom profile name. + public void setName(@Nullable String name) { this.name.set(name); } - private final BooleanProperty useRelativePath = new SimpleBooleanProperty(this, "useRelativePath", false); - - public BooleanProperty useRelativePathProperty() { - return useRelativePath; - } + /// The migrated legacy game settings preset ID, or `null` when this profile uses the default preset. + private final ObjectProperty<@Nullable GUID> legacyGameSettings; - public boolean isUseRelativePath() { - return useRelativePath.get(); + /// Returns the migrated legacy game settings preset ID property. + public ObjectProperty<@Nullable GUID> legacyGameSettingsProperty() { + return legacyGameSettings; } - public void setUseRelativePath(boolean useRelativePath) { - this.useRelativePath.set(useRelativePath); + /// Returns the migrated legacy game settings preset ID, or `null` when this profile uses the default preset. + public @Nullable GUID getLegacyGameSettings() { + return legacyGameSettings.get(); } - public Profile(String name) { - this(name, Path.of(".minecraft")); + /// Sets the migrated legacy game settings preset ID. + public void setLegacyGameSettings(@Nullable GUID legacyGameSettings) { + this.legacyGameSettings.set(legacyGameSettings); } - public Profile(String name, Path initialGameDir) { - this(name, initialGameDir, new VersionSetting()); + /// Creates a profile. + public Profile(GUID id, @Nullable String name, PortablePath path) { + this(id, name, path, null); } - public Profile(String name, Path initialGameDir, VersionSetting global) { - this(name, initialGameDir, global, null, false); - } - - public Profile(String name, Path initialGameDir, VersionSetting global, String selectedVersion, boolean useRelativePath) { + /// Creates a profile. + public Profile(GUID id, @Nullable String name, PortablePath path, @Nullable GUID legacyGameSettings) { + this.id = Objects.requireNonNull(id); this.name = new SimpleStringProperty(this, "name", name); - gameDir = new SimpleObjectProperty<>(this, "gameDir", initialGameDir); - repository = new HMCLGameRepository(this, initialGameDir); - this.global.set(global == null ? new VersionSetting() : global); - this.selectedVersion.set(selectedVersion); - this.useRelativePath.set(useRelativePath); + this.path = new SimpleObjectProperty<>(this, "path", Objects.requireNonNull(path)); + this.legacyGameSettings = new SimpleObjectProperty<>(this, "legacyGameSettings", legacyGameSettings); + repository = new HMCLGameRepository(this, path.toPath()); - gameDir.addListener((a, b, newValue) -> repository.changeDirectory(newValue)); - this.selectedVersion.addListener(o -> checkSelectedVersion()); - listenerHolder.add(EventBus.EVENT_BUS.channel(RefreshedVersionsEvent.class).registerWeak(event -> checkSelectedVersion(), EventPriority.HIGHEST)); + this.path.addListener((a, b, newValue) -> repository.changeDirectory(newValue.toPath())); addPropertyChangedListener(onInvalidating(this::invalidate)); } - private void checkSelectedVersion() { - runInFX(() -> { - if (!repository.isLoaded()) return; - String newValue = selectedVersion.get(); - if (!repository.hasVersion(newValue)) { - Optional version = repository.getVersions().stream().findFirst().map(Version::getId); - if (version.isPresent()) - selectedVersion.setValue(version.get()); - else if (newValue != null) - selectedVersion.setValue(null); - } - }); - } - public HMCLGameRepository getRepository() { return repository; } @@ -170,29 +144,21 @@ public DefaultDependencyManager getDependency(DownloadProvider downloadProvider) return new DefaultDependencyManager(repository, downloadProvider, HMCLCacheRepository.REPOSITORY); } - public VersionSetting getVersionSetting(String id) { - return repository.getVersionSetting(id); - } - @Override public String toString() { return new ToStringBuilder(this) - .append("gameDir", getGameDir()) + .append("path", getPath()) .append("name", getName()) - .append("useRelativePath", isUseRelativePath()) .toString(); } private void addPropertyChangedListener(InvalidationListener listener) { name.addListener(listener); - global.addListener(listener); - gameDir.addListener(listener); - useRelativePath.addListener(listener); - global.get().addListener(listener); - selectedVersion.addListener(listener); + path.addListener(listener); + legacyGameSettings.addListener(listener); } - private ObservableHelper observableHelper = new ObservableHelper(this); + private final ObservableHelper observableHelper = new ObservableHelper(this); @Override public void addListener(InvalidationListener listener) { @@ -208,49 +174,47 @@ private void invalidate() { Platform.runLater(observableHelper::invalidate); } - public static class ProfileVersion { - private final Profile profile; - private final String version; - - public ProfileVersion(Profile profile, String version) { - this.profile = profile; - this.version = version; - } - - public Profile getProfile() { - return profile; - } - - public String getVersion() { - return version; - } + public record ProfileVersion(Profile profile, @Nullable String version) { } public static final class Serializer implements JsonSerializer, JsonDeserializer { @Override - public JsonElement serialize(Profile src, Type typeOfSrc, JsonSerializationContext context) { + public JsonElement serialize(@Nullable Profile src, Type typeOfSrc, JsonSerializationContext context) { if (src == null) return JsonNull.INSTANCE; JsonObject jsonObject = new JsonObject(); - jsonObject.add("global", context.serialize(src.getGlobal())); - jsonObject.addProperty("gameDir", src.getGameDir().toString()); - jsonObject.addProperty("useRelativePath", src.isUseRelativePath()); - jsonObject.addProperty("selectedMinecraftVersion", src.getSelectedVersion()); + jsonObject.add("id", context.serialize(src.getId(), GUID.class)); + if (src.getName() != null) { + jsonObject.addProperty("name", src.getName()); + } + jsonObject.add("path", context.serialize(src.getPath(), PortablePath.class)); + if (src.getLegacyGameSettings() != null) { + jsonObject.add("legacyGameSettings", context.serialize(src.getLegacyGameSettings(), GUID.class)); + } return jsonObject; } @Override - public Profile deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { + public @Nullable Profile deserialize(@Nullable JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { if (!(json instanceof JsonObject obj)) return null; - String gameDir = Optional.ofNullable(obj.get("gameDir")).map(JsonElement::getAsString).orElse(""); + GUID id = context.deserialize(obj.get("id"), GUID.class); + if (id == null) { + throw new JsonParseException("Profile ID cannot be null"); + } else if (GUID.NIL.equals(id)) { + throw new JsonParseException("Profile ID cannot be nil"); + } + PortablePath path = context.deserialize(obj.get("path"), PortablePath.class); + if (path == null) { + String gameDir = Optional.ofNullable(obj.get("gameDir")).map(JsonElement::getAsString).orElse(""); + path = PortablePath.of(gameDir); + } - return new Profile("Default", - Path.of(gameDir), - context.deserialize(obj.get("global"), VersionSetting.class), - Optional.ofNullable(obj.get("selectedMinecraftVersion")).map(JsonElement::getAsString).orElse(""), - Optional.ofNullable(obj.get("useRelativePath")).map(JsonElement::getAsBoolean).orElse(false)); + return new Profile(id, + Optional.ofNullable(obj.get("name")).map(JsonElement::getAsString).orElse(null), + path, + context.deserialize(obj.get("legacyGameSettings"), GUID.class)); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Profiles.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Profiles.java index b4af9d5c665..ed480f89db8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Profiles.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Profiles.java @@ -17,156 +17,245 @@ */ package org.jackhuang.hmcl.setting; +import com.github.f4b6a3.uuid.alt.GUID; import javafx.application.Platform; -import javafx.beans.Observable; import javafx.beans.property.*; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.event.EventBus; import org.jackhuang.hmcl.event.RefreshedVersionsEvent; +import org.jackhuang.hmcl.game.Version; +import org.jackhuang.hmcl.util.PortablePath; +import org.jetbrains.annotations.Nullable; -import java.nio.file.Path; import java.util.ArrayList; import java.util.HashSet; import java.util.List; -import java.util.TreeMap; +import java.util.Objects; +import java.util.Optional; import java.util.function.Consumer; -import static javafx.collections.FXCollections.observableArrayList; -import static org.jackhuang.hmcl.setting.ConfigHolder.config; import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating; import static org.jackhuang.hmcl.ui.FXUtils.runInFX; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public final class Profiles { - public static final String DEFAULT_PROFILE = "Default"; - public static final String HOME_PROFILE = "Home"; + /// The default current-workspace game directory path. + private static final PortablePath CURRENT_PROFILE_PATH = PortablePath.of(".minecraft"); + + /// The default user-home game directory path. + private static final PortablePath HOME_PROFILE_PATH = PortablePath.fromPath(Metadata.MINECRAFT_DIRECTORY); private Profiles() { } - public static String getProfileDisplayName(Profile profile) { - return switch (profile.getName()) { - case Profiles.DEFAULT_PROFILE -> i18n("profile.default"); - case Profiles.HOME_PROFILE -> i18n("profile.home"); - default -> profile.getName(); - }; + /// Creates a profile ID that does not collide with existing profiles. + public static GUID newProfileId() { + return newProfileId(new HashSet<>()); } - private static final ObservableList profiles = observableArrayList(profile -> new Observable[] { profile }); - private static final ReadOnlyListWrapper profilesWrapper = new ReadOnlyListWrapper<>(profiles); + /// Creates a profile ID that does not collide with existing profiles or reserved IDs. + private static GUID newProfileId(HashSet reservedIds) { + GUID id; + do { + id = GUID.v7(); + } while (hasProfileId(id) || reservedIds.contains(id)); + return id; + } - private static final ObjectProperty selectedProfile = new SimpleObjectProperty() { - { - profiles.addListener(onInvalidating(this::invalidated)); + /// Returns whether an existing profile uses the given ID. + private static boolean hasProfileId(GUID id) { + for (Profile profile : SettingsManager.getGameDirectories()) { + if (id.equals(profile.getId())) { + return true; + } + } + return false; + } + + public static String getProfileDisplayName(Profile profile) { + String name = profile.getName(); + if (name != null) { + return name; + } + + if (isProfilePath(profile, CURRENT_PROFILE_PATH)) { + return i18n("profile.default"); + } + if (isProfilePath(profile, HOME_PROFILE_PATH)) { + return i18n("profile.home"); } + return profile.getId().toString(); + } + + /// Returns whether the profile uses the given path. + private static boolean isProfilePath(Profile profile, PortablePath expectedPath) { + PortablePath actualPath = profile.getPath(); + return actualPath.isAbsolute() == expectedPath.isAbsolute() + && actualPath.getPath().equals(expectedPath.getPath()); + } + + private static final ReadOnlyListWrapper profilesWrapper = + new ReadOnlyListWrapper<>(FXCollections.emptyObservableList()); + + /// Whether default profile creation has already been scheduled on the FX thread. + private static boolean creatingDefaultProfiles = false; + + private static final ObjectProperty selectedProfile = new SimpleObjectProperty<>() { @Override protected void invalidated() { - if (!initialized) - return; + refreshSelectedProfile(); + } + }; - Profile profile = get(); + private static void refreshSelectedProfile() { + if (!initialized) + return; - if (profiles.isEmpty()) { - if (profile != null) { - set(null); - return; - } - } else { - if (!profiles.contains(profile)) { - set(profiles.get(0)); - return; - } - } + ObservableList profiles = SettingsManager.getGameDirectories(); + Profile profile = selectedProfile.get(); - config().setSelectedProfile(profile == null ? "" : profile.getName()); + if (profiles.isEmpty()) { if (profile != null) { - if (profile.getRepository().isLoaded()) - selectedVersion.bind(profile.selectedVersionProperty()); - else { - selectedVersion.unbind(); - selectedVersion.set(null); - // bind when repository was reloaded. - profile.getRepository().refreshVersionsAsync().start(); - } + selectedProfile.set(null); + return; + } + } else { + if (!profiles.contains(profile)) { + selectedProfile.set(profiles.get(0)); + return; + } + } + + SettingsManager.setSelectedGameDirectory(profile == null ? null : profile.getId()); + if (profile != null) { + if (profile.getRepository().isLoaded()) { + refreshSelectedVersion(profile); } else { - selectedVersion.unbind(); selectedVersion.set(null); + // bind when repository was reloaded. + profile.getRepository().refreshVersionsAsync().start(); } + } else { + selectedVersion.set(null); } - }; + } + + private static void refreshSelectedVersion(Profile profile) { + String version = SettingsManager.getSelectedInstance(profile.getId()); + if (!profile.getRepository().hasVersion(version)) { + Optional fallback = profile.getRepository().getVersions().stream() + .findFirst() + .map(Version::getId); + version = fallback.orElse(null); + if (!Objects.equals(SettingsManager.getSelectedInstance(profile.getId()), version)) { + SettingsManager.setSelectedInstance(profile.getId(), version); + } + } + selectedVersion.set(version); + } private static void checkProfiles() { - if (profiles.isEmpty()) { - Profile current = new Profile(Profiles.DEFAULT_PROFILE, Path.of(".minecraft"), new VersionSetting(), null, true); - Profile home = new Profile(Profiles.HOME_PROFILE, Metadata.MINECRAFT_DIRECTORY); - Platform.runLater(() -> profiles.addAll(current, home)); + if (creatingDefaultProfiles) { + return; + } + + ObservableList profiles = SettingsManager.getGameDirectories(); + HashSet reservedIds = new HashSet<>(); + ArrayList missingProfiles = new ArrayList<>(2); + + if (profiles.stream().noneMatch(profile -> isProfilePath(profile, CURRENT_PROFILE_PATH))) { + GUID id = newProfileId(reservedIds); + reservedIds.add(id); + missingProfiles.add(new Profile(id, null, CURRENT_PROFILE_PATH)); + } + if (profiles.stream().noneMatch(profile -> isProfilePath(profile, HOME_PROFILE_PATH))) { + GUID id = newProfileId(reservedIds); + reservedIds.add(id); + missingProfiles.add(new Profile(id, null, HOME_PROFILE_PATH)); + } + + if (!missingProfiles.isEmpty()) { + creatingDefaultProfiles = true; + Platform.runLater(() -> { + try { + List profilesToAdd = missingProfiles.stream() + .filter(profile -> profiles.stream().noneMatch(existing -> isProfilePath(existing, profile.getPath()))) + .toList(); + for (Profile profile : profilesToAdd) { + profiles.add(getDefaultProfileInsertionIndex(profiles, profile), profile); + } + } finally { + creatingDefaultProfiles = false; + } + }); } } + /// Returns the insertion index for a generated default profile. + private static int getDefaultProfileInsertionIndex(ObservableList profiles, Profile profile) { + if (isProfilePath(profile, CURRENT_PROFILE_PATH)) { + for (int i = 0; i < profiles.size(); i++) { + if (isProfilePath(profiles.get(i), HOME_PROFILE_PATH)) { + return i; + } + } + } + + return profiles.size(); + } + /** * True if {@link #init()} hasn't been called. */ private static boolean initialized = false; static { - profiles.addListener(onInvalidating(Profiles::updateProfileStorages)); - profiles.addListener(onInvalidating(Profiles::checkProfiles)); - selectedProfile.addListener((a, b, newValue) -> { if (newValue != null) newValue.getRepository().refreshVersionsAsync().start(); }); } - private static void updateProfileStorages() { - // don't update the underlying storage before data loading is completed - // otherwise it might cause data loss - if (!initialized) - return; - // update storage - TreeMap newConfigurations = new TreeMap<>(); - for (Profile profile : profiles) { - newConfigurations.put(profile.getName(), profile); - } - config().getConfigurations().setValue(FXCollections.observableMap(newConfigurations)); - } - - /** - * Called when it's ready to load profiles from {@link ConfigHolder#config()}. - */ - static void init() { + /// Called when it's ready to load profiles from [SettingsManager]. + public static void init() { if (initialized) throw new IllegalStateException("Already initialized"); - HashSet names = new HashSet<>(); - config().getConfigurations().forEach((name, profile) -> { - if (!names.add(name)) return; - profiles.add(profile); - profile.setName(name); - }); + profilesWrapper.set(SettingsManager.getGameDirectories()); + removeDuplicateProfiles(SettingsManager.getGameDirectories()); + SettingsManager.getGameDirectories().addListener(onInvalidating(Profiles::refreshSelectedProfile)); + SettingsManager.getGameDirectories().addListener(onInvalidating(Profiles::checkProfiles)); + SettingsManager.getSelectedInstance().addListener(onInvalidating(() -> { + Profile profile = selectedProfile.get(); + if (profile != null && profile.getRepository().isLoaded()) { + refreshSelectedVersion(profile); + } + })); checkProfiles(); + migrateGameSettings(); // Platform.runLater is necessary or profiles will be empty // since checkProfiles adds 2 base profile later. Platform.runLater(() -> { initialized = true; + @Nullable GUID selectedId = SettingsManager.getSelectedGameDirectory(); selectedProfile.set( - profiles.stream() - .filter(it -> it.getName().equals(config().getSelectedProfile())) + SettingsManager.getGameDirectories().stream() + .filter(it -> it.getId().equals(selectedId)) .findFirst() - .orElse(profiles.get(0))); + .orElse(SettingsManager.getGameDirectories().isEmpty() ? null : SettingsManager.getGameDirectories().get(0))); }); EventBus.EVENT_BUS.channel(RefreshedVersionsEvent.class).registerWeak(event -> { runInFX(() -> { Profile profile = selectedProfile.get(); if (profile != null && profile.getRepository() == event.getSource()) { - selectedVersion.bind(profile.selectedVersionProperty()); + refreshSelectedVersion(profile); for (Consumer listener : versionsListeners) listener.accept(profile); } @@ -174,8 +263,25 @@ static void init() { }); } + private static void removeDuplicateProfiles(ObservableList profiles) { + HashSet ids = new HashSet<>(); + HashSet names = new HashSet<>(); + profiles.removeIf(profile -> { + String name = profile.getName(); + return !ids.add(profile.getId()) || (name != null && !names.add(name)); + }); + } + + private static void migrateGameSettings() { + if (SettingsManager.getGameSettings().isEmpty()) { + SettingsManager.getDefaultGameSettingsPresetOrCreate(); + } else if (SettingsManager.getGameSettings(SettingsManager.getDefaultGameSettingsPreset()) == null) { + SettingsManager.setDefaultGameSettingsPreset(SettingsManager.getGameSettings().get(0).idProperty().getValue()); + } + } + public static ObservableList getProfiles() { - return profiles; + return SettingsManager.getGameDirectories(); } public static ReadOnlyListProperty profilesProperty() { @@ -201,10 +307,31 @@ public static ReadOnlyStringProperty selectedVersionProperty() { } // Guaranteed that the repository is loaded. - public static String getSelectedVersion() { + public static @Nullable String getSelectedInstance() { return selectedVersion.get(); } + /// Returns the selected instance ID for the given profile. + public static @Nullable String getSelectedInstance(Profile profile) { + return SettingsManager.getSelectedInstance(profile.getId()); + } + + /// Sets the selected instance ID for the currently selected profile. + public static void setSelectedInstance(@Nullable String instance) { + Profile profile = selectedProfile.get(); + if (profile != null) { + setSelectedInstance(profile, instance); + } + } + + /// Sets the selected instance ID for the given profile. + public static void setSelectedInstance(Profile profile, @Nullable String instance) { + SettingsManager.setSelectedInstance(profile.getId(), instance); + if (profile == selectedProfile.get()) { + selectedVersion.set(SettingsManager.getSelectedInstance(profile.getId())); + } + } + private static final List> versionsListeners = new ArrayList<>(4); public static void registerVersionsListener(Consumer listener) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/ProxyManager.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/ProxyManager.java index 624613bd84c..d2337ae5fbf 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/ProxyManager.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/ProxyManager.java @@ -29,7 +29,7 @@ import java.util.List; import java.util.Objects; -import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.setting.SettingsManager.settings; import static org.jackhuang.hmcl.util.logging.Logger.LOG; public final class ProxyManager { @@ -48,10 +48,10 @@ public final class ProxyManager { private static volatile @Nullable SimpleAuthenticator defaultAuthenticator = null; private static ProxySelector getProxySelector() { - if (config().hasProxy()) { - Proxy.Type proxyType = config().getProxyType(); - String host = config().getProxyHost(); - int port = config().getProxyPort(); + if (settings().hasProxyProperty().get()) { + Proxy.Type proxyType = settings().proxyTypeProperty().get(); + String host = settings().proxyHostProperty().get(); + int port = settings().proxyPortProperty().get(); if (proxyType == Proxy.Type.DIRECT || StringUtils.isBlank(host)) { return NO_PROXY; @@ -67,9 +67,9 @@ private static ProxySelector getProxySelector() { } private static SimpleAuthenticator getAuthenticator() { - if (config().hasProxy() && config().hasProxyAuth()) { - String username = config().getProxyUser(); - String password = config().getProxyPass(); + if (settings().hasProxyProperty().get() && settings().hasProxyAuthProperty().get()) { + String username = settings().proxyUserProperty().get(); + String password = settings().proxyPasswordProperty().get(); if (username != null || password != null) return new SimpleAuthenticator( @@ -82,7 +82,8 @@ private static SimpleAuthenticator getAuthenticator() { return null; } - static void init() { + /// Installs proxy and authentication handlers backed by launcher settings. + public static void init() { ProxySelector.setDefault(new ProxySelector() { @Override public List select(URI uri) { @@ -104,17 +105,17 @@ protected PasswordAuthentication getPasswordAuthentication() { defaultProxySelector = getProxySelector(); InvalidationListener updateProxySelector = observable -> defaultProxySelector = getProxySelector(); - config().proxyTypeProperty().addListener(updateProxySelector); - config().proxyHostProperty().addListener(updateProxySelector); - config().proxyPortProperty().addListener(updateProxySelector); - config().hasProxyProperty().addListener(updateProxySelector); + settings().proxyTypeProperty().addListener(updateProxySelector); + settings().proxyHostProperty().addListener(updateProxySelector); + settings().proxyPortProperty().addListener(updateProxySelector); + settings().hasProxyProperty().addListener(updateProxySelector); defaultAuthenticator = getAuthenticator(); InvalidationListener updateAuthenticator = observable -> defaultAuthenticator = getAuthenticator(); - config().hasProxyProperty().addListener(updateAuthenticator); - config().hasProxyAuthProperty().addListener(updateAuthenticator); - config().proxyUserProperty().addListener(updateAuthenticator); - config().proxyPassProperty().addListener(updateAuthenticator); + settings().hasProxyProperty().addListener(updateAuthenticator); + settings().hasProxyAuthProperty().addListener(updateAuthenticator); + settings().proxyUserProperty().addListener(updateAuthenticator); + settings().proxyPasswordProperty().addListener(updateAuthenticator); FetchTask.notifyInitialized(); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Settings.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Settings.java deleted file mode 100644 index 6c61e9acfa4..00000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Settings.java +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.setting; - -import javafx.beans.binding.Bindings; -import org.jackhuang.hmcl.Metadata; -import org.jackhuang.hmcl.game.HMCLCacheRepository; -import org.jackhuang.hmcl.ui.animation.AnimationUtils; -import org.jackhuang.hmcl.util.CacheRepository; -import org.jackhuang.hmcl.util.io.FileUtils; - -import static org.jackhuang.hmcl.setting.ConfigHolder.config; - -public final class Settings { - - private static Settings instance; - - public static Settings instance() { - if (instance == null) { - throw new IllegalStateException("Settings hasn't been initialized"); - } - return instance; - } - - /** - * Should be called from {@link ConfigHolder#init()}. - */ - static void init() { - instance = new Settings(); - } - - private Settings() { - DownloadProviders.init(); - ProxyManager.init(); - Accounts.init(); - Profiles.init(); - AuthlibInjectorServers.init(); - AnimationUtils.init(); - - CacheRepository.setInstance(HMCLCacheRepository.REPOSITORY); - HMCLCacheRepository.REPOSITORY.directoryProperty().bind(Bindings.createStringBinding(() -> { - if (FileUtils.canCreateDirectory(getCommonDirectory())) { - return getCommonDirectory(); - } else { - return getDefaultCommonDirectory(); - } - }, config().commonDirectoryProperty(), config().commonDirTypeProperty())); - } - - public static String getDefaultCommonDirectory() { - return Metadata.MINECRAFT_DIRECTORY.toString(); - } - - public String getCommonDirectory() { - switch (config().getCommonDirType()) { - case DEFAULT: - return getDefaultCommonDirectory(); - case CUSTOM: - return config().getCommonDirectory(); - default: - return null; - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/SettingsManager.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/SettingsManager.java new file mode 100644 index 00000000000..8f281cf14d7 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/SettingsManager.java @@ -0,0 +1,821 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2020 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.setting; + +import com.github.f4b6a3.uuid.alt.GUID; +import com.google.gson.JsonParseException; +import com.google.gson.JsonObject; +import javafx.beans.property.ObjectProperty; +import javafx.collections.ObservableList; +import javafx.collections.ObservableMap; +import org.jackhuang.hmcl.Metadata; +import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; +import org.jackhuang.hmcl.util.FileSaver; +import org.jackhuang.hmcl.util.PortablePath; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.i18n.I18n; +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.platform.OperatingSystem; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.UnknownNullability; + +import java.io.IOException; +import java.nio.file.*; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// Owns the process-wide configuration and detached workspace settings instances. +@NotNullByDefault +public final class SettingsManager { + + /// Prevents instantiation. + private SettingsManager() { + } + + /// The user settings path shared by all workspaces. + public static final Path USER_SETTINGS_LOCATION = Metadata.HMCL_USER_HOME.resolve("user-settings.json"); + + /// The current per-workspace config path. + private static final Path SETTINGS_LOCATION = Metadata.HMCL_LOCAL_HOME.resolve("settings.json"); + + /// The current per-workspace launcher state path. + private static final Path STATE_LOCATION = Metadata.HMCL_LOCAL_HOME.resolve("launcher-state.json"); + + /// The current per-workspace authlib-injector server list path. + private static final Path AUTHLIB_INJECTOR_SERVERS_LOCATION = + Metadata.HMCL_LOCAL_HOME.resolve("authlib-injector-servers.json"); + + /// The current per-workspace game directories path. + private static final Path LOCAL_GAME_DIRECTORIES_LOCATION = + Metadata.HMCL_LOCAL_HOME.resolve("game-directories.json"); + + /// The current shared game directories path. + private static final Path GLOBAL_GAME_DIRECTORIES_LOCATION = + Metadata.HMCL_USER_HOME.resolve("user-game-directories.json"); + + /// The current per-workspace game settings path. + private static final Path GAME_SETTINGS_LOCATION = + Metadata.HMCL_LOCAL_HOME.resolve("game-settings.json"); + + /// The current per-workspace account storage path. + private static final Path GAME_ACCOUNTS_LOCATION = + Metadata.HMCL_LOCAL_HOME.resolve("game-accounts.json"); + + /// The per-workspace game directory file helper. + private static final JsonSettingFile LOCAL_GAME_DIRECTORIES_FILE = new JsonSettingFile<>( + LOCAL_GAME_DIRECTORIES_LOCATION, + "game directories", + GameDirectories.class, + GameDirectories.CURRENT_SCHEMA, + GameDirectories::new); + + /// The shared game directory file helper. + private static final JsonSettingFile GLOBAL_GAME_DIRECTORIES_FILE = new JsonSettingFile<>( + GLOBAL_GAME_DIRECTORIES_LOCATION, + "user game directories", + GameDirectories.class, + GameDirectories.CURRENT_SCHEMA, + GameDirectories::new); + + /// The detached game settings file helper. + private static final JsonSettingFile GAME_SETTINGS_FILE = new JsonSettingFile<>( + GAME_SETTINGS_LOCATION, + "game settings", + GameSettingsPresets.class, + GameSettingsPresets.CURRENT_SCHEMA, + GameSettingsPresets::new); + + /// The detached account storage file helper. + private static final JsonSettingFile GAME_ACCOUNTS_FILE = new JsonSettingFile<>( + GAME_ACCOUNTS_LOCATION, + "game accounts", + AccountStorages.class, + AccountStorages.CURRENT_SCHEMA, + AccountStorages::new); + + /// The detached launcher state file helper. + private static final JsonSettingFile STATE_FILE = new JsonSettingFile<>( + STATE_LOCATION, + "launcher state", + LauncherState.class, + LauncherState.CURRENT_SCHEMA, + LauncherState::new); + + /// The detached authlib-injector server list file helper. + private static final JsonSettingFile AUTHLIB_INJECTOR_SERVERS_FILE = new JsonSettingFile<>( + AUTHLIB_INJECTOR_SERVERS_LOCATION, + "authlib-injector servers", + AuthlibInjectorServerList.class, + AuthlibInjectorServerList.CURRENT_SCHEMA, + AuthlibInjectorServerList::createDefault); + + /// The user settings file helper. + private static final JsonSettingFile USER_SETTINGS_FILE = new JsonSettingFile<>( + USER_SETTINGS_LOCATION, + "user settings", + UserSettings.class, + UserSettings.CURRENT_SCHEMA, + UserSettings::new); + + /// The loaded per-workspace config instance. + private static @UnknownNullability LauncherSettings configInstance; + + /// The loaded user settings instance. + private static @UnknownNullability UserSettings userSettingsInstance; + + /// The loaded detached game directory store. + private static @UnknownNullability GameDirectories gameDirectories; + + /// Original storage location and path for loaded game directories. + private static final Map gameDirectorySources = new HashMap<>(); + + /// Whether the per-workspace game directories file may be overwritten. + private static boolean allowSaveLocalGameDirectories = false; + + /// Whether the shared game directories file may be overwritten. + private static boolean allowSaveGlobalGameDirectories = false; + + /// The loaded detached preset store. + private static @UnknownNullability GameSettingsPresets gameSettingsPresets; + + /// The loaded detached launcher state store. + private static @UnknownNullability LauncherState launcherState; + + /// The loaded detached authlib-injector server list store. + private static @UnknownNullability AuthlibInjectorServerList authlibInjectorServers; + + /// The loaded detached account storage store. + private static @UnknownNullability AccountStorages gameAccounts; + + /// Whether no current or legacy per-workspace config could be loaded. + private static boolean newlyCreated; + + /// Whether root is reading a per-workspace config owned by another user. + private static boolean ownerChanged = false; + + /// Whether a legacy config was newer than this build can safely overwrite. + private static boolean unsupportedVersion = false; + + /// Whether the per-workspace config file on disk is invalid and must be backed up + /// before being overwritten by the first successful save. + private static boolean needBackupSettings = false; + + /// Whether the per-workspace config should be saved after extracting detached data. + private static boolean needSaveSettings = false; + + /// Detached settings used as fallbacks when detached settings files do not exist yet. + private static LegacyConfigMigrator.DetachedSettings detachedSettingsFallback = + LegacyConfigMigrator.DetachedSettings.empty(); + + /// Returns the loaded per-workspace launcher settings. + public static LauncherSettings settings() { + if (configInstance == null) { + throw new IllegalStateException("Configuration hasn't been loaded"); + } + return configInstance; + } + + /// Returns the loaded user settings. + public static UserSettings userSettings() { + if (userSettingsInstance == null) { + throw new IllegalStateException("Configuration hasn't been loaded"); + } + return userSettingsInstance; + } + + /// Returns the loaded per-workspace launcher state. + public static LauncherState state() { + if (launcherState == null) { + throw new IllegalStateException("Launcher state hasn't been loaded"); + } + return launcherState; + } + + /// Returns the loaded per-workspace authlib-injector server list. + public static AuthlibInjectorServerList authlibInjectorServers() { + if (authlibInjectorServers == null) { + throw new IllegalStateException("Authlib-injector servers haven't been loaded"); + } + return authlibInjectorServers; + } + + /// Returns the current per-workspace config path. + public static Path configLocation() { + return SETTINGS_LOCATION; + } + + /// Returns the current per-workspace launcher state path. + public static Path stateLocation() { + return STATE_LOCATION; + } + + /// Returns the current per-workspace authlib-injector server list path. + public static Path authlibInjectorServersLocation() { + return AUTHLIB_INJECTOR_SERVERS_LOCATION; + } + + /// Returns the current per-workspace game directories path. + public static Path gameDirectoriesLocation() { + return LOCAL_GAME_DIRECTORIES_LOCATION; + } + + /// Returns the shared game directories path. + public static Path globalGameDirectoriesLocation() { + return GLOBAL_GAME_DIRECTORIES_LOCATION; + } + + /// Returns the current per-workspace game settings path. + public static Path gameSettingsLocation() { + return GAME_SETTINGS_LOCATION; + } + + /// Returns the current per-workspace account storage path. + public static Path gameAccountsLocation() { + return GAME_ACCOUNTS_LOCATION; + } + + /// Returns the loaded detached game directory store. + public static GameDirectories gameDirectories() { + if (gameDirectories == null) { + throw new IllegalStateException("Game directories haven't been loaded"); + } + return gameDirectories; + } + + /// Returns the loaded detached preset store. + public static GameSettingsPresets gameSettingsPresets() { + if (gameSettingsPresets == null) { + throw new IllegalStateException("Game settings presets haven't been loaded"); + } + return gameSettingsPresets; + } + + /// Returns the loaded detached account storage store. + static AccountStorages gameAccounts() { + if (gameAccounts == null) { + throw new IllegalStateException("Game accounts haven't been loaded"); + } + return gameAccounts; + } + + /// Returns the per-workspace authlib-injector servers. + public static ObservableList getAuthlibInjectorServers() { + return authlibInjectorServers().getServers(); + } + + /// Returns the per-workspace account storages. + public static ObservableList> getAccountStorages() { + return gameAccounts().getAccounts(); + } + + /// Serializes the per-workspace account storage file content. + public static String gameAccountsToJson() { + return JsonUtils.GSON.toJson(gameAccounts(), AccountStorages.class); + } + + /// Returns the merged game directories. + public static ObservableList getGameDirectories() { + return gameDirectories().getGameDirectories(); + } + + /// Returns the selected game directory ID property. + public static ObjectProperty<@Nullable GUID> selectedGameDirectoryProperty() { + return settings().selectedGameDirectoryProperty(); + } + + /// Returns the selected game directory ID. + public static @Nullable GUID getSelectedGameDirectory() { + return settings().selectedGameDirectoryProperty().get(); + } + + /// Sets the selected game directory ID. + public static void setSelectedGameDirectory(@Nullable GUID selectedGameDirectory) { + settings().selectedGameDirectoryProperty().set(selectedGameDirectory); + } + + /// Returns selected instance IDs keyed by game directory ID. + public static ObservableMap getSelectedInstance() { + return settings().getSelectedInstance(); + } + + /// Returns the selected instance ID for the given game directory ID. + public static @Nullable String getSelectedInstance(@Nullable GUID gameDirectoryId) { + return settings().getSelectedInstance(gameDirectoryId); + } + + /// Sets the selected instance ID for the given game directory ID. + public static void setSelectedInstance(@Nullable GUID gameDirectoryId, @Nullable String selectedInstance) { + settings().setSelectedInstance(gameDirectoryId, selectedInstance); + } + + /// Returns the reusable game setting presets. + public static ObservableList getGameSettings() { + return gameSettingsPresets().getPresets(); + } + + /// Returns the default game setting preset ID property. + public static ObjectProperty<@Nullable GUID> defaultGameSettingsPresetProperty() { + return settings().defaultGameSettingsPresetProperty(); + } + + /// Returns the default game setting preset ID. + public static @Nullable GUID getDefaultGameSettingsPreset() { + return settings().defaultGameSettingsPresetProperty().get(); + } + + /// Sets the default game setting preset ID. + public static void setDefaultGameSettingsPreset(@Nullable GUID defaultGameSettingsPreset) { + settings().defaultGameSettingsPresetProperty().set(defaultGameSettingsPreset); + } + + /// Returns the game setting preset with the given ID. + public static GameSettings.@Nullable Preset getGameSettings(@Nullable GUID id) { + return gameSettingsPresets().getPreset(id); + } + + /// Returns the default game setting preset, creating one when needed. + public static GameSettings.Preset getDefaultGameSettingsPresetOrCreate() { + GameSettings.Preset setting = getGameSettings(getDefaultGameSettingsPreset()); + if (setting != null) { + return setting; + } + + if (!getGameSettings().isEmpty()) { + setting = getGameSettings().get(0); + setDefaultGameSettingsPreset(setting.idProperty().getValue()); + return setting; + } + + setting = new GameSettings.Preset(gameSettingsPresets().newPresetId()); + setting.nameProperty().setValue(i18n("message.default")); + getGameSettings().add(setting); + setDefaultGameSettingsPreset(setting.idProperty().getValue()); + return setting; + } + + /// Returns whether this run created a new per-workspace config. + public static boolean isNewlyCreated() { + return newlyCreated; + } + + /// Returns whether root is reading a config owned by another user. + public static boolean isOwnerChanged() { + return ownerChanged; + } + + /// Returns whether the loaded legacy config should not be overwritten. + public static boolean isUnsupportedVersion() { + return unsupportedVersion; + } + + /// Loads configs, installs save listeners, and applies process-wide settings. + public static void init() throws IOException { + if (configInstance != null) { + throw new IllegalStateException("Configuration is already loaded"); + } + + LOG.info("Launcher settings location: " + SETTINGS_LOCATION); + + configInstance = loadConfig(); + if (!unsupportedVersion) { + configInstance.addListener(source -> { + // Back up the invalid on-disk file the first time we are about to overwrite it. + if (needBackupSettings) { + needBackupSettings = false; + backupInvalidConfig(SETTINGS_LOCATION); + } + FileSaver.save(SETTINGS_LOCATION, configInstance.toJson()); + }); + } + + loadUserSettings(); + + Locale.setDefault(settings().languageProperty().get().getLocale()); + I18n.setLocale(configInstance.languageProperty().get()); + LOG.setLogRetention(userSettings().logRetentionProperty().get()); + loadGameDirectories(detachedSettingsFallback.gameDirectories(), !unsupportedVersion); + loadGameSettingsPresets(detachedSettingsFallback.gameSettingsPresets(), !unsupportedVersion); + loadLauncherState(detachedSettingsFallback.launcherState(), !unsupportedVersion); + loadAuthlibInjectorServers(detachedSettingsFallback.authlibInjectorServers(), !unsupportedVersion); + loadGameAccounts(detachedSettingsFallback.accountStorages(), !unsupportedVersion); + + if (!unsupportedVersion && (newlyCreated || needSaveSettings)) { + LOG.info((newlyCreated ? "Creating" : "Updating") + " config file " + SETTINGS_LOCATION); + FileUtils.saveSafely(SETTINGS_LOCATION, configInstance.toJson()); + } + + checkWritable(SETTINGS_LOCATION); + } + + /// Loads the current per-workspace config or migrates a legacy config when needed. + private static LauncherSettings loadConfig() throws IOException { + if (Files.exists(SETTINGS_LOCATION)) { + checkOwner(SETTINGS_LOCATION); + + JsonObject jsonObject; + try { + jsonObject = JsonUtils.fromJsonFile(SETTINGS_LOCATION, JsonObject.class); + } catch (Exception e) { + needBackupSettings = true; + LOG.warning("Failed to read settings file: " + SETTINGS_LOCATION, e); + return new LauncherSettings(); + } + + if (jsonObject == null) { + LOG.warning("Settings file is empty: " + SETTINGS_LOCATION); + return new LauncherSettings(); + } + + JsonSchemaPolicy.Result schema = + JsonSchemaPolicy.check(SETTINGS_LOCATION, "settings file", jsonObject, LauncherSettings.CURRENT_SCHEMA); + if (!schema.allowSave()) { + unsupportedVersion = true; + } + if (!schema.readable()) { + return new LauncherSettings(); + } + + LegacyConfigMigrator.CurrentSettingsMigration migration = + LegacyConfigMigrator.migrateCurrentSettings(jsonObject); + detachedSettingsFallback = migration.detachedSettings(); + if (migration.changed()) { + needSaveSettings = true; + } + + try { + LauncherSettings settings = LauncherSettings.fromJson(jsonObject); + if (settings == null) { + return new LauncherSettings(); + } + + if (!schema.preserveSchema() && !LauncherSettings.CURRENT_SCHEMA.equals(settings.schemaProperty().get())) { + settings.schemaProperty().set(LauncherSettings.CURRENT_SCHEMA); + } + + return settings; + } catch (JsonParseException e) { + needBackupSettings = true; + LOG.warning("Failed to parse settings file: " + SETTINGS_LOCATION, e); + return new LauncherSettings(); + } + } else { + LegacyConfigMigrator.MigrationResult migrationResult = LegacyConfigMigrator.migrateLegacyConfig(); + if (migrationResult != null) { + LOG.info("Migrating settings from " + migrationResult.path() + " to " + SETTINGS_LOCATION); + detachedSettingsFallback = migrationResult.detachedSettings(); + FileUtils.saveSafely(SETTINGS_LOCATION, migrationResult.contentForMigration()); + return migrationResult.launcherSettings(); + } + } + + var newSettings = new LauncherSettings(); + newlyCreated = true; + return newSettings; + } + + /// Loads game directories and installs the save listener. + /// + /// @param fallbackGameDirectories the fallback store used when the local game directory file does not exist + /// @param allowSave whether the detached game directory file may be overwritten + private static void loadGameDirectories( + @Nullable GameDirectories fallbackGameDirectories, + boolean allowSave) throws IOException { + if (gameDirectories != null) { + throw new IllegalStateException("Game directories are already loaded"); + } + + LOG.info("Game directories location: " + LOCAL_GAME_DIRECTORIES_LOCATION); + LOG.info("User game directories location: " + GLOBAL_GAME_DIRECTORIES_LOCATION); + + boolean newlyCreatedLocal = !Files.exists(LOCAL_GAME_DIRECTORIES_LOCATION); + boolean newlyCreatedGlobal = !Files.exists(GLOBAL_GAME_DIRECTORIES_LOCATION); + JsonSettingFile.LoadResult globalResult = GLOBAL_GAME_DIRECTORIES_FILE.load(null); + JsonSettingFile.LoadResult localResult = LOCAL_GAME_DIRECTORIES_FILE.load(fallbackGameDirectories); + + gameDirectories = mergeGameDirectories(globalResult.value(), localResult.value()); + allowSaveLocalGameDirectories = allowSave && localResult.allowSave(); + allowSaveGlobalGameDirectories = allowSave && globalResult.allowSave(); + if (allowSaveLocalGameDirectories || allowSaveGlobalGameDirectories) { + gameDirectories.addListener(source -> saveGameDirectories()); + } + + if (newlyCreatedLocal && allowSaveLocalGameDirectories) { + LOG.info("Creating game directories file " + LOCAL_GAME_DIRECTORIES_LOCATION); + saveLocalGameDirectories(); + } + + if (newlyCreatedGlobal && allowSaveGlobalGameDirectories) { + LOG.info("Creating user game directories file " + GLOBAL_GAME_DIRECTORIES_LOCATION); + saveGlobalGameDirectories(); + } + } + + /// Merges shared and per-workspace game directories into the runtime store. + private static GameDirectories mergeGameDirectories(GameDirectories global, GameDirectories local) { + GameDirectories merged = new GameDirectories(); + gameDirectorySources.clear(); + for (Profile profile : global.getGameDirectories()) { + addMergedGameDirectory(merged, profile, GameDirectoryScope.GLOBAL); + } + for (Profile profile : local.getGameDirectories()) { + addMergedGameDirectory(merged, profile, GameDirectoryScope.LOCAL); + } + return merged; + } + + /// Adds one profile to the merged game directory store. + private static void addMergedGameDirectory(GameDirectories merged, Profile profile, GameDirectoryScope source) { + Objects.requireNonNull(merged); + Objects.requireNonNull(profile); + Objects.requireNonNull(source); + + GUID id = profile.getId(); + merged.getGameDirectories().removeIf(existing -> existing.getId().equals(id)); + merged.getGameDirectories().add(profile); + PortablePath path = profile.getPath(); + gameDirectorySources.put(id, new GameDirectorySource(source, path.getPath())); + } + + /// Saves the merged game directory store into the writable backing files. + private static void saveGameDirectories() { + if (allowSaveLocalGameDirectories) { + saveLocalGameDirectories(); + } + if (allowSaveGlobalGameDirectories) { + saveGlobalGameDirectories(); + } + updateGameDirectorySources(); + } + + /// Saves per-workspace game directories. + private static void saveLocalGameDirectories() { + LOCAL_GAME_DIRECTORIES_FILE.save(createScopedGameDirectories(GameDirectoryScope.LOCAL)); + } + + /// Saves shared game directories. + private static void saveGlobalGameDirectories() { + GLOBAL_GAME_DIRECTORIES_FILE.save(createScopedGameDirectories(GameDirectoryScope.GLOBAL)); + } + + /// Creates a game directory store containing only profiles that belong to the given scope. + private static GameDirectories createScopedGameDirectories(GameDirectoryScope scope) { + GameDirectories result = new GameDirectories(); + for (Profile profile : gameDirectories().getGameDirectories()) { + if (getGameDirectoryScope(profile) == scope) { + result.getGameDirectories().add(profile); + } + } + return result; + } + + /// Updates source tracking after saving the current merged directory store. + private static void updateGameDirectorySources() { + Map updated = new HashMap<>(); + for (Profile profile : gameDirectories().getGameDirectories()) { + PortablePath path = profile.getPath(); + updated.put(profile.getId(), new GameDirectorySource(getGameDirectoryScope(profile), path.getPath())); + } + gameDirectorySources.clear(); + gameDirectorySources.putAll(updated); + } + + /// Returns the target storage scope for a profile. + private static GameDirectoryScope getGameDirectoryScope(Profile profile) { + PortablePath path = profile.getPath(); + @Nullable GameDirectorySource source = gameDirectorySources.get(profile.getId()); + if (source != null && Objects.equals(source.path(), path.getPath())) { + return source.scope(); + } + + return path.isAbsolute() ? GameDirectoryScope.GLOBAL : GameDirectoryScope.LOCAL; + } + + /// Loads game settings presets and installs the save listener. + /// + /// @param fallbackGameSettingsPresets the fallback store used when the preset file does not exist + /// @param allowSave whether the detached preset file may be overwritten + private static void loadGameSettingsPresets( + @Nullable GameSettingsPresets fallbackGameSettingsPresets, + boolean allowSave) throws IOException { + if (gameSettingsPresets != null) { + throw new IllegalStateException("Game settings presets are already loaded"); + } + + LOG.info("Game settings location: " + GAME_SETTINGS_LOCATION); + + boolean newlyCreated = !Files.exists(GAME_SETTINGS_LOCATION); + JsonSettingFile.LoadResult result = + GAME_SETTINGS_FILE.load(fallbackGameSettingsPresets); + gameSettingsPresets = result.value(); + if (allowSave && result.allowSave()) { + GAME_SETTINGS_FILE.installAutoSave(gameSettingsPresets); + } + + if (newlyCreated && allowSave && result.allowSave()) { + LOG.info("Creating game settings file " + GAME_SETTINGS_LOCATION); + GAME_SETTINGS_FILE.save(gameSettingsPresets); + } + } + + /// Loads launcher state and installs the save listener. + /// + /// @param fallbackLauncherState the fallback state used when the launcher state file does not exist + /// @param allowSave whether the detached launcher state file may be overwritten + private static void loadLauncherState( + @Nullable LauncherState fallbackLauncherState, + boolean allowSave) throws IOException { + if (launcherState != null) { + throw new IllegalStateException("Launcher state is already loaded"); + } + + LOG.info("Launcher state location: " + STATE_LOCATION); + + boolean newlyCreated = !Files.exists(STATE_LOCATION); + JsonSettingFile.LoadResult result = + STATE_FILE.load(fallbackLauncherState); + launcherState = result.value(); + if (allowSave && result.allowSave()) { + STATE_FILE.installAutoSave(launcherState); + } + + if (newlyCreated && allowSave && result.allowSave()) { + LOG.info("Creating launcher state file " + STATE_LOCATION); + STATE_FILE.save(launcherState); + } + } + + /// Loads authlib-injector servers and installs the save listener. + /// + /// @param fallbackAuthlibInjectorServers the fallback list used when the server list file does not exist + /// @param allowSave whether the detached server list file may be overwritten + private static void loadAuthlibInjectorServers( + @Nullable AuthlibInjectorServerList fallbackAuthlibInjectorServers, + boolean allowSave) throws IOException { + if (authlibInjectorServers != null) { + throw new IllegalStateException("Authlib-injector servers are already loaded"); + } + + LOG.info("Authlib-injector servers location: " + AUTHLIB_INJECTOR_SERVERS_LOCATION); + + boolean newlyCreated = !Files.exists(AUTHLIB_INJECTOR_SERVERS_LOCATION); + JsonSettingFile.LoadResult result = + AUTHLIB_INJECTOR_SERVERS_FILE.load(fallbackAuthlibInjectorServers); + authlibInjectorServers = result.value(); + if (allowSave && result.allowSave()) { + AUTHLIB_INJECTOR_SERVERS_FILE.installAutoSave(authlibInjectorServers); + } + + if (newlyCreated && allowSave && result.allowSave()) { + LOG.info("Creating authlib-injector servers file " + AUTHLIB_INJECTOR_SERVERS_LOCATION); + AUTHLIB_INJECTOR_SERVERS_FILE.save(authlibInjectorServers); + } + } + + /// Loads account storages and installs the save listener. + /// + /// @param fallbackGameAccounts the fallback store used when the account storage file does not exist + /// @param allowSave whether the detached account storage file may be overwritten + private static void loadGameAccounts( + @Nullable AccountStorages fallbackGameAccounts, + boolean allowSave) throws IOException { + if (gameAccounts != null) { + throw new IllegalStateException("Game accounts are already loaded"); + } + + LOG.info("Game accounts location: " + GAME_ACCOUNTS_LOCATION); + + boolean newlyCreated = !Files.exists(GAME_ACCOUNTS_LOCATION); + JsonSettingFile.LoadResult result = + GAME_ACCOUNTS_FILE.load(fallbackGameAccounts); + gameAccounts = result.value(); + if (allowSave && result.allowSave()) { + GAME_ACCOUNTS_FILE.installAutoSave(gameAccounts); + } + + if (newlyCreated && allowSave && result.allowSave()) { + LOG.info("Creating game accounts file " + GAME_ACCOUNTS_LOCATION); + GAME_ACCOUNTS_FILE.save(gameAccounts); + } + } + + /// Moves an invalid config file to a numbered backup path (e.g. {@code settings.json.1}, + /// {@code settings.json.2}, …) so the original data is preserved for diagnosis. + /// This is called synchronously from the save listener, immediately before the first + /// successful write overwrites the invalid file. + /// Does nothing and logs a warning when the move fails. + /// + /// @param location the invalid config file to back up + private static void backupInvalidConfig(Path location) { + try { + // Find the first unused backup index: settings.json.1, settings.json.2, … + Path backup = null; + for (int i = 1; i < Integer.MAX_VALUE; i++) { + Path candidate = location.resolveSibling(location.getFileName() + "." + i); + if (!Files.exists(candidate)) { + backup = candidate; + break; + } + } + if (backup == null) { + LOG.warning("Could not find an available backup path for " + location); + return; + } + LOG.info("Backed up invalid config to " + backup); + Files.move(location, backup); + } catch (IOException e) { + LOG.warning("Failed to back up invalid config " + location, e); + } + } + + /// Checks whether root is reading a config file owned by another user. + private static void checkOwner(Path location) { + try { + if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS + && "root".equals(System.getProperty("user.name")) + && !"root".equals(Files.getOwner(location).getName())) { + ownerChanged = true; + } + } catch (IOException e) { + LOG.warning("Failed to get owner"); + } + } + + /// Checks that the given config file is writable. + private static void checkWritable(Path location) throws IOException { + if (!Files.isWritable(location)) { + if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS + && location.getFileSystem() == FileSystems.getDefault() + && location.toFile().canWrite()) { + LOG.warning("Launcher settings at " + location + " is not writable, but it seems to be a Samba share or OpenJDK bug"); + // There are some serious problems with the implementation of Samba or OpenJDK + throw new SambaException(); + } else { + // the config cannot be saved + // throw up the error now to prevent further data loss + throw new IOException("Launcher settings at " + location + " is not writable"); + } + } + } + + /// Loads user settings and installs the save listener. + private static void loadUserSettings() throws IOException { + if (userSettingsInstance != null) { + throw new IllegalStateException("User settings are already loaded"); + } + + LOG.info("User settings location: " + USER_SETTINGS_LOCATION); + + boolean newlyCreated = !Files.exists(USER_SETTINGS_LOCATION); + @Nullable UserSettings migratedUserSettings = newlyCreated + ? LegacyConfigMigrator.migrateLegacyUserSettings(USER_SETTINGS_LOCATION) + : null; + JsonSettingFile.LoadResult result = USER_SETTINGS_FILE.load(migratedUserSettings); + userSettingsInstance = result.value(); + if (result.allowSave()) { + USER_SETTINGS_FILE.installAutoSave(userSettingsInstance); + } + + if (newlyCreated && result.allowSave()) { + LOG.info("Creating user settings file " + USER_SETTINGS_LOCATION); + USER_SETTINGS_FILE.save(userSettingsInstance); + } + } + + /// Storage scope for a game directory entry. + private enum GameDirectoryScope { + /// Stored in `HMCL_LOCAL_HOME/game-directories.json`. + LOCAL, + + /// Stored in `HMCL_USER_HOME/user-game-directories.json`. + GLOBAL + } + + /// Original storage metadata for a game directory entry. + /// + /// @param scope the file where this entry belongs while its path remains unchanged + /// @param path the original serialized path string + private record GameDirectorySource(GameDirectoryScope scope, String path) { + } + +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/GlobalConfig.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/UserSettings.java similarity index 52% rename from HMCL/src/main/java/org/jackhuang/hmcl/setting/GlobalConfig.java rename to HMCL/src/main/java/org/jackhuang/hmcl/setting/UserSettings.java index 1c43366e4f8..af6ce406758 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/GlobalConfig.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/UserSettings.java @@ -23,135 +23,142 @@ import javafx.beans.property.*; import javafx.collections.FXCollections; import javafx.collections.ObservableSet; +import org.jackhuang.hmcl.util.gson.JsonSchema; +import org.jackhuang.hmcl.util.gson.JsonSerializable; +import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.gson.ObservableSetting; +import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; import java.util.*; -@JsonAdapter(GlobalConfig.Adapter.class) -public final class GlobalConfig extends ObservableSetting { +/// Stores launcher settings shared by all workspaces for the current user. +@NotNullByDefault +@JsonAdapter(UserSettings.Adapter.class) +@JsonSerializable +public final class UserSettings extends ObservableSetting implements JsonSchemaSetting { + /// The JSON schema supported by this user settings store. + public static final JsonSchema CURRENT_SCHEMA = + new JsonSchema("user-settings", new JsonSchema.Version(1, 0, 0)); + + /// Deserializes user settings from JSON. + /// + /// @param json the JSON content to parse + /// @return the parsed settings, or {@code null} when the JSON value is {@code null} + public static @Nullable UserSettings fromJson(String json) throws JsonParseException { + return JsonUtils.fromJson(JsonUtils.GSON, json, UserSettings.class); + } + + /// Creates empty user settings with default values. + public UserSettings() { + tracker.markDirty(schema); + register(); + } - @Nullable - public static GlobalConfig fromJson(String json) throws JsonParseException { - return Config.CONFIG_GSON.fromJson(json, GlobalConfig.class); + /// Serializes these settings to JSON. + public String toJson() { + return JsonUtils.GSON.toJson(this); } - public GlobalConfig() { - register(); + /// The schema used by this user settings file. + @SerializedName(JsonSchema.PROPERTY_SCHEMA) + private final ObjectProperty schema = new SimpleObjectProperty<>(CURRENT_SCHEMA); + + /// Returns the schema property. + public ObjectProperty schemaProperty() { + return schema; } - public String toJson() { - return Config.CONFIG_GSON.toJson(this); + /// Returns the schema used by this user settings file. + @Override + public JsonSchema getSchema() { + return schema.get(); + } + + /// Sets the schema used by this user settings file. + @Override + public void setSchema(JsonSchema schema) { + this.schema.set(Objects.requireNonNull(schema)); } + /// The accepted launcher agreement version. @SerializedName("agreementVersion") private final IntegerProperty agreementVersion = new SimpleIntegerProperty(); + /// Returns the accepted launcher agreement version property. public IntegerProperty agreementVersionProperty() { return agreementVersion; } - public int getAgreementVersion() { - return agreementVersion.get(); - } - - public void setAgreementVersion(int agreementVersion) { - this.agreementVersion.set(agreementVersion); - } - + /// The accepted Terracotta agreement version. @SerializedName("terracottaAgreementVersion") private final IntegerProperty terracottaAgreementVersion = new SimpleIntegerProperty(); + /// Returns the accepted Terracotta agreement version property. public IntegerProperty terracottaAgreementVersionProperty() { return terracottaAgreementVersion; } - public int getTerracottaAgreementVersion() { - return terracottaAgreementVersion.get(); - } - - public void setTerracottaAgreementVersion(int terracottaAgreementVersion) { - this.terracottaAgreementVersion.set(terracottaAgreementVersion); - } - + /// The platform prompt version shown to the user. @SerializedName("platformPromptVersion") private final IntegerProperty platformPromptVersion = new SimpleIntegerProperty(); + /// Returns the platform prompt version property. public IntegerProperty platformPromptVersionProperty() { return platformPromptVersion; } - public int getPlatformPromptVersion() { - return platformPromptVersion.get(); - } - - public void setPlatformPromptVersion(int platformPromptVersion) { - this.platformPromptVersion.set(platformPromptVersion); - } - + /// The number of launcher log files to retain. @SerializedName("logRetention") private final IntegerProperty logRetention = new SimpleIntegerProperty(20); + /// Returns the log retention property. public IntegerProperty logRetentionProperty() { return logRetention; } - public int getLogRetention() { - return logRetention.get(); - } - - public void setLogRetention(int logRetention) { - this.logRetention.set(logRetention); - } - + /// Whether offline accounts are enabled for this user. @SerializedName("enableOfflineAccount") private final BooleanProperty enableOfflineAccount = new SimpleBooleanProperty(false); + /// Returns the offline account enablement property. public BooleanProperty enableOfflineAccountProperty() { return enableOfflineAccount; } - public boolean isEnableOfflineAccount() { - return enableOfflineAccount.get(); - } - - public void setEnableOfflineAccount(boolean value) { - enableOfflineAccount.set(value); - } - + /// The JavaFX font antialiasing mode override. @SerializedName("fontAntiAliasing") private final StringProperty fontAntiAliasing = new SimpleStringProperty(); + /// Returns the JavaFX font antialiasing mode override property. public StringProperty fontAntiAliasingProperty() { return fontAntiAliasing; } - public String getFontAntiAliasing() { - return fontAntiAliasing.get(); - } - - public void setFontAntiAliasing(String value) { - this.fontAntiAliasing.set(value); - } - + /// User-added Java executable paths. @SerializedName("userJava") private final ObservableSet userJava = FXCollections.observableSet(new LinkedHashSet<>()); + /// Returns user-added Java executable paths. public ObservableSet getUserJava() { return userJava; } + /// Disabled Java executable paths. @SerializedName("disabledJava") private final ObservableSet disabledJava = FXCollections.observableSet(new LinkedHashSet<>()); + /// Returns disabled Java executable paths. public ObservableSet getDisabledJava() { return disabledJava; } - static final class Adapter extends ObservableSetting.Adapter { + /// Gson adapter for observable user settings. + static final class Adapter extends ObservableSetting.Adapter { + /// Creates an empty user settings instance during deserialization. @Override - protected GlobalConfig createInstance() { - return new GlobalConfig(); + protected UserSettings createInstance() { + return new UserSettings(); } } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/VersionSetting.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/VersionSetting.java deleted file mode 100644 index 389c5ca3635..00000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/VersionSetting.java +++ /dev/null @@ -1,962 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2021 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.setting; - -import com.google.gson.*; -import com.google.gson.annotations.JsonAdapter; -import javafx.beans.InvalidationListener; -import javafx.beans.Observable; -import javafx.beans.property.*; -import org.jackhuang.hmcl.game.*; -import org.jackhuang.hmcl.java.JavaManager; -import org.jackhuang.hmcl.util.Lang; -import org.jackhuang.hmcl.util.StringUtils; -import org.jackhuang.hmcl.util.javafx.ObservableHelper; -import org.jackhuang.hmcl.util.javafx.PropertyUtils; -import org.jackhuang.hmcl.java.JavaRuntime; -import org.jackhuang.hmcl.util.platform.SystemInfo; -import org.jackhuang.hmcl.util.versioning.GameVersionNumber; - -import java.io.IOException; -import java.lang.reflect.Type; -import java.nio.file.InvalidPathException; -import java.nio.file.Paths; -import java.util.*; -import java.util.stream.Collectors; - -import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES; -import static org.jackhuang.hmcl.util.logging.Logger.LOG; - -/** - * @author huangyuhui - */ -@JsonAdapter(VersionSetting.Serializer.class) -public final class VersionSetting implements Cloneable, Observable { - - private static final int SUGGESTED_MEMORY; - - static { - double totalMemoryMB = MEGABYTES.convertFromBytes(SystemInfo.getTotalMemorySize()); - SUGGESTED_MEMORY = totalMemoryMB >= 32768 - ? 8192 - : Integer.max((int) (Math.round(totalMemoryMB / 4.0 / 128.0) * 128), 256); - } - - private final transient ObservableHelper helper = new ObservableHelper(this); - - public VersionSetting() { - PropertyUtils.attachListener(this, helper); - } - - private final BooleanProperty usesGlobalProperty = new SimpleBooleanProperty(this, "usesGlobal", true); - - public BooleanProperty usesGlobalProperty() { - return usesGlobalProperty; - } - - /** - * HMCL Version Settings have been divided into 2 parts. - * 1. Global settings. - * 2. Version settings. - * If a version claims that it uses global settings, its version setting will be disabled. - *

- * Defaults false because if one version uses global first, custom version file will not be generated. - */ - public boolean isUsesGlobal() { - return usesGlobalProperty.get(); - } - - public void setUsesGlobal(boolean usesGlobal) { - usesGlobalProperty.set(usesGlobal); - } - - // java - - private final ObjectProperty javaVersionTypeProperty = new SimpleObjectProperty<>(this, "javaVersionType", JavaVersionType.AUTO); - - public ObjectProperty javaVersionTypeProperty() { - return javaVersionTypeProperty; - } - - public JavaVersionType getJavaVersionType() { - return javaVersionTypeProperty.get(); - } - - public void setJavaVersionType(JavaVersionType javaVersionType) { - javaVersionTypeProperty.set(javaVersionType); - } - - private final StringProperty javaVersionProperty = new SimpleStringProperty(this, "javaVersion", ""); - - public StringProperty javaVersionProperty() { - return javaVersionProperty; - } - - public String getJavaVersion() { - return javaVersionProperty.get(); - } - - public void setJavaVersion(String java) { - javaVersionProperty.set(java); - } - - public void setUsesCustomJavaDir() { - setJavaVersionType(JavaVersionType.CUSTOM); - setJavaVersion(""); - setDefaultJavaPath(null); - } - - public void setJavaAutoSelected() { - setJavaVersionType(JavaVersionType.AUTO); - setJavaVersion(""); - setDefaultJavaPath(null); - } - - private final StringProperty defaultJavaPathProperty = new SimpleStringProperty(this, "defaultJavaPath", ""); - - /** - * Path to Java executable, or null if user customizes java directory. - * It's used to determine which JRE to use when multiple JREs match the selected Java version. - */ - public String getDefaultJavaPath() { - return defaultJavaPathProperty.get(); - } - - public StringProperty defaultJavaPathPropertyProperty() { - return defaultJavaPathProperty; - } - - public void setDefaultJavaPath(String defaultJavaPath) { - defaultJavaPathProperty.set(defaultJavaPath); - } - - /** - * 0 - .minecraft/versions/<version>/natives/
- */ - private final ObjectProperty nativesDirTypeProperty = new SimpleObjectProperty<>(this, "nativesDirType", NativesDirectoryType.VERSION_FOLDER); - - public ObjectProperty nativesDirTypeProperty() { - return nativesDirTypeProperty; - } - - public NativesDirectoryType getNativesDirType() { - return nativesDirTypeProperty.get(); - } - - public void setNativesDirType(NativesDirectoryType nativesDirType) { - nativesDirTypeProperty.set(nativesDirType); - } - - // Path to lwjgl natives directory - - private final StringProperty nativesDirProperty = new SimpleStringProperty(this, "nativesDirProperty", ""); - - public StringProperty nativesDirProperty() { - return nativesDirProperty; - } - - public String getNativesDir() { - return nativesDirProperty.get(); - } - - public void setNativesDir(String nativesDir) { - nativesDirProperty.set(nativesDir); - } - - private final StringProperty javaDirProperty = new SimpleStringProperty(this, "javaDir", ""); - - public StringProperty javaDirProperty() { - return javaDirProperty; - } - - /** - * User customized java directory or null if user uses system Java. - */ - public String getJavaDir() { - return javaDirProperty.get(); - } - - public void setJavaDir(String javaDir) { - javaDirProperty.set(javaDir); - } - - private final StringProperty wrapperProperty = new SimpleStringProperty(this, "wrapper", ""); - - public StringProperty wrapperProperty() { - return wrapperProperty; - } - - /** - * The command to launch java, i.e. optirun. - */ - public String getWrapper() { - return wrapperProperty.get(); - } - - public void setWrapper(String wrapper) { - wrapperProperty.set(wrapper); - } - - private final StringProperty permSizeProperty = new SimpleStringProperty(this, "permSize", ""); - - public StringProperty permSizeProperty() { - return permSizeProperty; - } - - /** - * The permanent generation size of JVM garbage collection. - */ - public String getPermSize() { - return permSizeProperty.get(); - } - - public void setPermSize(String permSize) { - permSizeProperty.set(permSize); - } - - private final IntegerProperty maxMemoryProperty = new SimpleIntegerProperty(this, "maxMemory", SUGGESTED_MEMORY); - - public IntegerProperty maxMemoryProperty() { - return maxMemoryProperty; - } - - /** - * The maximum memory/MB that JVM can allocate for heap. - */ - public int getMaxMemory() { - return maxMemoryProperty.get(); - } - - public void setMaxMemory(int maxMemory) { - maxMemoryProperty.set(maxMemory); - } - - /** - * The minimum memory that JVM can allocate for heap. - */ - private final ObjectProperty minMemoryProperty = new SimpleObjectProperty<>(this, "minMemory", null); - - public ObjectProperty minMemoryProperty() { - return minMemoryProperty; - } - - public Integer getMinMemory() { - return minMemoryProperty.get(); - } - - public void setMinMemory(Integer minMemory) { - minMemoryProperty.set(minMemory); - } - - private final BooleanProperty autoMemory = new SimpleBooleanProperty(this, "autoMemory", true); - - public boolean isAutoMemory() { - return autoMemory.get(); - } - - public BooleanProperty autoMemoryProperty() { - return autoMemory; - } - - public void setAutoMemory(boolean autoMemory) { - this.autoMemory.set(autoMemory); - } - - private final StringProperty preLaunchCommandProperty = new SimpleStringProperty(this, "precalledCommand", ""); - - public StringProperty preLaunchCommandProperty() { - return preLaunchCommandProperty; - } - - /** - * The command that will be executed before launching the Minecraft. - * Operating system relevant. - */ - public String getPreLaunchCommand() { - return preLaunchCommandProperty.get(); - } - - public void setPreLaunchCommand(String preLaunchCommand) { - preLaunchCommandProperty.set(preLaunchCommand); - } - - private final StringProperty postExitCommand = new SimpleStringProperty(this, "postExitCommand", ""); - - public StringProperty postExitCommandProperty() { - return postExitCommand; - } - - /** - * The command that will be executed after game exits. - * Operating system relevant. - */ - public String getPostExitCommand() { - return postExitCommand.get(); - } - - public void setPostExitCommand(String postExitCommand) { - this.postExitCommand.set(postExitCommand); - } - - // options - - private final StringProperty javaArgsProperty = new SimpleStringProperty(this, "javaArgs", ""); - - public StringProperty javaArgsProperty() { - return javaArgsProperty; - } - - /** - * The user customized arguments passed to JVM. - */ - public String getJavaArgs() { - return javaArgsProperty.get(); - } - - public void setJavaArgs(String javaArgs) { - javaArgsProperty.set(javaArgs); - } - - private final StringProperty minecraftArgsProperty = new SimpleStringProperty(this, "minecraftArgs", ""); - - public StringProperty minecraftArgsProperty() { - return minecraftArgsProperty; - } - - /** - * The user customized arguments passed to Minecraft. - */ - public String getMinecraftArgs() { - return minecraftArgsProperty.get(); - } - - public void setMinecraftArgs(String minecraftArgs) { - minecraftArgsProperty.set(minecraftArgs); - } - - private final StringProperty environmentVariablesProperty = new SimpleStringProperty(this, "environmentVariables", ""); - - public StringProperty environmentVariablesProperty() { - return environmentVariablesProperty; - } - - public String getEnvironmentVariables() { - return environmentVariablesProperty.get(); - } - - public void setEnvironmentVariables(String env) { - environmentVariablesProperty.set(env); - } - - private final BooleanProperty noJVMArgsProperty = new SimpleBooleanProperty(this, "noJVMArgs", false); - - public BooleanProperty noJVMArgsProperty() { - return noJVMArgsProperty; - } - - /** - * True if disallow HMCL use default JVM arguments. - */ - public boolean isNoJVMArgs() { - return noJVMArgsProperty.get(); - } - - public void setNoJVMArgs(boolean noJVMArgs) { - noJVMArgsProperty.set(noJVMArgs); - } - - private final BooleanProperty noOptimizingJVMArgsProperty = new SimpleBooleanProperty(this, "noOptimizingJVMArgs", false); - - public BooleanProperty noOptimizingJVMArgsProperty() { - return noOptimizingJVMArgsProperty; - } - - public boolean isNoOptimizingJVMArgs() { - return noOptimizingJVMArgsProperty.get(); - } - - public void setNoOptimizingJVMArgs(boolean noOptimizingJVMArgs) { - noOptimizingJVMArgsProperty.set(noOptimizingJVMArgs); - } - - private final BooleanProperty notCheckJVMProperty = new SimpleBooleanProperty(this, "notCheckJVM", false); - - public BooleanProperty notCheckJVMProperty() { - return notCheckJVMProperty; - } - - /** - * True if HMCL does not check JVM validity. - */ - public boolean isNotCheckJVM() { - return notCheckJVMProperty.get(); - } - - public void setNotCheckJVM(boolean notCheckJVM) { - notCheckJVMProperty.set(notCheckJVM); - } - - private final BooleanProperty notCheckGameProperty = new SimpleBooleanProperty(this, "notCheckGame", false); - - public BooleanProperty notCheckGameProperty() { - return notCheckGameProperty; - } - - /** - * True if HMCL does not check game's completeness. - */ - public boolean isNotCheckGame() { - return notCheckGameProperty.get(); - } - - public void setNotCheckGame(boolean notCheckGame) { - notCheckGameProperty.set(notCheckGame); - } - - private final BooleanProperty notPatchNativesProperty = new SimpleBooleanProperty(this, "notPatchNatives", false); - - public BooleanProperty notPatchNativesProperty() { - return notPatchNativesProperty; - } - - public boolean isNotPatchNatives() { - return notPatchNativesProperty.get(); - } - - public void setNotPatchNatives(boolean notPatchNatives) { - notPatchNativesProperty.set(notPatchNatives); - } - - private final BooleanProperty showLogsProperty = new SimpleBooleanProperty(this, "showLogs", false); - - public BooleanProperty showLogsProperty() { - return showLogsProperty; - } - - /** - * True if show the logs after game launched. - */ - public boolean isShowLogs() { - return showLogsProperty.get(); - } - - public void setShowLogs(boolean showLogs) { - showLogsProperty.set(showLogs); - } - - private final BooleanProperty enableDebugLogOutputProperty = new SimpleBooleanProperty(this, "enableDebugLogOutput", false); - - public BooleanProperty enableDebugLogOutputProperty() { - return enableDebugLogOutputProperty; - } - - public boolean isEnableDebugLogOutput() { - return enableDebugLogOutputProperty.get(); - } - - public void setEnableDebugLogOutput(boolean u) { - this.enableDebugLogOutputProperty.set(u); - } - - // Minecraft settings. - - private final StringProperty serverIpProperty = new SimpleStringProperty(this, "serverIp", ""); - - public StringProperty serverIpProperty() { - return serverIpProperty; - } - - /** - * The server ip that will be entered after Minecraft successfully loaded ly. - *

- * Format: ip:port or without port. - */ - public String getServerIp() { - return serverIpProperty.get(); - } - - public void setServerIp(String serverIp) { - serverIpProperty.set(serverIp); - } - - - private final BooleanProperty fullscreenProperty = new SimpleBooleanProperty(this, "fullscreen", false); - - public BooleanProperty fullscreenProperty() { - return fullscreenProperty; - } - - /** - * True if Minecraft started in fullscreen mode. - */ - public boolean isFullscreen() { - return fullscreenProperty.get(); - } - - public void setFullscreen(boolean fullscreen) { - fullscreenProperty.set(fullscreen); - } - - private final IntegerProperty widthProperty = new SimpleIntegerProperty(this, "width", 854); - - public IntegerProperty widthProperty() { - return widthProperty; - } - - /** - * The width of Minecraft window, defaults 800. - *

- * The field saves int value. - * String type prevents unexpected value from JsonParseException. - * We can only reset this field instead of recreating the whole setting file. - */ - public int getWidth() { - return widthProperty.get(); - } - - public void setWidth(int width) { - widthProperty.set(width); - } - - private final IntegerProperty heightProperty = new SimpleIntegerProperty(this, "height", 480); - - public IntegerProperty heightProperty() { - return heightProperty; - } - - /** - * The height of Minecraft window, defaults 480. - *

- * The field saves int value. - * String type prevents unexpected value from JsonParseException. - * We can only reset this field instead of recreating the whole setting file. - */ - public int getHeight() { - return heightProperty.get(); - } - - public void setHeight(int height) { - heightProperty.set(height); - } - - /** - * 0 - .minecraft
- * 1 - .minecraft/versions/<version>/
- */ - private final ObjectProperty gameDirTypeProperty = new SimpleObjectProperty<>(this, "gameDirType", GameDirectoryType.ROOT_FOLDER); - - public ObjectProperty gameDirTypeProperty() { - return gameDirTypeProperty; - } - - public GameDirectoryType getGameDirType() { - return gameDirTypeProperty.get(); - } - - public void setGameDirType(GameDirectoryType gameDirType) { - gameDirTypeProperty.set(gameDirType); - } - - /** - * Your custom gameDir - */ - private final StringProperty gameDirProperty = new SimpleStringProperty(this, "gameDir", ""); - - public StringProperty gameDirProperty() { - return gameDirProperty; - } - - public String getGameDir() { - return gameDirProperty.get(); - } - - public void setGameDir(String gameDir) { - gameDirProperty.set(gameDir); - } - - private final ObjectProperty processPriorityProperty = new SimpleObjectProperty<>(this, "processPriority", ProcessPriority.NORMAL); - - public ObjectProperty processPriorityProperty() { - return processPriorityProperty; - } - - public ProcessPriority getProcessPriority() { - return processPriorityProperty.get(); - } - - public void setProcessPriority(ProcessPriority processPriority) { - processPriorityProperty.set(processPriority); - } - - private final ObjectProperty graphicsBackend = new SimpleObjectProperty<>(this, "graphicsBackend", GraphicsAPI.DEFAULT); - - public ObjectProperty graphicsBackendProperty() { - return graphicsBackend; - } - - public GraphicsAPI getGraphicsBackend() { - return graphicsBackendProperty().get(); - } - - public void setGraphicsBackend(GraphicsAPI api) { - graphicsBackendProperty().set(api); - } - - private final ObjectProperty rendererProperty = new SimpleObjectProperty<>(this, "renderer", Renderer.DEFAULT); - - public Renderer getRenderer() { - return rendererProperty.get(); - } - - public ObjectProperty rendererProperty() { - return rendererProperty; - } - - public void setRenderer(Renderer renderer) { - this.rendererProperty.set(renderer); - } - - private final BooleanProperty useNativeGLFW = new SimpleBooleanProperty(this, "nativeGLFW", false); - - public boolean isUseNativeGLFW() { - return useNativeGLFW.get(); - } - - public BooleanProperty useNativeGLFWProperty() { - return useNativeGLFW; - } - - public void setUseNativeGLFW(boolean useNativeGLFW) { - this.useNativeGLFW.set(useNativeGLFW); - } - - private final BooleanProperty useNativeOpenAL = new SimpleBooleanProperty(this, "nativeOpenAL", false); - - public boolean isUseNativeOpenAL() { - return useNativeOpenAL.get(); - } - - public BooleanProperty useNativeOpenALProperty() { - return useNativeOpenAL; - } - - public void setUseNativeOpenAL(boolean useNativeOpenAL) { - this.useNativeOpenAL.set(useNativeOpenAL); - } - - private final ObjectProperty versionIcon = new SimpleObjectProperty<>(this, "versionIcon", VersionIconType.DEFAULT); - - public VersionIconType getVersionIcon() { - return versionIcon.get(); - } - - public ObjectProperty versionIconProperty() { - return versionIcon; - } - - public void setVersionIcon(VersionIconType versionIcon) { - this.versionIcon.set(versionIcon); - } - - // launcher settings - - /** - * 0 - Close the launcher when the game starts.
- * 1 - Hide the launcher when the game starts.
- * 2 - Keep the launcher open.
- */ - private final ObjectProperty launcherVisibilityProperty = new SimpleObjectProperty<>(this, "launcherVisibility", LauncherVisibility.HIDE); - - public ObjectProperty launcherVisibilityProperty() { - return launcherVisibilityProperty; - } - - public LauncherVisibility getLauncherVisibility() { - return launcherVisibilityProperty.get(); - } - - public void setLauncherVisibility(LauncherVisibility launcherVisibility) { - launcherVisibilityProperty.set(launcherVisibility); - } - - public JavaRuntime getJava(GameVersionNumber gameVersion, Version version) throws InterruptedException { - switch (getJavaVersionType()) { - case DEFAULT: - return JavaRuntime.getDefault(); - case AUTO: - return JavaManager.findSuitableJava(gameVersion, version); - case CUSTOM: - try { - return JavaManager.getJava(Paths.get(getJavaDir())); - } catch (IOException | InvalidPathException e) { - return null; // Custom Java not found - } - case VERSION: { - String javaVersion = getJavaVersion(); - if (StringUtils.isBlank(javaVersion)) { - return JavaManager.findSuitableJava(gameVersion, version); - } - - int majorVersion = -1; - try { - majorVersion = Integer.parseInt(javaVersion); - } catch (NumberFormatException ignored) { - } - - if (majorVersion < 0) { - LOG.warning("Invalid Java version: " + javaVersion); - return null; - } - - final int finalMajorVersion = majorVersion; - Collection allJava = JavaManager.getAllJava().stream() - .filter(it -> it.getParsedVersion() == finalMajorVersion) - .collect(Collectors.toList()); - return JavaManager.findSuitableJava(allJava, gameVersion, version); - } - case DETECTED: { - String javaVersion = getJavaVersion(); - if (StringUtils.isBlank(javaVersion)) { - return JavaManager.findSuitableJava(gameVersion, version); - } - - try { - String defaultJavaPath = getDefaultJavaPath(); - if (StringUtils.isNotBlank(defaultJavaPath)) { - JavaRuntime java = JavaManager.getJava(Paths.get(defaultJavaPath).toRealPath()); - if (java != null && java.getVersion().equals(javaVersion)) { - return java; - } - } - } catch (IOException | InvalidPathException ignored) { - } - - for (JavaRuntime java : JavaManager.getAllJava()) { - if (java.getVersion().equals(javaVersion)) { - return java; - } - } - - return null; - } - default: - throw new AssertionError("JavaVersionType: " + getJavaVersionType()); - } - } - - @Override - public void addListener(InvalidationListener listener) { - helper.addListener(listener); - } - - @Override - public void removeListener(InvalidationListener listener) { - helper.removeListener(listener); - } - - @Override - public VersionSetting clone() { - VersionSetting cloned = new VersionSetting(); - PropertyUtils.copyProperties(this, cloned); - return cloned; - } - - public static class Serializer implements JsonSerializer, JsonDeserializer { - @Override - public JsonElement serialize(VersionSetting src, Type typeOfSrc, JsonSerializationContext context) { - if (src == null) return JsonNull.INSTANCE; - JsonObject obj = new JsonObject(); - - obj.addProperty("usesGlobal", src.isUsesGlobal()); - obj.addProperty("javaArgs", src.getJavaArgs()); - obj.addProperty("minecraftArgs", src.getMinecraftArgs()); - obj.addProperty("environmentVariables", src.getEnvironmentVariables()); - obj.addProperty("maxMemory", src.getMaxMemory() <= 0 ? SUGGESTED_MEMORY : src.getMaxMemory()); - obj.addProperty("minMemory", src.getMinMemory()); - obj.addProperty("autoMemory", src.isAutoMemory()); - obj.addProperty("permSize", src.getPermSize()); - obj.addProperty("width", src.getWidth()); - obj.addProperty("height", src.getHeight()); - obj.addProperty("javaDir", src.getJavaDir()); - obj.addProperty("precalledCommand", src.getPreLaunchCommand()); - obj.addProperty("postExitCommand", src.getPostExitCommand()); - obj.addProperty("serverIp", src.getServerIp()); - obj.addProperty("wrapper", src.getWrapper()); - obj.addProperty("fullscreen", src.isFullscreen()); - obj.addProperty("noJVMArgs", src.isNoJVMArgs()); - obj.addProperty("noOptimizingJVMArgs", src.isNoOptimizingJVMArgs()); - obj.addProperty("notCheckGame", src.isNotCheckGame()); - obj.addProperty("notCheckJVM", src.isNotCheckJVM()); - obj.addProperty("notPatchNatives", src.isNotPatchNatives()); - obj.addProperty("showLogs", src.isShowLogs()); - obj.addProperty("enableDebugLogOutput", src.isEnableDebugLogOutput()); - obj.addProperty("gameDir", src.getGameDir()); - obj.addProperty("launcherVisibility", src.getLauncherVisibility().ordinal()); - obj.addProperty("processPriority", src.getProcessPriority().ordinal()); - obj.addProperty("useNativeGLFW", src.isUseNativeGLFW()); - obj.addProperty("useNativeOpenAL", src.isUseNativeOpenAL()); - obj.addProperty("gameDirType", src.getGameDirType().ordinal()); - obj.addProperty("defaultJavaPath", src.getDefaultJavaPath()); - obj.addProperty("nativesDir", src.getNativesDir()); - obj.addProperty("nativesDirType", src.getNativesDirType().ordinal()); - obj.addProperty("versionIcon", src.getVersionIcon().ordinal()); - - obj.addProperty("javaVersionType", src.getJavaVersionType().name()); - String java; - switch (src.getJavaVersionType()) { - case DEFAULT: - java = "Default"; - break; - case AUTO: - java = "Auto"; - break; - case CUSTOM: - java = "Custom"; - break; - default: - java = src.getJavaVersion(); - break; - } - obj.addProperty("java", java); - - obj.addProperty("graphicsBackend", src.getGraphicsBackend().name()); - obj.addProperty("renderer", src.getRenderer().name()); - if (src.getRenderer() == Renderer.OpenGL.LLVMPIPE) - obj.addProperty("useSoftwareRenderer", true); - - return obj; - } - - @Override - public VersionSetting deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { - if (!(json instanceof JsonObject)) - return null; - JsonObject obj = (JsonObject) json; - - int maxMemoryN = parseJsonPrimitive(Optional.ofNullable(obj.get("maxMemory")).map(JsonElement::getAsJsonPrimitive).orElse(null), SUGGESTED_MEMORY); - if (maxMemoryN <= 0) maxMemoryN = SUGGESTED_MEMORY; - - VersionSetting vs = new VersionSetting(); - - vs.setUsesGlobal(Optional.ofNullable(obj.get("usesGlobal")).map(JsonElement::getAsBoolean).orElse(false)); - vs.setJavaArgs(Optional.ofNullable(obj.get("javaArgs")).map(JsonElement::getAsString).orElse("")); - vs.setMinecraftArgs(Optional.ofNullable(obj.get("minecraftArgs")).map(JsonElement::getAsString).orElse("")); - vs.setEnvironmentVariables(Optional.ofNullable(obj.get("environmentVariables")).map(JsonElement::getAsString).orElse("")); - vs.setMaxMemory(maxMemoryN); - vs.setMinMemory(Optional.ofNullable(obj.get("minMemory")).map(JsonElement::getAsInt).orElse(null)); - vs.setAutoMemory(Optional.ofNullable(obj.get("autoMemory")).map(JsonElement::getAsBoolean).orElse(true)); - vs.setPermSize(Optional.ofNullable(obj.get("permSize")).map(JsonElement::getAsString).orElse("")); - vs.setWidth(Optional.ofNullable(obj.get("width")).map(JsonElement::getAsJsonPrimitive).map(this::parseJsonPrimitive).orElse(0)); - vs.setHeight(Optional.ofNullable(obj.get("height")).map(JsonElement::getAsJsonPrimitive).map(this::parseJsonPrimitive).orElse(0)); - vs.setJavaDir(Optional.ofNullable(obj.get("javaDir")).map(JsonElement::getAsString).orElse("")); - vs.setPreLaunchCommand(Optional.ofNullable(obj.get("precalledCommand")).map(JsonElement::getAsString).orElse("")); - vs.setPostExitCommand(Optional.ofNullable(obj.get("postExitCommand")).map(JsonElement::getAsString).orElse("")); - vs.setServerIp(Optional.ofNullable(obj.get("serverIp")).map(JsonElement::getAsString).orElse("")); - vs.setWrapper(Optional.ofNullable(obj.get("wrapper")).map(JsonElement::getAsString).orElse("")); - vs.setGameDir(Optional.ofNullable(obj.get("gameDir")).map(JsonElement::getAsString).orElse("")); - vs.setNativesDir(Optional.ofNullable(obj.get("nativesDir")).map(JsonElement::getAsString).orElse("")); - vs.setFullscreen(Optional.ofNullable(obj.get("fullscreen")).map(JsonElement::getAsBoolean).orElse(false)); - vs.setNoJVMArgs(Optional.ofNullable(obj.get("noJVMArgs")).map(JsonElement::getAsBoolean).orElse(false)); - vs.setNoOptimizingJVMArgs(Optional.ofNullable(obj.get("noOptimizingJVMArgs")).map(JsonElement::getAsBoolean).orElse(false)); - vs.setNotCheckGame(Optional.ofNullable(obj.get("notCheckGame")).map(JsonElement::getAsBoolean).orElse(false)); - vs.setNotCheckJVM(Optional.ofNullable(obj.get("notCheckJVM")).map(JsonElement::getAsBoolean).orElse(false)); - vs.setNotPatchNatives(Optional.ofNullable(obj.get("notPatchNatives")).map(JsonElement::getAsBoolean).orElse(false)); - vs.setShowLogs(Optional.ofNullable(obj.get("showLogs")).map(JsonElement::getAsBoolean).orElse(false)); - vs.setEnableDebugLogOutput(Optional.ofNullable(obj.get("enableDebugLogOutput")).map(JsonElement::getAsBoolean).orElse(false)); - vs.setLauncherVisibility(parseJsonPrimitive(obj.getAsJsonPrimitive("launcherVisibility"), LauncherVisibility.class, LauncherVisibility.HIDE)); - vs.setProcessPriority(parseJsonPrimitive(obj.getAsJsonPrimitive("processPriority"), ProcessPriority.class, ProcessPriority.NORMAL)); - vs.setUseNativeGLFW(Optional.ofNullable(obj.get("useNativeGLFW")).map(JsonElement::getAsBoolean).orElse(false)); - vs.setUseNativeOpenAL(Optional.ofNullable(obj.get("useNativeOpenAL")).map(JsonElement::getAsBoolean).orElse(false)); - vs.setGameDirType(parseJsonPrimitive(obj.getAsJsonPrimitive("gameDirType"), GameDirectoryType.class, GameDirectoryType.ROOT_FOLDER)); - vs.setDefaultJavaPath(Optional.ofNullable(obj.get("defaultJavaPath")).map(JsonElement::getAsString).orElse(null)); - vs.setNativesDirType(parseJsonPrimitive(obj.getAsJsonPrimitive("nativesDirType"), NativesDirectoryType.class, NativesDirectoryType.VERSION_FOLDER)); - vs.setVersionIcon(parseJsonPrimitive(obj.getAsJsonPrimitive("versionIcon"), VersionIconType.class, VersionIconType.DEFAULT)); - - if (obj.get("javaVersionType") != null) { - JavaVersionType javaVersionType = parseJsonPrimitive(obj.getAsJsonPrimitive("javaVersionType"), JavaVersionType.class, JavaVersionType.AUTO); - vs.setJavaVersionType(javaVersionType); - vs.setJavaVersion(Optional.ofNullable(obj.get("java")).map(JsonElement::getAsString).orElse(null)); - } else { - String java = Optional.ofNullable(obj.get("java")).map(JsonElement::getAsString).orElse(""); - switch (java) { - case "Default": - vs.setJavaVersionType(JavaVersionType.DEFAULT); - break; - case "Auto": - vs.setJavaVersionType(JavaVersionType.AUTO); - break; - case "Custom": - vs.setJavaVersionType(JavaVersionType.CUSTOM); - break; - default: - vs.setJavaVersion(java); - } - } - - vs.setRenderer(Optional.ofNullable(obj.get("renderer")).map(JsonElement::getAsString) - .map(Renderer::of).orElseGet(() -> { - boolean useSoftwareRenderer = Optional.ofNullable(obj.get("useSoftwareRenderer")).map(JsonElement::getAsBoolean).orElse(false); - return useSoftwareRenderer ? Renderer.OpenGL.LLVMPIPE : Renderer.DEFAULT; - })); - - vs.setGraphicsBackend(Optional.ofNullable(obj.get("graphicsBackend")).map(JsonElement::getAsString) - .flatMap(name -> { - try { - return Optional.of(GraphicsAPI.valueOf(name.toUpperCase(Locale.ROOT))); - } catch (IllegalArgumentException ignored) { - return Optional.empty(); - } - }).orElseGet(() -> vs.getRenderer() instanceof Renderer.Driver renderer ? renderer.api() : GraphicsAPI.DEFAULT)); - - return vs; - } - - private int parseJsonPrimitive(JsonPrimitive primitive) { - return parseJsonPrimitive(primitive, 0); - } - - private int parseJsonPrimitive(JsonPrimitive primitive, int defaultValue) { - if (primitive == null) - return defaultValue; - else if (primitive.isNumber()) - return primitive.getAsInt(); - else - return Lang.parseInt(primitive.getAsString(), defaultValue); - } - - private > E parseJsonPrimitive(JsonPrimitive primitive, Class clazz, E defaultValue) { - if (primitive == null) - return defaultValue; - else { - E[] enumConstants = clazz.getEnumConstants(); - if (primitive.isNumber()) { - int index = primitive.getAsInt(); - return index >= 0 && index < enumConstants.length ? enumConstants[index] : defaultValue; - } else { - String name = primitive.getAsString(); - for (E enumConstant : enumConstants) { - if (enumConstant.name().equalsIgnoreCase(name)) { - return enumConstant; - } - } - return defaultValue; - } - } - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/property/InheritableProperty.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/property/InheritableProperty.java new file mode 100644 index 00000000000..23e6254f7fb --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/property/InheritableProperty.java @@ -0,0 +1,28 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.setting.property; + +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.UnknownNullability; + +/// A setting value whose instance override state is stored in `GameSettings.Instance.overrideProperties`. +/// +/// @author Glavo +@NotNullByDefault +public interface InheritableProperty extends SettingProperty { +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/property/SettingProperty.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/property/SettingProperty.java new file mode 100644 index 00000000000..8d169c68fdc --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/property/SettingProperty.java @@ -0,0 +1,33 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.setting.property; + +import javafx.beans.property.Property; +import org.jackhuang.hmcl.setting.GameSettings; +import org.jackhuang.hmcl.util.gson.RawPreservingProperty; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.UnknownNullability; + +/// @author Glavo +@NotNullByDefault +public interface SettingProperty extends Property, RawPreservingProperty { + @Override + GameSettings getBean(); + + T defaultValue(); +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/property/SimpleInheritableProperty.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/property/SimpleInheritableProperty.java new file mode 100644 index 00000000000..86d5767b17b --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/property/SimpleInheritableProperty.java @@ -0,0 +1,36 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.setting.property; + +import org.jackhuang.hmcl.setting.GameSettings; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.UnknownNullability; + +/// Stores a direct setting value that can be inherited by removing its name from `overrideProperties`. +/// +/// @author Glavo +@NotNullByDefault +public final class SimpleInheritableProperty + extends SimpleSettingProperty + implements InheritableProperty { + + /// Creates a property with the given owner, serialized name, and direct default value. + public SimpleInheritableProperty(GameSettings bean, String name, T defaultValue) { + super(bean, name, defaultValue); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/property/SimpleSettingProperty.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/property/SimpleSettingProperty.java new file mode 100644 index 00000000000..d820e147ec8 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/property/SimpleSettingProperty.java @@ -0,0 +1,62 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.setting.property; + +import com.google.gson.JsonElement; +import javafx.beans.property.SimpleObjectProperty; +import org.jackhuang.hmcl.setting.GameSettings; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.UnknownNullability; + +/// @author Glavo +@NotNullByDefault +public class SimpleSettingProperty extends SimpleObjectProperty + implements SettingProperty { + private @Nullable JsonElement rawJson; + + private final T defaultValue; + + public SimpleSettingProperty(GameSettings bean, + String name, + T defaultValue) { + super(bean, name, defaultValue); + this.defaultValue = defaultValue; + } + + @Override + public GameSettings getBean() { + return (GameSettings) super.getBean(); + } + + @Override + public T defaultValue() { + return defaultValue; + } + + @Override + public @Nullable JsonElement getRawJson() { + return rawJson; + } + + @Override + public void setRawJson(@Nullable JsonElement rawJson) { + this.rawJson = rawJson; + } + +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/MacOSProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/MacOSProvider.java index 83debfc0a0b..7771d16a970 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/MacOSProvider.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/MacOSProvider.java @@ -59,7 +59,7 @@ public Task install(Path pkg) throws IOException { throw new IllegalStateException("Cannot locate 'osascript' system executable on MacOS for installing Terracotta."); } - Path movedInstaller = Files.createTempDirectory(Metadata.HMCL_GLOBAL_DIRECTORY, "terracotta-pkg") + Path movedInstaller = Files.createTempDirectory(Metadata.HMCL_USER_HOME, "terracotta-pkg") .toRealPath() .resolve(FileUtils.getName(installer)); Files.copy(installer, movedInstaller, StandardCopyOption.REPLACE_EXISTING); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/theme/Themes.java b/HMCL/src/main/java/org/jackhuang/hmcl/theme/Themes.java index a26eeb9d9db..791fcb9deab 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/theme/Themes.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/theme/Themes.java @@ -52,7 +52,7 @@ import java.time.Duration; import java.util.*; -import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.setting.SettingsManager.settings; import static org.jackhuang.hmcl.util.logging.Logger.LOG; /// @author Glavo @@ -62,8 +62,8 @@ public final class Themes { { List observables = new ArrayList<>(); - observables.add(config().themeBrightnessProperty()); - observables.add(config().themeColorProperty()); + observables.add(settings().themeBrightnessProperty()); + observables.add(settings().themeColorProperty()); if (FXUtils.DARK_MODE != null) { observables.add(FXUtils.DARK_MODE); } @@ -71,7 +71,7 @@ public final class Themes { } private Brightness getBrightness() { - String themeBrightness = config().getThemeBrightness(); + String themeBrightness = settings().themeBrightnessProperty().get(); if (themeBrightness == null) return Brightness.DEFAULT; @@ -91,7 +91,7 @@ private Brightness getBrightness() { @Override protected Theme computeValue() { - ThemeColor themeColor = Objects.requireNonNullElse(config().getThemeColor(), ThemeColor.DEFAULT); + ThemeColor themeColor = Objects.requireNonNullElse(settings().themeColorProperty().get(), ThemeColor.DEFAULT); return new Theme(themeColor, getBrightness(), Theme.DEFAULT.colorStyle(), Contrast.DEFAULT); } @@ -189,11 +189,11 @@ public static ColorScheme getColorScheme() { } private static final ObjectBinding titleFill = Bindings.createObjectBinding( - () -> config().isTitleTransparent() + () -> settings().titleTransparentProperty().get() ? getColorScheme().getOnSurface() : getColorScheme().getOnPrimaryContainer(), colorSchemeProperty(), - config().titleTransparentProperty() + settings().titleTransparentProperty() ); public static ObservableValue titleFillProperty() { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java index f68bedac09d..2ff3236462f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java @@ -79,8 +79,10 @@ import java.util.List; import java.util.concurrent.CompletableFuture; -import static org.jackhuang.hmcl.setting.ConfigHolder.config; -import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig; +import static org.jackhuang.hmcl.setting.SettingsManager.settings; +import static org.jackhuang.hmcl.setting.SettingsManager.getAuthlibInjectorServers; +import static org.jackhuang.hmcl.setting.SettingsManager.state; +import static org.jackhuang.hmcl.setting.SettingsManager.userSettings; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -124,7 +126,7 @@ public final class Controllers { AccountListPage accountListPage = new AccountListPage(); accountListPage.selectedAccountProperty().bindBidirectional(Accounts.selectedAccountProperty()); accountListPage.accountsProperty().bindContent(Accounts.getAccounts()); - accountListPage.authServersProperty().bindContentBidirectional(config().getAuthlibInjectorServers()); + accountListPage.authServersProperty().bindContentBidirectional(getAuthlibInjectorServers()); return accountListPage; }); private static LauncherSettingsPage settingsPage; @@ -216,35 +218,35 @@ public static DecoratorController getDecorator() { public static void saveWindowStates() { if (stageX != null) { - config().setX(stageX.get() / SCREEN.getBounds().getWidth()); + state().setX(stageX.get() / SCREEN.getBounds().getWidth()); } if (stageY != null) { - config().setY(stageY.get() / SCREEN.getBounds().getHeight()); + state().setY(stageY.get() / SCREEN.getBounds().getHeight()); } if (stageHeight != null) { - config().setHeight(stageHeight.get()); + state().setHeight(stageHeight.get()); } if (stageWidth != null) { - config().setWidth(stageWidth.get()); + state().setWidth(stageWidth.get()); } } public static void onApplicationStop() { stageSizeChangeListener = null; if (stageX != null) { - config().setX(stageX.get() / SCREEN.getBounds().getWidth()); + state().setX(stageX.get() / SCREEN.getBounds().getWidth()); stageX = null; } if (stageY != null) { - config().setY(stageY.get() / SCREEN.getBounds().getHeight()); + state().setY(stageY.get() / SCREEN.getBounds().getHeight()); stageY = null; } if (stageHeight != null) { - config().setHeight(stageHeight.get()); + state().setHeight(stageHeight.get()); stageHeight = null; } if (stageWidth != null) { - config().setWidth(stageWidth.get()); + state().setWidth(stageWidth.get()); stageWidth = null; } } @@ -255,7 +257,7 @@ public static void initialize(Stage stage) { LOG.info("April Fools: " + AprilFools.isEnabled()); if (System.getProperty("prism.lcdtext") == null) { - String fontAntiAliasing = globalConfig().getFontAntiAliasing(); + @Nullable String fontAntiAliasing = SettingsManager.userSettings().fontAntiAliasingProperty().get(); if ("lcd".equalsIgnoreCase(fontAntiAliasing)) { LOG.info("Enable sub-pixel antialiasing"); System.getProperties().put("prism.lcdtext", "true"); @@ -306,12 +308,12 @@ public static void initialize(Stage stage) { WeakInvalidationListener weakListener = new WeakInvalidationListener(stageSizeChangeListener); - double initWidth = Math.max(MIN_WIDTH, config().getWidth()); - double initHeight = Math.max(MIN_HEIGHT, config().getHeight()); + double initWidth = Math.max(MIN_WIDTH, state().getWidth()); + double initHeight = Math.max(MIN_HEIGHT, state().getHeight()); { - double initX = config().getX() * SCREEN.getBounds().getWidth(); - double initY = config().getY() * SCREEN.getBounds().getHeight(); + double initX = state().getX() * SCREEN.getBounds().getWidth(); + double initY = state().getY() * SCREEN.getBounds().getHeight(); boolean invalid = true; double border = 20D; @@ -348,9 +350,9 @@ public static void initialize(Stage stage) { decorator = new DecoratorController(stage, getRootPage()); - if (config().getCommonDirType() == EnumCommonDirectory.CUSTOM && - !FileUtils.canCreateDirectory(config().getCommonDirectory())) { - config().setCommonDirType(EnumCommonDirectory.DEFAULT); + if (settings().commonDirectoryTypeProperty().get() == EnumCommonDirectory.CUSTOM && + !FileUtils.canCreateDirectory(settings().commonDirectoryProperty().get())) { + settings().commonDirectoryTypeProperty().set(EnumCommonDirectory.DEFAULT); dialog(i18n("launcher.cache_directory.invalid")); } @@ -387,8 +389,11 @@ public static void initialize(Stage stage) { timeline.play(); } - if (!Architecture.SYSTEM_ARCH.isX86() && globalConfig().getPlatformPromptVersion() < 1) { - Runnable continueAction = () -> globalConfig().setPlatformPromptVersion(1); + if (!Architecture.SYSTEM_ARCH.isX86() && SettingsManager.userSettings().platformPromptVersionProperty().get() < 1) { + Runnable continueAction = () -> { + UserSettings userSettings = userSettings(); + userSettings.platformPromptVersionProperty().set(1); + }; if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS && Architecture.SYSTEM_ARCH == Architecture.ARM64) { continueAction.run(); @@ -407,7 +412,7 @@ public static void initialize(Stage stage) { if (JavaRuntime.CURRENT_VERSION < Metadata.MINIMUM_SUPPORTED_JAVA_VERSION) { Number shownTipVersion = null; try { - shownTipVersion = (Number) config().getShownTips().get(JAVA_VERSION_TIP); + shownTipVersion = (Number) state().getShownTips().get(JAVA_VERSION_TIP); } catch (ClassCastException e) { LOG.warning("Invalid type for shown tips key: " + JAVA_VERSION_TIP, e); } @@ -420,30 +425,30 @@ public static void initialize(Stage stage) { downloadLink ); Controllers.dialog(builder - .ok(() -> config().getShownTips().put(JAVA_VERSION_TIP, Metadata.MINIMUM_SUPPORTED_JAVA_VERSION)) + .ok(() -> state().getShownTips().put(JAVA_VERSION_TIP, Metadata.MINIMUM_SUPPORTED_JAVA_VERSION)) .build()); } } // Check whether JIT is enabled in the current environment - if (!JavaRuntime.CURRENT_JIT_ENABLED && !Boolean.TRUE.equals(config().getShownTips().get(JAVA_INTERPRETED_MODE_TIP))) { + if (!JavaRuntime.CURRENT_JIT_ENABLED && !Boolean.TRUE.equals(state().getShownTips().get(JAVA_INTERPRETED_MODE_TIP))) { Controllers.dialog(new MessageDialogPane.Builder(i18n("warning.java_interpreted_mode"), i18n("message.warning"), MessageType.WARNING) .ok(null) .addCancel(i18n("button.do_not_show_again"), () -> - config().getShownTips().put(JAVA_INTERPRETED_MODE_TIP, true)) + state().getShownTips().put(JAVA_INTERPRETED_MODE_TIP, true)) .build()); } // Check whether hardware acceleration is enabled - if (!FXUtils.GPU_ACCELERATION_ENABLED && !Boolean.TRUE.equals(config().getShownTips().get(SOFTWARE_RENDERING))) { + if (!FXUtils.GPU_ACCELERATION_ENABLED && !Boolean.TRUE.equals(state().getShownTips().get(SOFTWARE_RENDERING))) { Controllers.dialog(new MessageDialogPane.Builder(i18n("warning.software_rendering"), i18n("message.warning"), MessageType.WARNING) .ok(null) .addCancel(i18n("button.do_not_show_again"), () -> - config().getShownTips().put(SOFTWARE_RENDERING, true)) + state().getShownTips().put(SOFTWARE_RENDERING, true)) .build()); } - if (globalConfig().getAgreementVersion() < 1) { + if (SettingsManager.userSettings().agreementVersionProperty().get() < 1) { JFXDialogLayout agreementPane = new JFXDialogLayout(); agreementPane.setHeading(new Label(i18n("launcher.agreement"))); agreementPane.setBody(new Label(i18n("launcher.agreement.hint"))); @@ -452,7 +457,8 @@ public static void initialize(Stage stage) { JFXButton yesButton = new JFXButton(i18n("launcher.agreement.accept")); yesButton.getStyleClass().add("dialog-accept"); yesButton.setOnAction(e -> { - globalConfig().setAgreementVersion(1); + UserSettings userSettings = userSettings(); + userSettings.agreementVersionProperty().set(1); agreementPane.fireEvent(new DialogCloseEvent()); }); JFXButton noButton = new JFXButton(i18n("launcher.agreement.decline")); @@ -465,7 +471,7 @@ public static void initialize(Stage stage) { aprilFools: if (AprilFools.isEnabled()) { int currentYear = LocalDate.now().getYear(); - if (config().getShownTips().get(APRIL_FOOLS) instanceof Number year && year.intValue() >= currentYear) + if (state().getShownTips().get(APRIL_FOOLS) instanceof Number year && year.intValue() >= currentYear) break aprilFools; if (!I18n.getLocale().getLocale().getLanguage().equals("zh")) @@ -480,7 +486,7 @@ public static void initialize(Stage stage) { break aprilFools; } - Runnable updateShowTips = () -> config().getShownTips().put(APRIL_FOOLS, currentYear); + Runnable updateShowTips = () -> state().getShownTips().put(APRIL_FOOLS, currentYear); Controllers.confirmWithCountdown(i18n("launcher.april_fools.switch_lzh"), null, 10, MessageType.QUESTION, () -> { @@ -488,7 +494,7 @@ public static void initialize(Stage stage) { LOG.info("Switching locale to " + lzh); updateShowTips.run(); - config().setLocalization(lzh); + settings().languageProperty().set(lzh); Controllers.onApplicationStop(); @@ -631,7 +637,7 @@ public static void onHyperlinkAction(String href) { break; case "hmcl://game/launch": Profile profile = Profiles.getSelectedProfile(); - Versions.launch(profile, profile.getSelectedVersion(), LauncherHelper::setKeep); + Versions.launch(profile, Profiles.getSelectedInstance(profile), LauncherHelper::setKeep); break; } } else { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/LogWindow.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/LogWindow.java index 5697ecb3ee5..64316da5c61 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/LogWindow.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/LogWindow.java @@ -55,7 +55,7 @@ import java.util.*; import java.util.stream.Collectors; -import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.setting.SettingsManager.settings; import static org.jackhuang.hmcl.util.Lang.thread; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -179,7 +179,7 @@ private final class LogWindowImpl extends Control { cboLines.getItems().setAll(500, 2000, 5000, 10000); cboLines.setValue(Log.getLogLines()); - cboLines.getSelectionModel().selectedItemProperty().addListener((a, b, newValue) -> config().setLogLines(newValue)); + cboLines.getSelectionModel().selectedItemProperty().addListener((a, b, newValue) -> settings().logLinesProperty().set(newValue)); for (int i = 0; i < LEVELS.length; ++i) { buttonText[i].bind(Bindings.concat(levelCountMap.get(LEVELS[i]), " " + LEVELS[i].name().toLowerCase(Locale.ROOT) + "s")); @@ -310,8 +310,8 @@ private static final class LogWindowSkin extends SkinBase { listView.scrollTo(listView.getItems().size() - 1); }); - listView.setStyle("-fx-font-family: \"" + Lang.requireNonNullElse(config().getFontFamily(), FXUtils.DEFAULT_MONOSPACE_FONT) - + "\"; -fx-font-size: " + config().getFontSize() + "px;"); + listView.setStyle("-fx-font-family: \"" + Lang.requireNonNullElse(settings().fontFamilyProperty().get(), FXUtils.DEFAULT_MONOSPACE_FONT) + + "\"; -fx-font-size: " + settings().fontSizeProperty().get() + "px;"); listView.setCellFactory(x -> new ListCell<>() { { getStyleClass().add("log-window-list-cell"); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListPage.java index bdcaff5f0a9..198fd794951 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListPage.java @@ -38,6 +38,7 @@ import org.jackhuang.hmcl.auth.Account; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; import org.jackhuang.hmcl.setting.Accounts; +import org.jackhuang.hmcl.setting.SettingsManager; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; @@ -52,7 +53,7 @@ import java.util.Locale; -import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig; +import static org.jackhuang.hmcl.setting.SettingsManager.userSettings; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.createSelectedItemPropertyFor; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -65,14 +66,14 @@ public final class AccountListPage extends DecoratorAnimatedPage implements Deco if ("false".equals(property) || "auto".equals(property) && LocaleUtils.IS_CHINA_MAINLAND - || globalConfig().isEnableOfflineAccount()) + || SettingsManager.userSettings().enableOfflineAccountProperty().get()) RESTRICTED.set(false); else - globalConfig().enableOfflineAccountProperty().addListener(new ChangeListener() { + userSettings().enableOfflineAccountProperty().addListener(new ChangeListener() { @Override public void changed(ObservableValue o, Boolean oldValue, Boolean newValue) { if (newValue) { - globalConfig().enableOfflineAccountProperty().removeListener(this); + userSettings().enableOfflineAccountProperty().removeListener(this); RESTRICTED.set(false); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AddAuthlibInjectorServerPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AddAuthlibInjectorServerPane.java index d0a76b122d7..d9f4a805fe0 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AddAuthlibInjectorServerPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AddAuthlibInjectorServerPane.java @@ -34,7 +34,7 @@ import javax.net.ssl.SSLException; import java.io.IOException; -import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.setting.SettingsManager.getAuthlibInjectorServers; import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; import static org.jackhuang.hmcl.util.logging.Logger.LOG; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; @@ -221,8 +221,8 @@ private void onAddPrev() { } private void onAddFinish() { - if (!config().getAuthlibInjectorServers().contains(serverBeingAdded)) { - config().getAuthlibInjectorServers().add(serverBeingAdded); + if (!getAuthlibInjectorServers().contains(serverBeingAdded)) { + getAuthlibInjectorServers().add(serverBeingAdded); } fireEvent(new DialogCloseEvent()); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java index 30b841f74ab..271a28c2240 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java @@ -66,7 +66,8 @@ import static java.util.Collections.unmodifiableList; import static javafx.beans.binding.Bindings.bindContent; import static javafx.beans.binding.Bindings.createBooleanBinding; -import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.setting.SettingsManager.settings; +import static org.jackhuang.hmcl.setting.SettingsManager.getAuthlibInjectorServers; import static org.jackhuang.hmcl.ui.FXUtils.*; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.classPropertyFor; @@ -100,7 +101,7 @@ public CreateAccountPane(AccountFactory factory) { factory = Accounts.FACTORY_MICROSOFT; } else { showMethodSwitcher = true; - String preferred = config().getPreferredLoginType(); + String preferred = settings().preferredLoginTypeProperty().get(); try { factory = Accounts.getAccountFactory(preferred); } catch (IllegalArgumentException e) { @@ -168,7 +169,7 @@ public CreateAccountPane(AccountFactory factory) { if (newItem == null) return; AccountFactory newMethod = (AccountFactory) newItem.getUserData(); - config().setPreferredLoginType(Accounts.getLoginType(newMethod)); + settings().preferredLoginTypeProperty().set(Accounts.getLoginType(newMethod)); this.factory = newMethod; initDetailsPane(); }); @@ -375,7 +376,7 @@ public AccountDetailsInputPane(AccountFactory factory, Runnable onAction) { cboServers = new JFXComboBox<>(); cboServers.setCellFactory(jfxListCellFactory(server -> new TwoLineListItem(server.getName(), server.getUrl()))); cboServers.setConverter(stringConverter(AuthlibInjectorServer::getName)); - bindContent(cboServers.getItems(), config().getAuthlibInjectorServers()); + bindContent(cboServers.getItems(), getAuthlibInjectorServers()); cboServers.getItems().addListener(onInvalidating( () -> Platform.runLater( // the selection will not be updated as expected if we call it immediately cboServers.getSelectionModel()::selectFirst))); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/MicrosoftAccountLoginPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/MicrosoftAccountLoginPane.java index ce6f9c2e4d5..e2d132b2991 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/MicrosoftAccountLoginPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/MicrosoftAccountLoginPane.java @@ -55,7 +55,7 @@ import java.util.concurrent.CancellationException; import java.util.function.Consumer; -import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.setting.SettingsManager.settings; import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; @@ -211,7 +211,7 @@ private void onStep(Step currentStep) { var lblCode = new Label(wait.userCode()); lblCode.getStyleClass().add("code-label"); - lblCode.setStyle("-fx-font-family: \"" + Lang.requireNonNullElse(config().getFontFamily(), FXUtils.DEFAULT_MONOSPACE_FONT) + "\";"); + lblCode.setStyle("-fx-font-family: \"" + Lang.requireNonNullElse(settings().fontFamilyProperty().get(), FXUtils.DEFAULT_MONOSPACE_FONT) + "\";"); var codeBox = new StackPane(lblCode); codeBox.getStyleClass().add("code-box"); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/AnimationUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/AnimationUtils.java index 57d21361d0e..bac6063e362 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/AnimationUtils.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/AnimationUtils.java @@ -18,7 +18,7 @@ package org.jackhuang.hmcl.ui.animation; import javafx.scene.Node; -import org.jackhuang.hmcl.setting.ConfigHolder; +import org.jackhuang.hmcl.setting.SettingsManager; import org.jackhuang.hmcl.util.platform.OperatingSystem; /** @@ -31,13 +31,12 @@ private AnimationUtils() { /** * Trigger initialization of this class. - * Should be called from {@link org.jackhuang.hmcl.setting.Settings#init()}. + * Should be called after settings have been loaded. */ - @SuppressWarnings("JavadocReference") public static void init() { } - private static final boolean ENABLED = !ConfigHolder.config().isAnimationDisabled(); + private static final boolean ENABLED = !SettingsManager.settings().animationDisabledProperty().get(); private static final boolean PLAY_WINDOW_ANIMATION = ENABLED && !OperatingSystem.CURRENT_OS.isLinuxOrBSD(); public static boolean isAnimationEnabled() { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentList.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentList.java index e5c4396fce0..81b56f7e5e8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentList.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentList.java @@ -69,7 +69,7 @@ private static final class Skin extends ControlSkinBase { sublist.getStyleClass().add("options-sublist"); wrapper = new ComponentSublistWrapper(sublist); } else { - wrapper = new StackPane(node); + wrapper = new ItemWrapper(node); } wrapper.getStyleClass().add("options-list-item"); @@ -144,4 +144,22 @@ public static void setVgrow(Node node, Priority priority) { public static void setNoPadding(Node node) { node.getProperties().put("ComponentList.noPadding", true); } + + /// Wrapper for a component list row. + private static final class ItemWrapper extends StackPane { + /// The row content displayed by this wrapper. + private final Node content; + + /// Creates a row wrapper for the given content node. + private ItemWrapper(Node content) { + super(content); + this.content = content; + } + + /// Propagates the child content bias so wrapped text can compute height from row width. + @Override + public Orientation getContentBias() { + return content.getContentBias(); + } + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentSublist.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentSublist.java index d5692c02408..28664e0b336 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentSublist.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentSublist.java @@ -18,23 +18,50 @@ package org.jackhuang.hmcl.ui.construct; import javafx.beans.property.*; +import javafx.collections.ObservableList; +import javafx.geometry.Orientation; import javafx.scene.Node; +import javafx.scene.control.Control; import java.util.List; import java.util.function.Supplier; -public class ComponentSublist extends ComponentList { +public class ComponentSublist extends Control implements NoPaddingComponent { + private final ComponentList contentList = new ComponentList(); Supplier> lazyInitializer; public ComponentSublist() { - super(); + contentList.getStyleClass().remove("options-list"); + contentList.getStyleClass().add("options-sublist"); } public ComponentSublist(Supplier> lazyInitializer) { + this(); this.lazyInitializer = lazyInitializer; } + public ObservableList getContent() { + return contentList.getContent(); + } + + @Override + public Orientation getContentBias() { + return Orientation.HORIZONTAL; + } + + @Override + protected javafx.scene.control.Skin createDefaultSkin() { + return new Skin(this); + } + + private static final class Skin extends ControlSkinBase { + Skin(ComponentSublist control) { + super(control); + node = control.contentList; + } + } + void doLazyInit() { if (lazyInitializer != null) { this.getContent().setAll(lazyInitializer.get()); @@ -57,12 +84,9 @@ public void setTitle(String title) { titleProperty().set(title); } - private StringProperty subtitle; + private final StringProperty subtitle = new SimpleStringProperty(this, "subtitle", ""); public StringProperty subtitleProperty() { - if (subtitle == null) - subtitle = new SimpleStringProperty(this, "subtitle", ""); - return subtitle; } @@ -74,7 +98,7 @@ public void setSubtitle(String subtitle) { subtitleProperty().set(subtitle); } - private boolean hasSubtitle = false; + private boolean hasSubtitle; public boolean isHasSubtitle() { return hasSubtitle; @@ -84,24 +108,101 @@ public void setHasSubtitle(boolean hasSubtitle) { this.hasSubtitle = hasSubtitle; } - private Node headerLeft; + private final ObjectProperty leading = new SimpleObjectProperty<>(this, "leading"); - public Node getHeaderLeft() { + public ObjectProperty leadingProperty() { + return leading; + } + + public Node getLeading() { + return leadingProperty().get(); + } + + public void setLeading(Node leading) { + leadingProperty().set(leading); + } + + private final ObjectProperty headerLeft = new SimpleObjectProperty<>(this, "headerLeft"); + + public ObjectProperty headerLeftProperty() { return headerLeft; } + public Node getHeaderLeft() { + return headerLeftProperty().get(); + } + public void setHeaderLeft(Node headerLeft) { - this.headerLeft = headerLeft; + headerLeftProperty().set(headerLeft); } - private Node headerRight; + private final ObjectProperty trailing = new SimpleObjectProperty<>(this, "trailing"); + + public ObjectProperty trailingProperty() { + return trailing; + } + + public Node getTrailing() { + return trailingProperty().get(); + } + + public void setTrailing(Node trailing) { + trailingProperty().set(trailing); + } public Node getHeaderRight() { - return headerRight; + return getTrailing(); } public void setHeaderRight(Node headerRight) { - this.headerRight = headerRight; + setTrailing(headerRight); + } + + /// The node displayed immediately after the default title text. + private final ObjectProperty titleTrailing = new SimpleObjectProperty<>(this, "titleTrailing"); + + /// Returns the node displayed immediately after the default title text. + public ObjectProperty titleTrailingProperty() { + return titleTrailing; + } + + /// Returns the node displayed immediately after the default title text. + public Node getTitleTrailing() { + return titleTrailingProperty().get(); + } + + /// Sets the node displayed immediately after the default title text. + public void setTitleTrailing(Node titleTrailing) { + titleTrailingProperty().set(titleTrailing); + } + + /// Returns the node displayed immediately after the default title text. + public ObjectProperty titleRightProperty() { + return titleTrailingProperty(); + } + + /// Returns the node displayed immediately after the default title text. + public Node getTitleRight() { + return getTitleTrailing(); + } + + /// Sets the node displayed immediately after the default title text. + public void setTitleRight(Node titleRight) { + setTitleTrailing(titleRight); + } + + private final StringProperty description = new SimpleStringProperty(this, "description", ""); + + public StringProperty descriptionProperty() { + return description; + } + + public String getDescription() { + return descriptionProperty().get(); + } + + public void setDescription(String description) { + descriptionProperty().set(description); } private boolean componentPadding = true; @@ -113,4 +214,18 @@ public boolean hasComponentPadding() { public void setComponentPadding(boolean componentPadding) { this.componentPadding = componentPadding; } + + private final StringProperty tip = new SimpleStringProperty(this, "tip"); + + public StringProperty tipProperty() { + return tip; + } + + public String getTip() { + return tip.get(); + } + + public void setTip(String tip) { + this.tip.set(tip); + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentSublistWrapper.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentSublistWrapper.java index f9e25998096..868d44b4017 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentSublistWrapper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentSublistWrapper.java @@ -21,22 +21,18 @@ import javafx.application.Platform; import javafx.beans.InvalidationListener; import javafx.css.PseudoClass; -import javafx.geometry.Insets; -import javafx.geometry.Pos; import javafx.scene.Node; -import javafx.scene.control.Label; -import javafx.scene.input.MouseButton; import javafx.scene.input.MouseEvent; import javafx.scene.layout.HBox; -import javafx.scene.layout.Priority; +import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; import javafx.scene.shape.Rectangle; import javafx.util.Duration; -import org.jackhuang.hmcl.theme.Themes; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.ui.animation.AnimationUtils; import org.jackhuang.hmcl.ui.animation.Motion; +import org.jackhuang.hmcl.util.StringUtils; /// @author Glavo final class ComponentSublistWrapper extends VBox implements NoPaddingComponent { @@ -52,48 +48,14 @@ final class ComponentSublistWrapper extends VBox implements NoPaddingComponent { expandIcon.getStyleClass().add("expand-icon"); expandIcon.setMouseTransparent(true); - VBox labelVBox = new VBox(); - labelVBox.setMouseTransparent(true); - labelVBox.setAlignment(Pos.CENTER_LEFT); - - Node leftNode = sublist.getHeaderLeft(); - if (leftNode == null) { - Label label = new Label(); - label.textProperty().bind(sublist.titleProperty()); - label.getStyleClass().add("title-label"); - labelVBox.getChildren().add(label); - - if (sublist.isHasSubtitle()) { - Label subtitleLabel = new Label(); - subtitleLabel.textProperty().bind(sublist.subtitleProperty()); - subtitleLabel.getStyleClass().add("subtitle-label"); - subtitleLabel.textFillProperty().bind(Themes.colorSchemeProperty().getOnSurfaceVariant()); - labelVBox.getChildren().add(subtitleLabel); - } - } else { - labelVBox.getChildren().setAll(leftNode); - } - - HBox header = new HBox(); - header.setSpacing(12); - header.getChildren().add(labelVBox); - header.setPadding(new Insets(10, 16, 10, 16)); - header.setAlignment(Pos.CENTER_LEFT); - HBox.setHgrow(labelVBox, Priority.ALWAYS); - Node rightNode = sublist.getHeaderRight(); - if (rightNode != null) - header.getChildren().add(rightNode); - header.getChildren().add(expandIcon); - - RipplerContainer headerRippler = new RipplerContainer(header); - this.getChildren().add(headerRippler); - - headerRippler.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> { - if (event.getButton() != MouseButton.PRIMARY) - return; - - event.consume(); - + HeaderButton header = new HeaderButton(); + header.getStyleClass().add("options-sublist-header"); + header.titleProperty().bind(sublist.titleProperty()); + header.subtitleProperty().bind(sublist.subtitleProperty()); + header.leadingProperty().bind(sublist.leadingProperty()); + header.trailingTextProperty().bind(sublist.descriptionProperty()); + header.setTrailingIcon(expandIcon); + header.setOnAction(event -> { if (expandAnimation != null && expandAnimation.getStatus() == Animation.Status.RUNNING) { expandAnimation.stop(); } @@ -135,28 +97,140 @@ final class ComponentSublistWrapper extends VBox implements NoPaddingComponent { } Platform.runLater(() -> { - double contentHeight = expanded ? sublist.prefHeight(sublist.getWidth()) : 0; double targetRotate = expanded ? -180 : 0; + if (!expanded && container != null) { + double currentHeight = container.getHeight() > 0 ? container.getHeight() : computeContentHeight(sublist); + setContentHeight(currentHeight); + } if (AnimationUtils.isAnimationEnabled()) { double currentRotate = expandIcon.getRotate(); Duration duration = Motion.LONG2.multiply(Math.abs(currentRotate - targetRotate) / 180.0); Interpolator interpolator = Motion.EASE_IN_OUT_CUBIC_EMPHASIZED; + double targetHeight = expanded ? computeContentHeight(sublist) : 0; expandAnimation = new Timeline( new KeyFrame(duration, - new KeyValue(container.minHeightProperty(), contentHeight, interpolator), - new KeyValue(container.maxHeightProperty(), contentHeight, interpolator), + new KeyValue(container.minHeightProperty(), targetHeight, interpolator), + new KeyValue(container.prefHeightProperty(), targetHeight, interpolator), + new KeyValue(container.maxHeightProperty(), targetHeight, interpolator), new KeyValue(expandIcon.rotateProperty(), targetRotate, interpolator)) ); + expandAnimation.setOnFinished(e -> { + if (this.expanded) { + setContentHeight(targetHeight); + } + }); expandAnimation.play(); } else { - container.setMinHeight(contentHeight); - container.setMaxHeight(contentHeight); + if (expanded) { + setContentHeight(computeContentHeight(sublist)); + } else { + setContentHeight(0); + } expandIcon.setRotate(targetRotate); } }); }); + + Node headerLeft = sublist.getHeaderLeft(); + if (headerLeft != null) { + header.setTitleContent(headerLeft); + } + + InvalidationListener updateTitleTrailing = observable -> { + Node titleTrailing = sublist.getTitleTrailing(); + String tip = sublist.getTip(); + + if (titleTrailing == null && StringUtils.isBlank(tip)) { + header.setTitleTrailing(null); + return; + } + + HBox box = new HBox(4); + box.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> event.consume()); + if (titleTrailing != null) { + box.getChildren().add(titleTrailing); + } + if (!StringUtils.isBlank(tip)) { + var tipContainer = new StackPane(SVG.INFO.createIcon(16)); + FXUtils.installFastTooltip(tipContainer, tip); + box.getChildren().add(tipContainer); + } + header.setTitleTrailing(box); + }; + sublist.tipProperty().addListener(updateTitleTrailing); + sublist.titleTrailingProperty().addListener(updateTitleTrailing); + updateTitleTrailing.invalidated(null); + + InvalidationListener updateTrailing = observable -> header.setExtraTrailing(sublist.getTrailing()); + sublist.trailingProperty().addListener(updateTrailing); + updateTrailing.invalidated(null); + + sublist.getContent().addListener((InvalidationListener) observable -> updateExpandedContentHeight(sublist)); + + this.getChildren().add(header); + } + + /// Uses the sublist's computed height while expanded so dynamic content can resize naturally. + private void updateExpandedContentHeight(ComponentSublist sublist) { + if (!expanded || container == null) { + return; + } + + Platform.runLater(() -> { + if (!expanded || container == null) { + return; + } + + if (expandAnimation != null && expandAnimation.getStatus() == Animation.Status.RUNNING) { + return; + } + + setContentHeight(computeContentHeight(sublist)); + }); + } + + /// Returns the preferred height for the current sublist content. + private double computeContentHeight(ComponentSublist sublist) { + sublist.applyCss(); + + double width = sublist.getWidth(); + if (width <= 0 && container != null) { + width = container.getWidth(); + } + if (width <= 0) { + width = getWidth(); + } + return sublist.prefHeight(width); + } + + /// Applies the same fixed height to all height constraints used during expand/collapse animation. + private void setContentHeight(double height) { + if (container == null) { + return; + } + + container.setMinHeight(height); + container.setPrefHeight(height); + container.setMaxHeight(height); + } + + private static final class HeaderButton extends LineButton { + private static final int EXTRA_TRAILING_INDEX = IDX_TRAILING + 1; + + @Override + protected int getTrailingIconIndex() { + return IDX_TRAILING + 2; + } + + private void setExtraTrailing(Node node) { + setNode(EXTRA_TRAILING_INDEX, node); + } + + private void setTitleContent(Node node) { + setNode(IDX_TITLE, node); + } } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FileSelector.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FileSelector.java index b53d70f73dc..d86c88022bf 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FileSelector.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FileSelector.java @@ -26,6 +26,7 @@ import javafx.collections.ObservableList; import javafx.geometry.Pos; import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; import javafx.stage.DirectoryChooser; import javafx.stage.FileChooser; import org.jackhuang.hmcl.ui.Controllers; @@ -88,6 +89,8 @@ public ObservableList getExtensionFilters() { public FileSelector() { JFXTextField customField = new JFXTextField(); + customField.setMaxWidth(Double.MAX_VALUE); + HBox.setHgrow(customField, Priority.ALWAYS); FXUtils.bindString(customField, valueProperty()); selectButton.setOnAction(e -> { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/LineButtonBase.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/LineButtonBase.java index ae4fea007ae..03f0ec579cb 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/LineButtonBase.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/LineButtonBase.java @@ -17,7 +17,11 @@ */ package org.jackhuang.hmcl.ui.construct; +import javafx.collections.ListChangeListener; import javafx.event.ActionEvent; +import javafx.scene.Cursor; +import javafx.scene.Node; +import javafx.scene.Parent; import org.jackhuang.hmcl.ui.FXUtils; /// @author Glavo @@ -30,12 +34,31 @@ public LineButtonBase() { this.getStyleClass().addAll(LineButtonBase.DEFAULT_STYLE_CLASS); this.ripplerContainer = new RipplerContainer(container); + container.getChildren().addListener((ListChangeListener) change -> updateCursor()); + disabledProperty().addListener(observable -> updateCursor()); + FXUtils.setOverflowHidden(this); FXUtils.onClicked(this, this::fire); this.getChildren().setAll(ripplerContainer); + updateCursor(); } public void fire() { fireEvent(new ActionEvent()); } + + /// Updates the cursor shown by the row rippler. + private void updateCursor() { + applyCursor(this, isDisabled() ? Cursor.DEFAULT : Cursor.HAND); + } + + /// Applies the row cursor to every current child node that may receive mouse hover. + private static void applyCursor(Node node, Cursor cursor) { + node.setCursor(cursor); + if (node instanceof Parent parent) { + for (Node child : parent.getChildrenUnmodifiable()) { + applyCursor(child, cursor); + } + } + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/LineComponent.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/LineComponent.java index d980caf4c2d..99f817d097e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/LineComponent.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/LineComponent.java @@ -23,6 +23,7 @@ import javafx.beans.property.StringPropertyBase; import javafx.css.PseudoClass; import javafx.geometry.Insets; +import javafx.geometry.Orientation; import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.control.*; @@ -30,6 +31,7 @@ import javafx.scene.image.ImageView; import javafx.scene.layout.*; import org.jackhuang.hmcl.ui.SVG; +import org.jetbrains.annotations.Nullable; import java.util.Arrays; import java.util.Objects; @@ -53,9 +55,21 @@ public static void setMargin(Node child, Insets value) { protected final HBox container; + /// The row containing the title and its optional trailing node. + private final HBox titleLine; + + /// The primary title label. private final Label titleLabel; + + /// The container holding the title row and optional subtitle. private final VBox titleContainer; + /// The optional node displayed immediately after the title. + private @Nullable Node titleTrailing; + + /// The optional subtitle label. + private @Nullable Label subtitleLabel; + public LineComponent() { this.getStyleClass().add(DEFAULT_STYLE_CLASS); @@ -68,18 +82,86 @@ public LineComponent() { this.titleLabel = new Label(); titleLabel.getStyleClass().add("title-label"); + titleLabel.setContentDisplay(ContentDisplay.RIGHT); + titleLabel.setGraphicTextGap(4); titleLabel.setMinWidth(Region.USE_PREF_SIZE); + titleLabel.setMouseTransparent(true); + titleLabel.setPickOnBounds(false); - this.titleContainer = new VBox(titleLabel); + this.titleLine = new HBox(titleLabel); + titleLine.setAlignment(Pos.CENTER_LEFT); + titleLine.setPickOnBounds(false); + + this.titleContainer = new VBox(titleLine); titleContainer.getStyleClass().add("title-container"); - titleContainer.setMouseTransparent(true); titleContainer.setAlignment(Pos.CENTER_LEFT); - titleContainer.minWidthProperty().bind(titleLabel.prefWidthProperty()); + titleContainer.minWidthProperty().bind(titleLine.prefWidthProperty()); + titleContainer.setMouseTransparent(true); + titleContainer.setPickOnBounds(false); HBox.setHgrow(titleContainer, Priority.ALWAYS); this.setNode(IDX_TITLE, titleContainer); this.getChildren().setAll(container); + widthProperty().addListener(observable -> updatePreferredHeight()); + } + + /// Computes wrapped subtitle height from the row width. + @Override + public Orientation getContentBias() { + return Orientation.HORIZONTAL; + } + + /// Computes preferred row height from the title column width after fixed-width nodes are removed. + @Override + protected double computePrefHeight(double width) { + return computeLineHeight(width); + } + + /// Keeps wrapped subtitle rows from being compressed below their preferred height. + @Override + protected double computeMinHeight(double width) { + return computeLineHeight(width); + } + + /// Computes row height with the same width split used by the `HBox` at layout time. + private double computeLineHeight(double width) { + double horizontalInsets = container.snappedLeftInset() + container.snappedRightInset(); + double verticalInsets = container.snappedTopInset() + container.snappedBottomInset(); + double contentWidth = width < 0 ? -1 : Math.max(0, width - horizontalInsets); + + int managedCount = 0; + double fixedWidth = 0; + double contentHeight = 0; + for (Node child : container.getChildren()) { + if (!child.isManaged()) { + continue; + } + + managedCount++; + if (child == titleContainer) { + continue; + } + + fixedWidth += child.prefWidth(-1); + contentHeight = Math.max(contentHeight, child.prefHeight(-1)); + } + + double titleWidth = contentWidth < 0 + ? -1 + : Math.max(0, contentWidth - fixedWidth - container.getSpacing() * Math.max(0, managedCount - 1)); + contentHeight = Math.max(contentHeight, computeTitleHeight(titleWidth)); + + return Math.max(MIN_HEIGHT, verticalInsets + contentHeight); + } + + /// Computes the height of the title column at the given width. + private double computeTitleHeight(double width) { + double height = titleLine.prefHeight(width); + if (subtitleLabel != null && subtitleLabel.getParent() == titleContainer) { + height += titleContainer.getSpacing() + subtitleLabel.prefHeight(width); + } + return height; } private Node[] nodes = new Node[2]; @@ -91,9 +173,22 @@ protected void setNode(int idx, Node node) { if (nodes[idx] != node) { nodes[idx] = node; container.getChildren().setAll(Arrays.stream(nodes).filter(Objects::nonNull).toArray(Node[]::new)); + updatePreferredHeight(); } } + /// Sets the node displayed immediately after the title label. + public final void setTitleTrailing(@Nullable Node node) { + if (titleTrailing == node) { + return; + } + + titleTrailing = node; + titleLabel.setGraphic(node); + titleLabel.setMouseTransparent(node == null); + titleContainer.setMouseTransparent(node == null); + } + public void setLargeTitle(boolean largeTitle) { pseudoClassStateChanged(PSEUDO_LARGER_TITLE, largeTitle); } @@ -132,8 +227,6 @@ public void setTitle(String title) { public final StringProperty subtitleProperty() { if (subtitle == null) { subtitle = new StringPropertyBase() { - private Label subtitleLabel; - @Override public String getName() { return "subtitle"; @@ -152,6 +245,7 @@ protected void invalidated() { subtitleLabel = new Label(); subtitleLabel.setWrapText(true); subtitleLabel.setMinHeight(Region.USE_PREF_SIZE); + subtitleLabel.setMouseTransparent(true); subtitleLabel.getStyleClass().add("subtitle-label"); } subtitleLabel.setText(subtitle); @@ -162,6 +256,7 @@ protected void invalidated() { if (titleContainer.getChildren().size() == 2) titleContainer.getChildren().remove(1); } + updatePreferredHeight(); } }; } @@ -177,6 +272,21 @@ public final void setSubtitle(String subtitle) { subtitleProperty().set(subtitle); } + /// Updates the fixed preferred height after the row width or subtitle content changes. + private void updatePreferredHeight() { + double width = getWidth(); + if (width <= 0) { + setMinHeight(MIN_HEIGHT); + setPrefHeight(Region.USE_COMPUTED_SIZE); + return; + } + + applyCss(); + double height = computeLineHeight(width); + setMinHeight(height); + setPrefHeight(height); + } + private ObjectProperty leading; public final ObjectProperty leadingProperty() { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/LineInheritableToggleButton.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/LineInheritableToggleButton.java new file mode 100644 index 00000000000..12e04396b26 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/LineInheritableToggleButton.java @@ -0,0 +1,277 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.construct; + +import com.jfoenix.controls.JFXButton; +import com.jfoenix.controls.JFXToggleButton; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.css.PseudoClass; +import javafx.scene.control.ContentDisplay; +import javafx.scene.control.Tooltip; +import javafx.scene.input.MouseEvent; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.ui.SVG; +import org.jetbrains.annotations.NotNullByDefault; + +/// A line component that edits an inheritable boolean while showing the effective toggle state. +/// +/// The override state is represented separately from the direct boolean value. The toggle always +/// reflects the effective value currently applied by the setting hierarchy. +@NotNullByDefault +public final class LineInheritableToggleButton extends LineButtonBase { + /// The style class applied to inheritable toggle rows. + private static final String DEFAULT_STYLE_CLASS = "line-inheritable-toggle-button"; + + /// The pseudo class applied while the value overrides the inherited setting. + private static final PseudoClass PSEUDO_OVERRIDDEN = PseudoClass.getPseudoClass("overridden"); + + /// The style class applied to the compact inheritance state button. + private static final String INHERIT_BUTTON_STYLE_CLASS = "toggle-icon-tiny"; + + /// The icon size used by the compact inheritance state button. + private static final int INHERIT_BUTTON_ICON_SIZE = 12; + + /// The button that toggles between inherited and overridden mode. + private final JFXButton inheritButton; + + /// The tooltip shown on the inheritance button. + private final Tooltip inheritTooltip; + + /// The visual toggle that displays the effective value. + private final JFXToggleButton toggleButton; + + /// Creates an inheritable boolean toggle row. + public LineInheritableToggleButton() { + this.getStyleClass().addAll(DEFAULT_STYLE_CLASS, "line-toggle-button"); + + this.inheritButton = new JFXButton(); + inheritButton.getStyleClass().add(INHERIT_BUTTON_STYLE_CLASS); + inheritButton.setContentDisplay(ContentDisplay.GRAPHIC_ONLY); + inheritButton.setGraphic(SVG.PUBLIC.createIcon(INHERIT_BUTTON_ICON_SIZE)); + this.inheritTooltip = new Tooltip(); + FXUtils.installFastTooltip(inheritButton, inheritTooltip); + inheritButton.addEventFilter(MouseEvent.MOUSE_CLICKED, event -> { + if (!isInheritAvailable()) { + return; + } + + if (!isOverridden()) { + setRawValue(isEffectiveValue()); + setOverridden(true); + } else { + setOverridden(false); + } + super.fire(); + event.consume(); + }); + + this.toggleButton = new JFXToggleButton(); + toggleButton.setMouseTransparent(true); + toggleButton.setSize(8); + FXUtils.setLimitHeight(toggleButton, 30); + + setTitleTrailing(inheritButton); + setNode(IDX_TRAILING, toggleButton); + + rawValue.addListener(observable -> refresh()); + overridden.addListener(observable -> refresh()); + effectiveValue.addListener(observable -> refresh()); + inheritAvailable.addListener(observable -> refresh()); + inheritedText.addListener(observable -> refresh()); + overriddenText.addListener(observable -> refresh()); + inheritedTooltip.addListener(observable -> refresh()); + overriddenTooltip.addListener(observable -> refresh()); + refresh(); + } + + @Override + public void fire() { + setOverridden(true); + setRawValue(!isEffectiveValue()); + super.fire(); + } + + /// Refreshes the visual state from the raw and effective values. + private void refresh() { + boolean inheritAvailable = isInheritAvailable(); + boolean inherited = inheritAvailable && !isOverridden(); + boolean overridden = inheritAvailable && isOverridden(); + + inheritButton.setGraphic((inherited ? SVG.PUBLIC : SVG.TUNE).createIcon(INHERIT_BUTTON_ICON_SIZE)); + inheritButton.pseudoClassStateChanged(PSEUDO_OVERRIDDEN, overridden); + inheritButton.setVisible(inheritAvailable); + inheritButton.setManaged(inheritAvailable); + inheritTooltip.setText(inherited ? getInheritedTooltip() : getOverriddenTooltip()); + + toggleButton.setSelected(isEffectiveValue()); + } + + /// The raw value stored in this setting. + private final BooleanProperty rawValue = new SimpleBooleanProperty(this, "rawValue"); + + /// Returns the raw setting value. + public BooleanProperty rawValueProperty() { + return rawValue; + } + + /// Returns the raw setting value. + public boolean getRawValue() { + return rawValueProperty().get(); + } + + /// Sets the raw setting value. + public void setRawValue(boolean rawValue) { + rawValueProperty().set(rawValue); + } + + /// Whether the direct value overrides the inherited value. + private final BooleanProperty overridden = new SimpleBooleanProperty(this, "overridden"); + + /// Returns the override-state property. + public BooleanProperty overriddenProperty() { + return overridden; + } + + /// Returns whether the direct value overrides the inherited value. + public boolean isOverridden() { + return overriddenProperty().get(); + } + + /// Sets whether the direct value overrides the inherited value. + public void setOverridden(boolean overridden) { + overriddenProperty().set(overridden); + } + + /// The effective value displayed by the toggle. + private final BooleanProperty effectiveValue = new SimpleBooleanProperty(this, "effectiveValue"); + + /// Returns the effective value displayed by the toggle. + public BooleanProperty effectiveValueProperty() { + return effectiveValue; + } + + /// Returns the effective value displayed by the toggle. + public boolean isEffectiveValue() { + return effectiveValueProperty().get(); + } + + /// Sets the effective value displayed by the toggle. + public void setEffectiveValue(boolean effectiveValue) { + effectiveValueProperty().set(effectiveValue); + } + + /// Whether inherited mode can be selected. + private final BooleanProperty inheritAvailable = new SimpleBooleanProperty(this, "inheritAvailable", true); + + /// Returns whether inherited mode can be selected. + public BooleanProperty inheritAvailableProperty() { + return inheritAvailable; + } + + /// Returns whether inherited mode can be selected. + public boolean isInheritAvailable() { + return inheritAvailableProperty().get(); + } + + /// Sets whether inherited mode can be selected. + public void setInheritAvailable(boolean inheritAvailable) { + inheritAvailableProperty().set(inheritAvailable); + } + + /// The text that describes inherited mode. + private final StringProperty inheritedText = new SimpleStringProperty(this, "inheritedText", ""); + + /// Returns the text that describes inherited mode. + public StringProperty inheritedTextProperty() { + return inheritedText; + } + + /// Returns the text that describes inherited mode. + public String getInheritedText() { + return inheritedTextProperty().get(); + } + + /// Sets the text that describes inherited mode. + public void setInheritedText(String inheritedText) { + inheritedTextProperty().set(inheritedText); + } + + /// The text that describes overridden mode. + private final StringProperty overriddenText = new SimpleStringProperty(this, "overriddenText", ""); + + /// Returns the text that describes overridden mode. + public StringProperty overriddenTextProperty() { + return overriddenText; + } + + /// Returns the text that describes overridden mode. + public String getOverriddenText() { + return overriddenTextProperty().get(); + } + + /// Sets the text that describes overridden mode. + public void setOverriddenText(String overriddenText) { + overriddenTextProperty().set(overriddenText); + } + + /// The tooltip displayed while inheriting the parent value. + private final StringProperty inheritedTooltip = new SimpleStringProperty(this, "inheritedTooltip", ""); + + /// Returns the tooltip displayed while inheriting the parent value. + public StringProperty inheritedTooltipProperty() { + return inheritedTooltip; + } + + /// Returns the tooltip displayed while inheriting the parent value. + public String getInheritedTooltip() { + return inheritedTooltipProperty().get(); + } + + /// Sets the tooltip displayed while inheriting the parent value. + public void setInheritedTooltip(String inheritedTooltip) { + inheritedTooltipProperty().set(inheritedTooltip); + refresh(); + } + + /// The tooltip displayed while overriding the parent value. + private final StringProperty overriddenTooltip = new SimpleStringProperty(this, "overriddenTooltip", ""); + + /// Returns the tooltip displayed while overriding the parent value. + public StringProperty overriddenTooltipProperty() { + return overriddenTooltip; + } + + /// Returns the tooltip displayed while overriding the parent value. + public String getOverriddenTooltip() { + return overriddenTooltipProperty().get(); + } + + /// Sets the tooltip displayed while overriding the parent value. + public void setOverriddenTooltip(String overriddenTooltip) { + overriddenTooltipProperty().set(overriddenTooltip); + refresh(); + } + + /// Sets the tooltip displayed on the inherit button in inherited mode. + public void setInheritTooltip(String tooltip) { + setInheritedTooltip(tooltip); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/RadioChoiceList.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/RadioChoiceList.java new file mode 100644 index 00000000000..63e1cf5260a --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/RadioChoiceList.java @@ -0,0 +1,429 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.construct; + +import com.jfoenix.controls.JFXRadioButton; +import com.jfoenix.controls.JFXTextField; +import com.jfoenix.validation.base.ValidatorBase; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.Property; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.StringProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Toggle; +import javafx.scene.control.ToggleGroup; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.VBox; +import javafx.stage.FileChooser; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.util.StringUtils; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; +import org.jetbrains.annotations.UnknownNullability; + +import java.util.Collection; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/// A vertical single-selection list backed by radio buttons. +/// +/// Each choice owns its optional editor control, such as a text field or file selector. +/// The editor is enabled only while the choice is selected. +/// +/// @param the selected value type +/// @author Glavo +@NotNullByDefault +public final class RadioChoiceList extends VBox { + /// The selected choice value. + private final ObjectProperty selectedValue = new SimpleObjectProperty<>(this, "selectedValue"); + + /// The fallback value selected when the requested value is not present. + private final ObjectProperty fallbackValue = new SimpleObjectProperty<>(this, "fallbackValue"); + + /// The currently selected choice object. + private final ObjectProperty<@Nullable Choice> selectedChoice = new SimpleObjectProperty<>(this, "selectedChoice"); + + /// The shared radio-button group. + private final ToggleGroup group = new ToggleGroup(); + + /// The choices currently displayed by this list. + private final ObservableList> choices = FXCollections.observableArrayList(); + + /// The reverse lookup from rendered toggles to choices. + private final Map> choiceByToggle = new IdentityHashMap<>(); + + /// Creates an empty radio choice list. + public RadioChoiceList() { + getStyleClass().addAll("radio-choice-list", "multi-file-item"); + setSpacing(8); + + group.selectedToggleProperty().addListener((observable, oldValue, newValue) -> { + Choice choice = newValue != null ? choiceByToggle.get(newValue) : null; + selectedChoice.set(choice); + selectedValue.set(choice != null ? choice.getValue() : null); + }); + + selectedValue.addListener((observable, oldValue, newValue) -> selectValue(newValue)); + } + + /// Replaces the displayed choices. + public void setChoices(Collection> choices) { + this.choices.setAll(choices); + choiceByToggle.clear(); + + getChildren().setAll(this.choices.stream() + .map(choice -> { + Node node = choice.createNode(group); + choiceByToggle.put(choice.getRadioButton(), choice); + return node; + }) + .toList()); + + selectValue(getSelectedValue()); + } + + /// Returns the immutable list of displayed choices. + public @Unmodifiable List> getChoices() { + return List.copyOf(choices); + } + + /// Clears the selected choice. + public void clearSelection() { + Toggle selectedToggle = group.getSelectedToggle(); + if (selectedToggle != null) { + selectedToggle.setSelected(false); + } + } + + /// Returns the selected value. + public T getSelectedValue() { + return selectedValue.get(); + } + + /// Returns the selected value property. + public ObjectProperty selectedValueProperty() { + return selectedValue; + } + + /// Sets the selected value. + public void setSelectedValue(T selectedValue) { + this.selectedValue.set(selectedValue); + } + + /// Returns the fallback value. + public T getFallbackValue() { + return fallbackValue.get(); + } + + /// Returns the fallback value property. + public ObjectProperty fallbackValueProperty() { + return fallbackValue; + } + + /// Sets the fallback value. + public void setFallbackValue(T fallbackValue) { + this.fallbackValue.set(fallbackValue); + } + + /// Returns the selected choice. + public @Nullable Choice getSelectedChoice() { + return selectedChoice.get(); + } + + /// Returns the selected choice property. + public ObjectProperty<@Nullable Choice> selectedChoiceProperty() { + return selectedChoice; + } + + /// Selects the matching choice for the given value. + private void selectValue(T value) { + @Nullable Choice choice = findChoice(value); + if (choice == null) { + choice = findChoice(getFallbackValue()); + } + + if (choice != null) { + choice.setSelected(true); + } + } + + /// Finds the first choice with the given value. + private @Nullable Choice findChoice(T value) { + for (Choice choice : choices) { + if (Objects.equals(choice.getValue(), value)) { + return choice; + } + } + return null; + } + + /// A radio choice with an optional subtitle or tooltip. + /// + /// @param the selected value type + public static class Choice { + /// The title shown beside the radio button. + protected String title; + + /// The selected value represented by this choice. + protected final T value; + + /// The optional subtitle shown on the right side. + protected @Nullable String subtitle; + + /// The optional tooltip installed on the radio button. + protected @Nullable String tooltip; + + /// The radio button used by this choice. + protected final JFXRadioButton radioButton = new JFXRadioButton(); + + /// Creates a choice. + public Choice(String title, T value) { + this.title = title; + this.value = value; + } + + /// Returns the selected value represented by this choice. + public T getValue() { + return value; + } + + /// Returns the title shown beside the radio button. + public String getTitle() { + return title; + } + + /// Sets the title shown beside the radio button. + public Choice setTitle(String title) { + this.title = title; + radioButton.setText(title); + return this; + } + + /// Returns the optional subtitle. + public @Nullable String getSubtitle() { + return subtitle; + } + + /// Sets the optional subtitle. + public Choice setSubtitle(@Nullable String subtitle) { + this.subtitle = subtitle; + return this; + } + + /// Sets the optional tooltip. + public Choice setTooltip(@Nullable String tooltip) { + this.tooltip = tooltip; + return this; + } + + /// Returns whether this choice is selected. + public boolean isSelected() { + return radioButton.isSelected(); + } + + /// Returns this choice's selected property. + public BooleanProperty selectedProperty() { + return radioButton.selectedProperty(); + } + + /// Sets whether this choice is selected. + public void setSelected(boolean selected) { + radioButton.setSelected(selected); + } + + /// Returns the radio button owned by this choice. + protected final JFXRadioButton getRadioButton() { + return radioButton; + } + + /// Configures the radio button for rendering in this list. + protected final void configureRadioButton(ToggleGroup group) { + radioButton.setText(title); + radioButton.setToggleGroup(group); + radioButton.setUserData(value); + if (StringUtils.isNotBlank(tooltip)) { + FXUtils.installFastTooltip(radioButton, tooltip); + } + } + + /// Creates the rendered row node. + private Node createNode(ToggleGroup group) { + BorderPane pane = new BorderPane(); + pane.setPadding(new Insets(3)); + FXUtils.setLimitHeight(pane, 30); + + configureRadioButton(group); + BorderPane.setAlignment(radioButton, Pos.CENTER_LEFT); + pane.setLeft(radioButton); + + @Nullable Node right = createRightNode(); + if (right != null) { + if (shouldDisableRightNodeWhenUnselected()) { + right.disableProperty().bind(radioButton.selectedProperty().not()); + } + BorderPane.setAlignment(right, Pos.CENTER_RIGHT); + pane.setRight(right); + } else if (StringUtils.isNotBlank(subtitle)) { + pane.setCenter(createSubtitleLabel()); + } + + return pane; + } + + /// Creates the optional right-side editor node. + protected @Nullable Node createRightNode() { + return null; + } + + /// Returns whether the right-side node should be disabled while this choice is not selected. + protected boolean shouldDisableRightNodeWhenUnselected() { + return true; + } + + /// Creates the subtitle label for choices without a right-side editor. + private Node createSubtitleLabel() { + var label = new javafx.scene.control.Label(subtitle); + BorderPane.setAlignment(label, Pos.CENTER_RIGHT); + label.setWrapText(true); + label.getStyleClass().add("subtitle-label"); + label.setStyle("-fx-font-size: 10;"); + label.setPadding(new Insets(0, 0, 0, 15)); + return label; + } + } + + /// A choice with an attached text field. + /// + /// @param the selected value type + public static final class TextChoice extends Choice { + /// The text field attached to this choice. + private final JFXTextField textField = new JFXTextField(); + + /// Creates a text choice. + public TextChoice(String title, T value) { + super(title, value); + } + + /// Returns the attached text field. + public JFXTextField getTextField() { + return textField; + } + + /// Returns the text field value. + public String getText() { + return textField.getText(); + } + + /// Returns the text field property. + public StringProperty textProperty() { + return textField.textProperty(); + } + + /// Sets the text field value. + public void setText(String value) { + textField.setText(value); + } + + /// Binds the text field to another property. + public TextChoice bindTextBidirectional(Property property) { + FXUtils.bindString(textField, property); + return this; + } + + /// Sets validators on the text field. + public TextChoice setValidators(ValidatorBase... validators) { + textField.setValidators(validators); + return this; + } + + /// Creates the right-side text field. + @Override + protected Node createRightNode() { + if (!textField.getValidators().isEmpty()) { + FXUtils.setValidateWhileTextChanged(textField, true); + } + return textField; + } + } + + /// A choice with an attached file selector. + /// + /// @param the selected value type + public static final class FileChoice extends Choice { + /// The file selector attached to this choice. + private final FileSelector selector = new FileSelector(); + + /// Creates a file choice. + public FileChoice(String title, T value) { + super(title, value); + } + + /// Returns the selected path. + public String getPath() { + return selector.getValue(); + } + + /// Returns the selected path property. + public StringProperty pathProperty() { + return selector.valueProperty(); + } + + /// Sets the selected path. + public void setPath(String value) { + selector.setValue(value); + } + + /// Sets the file selector mode. + public FileChoice setSelectionMode(FileSelector.SelectionMode selectionMode) { + selector.setSelectionMode(selectionMode); + return this; + } + + /// Binds the selected path to another property. + public FileChoice bindPathBidirectional(Property property) { + selector.valueProperty().bindBidirectional(property); + return this; + } + + /// Sets the chooser title. + public FileChoice setChooserTitle(String chooserTitle) { + selector.setChooserTitle(chooserTitle); + return this; + } + + /// Adds an extension filter to the chooser. + public FileChoice addExtensionFilter(FileChooser.ExtensionFilter filter) { + selector.getExtensionFilters().add(filter); + return this; + } + + /// Creates the right-side file selector. + @Override + protected Node createRightNode() { + return selector; + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/DecoratorController.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/DecoratorController.java index 4f3b060c412..5857369c0e4 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/DecoratorController.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/decorator/DecoratorController.java @@ -64,7 +64,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; -import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.setting.SettingsManager.settings; import static org.jackhuang.hmcl.ui.FXUtils.newBuiltinImage; import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; import static org.jackhuang.hmcl.util.io.FileUtils.getExtension; @@ -76,7 +76,7 @@ public class DecoratorController { public DecoratorController(Stage stage, Node mainPage) { decorator = new Decorator(stage); - decorator.titleTransparentProperty().bind(config().titleTransparentProperty()); + decorator.titleTransparentProperty().bind(settings().titleTransparentProperty()); navigator = new Navigator(); navigator.setOnNavigated(this::onNavigated); @@ -93,11 +93,11 @@ public DecoratorController(Stage stage, Node mainPage) { decorator.setContentBackground(getBackground()); changeBackgroundListener = o -> updateBackground(); WeakInvalidationListener weakListener = new WeakInvalidationListener(changeBackgroundListener); - config().backgroundImageTypeProperty().addListener(weakListener); - config().backgroundImageProperty().addListener(weakListener); - config().backgroundImageUrlProperty().addListener(weakListener); - config().backgroundPaintProperty().addListener(weakListener); - config().backgroundImageOpacityProperty().addListener(weakListener); + settings().backgroundImageTypeProperty().addListener(weakListener); + settings().backgroundImageProperty().addListener(weakListener); + settings().backgroundImageUrlProperty().addListener(weakListener); + settings().backgroundPaintProperty().addListener(weakListener); + settings().backgroundImageOpacityProperty().addListener(weakListener); // pass key events to current dialog / current page decorator.addEventFilter(KeyEvent.ANY, e -> { @@ -184,14 +184,14 @@ private void updateBackground() { } private Background getBackground() { - EnumBackgroundImage imageType = config().getBackgroundImageType(); + EnumBackgroundImage imageType = settings().backgroundImageTypeProperty().get(); if (imageType == null) imageType = EnumBackgroundImage.DEFAULT; Image image = null; switch (imageType) { case CUSTOM: - String backgroundImage = config().getBackgroundImage(); + String backgroundImage = settings().backgroundImageProperty().get(); if (backgroundImage != null) try { Path path = Path.of(backgroundImage); @@ -203,7 +203,7 @@ private Background getBackground() { } break; case NETWORK: - String backgroundImageUrl = config().getBackgroundImageUrl(); + String backgroundImageUrl = settings().backgroundImageUrlProperty().get(); if (backgroundImageUrl != null) { try { image = FXUtils.loadImage(WebURL.parseBrowserInput(backgroundImageUrl)); @@ -218,8 +218,8 @@ private Background getBackground() { case TRANSLUCENT: // Deprecated return new Background(new BackgroundFill(new Color(1, 1, 1, 0.5), CornerRadii.EMPTY, Insets.EMPTY)); case PAINT: - Paint paint = config().getBackgroundPaint(); - double opacity = MathUtils.clamp(config().getBackgroundImageOpacity(), 0, 100) / 100.; + Paint paint = settings().backgroundPaintProperty().get(); + double opacity = MathUtils.clamp(settings().backgroundImageOpacityProperty().get(), 0, 100) / 100.; if (paint instanceof Color || paint == null) { Color color = (Color) paint; if (color == null) @@ -235,7 +235,7 @@ private Background getBackground() { if (image == null) { image = loadDefaultBackgroundImage(); } - return createBackgroundWithOpacity(image, config().getBackgroundImageOpacity()); + return createBackgroundWithOpacity(image, settings().backgroundImageOpacityProperty().get()); } private Background createBackgroundWithOpacity(Image image, int opacity) { @@ -275,12 +275,12 @@ private Background createBackgroundWithOpacity(Image image, int opacity) { * Load background image from bg/, background.png, background.jpg, background.gif */ private Image loadDefaultBackgroundImage() { - Image image = randomImageIn(Metadata.HMCL_CURRENT_DIRECTORY.resolve("background")); + Image image = randomImageIn(Metadata.HMCL_LOCAL_HOME.resolve("background")); if (image != null) return image; for (String extension : FXUtils.IMAGE_EXTENSIONS) { - image = tryLoadImage(Metadata.HMCL_CURRENT_DIRECTORY.resolve("background." + extension)); + image = tryLoadImage(Metadata.HMCL_LOCAL_HOME.resolve("background." + extension)); if (image != null) return image; } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AbstractInstallersPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AbstractInstallersPage.java index 4c1d8919b49..b1c2a97d590 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AbstractInstallersPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AbstractInstallersPage.java @@ -38,7 +38,7 @@ import org.jackhuang.hmcl.ui.wizard.WizardPage; import org.jackhuang.hmcl.util.SettingsMap; -import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.setting.SettingsManager.state; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public abstract class AbstractInstallersPage extends Control implements WizardPage { @@ -58,7 +58,7 @@ public AbstractInstallersPage(WizardController controller, String gameVersion, D String libraryId = library.getLibraryId(); if (libraryId.equals(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId())) continue; library.setOnInstall(() -> { - if (!Boolean.TRUE.equals(config().getShownTips().get(FABRIC_QUILT_API_TIP)) + if (!Boolean.TRUE.equals(state().getShownTips().get(FABRIC_QUILT_API_TIP)) && (LibraryAnalyzer.LibraryType.FABRIC_API.getPatchId().equals(libraryId) || LibraryAnalyzer.LibraryType.QUILT_API.getPatchId().equals(libraryId) || LibraryAnalyzer.LibraryType.LEGACY_FABRIC_API.getPatchId().equals(libraryId))) { @@ -66,7 +66,7 @@ public AbstractInstallersPage(WizardController controller, String gameVersion, D i18n("install.installer.fabric-quilt-api.warning", i18n("install.installer." + libraryId)), i18n("message.warning"), MessageDialogPane.MessageType.WARNING - ).ok(null).addCancel(i18n("button.do_not_show_again"), () -> config().getShownTips().put(FABRIC_QUILT_API_TIP, true)).build()); + ).ok(null).addCancel(i18n("button.do_not_show_again"), () -> state().getShownTips().put(FABRIC_QUILT_API_TIP, true)).build()); } if (!(library.resolvedStateProperty().get() instanceof InstallerItem.IncompatibleState)) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java index ee2cac9b3e0..c35b6a5eabe 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java @@ -142,7 +142,7 @@ private static Supplier loadVersionFor(Supplier nodeSuppl } public static void download(DownloadProvider downloadProvider, Profile profile, @Nullable String version, RemoteMod.Version file, String subdirectoryName) { - if (version == null) version = profile.getSelectedVersion(); + if (version == null) version = Profiles.getSelectedInstance(profile); Path runDirectory = profile.getRepository().hasVersion(version) ? profile.getRepository().getRunDirectory(version) : profile.getRepository().getBaseDirectory(); @@ -186,7 +186,7 @@ private void loadVersions(Profile profile) { listenerHolder = new WeakListenerHolder(); runInFX(() -> { if (profile == Profiles.getSelectedProfile()) { - listenerHolder.add(FXUtils.onWeakChangeAndOperate(profile.selectedVersionProperty(), version -> { + listenerHolder.add(FXUtils.onWeakChangeAndOperate(Profiles.selectedVersionProperty(), version -> { if (modTab.isInitialized()) { modTab.getNode().loadVersion(profile, null); } @@ -317,8 +317,10 @@ private Task finishVersionDownloadingAsync(SettingsMap settings) { builder.version(remoteVersion); }); - return builder.buildAsync().whenComplete(any -> profile.getRepository().refreshVersions()) - .thenRunAsync(Schedulers.javafx(), () -> profile.setSelectedVersion(name)); + return builder.buildAsync().whenComplete(any -> { + profile.getRepository().refreshVersions(); + profile.getRepository().applyDefaultIsolationSetting(name); + }).thenRunAsync(Schedulers.javafx(), () -> Profiles.setSelectedInstance(profile, name)); } @Override diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/LocalModpackPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/LocalModpackPage.java index 064f580d526..16393858e66 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/LocalModpackPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/LocalModpackPage.java @@ -72,7 +72,7 @@ public LocalModpackPage(WizardController controller) { } else { txtModpackName.getValidators().setAll( new RequiredValidator(), - new Validator(i18n("install.new_game.already_exists"), str -> !ModpackHelper.isExternalGameNameConflicts(str) && Profiles.getProfiles().stream().noneMatch(p -> p.getName().equals(str))), + new Validator(i18n("install.new_game.already_exists"), str -> !ModpackHelper.isExternalGameNameConflicts(str) && Profiles.getProfiles().stream().noneMatch(p -> str.equals(p.getName()))), new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidVersionId)); } }); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/ModpackInstallWizardProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/ModpackInstallWizardProvider.java index 1826421b108..ee03ef6d0d3 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/ModpackInstallWizardProvider.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/ModpackInstallWizardProvider.java @@ -26,6 +26,7 @@ import org.jackhuang.hmcl.mod.UnsupportedModpackException; import org.jackhuang.hmcl.mod.server.ServerModpackManifest; import org.jackhuang.hmcl.setting.Profile; +import org.jackhuang.hmcl.setting.Profiles; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.Controllers; @@ -120,10 +121,10 @@ private Task finishModpackInstallingAsync(SettingsMap settings) { } else { if (serverModpackManifest != null) { return ModpackHelper.getInstallTask(profile, serverModpackManifest, name, modpack) - .thenRunAsync(Schedulers.javafx(), () -> profile.setSelectedVersion(name)); + .thenRunAsync(Schedulers.javafx(), () -> Profiles.setSelectedInstance(profile, name)); } else { return ModpackHelper.getInstallTask(profile, selected, name, modpack, iconUrl) - .thenRunAsync(Schedulers.javafx(), () -> profile.setSelectedVersion(name)); + .thenRunAsync(Schedulers.javafx(), () -> Profiles.setSelectedInstance(profile, name)); } } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/VanillaInstallWizardProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/VanillaInstallWizardProvider.java index 03d26e87230..72fb0c9dcfd 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/VanillaInstallWizardProvider.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/VanillaInstallWizardProvider.java @@ -24,6 +24,7 @@ import org.jackhuang.hmcl.download.RemoteVersion; import org.jackhuang.hmcl.setting.DownloadProviders; import org.jackhuang.hmcl.setting.Profile; +import org.jackhuang.hmcl.setting.Profiles; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.wizard.WizardController; @@ -60,8 +61,11 @@ private Task finishVersionDownloadingAsync(SettingsMap settings) { builder.version(remoteVersion); }); - return builder.buildAsync().whenComplete(any -> profile.getRepository().refreshVersions()) - .thenRunAsync(Schedulers.javafx(), () -> profile.setSelectedVersion(name)); + return builder.buildAsync().whenComplete(any -> { + profile.getRepository().refreshVersions(); + profile.getRepository().applyDefaultIsolationSetting(name); + }) + .thenRunAsync(Schedulers.javafx(), () -> Profiles.setSelectedInstance(profile, name)); } @Override diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ExportWizardProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ExportWizardProvider.java index 8473cb27a71..5cd474fa7da 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ExportWizardProvider.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ExportWizardProvider.java @@ -26,16 +26,19 @@ import org.jackhuang.hmcl.mod.multimc.MultiMCInstanceConfiguration; import org.jackhuang.hmcl.mod.multimc.MultiMCModpackExportTask; import org.jackhuang.hmcl.mod.server.ServerModpackExportTask; -import org.jackhuang.hmcl.setting.Config; +import org.jackhuang.hmcl.setting.AuthlibInjectorServerList; +import org.jackhuang.hmcl.setting.LauncherSettings; import org.jackhuang.hmcl.setting.FontManager; +import org.jackhuang.hmcl.setting.GameSettings; +import org.jackhuang.hmcl.setting.GameWindowType; import org.jackhuang.hmcl.setting.Profile; -import org.jackhuang.hmcl.setting.VersionSetting; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.wizard.WizardController; import org.jackhuang.hmcl.ui.wizard.WizardProvider; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.SettingsMap; +import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.JarUtils; import org.jackhuang.hmcl.util.io.Zipper; @@ -45,7 +48,9 @@ import java.util.Collections; import java.util.List; -import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.setting.SettingsManager.settings; +import static org.jackhuang.hmcl.setting.SettingsManager.gameAccountsToJson; +import static org.jackhuang.hmcl.setting.SettingsManager.getAuthlibInjectorServers; public final class ExportWizardProvider implements WizardProvider { private final Profile profile; @@ -124,21 +129,27 @@ public Collection> getDependents() { public void execute() throws Exception { if (!packWithLauncher) return; try (Zipper zip = new Zipper(modpackFile)) { - Config exported = new Config(); - - exported.setBackgroundImageType(config().getBackgroundImageType()); - exported.setBackgroundImage(config().getBackgroundImage()); - exported.setThemeColor(config().getThemeColor()); - exported.setDownloadType(config().getDownloadType()); - exported.setPreferredLoginType(config().getPreferredLoginType()); - exported.getAuthlibInjectorServers().setAll(config().getAuthlibInjectorServers()); - - zip.putTextFile(exported.toJson(), ".hmcl/hmcl.json"); + LauncherSettings exported = new LauncherSettings(); + + exported.backgroundImageTypeProperty().set(settings().backgroundImageTypeProperty().get()); + exported.backgroundImageProperty().set(settings().backgroundImageProperty().get()); + exported.themeColorProperty().set(settings().themeColorProperty().get()); + exported.versionListSourceProperty().set(settings().versionListSourceProperty().get()); + exported.fileDownloadSourceProperty().set(settings().fileDownloadSourceProperty().get()); + exported.preferredLoginTypeProperty().set(settings().preferredLoginTypeProperty().get()); + + zip.putTextFile(exported.toJson(), ".hmcl/settings.json"); + AuthlibInjectorServerList exportedServers = new AuthlibInjectorServerList(); + exportedServers.getServers().setAll(getAuthlibInjectorServers()); + zip.putTextFile( + JsonUtils.GSON.toJson(exportedServers, AuthlibInjectorServerList.class), + ".hmcl/authlib-injector-servers.json"); + zip.putTextFile(gameAccountsToJson(), ".hmcl/game-accounts.json"); zip.putFile(tempModpack, ModpackTypeSelectionPage.MODPACK_TYPE_MODRINTH.equals(modpackType) ? "modpack.mrpack" : "modpack.zip"); - Path bg = Metadata.HMCL_CURRENT_DIRECTORY.resolve("background"); + Path bg = Metadata.HMCL_LOCAL_HOME.resolve("background"); if (!Files.isDirectory(bg)) bg = Metadata.CURRENT_DIRECTORY.resolve("bg"); if (Files.isDirectory(bg)) @@ -146,7 +157,7 @@ public void execute() throws Exception { for (String extension : FXUtils.IMAGE_EXTENSIONS) { String fileName = "background." + extension; - Path background = Metadata.HMCL_CURRENT_DIRECTORY.resolve(fileName); + Path background = Metadata.HMCL_LOCAL_HOME.resolve(fileName); if (!Files.isRegularFile(background)) background = Metadata.CURRENT_DIRECTORY.resolve(fileName); if (Files.isRegularFile(background)) @@ -155,7 +166,7 @@ public void execute() throws Exception { for (String extension : FontManager.FONT_EXTENSIONS) { String fileName = "font." + extension; - Path font = Metadata.HMCL_CURRENT_DIRECTORY.resolve(fileName); + Path font = Metadata.HMCL_LOCAL_HOME.resolve(fileName); if (!Files.isRegularFile(font)) font = Metadata.CURRENT_DIRECTORY.resolve(fileName); if (Files.isRegularFile(font)) @@ -198,25 +209,25 @@ private Task exportAsMultiMC(ModpackExportInfo exportInfo, Path modpackFile) @Override public void execute() { - VersionSetting vs = profile.getVersionSetting(version); + GameSettings.Effective setting = profile.getRepository().getEffectiveGameSettings(version); dependency = new MultiMCModpackExportTask(profile.getRepository(), version, exportInfo.getWhitelist(), new MultiMCInstanceConfiguration( "OneSix", exportInfo.getName() + "-" + exportInfo.getVersion(), null, - Lang.toIntOrNull(vs.getPermSize()), - vs.getWrapper(), - vs.getPreLaunchCommand(), + Lang.toIntOrNull(setting.getPermSize()), + setting.getCommandWrapper(), + setting.getPreLaunchCommand(), null, exportInfo.getDescription(), null, exportInfo.getJavaArguments(), - vs.isFullscreen(), - vs.getWidth(), - vs.getHeight(), + setting.getWindowType() == GameWindowType.FULLSCREEN, + setting.getWidth(), + setting.getHeight(), null, exportInfo.getMinMemory(), - vs.isShowLogs(), + setting.isShowLogs(), /* showConsoleOnError */ true, /* autoCloseConsole */ false, /* overrideMemory */ true, diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackInfoPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackInfoPage.java index 65aff682c06..167e8a30bc1 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackInfoPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackInfoPage.java @@ -39,7 +39,7 @@ import org.jackhuang.hmcl.mod.ModpackExportInfo; import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackManifest; import org.jackhuang.hmcl.setting.Accounts; -import org.jackhuang.hmcl.setting.VersionSetting; +import org.jackhuang.hmcl.setting.GameSettings; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.construct.*; @@ -56,7 +56,7 @@ import java.util.*; import java.util.concurrent.atomic.AtomicBoolean; -import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.setting.SettingsManager.getAuthlibInjectorServers; import static org.jackhuang.hmcl.ui.export.ModpackTypeSelectionPage.MODPACK_TYPE; import static org.jackhuang.hmcl.ui.export.ModpackTypeSelectionPage.MODPACK_TYPE_MODRINTH; import static org.jackhuang.hmcl.ui.export.ModpackTypeSelectionPage.MODPACK_TYPE_SERVER; @@ -100,10 +100,10 @@ public ModpackInfoPage(WizardController controller, HMCLGameRepository gameRepos name.set(version); author.set(Optional.ofNullable(Accounts.getSelectedAccount()).map(Account::getUsername).orElse("")); - VersionSetting versionSetting = gameRepository.getVersionSetting(versionName); + GameSettings.Effective versionSetting = gameRepository.getEffectiveGameSettings(versionName); minMemory.set(Optional.ofNullable(versionSetting.getMinMemory()).orElse(0)); - launchArguments.set(versionSetting.getMinecraftArgs()); - javaArguments.set(versionSetting.getJavaArgs()); + launchArguments.set(versionSetting.getGameArgs()); + javaArguments.set(versionSetting.getJVMOptions()); canIncludeLauncher = JarUtils.thisJarPath() != null; } @@ -318,7 +318,7 @@ public ModpackInfoPageSkin(ModpackInfoPage skinnable) { serversSelectButton.setTitle(i18n("account.injector.server")); serversSelectButton.setNullSafeConverter(AuthlibInjectorServer::getName); serversSelectButton.setDescriptionConverter(AuthlibInjectorServer::getUrl); - serversSelectButton.itemsProperty().set(config().getAuthlibInjectorServers()); + serversSelectButton.itemsProperty().set(getAuthlibInjectorServers()); skinnable.authlibInjectorServer.bind(Bindings.createStringBinding(() -> { AuthlibInjectorServer selected = serversSelectButton.getValue(); 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 new file mode 100644 index 00000000000..63878f673a4 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/game/GameSettingsPage.java @@ -0,0 +1,2549 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.game; + +import com.github.f4b6a3.uuid.alt.GUID; +import com.jfoenix.controls.JFXButton; +import com.jfoenix.controls.JFXComboBox; +import com.jfoenix.controls.JFXSlider; +import com.jfoenix.controls.JFXTextField; +import javafx.beans.InvalidationListener; +import javafx.beans.binding.Bindings; +import javafx.beans.property.*; +import javafx.beans.value.ChangeListener; +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.css.PseudoClass; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.geometry.Rectangle2D; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.control.ContentDisplay; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.Tooltip; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.*; +import javafx.stage.FileChooser; +import javafx.stage.Screen; +import org.jackhuang.hmcl.game.*; +import org.jackhuang.hmcl.java.JavaManager; +import org.jackhuang.hmcl.java.JavaRuntime; +import org.jackhuang.hmcl.setting.*; +import org.jackhuang.hmcl.setting.property.InheritableProperty; +import org.jackhuang.hmcl.setting.property.SettingProperty; +import org.jackhuang.hmcl.ui.*; +import org.jackhuang.hmcl.ui.construct.*; +import org.jackhuang.hmcl.ui.decorator.DecoratorPage; +import org.jackhuang.hmcl.ui.versions.VersionIconDialog; +import org.jackhuang.hmcl.ui.versions.VersionPage; +import org.jackhuang.hmcl.ui.versions.Versions; +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; +import org.jackhuang.hmcl.util.platform.Architecture; +import org.jackhuang.hmcl.util.platform.OperatingSystem; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.UnknownNullability; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.function.Function; + +import static org.jackhuang.hmcl.util.Pair.pair; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; + +/// @author Glavo +@NotNullByDefault +public final class GameSettingsPage extends StackPane + implements DecoratorPage, VersionPage.VersionLoadable, PageAware { + + private static final Object INHERIT_BUTTON_TOOLTIP_KEY = new Object(); + private static final PseudoClass PSEUDO_OVERRIDDEN = PseudoClass.getPseudoClass("overridden"); + private static final String INHERIT_BUTTON_STYLE_CLASS = "toggle-icon-tiny"; + private static final int INHERIT_BUTTON_ICON_SIZE = 12; + + private final boolean isPresetSetting; + + private final ObjectProperty state = new SimpleObjectProperty<>(this, "state", new State("", null, false, false, false)); + private final WeakListenerHolder holder = new WeakListenerHolder(); + + /// The selected profile. + private @Nullable Profile profile; + + /// The current instance ID. + private @Nullable String instanceId; + + /// The current setting. + private final ObjectProperty<@Nullable S> currentSetting = new SimpleObjectProperty<>(this, "setting"); + + private boolean updatingJavaSetting = false; + private boolean updatingSelectedJava = false; + private boolean updatingParentSetting = false; + + // GUI + private final ScrollPane scrollPane; + private final VBox rootPane; + + private final @UnknownNullability ImagePickerItem iconPickerItem; + + private final ComponentSublist javaSublist; + private final RadioChoiceList<@Nullable Pair<@Nullable JavaVersionType, @Nullable JavaRuntime>> javaItem; + private final RadioChoiceList.Choice<@Nullable Pair<@Nullable JavaVersionType, @Nullable JavaRuntime>> javaAutoDeterminedOption; + private final RadioChoiceList.TextChoice<@Nullable Pair<@Nullable JavaVersionType, @Nullable JavaRuntime>> javaVersionOption; + private final RadioChoiceList.FileChoice<@Nullable Pair<@Nullable JavaVersionType, @Nullable JavaRuntime>> javaCustomOption; + private final InvalidationListener javaListener = o -> initializeSelectedJava(); + + public GameSettingsPage(Class settingType) { + assert settingType == GameSettings.Preset.class || settingType == GameSettings.Instance.class; + + this.isPresetSetting = settingType == GameSettings.Preset.class; + + this.scrollPane = new ScrollPane(); + scrollPane.setFitToHeight(true); + scrollPane.setFitToWidth(true); + scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); + getChildren().setAll(scrollPane); + + this.rootPane = new VBox(); + rootPane.setFillWidth(true); + scrollPane.setContent(rootPane); + FXUtils.smoothScrolling(scrollPane); + rootPane.getStyleClass().add("card-list"); + scrollPane.setContent(rootPane); + + var basicSettings = new ComponentList(); + var gameSettings = new ComponentList(); + var launcherSettings = new ComponentList(); + { + if (isPresetSetting) { + var presetSettings = new ComponentList(); + rootPane.getChildren().addAll( + ComponentList.createComponentListTitle(i18n("settings.type.global.preset")), + presetSettings, + ComponentList.createComponentListTitle(i18n("settings.game.section.basic")), + basicSettings, + ComponentList.createComponentListTitle(i18n("settings.game.section.game")), + gameSettings, + ComponentList.createComponentListTitle(i18n("settings.launcher")), + launcherSettings + ); + + iconPickerItem = null; + createPresetManagementSublist(presetSettings); + } else { + rootPane.getChildren().addAll( + ComponentList.createComponentListTitle(i18n("settings.game.section.basic")), + basicSettings, + ComponentList.createComponentListTitle(i18n("settings.game.section.game")), + gameSettings, + ComponentList.createComponentListTitle(i18n("settings.launcher")), + launcherSettings + ); + + iconPickerItem = new ImagePickerItem(); + basicSettings.getContent().add(iconPickerItem); + iconPickerItem.setImage(FXUtils.newBuiltinImage("/assets/img/icon.png")); + iconPickerItem.setTitle(i18n("settings.icon")); + iconPickerItem.setOnSelectButtonClicked(e -> onExploreIcon()); + iconPickerItem.setOnDeleteButtonClicked(e -> onDeleteIcon()); + + var parentGameSettingsPane = new LineSelectButton(); + basicSettings.getContent().add(parentGameSettingsPane); + parentGameSettingsPane.setTitle(i18n("settings.type.global.preset")); + parentGameSettingsPane.setConverter(setting -> setting != null + ? setting.nameProperty().getValue() + : i18n("settings.type.global.preset.default")); + bindInstanceParentSetting(parentGameSettingsPane); + } + + // Java Setting + javaSublist = new ComponentSublist(); + gameSettings.getContent().add(javaSublist); + javaSublist.setTitle(i18n("settings.game.java_directory")); + javaSublist.setHasSubtitle(true); + { + javaItem = new RadioChoiceList<>(); + javaSublist.getContent().setAll(javaItem); + bindJavaInheritanceButton(javaSublist); + + javaAutoDeterminedOption = new RadioChoiceList.Choice<>(i18n("settings.game.java_directory.auto"), pair(JavaVersionType.AUTO, null)); + javaVersionOption = new RadioChoiceList.TextChoice<>(i18n("settings.game.java_directory.version"), pair(JavaVersionType.VERSION, null)); + javaVersionOption.setValidators(new NumberValidator(true)); + FXUtils.setLimitWidth(javaVersionOption.getTextField(), 40); + + javaCustomOption = new RadioChoiceList.FileChoice<@Nullable Pair<@Nullable JavaVersionType, @Nullable JavaRuntime>>(i18n("settings.custom"), pair(JavaVersionType.CUSTOM, null)) + .setChooserTitle(i18n("settings.game.java_directory.choose")); + if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) + javaCustomOption.addExtensionFilter(new FileChooser.ExtensionFilter(i18n("settings.game.java_directory"), "java.exe")); + + holder.add(FXUtils.onWeakChangeAndOperate(JavaManager.getAllJavaProperty(), allJava -> { + var options = new ArrayList>>(); + options.add(javaAutoDeterminedOption); + options.add(javaVersionOption); + if (allJava != null) { + boolean isX86 = Architecture.SYSTEM_ARCH.isX86() && allJava.stream().allMatch(java -> java.getArchitecture().isX86()); + + for (JavaRuntime java : allJava) { + options.add(new RadioChoiceList.Choice<>( + i18n("settings.game.java_directory.template", + java.getVersion(), + isX86 ? i18n("settings.game.java_directory.bit", java.getBits().getBit()) + : java.getPlatform().getArchitecture().getDisplayName()), + pair(JavaVersionType.DETECTED, java)) + .setSubtitle(java.getBinary().toString())); + } + } + + options.add(javaCustomOption); + javaItem.setChoices(options); + initializeSelectedJava(); + })); + } + currentSetting.addListener((o, oldSetting, newSetting) -> { + if (oldSetting != null) { + oldSetting.javaTypeProperty().removeListener(javaListener); + oldSetting.defaultJavaPathProperty().removeListener(javaListener); + oldSetting.customJavaPathProperty().removeListener(javaListener); + oldSetting.javaVersionProperty().removeListener(javaListener); + } + + if (newSetting != null) { + newSetting.javaTypeProperty().addListener(javaListener); + newSetting.defaultJavaPathProperty().addListener(javaListener); + newSetting.customJavaPathProperty().addListener(javaListener); + newSetting.javaVersionProperty().addListener(javaListener); + } + + initJavaSubtitle(); + }); + + javaItem.selectedChoiceProperty().addListener((observable, oldChoice, newChoice) -> { + S setting = currentSetting.get(); + if (setting == null || updatingSelectedJava) { + return; + } + + updatingJavaSetting = true; + try { + if (javaCustomOption.isSelected()) { + setPropertyOverridden(setting, setting.javaTypeProperty(), true); + setting.javaTypeProperty().setValue(JavaVersionType.CUSTOM); + setting.customJavaPathProperty().setValue(javaCustomOption.getPath()); + setting.javaVersionProperty().setValue(""); + setting.defaultJavaPathProperty().setValue(""); + } else if (javaAutoDeterminedOption.isSelected()) { + setPropertyOverridden(setting, setting.javaTypeProperty(), true); + setting.javaTypeProperty().setValue(JavaVersionType.AUTO); + setting.javaVersionProperty().setValue(""); + setting.defaultJavaPathProperty().setValue(""); + } else if (javaVersionOption.isSelected()) { + setPropertyOverridden(setting, setting.javaTypeProperty(), true); + setting.javaTypeProperty().setValue(JavaVersionType.VERSION); + setting.javaVersionProperty().setValue(javaVersionOption.getText()); + setting.defaultJavaPathProperty().setValue(""); + } else if (newChoice != null) { + @Nullable Pair<@Nullable JavaVersionType, @Nullable JavaRuntime> selectedJava = newChoice.getValue(); + if (selectedJava != null + && selectedJava.getKey() == JavaVersionType.DETECTED + && selectedJava.getValue() != null) { + JavaRuntime java = selectedJava.getValue(); + setPropertyOverridden(setting, setting.javaTypeProperty(), true); + setting.javaTypeProperty().setValue(JavaVersionType.DETECTED); + setting.javaVersionProperty().setValue(java.getVersion()); + setting.defaultJavaPathProperty().setValue(java.getBinary().toString()); + } + } + } finally { + updatingJavaSetting = false; + initJavaSubtitle(); + } + }); + + javaVersionOption.textProperty().addListener((observable, oldValue, newValue) -> { + S setting = currentSetting.get(); + if (setting != null && javaVersionOption.isSelected() && !updatingSelectedJava) { + setPropertyOverridden(setting, setting.javaTypeProperty(), true); + setting.javaTypeProperty().setValue(JavaVersionType.VERSION); + setting.javaVersionProperty().setValue(newValue); + initJavaSubtitle(); + } + }); + + javaCustomOption.pathProperty().addListener((observable, oldValue, newValue) -> { + S setting = currentSetting.get(); + if (setting != null && javaCustomOption.isSelected() && !updatingSelectedJava) { + setPropertyOverridden(setting, setting.javaTypeProperty(), true); + setting.javaTypeProperty().setValue(JavaVersionType.CUSTOM); + setting.customJavaPathProperty().setValue(newValue); + initJavaSubtitle(); + } + }); + + // Isolation Setting + if (isPresetSetting) { + var defaultIsolationTypePane = new LineSelectButton(); + basicSettings.getContent().add(defaultIsolationTypePane); + defaultIsolationTypePane.setTitle(i18n("settings.game.default_isolation")); + defaultIsolationTypePane.setItems(DefaultIsolationType.values()); + defaultIsolationTypePane.setNullSafeConverter(type -> switch (type) { + case NEVER -> i18n("settings.game.default_isolation.never"); + case ALWAYS -> i18n("settings.game.default_isolation.always"); + case MODED -> i18n("settings.game.default_isolation.modded"); + }); + + bindPresetBidirectional(defaultIsolationTypePane.valueProperty(), GameSettings.Preset::defaultIsolationTypeProperty); + } else { + var isolationButton = new LineToggleButton(); + basicSettings.getContent().add(isolationButton); + isolationButton.setTitle(i18n("settings.game.isolation")); + isolationButton.setSubtitle(i18n("settings.game.isolation.subtitle")); + bindInstanceIsolationButton(isolationButton); + } + + // Memory Setting + @Nullable JFXButton autoMemoryButton = !isPresetSetting ? createInheritanceButton() : null; + var memorySublist = new ComponentSublist(() -> { + var memoryItem = new RadioChoiceList(); + memoryItem.setFallbackValue(true); + + var maxMemorySlider = new JFXSlider(0, 1, 0); + maxMemorySlider.setPrefWidth(220); + HBox.setMargin(maxMemorySlider, new Insets(0, 0, 0, 8)); + HBox.setHgrow(maxMemorySlider, Priority.ALWAYS); + + var maxMemoryTextField = new JFXTextField(); + FXUtils.setLimitWidth(maxMemoryTextField, 60); + FXUtils.setValidateWhileTextChanged(maxMemoryTextField, true); + maxMemoryTextField.setValidators(new NumberValidator(i18n("input.number"), false)); + + @Nullable JFXButton maxMemoryButton = null; + if (!isPresetSetting) { + maxMemoryButton = createInheritanceButton(); + } + + var options = new ArrayList>(); + options.add(new RadioChoiceList.Choice<>(i18n("settings.memory.auto_allocate"), true)); + options.add(new ManualMemoryChoice(maxMemorySlider, maxMemoryTextField, maxMemoryButton)); + memoryItem.setChoices(options); + + var memoryStatusBar = new MemoryStatusBar(); + + var digitalPane = new BorderPane(); + var physicalMemoryLabel = new Label(); + physicalMemoryLabel.getStyleClass().add("memory-label"); + digitalPane.setLeft(physicalMemoryLabel); + var allocatedMemoryLabel = new Label(); + allocatedMemoryLabel.getStyleClass().add("memory-label"); + digitalPane.setRight(allocatedMemoryLabel); + + var memoryStatusPane = new VBox(); + memoryStatusPane.setPadding(new Insets(16, 16, 10, 16)); + memoryStatusPane.getChildren().setAll(memoryStatusBar, digitalPane); + ComponentList.setNoPadding(memoryStatusPane); + + IndependentSettingBinder.bindMemoryChoiceList( + currentSetting, + memoryItem, + maxMemorySlider, + maxMemoryTextField, + memoryStatusBar, + digitalPane, + autoMemoryButton, + maxMemoryButton, + GameSettingsPage::updateInheritanceButton, + this::getParentGameSettings); + + return List.of(memoryItem, memoryStatusPane); + }); + if (autoMemoryButton != null) { + memorySublist.setTitleRight(autoMemoryButton); + } + gameSettings.getContent().add(memorySublist); + memorySublist.setTitle(i18n("settings.memory")); + memorySublist.setHasSubtitle(true); + memorySublist.setDescription(i18n("settings.memory.auto_allocate")); + + // Launcher Visibility Setting + var launcherVisibilityPane = createInheritableButton( + GameSettings::launcherVisibilityProperty, + value -> i18n("settings.advanced.launcher_visibility." + value.name().toLowerCase(Locale.ROOT)), + null, + LauncherVisibility.values() + ); + launcherSettings.getContent().add(launcherVisibilityPane); + launcherVisibilityPane.setTitle(i18n("settings.advanced.launcher_visible")); + + var allowAutoAgentPane = createInheritableBooleanButton(GameSettings::allowAutoAgentProperty); + launcherSettings.getContent().add(allowAutoAgentPane); + allowAutoAgentPane.setTitle(i18n("settings.launcher.allow_auto_agent")); + allowAutoAgentPane.setSubtitle(i18n("settings.launcher.allow_auto_agent.subtitle")); + + var disableAutoGameOptionsPane = createInheritableBooleanButton(GameSettings::disableAutoGameOptionsProperty); + launcherSettings.getContent().add(disableAutoGameOptionsPane); + disableAutoGameOptionsPane.setTitle(i18n("settings.launcher.disable_auto_game_options")); + + // Game Window Setting + var windowTypeSublist = new ComponentSublist(); + gameSettings.getContent().add(windowTypeSublist); + windowTypeSublist.setTitle(i18n("settings.game.window_type")); + windowTypeSublist.setHasSubtitle(true); + { + var windowTypeItem = new RadioChoiceList(); + var windowTypeOptions = new ArrayList>(); + windowTypeItem.setFallbackValue(GameWindowType.WINDOWED); + + var cboWindowSize = new JFXComboBox(); + cboWindowSize.setPrefWidth(150); + cboWindowSize.setEditable(true); + cboWindowSize.setPromptText("854x480"); + cboWindowSize.getItems().setAll(getSupportedResolutions()); + bindWindowSizeComboBox(cboWindowSize); + + for (GameWindowType type : GameWindowType.values()) { + if (type == GameWindowType.WINDOWED) { + windowTypeOptions.add(new WindowedWindowTypeOption(cboWindowSize)); + } else { + windowTypeOptions.add(new RadioChoiceList.Choice<>(getWindowTypeDisplayName(type), type)); + } + } + + windowTypeItem.setChoices(windowTypeOptions); + windowTypeSublist.getContent().add(windowTypeItem); + bindWindowSettings(windowTypeSublist, windowTypeItem); + bindInheritableSublistDescription( + windowTypeSublist, + GameSettings::windowTypeProperty, + GameSettingsPage::getWindowTypeDisplayName); + } + + // Show Logs Window Setting + var showLogsPane = createInheritableBooleanButton(GameSettings::showLogsProperty); + launcherSettings.getContent().add(showLogsPane); + showLogsPane.setTitle(i18n("settings.show_log")); + + // Enable Debug Log Output Setting + var enableDebugLogOutputPane = createInheritableBooleanButton(GameSettings::enableDebugLogOutputProperty); + launcherSettings.getContent().add(enableDebugLogOutputPane); + enableDebugLogOutputPane.setTitle(i18n("settings.enable_debug_log_output")); + + var noGameCheckPane = createInheritableBooleanButton(GameSettings::notCheckGameProperty); + launcherSettings.getContent().add(noGameCheckPane); + noGameCheckPane.setTitle(i18n("settings.advanced.dont_check_game_completeness")); + + // Quick Play + var quickSublist = new ComponentSublist(); + { + var quickPlayItem = new RadioChoiceList(); + + var noneOption = new RadioChoiceList.Choice<>(i18n("settings.game.quick_play.none"), QuickPlayType.NONE); + + var multiplayerOption = new RadioChoiceList.TextChoice<>( + i18n("settings.game.quick_play.multiplayer"), QuickPlayType.MULTIPLAYER); + multiplayerOption.setValidators(new Validator(str -> { + if (StringUtils.isBlank(str)) + return true; + try { + ServerAddress.parse(str); + return true; + } catch (Exception ignored) { + return false; + } + })); + + var singleplayerOption = new RadioChoiceList.TextChoice<>( + i18n("settings.game.quick_play.singleplayer"), QuickPlayType.SINGLEPLAYER); + singleplayerOption.setValidators(new Validator(str -> { + if (StringUtils.isBlank(str)) + return true; + return FileUtils.isNameValid(str); + })); + + var realmsOption = new RadioChoiceList.TextChoice<>( + i18n("settings.game.quick_play.realms"), QuickPlayType.REALMS); + + quickPlayItem.setFallbackValue(QuickPlayType.NONE); + quickPlayItem.setChoices(List.of( + noneOption, + multiplayerOption, + singleplayerOption, + realmsOption + )); + + bindInheritableRadioChoiceList(quickSublist, quickPlayItem, GameSettings::quickPlayProperty); + bindInheritableStringValue(multiplayerOption.textProperty(), GameSettings::quickPlayMultiplayerProperty); + bindInheritableStringValue(singleplayerOption.textProperty(), GameSettings::quickPlaySingleplayerProperty); + bindInheritableStringValue(realmsOption.textProperty(), GameSettings::quickPlayRealmsProperty); + quickSublist.getContent().setAll(quickPlayItem); + } + gameSettings.getContent().add(quickSublist); + quickSublist.setTitle(i18n("settings.game.quick_play")); + quickSublist.setSubtitle(i18n("settings.game.quick_play.subtitle")); + quickSublist.setHasSubtitle(true); + + var advancedLaunchSublist = new ComponentSublist(() -> { + var runningDirPane = new LinePane(); + runningDirPane.setTitle(i18n("settings.game.running_directory")); + runningDirPane.setSubtitle(i18n(isPresetSetting + ? "settings.game.running_directory.subtitle" + : "settings.game.running_directory.subtitle.instance")); + { + var runningDirSelector = new FileSelector() + .setChooserTitle(i18n("settings.game.working_directory.choose")) + .setSelectionMode(FileSelector.SelectionMode.DIRECTORY); + runningDirSelector.setPrefWidth(400); + runningDirPane.setRight(runningDirSelector); + bindRunningDirectoryProperty(runningDirPane, runningDirSelector.valueProperty(), runningDirSelector); + } + + var gameArgsPane = new LinePane(); + gameArgsPane.setTitle(i18n("settings.advanced.minecraft_arguments")); + { + var txtGameArgs = new JFXTextField(); + txtGameArgs.setPromptText(i18n("settings.advanced.minecraft_arguments.prompt")); + txtGameArgs.setPrefWidth(400); + gameArgsPane.setRight(txtGameArgs); + bindIndependentTextField(gameArgsPane, txtGameArgs, GameSettings::gameArgsProperty); + } + + var environmentVariablesPane = new LinePane(); + environmentVariablesPane.setTitle(i18n("settings.advanced.environment_variables")); + environmentVariablesPane.setSubtitle(i18n("settings.advanced.environment_variables.subtitle")); + { + var txtEnvironmentVariables = new JFXTextField(); + txtEnvironmentVariables.setPrefWidth(400); + environmentVariablesPane.setRight(txtEnvironmentVariables); + bindIndependentTextField(environmentVariablesPane, txtEnvironmentVariables, GameSettings::environmentVariablesProperty); + } + + var processPriorityPane = createInheritableButton( + GameSettings::processPriorityProperty, + e -> i18n("settings.advanced.process_priority." + e.name().toLowerCase(Locale.ROOT)), + e -> { + String bundleKey = "settings.advanced.process_priority." + e.name().toLowerCase(Locale.ROOT) + ".desc"; + return I18n.hasKey(bundleKey) ? i18n(bundleKey) : ""; + }, + ProcessPriority.values() + ); + processPriorityPane.setTitle(i18n("settings.advanced.process_priority")); + + return List.of(runningDirPane, gameArgsPane, environmentVariablesPane, processPriorityPane); + }); + gameSettings.getContent().add(advancedLaunchSublist); + advancedLaunchSublist.setTitle(i18n("settings.advanced.launch_options")); + advancedLaunchSublist.setSubtitle(i18n("settings.advanced.launch_options.subtitle")); + advancedLaunchSublist.setHasSubtitle(true); + } + + var jvmSettings = new ComponentList(); + rootPane.getChildren().addAll( + ComponentList.createComponentListTitle(i18n("settings.advanced.jvm")), + jvmSettings + ); + { + var noJVMArgsPane = createInheritableBooleanButton(GameSettings::noJVMOptionsProperty); + jvmSettings.getContent().add(noJVMArgsPane); + noJVMArgsPane.setTitle(i18n("settings.advanced.no_jvm_args")); + + var noOptimizingJVMArgsPane = createInheritableBooleanButton(GameSettings::noOptimizingJVMOptionsProperty); + jvmSettings.getContent().add(noOptimizingJVMArgsPane); + noOptimizingJVMArgsPane.setTitle(i18n("settings.advanced.no_optimizing_jvm_args")); + noOptimizingJVMArgsPane.disableProperty().bind(noJVMArgsPane.effectiveValueProperty()); + + var noJVMCheckPane = createInheritableBooleanButton(GameSettings::notCheckJVMProperty); + jvmSettings.getContent().add(noJVMCheckPane); + noJVMCheckPane.setTitle(i18n("settings.advanced.dont_check_jvm_validity")); + + var jvmArgsPane = new LinePane(); + jvmSettings.getContent().add(jvmArgsPane); + jvmArgsPane.setTitle(i18n("settings.advanced.jvm_args")); + { + var txtJVMArgs = new JFXTextField(); + // txtJVMArgs.setPromptText(i18n("settings.advanced.jvm_args.prompt")); + txtJVMArgs.setPrefWidth(400); + jvmArgsPane.setRight(txtJVMArgs); + bindIndependentTextField(jvmArgsPane, txtJVMArgs, GameSettings::jvmOptionsProperty); + } + + var deprecatedJvmMemorySettings = new ComponentSublist(() -> { + var minMemoryPane = new LinePane(); + minMemoryPane.setTitle(i18n("settings.memory.lower_bound")); + { + var txtMinMemory = new JFXTextField(); + txtMinMemory.setPrefWidth(160); + minMemoryPane.setRight(new HBox(8, txtMinMemory, new Label(i18n("settings.memory.unit.mib")))); + bindIndependentIntegerTextField(minMemoryPane, txtMinMemory, GameSettings::minMemoryProperty); + } + + var metaspacePane = new LinePane(); + metaspacePane.setTitle(i18n("settings.advanced.java_permanent_generation_space")); + { + var txtMetaspace = new JFXTextField(); + txtMetaspace.setPromptText(i18n("settings.advanced.java_permanent_generation_space.prompt")); + txtMetaspace.setPrefWidth(160); + metaspacePane.setRight(new HBox(8, txtMetaspace, new Label(i18n("settings.memory.unit.mib")))); + bindIndependentTextField(metaspacePane, txtMetaspace, GameSettings::permSizeProperty); + } + + return List.of(minMemoryPane, metaspacePane); + }); + jvmSettings.getContent().add(deprecatedJvmMemorySettings); + deprecatedJvmMemorySettings.setTitle(i18n("settings.advanced.jvm_memory.deprecated")); + deprecatedJvmMemorySettings.setHasSubtitle(true); + deprecatedJvmMemorySettings.setSubtitle(i18n("settings.advanced.jvm_memory.deprecated.subtitle")); + } + + var customCommandSettings = new ComponentList(); + rootPane.getChildren().addAll( + ComponentList.createComponentListTitle(i18n("settings.advanced.custom_commands")), + customCommandSettings + ); + { + var preLaunchCommandPane = new LinePane(); + customCommandSettings.getContent().add(preLaunchCommandPane); + preLaunchCommandPane.setTitle(i18n("settings.advanced.precall_command")); + { + var txtPreLaunchCommand = new JFXTextField(); + txtPreLaunchCommand.setPromptText(i18n("settings.advanced.precall_command.prompt")); + txtPreLaunchCommand.setPrefWidth(400); + preLaunchCommandPane.setRight(txtPreLaunchCommand); + bindInheritableTextField(preLaunchCommandPane, txtPreLaunchCommand, GameSettings::preLaunchCommandProperty); + } + + var wrapperPane = new LinePane(); + customCommandSettings.getContent().add(wrapperPane); + wrapperPane.setTitle(i18n("settings.advanced.wrapper_launcher")); + { + var txtWrapper = new JFXTextField(); + txtWrapper.setPromptText(i18n("settings.advanced.wrapper_launcher.prompt")); + txtWrapper.setPrefWidth(400); + wrapperPane.setRight(txtWrapper); + bindInheritableTextField(wrapperPane, txtWrapper, GameSettings::commandWrapperProperty); + } + + var postExitCommandPane = new LinePane(); + customCommandSettings.getContent().add(postExitCommandPane); + postExitCommandPane.setTitle(i18n("settings.advanced.post_exit_command")); + { + var txtPostExitCommand = new JFXTextField(); + txtPostExitCommand.setPromptText(i18n("settings.advanced.post_exit_command.prompt")); + txtPostExitCommand.setPrefWidth(400); + postExitCommandPane.setRight(txtPostExitCommand); + bindInheritableTextField(postExitCommandPane, txtPostExitCommand, GameSettings::postExitCommandProperty); + } + } + + var graphicsSettings = new ComponentList(); + rootPane.getChildren().addAll( + ComponentList.createComponentListTitle(i18n("settings.advanced.graphics")), + graphicsSettings + ); + { + var graphicsBackendPane = createInheritableButton( + GameSettings::graphicsBackendProperty, + backend -> i18n("settings.advanced.graphics_backend." + backend.name().toLowerCase(Locale.ROOT)), + backend -> switch (backend) { + case DEFAULT -> i18n("settings.advanced.graphics_backend.default.desc"); + case OPENGL -> i18n("settings.advanced.graphics_backend.opengl.desc"); + case VULKAN -> i18n("settings.advanced.graphics_backend.vulkan.desc"); + }, + GraphicsAPI.values()); + graphicsSettings.getContent().add(graphicsBackendPane); + graphicsBackendPane.setTitle(i18n("settings.advanced.graphics_backend")); + + var openGLRendererPane = createInheritableButton( + GameSettings::openGLRendererProperty, + e -> i18n("settings.advanced.renderer." + e.name().toLowerCase(Locale.ROOT)), + e -> { + String bundleKey = "settings.advanced.renderer." + e.name().toLowerCase(Locale.ROOT) + ".desc"; + return I18n.hasKey(bundleKey) ? i18n(bundleKey) : null; + }, + Renderer.getSupported(GraphicsAPI.OPENGL).toArray(Renderer[]::new)); + graphicsSettings.getContent().add(openGLRendererPane); + openGLRendererPane.setTitle(i18n("settings.advanced.renderer.opengl")); + + var vulkanRendererPane = createInheritableButton( + GameSettings::vulkanRendererProperty, + e -> i18n("settings.advanced.renderer." + e.name().toLowerCase(Locale.ROOT)), + e -> { + String bundleKey = "settings.advanced.renderer." + e.name().toLowerCase(Locale.ROOT) + ".desc"; + return I18n.hasKey(bundleKey) ? i18n(bundleKey) : null; + }, + Renderer.getSupported(GraphicsAPI.VULKAN).toArray(Renderer[]::new)); + graphicsSettings.getContent().add(vulkanRendererPane); + vulkanRendererPane.setTitle(i18n("settings.advanced.renderer.vulkan")); + + } + + var nativeLibrarySettings = new ComponentList(); + rootPane.getChildren().addAll( + ComponentList.createComponentListTitle(i18n("settings.advanced.natives_settings")), + nativeLibrarySettings + ); + { + var useCustomNativesDirPane = createIndependentNativesDirTypeButton(); + nativeLibrarySettings.getContent().add(useCustomNativesDirPane); + useCustomNativesDirPane.setTitle(i18n("settings.advanced.natives_directory.custom.enabled")); + + var nativesDirPane = new LinePane(); + nativeLibrarySettings.getContent().add(nativesDirPane); + nativesDirPane.setTitle(i18n("settings.advanced.natives_directory")); + { + var txtNativesDir = new JFXTextField(); + txtNativesDir.setPrefWidth(400); + nativesDirPane.setRight(txtNativesDir); + bindIndependentTextField(nativesDirPane, txtNativesDir, GameSettings::nativesDirProperty); + } + + var noNativesPatchPane = createIndependentBooleanButton(GameSettings::notPatchNativesProperty); + nativeLibrarySettings.getContent().add(noNativesPatchPane); + noNativesPatchPane.setTitle(i18n("settings.advanced.dont_patch_natives")); + + var useNativeGLFWPane = createIndependentBooleanButton(GameSettings::useNativeGLFWProperty); + nativeLibrarySettings.getContent().add(useNativeGLFWPane); + useNativeGLFWPane.setTitle(i18n("settings.advanced.use_native_glfw")); + useNativeGLFWPane.setSubtitle(i18n("settings.advanced.linux_freebsd_only")); + + var useNativeOpenALPane = createIndependentBooleanButton(GameSettings::useNativeOpenALProperty); + nativeLibrarySettings.getContent().add(useNativeOpenALPane); + useNativeOpenALPane.setTitle(i18n("settings.advanced.use_native_openal")); + useNativeOpenALPane.setSubtitle(i18n("settings.advanced.linux_freebsd_only")); + } + + } + + // region Helper Methods for UI + + @SuppressWarnings("unchecked") + private void selectPreset(GameSettings.Preset setting) { + currentSetting.set((S) setting); + } + + private @Nullable GameSettings.Preset getCurrentPreset() { + GameSettings setting = currentSetting.get(); + return setting instanceof GameSettings.Preset preset ? preset : null; + } + + /// Returns the display name for a preset. + private static String getPresetDisplayName(GameSettings.Preset setting) { + return StringUtils.isBlank(setting.nameProperty().getValue()) + ? setting.idProperty().getValue().toString() + : setting.nameProperty().getValue(); + } + + /// Creates the preset management sublist. + private void createPresetManagementSublist(ComponentList list) { + var sublist = new ComponentSublist(); + sublist.setTitle(i18n("settings.type.global.preset.manage_all")); + sublist.setHasSubtitle(true); + + var presetItem = new RadioChoiceList(); + var createButton = new LineButton(); + createButton.setTitle(i18n("settings.type.global.preset.create")); + createButton.setLeading(SVG.ADD, 20); + createButton.setOnAction(event -> createPreset()); + sublist.getContent().setAll(presetItem, createButton); + list.getContent().add(sublist); + + final Holder updating = new Holder<>(false); + Runnable rebuildItems = () -> { + updating.value = true; + try { + List> choices = new ArrayList<>(); + for (GameSettings.Preset setting : SettingsManager.getGameSettings()) { + choices.add(new PresetChoice(setting)); + } + presetItem.setFallbackValue(SettingsManager.getDefaultGameSettingsPresetOrCreate()); + presetItem.setChoices(choices); + presetItem.setSelectedValue(getCurrentPreset()); + updatePresetManagementDescription(sublist); + } finally { + updating.value = false; + } + }; + ListChangeListener updateItems = change -> { + boolean rebuild = false; + while (change.next()) { + if (change.wasAdded() || change.wasRemoved() || change.wasPermutated()) { + rebuild = true; + } + } + + if (rebuild) { + rebuildItems.run(); + } else { + updatePresetManagementLabels(presetItem, sublist); + } + }; + + presetItem.selectedValueProperty().addListener((observable, oldValue, newValue) -> { + if (!updating.value && newValue != null) { + selectPreset(newValue); + } + }); + + currentSetting.addListener((observable, oldValue, newValue) -> { + updating.value = true; + try { + presetItem.setSelectedValue(getCurrentPreset()); + updatePresetManagementDescription(sublist); + } finally { + updating.value = false; + } + }); + + SettingsManager.getGameSettings().addListener(holder.weak(updateItems)); + rebuildItems.run(); + } + + /// Updates existing preset choices without rebuilding the rendered list. + private void updatePresetManagementLabels( + RadioChoiceList presetItem, + ComponentSublist sublist) { + for (RadioChoiceList.Choice choice : presetItem.getChoices()) { + choice.setTitle(getPresetDisplayName(choice.getValue())); + } + updatePresetManagementDescription(sublist); + } + + /// Updates the selected preset name shown in the management sublist header. + private void updatePresetManagementDescription(ComponentSublist sublist) { + GameSettings.Preset setting = getCurrentPreset(); + sublist.setDescription(setting != null ? getPresetDisplayName(setting) : ""); + } + + /// Creates a new preset and selects it for editing. + private void createPreset() { + Controllers.prompt(i18n("settings.type.global.preset.create"), (name, handler) -> { + if (StringUtils.isBlank(name)) { + handler.reject(i18n("input.not_empty")); + return; + } + + GameSettings.Preset setting = new GameSettings.Preset(SettingsManager.gameSettingsPresets().newPresetId()); + setting.nameProperty().setValue(name.trim()); + SettingsManager.getGameSettings().add(setting); + selectPreset(setting); + handler.resolve(); + }, createDefaultPresetName(), new RequiredValidator()); + } + + /// Returns the first numbered preset name that is not used by existing presets. + private String createDefaultPresetName() { + for (int index = 1; ; index++) { + String name = i18n("settings.type.global.preset.new", index); + boolean used = false; + for (GameSettings.Preset setting : SettingsManager.getGameSettings()) { + if (Objects.equals(name, setting.nameProperty().getValue())) { + used = true; + break; + } + } + + if (!used) { + return name; + } + } + } + + /// Asks the user for a new preset name. + private void renamePreset(GameSettings.Preset setting) { + Controllers.prompt(i18n("settings.type.global.preset.rename"), (name, handler) -> { + if (StringUtils.isBlank(name)) { + handler.reject(i18n("input.not_empty")); + return; + } + + setting.nameProperty().setValue(name.trim()); + handler.resolve(); + }, setting.nameProperty().getValue(), new RequiredValidator()); + } + + /// Asks the user to confirm removing the given preset. + private void confirmRemovePreset(GameSettings.Preset setting) { + if (SettingsManager.getGameSettings().size() <= 1) { + return; + } + + Controllers.confirm( + i18n("settings.type.global.preset.remove.confirm", getPresetDisplayName(setting)), + i18n("settings.type.global.preset.remove"), + () -> removePreset(setting), + null); + } + + /// Removes a preset and selects another preset for editing. + private void removePreset(GameSettings.Preset setting) { + ObservableList settings = SettingsManager.getGameSettings(); + int index = settings.indexOf(setting); + if (index < 0 || settings.size() <= 1) { + return; + } + + boolean removedCurrentPreset = Objects.equals(getCurrentPreset(), setting); + GameSettings.Preset next = settings.get(index == 0 ? 1 : index - 1); + GUID removedId = setting.idProperty().getValue(); + if (Objects.equals(SettingsManager.getDefaultGameSettingsPreset(), removedId)) { + SettingsManager.setDefaultGameSettingsPreset(next.idProperty().getValue()); + } + + settings.remove(index); + if (SettingsManager.getGameSettings(SettingsManager.getDefaultGameSettingsPreset()) == null) { + SettingsManager.setDefaultGameSettingsPreset(next.idProperty().getValue()); + } + if (removedCurrentPreset) { + selectPreset(next); + } + } + + private void bindInstanceParentSetting(LineSelectButton button) { + ObservableList items = FXCollections.observableArrayList(); + InvalidationListener updateItems = observable -> { + @Nullable GameSettings.Preset selected = button.getValue(); + items.setAll((GameSettings.Preset) null); + items.addAll(SettingsManager.getGameSettings()); + if (selected != null && SettingsManager.getGameSettings(selected.idProperty().getValue()) == null) { + button.setValue(null); + } + }; + updateItems.invalidated(SettingsManager.getGameSettings()); + SettingsManager.getGameSettings().addListener(updateItems); + button.setItems(items); + + button.valueProperty().addListener((observable, oldValue, newValue) -> { + if (updatingParentSetting || !(currentSetting.get() instanceof GameSettings.Instance setting)) { + return; + } + setting.parentProperty().setValue(newValue != null ? newValue.idProperty().getValue() : null); + }); + + currentSetting.addListener((observable, oldValue, newValue) -> { + if (newValue instanceof GameSettings.Instance setting) { + updatingParentSetting = true; + try { + GUID parent = setting.parentProperty().getValue(); + button.setValue(parent != null ? SettingsManager.getGameSettings(parent) : null); + } finally { + updatingParentSetting = false; + } + } + }); + } + + /// Adds the title-line inheritance button for the Java selection sublist. + private void bindJavaInheritanceButton(ComponentSublist sublist) { + if (isPresetSetting) { + return; + } + + var button = createInheritanceButton(); + sublist.setTitleRight(button); + + InvalidationListener refresh = observable -> { + S setting = currentSetting.get(); + updateInheritanceButton(button, setting == null || !isPropertyOverridden(setting, setting.javaTypeProperty())); + }; + + button.addEventFilter(MouseEvent.MOUSE_CLICKED, event -> { + S setting = currentSetting.get(); + if (setting == null) { + return; + } + + updatingJavaSetting = true; + try { + if (!isPropertyOverridden(setting, setting.javaTypeProperty())) { + GameSettings source = getEffectiveInheritableSource(setting, GameSettings::javaTypeProperty); + setting.javaTypeProperty().setValue(getEffectiveValue(setting, GameSettings::javaTypeProperty)); + setting.javaVersionProperty().setValue(source.javaVersionProperty().getValue()); + setting.customJavaPathProperty().setValue(source.customJavaPathProperty().getValue()); + setting.defaultJavaPathProperty().setValue(source.defaultJavaPathProperty().getValue()); + setPropertyOverridden(setting, setting.javaTypeProperty(), true); + } else { + setPropertyOverridden(setting, setting.javaTypeProperty(), false); + } + } finally { + updatingJavaSetting = false; + } + + initializeSelectedJava(); + initJavaSubtitle(); + refresh.invalidated(setting); + event.consume(); + }); + + currentSetting.addListener((observable, oldValue, newValue) -> { + if (oldValue != null) { + oldValue.removeListener(refresh); + } + if (newValue != null) { + newValue.addListener(refresh); + } + refresh.invalidated(newValue); + }); + + S setting = currentSetting.get(); + if (setting != null) { + setting.addListener(refresh); + } + refresh.invalidated(setting); + } + + /// Binds a text field to a setting property with independent override state. + private void bindIndependentTextField( + LineComponent line, + JFXTextField textField, + Function> propertyGetter) { + IndependentSettingBinder.bindTextField( + isPresetSetting, + currentSetting, + line, + textField, + propertyGetter, + this::createInheritanceButton, + GameSettingsPage::updateInheritanceButton, + this::getParentGameSettings); + } + + /// Binds an integer text field to a setting property with independent override state. + private void bindIndependentIntegerTextField( + LineComponent line, + JFXTextField textField, + Function> propertyGetter) { + IndependentSettingBinder.bindIntegerTextField( + isPresetSetting, + currentSetting, + line, + textField, + propertyGetter, + this::createInheritanceButton, + GameSettingsPage::updateInheritanceButton, + this::getParentGameSettings); + } + + private void bindWindowSizeComboBox(JFXComboBox comboBox) { + ObjectProperty<@Nullable Property> activeWidthProperty = new SimpleObjectProperty<>(); + ObjectProperty<@Nullable Property> activeHeightProperty = new SimpleObjectProperty<>(); + final Holder<@Nullable String> committedValue = new Holder<>(); + final Holder updating = new Holder<>(false); + + InvalidationListener propertyListener = observable -> { + @Nullable Property widthProperty = activeWidthProperty.get(); + @Nullable Property heightProperty = activeHeightProperty.get(); + if (widthProperty == null || heightProperty == null || updating.value) { + return; + } + + updating.value = true; + try { + S setting = currentSetting.get(); + Double width = setting != null ? getEffectiveValue(setting, GameSettings::widthProperty) : widthProperty.getValue(); + Double height = setting != null ? getEffectiveValue(setting, GameSettings::heightProperty) : heightProperty.getValue(); + String value = isSpecifiedWindowSize(width, height) ? formatWindowSize(width, height) : null; + committedValue.value = value; + comboBox.setValue(value); + } finally { + updating.value = false; + } + }; + + ChangeListener<@Nullable Boolean> focusedListener = (observable, oldValue, newValue) -> { + if (!newValue) { + applyWindowSizeComboBoxValue( + comboBox, + currentSetting.get(), + activeWidthProperty.get(), + activeHeightProperty.get(), + committedValue, + updating); + } + }; + + ChangeListener<@Nullable Scene> sceneListener = (observable, oldValue, newValue) -> { + if (newValue == null) { + applyWindowSizeComboBoxValue( + comboBox, + currentSetting.get(), + activeWidthProperty.get(), + activeHeightProperty.get(), + committedValue, + updating); + } + }; + + comboBox.focusedProperty().addListener(focusedListener); + comboBox.sceneProperty().addListener(sceneListener); + currentSetting.addListener((observable, oldValue, newValue) -> { + Property oldWidthProperty = activeWidthProperty.get(); + Property oldHeightProperty = activeHeightProperty.get(); + if (oldWidthProperty != null) { + oldWidthProperty.removeListener(propertyListener); + } + if (oldHeightProperty != null) { + oldHeightProperty.removeListener(propertyListener); + } + + Property newWidthProperty = newValue != null ? newValue.widthProperty() : null; + Property newHeightProperty = newValue != null ? newValue.heightProperty() : null; + activeWidthProperty.set(newWidthProperty); + activeHeightProperty.set(newHeightProperty); + if (newWidthProperty != null) { + newWidthProperty.addListener(propertyListener); + } + if (newHeightProperty != null) { + newHeightProperty.addListener(propertyListener); + } + propertyListener.invalidated(newWidthProperty); + }); + + S setting = currentSetting.get(); + if (setting != null) { + Property widthProperty = setting.widthProperty(); + Property heightProperty = setting.heightProperty(); + activeWidthProperty.set(widthProperty); + activeHeightProperty.set(heightProperty); + widthProperty.addListener(propertyListener); + heightProperty.addListener(propertyListener); + propertyListener.invalidated(widthProperty); + } + } + + private void applyWindowSizeComboBoxValue(JFXComboBox comboBox, + @Nullable GameSettings setting, + @Nullable Property widthProperty, + @Nullable Property heightProperty, + Holder<@Nullable String> committedValue, + Holder updating) { + if (widthProperty == null || heightProperty == null || updating.value) { + return; + } + + String value = comboBox.getValue(); + if (Objects.equals(value, committedValue.value)) { + return; + } + + updating.value = true; + try { + if (StringUtils.isBlank(value)) { + setWindowSizeOverridden(setting); + widthProperty.setValue(0.0); + heightProperty.setValue(0.0); + comboBox.setValue(null); + committedValue.value = null; + return; + } + + int idx = value.indexOf('x'); + if (idx < 0) { + idx = value.indexOf('*'); + } + + if (idx < 0) { + comboBox.setValue(committedValue.value); + return; + } + + try { + double width = Double.parseDouble(value.substring(0, idx).trim()); + double height = Double.parseDouble(value.substring(idx + 1).trim()); + setWindowSizeOverridden(setting); + widthProperty.setValue(width); + heightProperty.setValue(height); + String formattedValue = formatNullableWindowSize(width, height); + comboBox.setValue(formattedValue); + committedValue.value = formattedValue; + } catch (NumberFormatException e) { + comboBox.setValue(committedValue.value); + } + } finally { + updating.value = false; + } + } + + private void setWindowSizeOverridden(@Nullable GameSettings setting) { + if (setting == null) { + return; + } + setWindowSettingsOverridden(setting, true); + } + + private static boolean isSpecifiedWindowSize(@Nullable Double width, @Nullable Double height) { + return width != null && height != null && width > 0 && height > 0; + } + + private static @Nullable String formatNullableWindowSize(@Nullable Double width, @Nullable Double height) { + return isSpecifiedWindowSize(width, height) ? formatWindowSize(width, height) : null; + } + + private static String formatWindowSize(double width, double height) { + return Math.round(width) + "x" + Math.round(height); + } + + /// Creates a compact button that displays inherited or overridden state. + private JFXButton createInheritanceButton() { + var button = new JFXButton(); + button.getStyleClass().add(INHERIT_BUTTON_STYLE_CLASS); + button.setContentDisplay(ContentDisplay.GRAPHIC_ONLY); + Tooltip tooltip = new Tooltip(); + button.getProperties().put(INHERIT_BUTTON_TOOLTIP_KEY, tooltip); + FXUtils.installFastTooltip(button, tooltip); + updateInheritanceButton(button, true); + return button; + } + + /// Updates the icon and pseudo class of an inheritance state button. + private static void updateInheritanceButton(JFXButton button, boolean inherited) { + button.setGraphic((inherited ? SVG.PUBLIC : SVG.TUNE).createIcon(INHERIT_BUTTON_ICON_SIZE)); + button.pseudoClassStateChanged(PSEUDO_OVERRIDDEN, !inherited); + + Object tooltip = button.getProperties().get(INHERIT_BUTTON_TOOLTIP_KEY); + if (tooltip instanceof Tooltip inheritTooltip) { + inheritTooltip.setText(i18n(inherited + ? "settings.game.inherit_global" + : "settings.game.override_global")); + } + } + + /// Returns whether the setting uses its direct property value. + private static boolean isPropertyOverridden(GameSettings setting, SettingProperty property) { + return !(setting instanceof GameSettings.Instance instance) + || instance.getOverrideProperties().contains(property.getName()); + } + + /// Updates whether an instance setting uses its direct property value. + private static void setPropertyOverridden(GameSettings setting, SettingProperty property, boolean overridden) { + if (!(setting instanceof GameSettings.Instance instance)) { + return; + } + + if (overridden) { + instance.getOverrideProperties().add(property.getName()); + } else { + instance.getOverrideProperties().remove(property.getName()); + } + } + + /// Returns the direct property value, falling back to the property's default value. + private static T getDirectValue(SettingProperty property) { + T value = property.getValue(); + return value != null ? value : property.defaultValue(); + } + + private void bindSettingBidirectional(Property property, Function> propertyGetter) { + currentSetting.addListener((observable, oldValue, newValue) -> { + if (oldValue != null) + property.unbindBidirectional(propertyGetter.apply(oldValue)); + + if (newValue != null) + property.bindBidirectional(propertyGetter.apply(newValue)); + }); + + S setting = currentSetting.get(); + if (setting != null) { + property.bindBidirectional(propertyGetter.apply(setting)); + } + } + + /// Binds a text field to an inheritable string setting. + private void bindInheritableTextField( + LineComponent line, + JFXTextField textField, + Function> propertyGetter) { + bindInheritableStringProperty(line, textField.textProperty(), propertyGetter); + } + + /// Binds a string property to an inheritable string setting. + private void bindInheritableStringProperty( + LineComponent line, + Property textProperty, + Function> propertyGetter) { + ObjectProperty<@Nullable InheritableProperty> activeProperty = new SimpleObjectProperty<>(); + ObjectProperty<@Nullable InheritableProperty> activeParentProperty = new SimpleObjectProperty<>(); + final Holder updating = new Holder<>(false); + final Holder refreshHolder = new Holder<>(); + @Nullable JFXButton inheritButton = null; + if (!isPresetSetting) { + inheritButton = createInheritanceButton(); + line.setTitleTrailing(inheritButton); + } + @Nullable JFXButton finalInheritButton = inheritButton; + + InvalidationListener refresh = observable -> { + GameSettings setting = currentSetting.get(); + updateParentInheritablePropertyListener(setting, activeParentProperty, propertyGetter, refreshHolder.value); + InheritableProperty property = activeProperty.get(); + if (setting == null || property == null || updating.value) { + return; + } + + updating.value = true; + try { + textProperty.setValue(getEffectiveValue(setting, propertyGetter)); + if (finalInheritButton != null) { + updateInheritanceButton(finalInheritButton, !isPropertyOverridden(setting, property)); + } + } finally { + updating.value = false; + } + }; + refreshHolder.value = refresh; + + textProperty.addListener((observable, oldValue, newValue) -> { + GameSettings setting = currentSetting.get(); + InheritableProperty property = activeProperty.get(); + if (setting == null || property == null || updating.value) { + return; + } + + updating.value = true; + try { + setPropertyOverridden(setting, property, true); + property.setValue(newValue != null ? newValue : ""); + if (finalInheritButton != null) { + updateInheritanceButton(finalInheritButton, false); + } + } finally { + updating.value = false; + } + }); + + if (finalInheritButton != null) { + finalInheritButton.addEventFilter(MouseEvent.MOUSE_CLICKED, event -> { + GameSettings setting = currentSetting.get(); + InheritableProperty property = activeProperty.get(); + if (setting == null || property == null || updating.value) { + return; + } + + updating.value = true; + try { + if (!isPropertyOverridden(setting, property)) { + property.setValue(getEffectiveValue(setting, propertyGetter)); + setPropertyOverridden(setting, property, true); + } else { + setPropertyOverridden(setting, property, false); + } + } finally { + updating.value = false; + } + refresh.invalidated(property); + event.consume(); + }); + } + + currentSetting.addListener((observable, oldValue, newValue) -> { + if (oldValue != null) { + oldValue.removeListener(refresh); + } + + InheritableProperty oldProperty = activeProperty.get(); + if (oldProperty != null) { + oldProperty.removeListener(refresh); + } + + InheritableProperty newProperty = newValue != null ? propertyGetter.apply(newValue) : null; + activeProperty.set(newProperty); + if (newValue != null) { + newValue.addListener(refresh); + } + if (newProperty != null) { + newProperty.addListener(refresh); + } + refresh.invalidated(newValue); + }); + + SettingsManager.getGameSettings().addListener(refresh); + SettingsManager.defaultGameSettingsPresetProperty().addListener(refresh); + + S setting = currentSetting.get(); + if (setting != null) { + InheritableProperty property = propertyGetter.apply(setting); + activeProperty.set(property); + setting.addListener(refresh); + property.addListener(refresh); + refresh.invalidated(setting); + } + } + + /// Binds a string value to an inheritable setting without adding a separate inheritance button. + private void bindInheritableStringValue( + Property textProperty, + Function> propertyGetter) { + ObjectProperty<@Nullable InheritableProperty> activeProperty = new SimpleObjectProperty<>(); + ObjectProperty<@Nullable InheritableProperty> activeParentProperty = new SimpleObjectProperty<>(); + final Holder updating = new Holder<>(false); + final Holder refreshHolder = new Holder<>(); + + InvalidationListener refresh = observable -> { + GameSettings setting = currentSetting.get(); + updateParentInheritablePropertyListener(setting, activeParentProperty, propertyGetter, refreshHolder.value); + InheritableProperty property = activeProperty.get(); + if (setting == null || property == null || updating.value) { + return; + } + + updating.value = true; + try { + textProperty.setValue(getEffectiveValue(setting, propertyGetter)); + } finally { + updating.value = false; + } + }; + refreshHolder.value = refresh; + + textProperty.addListener((observable, oldValue, newValue) -> { + GameSettings setting = currentSetting.get(); + InheritableProperty property = activeProperty.get(); + if (setting == null || property == null || updating.value) { + return; + } + + updating.value = true; + try { + setPropertyOverridden(setting, property, true); + property.setValue(newValue != null ? newValue : ""); + } finally { + updating.value = false; + } + }); + + currentSetting.addListener((observable, oldValue, newValue) -> { + if (oldValue != null) { + oldValue.removeListener(refresh); + } + + InheritableProperty oldProperty = activeProperty.get(); + if (oldProperty != null) { + oldProperty.removeListener(refresh); + } + + InheritableProperty newProperty = newValue != null ? propertyGetter.apply(newValue) : null; + activeProperty.set(newProperty); + if (newValue != null) { + newValue.addListener(refresh); + } + if (newProperty != null) { + newProperty.addListener(refresh); + } + refresh.invalidated(newValue); + }); + + SettingsManager.getGameSettings().addListener(refresh); + SettingsManager.defaultGameSettingsPresetProperty().addListener(refresh); + + S setting = currentSetting.get(); + if (setting != null) { + InheritableProperty property = propertyGetter.apply(setting); + activeProperty.set(property); + setting.addListener(refresh); + property.addListener(refresh); + refresh.invalidated(setting); + } + } + + /// Binds the instance isolation toggle to the current instance setting. + private void bindInstanceIsolationButton(LineToggleButton button) { + final Holder updating = new Holder<>(false); + + InvalidationListener refresh = observable -> { + S setting = currentSetting.get(); + if (!(setting instanceof GameSettings.Instance instance) || updating.value) { + return; + } + + updating.value = true; + try { + boolean forceIsolated = isCurrentInstanceModpack(); + button.setSelected(forceIsolated + || instance.getOverrideProperties().contains(GameSettings.PROPERTY_RUNNING_DIR)); + button.setDisable(forceIsolated); + } finally { + updating.value = false; + } + }; + + button.selectedProperty().addListener((observable, oldValue, newValue) -> { + S setting = currentSetting.get(); + if (!(setting instanceof GameSettings.Instance instance) || updating.value || isCurrentInstanceModpack()) { + return; + } + + updating.value = true; + try { + if (newValue) { + instance.runningDirProperty().setValue(""); + instance.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIR); + } else { + instance.getOverrideProperties().remove(GameSettings.PROPERTY_RUNNING_DIR); + } + } finally { + updating.value = false; + } + refresh.invalidated(instance); + }); + + currentSetting.addListener((observable, oldValue, newValue) -> { + if (oldValue instanceof GameSettings.Instance oldInstance) { + oldInstance.removeListener(refresh); + } + + if (newValue instanceof GameSettings.Instance instance) { + instance.addListener(refresh); + } + refresh.invalidated(newValue); + }); + + S setting = currentSetting.get(); + if (setting instanceof GameSettings.Instance instance) { + instance.addListener(refresh); + refresh.invalidated(setting); + } + } + + /// Binds the running directory editor to the source selected by version isolation. + private void bindRunningDirectoryProperty( + LineComponent line, + Property textProperty, + Node editor) { + if (isPresetSetting) { + bindInheritableStringProperty(line, textProperty, GameSettings::runningDirProperty); + return; + } + + final Holder updating = new Holder<>(false); + ObjectProperty<@Nullable InheritableProperty> activeParentProperty = new SimpleObjectProperty<>(); + final Holder refreshHolder = new Holder<>(); + JFXButton inheritButton = createInheritanceButton(); + line.setTitleTrailing(inheritButton); + + InvalidationListener refresh = observable -> { + GameSettings setting = currentSetting.get(); + updateParentInheritablePropertyListener(setting, activeParentProperty, GameSettings::runningDirProperty, refreshHolder.value); + if (!(setting instanceof GameSettings.Instance instance) || updating.value) { + return; + } + + updating.value = true; + try { + boolean useInstanceRunningDirectory = isCurrentInstanceModpack() + || instance.getOverrideProperties().contains(GameSettings.PROPERTY_RUNNING_DIR); + String runningDirectory; + if (useInstanceRunningDirectory) { + String value = instance.runningDirProperty().getValue(); + runningDirectory = value != null ? value : instance.runningDirProperty().defaultValue(); + } else { + runningDirectory = getParentValue(instance, GameSettings::runningDirProperty); + } + + textProperty.setValue(runningDirectory); + editor.setDisable(!useInstanceRunningDirectory); + updateInheritanceButton(inheritButton, !useInstanceRunningDirectory); + inheritButton.setDisable(isCurrentInstanceModpack()); + } finally { + updating.value = false; + } + }; + refreshHolder.value = refresh; + + inheritButton.addEventFilter(MouseEvent.MOUSE_CLICKED, event -> { + GameSettings setting = currentSetting.get(); + if (!(setting instanceof GameSettings.Instance instance) || updating.value || isCurrentInstanceModpack()) { + return; + } + + updating.value = true; + try { + if (instance.getOverrideProperties().contains(GameSettings.PROPERTY_RUNNING_DIR)) { + instance.getOverrideProperties().remove(GameSettings.PROPERTY_RUNNING_DIR); + } else { + instance.runningDirProperty().setValue(""); + instance.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIR); + } + } finally { + updating.value = false; + } + refresh.invalidated(instance); + event.consume(); + }); + + textProperty.addListener((observable, oldValue, newValue) -> { + GameSettings setting = currentSetting.get(); + if (!(setting instanceof GameSettings.Instance instance) + || updating.value + || (!isCurrentInstanceModpack() + && !instance.getOverrideProperties().contains(GameSettings.PROPERTY_RUNNING_DIR))) { + return; + } + + updating.value = true; + try { + instance.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIR); + instance.runningDirProperty().setValue(newValue != null ? newValue : ""); + } finally { + updating.value = false; + } + }); + + currentSetting.addListener((observable, oldValue, newValue) -> { + if (oldValue instanceof GameSettings.Instance oldInstance) { + oldInstance.removeListener(refresh); + } + + if (newValue instanceof GameSettings.Instance newInstance) { + newInstance.addListener(refresh); + } + refresh.invalidated(newValue); + }); + + SettingsManager.getGameSettings().addListener(refresh); + SettingsManager.defaultGameSettingsPresetProperty().addListener(refresh); + + S setting = currentSetting.get(); + if (setting instanceof GameSettings.Instance instance) { + instance.addListener(refresh); + refresh.invalidated(setting); + } + } + + private boolean isCurrentInstanceModpack() { + return profile != null && instanceId != null && profile.getRepository().isModpack(instanceId); + } + + /// Keeps a listener attached to the current instance's parent preset property. + private void updateParentInheritablePropertyListener( + @Nullable GameSettings setting, + ObjectProperty<@Nullable InheritableProperty> activeParentProperty, + Function> propertyGetter, + InvalidationListener listener) { + InheritableProperty oldParentProperty = activeParentProperty.get(); + InheritableProperty newParentProperty = setting instanceof GameSettings.Instance instance + ? propertyGetter.apply(getParentGameSettings(instance)) + : null; + if (oldParentProperty == newParentProperty) { + return; + } + + if (oldParentProperty != null) { + oldParentProperty.removeListener(listener); + } + activeParentProperty.set(newParentProperty); + if (newParentProperty != null) { + newParentProperty.addListener(listener); + } + } + + /// Binds a radio choice list to an inheritable setting property. + private void bindInheritableRadioChoiceList( + ComponentSublist sublist, + RadioChoiceList item, + Function> propertyGetter) { + ObjectProperty<@Nullable InheritableProperty> activeProperty = new SimpleObjectProperty<>(); + final Holder updating = new Holder<>(false); + @Nullable JFXButton inheritButton = null; + if (!isPresetSetting) { + inheritButton = createInheritanceButton(); + sublist.setTitleRight(inheritButton); + } + @Nullable JFXButton finalInheritButton = inheritButton; + + InvalidationListener propertyListener = observable -> { + GameSettings setting = currentSetting.get(); + InheritableProperty property = activeProperty.get(); + if (setting == null || property == null || updating.value) { + return; + } + + updating.value = true; + try { + item.setSelectedValue(getEffectiveValue(setting, propertyGetter)); + if (finalInheritButton != null) { + updateInheritanceButton(finalInheritButton, !isPropertyOverridden(setting, property)); + } + } finally { + updating.value = false; + } + }; + + ChangeListener<@Nullable T> itemListener = (observable, oldValue, newValue) -> { + GameSettings setting = currentSetting.get(); + InheritableProperty property = activeProperty.get(); + if (setting == null || property == null || updating.value) { + return; + } + + updating.value = true; + try { + setPropertyOverridden(setting, property, true); + property.setValue(newValue != null ? newValue : property.defaultValue()); + if (finalInheritButton != null) { + updateInheritanceButton(finalInheritButton, false); + } + } finally { + updating.value = false; + } + }; + + item.selectedValueProperty().addListener(itemListener); + if (finalInheritButton != null) { + finalInheritButton.addEventFilter(MouseEvent.MOUSE_CLICKED, event -> { + GameSettings setting = currentSetting.get(); + InheritableProperty property = activeProperty.get(); + if (setting == null || property == null || updating.value) { + return; + } + + updating.value = true; + try { + if (!isPropertyOverridden(setting, property)) { + property.setValue(getEffectiveValue(setting, propertyGetter)); + setPropertyOverridden(setting, property, true); + } else { + setPropertyOverridden(setting, property, false); + } + } finally { + updating.value = false; + } + propertyListener.invalidated(property); + event.consume(); + }); + } + currentSetting.addListener((observable, oldValue, newValue) -> { + if (oldValue != null) { + oldValue.removeListener(propertyListener); + } + + InheritableProperty oldProperty = activeProperty.get(); + if (oldProperty != null) { + oldProperty.removeListener(propertyListener); + } + + InheritableProperty newProperty = newValue != null ? propertyGetter.apply(newValue) : null; + activeProperty.set(newProperty); + if (newValue != null) { + newValue.addListener(propertyListener); + } + if (newProperty != null) { + newProperty.addListener(propertyListener); + } + propertyListener.invalidated(newProperty); + }); + SettingsManager.getGameSettings().addListener(propertyListener); + SettingsManager.defaultGameSettingsPresetProperty().addListener(propertyListener); + + S setting = currentSetting.get(); + if (setting != null) { + InheritableProperty property = propertyGetter.apply(setting); + activeProperty.set(property); + setting.addListener(propertyListener); + property.addListener(propertyListener); + propertyListener.invalidated(property); + } + } + + /// Binds the window type and size editor as one inheritable setting group. + private void bindWindowSettings(ComponentSublist sublist, RadioChoiceList item) { + ObjectProperty<@Nullable InheritableProperty> activeWindowTypeProperty = new SimpleObjectProperty<>(); + ObjectProperty<@Nullable InheritableProperty> activeWidthProperty = new SimpleObjectProperty<>(); + ObjectProperty<@Nullable InheritableProperty> activeHeightProperty = new SimpleObjectProperty<>(); + final Holder updating = new Holder<>(false); + @Nullable JFXButton inheritButton = null; + if (!isPresetSetting) { + inheritButton = createInheritanceButton(); + sublist.setTitleRight(inheritButton); + } + @Nullable JFXButton finalInheritButton = inheritButton; + + InvalidationListener propertyListener = observable -> { + GameSettings setting = currentSetting.get(); + InheritableProperty property = activeWindowTypeProperty.get(); + if (setting == null || property == null || updating.value) { + return; + } + + updating.value = true; + try { + item.setSelectedValue(getEffectiveValue(setting, GameSettings::windowTypeProperty)); + if (finalInheritButton != null) { + updateInheritanceButton(finalInheritButton, !isWindowSettingsOverridden(setting)); + } + } finally { + updating.value = false; + } + }; + + item.selectedValueProperty().addListener((observable, oldValue, newValue) -> { + GameSettings setting = currentSetting.get(); + InheritableProperty property = activeWindowTypeProperty.get(); + if (setting == null || property == null || updating.value) { + return; + } + + updating.value = true; + try { + setWindowSettingsOverridden(setting, true); + property.setValue(newValue != null ? newValue : property.defaultValue()); + if (finalInheritButton != null) { + updateInheritanceButton(finalInheritButton, false); + } + } finally { + updating.value = false; + } + }); + + if (finalInheritButton != null) { + finalInheritButton.addEventFilter(MouseEvent.MOUSE_CLICKED, event -> { + GameSettings setting = currentSetting.get(); + if (setting == null || updating.value) { + return; + } + + updating.value = true; + try { + setWindowSettingsOverridden(setting, !isWindowSettingsOverridden(setting)); + } finally { + updating.value = false; + } + propertyListener.invalidated(setting); + event.consume(); + }); + } + + currentSetting.addListener((observable, oldValue, newValue) -> { + if (oldValue != null) { + oldValue.removeListener(propertyListener); + } + + InheritableProperty oldWindowTypeProperty = activeWindowTypeProperty.get(); + if (oldWindowTypeProperty != null) { + oldWindowTypeProperty.removeListener(propertyListener); + } + InheritableProperty oldWidthProperty = activeWidthProperty.get(); + if (oldWidthProperty != null) { + oldWidthProperty.removeListener(propertyListener); + } + InheritableProperty oldHeightProperty = activeHeightProperty.get(); + if (oldHeightProperty != null) { + oldHeightProperty.removeListener(propertyListener); + } + + InheritableProperty newWindowTypeProperty = newValue != null ? newValue.windowTypeProperty() : null; + InheritableProperty newWidthProperty = newValue != null ? newValue.widthProperty() : null; + InheritableProperty newHeightProperty = newValue != null ? newValue.heightProperty() : null; + activeWindowTypeProperty.set(newWindowTypeProperty); + activeWidthProperty.set(newWidthProperty); + activeHeightProperty.set(newHeightProperty); + if (newValue != null) { + newValue.addListener(propertyListener); + } + if (newWindowTypeProperty != null) { + newWindowTypeProperty.addListener(propertyListener); + } + if (newWidthProperty != null) { + newWidthProperty.addListener(propertyListener); + } + if (newHeightProperty != null) { + newHeightProperty.addListener(propertyListener); + } + propertyListener.invalidated(newValue); + }); + + SettingsManager.getGameSettings().addListener(propertyListener); + SettingsManager.defaultGameSettingsPresetProperty().addListener(propertyListener); + + S setting = currentSetting.get(); + if (setting != null) { + activeWindowTypeProperty.set(setting.windowTypeProperty()); + activeWidthProperty.set(setting.widthProperty()); + activeHeightProperty.set(setting.heightProperty()); + setting.addListener(propertyListener); + setting.windowTypeProperty().addListener(propertyListener); + setting.widthProperty().addListener(propertyListener); + setting.heightProperty().addListener(propertyListener); + } + propertyListener.invalidated(setting); + } + + /// Returns whether any property in the window settings group uses a direct value. + private static boolean isWindowSettingsOverridden(GameSettings setting) { + return isPropertyOverridden(setting, setting.windowTypeProperty()) + || isPropertyOverridden(setting, setting.widthProperty()) + || isPropertyOverridden(setting, setting.heightProperty()); + } + + /// Updates whether the window settings group uses direct property values. + private void setWindowSettingsOverridden(GameSettings setting, boolean overridden) { + if (!(setting instanceof GameSettings.Instance)) { + return; + } + + if (overridden) { + if (!isPropertyOverridden(setting, setting.windowTypeProperty())) { + setting.windowTypeProperty().setValue(getEffectiveValue(setting, GameSettings::windowTypeProperty)); + } + if (!isPropertyOverridden(setting, setting.widthProperty())) { + setting.widthProperty().setValue(getEffectiveValue(setting, GameSettings::widthProperty)); + } + if (!isPropertyOverridden(setting, setting.heightProperty())) { + setting.heightProperty().setValue(getEffectiveValue(setting, GameSettings::heightProperty)); + } + } + + setPropertyOverridden(setting, setting.windowTypeProperty(), overridden); + setPropertyOverridden(setting, setting.widthProperty(), overridden); + setPropertyOverridden(setting, setting.heightProperty(), overridden); + } + + private void bindInheritableSublistDescription(ComponentSublist sublist, + Function> propertyGetter, + Function converter) { + InvalidationListener propertyListener = observable -> initInheritableSublistDescription(sublist, propertyGetter, converter); + + currentSetting.addListener((observable, oldValue, newValue) -> { + if (oldValue != null) { + propertyGetter.apply(oldValue).removeListener(propertyListener); + } + + if (newValue != null) { + propertyGetter.apply(newValue).addListener(propertyListener); + } + + initInheritableSublistDescription(sublist, propertyGetter, converter); + }); + SettingsManager.getGameSettings().addListener(propertyListener); + SettingsManager.defaultGameSettingsPresetProperty().addListener(propertyListener); + + S setting = currentSetting.get(); + if (setting != null) { + propertyGetter.apply(setting).addListener(propertyListener); + } + initInheritableSublistDescription(sublist, propertyGetter, converter); + } + + private void initInheritableSublistDescription(ComponentSublist sublist, + Function> propertyGetter, + Function converter) { + S setting = currentSetting.get(); + if (setting == null) { + sublist.setDescription(""); + return; + } + + sublist.setDescription(converter.apply(getEffectiveValue(setting, propertyGetter))); + } + + private static String getWindowTypeDisplayName(GameWindowType type) { + return switch (type) { + case FULLSCREEN -> i18n("settings.game.window_type.fullscreen"); + case MAXIMIZED -> i18n("settings.game.window_type.maximized"); + case WINDOWED -> i18n("settings.game.window_type.windowed"); + }; + } + + /// Preset option with an inline remove button. + private final class PresetChoice extends RadioChoiceList.Choice { + /// Creates a preset option. + private PresetChoice(GameSettings.Preset setting) { + super(getPresetDisplayName(setting), setting); + } + + /// Creates the remove button shown on the right side of the option. + @Override + protected Node createRightNode() { + JFXButton renameButton = FXUtils.newToggleButton4(SVG.EDIT, 14); + renameButton.setOnAction(event -> { + renamePreset(getValue()); + event.consume(); + }); + FXUtils.installFastTooltip(renameButton, i18n("settings.type.global.preset.rename")); + + JFXButton removeButton = FXUtils.newToggleButton4(SVG.DELETE_FOREVER, 14); + removeButton.disableProperty().bind(Bindings.createBooleanBinding( + () -> SettingsManager.getGameSettings().size() <= 1, + SettingsManager.getGameSettings())); + removeButton.setOnAction(event -> { + confirmRemovePreset(getValue()); + event.consume(); + }); + FXUtils.installFastTooltip(removeButton, i18n("settings.type.global.preset.remove")); + + HBox buttons = new HBox(8, renameButton, removeButton); + buttons.setAlignment(Pos.CENTER_RIGHT); + return buttons; + } + + /// Keeps the remove button available on every preset option, not only the selected one. + @Override + protected boolean shouldDisableRightNodeWhenUnselected() { + return false; + } + } + + /// Manual memory option with the maximum memory slider on the same row. + private static final class ManualMemoryChoice extends RadioChoiceList.Choice { + /// The right-side editor used to select maximum memory. + private final HBox rightNode; + + /// Creates the manual memory option. + private ManualMemoryChoice( + JFXSlider maxMemorySlider, + JFXTextField maxMemoryTextField, + @Nullable JFXButton inheritButton) { + super(i18n("settings.memory.manual_allocate"), false); + this.rightNode = new HBox(8); + rightNode.setAlignment(Pos.CENTER_RIGHT); + if (inheritButton != null) { + radioButton.setGraphic(inheritButton); + radioButton.setContentDisplay(ContentDisplay.RIGHT); + radioButton.setGraphicTextGap(4); + } + rightNode.getChildren().setAll( + maxMemorySlider, + maxMemoryTextField, + new Label(i18n("settings.memory.unit.mib"))); + } + + /// Creates the right-side memory slider. + @Override + protected Node createRightNode() { + return rightNode; + } + } + + /// Windowed game window mode option with the window size selector on the same row. + private static final class WindowedWindowTypeOption extends RadioChoiceList.Choice { + /// The selector used to edit the initial game window size. + private final JFXComboBox windowSizeComboBox; + + /// Creates the windowed option. + private WindowedWindowTypeOption(JFXComboBox windowSizeComboBox) { + super(getWindowTypeDisplayName(GameWindowType.WINDOWED), GameWindowType.WINDOWED); + this.windowSizeComboBox = windowSizeComboBox; + } + + /// Creates the right-side size selector. + @Override + protected Node createRightNode() { + return windowSizeComboBox; + } + } + + @SuppressWarnings("unchecked") + private void bindPresetBidirectional(Property property, Function> propertyGetter) { + assert isPresetSetting; + + bindSettingBidirectional(property, (Function>) propertyGetter); + } + + /// Creates a toggle-based editor for a setting property with independent override state. + private LineInheritableToggleButton createIndependentBooleanButton( + Function> propertyGetter) { + var button = new LineInheritableToggleButton(); + button.setInheritedText(i18n("settings.game.inherit")); + button.setOverriddenText(i18n("settings.game.override")); + button.setInheritTooltip(i18n("settings.game.inherit_global")); + button.setOverriddenTooltip(i18n("settings.game.override_global")); + button.setInheritAvailable(!isPresetSetting); + + IndependentSettingBinder.bindToggleButton(currentSetting, button, propertyGetter, this::getParentGameSettings); + return button; + } + + /// Creates the native directory mode editor with independent override state. + private LineInheritableToggleButton createIndependentNativesDirTypeButton() { + var button = new LineInheritableToggleButton(); + button.setInheritedText(i18n("settings.game.inherit")); + button.setOverriddenText(i18n("settings.game.override")); + button.setInheritTooltip(i18n("settings.game.inherit_global")); + button.setOverriddenTooltip(i18n("settings.game.override_global")); + button.setInheritAvailable(!isPresetSetting); + + IndependentSettingBinder.bindNativesDirTypeButton(currentSetting, button, this::getParentGameSettings); + return button; + } + + /// Creates a toggle-based inheritable boolean editor that displays the effective value. + private LineInheritableToggleButton createInheritableBooleanButton( + Function> propertyGetter) { + var button = new LineInheritableToggleButton(); + button.setInheritedText(i18n("settings.game.inherit")); + button.setOverriddenText(i18n("settings.game.override")); + button.setInheritTooltip(i18n("settings.game.inherit_global")); + button.setOverriddenTooltip(i18n("settings.game.override_global")); + button.setInheritAvailable(!isPresetSetting); + + bindEffectiveInheritableToggleButton(button, propertyGetter); + return button; + } + + /// Binds an inheritable select editor to an inheritable setting. + private void bindInheritableLineSelectButton( + LineSelectButton button, + Function> propertyGetter) { + ObjectProperty<@Nullable InheritableProperty> activeProperty = new SimpleObjectProperty<>(); + final Holder updating = new Holder<>(false); + @Nullable JFXButton inheritButton = null; + if (!isPresetSetting) { + inheritButton = createInheritanceButton(); + button.setTitleTrailing(inheritButton); + } + @Nullable JFXButton finalInheritButton = inheritButton; + + InvalidationListener refresh = observable -> { + GameSettings setting = currentSetting.get(); + InheritableProperty property = activeProperty.get(); + if (setting == null || property == null || updating.value) { + return; + } + + updating.value = true; + try { + button.setValue(getEffectiveValue(setting, propertyGetter)); + if (finalInheritButton != null) { + updateInheritanceButton(finalInheritButton, !isPropertyOverridden(setting, property)); + } + } finally { + updating.value = false; + } + }; + + button.valueProperty().addListener((observable, oldValue, newValue) -> { + GameSettings setting = currentSetting.get(); + InheritableProperty property = activeProperty.get(); + if (setting == null || property == null || updating.value) { + return; + } + + updating.value = true; + try { + setPropertyOverridden(setting, property, true); + property.setValue(newValue != null ? newValue : property.defaultValue()); + if (finalInheritButton != null) { + updateInheritanceButton(finalInheritButton, false); + } + } finally { + updating.value = false; + } + }); + + if (finalInheritButton != null) { + finalInheritButton.addEventFilter(MouseEvent.MOUSE_CLICKED, event -> { + GameSettings setting = currentSetting.get(); + InheritableProperty property = activeProperty.get(); + if (setting == null || property == null || updating.value) { + return; + } + + updating.value = true; + try { + if (!isPropertyOverridden(setting, property)) { + property.setValue(getEffectiveValue(setting, propertyGetter)); + setPropertyOverridden(setting, property, true); + } else { + setPropertyOverridden(setting, property, false); + } + } finally { + updating.value = false; + } + refresh.invalidated(property); + event.consume(); + }); + } + + currentSetting.addListener((observable, oldValue, newValue) -> { + if (oldValue != null) { + oldValue.removeListener(refresh); + } + + InheritableProperty oldProperty = activeProperty.get(); + if (oldProperty != null) { + oldProperty.removeListener(refresh); + } + + InheritableProperty newProperty = newValue != null ? propertyGetter.apply(newValue) : null; + activeProperty.set(newProperty); + if (newValue != null) { + newValue.addListener(refresh); + } + if (newProperty != null) { + newProperty.addListener(refresh); + } + refresh.invalidated(newValue); + }); + + SettingsManager.getGameSettings().addListener(refresh); + SettingsManager.defaultGameSettingsPresetProperty().addListener(refresh); + + S setting = currentSetting.get(); + if (setting != null) { + activeProperty.set(propertyGetter.apply(setting)); + setting.addListener(refresh); + activeProperty.get().addListener(refresh); + refresh.invalidated(setting); + } + } + + /// Binds an inheritable toggle editor to an inheritable boolean setting. + private void bindEffectiveInheritableToggleButton( + LineInheritableToggleButton button, + Function> propertyGetter) { + ObjectProperty<@Nullable InheritableProperty> activeProperty = new SimpleObjectProperty<>(); + final Holder updating = new Holder<>(false); + + InvalidationListener refresh = observable -> { + GameSettings setting = currentSetting.get(); + InheritableProperty property = activeProperty.get(); + if (setting == null || property == null || updating.value) { + return; + } + + updating.value = true; + try { + boolean overridden = isPropertyOverridden(setting, property); + button.setRawValue(overridden ? getDirectValue(property) : getEffectiveValue(setting, propertyGetter)); + button.setOverridden(overridden); + button.setEffectiveValue(getEffectiveValue(setting, propertyGetter)); + } finally { + updating.value = false; + } + }; + + button.rawValueProperty().addListener((observable, oldValue, newValue) -> { + InheritableProperty property = activeProperty.get(); + GameSettings setting = currentSetting.get(); + if (property == null || setting == null || updating.value) { + return; + } + + updating.value = true; + try { + setPropertyOverridden(setting, property, true); + property.setValue(newValue); + button.setEffectiveValue(getEffectiveValue(setting, propertyGetter)); + } finally { + updating.value = false; + } + }); + + button.overriddenProperty().addListener((observable, oldValue, newValue) -> { + InheritableProperty property = activeProperty.get(); + GameSettings setting = currentSetting.get(); + if (property == null || setting == null || updating.value) { + return; + } + + updating.value = true; + try { + setPropertyOverridden(setting, property, newValue); + if (newValue) { + property.setValue(button.getRawValue()); + } + button.setEffectiveValue(getEffectiveValue(setting, propertyGetter)); + } finally { + updating.value = false; + } + }); + + currentSetting.addListener((observable, oldValue, newValue) -> { + if (oldValue != null) { + oldValue.removeListener(refresh); + } + + InheritableProperty newProperty = newValue != null ? propertyGetter.apply(newValue) : null; + activeProperty.set(newProperty); + if (newValue != null) { + newValue.addListener(refresh); + } + refresh.invalidated(newValue); + }); + + SettingsManager.getGameSettings().addListener(refresh); + SettingsManager.defaultGameSettingsPresetProperty().addListener(refresh); + + S setting = currentSetting.get(); + if (setting != null) { + activeProperty.set(propertyGetter.apply(setting)); + setting.addListener(refresh); + refresh.invalidated(setting); + } + } + + /// Resolves the setting with its parent preset for effective-value queries. + private GameSettings.Effective resolveEffectiveSetting(GameSettings setting) { + if (setting instanceof GameSettings.Preset preset) { + return GameSettings.resolve(preset, null); + } + + if (setting instanceof GameSettings.Instance instance) { + return GameSettings.resolve(getParentGameSettings(instance), instance); + } + + throw new AssertionError("Unknown game setting type: " + setting.getClass()); + } + + /// Returns the effective value after applying parent inheritance. + private T getEffectiveValue( + GameSettings setting, + Function> propertyGetter) { + InheritableProperty property = propertyGetter.apply(setting); + if (isPropertyOverridden(setting, property)) { + return getDirectValue(property); + } + + if (setting instanceof GameSettings.Instance instance) { + return getParentValue(instance, propertyGetter); + } + + return getDirectValue(property); + } + + /// Returns the value provided by an instance's parent preset. + private T getParentValue( + GameSettings.Instance instance, + Function> propertyGetter) { + GameSettings.Preset parent = profile != null + ? profile.getRepository().getParentGameSettings(instance) + : getParentGameSettings(instance); + return getDirectValue(propertyGetter.apply(parent)); + } + + /// Returns the setting object that provides the effective inheritable value. + private GameSettings getEffectiveInheritableSource( + GameSettings setting, + Function> propertyGetter) { + if (isPropertyOverridden(setting, propertyGetter.apply(setting)) || !(setting instanceof GameSettings.Instance instance)) { + return setting; + } + + return profile != null + ? profile.getRepository().getParentGameSettings(instance) + : getParentGameSettings(instance); + } + + /// Returns the configured parent preset for an instance. + private GameSettings.Preset getParentGameSettings(GameSettings.Instance instance) { + GUID parent = instance.parentProperty().getValue(); + GameSettings.Preset parentSetting = SettingsManager.getGameSettings(parent); + return parentSetting != null ? parentSetting : SettingsManager.getDefaultGameSettingsPresetOrCreate(); + } + + @SafeVarargs + private LineSelectButton createInheritableButton( + Function> propertyGetter, + Function convert, + @Nullable Function descriptionConverter, + T... items + ) { + var button = new LineSelectButton(); + + button.setNullSafeConverter(convert); + if (descriptionConverter != null) + button.setDescriptionConverter(value -> value != null ? descriptionConverter.apply(value) : ""); + button.setItems(items); + bindInheritableLineSelectButton(button, propertyGetter); + + return button; + } + + // endregion + + @Override + public ReadOnlyObjectProperty stateProperty() { + return state; + } + + @SuppressWarnings("unchecked") + @Override + public void loadVersion(Profile profile, @Nullable String instanceId) { + this.profile = profile; + this.instanceId = instanceId; + + assert isPresetSetting == (instanceId == null); + + if (instanceId != null) { + this.currentSetting.set((S) profile.getRepository().getLocalGameSettingsOrCreate(instanceId)); + loadIcon(); + } else { + this.currentSetting.set((S) SettingsManager.getDefaultGameSettingsPresetOrCreate()); + } + } + + private void loadIcon() { + if (profile == null || instanceId == null) + return; + + iconPickerItem.setImage(profile.getRepository().getVersionIconImage(instanceId)); + } + + private void initializeSelectedJava() { + S setting = currentSetting.get(); + + if (setting == null || updatingJavaSetting) + return; + + updatingSelectedJava = true; + GameSettings source = getEffectiveInheritableSource(setting, GameSettings::javaTypeProperty); + JavaVersionType javaType = getEffectiveValue(setting, GameSettings::javaTypeProperty); + switch (javaType) { + case CUSTOM: + javaCustomOption.setSelected(true); + javaCustomOption.setPath(source.customJavaPathProperty().getValue()); + break; + case VERSION: + javaVersionOption.setSelected(true); + javaVersionOption.setText(source.javaVersionProperty().getValue()); + break; + case AUTO: + javaAutoDeterminedOption.setSelected(true); + break; + default: + RadioChoiceList.Choice<@Nullable Pair<@Nullable JavaVersionType, @Nullable JavaRuntime>> choice = null; + if (JavaManager.isInitialized()) { + try { + JavaRuntime java = resolveEffectiveSetting(setting).getJava(null, null); + if (java != null) { + for (var candidate : javaItem.getChoices()) { + var value = candidate.getValue(); + if (value != null && value.getValue() != null && java.getBinary().equals(value.getValue().getBinary())) { + choice = candidate; + break; + } + } + } + } catch (InterruptedException ignored) { + } + } + + if (choice != null) { + choice.setSelected(true); + } else { + javaItem.clearSelection(); + } + break; + } + updatingSelectedJava = false; + } + + private void initJavaSubtitle() { + S setting = currentSetting.get(); + + if (setting == null || profile == null) + return; + initializeSelectedJava(); + + HMCLGameRepository repository = this.profile.getRepository(); + JavaVersionType javaVersionType = setting.javaTypeProperty().getValue(); + GameSettings.Effective effectiveSetting = this.instanceId != null ? repository.getEffectiveGameSettings(this.instanceId) : null; + JavaVersionType effectiveJavaVersionType = effectiveSetting != null ? effectiveSetting.getJavaVersionType() : javaVersionType; + boolean autoSelected = effectiveJavaVersionType == JavaVersionType.AUTO || effectiveJavaVersionType == JavaVersionType.VERSION; + + if (instanceId == null && autoSelected) { + javaSublist.setDescription(i18n("settings.game.java_directory.auto")); + return; + } + + var selectedJava = javaItem.getSelectedValue(); + if (selectedJava != null && selectedJava.getValue() != null) { + javaSublist.setDescription(selectedJava.getValue().getBinary().toString()); + return; + } + + if (JavaManager.isInitialized()) { + GameVersionNumber gameVersionNumber; + Version version; + if (this.instanceId == null) { + gameVersionNumber = GameVersionNumber.unknown(); + version = null; + } else { + gameVersionNumber = GameVersionNumber.asGameVersion(repository.getGameVersion(this.instanceId)); + version = repository.getResolvedVersion(this.instanceId); + } + + try { + JavaRuntime java = effectiveSetting != null + ? effectiveSetting.getJava(gameVersionNumber, version) + : resolveEffectiveSetting(setting).getJava(gameVersionNumber, version); + if (java != null) { + javaSublist.setDescription(java.getBinary().toString()); + } else { + javaSublist.setDescription(autoSelected ? i18n("settings.game.java_directory.auto.not_found") : i18n("settings.game.java_directory.invalid")); + } + return; + } catch (InterruptedException ignored) { + } + } + + javaSublist.setDescription(""); + } + + private void editSpecificSettings() { + if (profile != null) + Versions.modifyGameSettings(profile, Profiles.getSelectedInstance(profile)); + } + + private void onExploreIcon() { + if (profile == null || instanceId == null) + return; + + Controllers.dialog(new VersionIconDialog(profile, instanceId, this::loadIcon)); + } + + private void onDeleteIcon() { + if (profile == null || instanceId == null) + return; + + profile.getRepository().deleteIconFile(instanceId); + GameSettings.Instance localGameSettings = profile.getRepository().getLocalGameSettingsOrCreate(instanceId); + if (localGameSettings != null) { + localGameSettings.iconProperty().setValue(VersionIconType.DEFAULT); + } + loadIcon(); + } + + private static List getSupportedResolutions() { + int maxScreenWidth = 0; + int maxScreenHeight = 0; + + for (Screen screen : Screen.getScreens()) { + Rectangle2D bounds = screen.getBounds(); + int screenWidth = (int) (bounds.getWidth() * screen.getOutputScaleX()); + int screenHeight = (int) (bounds.getHeight() * screen.getOutputScaleY()); + + maxScreenWidth = Math.max(maxScreenWidth, screenWidth); + maxScreenHeight = Math.max(maxScreenHeight, screenHeight); + } + + List resolutions = new ArrayList<>(List.of("854x480", "1280x720", "1600x900")); + + if (maxScreenWidth >= 1920 && maxScreenHeight >= 1080) resolutions.add("1920x1080"); + if (maxScreenWidth >= 2560 && maxScreenHeight >= 1440) resolutions.add("2560x1440"); + if (maxScreenWidth >= 3840 && maxScreenHeight >= 2160) resolutions.add("3840x2160"); + + return resolutions; + } + +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/game/IndependentSettingBinder.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/game/IndependentSettingBinder.java new file mode 100644 index 00000000000..48a5308af71 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/game/IndependentSettingBinder.java @@ -0,0 +1,805 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.game; + +import com.jfoenix.controls.JFXButton; +import com.jfoenix.controls.JFXSlider; +import com.jfoenix.controls.JFXTextField; +import javafx.beans.InvalidationListener; +import javafx.beans.binding.Bindings; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.scene.control.Label; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.BorderPane; +import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.game.NativesDirectoryType; +import org.jackhuang.hmcl.setting.SettingsManager; +import org.jackhuang.hmcl.setting.GameSettings; +import org.jackhuang.hmcl.setting.property.SettingProperty; +import org.jackhuang.hmcl.ui.MemoryStatusBar; +import org.jackhuang.hmcl.ui.construct.LineComponent; +import org.jackhuang.hmcl.ui.construct.LineInheritableToggleButton; +import org.jackhuang.hmcl.ui.construct.RadioChoiceList; +import org.jackhuang.hmcl.util.Holder; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.platform.SystemInfo; +import org.jackhuang.hmcl.util.platform.hardware.PhysicalMemoryStatus; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.UnknownNullability; + +import java.util.function.BiConsumer; +import java.util.function.Function; +import java.util.function.Supplier; + +import static org.jackhuang.hmcl.util.DataSizeUnit.GIGABYTES; +import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; + +/// Binds independently overridden game setting properties to setting page controls. +@NotNullByDefault +final class IndependentSettingBinder { + private IndependentSettingBinder() { + } + + /// Binds a text field to a setting property with independent override state. + static void bindTextField( + boolean presetSetting, + ObjectProperty currentSetting, + LineComponent line, + JFXTextField textField, + Function> propertyGetter, + Supplier inheritanceButtonFactory, + BiConsumer inheritanceButtonUpdater, + Function parentGetter) { + ObjectProperty<@Nullable SettingProperty> activeProperty = new SimpleObjectProperty<>(); + ObjectProperty<@Nullable SettingProperty> activeParentProperty = new SimpleObjectProperty<>(); + final Holder updating = new Holder<>(false); + final Holder refreshHolder = new Holder<>(); + @Nullable JFXButton inheritButton; + if (presetSetting) { + inheritButton = null; + } else { + inheritButton = inheritanceButtonFactory.get(); + line.setTitleTrailing(inheritButton); + } + + InvalidationListener refresh = observable -> { + GameSettings setting = currentSetting.get(); + updateParentPropertyListener(setting, activeParentProperty, propertyGetter, parentGetter, refreshHolder.value); + SettingProperty property = activeProperty.get(); + if (setting == null || property == null || updating.value) { + return; + } + + updating.value = true; + try { + boolean overridden = isOverridden(setting, property); + textField.setText(empty(getEffectiveValue(setting, propertyGetter, parentGetter))); + textField.setDisable(!overridden); + if (inheritButton != null) { + inheritanceButtonUpdater.accept(inheritButton, !overridden); + } + } finally { + updating.value = false; + } + }; + refreshHolder.value = refresh; + + textField.textProperty().addListener((observable, oldValue, newValue) -> { + GameSettings setting = currentSetting.get(); + SettingProperty property = activeProperty.get(); + if (setting == null || property == null || updating.value) { + return; + } + + updating.value = true; + try { + setOverridden(setting, property, true); + property.setValue(newValue != null ? newValue : ""); + if (inheritButton != null) { + inheritanceButtonUpdater.accept(inheritButton, false); + } + } finally { + updating.value = false; + } + }); + + if (inheritButton != null) { + inheritButton.addEventFilter(MouseEvent.MOUSE_CLICKED, event -> { + GameSettings setting = currentSetting.get(); + SettingProperty property = activeProperty.get(); + if (setting == null || property == null || updating.value) { + return; + } + + updating.value = true; + try { + if (isOverridden(setting, property)) { + setOverridden(setting, property, false); + } else { + property.setValue(empty(getEffectiveValue(setting, propertyGetter, parentGetter))); + setOverridden(setting, property, true); + } + } finally { + updating.value = false; + } + refresh.invalidated(property); + event.consume(); + }); + } + + bindActiveProperty(currentSetting, activeProperty, propertyGetter, refresh); + } + + /// Binds an integer text field to a setting property with independent override state. + static void bindIntegerTextField( + boolean presetSetting, + ObjectProperty currentSetting, + LineComponent line, + JFXTextField textField, + Function> propertyGetter, + Supplier inheritanceButtonFactory, + BiConsumer inheritanceButtonUpdater, + Function parentGetter) { + ObjectProperty<@Nullable SettingProperty> activeProperty = new SimpleObjectProperty<>(); + final Holder updating = new Holder<>(false); + @Nullable JFXButton inheritButton; + if (presetSetting) { + inheritButton = null; + } else { + inheritButton = inheritanceButtonFactory.get(); + line.setTitleTrailing(inheritButton); + } + + InvalidationListener refresh = observable -> { + GameSettings setting = currentSetting.get(); + SettingProperty property = activeProperty.get(); + if (setting == null || property == null || updating.value) { + return; + } + + updating.value = true; + try { + boolean overridden = isOverridden(setting, property); + Integer value = getEffectiveValue(setting, propertyGetter, parentGetter); + textField.setText(value != null ? value.toString() : ""); + textField.setDisable(!overridden); + if (inheritButton != null) { + inheritanceButtonUpdater.accept(inheritButton, !overridden); + } + } finally { + updating.value = false; + } + }; + + textField.textProperty().addListener((observable, oldValue, newValue) -> { + GameSettings setting = currentSetting.get(); + SettingProperty property = activeProperty.get(); + if (setting == null || property == null || updating.value) { + return; + } + + updating.value = true; + try { + setOverridden(setting, property, true); + property.setValue(parseInteger(newValue, true)); + if (inheritButton != null) { + inheritanceButtonUpdater.accept(inheritButton, false); + } + } finally { + updating.value = false; + } + }); + + if (inheritButton != null) { + inheritButton.addEventFilter(MouseEvent.MOUSE_CLICKED, event -> { + GameSettings setting = currentSetting.get(); + SettingProperty property = activeProperty.get(); + if (setting == null || property == null || updating.value) { + return; + } + + updating.value = true; + try { + if (isOverridden(setting, property)) { + setOverridden(setting, property, false); + } else { + property.setValue(getEffectiveValue(setting, propertyGetter, parentGetter)); + setOverridden(setting, property, true); + } + } finally { + updating.value = false; + } + refresh.invalidated(property); + event.consume(); + }); + } + + bindActiveProperty( + currentSetting, + activeProperty, + setting -> (SettingProperty) propertyGetter.apply(setting), + refresh); + } + + /// Binds the game memory radio options and manual memory slider. + static void bindMemoryChoiceList( + ObjectProperty currentSetting, + RadioChoiceList choiceList, + JFXSlider maxMemorySlider, + JFXTextField maxMemoryTextField, + MemoryStatusBar memoryStatusBar, + BorderPane memoryStatusLabels, + @Nullable JFXButton autoMemoryButton, + @Nullable JFXButton maxMemoryButton, + BiConsumer inheritanceButtonUpdater, + Function parentGetter) { + ObjectProperty<@Nullable SettingProperty> activeAutoMemoryProperty = new SimpleObjectProperty<>(); + ObjectProperty<@Nullable SettingProperty> activeMaxMemoryProperty = new SimpleObjectProperty<>(); + Label physicalMemoryLabel = (Label) memoryStatusLabels.getLeft(); + Label allocatedMemoryLabel = (Label) memoryStatusLabels.getRight(); + final Holder updating = new Holder<>(false); + + int totalMemoryMiB = Math.max(1, (int) MEGABYTES.convertFromBytes(SystemInfo.getTotalMemorySize())); + maxMemorySlider.setValueFactory(slider -> Bindings.createStringBinding( + () -> (int) (slider.getValue() * 100) + "%", + slider.valueProperty())); + + InvalidationListener refresh = observable -> { + GameSettings setting = currentSetting.get(); + SettingProperty autoMemoryProperty = activeAutoMemoryProperty.get(); + SettingProperty maxMemoryProperty = activeMaxMemoryProperty.get(); + if (setting == null || autoMemoryProperty == null || maxMemoryProperty == null || updating.value) { + return; + } + + updating.value = true; + try { + boolean autoMemoryOverridden = isOverridden(setting, autoMemoryProperty); + boolean maxMemoryOverridden = isOverridden(setting, maxMemoryProperty); + Boolean autoMemory = getEffectiveValue(setting, GameSettings::autoMemoryProperty, parentGetter); + @Nullable Integer maxMemory = getEffectiveValue(setting, GameSettings::maxMemoryProperty, parentGetter); + + choiceList.setSelectedValue(autoMemory); + maxMemorySlider.setValue(maxMemoryToSliderValue(maxMemory, totalMemoryMiB)); + maxMemoryTextField.setText(maxMemoryToText(maxMemory)); + updateMemoryStatus(memoryStatusBar, physicalMemoryLabel, allocatedMemoryLabel, autoMemory, maxMemory); + if (autoMemoryButton != null) { + inheritanceButtonUpdater.accept(autoMemoryButton, !autoMemoryOverridden); + } + if (maxMemoryButton != null) { + inheritanceButtonUpdater.accept(maxMemoryButton, !maxMemoryOverridden); + } + } finally { + updating.value = false; + } + }; + + choiceList.selectedValueProperty().addListener((observable, oldValue, newValue) -> { + GameSettings setting = currentSetting.get(); + SettingProperty property = activeAutoMemoryProperty.get(); + if (setting == null || property == null || updating.value) { + return; + } + + updating.value = true; + try { + setOverridden(setting, property, true); + property.setValue(Boolean.TRUE.equals(newValue)); + if (autoMemoryButton != null) { + inheritanceButtonUpdater.accept(autoMemoryButton, false); + } + updateMemoryStatus( + memoryStatusBar, + physicalMemoryLabel, + allocatedMemoryLabel, + property.getValue(), + getEffectiveValue(setting, GameSettings::maxMemoryProperty, parentGetter)); + } finally { + updating.value = false; + } + }); + + maxMemorySlider.valueProperty().addListener((observable, oldValue, newValue) -> { + GameSettings setting = currentSetting.get(); + SettingProperty property = activeMaxMemoryProperty.get(); + if (setting == null || property == null || updating.value) { + return; + } + + updating.value = true; + try { + int maxMemory = sliderValueToMaxMemory(newValue.doubleValue(), totalMemoryMiB); + setOverridden(setting, property, true); + property.setValue(maxMemory); + maxMemoryTextField.setText(Integer.toString(maxMemory)); + if (maxMemoryButton != null) { + inheritanceButtonUpdater.accept(maxMemoryButton, false); + } + updateMemoryStatus( + memoryStatusBar, + physicalMemoryLabel, + allocatedMemoryLabel, + getEffectiveValue(setting, GameSettings::autoMemoryProperty, parentGetter), + maxMemory); + } finally { + updating.value = false; + } + }); + + maxMemoryTextField.textProperty().addListener((observable, oldValue, newValue) -> { + GameSettings setting = currentSetting.get(); + SettingProperty property = activeMaxMemoryProperty.get(); + Integer maxMemory = parseMemoryText(newValue); + if (setting == null || property == null || maxMemory == null || updating.value) { + return; + } + + updating.value = true; + try { + setOverridden(setting, property, true); + property.setValue(maxMemory); + maxMemorySlider.setValue(maxMemoryToSliderValue(maxMemory, totalMemoryMiB)); + if (maxMemoryButton != null) { + inheritanceButtonUpdater.accept(maxMemoryButton, false); + } + updateMemoryStatus( + memoryStatusBar, + physicalMemoryLabel, + allocatedMemoryLabel, + getEffectiveValue(setting, GameSettings::autoMemoryProperty, parentGetter), + maxMemory); + } finally { + updating.value = false; + } + }); + + if (autoMemoryButton != null) { + autoMemoryButton.addEventFilter(MouseEvent.MOUSE_CLICKED, event -> { + GameSettings setting = currentSetting.get(); + toggleOverride( + setting, + activeAutoMemoryProperty.get(), + () -> getEffectiveValue(setting, GameSettings::autoMemoryProperty, parentGetter), + refresh); + event.consume(); + }); + } + + if (maxMemoryButton != null) { + maxMemoryButton.addEventFilter(MouseEvent.MOUSE_CLICKED, event -> { + GameSettings setting = currentSetting.get(); + toggleOverride( + setting, + activeMaxMemoryProperty.get(), + () -> getEffectiveValue(setting, GameSettings::maxMemoryProperty, parentGetter), + refresh); + event.consume(); + }); + } + + currentSetting.addListener((observable, oldValue, newValue) -> { + if (oldValue != null) { + oldValue.removeListener(refresh); + } + + SettingProperty oldAutoMemoryProperty = activeAutoMemoryProperty.get(); + if (oldAutoMemoryProperty != null) { + oldAutoMemoryProperty.removeListener(refresh); + } + + SettingProperty oldMaxMemoryProperty = activeMaxMemoryProperty.get(); + if (oldMaxMemoryProperty != null) { + oldMaxMemoryProperty.removeListener(refresh); + } + + SettingProperty newAutoMemoryProperty = newValue != null ? newValue.autoMemoryProperty() : null; + SettingProperty newMaxMemoryProperty = newValue != null ? newValue.maxMemoryProperty() : null; + activeAutoMemoryProperty.set(newAutoMemoryProperty); + activeMaxMemoryProperty.set(newMaxMemoryProperty); + if (newValue != null) { + newValue.addListener(refresh); + } + if (newAutoMemoryProperty != null) { + newAutoMemoryProperty.addListener(refresh); + } + if (newMaxMemoryProperty != null) { + newMaxMemoryProperty.addListener(refresh); + } + refresh.invalidated(newValue); + }); + SettingsManager.getGameSettings().addListener(refresh); + SettingsManager.defaultGameSettingsPresetProperty().addListener(refresh); + memoryStatusBar.memoryStatusProperty().addListener(observable -> { + GameSettings setting = currentSetting.get(); + if (setting == null) { + return; + } + + updateMemoryStatus( + memoryStatusBar, + physicalMemoryLabel, + allocatedMemoryLabel, + getEffectiveValue(setting, GameSettings::autoMemoryProperty, parentGetter), + getEffectiveValue(setting, GameSettings::maxMemoryProperty, parentGetter)); + }); + + GameSettings setting = currentSetting.get(); + if (setting != null) { + SettingProperty autoMemoryProperty = setting.autoMemoryProperty(); + SettingProperty maxMemoryProperty = setting.maxMemoryProperty(); + activeAutoMemoryProperty.set(autoMemoryProperty); + activeMaxMemoryProperty.set(maxMemoryProperty); + setting.addListener(refresh); + autoMemoryProperty.addListener(refresh); + maxMemoryProperty.addListener(refresh); + refresh.invalidated(setting); + } + } + + /// Binds an independent boolean setting to a toggle editor. + static void bindToggleButton( + ObjectProperty currentSetting, + LineInheritableToggleButton button, + Function> propertyGetter, + Function parentGetter) { + ObjectProperty<@Nullable SettingProperty> activeProperty = new SimpleObjectProperty<>(); + final Holder updating = new Holder<>(false); + + InvalidationListener refresh = observable -> { + GameSettings setting = currentSetting.get(); + SettingProperty property = activeProperty.get(); + if (setting == null || property == null || updating.value) { + return; + } + + updating.value = true; + try { + boolean overridden = isOverridden(setting, property); + Boolean effectiveValue = getEffectiveValue(setting, propertyGetter, parentGetter); + button.setRawValue(overridden ? getDirectValue(property) : Boolean.TRUE.equals(effectiveValue)); + button.setOverridden(overridden); + button.setEffectiveValue(Boolean.TRUE.equals(effectiveValue)); + } finally { + updating.value = false; + } + }; + + button.rawValueProperty().addListener((observable, oldValue, newValue) -> { + GameSettings setting = currentSetting.get(); + SettingProperty property = activeProperty.get(); + if (setting == null || property == null || updating.value) { + return; + } + + updating.value = true; + try { + setOverridden(setting, property, true); + property.setValue(newValue); + button.setEffectiveValue(Boolean.TRUE.equals(getEffectiveValue(setting, propertyGetter, parentGetter))); + } finally { + updating.value = false; + } + }); + + button.overriddenProperty().addListener((observable, oldValue, newValue) -> { + GameSettings setting = currentSetting.get(); + SettingProperty property = activeProperty.get(); + if (setting == null || property == null || updating.value) { + return; + } + + updating.value = true; + try { + setOverridden(setting, property, newValue); + if (newValue) { + property.setValue(button.getRawValue()); + } + button.setEffectiveValue(Boolean.TRUE.equals(getEffectiveValue(setting, propertyGetter, parentGetter))); + } finally { + updating.value = false; + } + }); + + bindActiveProperty(currentSetting, activeProperty, propertyGetter, refresh); + } + + /// Binds the native directory mode to a boolean toggle editor. + static void bindNativesDirTypeButton( + ObjectProperty currentSetting, + LineInheritableToggleButton button, + Function parentGetter) { + ObjectProperty<@Nullable SettingProperty> activeProperty = new SimpleObjectProperty<>(); + final Holder updating = new Holder<>(false); + + InvalidationListener refresh = observable -> { + GameSettings setting = currentSetting.get(); + SettingProperty property = activeProperty.get(); + if (setting == null || property == null || updating.value) { + return; + } + + updating.value = true; + try { + boolean overridden = isOverridden(setting, property); + NativesDirectoryType rawValue = getDirectValue(property); + NativesDirectoryType effectiveValue = getEffectiveValue(setting, GameSettings::nativesDirTypeProperty, parentGetter); + button.setRawValue((overridden ? rawValue : effectiveValue) == NativesDirectoryType.CUSTOM); + button.setOverridden(overridden); + button.setEffectiveValue(effectiveValue == NativesDirectoryType.CUSTOM); + } finally { + updating.value = false; + } + }; + + button.rawValueProperty().addListener((observable, oldValue, newValue) -> { + GameSettings setting = currentSetting.get(); + SettingProperty property = activeProperty.get(); + if (setting == null || property == null || updating.value) { + return; + } + + updating.value = true; + try { + setOverridden(setting, property, true); + property.setValue(newValue ? NativesDirectoryType.CUSTOM : NativesDirectoryType.VERSION_FOLDER); + button.setEffectiveValue(getEffectiveValue(setting, GameSettings::nativesDirTypeProperty, parentGetter) == NativesDirectoryType.CUSTOM); + } finally { + updating.value = false; + } + }); + + button.overriddenProperty().addListener((observable, oldValue, newValue) -> { + GameSettings setting = currentSetting.get(); + SettingProperty property = activeProperty.get(); + if (setting == null || property == null || updating.value) { + return; + } + + updating.value = true; + try { + setOverridden(setting, property, newValue); + if (newValue) { + property.setValue(button.getRawValue() ? NativesDirectoryType.CUSTOM : NativesDirectoryType.VERSION_FOLDER); + } + button.setEffectiveValue(getEffectiveValue(setting, GameSettings::nativesDirTypeProperty, parentGetter) == NativesDirectoryType.CUSTOM); + } finally { + updating.value = false; + } + }); + + bindActiveProperty(currentSetting, activeProperty, GameSettings::nativesDirTypeProperty, refresh); + } + + private static int sliderValueToMaxMemory(double value, int totalMemoryMiB) { + return Math.max(0, (int) (clamp(value) * totalMemoryMiB)); + } + + private static double maxMemoryToSliderValue(@Nullable Integer maxMemory, int totalMemoryMiB) { + if (maxMemory == null || totalMemoryMiB <= 0) { + return 0; + } + + return clamp(maxMemory.doubleValue() / totalMemoryMiB); + } + + private static double clamp(double value) { + return Math.max(0, Math.min(1, value)); + } + + private static String maxMemoryToText(@Nullable Integer maxMemory) { + return Integer.toString(Math.max(0, maxMemory != null ? maxMemory : 0)); + } + + private static @Nullable Integer parseMemoryText(String text) { + if (StringUtils.isBlank(text)) { + return null; + } + + try { + return Math.max(0, Integer.parseInt(text)); + } catch (NumberFormatException e) { + return null; + } + } + + private static void updateMemoryStatus( + MemoryStatusBar memoryStatusBar, + Label physicalMemoryLabel, + Label allocatedMemoryLabel, + @Nullable Boolean autoMemory, + @Nullable Integer maxMemory) { + memoryStatusBar.memoryAllocatedProperty().set(calculateAllocatedMemory(memoryStatusBar, autoMemory, maxMemory)); + updateMemoryLabels(memoryStatusBar, physicalMemoryLabel, allocatedMemoryLabel, autoMemory, maxMemory); + } + + private static double calculateAllocatedMemory( + MemoryStatusBar memoryStatusBar, + @Nullable Boolean autoMemory, + @Nullable Integer maxMemory) { + long maxMemoryBytes = Math.max(0, maxMemory != null ? maxMemory : 0) * 1024L * 1024L; + return HMCLGameRepository.getAllocatedMemory( + maxMemoryBytes, + memoryStatusBar.getMemoryStatus().getAvailable(), + Boolean.TRUE.equals(autoMemory)); + } + + private static void updateMemoryLabels( + MemoryStatusBar memoryStatusBar, + Label physicalMemoryLabel, + Label allocatedMemoryLabel, + @Nullable Boolean autoMemory, + @Nullable Integer maxMemory) { + PhysicalMemoryStatus memoryStatus = memoryStatusBar.getMemoryStatus(); + long maxMemoryBytes = Math.max(0, maxMemory != null ? maxMemory : 0) * 1024L * 1024L; + boolean autoMemoryEnabled = Boolean.TRUE.equals(autoMemory); + physicalMemoryLabel.setText(i18n("settings.memory.used_per_total", + GIGABYTES.convertFromBytes(memoryStatus.getUsed()), + GIGABYTES.convertFromBytes(memoryStatus.getTotal()))); + allocatedMemoryLabel.setText(i18n( + memoryStatus.hasAvailable() && maxMemoryBytes > memoryStatus.getAvailable() + ? (autoMemoryEnabled + ? "settings.memory.allocate.auto.exceeded" + : "settings.memory.allocate.manual.exceeded") + : (autoMemoryEnabled + ? "settings.memory.allocate.auto" + : "settings.memory.allocate.manual"), + GIGABYTES.convertFromBytes(maxMemoryBytes), + GIGABYTES.convertFromBytes(HMCLGameRepository.getAllocatedMemory( + maxMemoryBytes, + memoryStatus.getAvailable(), + autoMemoryEnabled)), + GIGABYTES.convertFromBytes(memoryStatus.getAvailable()))); + } + + private static void toggleOverride( + @Nullable GameSettings setting, + @Nullable SettingProperty property, + Supplier effectiveValueSupplier, + InvalidationListener refresh) { + if (setting == null || property == null) { + return; + } + + if (isOverridden(setting, property)) { + setOverridden(setting, property, false); + } else { + property.setValue(effectiveValueSupplier.get()); + setOverridden(setting, property, true); + } + refresh.invalidated(property); + } + + private static void bindActiveProperty( + ObjectProperty currentSetting, + ObjectProperty<@Nullable SettingProperty> activeProperty, + Function> propertyGetter, + InvalidationListener refresh) { + currentSetting.addListener((observable, oldValue, newValue) -> { + if (oldValue != null) { + oldValue.removeListener(refresh); + } + + SettingProperty oldProperty = activeProperty.get(); + if (oldProperty != null) { + oldProperty.removeListener(refresh); + } + + SettingProperty newProperty = newValue != null ? propertyGetter.apply(newValue) : null; + activeProperty.set(newProperty); + if (newValue != null) { + newValue.addListener(refresh); + } + if (newProperty != null) { + newProperty.addListener(refresh); + } + refresh.invalidated(newProperty); + }); + SettingsManager.getGameSettings().addListener(refresh); + SettingsManager.defaultGameSettingsPresetProperty().addListener(refresh); + + GameSettings setting = currentSetting.get(); + if (setting != null) { + SettingProperty property = propertyGetter.apply(setting); + activeProperty.set(property); + setting.addListener(refresh); + property.addListener(refresh); + refresh.invalidated(property); + } + } + + private static String empty(@Nullable String value) { + return value != null ? value : ""; + } + + private static @Nullable Integer parseInteger(@Nullable String value, boolean nullable) { + if (StringUtils.isBlank(value)) { + return nullable ? null : 0; + } + + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException ignored) { + return nullable ? null : 0; + } + } + + private static T getDirectValue(SettingProperty property) { + T value = property.getValue(); + return value != null ? value : property.defaultValue(); + } + + private static boolean isOverridden(GameSettings setting, SettingProperty property) { + return !(setting instanceof GameSettings.Instance instance) + || instance.getOverrideProperties().contains(property.getName()); + } + + private static void setOverridden(GameSettings setting, SettingProperty property, boolean overridden) { + if (!(setting instanceof GameSettings.Instance instance)) { + return; + } + + if (overridden) { + instance.getOverrideProperties().add(property.getName()); + } else { + instance.getOverrideProperties().remove(property.getName()); + } + } + + private static T getEffectiveValue( + GameSettings setting, + Function> propertyGetter, + Function parentGetter) { + SettingProperty property = propertyGetter.apply(setting); + if (isOverridden(setting, property)) { + return getDirectValue(property); + } + + if (setting instanceof GameSettings.Instance instance) { + return getDirectValue(propertyGetter.apply(parentGetter.apply(instance))); + } + + return getDirectValue(property); + } + + /// Keeps a listener attached to the current instance's parent preset property. + private static void updateParentPropertyListener( + @Nullable GameSettings setting, + ObjectProperty<@Nullable SettingProperty> activeParentProperty, + Function> propertyGetter, + Function parentGetter, + InvalidationListener listener) { + SettingProperty oldParentProperty = activeParentProperty.get(); + SettingProperty newParentProperty = setting instanceof GameSettings.Instance instance + ? propertyGetter.apply(parentGetter.apply(instance)) + : null; + if (oldParentProperty == newParentProperty) { + return; + } + + if (oldParentProperty != null) { + oldParentProperty.removeListener(listener); + } + activeParentProperty.set(newParentProperty); + if (newParentProperty != null) { + newParentProperty.addListener(listener); + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/DownloadSettingsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/DownloadSettingsPage.java index 8eae1669bcd..92ff42879ae 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/DownloadSettingsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/DownloadSettingsPage.java @@ -26,9 +26,8 @@ import javafx.scene.control.ScrollPane; import javafx.scene.control.ToggleGroup; import javafx.scene.layout.*; -import org.jackhuang.hmcl.setting.DownloadProviders; +import org.jackhuang.hmcl.setting.DownloadSource; import org.jackhuang.hmcl.setting.EnumCommonDirectory; -import org.jackhuang.hmcl.setting.Settings; import org.jackhuang.hmcl.task.FetchTask; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.WeakListenerHolder; @@ -44,7 +43,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Function; -import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.setting.SettingsManager.settings; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public class DownloadSettingsPage extends StackPane { @@ -64,40 +63,41 @@ public DownloadSettingsPage() { var downloadSource = new ComponentList(); downloadSource.getStyleClass().add("card-non-transparent"); { - - var autoChooseDownloadSource = new LineToggleButton(); - autoChooseDownloadSource.setTitle(i18n("settings.launcher.download_source.auto")); - autoChooseDownloadSource.selectedProperty().bindBidirectional(config().autoChooseDownloadTypeProperty()); - - Function converter = key -> i18n("download.provider." + key); - Function descriptionConverter = key -> { - String bundleKey = "download.provider." + key + ".desc"; + Function converter = source -> switch (source) { + case DEFAULT -> i18n("settings.launcher.download_source.auto"); + case OFFICIAL -> i18n("download.provider.official"); + case MIRROR -> i18n("download.provider.mirror"); + }; + Function descriptionConverter = source -> { + String bundleKey = switch (source) { + case DEFAULT -> "download.provider.balanced.desc"; + case OFFICIAL -> "download.provider.official.desc"; + case MIRROR -> "download.provider.mirror.desc"; + }; return I18n.hasKey(bundleKey) ? i18n(bundleKey) : null; }; - var versionListSourcePane = new LineSelectButton(); - versionListSourcePane.disableProperty().bind(autoChooseDownloadSource.selectedProperty().not()); + var versionListSourcePane = new LineSelectButton(); versionListSourcePane.setTitle(i18n("settings.launcher.version_list_source")); versionListSourcePane.setNullSafeConverter(converter); versionListSourcePane.setDescriptionConverter(descriptionConverter); - versionListSourcePane.setItems(DownloadProviders.AUTO_PROVIDERS.keySet()); - versionListSourcePane.valueProperty().bindBidirectional(config().versionListSourceProperty()); + versionListSourcePane.setItems(DownloadSource.values()); + versionListSourcePane.valueProperty().bindBidirectional(settings().versionListSourceProperty()); - var downloadSourcePane = new LineSelectButton(); - downloadSourcePane.disableProperty().bind(autoChooseDownloadSource.selectedProperty()); + var downloadSourcePane = new LineSelectButton(); downloadSourcePane.setTitle(i18n("settings.launcher.download_source")); downloadSourcePane.setNullSafeConverter(converter); downloadSourcePane.setDescriptionConverter(descriptionConverter); - downloadSourcePane.setItems(DownloadProviders.DIRECT_PROVIDERS.keySet()); - downloadSourcePane.valueProperty().bindBidirectional(config().downloadTypeProperty()); + downloadSourcePane.setItems(DownloadSource.values()); + downloadSourcePane.valueProperty().bindBidirectional(settings().fileDownloadSourceProperty()); var defaultAddonSourcePane = new LineSelectButton(); defaultAddonSourcePane.setTitle(i18n("settings.launcher.default_addon_source")); defaultAddonSourcePane.setNullSafeConverter(key -> I18n.i18n("mods." + key)); defaultAddonSourcePane.setItems("modrinth", "curseforge"); - defaultAddonSourcePane.valueProperty().bindBidirectional(config().defaultAddonSourceProperty()); + defaultAddonSourcePane.valueProperty().bindBidirectional(settings().defaultAddonSourceProperty()); - downloadSource.getContent().setAll(autoChooseDownloadSource, versionListSourcePane, downloadSourcePane, defaultAddonSourcePane); + downloadSource.getContent().setAll(versionListSourcePane, downloadSourcePane, defaultAddonSourcePane); } content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.download_source")), downloadSource); @@ -118,17 +118,17 @@ public DownloadSettingsPage() { new MultiFileItem.FileOption<>(i18n("settings.custom"), EnumCommonDirectory.CUSTOM) .setChooserTitle(i18n("launcher.cache_directory.choose")) .setSelectionMode(FileSelector.SelectionMode.DIRECTORY) - .bindBidirectional(config().commonDirectoryProperty()) + .bindBidirectional(settings().commonDirectoryProperty()) )); - fileCommonLocation.selectedDataProperty().bindBidirectional(config().commonDirTypeProperty()); + fileCommonLocation.selectedDataProperty().bindBidirectional(settings().commonDirectoryTypeProperty()); fileCommonLocationSublist.getContent().add(fileCommonLocation); fileCommonLocationSublist.setTitle(i18n("launcher.cache_directory")); fileCommonLocationSublist.setHasSubtitle(true); - fileCommonLocationSublist.subtitleProperty().bind( - Bindings.createObjectBinding(() -> Optional.ofNullable(Settings.instance().getCommonDirectory()) + fileCommonLocationSublist.descriptionProperty().bind( + Bindings.createObjectBinding(() -> Optional.ofNullable(settings().getResolvedCommonDirectory()) .orElse(i18n("launcher.cache_directory.disabled")), - config().commonDirectoryProperty(), config().commonDirTypeProperty())); + settings().commonDirectoryProperty(), settings().commonDirectoryTypeProperty())); JFXButton cleanButton = FXUtils.newBorderButton(i18n("launcher.cache_directory.clean")); cleanButton.setOnAction(e -> clearCacheDirectory()); @@ -138,12 +138,12 @@ public DownloadSettingsPage() { { JFXCheckBox chkAutoDownloadThreads = new JFXCheckBox(i18n("settings.launcher.download.threads.auto")); VBox.setMargin(chkAutoDownloadThreads, new Insets(8, 0, 0, 0)); - chkAutoDownloadThreads.selectedProperty().bindBidirectional(config().autoDownloadThreadsProperty()); + chkAutoDownloadThreads.selectedProperty().bindBidirectional(settings().autoDownloadThreadsProperty()); downloadThreads.getChildren().add(chkAutoDownloadThreads); chkAutoDownloadThreads.selectedProperty().addListener((a, b, newValue) -> { if (newValue) { - config().downloadThreadsProperty().set(FetchTask.DEFAULT_CONCURRENCY); + settings().downloadThreadsProperty().set(FetchTask.DEFAULT_CONCURRENCY); } }); } @@ -153,7 +153,7 @@ public DownloadSettingsPage() { hbox.setStyle("-fx-view-order: -1;"); // prevent the indicator from being covered by the hint hbox.setAlignment(Pos.CENTER); hbox.setPadding(new Insets(0, 0, 0, 30)); - hbox.disableProperty().bind(config().autoDownloadThreadsProperty()); + hbox.disableProperty().bind(settings().autoDownloadThreadsProperty()); Label label = new Label(i18n("settings.launcher.download.threads")); JFXSlider slider = new JFXSlider(1, 256, 64); @@ -161,17 +161,17 @@ public DownloadSettingsPage() { JFXTextField threadsField = new JFXTextField(); FXUtils.setLimitWidth(threadsField, 60); - FXUtils.bindInt(threadsField, config().downloadThreadsProperty()); + FXUtils.bindInt(threadsField, settings().downloadThreadsProperty()); AtomicBoolean changedByTextField = new AtomicBoolean(false); - FXUtils.onChangeAndOperate(config().downloadThreadsProperty(), value -> { + FXUtils.onChangeAndOperate(settings().downloadThreadsProperty(), value -> { changedByTextField.set(true); slider.setValue(value.intValue()); changedByTextField.set(false); }); slider.valueProperty().addListener((value, oldVal, newVal) -> { if (changedByTextField.get()) return; - config().downloadThreadsProperty().set(value.getValue().intValue()); + settings().downloadThreadsProperty().set(value.getValue().intValue()); }); hbox.getChildren().setAll(label, slider, threadsField); @@ -181,7 +181,7 @@ public DownloadSettingsPage() { { HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO); VBox.setMargin(hintPane, new Insets(0, 0, 0, 30)); - hintPane.disableProperty().bind(config().autoDownloadThreadsProperty()); + hintPane.disableProperty().bind(settings().autoDownloadThreadsProperty()); hintPane.setText(i18n("settings.launcher.download.threads.hint")); downloadThreads.getChildren().add(hintPane); } @@ -218,8 +218,8 @@ public DownloadSettingsPage() { chkProxySocks.setUserData(Proxy.Type.SOCKS); chkProxySocks.setToggleGroup(proxyConfigurationGroup); - if (config().hasProxy()) { - Proxy.Type proxyType = config().getProxyType(); + if (settings().hasProxyProperty().get()) { + Proxy.Type proxyType = settings().proxyTypeProperty().get(); if (proxyType == Proxy.Type.DIRECT) { chkProxyNone.setSelected(true); } else if (proxyType == Proxy.Type.HTTP) { @@ -237,11 +237,11 @@ public DownloadSettingsPage() { Proxy.Type proxyType = toggle != null ? (Proxy.Type) toggle.getUserData() : null; if (proxyType == null) { - config().setHasProxy(false); - config().setProxyType(null); + settings().hasProxyProperty().set(false); + settings().proxyTypeProperty().set(null); } else { - config().setHasProxy(true); - config().setProxyType(proxyType); + settings().hasProxyProperty().set(true); + settings().proxyTypeProperty().set(proxyType); } })); @@ -253,9 +253,9 @@ public DownloadSettingsPage() { { proxyPane.disableProperty().bind( Bindings.createBooleanBinding(() -> - !config().hasProxy() || config().getProxyType() == null || config().getProxyType() == Proxy.Type.DIRECT, - config().hasProxyProperty(), - config().proxyTypeProperty())); + !settings().hasProxyProperty().get() || settings().proxyTypeProperty().get() == null || settings().proxyTypeProperty().get() == Proxy.Type.DIRECT, + settings().hasProxyProperty(), + settings().proxyTypeProperty())); ColumnConstraints colHgrow = new ColumnConstraints(); colHgrow.setHgrow(Priority.ALWAYS); @@ -280,7 +280,7 @@ public DownloadSettingsPage() { GridPane.setRowIndex(txtProxyHost, 1); GridPane.setColumnIndex(txtProxyHost, 1); gridPane.getChildren().add(txtProxyHost); - FXUtils.bindString(txtProxyHost, config().proxyHostProperty()); + FXUtils.bindString(txtProxyHost, settings().proxyHostProperty()); } { @@ -299,7 +299,7 @@ public DownloadSettingsPage() { FXUtils.setValidateWhileTextChanged(txtProxyPort, true); gridPane.getChildren().add(txtProxyPort); - FXUtils.bind(txtProxyPort, config().proxyPortProperty(), SafeStringConverter.fromInteger() + FXUtils.bind(txtProxyPort, settings().proxyPortProperty(), SafeStringConverter.fromInteger() .restrict(it -> it >= 0 && it <= 0xFFFF) .fallbackTo(0) .asPredicate(Validator.addTo(txtProxyPort))); @@ -313,7 +313,7 @@ public DownloadSettingsPage() { JFXCheckBox chkProxyAuthentication = new JFXCheckBox(i18n("settings.launcher.proxy.authentication")); chkProxyAuthenticationPane.getChildren().add(chkProxyAuthentication); - chkProxyAuthentication.selectedProperty().bindBidirectional(config().hasProxyAuthProperty()); + chkProxyAuthentication.selectedProperty().bindBidirectional(settings().hasProxyAuthProperty()); proxyPane.getChildren().add(chkProxyAuthenticationPane); } @@ -325,7 +325,7 @@ public DownloadSettingsPage() { authPane.setVgap(10); authPane.getColumnConstraints().setAll(new ColumnConstraints(), colHgrow); authPane.getRowConstraints().setAll(new RowConstraints(), new RowConstraints()); - authPane.disableProperty().bind(config().hasProxyAuthProperty().not()); + authPane.disableProperty().bind(settings().hasProxyAuthProperty().not()); { Label username = new Label(i18n("settings.launcher.proxy.username")); @@ -339,7 +339,7 @@ public DownloadSettingsPage() { GridPane.setRowIndex(txtProxyUsername, 0); GridPane.setColumnIndex(txtProxyUsername, 1); authPane.getChildren().add(txtProxyUsername); - FXUtils.bindString(txtProxyUsername, config().proxyUserProperty()); + FXUtils.bindString(txtProxyUsername, settings().proxyUserProperty()); } { @@ -354,7 +354,7 @@ public DownloadSettingsPage() { GridPane.setRowIndex(txtProxyPassword, 1); GridPane.setColumnIndex(txtProxyPassword, 1); authPane.getChildren().add(txtProxyPassword); - txtProxyPassword.textProperty().bindBidirectional(config().proxyPassProperty()); + txtProxyPassword.textProperty().bindBidirectional(settings().proxyPasswordProperty()); } proxyPane.getChildren().add(authPane); @@ -367,7 +367,7 @@ public DownloadSettingsPage() { } private void clearCacheDirectory() { - String commonDirectory = Settings.instance().getCommonDirectory(); + String commonDirectory = settings().getResolvedCommonDirectory(); if (commonDirectory != null) { FileUtils.cleanDirectoryQuietly(Path.of(commonDirectory, "cache")); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/JavaManagementPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/JavaManagementPage.java index 5fa9e5b9f5b..4d1c8cf13cc 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/JavaManagementPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/JavaManagementPage.java @@ -36,7 +36,7 @@ import org.jackhuang.hmcl.java.JavaInfo; import org.jackhuang.hmcl.java.JavaManager; import org.jackhuang.hmcl.java.JavaRuntime; -import org.jackhuang.hmcl.setting.ConfigHolder; +import org.jackhuang.hmcl.setting.SettingsManager; import org.jackhuang.hmcl.setting.DownloadProviders; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; @@ -126,7 +126,7 @@ void onAddJava() { } void onShowRestoreJavaPage() { - Controllers.navigateForward(new JavaRestorePage(ConfigHolder.globalConfig().getDisabledJava())); + Controllers.navigateForward(new JavaRestorePage(SettingsManager.userSettings().getDisabledJava())); } private void onAddJavaBinary(Path file) { @@ -203,7 +203,7 @@ protected List initializeToolbar(JavaManagementPage skinnable) { res.add(createToolbarButton2(i18n("java.add"), SVG.ADD, skinnable::onAddJava)); JFXButton disableJava = createToolbarButton2(i18n("java.disabled.management"), SVG.FORMAT_LIST_BULLETED, skinnable::onShowRestoreJavaPage); - disableJava.disableProperty().bind(Bindings.isEmpty(ConfigHolder.globalConfig().getDisabledJava())); + disableJava.disableProperty().bind(Bindings.isEmpty(SettingsManager.userSettings().getDisabledJava())); res.add(disableJava); return res; @@ -332,8 +332,8 @@ private void onRemove(JavaRuntime java) { i18n("message.warning"), () -> { String path = java.getBinary().toString(); - ConfigHolder.globalConfig().getUserJava().remove(path); - ConfigHolder.globalConfig().getDisabledJava().add(path); + SettingsManager.userSettings().getUserJava().remove(path); + SettingsManager.userSettings().getDisabledJava().add(path); try { JavaManager.removeJava(java); } catch (InterruptedException ignored) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/LauncherSettingsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/LauncherSettingsPage.java index de82ee32beb..3460f460a85 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/LauncherSettingsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/LauncherSettingsPage.java @@ -19,6 +19,7 @@ import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.ReadOnlyObjectWrapper; +import org.jackhuang.hmcl.setting.GameSettings; import org.jackhuang.hmcl.setting.Profile; import org.jackhuang.hmcl.setting.Profiles; import org.jackhuang.hmcl.ui.FXUtils; @@ -27,7 +28,7 @@ import org.jackhuang.hmcl.ui.construct.*; import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage; import org.jackhuang.hmcl.ui.decorator.DecoratorPage; -import org.jackhuang.hmcl.ui.versions.VersionSettingsPage; +import org.jackhuang.hmcl.ui.game.GameSettingsPage; import java.util.Locale; @@ -36,7 +37,7 @@ public class LauncherSettingsPage extends DecoratorAnimatedPage implements DecoratorPage, PageAware { private final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("settings"))); private final TabHeader tab; - private final TabHeader.Tab gameTab = new TabHeader.Tab<>("versionSettingsPage"); + private final TabHeader.Tab> gameTab = new TabHeader.Tab<>("versionSettingsPage"); private final TabControl.Tab javaManagementTab = new TabControl.Tab<>("javaManagementPage"); private final TabHeader.Tab settingsTab = new TabHeader.Tab<>("settingsPage"); private final TabHeader.Tab personalizationTab = new TabHeader.Tab<>("personalizationPage"); @@ -47,7 +48,7 @@ public class LauncherSettingsPage extends DecoratorAnimatedPage implements Decor private final TransitionPane transitionPane = new TransitionPane(); public LauncherSettingsPage() { - gameTab.setNodeSupplier(() -> new VersionSettingsPage(true)); + gameTab.setNodeSupplier(() -> new GameSettingsPage<>(GameSettings.Preset.class)); javaManagementTab.setNodeSupplier(JavaManagementPage::new); settingsTab.setNodeSupplier(SettingsPage::new); personalizationTab.setNodeSupplier(PersonalizationPage::new); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java index 3ed51868b54..88fc00dfd09 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java @@ -79,7 +79,8 @@ import java.util.function.Consumer; import static org.jackhuang.hmcl.download.RemoteVersion.Type.RELEASE; -import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.setting.SettingsManager.settings; +import static org.jackhuang.hmcl.setting.SettingsManager.state; import static org.jackhuang.hmcl.ui.FXUtils.SINE; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -118,7 +119,7 @@ public final class MainPage extends StackPane implements DecoratorPage { setPadding(new Insets(20)); - if (Metadata.isNightly() || (Metadata.isDev() && !Objects.equals(Metadata.VERSION, config().getShownTips().get(ANNOUNCEMENT)))) { + if (Metadata.isNightly() || (Metadata.isDev() && !Objects.equals(Metadata.VERSION, state().getShownTips().get(ANNOUNCEMENT)))) { String title; String content; if (Metadata.isNightly()) { @@ -139,7 +140,7 @@ public final class MainPage extends StackPane implements DecoratorPage { btnHide.setOnAction(e -> { announcementPane.setContent(new StackPane(), ContainerAnimations.FADE); if (Metadata.isDev()) { - config().getShownTips().put(ANNOUNCEMENT, Metadata.VERSION); + state().getShownTips().put(ANNOUNCEMENT, Metadata.VERSION); } }); btnHide.getStyleClass().add("announcement-close-button"); @@ -205,7 +206,7 @@ public final class MainPage extends StackPane implements DecoratorPage { FXUtils.onScroll(launchPane, versions, list -> { String currentId = getCurrentGame(); return Lang.indexWhere(list, instance -> instance.getId().equals(currentId)); - }, it -> profile.setSelectedVersion(it.getId())); + }, it -> Profiles.setSelectedInstance(profile, it.getId())); StackPane.setAlignment(launchPane, Pos.BOTTOM_RIGHT); { @@ -279,12 +280,12 @@ public void accept(String currentGame) { private void showUpdate(boolean show) { doAnimation(show); - if (show && !config().isDisableAutoShowUpdateDialog() + if (show && !settings().disableAutoShowUpdateDialogProperty().get() && getLatestVersion() != null - && !Objects.equals(config().getPromptedVersion(), getLatestVersion().version())) { + && !Objects.equals(state().getPromptedVersion(), getLatestVersion().version())) { Controllers.dialog(new MessageDialogPane.Builder("", i18n("update.bubble.title", getLatestVersion().version()), MessageDialogPane.MessageType.INFO) .addAction(i18n("button.view"), () -> { - config().setPromptedVersion(getLatestVersion().version()); + state().setPromptedVersion(getLatestVersion().version()); onUpgrade(); }) .addCancel(null) @@ -313,7 +314,7 @@ private void doAnimation(boolean show) { private void launch() { Profile profile = Profiles.getSelectedProfile(); - Versions.launch(profile, profile.getSelectedVersion()); + Versions.launch(profile, Profiles.getSelectedInstance(profile)); } private void launchNoGame() { @@ -341,7 +342,7 @@ private void launchNoGame() { .whenComplete(any -> profile.getRepository().refreshVersions()) .whenComplete(Schedulers.javafx(), (result, exception) -> { if (exception == null) { - profile.setSelectedVersion(gameVersionHolder.value); + Profiles.setSelectedInstance(profile, gameVersionHolder.value); launch(); } else if (exception instanceof CancellationException) { Controllers.showToast(i18n("message.cancelled")); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/PersonalizationPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/PersonalizationPage.java index 598b8a6bed9..e253f9fa994 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/PersonalizationPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/PersonalizationPage.java @@ -33,21 +33,24 @@ import javafx.scene.layout.*; import javafx.scene.text.Font; import javafx.scene.text.FontSmoothingType; +import org.jackhuang.hmcl.setting.SettingsManager; import org.jackhuang.hmcl.setting.EnumBackgroundImage; import org.jackhuang.hmcl.setting.FontManager; +import org.jackhuang.hmcl.setting.UserSettings; import org.jackhuang.hmcl.theme.ThemeColor; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.ui.construct.*; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.javafx.SafeStringConverter; +import org.jetbrains.annotations.Nullable; import java.util.Arrays; import java.util.Locale; import java.util.Optional; -import static org.jackhuang.hmcl.setting.ConfigHolder.config; -import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig; +import static org.jackhuang.hmcl.setting.SettingsManager.settings; +import static org.jackhuang.hmcl.setting.SettingsManager.userSettings; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public class PersonalizationPage extends StackPane { @@ -81,7 +84,7 @@ public PersonalizationPage() { brightnessPane.setTitle(i18n("settings.launcher.brightness")); brightnessPane.setNullSafeConverter(name -> i18n("settings.launcher.brightness." + name)); brightnessPane.setItems("auto", "light", "dark"); - brightnessPane.valueProperty().bindBidirectional(config().themeBrightnessProperty()); + brightnessPane.valueProperty().bindBidirectional(settings().themeBrightnessProperty()); themeList.getContent().add(brightnessPane); } @@ -100,20 +103,20 @@ public PersonalizationPage() { ColorPicker picker = new JFXColorPicker(); picker.getCustomColors().setAll(ThemeColor.STANDARD_COLORS.stream().map(ThemeColor::color).toList()); - ThemeColor.bindBidirectional(picker, config().themeColorProperty()); + ThemeColor.bindBidirectional(picker, settings().themeColorProperty()); themeColorPickerContainer.getChildren().setAll(picker); Platform.runLater(() -> JFXDepthManager.setDepth(picker, 0)); } { LineToggleButton titleTransparentButton = new LineToggleButton(); themeList.getContent().add(titleTransparentButton); - titleTransparentButton.selectedProperty().bindBidirectional(config().titleTransparentProperty()); + titleTransparentButton.selectedProperty().bindBidirectional(settings().titleTransparentProperty()); titleTransparentButton.setTitle(i18n("settings.launcher.title_transparent")); } { LineToggleButton animationButton = new LineToggleButton(); themeList.getContent().add(animationButton); - animationButton.selectedProperty().bindBidirectional(config().animationDisabledProperty()); + animationButton.selectedProperty().bindBidirectional(settings().animationDisabledProperty()); animationButton.setTitle(i18n("settings.launcher.turn_off_animations")); animationButton.setSubtitle(i18n("settings.take_effect_after_restart")); } @@ -136,18 +139,18 @@ public PersonalizationPage() { .setChooserTitle(i18n("launcher.background.choose")) .addExtensionFilter(FXUtils.getImageExtensionFilter()) .setSelectionMode(FileSelector.SelectionMode.FILE_OR_DIRECTORY) - .bindBidirectional(config().backgroundImageProperty()), + .bindBidirectional(settings().backgroundImageProperty()), new MultiFileItem.StringOption<>(i18n("launcher.background.network"), EnumBackgroundImage.NETWORK) .setValidators(new URLValidator(true)) - .bindBidirectional(config().backgroundImageUrlProperty()), + .bindBidirectional(settings().backgroundImageUrlProperty()), new MultiFileItem.PaintOption<>(i18n("launcher.background.paint"), EnumBackgroundImage.PAINT) - .bindBidirectional(config().backgroundPaintProperty()) + .bindBidirectional(settings().backgroundPaintProperty()) )); - backgroundItem.selectedDataProperty().bindBidirectional(config().backgroundImageTypeProperty()); - backgroundSublist.subtitleProperty().bind( + backgroundItem.selectedDataProperty().bindBidirectional(settings().backgroundImageTypeProperty()); + backgroundSublist.descriptionProperty().bind( new When(backgroundItem.selectedDataProperty().isEqualTo(EnumBackgroundImage.DEFAULT)) .then(i18n("launcher.background.default")) - .otherwise(config().backgroundImageProperty())); + .otherwise(settings().backgroundImageProperty())); HBox opacityItem = new HBox(8); { @@ -156,8 +159,8 @@ public PersonalizationPage() { Label label = new Label(i18n("settings.launcher.background.settings.opacity")); JFXSlider slider = new JFXSlider(0, 100, - config().getBackgroundImageType() != EnumBackgroundImage.TRANSLUCENT - ? config().getBackgroundImageOpacity() : 50); + settings().backgroundImageTypeProperty().get() != EnumBackgroundImage.TRANSLUCENT + ? settings().backgroundImageOpacityProperty().get() : 50); slider.setShowTickMarks(true); slider.setMajorTickUnit(10); slider.setMinorTickCount(1); @@ -166,13 +169,13 @@ public PersonalizationPage() { slider.setPadding(new Insets(9, 0, 0, 0)); HBox.setHgrow(slider, Priority.ALWAYS); - if (config().getBackgroundImageType() == EnumBackgroundImage.TRANSLUCENT) { + if (settings().backgroundImageTypeProperty().get() == EnumBackgroundImage.TRANSLUCENT) { slider.setDisable(true); - config().backgroundImageTypeProperty().addListener(new ChangeListener<>() { + settings().backgroundImageTypeProperty().addListener(new ChangeListener<>() { @Override public void changed(ObservableValue observable, EnumBackgroundImage oldValue, EnumBackgroundImage newValue) { if (newValue != EnumBackgroundImage.TRANSLUCENT) { - config().backgroundImageTypeProperty().removeListener(this); + settings().backgroundImageTypeProperty().removeListener(this); slider.setDisable(false); slider.setValue(100); } @@ -188,7 +191,7 @@ public void changed(ObservableValue observable, E slider.setValueFactory(s -> valueBinding); slider.valueProperty().addListener((observable, oldValue, newValue) -> - config().setBackgroundImageOpacity(snapOpacity(newValue.doubleValue()))); + settings().backgroundImageOpacityProperty().set(snapOpacity(newValue.doubleValue()))); opacityItem.getChildren().setAll(label, slider, textOpacity); } @@ -218,11 +221,11 @@ public void changed(ObservableValue observable, E hBox.setSpacing(3); FontComboBox cboLogFont = new FontComboBox(); - cboLogFont.valueProperty().bindBidirectional(config().fontFamilyProperty()); + cboLogFont.valueProperty().bindBidirectional(settings().fontFamilyProperty()); JFXTextField txtLogFontSize = new JFXTextField(); FXUtils.setLimitWidth(txtLogFontSize, 50); - FXUtils.bind(txtLogFontSize, config().fontSizeProperty(), SafeStringConverter.fromFiniteDouble() + FXUtils.bind(txtLogFontSize, settings().fontSizeProperty(), SafeStringConverter.fromFiniteDouble() .restrict(it -> it > 0) .fallbackTo(12.0) .asPredicate(Validator.addTo(txtLogFontSize))); @@ -240,8 +243,8 @@ public void changed(ObservableValue observable, E Label lblLogFontDisplay = new Label("[23:33:33] [Client Thread/INFO] [WaterPower]: Loaded mod WaterPower."); lblLogFontDisplay.fontProperty().bind(Bindings.createObjectBinding( - () -> Font.font(Lang.requireNonNullElse(config().getFontFamily(), FXUtils.DEFAULT_MONOSPACE_FONT), config().getFontSize()), - config().fontFamilyProperty(), config().fontSizeProperty())); + () -> Font.font(Lang.requireNonNullElse(settings().fontFamilyProperty().get(), FXUtils.DEFAULT_MONOSPACE_FONT), settings().fontSizeProperty().get()), + settings().fontFamilyProperty(), settings().fontSizeProperty())); fontPane.getChildren().add(lblLogFontDisplay); @@ -272,7 +275,7 @@ public void changed(ObservableValue observable, E hBox.setSpacing(8); FontComboBox cboFont = new FontComboBox(); - cboFont.setValue(config().getLauncherFontFamily()); + cboFont.setValue(settings().launcherFontFamilyProperty().get()); FXUtils.onChange(cboFont.valueProperty(), FontManager::setFontFamily); JFXButton clearButton = FXUtils.newToggleButton4(SVG.RESTORE); @@ -306,7 +309,7 @@ public void changed(ObservableValue observable, E Optional.of(FontSmoothingType.GRAY) ); - String fontAntiAliasing = globalConfig().getFontAntiAliasing(); + @Nullable String fontAntiAliasing = SettingsManager.userSettings().fontAntiAliasingProperty().get(); if ("lcd".equalsIgnoreCase(fontAntiAliasing)) { fontAntiAliasingPane.setValue(Optional.of(FontSmoothingType.LCD)); } else if ("gray".equalsIgnoreCase(fontAntiAliasing)) { @@ -316,8 +319,11 @@ public void changed(ObservableValue observable, E } FXUtils.onChange(fontAntiAliasingPane.valueProperty(), value -> - globalConfig().setFontAntiAliasing(value.map(it -> it.name().toLowerCase(Locale.ROOT)) - .orElse(null))); + { + UserSettings userSettings = userSettings(); + userSettings.fontAntiAliasingProperty().set(value.map(it -> it.name().toLowerCase(Locale.ROOT)) + .orElse(null)); + }); fontPane.getContent().add(fontAntiAliasingPane); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java index 0f42bcad0d2..95e16edebb0 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java @@ -156,7 +156,7 @@ protected Skin(RootPage control) { GameAdvancedListItem gameListItem = new GameAdvancedListItem(); gameListItem.setOnAction(e -> { Profile profile = Profiles.getSelectedProfile(); - String version = Profiles.getSelectedVersion(); + String version = Profiles.getSelectedInstance(); if (version == null) { Controllers.navigate(Controllers.getGameListPage()); } else { @@ -166,7 +166,7 @@ protected Skin(RootPage control) { FXUtils.onScroll(gameListItem, getSkinnable().getMainPage().getVersions(), list -> { String currentId = getSkinnable().getMainPage().getCurrentGame(); return Lang.indexWhere(list, instance -> instance.getId().equals(currentId)); - }, it -> getSkinnable().getMainPage().getProfile().setSelectedVersion(it.getId())); + }, it -> Profiles.setSelectedInstance(getSkinnable().getMainPage().getProfile(), it.getId())); if (AnimationUtils.isAnimationEnabled()) { FXUtils.prepareOnMouseEnter(gameListItem, Controllers::prepareVersionPage); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsPage.java index ceff5dc8fb1..4fcc43ac506 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsPage.java @@ -65,7 +65,7 @@ import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; -import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.setting.SettingsManager.settings; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -143,7 +143,7 @@ protected int getTrailingTextIndex() { LineToggleButton previewPane = new LineToggleButton(); previewPane.setTitle(i18n("update.preview")); previewPane.setSubtitle(i18n("update.preview.subtitle")); - previewPane.selectedProperty().bindBidirectional(config().acceptPreviewUpdateProperty()); + previewPane.selectedProperty().bindBidirectional(settings().acceptPreviewUpdateProperty()); InvalidationListener checkUpdateListener = e -> { UpdateChecker.requestCheckUpdate(updateChannel.get(), previewPane.isSelected()); @@ -158,7 +158,7 @@ protected int getTrailingTextIndex() { LineToggleButton disableAutoShowUpdateDialogPane = new LineToggleButton(); disableAutoShowUpdateDialogPane.setTitle(i18n("update.disable_auto_show_update_dialog")); disableAutoShowUpdateDialogPane.setSubtitle(i18n("update.disable_auto_show_update_dialog.subtitle")); - disableAutoShowUpdateDialogPane.selectedProperty().bindBidirectional(config().disableAutoShowUpdateDialogProperty()); + disableAutoShowUpdateDialogPane.selectedProperty().bindBidirectional(settings().disableAutoShowUpdateDialogProperty()); updatePaneList.getContent().add(disableAutoShowUpdateDialogPane); } @@ -183,15 +183,10 @@ else if (locale.isSameLanguage(currentLocale)) return locale.getDisplayName(currentLocale) + " - " + locale.getDisplayName(locale); }); chooseLanguagePane.setItems(SupportedLocale.getSupportedLocales()); - chooseLanguagePane.valueProperty().bindBidirectional(config().localizationProperty()); + chooseLanguagePane.valueProperty().bindBidirectional(settings().languageProperty()); languagePaneList.getContent().add(chooseLanguagePane); - LineToggleButton disableAutoGameOptionsPane = new LineToggleButton(); - disableAutoGameOptionsPane.setTitle(i18n("settings.launcher.disable_auto_game_options")); - disableAutoGameOptionsPane.selectedProperty().bindBidirectional(config().disableAutoGameOptionsProperty()); - - languagePaneList.getContent().add(disableAutoGameOptionsPane); } rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.language")), languagePaneList); @@ -204,19 +199,10 @@ else if (locale.isSameLanguage(currentLocale)) LineToggleButton disableAprilFools = new LineToggleButton(); disableAprilFools.setTitle(i18n("settings.launcher.disable_april_fools")); disableAprilFools.setSubtitle(i18n("settings.take_effect_after_restart")); - disableAprilFools.selectedProperty().bindBidirectional(config().disableAprilFoolsProperty()); + disableAprilFools.selectedProperty().bindBidirectional(settings().disableAprilFoolsProperty()); miscPaneList.getContent().add(disableAprilFools); } - { - LineToggleButton allowAutoAgentPane = new LineToggleButton(); - allowAutoAgentPane.setTitle(i18n("settings.launcher.allow_auto_agent")); - allowAutoAgentPane.setSubtitle(i18n("settings.launcher.allow_auto_agent.subtitle")); - allowAutoAgentPane.selectedProperty().bindBidirectional(config().allowAutoAgentProperty()); - - miscPaneList.getContent().add(allowAutoAgentPane); - } - { BorderPane debugPane = new BorderPane(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/profile/ProfileListItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/profile/ProfileListItem.java index 055f29c58cc..d1f2d30ab7b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/profile/ProfileListItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/profile/ProfileListItem.java @@ -36,7 +36,7 @@ public ProfileListItem(Profile profile) { setUserData(profile); title.set(Profiles.getProfileDisplayName(profile)); - subtitle.set(profile.getGameDir().toString()); + subtitle.set(profile.getPath().toString()); } @Override diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/profile/ProfilePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/profile/ProfilePage.java index e972bbab8b7..48528eff278 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/profile/ProfilePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/profile/ProfilePage.java @@ -37,11 +37,13 @@ import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.construct.*; import org.jackhuang.hmcl.ui.decorator.DecoratorPage; +import org.jackhuang.hmcl.util.PortablePath; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.io.FileUtils; import java.nio.file.InvalidPathException; import java.nio.file.Path; +import java.util.Objects; import java.util.Optional; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; @@ -65,7 +67,7 @@ public ProfilePage(Profile profile) { state.set(State.fromTitle(profile == null ? i18n("profile.new") : i18n("profile") + " - " + profileDisplayName)); location = new SimpleStringProperty(this, "location", - Optional.ofNullable(profile).map(Profile::getGameDir).map(FileUtils::getAbsolutePath).orElse(".minecraft")); + Optional.ofNullable(profile).map(Profile::getPath).map(PortablePath::toPath).map(FileUtils::getAbsolutePath).orElse(".minecraft")); ScrollPane scroll = new ScrollPane(); this.setCenter(scroll); @@ -99,7 +101,7 @@ public ProfilePage(Profile profile) { @Override protected void eval() { JFXTextField control = (JFXTextField) this.getSrcControl(); - hasErrors.set(Profiles.getProfiles().stream().anyMatch(profile -> profile.getName().equals(control.getText()))); + hasErrors.set(Profiles.getProfiles().stream().anyMatch(profile -> Objects.equals(profile.getName(), control.getText()))); } }); } @@ -115,7 +117,7 @@ protected void eval() { gameDir.convertToRelativePathProperty().bind(toggleUseRelativePath.selectedProperty()); if (profile != null) { - toggleUseRelativePath.setSelected(profile.isUseRelativePath()); + toggleUseRelativePath.setSelected(!profile.getPath().isAbsolute()); } componentList.getContent().setAll(profileNamePane, gameDir, toggleUseRelativePath); @@ -180,16 +182,14 @@ public void changed(ObservableValue observable, String oldValu private void onSave() { if (profile != null) { profile.setName(txtProfileName.getText()); - profile.setUseRelativePath(toggleUseRelativePath.isSelected()); if (StringUtils.isNotBlank(getLocation())) { - profile.setGameDir(Path.of(getLocation())); + profile.setPath(PortablePath.of(getLocation())); } } else { if (StringUtils.isBlank(getLocation())) { gameDir.fire(); } - Profile newProfile = new Profile(txtProfileName.getText(), Path.of(getLocation())); - newProfile.setUseRelativePath(toggleUseRelativePath.isSelected()); + Profile newProfile = new Profile(Profiles.newProfileId(), txtProfileName.getText(), PortablePath.of(getLocation())); Profiles.getProfiles().add(newProfile); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaControllerPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaControllerPage.java index d7d7d3f77f5..9724f6d65dc 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaControllerPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaControllerPage.java @@ -69,7 +69,7 @@ import java.util.Locale; import java.util.concurrent.ThreadLocalRandom; -import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.setting.SettingsManager.state; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public class TerracottaControllerPage extends StackPane { @@ -162,11 +162,11 @@ public TerracottaControllerPage() { } if (uninitialized.hasLegacy() && I18n.isUseChinese()) { - Object feedback = config().getShownTips().get(FEEDBACK_TIP); + Object feedback = state().getShownTips().get(FEEDBACK_TIP); if (!(feedback instanceof Number number) || number.intValue() < 1) { Controllers.confirm(i18n("terracotta.feedback.desc.update"), i18n("terracotta.feedback.title"), () -> { FXUtils.openLink(TerracottaMetadata.FEEDBACK_LINK); - config().getShownTips().put(FEEDBACK_TIP, 1); + state().getShownTips().put(FEEDBACK_TIP, 1); }, () -> { }); } @@ -212,7 +212,7 @@ public TerracottaControllerPage() { MessageDialogPane.MessageType.QUESTION ).addAction(i18n("version.launch"), () -> { Profile profile = Profiles.getSelectedProfile(); - Versions.launch(profile, profile.getSelectedVersion(), launcherHelper -> { + Versions.launch(profile, Profiles.getSelectedInstance(profile), launcherHelper -> { launcherHelper.setKeep(); launcherHelper.setDisableOfflineSkin(); }); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaPage.java index f08bbf43efa..38190ef2413 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaPage.java @@ -25,9 +25,7 @@ import javafx.scene.layout.BorderPane; import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; -import org.jackhuang.hmcl.setting.Accounts; -import org.jackhuang.hmcl.setting.Profile; -import org.jackhuang.hmcl.setting.Profiles; +import org.jackhuang.hmcl.setting.*; import org.jackhuang.hmcl.terracotta.TerracottaMetadata; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; @@ -44,7 +42,7 @@ import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.StringUtils; -import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig; +import static org.jackhuang.hmcl.setting.SettingsManager.userSettings; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public class TerracottaPage extends DecoratorAnimatedPage implements DecoratorPage, PageAware { @@ -81,7 +79,7 @@ public TerracottaPage() { .add(accountListItem) .addNavigationDrawerItem(i18n("version.launch"), SVG.ROCKET_LAUNCH, () -> { Profile profile = Profiles.getSelectedProfile(); - Versions.launch(profile, profile.getSelectedVersion(), launcherHelper -> { + Versions.launch(profile, Profiles.getSelectedInstance(profile), launcherHelper -> { launcherHelper.setKeep(); launcherHelper.setDisableOfflineSkin(); }); @@ -94,7 +92,7 @@ public TerracottaPage() { FXUtils.onScroll(item, mainPage.getVersions(), list -> { String currentId = mainPage.getCurrentGame(); return Lang.indexWhere(list, instance -> instance.getId().equals(currentId)); - }, it -> mainPage.getProfile().setSelectedVersion(it.getId())); + }, it -> Profiles.setSelectedInstance(mainPage.getProfile(), it.getId())); FXUtils.onSecondaryButtonClicked(item, () -> GameListPopupMenu.show(item, JFXPopup.PopupVPosition.BOTTOM, @@ -114,9 +112,10 @@ public TerracottaPage() { public void onPageShown() { tab.onPageShown(); - if (globalConfig().getTerracottaAgreementVersion() < TERRACOTTA_AGREEMENT_VERSION) { + if (SettingsManager.userSettings().terracottaAgreementVersionProperty().get() < TERRACOTTA_AGREEMENT_VERSION) { Controllers.confirmWithCountdown(i18n("terracotta.confirm.desc"), i18n("terracotta.confirm.title"), 5, MessageDialogPane.MessageType.INFO, () -> { - globalConfig().setTerracottaAgreementVersion(TERRACOTTA_AGREEMENT_VERSION); + UserSettings userSettings = userSettings(); + userSettings.terracottaAgreementVersionProperty().set(TERRACOTTA_AGREEMENT_VERSION); }, () -> fireEvent(new PageCloseEvent())); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/AdvancedVersionSettingPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/AdvancedVersionSettingPage.java deleted file mode 100644 index e3e00e73e23..00000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/AdvancedVersionSettingPage.java +++ /dev/null @@ -1,354 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2026 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.ui.versions; - -import com.jfoenix.controls.JFXTextField; -import javafx.beans.binding.Bindings; -import javafx.beans.property.ObjectProperty; -import javafx.beans.property.ReadOnlyObjectProperty; -import javafx.beans.property.SimpleObjectProperty; -import javafx.scene.control.Label; -import javafx.scene.control.ScrollPane; -import javafx.scene.layout.*; -import org.jackhuang.hmcl.game.GraphicsAPI; -import org.jackhuang.hmcl.game.NativesDirectoryType; -import org.jackhuang.hmcl.game.Renderer; -import org.jackhuang.hmcl.setting.Profile; -import org.jackhuang.hmcl.setting.VersionSetting; -import org.jackhuang.hmcl.ui.FXUtils; -import org.jackhuang.hmcl.ui.construct.*; -import org.jackhuang.hmcl.ui.decorator.DecoratorPage; -import org.jackhuang.hmcl.util.i18n.I18n; -import org.jackhuang.hmcl.util.io.FileUtils; -import org.jackhuang.hmcl.util.platform.OperatingSystem; -import org.jackhuang.hmcl.util.platform.Platform; -import org.jackhuang.hmcl.util.versioning.GameVersionNumber; -import org.jetbrains.annotations.Nullable; - -import java.nio.file.FileSystems; -import java.util.Arrays; -import java.util.Locale; - -import static org.jackhuang.hmcl.util.i18n.I18n.i18n; - -public final class AdvancedVersionSettingPage extends StackPane implements DecoratorPage { - - private final ObjectProperty stateProperty; - - private final Profile profile; - private final String versionId; - private final VersionSetting versionSetting; - - private final JFXTextField txtJVMArgs; - private final JFXTextField txtGameArgs; - private final JFXTextField txtEnvironmentVariables; - private final JFXTextField txtMetaspace; - private final JFXTextField txtWrapper; - private final JFXTextField txtPreLaunchCommand; - private final JFXTextField txtPostExitCommand; - private final LineToggleButton noJVMArgsPane; - private final LineToggleButton noOptimizingJVMArgsPane; - private final LineToggleButton noGameCheckPane; - private final LineToggleButton noJVMCheckPane; - private final LineToggleButton noNativesPatchPane; - private final LineToggleButton useNativeGLFWPane; - private final LineToggleButton useNativeOpenALPane; - private final ComponentSublist nativesDirSublist; - private final MultiFileItem nativesDirItem; - private final MultiFileItem.FileOption nativesDirCustomOption; - private final LineSelectButton graphicsBackendPane; - private final LineSelectButton rendererPane; - - public AdvancedVersionSettingPage(Profile profile, @Nullable String versionId, VersionSetting versionSetting) { - this.profile = profile; - this.versionId = versionId; - this.versionSetting = versionSetting; - this.stateProperty = new SimpleObjectProperty<>(State.fromTitle( - versionId == null ? i18n("settings.advanced") : i18n("settings.advanced.title", versionId) - )); - - @Nullable GameVersionNumber gameVersion = versionId != null - ? GameVersionNumber.asGameVersion(profile.getRepository().getGameVersion(versionId)) - : null; - - this.getStyleClass().add("gray-background"); - - ScrollPane scrollPane = new ScrollPane(); - scrollPane.setFitToHeight(true); - scrollPane.setFitToWidth(true); - scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); - getChildren().setAll(scrollPane); - - VBox rootPane = new VBox(); - rootPane.setFocusTraversable(true); - rootPane.setFillWidth(true); - scrollPane.setContent(rootPane); - FXUtils.smoothScrolling(scrollPane); - rootPane.getStyleClass().add("card-list"); - - ComponentList customCommandsPane = new ComponentList(); - { - GridPane pane = new GridPane(); - pane.setHgap(16); - pane.setVgap(8); - pane.getColumnConstraints().setAll(new ColumnConstraints(), FXUtils.getColumnHgrowing()); - - txtGameArgs = new JFXTextField(); - txtGameArgs.setPromptText(i18n("settings.advanced.minecraft_arguments.prompt")); - txtGameArgs.getStyleClass().add("fit-width"); - pane.addRow(0, new Label(i18n("settings.advanced.minecraft_arguments")), txtGameArgs); - - txtPreLaunchCommand = new JFXTextField(); - txtPreLaunchCommand.setPromptText(i18n("settings.advanced.precall_command.prompt")); - txtPreLaunchCommand.getStyleClass().add("fit-width"); - pane.addRow(1, new Label(i18n("settings.advanced.precall_command")), txtPreLaunchCommand); - - txtWrapper = new JFXTextField(); - txtWrapper.setPromptText(i18n("settings.advanced.wrapper_launcher.prompt")); - txtWrapper.getStyleClass().add("fit-width"); - pane.addRow(2, new Label(i18n("settings.advanced.wrapper_launcher")), txtWrapper); - - txtPostExitCommand = new JFXTextField(); - txtPostExitCommand.setPromptText(i18n("settings.advanced.post_exit_command.prompt")); - txtPostExitCommand.getStyleClass().add("fit-width"); - pane.addRow(3, new Label(i18n("settings.advanced.post_exit_command")), txtPostExitCommand); - - HintPane hintPane = new HintPane(); - hintPane.setText(i18n("settings.advanced.custom_commands.hint")); - GridPane.setColumnSpan(hintPane, 2); - pane.addRow(4, hintPane); - - customCommandsPane.getContent().setAll(pane); - } - - ComponentList jvmPane = new ComponentList(); - { - GridPane pane = new GridPane(); - ColumnConstraints title = new ColumnConstraints(); - ColumnConstraints value = new ColumnConstraints(); - value.setFillWidth(true); - value.setHgrow(Priority.ALWAYS); - pane.setHgap(16); - pane.setVgap(8); - pane.getColumnConstraints().setAll(title, value); - - txtJVMArgs = new JFXTextField(); - txtJVMArgs.getStyleClass().add("fit-width"); - pane.addRow(0, new Label(i18n("settings.advanced.jvm_args")), txtJVMArgs); - - HintPane hintPane = new HintPane(); - hintPane.setText(i18n("settings.advanced.jvm_args.prompt")); - GridPane.setColumnSpan(hintPane, 2); - pane.addRow(4, hintPane); - - txtMetaspace = new JFXTextField(); - txtMetaspace.setPromptText(i18n("settings.advanced.java_permanent_generation_space.prompt")); - txtMetaspace.getStyleClass().add("fit-width"); - FXUtils.setValidateWhileTextChanged(txtMetaspace, true); - txtMetaspace.setValidators(new NumberValidator(i18n("input.number"), true)); - pane.addRow(1, new Label(i18n("settings.advanced.java_permanent_generation_space")), txtMetaspace); - - txtEnvironmentVariables = new JFXTextField(); - txtEnvironmentVariables.getStyleClass().add("fit-width"); - pane.addRow(2, new Label(i18n("settings.advanced.environment_variables")), txtEnvironmentVariables); - - jvmPane.getContent().setAll(pane); - } - - ComponentList workaroundPane = new ComponentList(); - - HintPane workaroundWarning = new HintPane(MessageDialogPane.MessageType.WARNING); - workaroundWarning.setText(i18n("settings.advanced.workaround.warning")); - - { - nativesDirItem = new MultiFileItem<>(); - nativesDirSublist = new ComponentSublist(); - nativesDirSublist.getContent().add(nativesDirItem); - nativesDirSublist.setTitle(i18n("settings.advanced.natives_directory")); - nativesDirSublist.setHasSubtitle(true); - nativesDirCustomOption = new MultiFileItem.FileOption<>(i18n("settings.advanced.natives_directory.custom"), NativesDirectoryType.CUSTOM) - .setChooserTitle(i18n("settings.advanced.natives_directory.choose")) - .setSelectionMode(FileSelector.SelectionMode.DIRECTORY); - nativesDirItem.loadChildren(Arrays.asList( - new MultiFileItem.Option<>(i18n("settings.advanced.natives_directory.default"), NativesDirectoryType.VERSION_FOLDER), - nativesDirCustomOption - )); - - graphicsBackendPane = new LineSelectButton<>(); - graphicsBackendPane.setTitle(i18n("settings.advanced.graphics_backend")); - graphicsBackendPane.setNullSafeConverter(backend -> i18n("settings.advanced.graphics_backend." + backend.name().toLowerCase(Locale.ROOT))); - graphicsBackendPane.setDescriptionConverter(backend -> switch (backend) { - case DEFAULT -> i18n("settings.advanced.graphics_backend.default.desc"); - case OPENGL -> i18n("settings.advanced.graphics_backend.opengl.desc"); - case VULKAN -> { - if (gameVersion == null) - yield i18n("settings.advanced.graphics_backend.vulkan.desc.global"); - else if (gameVersion.compareTo("26.2-snapshot-2") < 0) - yield i18n("settings.advanced.graphics_backend.vulkan.desc.unsupported"); - else - yield i18n("settings.advanced.graphics_backend.vulkan.desc"); - } - default -> null; - }); - graphicsBackendPane.setValue(GraphicsAPI.DEFAULT); - graphicsBackendPane.setItems(GraphicsAPI.values()); - - rendererPane = new LineSelectButton<>(); - rendererPane.setTitle(i18n("settings.advanced.renderer")); - rendererPane.setNullSafeConverter(e -> i18n("settings.advanced.renderer." + e.name().toLowerCase(Locale.ROOT))); - rendererPane.setDescriptionConverter(e -> { - String bundleKey = "settings.advanced.renderer." + e.name().toLowerCase(Locale.ROOT) + ".desc"; - return I18n.hasKey(bundleKey) ? i18n(bundleKey) : null; - }); - rendererPane.setValue(Renderer.DEFAULT); - - FXUtils.onChangeAndOperate(graphicsBackendPane.valueProperty(), backend -> { - if (backend == null) { // unbind - return; - } - - rendererPane.setItems(Renderer.getSupported(backend)); - if (backend == GraphicsAPI.DEFAULT) { - rendererPane.setDisable(true); - rendererPane.setValue(Renderer.DEFAULT); - } else { - rendererPane.setDisable(false); - if (!(rendererPane.getValue() instanceof Renderer.Driver driver) || driver.api() != backend) - rendererPane.setValue(Renderer.DEFAULT); - } - }); - - noJVMArgsPane = new LineToggleButton(); - noJVMArgsPane.setTitle(i18n("settings.advanced.no_jvm_args")); - - noOptimizingJVMArgsPane = new LineToggleButton(); - noOptimizingJVMArgsPane.setTitle(i18n("settings.advanced.no_optimizing_jvm_args")); - noOptimizingJVMArgsPane.disableProperty().bind(noJVMArgsPane.selectedProperty()); - - noGameCheckPane = new LineToggleButton(); - noGameCheckPane.setTitle(i18n("settings.advanced.dont_check_game_completeness")); - - noJVMCheckPane = new LineToggleButton(); - noJVMCheckPane.setTitle(i18n("settings.advanced.dont_check_jvm_validity")); - - noNativesPatchPane = new LineToggleButton(); - noNativesPatchPane.setTitle(i18n("settings.advanced.dont_patch_natives")); - - useNativeGLFWPane = new LineToggleButton(); - useNativeGLFWPane.setTitle(i18n("settings.advanced.use_native_glfw")); - useNativeGLFWPane.setSubtitle(i18n("settings.advanced.linux_freebsd_only")); - - useNativeOpenALPane = new LineToggleButton(); - useNativeOpenALPane.setTitle(i18n("settings.advanced.use_native_openal")); - useNativeOpenALPane.setSubtitle(i18n("settings.advanced.linux_freebsd_only")); - - workaroundPane.getContent().setAll( - nativesDirSublist, graphicsBackendPane, rendererPane, noJVMArgsPane, noOptimizingJVMArgsPane, noGameCheckPane, - noJVMCheckPane, noNativesPatchPane - ); - - if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) { - workaroundPane.getContent().addAll(useNativeGLFWPane, useNativeOpenALPane); - } else { - ComponentSublist unsupportedOptionsSublist = new ComponentSublist(); - unsupportedOptionsSublist.setTitle(i18n("settings.advanced.unsupported_system_options")); - unsupportedOptionsSublist.getContent().addAll(useNativeGLFWPane, useNativeOpenALPane); - workaroundPane.getContent().add(unsupportedOptionsSublist); - } - } - - rootPane.getChildren().addAll( - ComponentList.createComponentListTitle(i18n("settings.advanced.custom_commands")), customCommandsPane, - ComponentList.createComponentListTitle(i18n("settings.advanced.jvm")), jvmPane, - ComponentList.createComponentListTitle(i18n("settings.advanced.workaround")), workaroundWarning, workaroundPane - ); - - bindProperties(); - } - - void bindProperties() { - nativesDirCustomOption.bindBidirectional(versionSetting.nativesDirProperty()); - FXUtils.bindString(txtJVMArgs, versionSetting.javaArgsProperty()); - FXUtils.bindString(txtGameArgs, versionSetting.minecraftArgsProperty()); - FXUtils.bindString(txtMetaspace, versionSetting.permSizeProperty()); - FXUtils.bindString(txtEnvironmentVariables, versionSetting.environmentVariablesProperty()); - FXUtils.bindString(txtWrapper, versionSetting.wrapperProperty()); - FXUtils.bindString(txtPreLaunchCommand, versionSetting.preLaunchCommandProperty()); - FXUtils.bindString(txtPostExitCommand, versionSetting.postExitCommandProperty()); - rendererPane.valueProperty().bindBidirectional(versionSetting.rendererProperty()); - graphicsBackendPane.valueProperty().bindBidirectional(versionSetting.graphicsBackendProperty()); - noGameCheckPane.selectedProperty().bindBidirectional(versionSetting.notCheckGameProperty()); - noJVMCheckPane.selectedProperty().bindBidirectional(versionSetting.notCheckJVMProperty()); - noJVMArgsPane.selectedProperty().bindBidirectional(versionSetting.noJVMArgsProperty()); - noOptimizingJVMArgsPane.selectedProperty().bindBidirectional(versionSetting.noOptimizingJVMArgsProperty()); - noNativesPatchPane.selectedProperty().bindBidirectional(versionSetting.notPatchNativesProperty()); - useNativeGLFWPane.selectedProperty().bindBidirectional(versionSetting.useNativeGLFWProperty()); - useNativeOpenALPane.selectedProperty().bindBidirectional(versionSetting.useNativeOpenALProperty()); - - nativesDirItem.selectedDataProperty().bindBidirectional(versionSetting.nativesDirTypeProperty()); - nativesDirSublist.subtitleProperty().bind(Bindings.createStringBinding(() -> { - if (versionSetting.getNativesDirType() == NativesDirectoryType.VERSION_FOLDER) { - String nativesDirName = "natives-" + Platform.SYSTEM_PLATFORM; - if (versionId == null) { - return String.join(FileSystems.getDefault().getSeparator(), - FileUtils.getAbsolutePath(profile.getRepository().getBaseDirectory().resolve("versions")), - i18n("settings.advanced.natives_directory.default.version_id"), - nativesDirName - ); - } else { - return profile.getRepository().getVersionRoot(versionId) - .toAbsolutePath().normalize() - .resolve(nativesDirName) - .toString(); - } - } else if (versionSetting.getNativesDirType() == NativesDirectoryType.CUSTOM) { - return versionSetting.getNativesDir(); - } else { - return null; - } - }, versionSetting.nativesDirProperty(), versionSetting.nativesDirTypeProperty())); - } - - void unbindProperties() { - nativesDirCustomOption.valueProperty().unbindBidirectional(versionSetting.nativesDirProperty()); - FXUtils.unbind(txtJVMArgs, versionSetting.javaArgsProperty()); - FXUtils.unbind(txtGameArgs, versionSetting.minecraftArgsProperty()); - FXUtils.unbind(txtMetaspace, versionSetting.permSizeProperty()); - FXUtils.unbind(txtEnvironmentVariables, versionSetting.environmentVariablesProperty()); - FXUtils.unbind(txtWrapper, versionSetting.wrapperProperty()); - FXUtils.unbind(txtPreLaunchCommand, versionSetting.preLaunchCommandProperty()); - FXUtils.unbind(txtPostExitCommand, versionSetting.postExitCommandProperty()); - rendererPane.valueProperty().unbindBidirectional(versionSetting.rendererProperty()); - graphicsBackendPane.valueProperty().unbindBidirectional(versionSetting.graphicsBackendProperty()); - noGameCheckPane.selectedProperty().unbindBidirectional(versionSetting.notCheckGameProperty()); - noJVMCheckPane.selectedProperty().unbindBidirectional(versionSetting.notCheckJVMProperty()); - noJVMArgsPane.selectedProperty().unbindBidirectional(versionSetting.noJVMArgsProperty()); - noOptimizingJVMArgsPane.selectedProperty().unbindBidirectional(versionSetting.noOptimizingJVMArgsProperty()); - noNativesPatchPane.selectedProperty().unbindBidirectional(versionSetting.notPatchNativesProperty()); - useNativeGLFWPane.selectedProperty().unbindBidirectional(versionSetting.useNativeGLFWProperty()); - useNativeOpenALPane.selectedProperty().unbindBidirectional(versionSetting.useNativeOpenALProperty()); - - nativesDirItem.selectedDataProperty().unbindBidirectional(versionSetting.nativesDirTypeProperty()); - nativesDirSublist.subtitleProperty().unbind(); - } - - @Override - public ReadOnlyObjectProperty stateProperty() { - return stateProperty; - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadListPage.java index d73f1164ee0..68ee84714ba 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadListPage.java @@ -44,6 +44,7 @@ import org.jackhuang.hmcl.mod.modrinth.ModrinthRemoteModRepository; import org.jackhuang.hmcl.setting.DownloadProviders; import org.jackhuang.hmcl.setting.Profile; +import org.jackhuang.hmcl.setting.Profiles; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.Controllers; @@ -125,7 +126,7 @@ public void loadVersion(Profile profile, String version) { versions.setAll(profile.getRepository().getDisplayVersions() .map(Version::getId) .collect(Collectors.toList())); - selectedVersion.set(profile.getSelectedVersion()); + selectedVersion.set(Profiles.getSelectedInstance(profile)); } } @@ -165,11 +166,11 @@ private void search(String userGameVersion, RemoteModRepository.Category categor int currentSearchID = searchID = searchID + 1; Task.supplyAsync(() -> { Profile.ProfileVersion version = this.version.get(); - if (StringUtils.isBlank(version.getVersion())) { + if (StringUtils.isBlank(version.version())) { return userGameVersion; } else { - return StringUtils.isNotBlank(version.getVersion()) - ? version.getProfile().getRepository().getGameVersion(version.getVersion()).orElse("") + return StringUtils.isNotBlank(version.version()) + ? version.profile().getRepository().getGameVersion(version.version()).orElse("") : ""; } }).thenApplyAsync( @@ -219,7 +220,7 @@ protected String getLocalizedOfficialPage() { protected Profile.ProfileVersion getProfileVersion() { if (versionSelection) { - return new Profile.ProfileVersion(version.get().getProfile(), selectedVersion.get()); + return new Profile.ProfileVersion(version.get().profile(), selectedVersion.get()); } else { return version.get(); } @@ -320,7 +321,7 @@ protected ModDownloadListPageSkin(DownloadListPage control) { searchPane.addRow(rowIndex++, new Label(i18n("mods.name")), nameField, lblGameVersion, gameVersionField); ObjectBinding hasVersion = BindingMapping.of(getSkinnable().version) - .map(version -> version.getVersion() == null); + .map(version -> version.version() == null); lblGameVersion.managedProperty().bind(hasVersion); lblGameVersion.visibleProperty().bind(hasVersion); gameVersionField.managedProperty().bind(hasVersion); @@ -328,7 +329,7 @@ protected ModDownloadListPageSkin(DownloadListPage control) { FXUtils.installFastTooltip(gameVersionField, i18n("search.enter")); FXUtils.onChangeAndOperate(getSkinnable().version, version -> { - if (StringUtils.isNotBlank(version.getVersion())) { + if (StringUtils.isNotBlank(version.version())) { GridPane.setColumnSpan(nameField, 3); } else { GridPane.setColumnSpan(nameField, 1); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadPage.java index d65bba001bd..54a7d039b74 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadPage.java @@ -163,7 +163,7 @@ public void download(RemoteMod mod, RemoteMod.Version file) { if (this.callback == null) { saveAs(file); } else { - this.callback.download(page.getDownloadProvider(), version.getProfile(), version.getVersion(), mod, file); + this.callback.download(page.getDownloadProvider(), version.profile(), version.version(), mod, file); } } @@ -272,9 +272,9 @@ protected ModDownloadPageSkin(DownloadPage control) { FXUtils.onChangeAndOperate(control.loaded, loaded -> { if (control.versions == null) return; - if (control.version.getProfile() != null && control.version.getVersion() != null) { - HMCLGameRepository repository = control.version.getProfile().getRepository(); - Version game = repository.getResolvedPreservingPatchesVersion(control.version.getVersion()); + if (control.version.profile() != null && control.version.version() != null) { + HMCLGameRepository repository = control.version.profile().getRepository(); + Version game = repository.getResolvedPreservingPatchesVersion(control.version.version()); String gameVersion = repository.getGameVersion(game).orElse(null); if (gameVersion != null && control.versions.containsKey(gameVersion)) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameAdvancedListItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameAdvancedListItem.java index d333468bc04..9a32b5017d6 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameAdvancedListItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameAdvancedListItem.java @@ -52,7 +52,7 @@ private void loadVersion(String version) { profile = Profiles.getSelectedProfile(); if (profile != null) { onVersionIconChangedListener = profile.getRepository().onVersionIconChanged.registerWeak(event -> { - this.loadVersion(Profiles.getSelectedVersion()); + this.loadVersion(Profiles.getSelectedInstance()); }); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListCell.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListCell.java index 4938332eac0..3c242cb2815 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListCell.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListCell.java @@ -29,6 +29,7 @@ import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import javafx.scene.layout.Region; +import org.jackhuang.hmcl.setting.Profiles; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.ui.construct.*; @@ -69,7 +70,7 @@ public void fire() { fireEvent(new ActionEvent()); GameListItem item = GameListCell.this.getItem(); if (item != null) { - item.getProfile().setSelectedVersion(item.getId()); + Profiles.setSelectedInstance(item.getProfile(), item.getId()); } } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListItem.java index 8151215a5ac..fb76ed753ea 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListItem.java @@ -17,10 +17,14 @@ */ package org.jackhuang.hmcl.ui.versions; +import javafx.beans.binding.Bindings; import javafx.beans.property.BooleanProperty; import javafx.beans.property.ReadOnlyBooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import org.jackhuang.hmcl.setting.Profile; +import org.jackhuang.hmcl.setting.Profiles; + +import java.util.Objects; public class GameListItem extends GameItem { private final boolean isModpack; @@ -29,7 +33,10 @@ public class GameListItem extends GameItem { public GameListItem(Profile profile, String id) { super(profile, id); this.isModpack = profile.getRepository().isModpack(id); - selected.bind(profile.selectedVersionProperty().isEqualTo(id)); + selected.bind(Bindings.createBooleanBinding( + () -> profile == Profiles.getSelectedProfile() && Objects.equals(Profiles.getSelectedInstance(profile), id), + Profiles.selectedProfileProperty(), + Profiles.selectedVersionProperty())); } public ReadOnlyBooleanProperty selectedProperty() { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListPopupMenu.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListPopupMenu.java index 4ea1b832b34..c25fcea7b15 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListPopupMenu.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/GameListPopupMenu.java @@ -35,6 +35,7 @@ import javafx.scene.layout.StackPane; import org.jackhuang.hmcl.game.Version; import org.jackhuang.hmcl.setting.Profile; +import org.jackhuang.hmcl.setting.Profiles; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.construct.ImageContainer; import org.jackhuang.hmcl.ui.construct.RipplerContainer; @@ -127,7 +128,7 @@ public Cell(ListView listView) { FXUtils.onClicked(rootPane, () -> { GameItem item = getItem(); if (item != null) { - item.getProfile().setSelectedVersion(item.getId()); + Profiles.setSelectedInstance(item.getProfile(), item.getId()); if (getScene().getWindow() instanceof JFXPopup popup) popup.hide(); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/HMCLLocalizedDownloadListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/HMCLLocalizedDownloadListPage.java index 9b458b52786..9392d84b361 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/HMCLLocalizedDownloadListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/HMCLLocalizedDownloadListPage.java @@ -26,7 +26,7 @@ import java.util.MissingResourceException; -import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.setting.SettingsManager.settings; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -79,7 +79,7 @@ private HMCLLocalizedDownloadListPage(DownloadPage.DownloadCallback callback, bo downloadSources.add("mods.curseforge"); } - if ("curseforge".equalsIgnoreCase(config().getDefaultAddonSource())) { + if ("curseforge".equalsIgnoreCase(settings().defaultAddonSourceProperty().get())) { if (supportedCurseForge) { downloadSource.set("mods.curseforge"); } else if (modrinth != null) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ResourcePackListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ResourcePackListPage.java index 120c1f3f7b9..25d86186035 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ResourcePackListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ResourcePackListPage.java @@ -42,7 +42,7 @@ import org.jackhuang.hmcl.mod.modrinth.ModrinthRemoteModRepository; import org.jackhuang.hmcl.resourcepack.ResourcePackFile; import org.jackhuang.hmcl.resourcepack.ResourcePackManager; -import org.jackhuang.hmcl.setting.ConfigHolder; +import org.jackhuang.hmcl.setting.SettingsManager; import org.jackhuang.hmcl.setting.DownloadProviders; import org.jackhuang.hmcl.setting.Profile; import org.jackhuang.hmcl.task.Schedulers; @@ -194,13 +194,13 @@ private void onOpenFolder() { } private void setSelectedEnabled(List selectedItems, boolean enabled) { - if (!Boolean.TRUE.equals(ConfigHolder.config().getShownTips().get(TIP_KEY)) && enabled && !selectedItems.stream().map(ResourcePackInfoObject::getFile).allMatch(ResourcePackFile::isCompatible)) { + if (!Boolean.TRUE.equals(SettingsManager.state().getShownTips().get(TIP_KEY)) && enabled && !selectedItems.stream().map(ResourcePackInfoObject::getFile).allMatch(ResourcePackFile::isCompatible)) { Controllers.confirm( i18n("resourcepack.warning.manipulate"), i18n("message.warning"), MessageDialogPane.MessageType.WARNING, () -> { - ConfigHolder.config().getShownTips().put(TIP_KEY, true); + SettingsManager.state().getShownTips().put(TIP_KEY, true); setSelectedEnabled(selectedItems, true); }, null); } else { @@ -493,14 +493,14 @@ public ResourcePackListCell(JFXListView listView, Resour checkBox = new JFXCheckBox() { @Override public void fire() { - if (!Boolean.TRUE.equals(ConfigHolder.config().getShownTips().get(TIP_KEY)) && !isSelected() && object != null && !object.getFile().isCompatible()) { + if (!Boolean.TRUE.equals(SettingsManager.state().getShownTips().get(TIP_KEY)) && !isSelected() && object != null && !object.getFile().isCompatible()) { Controllers.confirm( i18n("resourcepack.warning.manipulate"), i18n("message.info"), MessageDialogPane.MessageType.INFO, () -> { super.fire(); - ConfigHolder.config().getShownTips().put(TIP_KEY, true); + SettingsManager.state().getShownTips().put(TIP_KEY, true); }, null); } else { super.fire(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionIconDialog.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionIconDialog.java index a48fc63a090..f5ae311a312 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionIconDialog.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionIconDialog.java @@ -22,9 +22,9 @@ import javafx.scene.layout.FlowPane; import javafx.stage.FileChooser; import org.jackhuang.hmcl.event.Event; +import org.jackhuang.hmcl.setting.GameSettings; import org.jackhuang.hmcl.setting.Profile; import org.jackhuang.hmcl.setting.VersionIconType; -import org.jackhuang.hmcl.setting.VersionSetting; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; @@ -42,13 +42,13 @@ public class VersionIconDialog extends DialogPane { private final Profile profile; private final String versionId; private final Runnable onFinish; - private final VersionSetting vs; + private final GameSettings.Instance setting; public VersionIconDialog(Profile profile, String versionId, Runnable onFinish) { this.profile = profile; this.versionId = versionId; this.onFinish = onFinish; - this.vs = profile.getRepository().getLocalVersionSettingOrCreate(versionId); + this.setting = profile.getRepository().getLocalGameSettingsOrCreate(versionId); setTitle(i18n("settings.icon")); FlowPane pane = new FlowPane(); @@ -81,8 +81,8 @@ private void exploreIcon() { try { profile.getRepository().setVersionIconFile(versionId, selectedFile); - if (vs != null) { - vs.setVersionIcon(VersionIconType.DEFAULT); + if (setting != null) { + setting.iconProperty().setValue(VersionIconType.DEFAULT); } onAccept(); @@ -109,8 +109,8 @@ private Node createIcon(VersionIconType type) { FXUtils.setLimitWidth(container, 36); FXUtils.setLimitHeight(container, 36); FXUtils.onClicked(container, () -> { - if (vs != null) { - vs.setVersionIcon(type); + if (setting != null) { + setting.iconProperty().setValue(type); onAccept(); } }); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionPage.java index 4d9cc87d409..62f144fee79 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionPage.java @@ -30,6 +30,7 @@ import org.jackhuang.hmcl.event.EventPriority; import org.jackhuang.hmcl.event.RefreshedVersionsEvent; import org.jackhuang.hmcl.game.GameRepository; +import org.jackhuang.hmcl.setting.GameSettings; import org.jackhuang.hmcl.setting.Profile; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; @@ -41,6 +42,7 @@ import org.jackhuang.hmcl.ui.construct.*; import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage; import org.jackhuang.hmcl.ui.decorator.DecoratorPage; +import org.jackhuang.hmcl.ui.game.GameSettingsPage; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.io.FileUtils; @@ -54,7 +56,7 @@ public class VersionPage extends DecoratorAnimatedPage implements DecoratorPage { private final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(); private final TabHeader tab; - private final TabHeader.Tab versionSettingsTab = new TabHeader.Tab<>("versionSettingsTab"); + private final TabHeader.Tab> versionSettingsTab = new TabHeader.Tab<>("versionSettingsTab"); private final TabHeader.Tab installerListTab = new TabHeader.Tab<>("installerListTab"); private final TabHeader.Tab modListTab = new TabHeader.Tab<>("modListTab"); private final TabHeader.Tab worldListTab = new TabHeader.Tab<>("worldList"); @@ -69,13 +71,14 @@ public class VersionPage extends DecoratorAnimatedPage implements DecoratorPage public static class WorkingDirChangedEvent extends Event { public static final EventType EVENT_TYPE = new EventType<>(Event.ANY, "WORKING_DIR_CHANGED"); + public WorkingDirChangedEvent() { super(EVENT_TYPE); } } public VersionPage() { - versionSettingsTab.setNodeSupplier(loadVersionFor(() -> new VersionSettingsPage(false))); + versionSettingsTab.setNodeSupplier(loadVersionFor(() -> new GameSettingsPage<>(GameSettings.Instance.class))); installerListTab.setNodeSupplier(loadVersionFor(InstallerListPage::new)); modListTab.setNodeSupplier(loadVersionFor(ModListPage::new)); resourcePackTab.setNodeSupplier(loadVersionFor(ResourcePackListPage::new)); @@ -101,17 +104,17 @@ public VersionPage() { schematicsTab.getNode().loadVersion(getProfile(), getVersion()); } }); - + listenerHolder.add(EventBus.EVENT_BUS.channel(RefreshedVersionsEvent.class).registerWeak(event -> checkSelectedVersion(), EventPriority.HIGHEST)); } private void checkSelectedVersion() { runInFX(() -> { if (this.version.get() == null) return; - GameRepository repository = this.version.get().getProfile().getRepository(); - if (!repository.hasVersion(this.version.get().getVersion())) { + GameRepository repository = this.version.get().profile().getRepository(); + if (!repository.hasVersion(this.version.get().version())) { if (preferredVersionName != null) { - loadVersion(preferredVersionName, this.version.get().getProfile()); + loadVersion(preferredVersionName, this.version.get().profile()); } else { fireEvent(new PageCloseEvent()); } @@ -124,7 +127,7 @@ private Supplier loadVersionFor(Supplier nodeSupplier) { T node = nodeSupplier.get(); if (version.get() != null) { if (node instanceof VersionPage.VersionLoadable) { - ((VersionLoadable) node).loadVersion(version.get().getProfile(), version.get().getVersion()); + ((VersionLoadable) node).loadVersion(version.get().profile(), version.get().version()); } } return node; @@ -205,7 +208,7 @@ private void clearAssets() { Profile.ProfileVersion currentVersion = version.get(); Path resourcesDir = currentVersion != null - ? getProfile().getRepository().getRunDirectory(currentVersion.getVersion()).resolve("resources") + ? getProfile().getRepository().getRunDirectory(currentVersion.version()).resolve("resources") : null; Task.runAsync(Schedulers.io(), () -> { @@ -254,11 +257,11 @@ private void duplicate() { } public Profile getProfile() { - return Optional.ofNullable(version.get()).map(Profile.ProfileVersion::getProfile).orElse(null); + return Optional.ofNullable(version.get()).map(Profile.ProfileVersion::profile).orElse(null); } public String getVersion() { - return Optional.ofNullable(version.get()).map(Profile.ProfileVersion::getVersion).orElse(null); + return Optional.ofNullable(version.get()).map(Profile.ProfileVersion::version).orElse(null); } @Override diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionSettingsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionSettingsPage.java deleted file mode 100644 index a070e815da0..00000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionSettingsPage.java +++ /dev/null @@ -1,762 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.ui.versions; - -import com.jfoenix.controls.*; -import javafx.beans.InvalidationListener; -import javafx.beans.binding.Bindings; -import javafx.beans.property.*; -import javafx.beans.value.ChangeListener; -import javafx.geometry.HPos; -import javafx.geometry.Insets; -import javafx.geometry.Pos; -import javafx.geometry.Rectangle2D; -import javafx.scene.control.Label; -import javafx.scene.control.ScrollPane; -import javafx.scene.control.Toggle; -import javafx.scene.layout.*; -import javafx.scene.text.Text; -import javafx.stage.FileChooser; -import javafx.stage.Screen; -import org.jackhuang.hmcl.game.GameDirectoryType; -import org.jackhuang.hmcl.game.HMCLGameRepository; -import org.jackhuang.hmcl.game.ProcessPriority; -import org.jackhuang.hmcl.game.Version; -import org.jackhuang.hmcl.java.JavaManager; -import org.jackhuang.hmcl.java.JavaRuntime; -import org.jackhuang.hmcl.setting.*; -import org.jackhuang.hmcl.ui.Controllers; -import org.jackhuang.hmcl.ui.FXUtils; -import org.jackhuang.hmcl.ui.MemoryStatusBar; -import org.jackhuang.hmcl.ui.WeakListenerHolder; -import org.jackhuang.hmcl.ui.construct.*; -import org.jackhuang.hmcl.ui.decorator.DecoratorPage; -import org.jackhuang.hmcl.util.*; -import org.jackhuang.hmcl.util.i18n.I18n; -import org.jackhuang.hmcl.util.javafx.BindingMapping; -import org.jackhuang.hmcl.util.javafx.PropertyUtils; -import org.jackhuang.hmcl.util.javafx.SafeStringConverter; -import org.jackhuang.hmcl.util.platform.Architecture; -import org.jackhuang.hmcl.util.platform.OperatingSystem; -import org.jackhuang.hmcl.util.platform.SystemInfo; -import org.jackhuang.hmcl.util.platform.hardware.PhysicalMemoryStatus; -import org.jackhuang.hmcl.util.versioning.GameVersionNumber; - -import java.util.*; -import java.util.concurrent.atomic.AtomicBoolean; - -import static org.jackhuang.hmcl.util.DataSizeUnit.GIGABYTES; -import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES; -import static org.jackhuang.hmcl.util.Pair.pair; -import static org.jackhuang.hmcl.util.i18n.I18n.i18n; - -public final class VersionSettingsPage extends StackPane implements DecoratorPage, VersionPage.VersionLoadable, PageAware { - - private final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(new State("", null, false, false, false)); - - private AdvancedVersionSettingPage advancedVersionSettingPage; - - private VersionSetting lastVersionSetting = null; - private Profile profile; - private WeakListenerHolder listenerHolder; - private String versionId; - - private final VBox rootPane; - private final JFXComboBox cboWindowsSize; - private final JFXTextField txtServerIP; - private final ComponentList componentList; - private final LineSelectButton launcherVisibilityPane; - private final JFXCheckBox chkAutoAllocate; - private final JFXCheckBox chkFullscreen; - private final ComponentSublist javaSublist; - private final MultiFileItem> javaItem; - private final MultiFileItem.Option> javaAutoDeterminedOption; - private final MultiFileItem.StringOption> javaVersionOption; - private final MultiFileItem.FileOption> javaCustomOption; - - private final ComponentSublist gameDirSublist; - private final MultiFileItem gameDirItem; - private final MultiFileItem.FileOption gameDirCustomOption; - private final LineSelectButton processPriorityPane; - private final LineToggleButton showLogsPane; - private final LineToggleButton enableDebugLogOutputPane; - private final ImagePickerItem iconPickerItem; - - private final ChangeListener> javaListChangeListener; - private final InvalidationListener usesGlobalListener; - private final ChangeListener specificSettingsListener; - private final InvalidationListener javaListener = any -> initJavaSubtitle(); - - private final ChangeListener gameDirTypeListener = (ob, o, n) -> fireWorkingDirChanged(); - private final ChangeListener gameDirListener = (ob, o, n) -> fireWorkingDirChanged(); - - private boolean updatingJavaSetting = false; - private boolean updatingSelectedJava = false; - - private final StringProperty selectedVersion = new SimpleStringProperty(); - private final BooleanProperty navigateToSpecificSettings = new SimpleBooleanProperty(false); - private final BooleanProperty enableSpecificSettings = new SimpleBooleanProperty(false); - private final IntegerProperty maxMemory = new SimpleIntegerProperty(); - private final BooleanProperty modpack = new SimpleBooleanProperty(); - - public VersionSettingsPage(boolean globalSetting) { - ScrollPane scrollPane = new ScrollPane(); - scrollPane.setFitToHeight(true); - scrollPane.setFitToWidth(true); - scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); - getChildren().setAll(scrollPane); - - rootPane = new VBox(); - rootPane.setFillWidth(true); - scrollPane.setContent(rootPane); - FXUtils.smoothScrolling(scrollPane); - rootPane.getStyleClass().add("card-list"); - - if (globalSetting) { - HintPane specificSettingsHint = new HintPane(MessageDialogPane.MessageType.WARNING); - Text text = new Text(); - text.textProperty().bind(BindingMapping.of(selectedVersion) - .map(selectedVersion -> i18n("settings.type.special.edit.hint", selectedVersion))); - - JFXHyperlink specificSettingsLink = new JFXHyperlink(i18n("settings.type.special.edit")); - specificSettingsLink.setOnAction(e -> editSpecificSettings()); - - specificSettingsHint.setChildren(text, specificSettingsLink); - specificSettingsHint.managedProperty().bind(navigateToSpecificSettings); - specificSettingsHint.visibleProperty().bind(navigateToSpecificSettings); - - iconPickerItem = null; - - rootPane.getChildren().addAll(specificSettingsHint); - } else { - HintPane gameDirHint = new HintPane(MessageDialogPane.MessageType.INFO); - gameDirHint.setText(i18n("settings.game.working_directory.hint")); - rootPane.getChildren().add(gameDirHint); - - ComponentList iconPickerItemWrapper = new ComponentList(); - rootPane.getChildren().add(iconPickerItemWrapper); - - iconPickerItem = new ImagePickerItem(); - iconPickerItem.setImage(FXUtils.newBuiltinImage("/assets/img/icon.png")); - iconPickerItem.setTitle(i18n("settings.icon")); - iconPickerItem.setOnSelectButtonClicked(e -> onExploreIcon()); - iconPickerItem.setOnDeleteButtonClicked(e -> onDeleteIcon()); - iconPickerItemWrapper.getContent().setAll(iconPickerItem); - - BorderPane settingsTypePane = new BorderPane(); - settingsTypePane.disableProperty().bind(modpack); - rootPane.getChildren().add(settingsTypePane); - - JFXCheckBox enableSpecificCheckBox = new JFXCheckBox(); - enableSpecificCheckBox.selectedProperty().bindBidirectional(enableSpecificSettings); - settingsTypePane.setLeft(enableSpecificCheckBox); - enableSpecificCheckBox.setText(i18n("settings.type.special.enable")); - BorderPane.setAlignment(enableSpecificCheckBox, Pos.CENTER_RIGHT); - - JFXButton editGlobalSettingsButton = FXUtils.newRaisedButton(i18n("settings.type.global.edit")); - settingsTypePane.setRight(editGlobalSettingsButton); - editGlobalSettingsButton.disableProperty().bind(enableSpecificCheckBox.selectedProperty()); - BorderPane.setAlignment(editGlobalSettingsButton, Pos.CENTER_RIGHT); - editGlobalSettingsButton.setOnAction(e -> editGlobalSettings()); - } - - { - componentList = new ComponentList(); - - if (!globalSetting) { - var copyGlobalButton = LineButton.createNavigationButton(); - copyGlobalButton.setTitle(i18n("settings.game.copy_global")); - copyGlobalButton.setOnAction(event -> - Controllers.confirm(i18n("settings.game.copy_global.copy_all.confirm"), null, () -> { - Set ignored = new HashSet<>(Arrays.asList( - "usesGlobal", - "versionIcon" - )); - - PropertyUtils.copyProperties(profile.getGlobal(), lastVersionSetting, name -> !ignored.contains(name)); - }, null)); - - componentList.getContent().add(copyGlobalButton); - } - - javaItem = new MultiFileItem<>(); - javaSublist = new ComponentSublist(); - javaSublist.getContent().add(javaItem); - javaSublist.setTitle(i18n("settings.game.java_directory")); - javaSublist.setHasSubtitle(true); - javaAutoDeterminedOption = new MultiFileItem.Option<>(i18n("settings.game.java_directory.auto"), pair(JavaVersionType.AUTO, null)); - javaVersionOption = new MultiFileItem.StringOption<>(i18n("settings.game.java_directory.version"), pair(JavaVersionType.VERSION, null)); - javaVersionOption.setValidators(new NumberValidator(true)); - FXUtils.setLimitWidth(javaVersionOption.getCustomField(), 40); - javaCustomOption = new MultiFileItem.FileOption>(i18n("settings.custom"), pair(JavaVersionType.CUSTOM, null)) - .setChooserTitle(i18n("settings.game.java_directory.choose")); - if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) - javaCustomOption.addExtensionFilter(new FileChooser.ExtensionFilter("Java", "java.exe")); - - javaListChangeListener = FXUtils.onWeakChangeAndOperate(JavaManager.getAllJavaProperty(), allJava -> { - List>> options = new ArrayList<>(); - options.add(javaAutoDeterminedOption); - options.add(javaVersionOption); - if (allJava != null) { - boolean isX86 = Architecture.SYSTEM_ARCH.isX86() && allJava.stream().allMatch(java -> java.getArchitecture().isX86()); - - for (JavaRuntime java : allJava) { - options.add(new MultiFileItem.Option<>( - i18n("settings.game.java_directory.template", - java.getVersion(), - isX86 ? i18n("settings.game.java_directory.bit", java.getBits().getBit()) - : java.getPlatform().getArchitecture().getDisplayName()), - pair(JavaVersionType.DETECTED, java)) - .setSubtitle(java.getBinary().toString())); - } - } - - options.add(javaCustomOption); - javaItem.loadChildren(options); - initializeSelectedJava(); - }); - - gameDirItem = new MultiFileItem<>(); - gameDirSublist = new ComponentSublist(); - gameDirSublist.getContent().add(gameDirItem); - gameDirSublist.setTitle(i18n("settings.game.working_directory")); - gameDirSublist.setHasSubtitle(versionId != null); - gameDirItem.disableProperty().bind(modpack); - gameDirCustomOption = new MultiFileItem.FileOption<>(i18n("settings.custom"), GameDirectoryType.CUSTOM) - .setChooserTitle(i18n("settings.game.working_directory.choose")) - .setSelectionMode(FileSelector.SelectionMode.DIRECTORY); - - gameDirItem.loadChildren(Arrays.asList( - new MultiFileItem.Option<>(i18n("settings.advanced.game_dir.default"), GameDirectoryType.ROOT_FOLDER), - new MultiFileItem.Option<>(i18n("settings.advanced.game_dir.independent"), GameDirectoryType.VERSION_FOLDER), - gameDirCustomOption - )); - - VBox maxMemoryPane = new VBox(8); - { - Label title = new Label(i18n("settings.memory")); - VBox.setMargin(title, new Insets(0, 0, 8, 0)); - - chkAutoAllocate = new JFXCheckBox(i18n("settings.memory.auto_allocate")); - VBox.setMargin(chkAutoAllocate, new Insets(0, 0, 8, 5)); - - HBox lowerBoundPane = new HBox(8); - lowerBoundPane.setStyle("-fx-view-order: -1;"); // prevent the indicator from being covered by the progress bar - lowerBoundPane.setAlignment(Pos.CENTER); - VBox.setMargin(lowerBoundPane, new Insets(0, 0, 0, 16)); - { - Label label = new Label(); - label.textProperty().bind(Bindings.createStringBinding(() -> { - if (chkAutoAllocate.isSelected()) { - return i18n("settings.memory.lower_bound"); - } else { - return i18n("settings.memory"); - } - }, chkAutoAllocate.selectedProperty())); - - JFXSlider slider = new JFXSlider(0, 1, 0); - HBox.setMargin(slider, new Insets(0, 0, 0, 8)); - HBox.setHgrow(slider, Priority.ALWAYS); - slider.setValueFactory(self -> Bindings.createStringBinding(() -> (int) (self.getValue() * 100) + "%", self.valueProperty())); - AtomicBoolean changedByTextField = new AtomicBoolean(false); - FXUtils.onChangeAndOperate(maxMemory, maxMemory -> { - changedByTextField.set(true); - slider.setValue(maxMemory.intValue() * 1.0 / MEGABYTES.convertFromBytes(SystemInfo.getTotalMemorySize())); - changedByTextField.set(false); - }); - slider.valueProperty().addListener((value, oldVal, newVal) -> { - if (changedByTextField.get()) return; - maxMemory.set((int) (value.getValue().doubleValue() * MEGABYTES.convertFromBytes(SystemInfo.getTotalMemorySize()))); - }); - - JFXTextField txtMaxMemory = new JFXTextField(); - FXUtils.setLimitWidth(txtMaxMemory, 60); - FXUtils.setValidateWhileTextChanged(txtMaxMemory, true); - txtMaxMemory.textProperty().bindBidirectional(maxMemory, SafeStringConverter.fromInteger()); - txtMaxMemory.setValidators(new NumberValidator(i18n("input.number"), false)); - - lowerBoundPane.getChildren().setAll(label, slider, txtMaxMemory, new Label(i18n("settings.memory.unit.mib"))); - } - - MemoryStatusBar memoryStatusBar = new MemoryStatusBar(); - VBox.setMargin(memoryStatusBar, new Insets(8, 0, 0, 16)); - memoryStatusBar.memoryAllocatedProperty().bind(Bindings.createDoubleBinding(() -> - (double) HMCLGameRepository.getAllocatedMemory( - maxMemory.get() * 1024L * 1024L, - memoryStatusBar.getMemoryStatus().getAvailable(), - chkAutoAllocate.isSelected()), - memoryStatusBar.memoryStatusProperty(), maxMemory, chkAutoAllocate.selectedProperty())); - - BorderPane digitalPane = new BorderPane(); - VBox.setMargin(digitalPane, new Insets(0, 0, 0, 16)); - { - Label lblPhysicalMemory = new Label(); - lblPhysicalMemory.getStyleClass().add("memory-label"); - digitalPane.setLeft(lblPhysicalMemory); - lblPhysicalMemory.textProperty().bind(Bindings.createStringBinding(() -> { - PhysicalMemoryStatus memoryStatus = memoryStatusBar.getMemoryStatus(); - return i18n("settings.memory.used_per_total", - GIGABYTES.convertFromBytes(memoryStatus.getUsed()), - GIGABYTES.convertFromBytes(memoryStatus.getTotal())); - }, memoryStatusBar.memoryStatusProperty())); - - Label lblAllocateMemory = new Label(); - lblAllocateMemory.textProperty().bind(Bindings.createStringBinding(() -> { - PhysicalMemoryStatus memoryStatus = memoryStatusBar.getMemoryStatus(); - long maxMemory = Lang.parseInt(this.maxMemory.get(), 0) * 1024L * 1024L; - return i18n(memoryStatus.hasAvailable() && maxMemory > memoryStatus.getAvailable() - ? (chkAutoAllocate.isSelected() ? "settings.memory.allocate.auto.exceeded" : "settings.memory.allocate.manual.exceeded") - : (chkAutoAllocate.isSelected() ? "settings.memory.allocate.auto" : "settings.memory.allocate.manual"), - GIGABYTES.convertFromBytes(maxMemory), - GIGABYTES.convertFromBytes(HMCLGameRepository.getAllocatedMemory(maxMemory, memoryStatus.getAvailable(), chkAutoAllocate.isSelected())), - GIGABYTES.convertFromBytes(memoryStatus.getAvailable())); - }, memoryStatusBar.memoryStatusProperty(), maxMemory, chkAutoAllocate.selectedProperty())); - lblAllocateMemory.getStyleClass().add("memory-label"); - digitalPane.setRight(lblAllocateMemory); - } - - maxMemoryPane.getChildren().setAll(title, chkAutoAllocate, lowerBoundPane, memoryStatusBar, digitalPane); - } - - launcherVisibilityPane = new LineSelectButton<>(); - launcherVisibilityPane.setTitle(i18n("settings.advanced.launcher_visible")); - launcherVisibilityPane.setItems(LauncherVisibility.values()); - launcherVisibilityPane.setNullSafeConverter(e -> i18n("settings.advanced.launcher_visibility." + e.name().toLowerCase(Locale.ROOT))); - - BorderPane dimensionPane = new BorderPane(); - { - Label label = new Label(i18n("settings.game.dimension")); - dimensionPane.setLeft(label); - BorderPane.setAlignment(label, Pos.CENTER_LEFT); - - BorderPane right = new BorderPane(); - dimensionPane.setRight(right); - { - cboWindowsSize = new JFXComboBox<>(); - cboWindowsSize.setPrefWidth(150); - right.setLeft(cboWindowsSize); - cboWindowsSize.setEditable(true); - cboWindowsSize.setPromptText("854x480"); - cboWindowsSize.getItems().setAll(getSupportedResolutions()); - - chkFullscreen = new JFXCheckBox(); - right.setRight(chkFullscreen); - chkFullscreen.setText(i18n("settings.game.fullscreen")); - chkFullscreen.setAlignment(Pos.CENTER); - BorderPane.setAlignment(chkFullscreen, Pos.CENTER); - BorderPane.setMargin(chkFullscreen, new Insets(0, 0, 0, 7)); - - cboWindowsSize.disableProperty().bind(chkFullscreen.selectedProperty()); - } - } - - showLogsPane = new LineToggleButton(); - showLogsPane.setTitle(i18n("settings.show_log")); - - enableDebugLogOutputPane = new LineToggleButton(); - enableDebugLogOutputPane.setTitle(i18n("settings.enable_debug_log_output")); - processPriorityPane = new LineSelectButton<>(); - processPriorityPane.setTitle(i18n("settings.advanced.process_priority")); - processPriorityPane.setNullSafeConverter(e -> i18n("settings.advanced.process_priority." + e.name().toLowerCase(Locale.ROOT))); - processPriorityPane.setDescriptionConverter(e -> { - String bundleKey = "settings.advanced.process_priority." + e.name().toLowerCase(Locale.ROOT) + ".desc"; - return I18n.hasKey(bundleKey) ? i18n(bundleKey) : null; - }); - processPriorityPane.setItems(ProcessPriority.values()); - - GridPane serverPane = new GridPane(); - { - ColumnConstraints title = new ColumnConstraints(); - ColumnConstraints value = new ColumnConstraints(); - value.setFillWidth(true); - value.setHgrow(Priority.ALWAYS); - value.setHalignment(HPos.RIGHT); - serverPane.setHgap(16); - serverPane.setVgap(8); - serverPane.getColumnConstraints().setAll(title, value); - - txtServerIP = new JFXTextField(); - txtServerIP.setPromptText(i18n("settings.advanced.server_ip.prompt")); - Validator.addTo(txtServerIP).accept(str -> { - if (StringUtils.isBlank(str)) - return true; - try { - ServerAddress.parse(str); - return true; - } catch (Exception ignored) { - return false; - } - }); - FXUtils.setLimitWidth(txtServerIP, 300); - serverPane.addRow(0, new Label(i18n("settings.advanced.server_ip")), txtServerIP); - } - - var showAdvancedSettingPane = LineButton.createNavigationButton(); - showAdvancedSettingPane.setTitle(i18n("settings.advanced")); - showAdvancedSettingPane.setOnAction(event -> { - if (lastVersionSetting != null) { - if (advancedVersionSettingPage == null) - advancedVersionSettingPage = new AdvancedVersionSettingPage(profile, versionId, lastVersionSetting); - - Controllers.navigateForward(advancedVersionSettingPage); - } - }); - - componentList.getContent().addAll( - javaSublist, - gameDirSublist, - maxMemoryPane, - launcherVisibilityPane, - dimensionPane, - showLogsPane, - enableDebugLogOutputPane, - processPriorityPane, - serverPane, - showAdvancedSettingPane - ); - } - - rootPane.getChildren().add(componentList); - - usesGlobalListener = any -> enableSpecificSettings.set(!lastVersionSetting.isUsesGlobal()); - specificSettingsListener = (a, b, newValue) -> { - if (versionId == null) return; - - // do not call versionSettings.setUsesGlobal(true/false) - // because versionSettings can be the global one. - // global versionSettings.usesGlobal is always true. - if (newValue) - profile.getRepository().specializeVersionSetting(versionId); - else - profile.getRepository().globalizeVersionSetting(versionId); - - FXUtils.runInFX(() -> { - loadVersion(profile, versionId); - fireWorkingDirChanged(); - }); - }; - - addEventHandler(Navigator.NavigationEvent.NAVIGATED, this::onDecoratorPageNavigating); - - componentList.disableProperty().bind(enableSpecificSettings.not()); - } - - private void fireWorkingDirChanged() { - FXUtils.runInFX(() -> fireEvent(new VersionPage.WorkingDirChangedEvent())); - } - - @Override - public void loadVersion(Profile profile, String versionId) { - this.profile = profile; - this.versionId = versionId; - this.listenerHolder = new WeakListenerHolder(); - - if (versionId == null) { - enableSpecificSettings.set(true); - state.set(State.fromTitle(Profiles.getProfileDisplayName(profile) + " - " + i18n("settings.type.global.manage"))); - - listenerHolder.add(FXUtils.onWeakChangeAndOperate(profile.selectedVersionProperty(), selectedVersion -> { - this.selectedVersion.setValue(selectedVersion); - - VersionSetting specializedVersionSetting = profile.getRepository().getLocalVersionSetting(selectedVersion); - if (specializedVersionSetting != null) { - navigateToSpecificSettings.bind(specializedVersionSetting.usesGlobalProperty().not()); - } else { - navigateToSpecificSettings.unbind(); - navigateToSpecificSettings.set(false); - } - })); - } else { - navigateToSpecificSettings.unbind(); - navigateToSpecificSettings.set(false); - } - - VersionSetting versionSetting = profile.getVersionSetting(versionId); - - modpack.set(versionId != null && profile.getRepository().isModpack(versionId)); - - // unbind data fields - if (lastVersionSetting != null) { - FXUtils.unbindWindowsSize(cboWindowsSize, lastVersionSetting.widthProperty(), lastVersionSetting.heightProperty()); - maxMemory.unbindBidirectional(lastVersionSetting.maxMemoryProperty()); - javaCustomOption.valueProperty().unbindBidirectional(lastVersionSetting.javaDirProperty()); - gameDirCustomOption.valueProperty().unbindBidirectional(lastVersionSetting.gameDirProperty()); - FXUtils.unbind(txtServerIP, lastVersionSetting.serverIpProperty()); - chkAutoAllocate.selectedProperty().unbindBidirectional(lastVersionSetting.autoMemoryProperty()); - chkFullscreen.selectedProperty().unbindBidirectional(lastVersionSetting.fullscreenProperty()); - showLogsPane.selectedProperty().unbindBidirectional(lastVersionSetting.showLogsProperty()); - enableDebugLogOutputPane.selectedProperty().unbindBidirectional(lastVersionSetting.enableDebugLogOutputProperty()); - launcherVisibilityPane.valueProperty().unbindBidirectional(lastVersionSetting.launcherVisibilityProperty()); - processPriorityPane.valueProperty().unbindBidirectional(lastVersionSetting.processPriorityProperty()); - - lastVersionSetting.usesGlobalProperty().removeListener(usesGlobalListener); - lastVersionSetting.javaVersionTypeProperty().removeListener(javaListener); - lastVersionSetting.javaDirProperty().removeListener(javaListener); - lastVersionSetting.defaultJavaPathPropertyProperty().removeListener(javaListener); - lastVersionSetting.javaVersionProperty().removeListener(javaListener); - - lastVersionSetting.gameDirTypeProperty().removeListener(gameDirTypeListener); - lastVersionSetting.gameDirProperty().removeListener(gameDirListener); - - gameDirItem.selectedDataProperty().unbindBidirectional(lastVersionSetting.gameDirTypeProperty()); - gameDirSublist.subtitleProperty().unbind(); - - enableSpecificSettings.removeListener(specificSettingsListener); - - if (advancedVersionSettingPage != null) { - advancedVersionSettingPage.unbindProperties(); - advancedVersionSettingPage = null; - } - } - - // unbind data fields - javaItem.setToggleSelectedListener(null); - javaVersionOption.valueProperty().unbind(); - - // bind new data fields - FXUtils.bindWindowsSize(cboWindowsSize, versionSetting.widthProperty(), versionSetting.heightProperty()); - maxMemory.bindBidirectional(versionSetting.maxMemoryProperty()); - - javaCustomOption.bindBidirectional(versionSetting.javaDirProperty()); - gameDirCustomOption.bindBidirectional(versionSetting.gameDirProperty()); - FXUtils.bindString(txtServerIP, versionSetting.serverIpProperty()); - chkAutoAllocate.selectedProperty().bindBidirectional(versionSetting.autoMemoryProperty()); - chkFullscreen.selectedProperty().bindBidirectional(versionSetting.fullscreenProperty()); - showLogsPane.selectedProperty().bindBidirectional(versionSetting.showLogsProperty()); - enableDebugLogOutputPane.selectedProperty().bindBidirectional(versionSetting.enableDebugLogOutputProperty()); - launcherVisibilityPane.valueProperty().bindBidirectional(versionSetting.launcherVisibilityProperty()); - processPriorityPane.valueProperty().bindBidirectional(versionSetting.processPriorityProperty()); - - if (versionId != null) - enableSpecificSettings.set(!versionSetting.isUsesGlobal()); - versionSetting.usesGlobalProperty().addListener(usesGlobalListener); - enableSpecificSettings.addListener(specificSettingsListener); - - javaItem.setToggleSelectedListener(newValue -> { - if (javaItem.getSelectedData() == null || updatingSelectedJava) - return; - - updatingJavaSetting = true; - - if (javaVersionOption.isSelected()) { - javaVersionOption.valueProperty().bindBidirectional(versionSetting.javaVersionProperty()); - } else { - javaVersionOption.valueProperty().unbind(); - javaVersionOption.setValue(""); - } - - if (javaCustomOption.isSelected()) { - versionSetting.setUsesCustomJavaDir(); - } else if (javaAutoDeterminedOption.isSelected()) { - versionSetting.setJavaAutoSelected(); - } else if (javaVersionOption.isSelected()) { - if (versionSetting.getJavaVersionType() != JavaVersionType.VERSION) - versionSetting.setJavaVersion(""); - versionSetting.setJavaVersionType(JavaVersionType.VERSION); - versionSetting.setDefaultJavaPath(null); - } else { - @SuppressWarnings("unchecked") - JavaRuntime java = ((Pair) newValue.getUserData()).getValue(); - versionSetting.setJavaVersionType(JavaVersionType.DETECTED); - versionSetting.setJavaVersion(java.getVersion()); - versionSetting.setDefaultJavaPath(java.getBinary().toString()); - } - - updatingJavaSetting = false; - }); - - versionSetting.javaVersionTypeProperty().addListener(javaListener); - versionSetting.javaDirProperty().addListener(javaListener); - versionSetting.defaultJavaPathPropertyProperty().addListener(javaListener); - versionSetting.javaVersionProperty().addListener(javaListener); - - gameDirItem.selectedDataProperty().bindBidirectional(versionSetting.gameDirTypeProperty()); - gameDirSublist.subtitleProperty().bind(Bindings.createStringBinding(() -> profile.getRepository().getRunDirectory(versionId).toAbsolutePath().normalize().toString(), - versionSetting.gameDirProperty(), versionSetting.gameDirTypeProperty())); - - versionSetting.gameDirTypeProperty().addListener(gameDirTypeListener); - versionSetting.gameDirProperty().addListener(gameDirListener); - - lastVersionSetting = versionSetting; - - initJavaSubtitle(); - - loadIcon(); - } - - private void initializeSelectedJava() { - if (lastVersionSetting == null || updatingJavaSetting) - return; - - updatingSelectedJava = true; - switch (lastVersionSetting.getJavaVersionType()) { - case CUSTOM: - javaCustomOption.setSelected(true); - break; - case VERSION: - javaVersionOption.setSelected(true); - javaVersionOption.setValue(lastVersionSetting.getJavaVersion()); - break; - case AUTO: - javaAutoDeterminedOption.setSelected(true); - break; - default: - Toggle toggle = null; - if (JavaManager.isInitialized()) { - try { - JavaRuntime java = lastVersionSetting.getJava(null, null); - if (java != null) { - for (Toggle t : javaItem.getGroup().getToggles()) { - if (t.getUserData() != null) { - @SuppressWarnings("unchecked") - Pair userData = (Pair) t.getUserData(); - if (userData.getValue() != null && java.getBinary().equals(userData.getValue().getBinary())) { - toggle = t; - break; - - } - } - } - } - } catch (InterruptedException ignored) { - } - } - - if (toggle != null) { - toggle.setSelected(true); - } else { - Toggle selectedToggle = javaItem.getGroup().getSelectedToggle(); - if (selectedToggle != null) { - selectedToggle.setSelected(false); - } - } - break; - } - updatingSelectedJava = false; - } - - private void initJavaSubtitle() { - FXUtils.checkFxUserThread(); - if (lastVersionSetting == null) - return; - initializeSelectedJava(); - HMCLGameRepository repository = this.profile.getRepository(); - String versionId = this.versionId; - JavaVersionType javaVersionType = lastVersionSetting.getJavaVersionType(); - boolean autoSelected = javaVersionType == JavaVersionType.AUTO || javaVersionType == JavaVersionType.VERSION; - - if (versionId == null && autoSelected) { - javaSublist.setSubtitle(i18n("settings.game.java_directory.auto")); - return; - } - - Pair selectedData = javaItem.getSelectedData(); - if (selectedData != null && selectedData.getValue() != null) { - javaSublist.setSubtitle(selectedData.getValue().getBinary().toString()); - return; - } - - if (JavaManager.isInitialized()) { - GameVersionNumber gameVersionNumber; - Version version; - if (versionId == null) { - gameVersionNumber = GameVersionNumber.unknown(); - version = null; - } else { - gameVersionNumber = GameVersionNumber.asGameVersion(repository.getGameVersion(versionId)); - version = repository.getResolvedVersion(versionId); - } - - try { - JavaRuntime java = lastVersionSetting.getJava(gameVersionNumber, version); - if (java != null) { - javaSublist.setSubtitle(java.getBinary().toString()); - } else { - javaSublist.setSubtitle(autoSelected ? i18n("settings.game.java_directory.auto.not_found") : i18n("settings.game.java_directory.invalid")); - } - return; - } catch (InterruptedException ignored) { - } - } - - javaSublist.setSubtitle(""); - } - - private void editSpecificSettings() { - Versions.modifyGameSettings(profile, profile.getSelectedVersion()); - } - - private void editGlobalSettings() { - Versions.modifyGlobalSettings(profile); - } - - private void onExploreIcon() { - if (versionId == null) - return; - - Controllers.dialog(new VersionIconDialog(profile, versionId, this::loadIcon)); - } - - private void onDeleteIcon() { - if (versionId == null) - return; - - profile.getRepository().deleteIconFile(versionId); - VersionSetting localVersionSetting = profile.getRepository().getLocalVersionSettingOrCreate(versionId); - if (localVersionSetting != null) { - localVersionSetting.setVersionIcon(VersionIconType.DEFAULT); - } - loadIcon(); - } - - private void loadIcon() { - if (versionId == null) { - return; - } - - iconPickerItem.setImage(profile.getRepository().getVersionIconImage(versionId)); - } - - private static List getSupportedResolutions() { - int maxScreenWidth = 0; - int maxScreenHeight = 0; - - for (Screen screen : Screen.getScreens()) { - Rectangle2D bounds = screen.getBounds(); - int screenWidth = (int) (bounds.getWidth() * screen.getOutputScaleX()); - int screenHeight = (int) (bounds.getHeight() * screen.getOutputScaleY()); - - maxScreenWidth = Math.max(maxScreenWidth, screenWidth); - maxScreenHeight = Math.max(maxScreenHeight, screenHeight); - } - - List resolutions = new ArrayList<>(List.of("854x480", "1280x720", "1600x900")); - - if (maxScreenWidth >= 1920 && maxScreenHeight >= 1080) resolutions.add("1920x1080"); - if (maxScreenWidth >= 2560 && maxScreenHeight >= 1440) resolutions.add("2560x1440"); - if (maxScreenWidth >= 3840 && maxScreenHeight >= 2160) resolutions.add("3840x2160"); - - return resolutions; - } - - @Override - public ReadOnlyObjectProperty stateProperty() { - return state.getReadOnlyProperty(); - } - -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/Versions.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/Versions.java index 1c9c79a0785..d86ce30200b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/Versions.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/Versions.java @@ -114,7 +114,8 @@ public static void downloadModpackImpl(DownloadProvider downloadProvider, Profil } public static void deleteVersion(Profile profile, String version) { - boolean isIndependent = profile.getVersionSetting(version).getGameDirType() == GameDirectoryType.VERSION_FOLDER; + boolean isIndependent = profile.getRepository().getRunDirectory(version).toAbsolutePath().normalize() + .equals(profile.getRepository().getVersionRoot(version).toAbsolutePath().normalize()); String message = isIndependent ? i18n("version.manage.remove.confirm.independent", version) : i18n("version.manage.remove.confirm.trash", version, version + "_removed"); @@ -143,7 +144,7 @@ public static CompletableFuture renameVersion(Profile profile, String ve profile.getRepository().refreshVersionsAsync() .thenRunAsync(Schedulers.javafx(), () -> { if (profile.getRepository().hasVersion(newName)) { - profile.setSelectedVersion(newName); + Profiles.setSelectedInstance(profile, newName); } }).start(); } else { @@ -190,7 +191,7 @@ public static void installFromJson(Profile profile, Path file) { .thenRunAsync(repository::refreshVersions) .whenComplete(Schedulers.javafx(), (exception) -> { if (exception == null) { - profile.setSelectedVersion(result); + Profiles.setSelectedInstance(profile, result); } else { Controllers.dialog( DownloadProviders.localizeErrorMessage(exception), i18n("install.failed"), MessageDialogPane.MessageType.ERROR); @@ -338,7 +339,7 @@ private static boolean checkVersionForLaunching(Profile profile, String id) { private static void ensureSelectedAccount(Consumer action) { Account account = Accounts.getSelectedAccount(); - if (ConfigHolder.isNewlyCreated() && !AuthlibInjectorServers.getServers().isEmpty() && + if (SettingsManager.isNewlyCreated() && !AuthlibInjectorServers.getServers().isEmpty() && !(account instanceof AuthlibInjectorAccount && AuthlibInjectorServers.getServers().contains(((AuthlibInjectorAccount) account).getServer()))) { CreateAccountPane dialog = new CreateAccountPane(AuthlibInjectorServers.getServers().iterator().next()); dialog.addEventHandler(DialogCloseEvent.CLOSE, e -> { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateChecker.java b/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateChecker.java index df15a3fac11..ac4a4e5d77e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateChecker.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateChecker.java @@ -29,7 +29,7 @@ import java.io.IOException; import java.util.LinkedHashMap; -import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.setting.SettingsManager.settings; import static org.jackhuang.hmcl.util.Lang.*; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -56,7 +56,7 @@ private UpdateChecker() { private static final ReadOnlyBooleanWrapper checkingUpdate = new ReadOnlyBooleanWrapper(false); public static void init() { - requestCheckUpdate(UpdateChannel.getChannel(), config().isAcceptPreviewUpdate()); + requestCheckUpdate(UpdateChannel.getChannel(), settings().acceptPreviewUpdateProperty().get()); } public static RemoteVersion getLatestVersion() { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateHandler.java b/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateHandler.java index 95c8f1b772c..ec28b67daff 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateHandler.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateHandler.java @@ -288,7 +288,7 @@ private static Optional getParentApplicationLocation() { private static boolean isFirstLaunchAfterUpgrade() { Path currentPath = JarUtils.thisJarPath(); if (currentPath != null) { - Path updated = Metadata.HMCL_GLOBAL_DIRECTORY.resolve("HMCL-" + Metadata.VERSION + ".jar"); + Path updated = Metadata.HMCL_USER_HOME.resolve("HMCL-" + Metadata.VERSION + ".jar"); if (currentPath.equals(updated.toAbsolutePath())) { return true; } @@ -297,7 +297,7 @@ private static boolean isFirstLaunchAfterUpgrade() { } private static void breakForceUpdateFeature() { - Path hmclVersionJson = Metadata.HMCL_GLOBAL_DIRECTORY.resolve("hmclver.json"); + Path hmclVersionJson = Metadata.HMCL_USER_HOME.resolve("hmclver.json"); if (Files.isRegularFile(hmclVersionJson)) { try { Map content = new Gson().fromJson(Files.readString(hmclVersionJson), Map.class); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/util/AprilFools.java b/HMCL/src/main/java/org/jackhuang/hmcl/util/AprilFools.java index 7f70f7050a9..10c4ebb0ebf 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/util/AprilFools.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/util/AprilFools.java @@ -23,7 +23,7 @@ import java.time.Month; import java.util.List; -import static org.jackhuang.hmcl.setting.ConfigHolder.config; +import static org.jackhuang.hmcl.setting.SettingsManager.settings; /// April Fools' Day utilities. /// @@ -57,7 +57,7 @@ else if ("false".equalsIgnoreCase(value) || !supportedRegion) else aprilFoolsMode = date.getMonth() == Month.APRIL && date.getDayOfMonth() == 1; - ENABLED = aprilFoolsMode && !config().isDisableAprilFools(); + ENABLED = aprilFoolsMode && !settings().disableAprilFoolsProperty().get(); SHOW_APRIL_FOOLS_SETTINGS = aprilFoolsMode || supportedRegion && date.getMonth() == Month.MARCH && date.getDayOfMonth() > 30; } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/util/NativePatcher.java b/HMCL/src/main/java/org/jackhuang/hmcl/util/NativePatcher.java index 5c545231ab3..539fa7a3c2a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/util/NativePatcher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/util/NativePatcher.java @@ -20,7 +20,7 @@ import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.mod.LocalModFile; import org.jackhuang.hmcl.mod.ModManager; -import org.jackhuang.hmcl.setting.VersionSetting; +import org.jackhuang.hmcl.setting.GameSettings; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.platform.Architecture; import org.jackhuang.hmcl.java.JavaRuntime; @@ -76,7 +76,7 @@ public static boolean needPatchMemoryUtil(Version version, int javaVersion) { public static Version patchNative(DefaultGameRepository repository, Version version, String gameVersion, JavaRuntime javaVersion, - VersionSetting settings, + GameSettings.Effective settings, List javaArguments) { if (settings.getNativesDirType() == NativesDirectoryType.CUSTOM) { if (gameVersion != null && GameVersionNumber.compare(gameVersion, "1.19") < 0) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/SupportedLocale.java b/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/SupportedLocale.java index 97d98b25fbb..248e694be00 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/SupportedLocale.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/SupportedLocale.java @@ -91,7 +91,7 @@ public static SupportedLocale getLocaleByName(String name) { SupportedLocale() { this.isDefault = true; - this.name = "def"; // TODO: Change to "default" after updating the Config format + this.name = "default"; String language = System.getenv("HMCL_LANGUAGE"); if (StringUtils.isBlank(language)) { @@ -310,13 +310,7 @@ public SupportedLocale read(JsonReader in) throws IOException { if (in.peek() == JsonToken.NULL) return DEFAULT; - String language = in.nextString(); - return getLocaleByName(switch (language) { - // TODO: Remove these compatibility codes after updating the Config format - case "zh_CN" -> "zh-Hans"; // For compatibility - case "zh" -> "zh-Hant"; // For compatibility - default -> language; - }); + return getLocaleByName(in.nextString()); } } } diff --git a/HMCL/src/main/resources/assets/css/root.css b/HMCL/src/main/resources/assets/css/root.css index 26a9bccadd0..3f9a036d40b 100644 --- a/HMCL/src/main/resources/assets/css/root.css +++ b/HMCL/src/main/resources/assets/css/root.css @@ -1316,6 +1316,7 @@ -fx-pref-height: -fx-toggle-icon-tiny-size; -fx-max-height: -fx-toggle-icon-tiny-size; -fx-min-height: -fx-toggle-icon-tiny-size; + -fx-padding: 0; -fx-background-radius: 25px; -fx-background-color: transparent; -jfx-toggle-color: -monet-primary; @@ -1324,7 +1325,11 @@ .toggle-icon-tiny .icon { -fx-fill: rgb(204.0, 204.0, 51.0); - -fx-padding: 5.0; + -fx-padding: 0; +} + +.toggle-icon-tiny:overridden .svg { + -fx-fill: -monet-primary; } .toggle-icon-tiny .jfx-rippler { diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 3db556cbeb3..b6c80adb99b 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1352,8 +1352,10 @@ settings.advanced.dont_check_game_completeness=Do not check game integrity settings.advanced.dont_check_jvm_validity=Do not check JVM compatibility settings.advanced.dont_patch_natives=Do not attempt to automatically replace native libraries settings.advanced.environment_variables=Environment Variables +settings.advanced.environment_variables.subtitle=Key-value pairs passed to the game process settings.advanced.game_dir.default=Default (".minecraft/") settings.advanced.game_dir.independent=Isolated (".minecraft/versions//", except for assets and libraries) +settings.advanced.graphics=Graphics Settings settings.advanced.graphics_backend=Graphics API settings.advanced.graphics_backend.default=Default settings.advanced.graphics_backend.default.desc=Follow the game's settings @@ -1370,6 +1372,10 @@ settings.advanced.jvm_args=JVM Arguments settings.advanced.jvm_args.prompt=\ · If the arguments entered in "JVM Arguments" are the same as the default arguments, it will not be added.\n\ \ · Enter any GC arguments in "JVM Arguments", and the G1 argument of the default arguments will be disabled.\n\ \ · Enable "Do not add default JVM arguments" to launch the game without adding default arguments. +settings.advanced.jvm_memory.deprecated=Deprecated JVM Memory Options +settings.advanced.jvm_memory.deprecated.subtitle=These options are kept only for compatibility with older versions +settings.advanced.launch_options=Advanced Options +settings.advanced.launch_options.subtitle=Working directory, launch arguments, environment variables, and process priority settings.advanced.launcher_visibility.close=Close the launcher after the game launches settings.advanced.launcher_visibility.hide=Hide the launcher after the game launches settings.advanced.launcher_visibility.hide_and_reopen=Hide the launcher and show it when the game closes @@ -1377,9 +1383,12 @@ settings.advanced.launcher_visibility.keep=Keep the launcher visible settings.advanced.launcher_visible=Launcher Visibility settings.advanced.minecraft_arguments=Launch Arguments settings.advanced.minecraft_arguments.prompt=Default +settings.advanced.natives=Native Libraries +settings.advanced.natives_settings=Native Library Settings settings.advanced.natives_directory=Native Library Path settings.advanced.natives_directory.choose=Choose the location of the desired native library settings.advanced.natives_directory.custom=Custom +settings.advanced.natives_directory.custom.enabled=Use Custom Native Libraries settings.advanced.natives_directory.default=Default settings.advanced.natives_directory.default.version_id= settings.advanced.no_jvm_args=Do not add default JVM arguments @@ -1402,6 +1411,8 @@ settings.advanced.post_exit_command.prompt=Commands to execute after the game ex settings.advanced.renderer=Renderer settings.advanced.renderer.default=Default settings.advanced.renderer.default.desc=Use System Default +settings.advanced.renderer.opengl=OpenGL Renderer/Driver +settings.advanced.renderer.vulkan=Vulkan Renderer/Driver # Vulkan Renderers settings.advanced.renderer.lavapipe=Mesa Lavapipe settings.advanced.renderer.lavapipe.desc=Software Vulkan Renderer @@ -1460,9 +1471,17 @@ settings.game.copy_global=Copy from Global Settings settings.game.copy_global.copy_all=Copy All settings.game.copy_global.copy_all.confirm=Are you sure you want to overwrite the current instance settings? This action cannot be undone! settings.game.current=Game +settings.game.default_isolation=Default Isolation Strategy +settings.game.default_isolation.always=Always Isolate +settings.game.default_isolation.modded=Only Isolate Modded Instances +settings.game.default_isolation.never=Never Isolate settings.game.dimension=Resolution settings.game.exploration=Explore settings.game.fullscreen=Fullscreen +settings.game.inherit=Inherit +settings.game.inherit_global=Inherit Global Setting +settings.game.isolation=Version Isolation +settings.game.isolation.subtitle=When enabled, this instance uses its own working directory setting instead of inheriting the global one. settings.game.java_directory=Java settings.game.java_directory.auto=Automatically Choose settings.game.java_directory.auto.not_found=No suitable Java version was installed. @@ -1472,6 +1491,23 @@ settings.game.java_directory.invalid=Incorrect Java path settings.game.java_directory.version=Specify Java Version settings.game.java_directory.template=%1$s (%2$s) settings.game.management=Manage +settings.game.override=Override +settings.game.override_global=Override Global Setting +settings.game.quick_play=Quick Play +settings.game.quick_play.multiplayer=Multiplayer +settings.game.quick_play.none=None +settings.game.quick_play.realms=Realms +settings.game.quick_play.singleplayer=Singleplayer +settings.game.quick_play.subtitle=Enter a server or world directly after launching the game +settings.game.running_directory=Game Working Directory +settings.game.running_directory.subtitle=Leave empty to use the default game folder. +settings.game.running_directory.subtitle.instance=When version isolation is enabled, leave empty to use the instance folder; otherwise the global working directory is inherited. +settings.game.section.basic=Basic Settings +settings.game.section.game=Game Settings +settings.game.window_type=Game Window Type +settings.game.window_type.fullscreen=Fullscreen +settings.game.window_type.maximized=Maximized +settings.game.window_type.windowed=Windowed settings.game.working_directory=Working Directory settings.game.working_directory.choose=Choose the working directory settings.game.working_directory.hint=Enable the "Isolated" option in "Working Directory" to allow the current instance to store its settings, worlds, and mods in a separate directory.\n\ @@ -1537,6 +1573,7 @@ settings.memory.allocate.manual=%1$.1f GiB Allocated settings.memory.allocate.manual.exceeded=%1$.1f GiB Allocated (%3$.1f GiB Available) settings.memory.auto_allocate=Automatically Allocate settings.memory.lower_bound=Minimum Memory +settings.memory.manual_allocate=Manually Allocate Memory settings.memory.unit.mib=MiB settings.memory.used_per_total=%1$.1f GiB Used / %2$.1f GiB Total settings.physical_memory=Physical Memory Size @@ -1547,7 +1584,16 @@ settings.take_effect_after_restart=Applies After Restart settings.type=Settings Type of Instance settings.type.global=Global Settings (Shared Among Instances without the "Instance-specific Settings" enabled) settings.type.global.manage=Global Settings +settings.type.global.preset=Preset +settings.type.global.preset.create=Create Preset +settings.type.global.preset.default=Use Default Preset +settings.type.global.preset.manage_all=Default Preset +settings.type.global.preset.name=Preset Name +settings.type.global.preset.rename=Rename Preset +settings.type.global.preset.remove=Delete Preset +settings.type.global.preset.remove.confirm=Are you sure you want to delete preset "%s"? Instances using this preset will fall back to the default preset. settings.type.global.edit=Edit Global Settings +settings.type.global.preset.new=Preset %d settings.type.special.enable=Enable Instance-specific Settings settings.type.special.edit=Edit Current Instance Settings settings.type.special.edit.hint=Current instance "%s" has enabled the "Instance-specific Settings". All options on this page will NOT affect that instance. Click here to edit its own settings. @@ -1744,4 +1790,4 @@ wiki.version.game.snapshot=https://minecraft.wiki/w/Java_Edition_%s wizard.prev=< Prev wizard.failed=Failed wizard.finish=Finish -wizard.next=Next > \ No newline at end of file +wizard.next=Next > diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index d469c1a29b5..07ffa25bccd 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -1145,8 +1145,10 @@ settings.advanced.dont_check_game_completeness=不檢查遊戲完整性 settings.advanced.dont_check_jvm_validity=不檢查 Java 虛擬機與遊戲的相容性 settings.advanced.dont_patch_natives=不嘗試自動取代本機庫 settings.advanced.environment_variables=環境變數 +settings.advanced.environment_variables.subtitle=傳遞給遊戲處理程序的鍵值對 settings.advanced.game_dir.default=預設 (".minecraft/") settings.advanced.game_dir.independent=各實例獨立 (".minecraft/versions/<實例名>/",除 assets、libraries 外) +settings.advanced.graphics=圖形設定 settings.advanced.graphics_backend=圖形 API settings.advanced.graphics_backend.default=預設 settings.advanced.graphics_backend.default.desc=遵循遊戲設定 @@ -1163,6 +1165,10 @@ settings.advanced.jvm_args=Java 虛擬機參數 settings.advanced.jvm_args.prompt=\ · 若在「Java 虛擬機參數」中輸入的參數與預設參數相同,則不會新增;\n\ \ · 若在「Java 虛擬機參數」輸入任何 GC 參數,預設參數的 G1 參數會被停用;\n\ \ · 啟用下方「不新增預設的 Java 虛擬機參數」可在啟動遊戲時不新增預設參數。 +settings.advanced.jvm_memory.deprecated=已棄用的 JVM 記憶體選項 +settings.advanced.jvm_memory.deprecated.subtitle=這些選項只為相容舊版本保留 +settings.advanced.launch_options=進階選項 +settings.advanced.launch_options.subtitle=執行路徑、遊戲參數、環境變數與處理程序優先度 settings.advanced.launcher_visibility.close=遊戲啟動後結束啟動器 settings.advanced.launcher_visibility.hide=遊戲啟動後隱藏啟動器 settings.advanced.launcher_visibility.hide_and_reopen=隱藏啟動器並在遊戲結束後重新開啟 @@ -1170,9 +1176,12 @@ settings.advanced.launcher_visibility.keep=不隱藏啟動器 settings.advanced.launcher_visible=啟動器可見性 settings.advanced.minecraft_arguments=Minecraft 額外參數 settings.advanced.minecraft_arguments.prompt=預設 +settings.advanced.natives=本機庫 +settings.advanced.natives_settings=本機庫設定 settings.advanced.natives_directory=本機庫路徑 (LWJGL) settings.advanced.natives_directory.choose=選取本機庫路徑 settings.advanced.natives_directory.custom=自訂 (由你提供遊戲需要的本機庫) +settings.advanced.natives_directory.custom.enabled=使用自訂本機庫 settings.advanced.natives_directory.default=預設 (由啟動器提供遊戲本機庫) settings.advanced.natives_directory.default.version_id=<實例名稱> settings.advanced.no_jvm_args=不新增預設的 Java 虛擬機參數 @@ -1195,6 +1204,8 @@ settings.advanced.post_exit_command.prompt=將在遊戲結束後呼叫使用 settings.advanced.renderer=繪製器/驅動 settings.advanced.renderer.default=預設 settings.advanced.renderer.default.desc=使用系統預設繪製器 +settings.advanced.renderer.opengl=OpenGL 繪製器/驅動 +settings.advanced.renderer.vulkan=Vulkan 繪製器/驅動 # Vulkan Renderers settings.advanced.renderer.lavapipe=Mesa Lavapipe settings.advanced.renderer.lavapipe.desc=軟體 Vulkan 繪製器 @@ -1253,9 +1264,17 @@ settings.game.copy_global=複製全域遊戲設定 settings.game.copy_global.copy_all=複製全部 settings.game.copy_global.copy_all.confirm=你確定要覆寫目前實例特定遊戲設定嗎?該操作無法復原! settings.game.current=遊戲 +settings.game.default_isolation=預設版本隔離策略 +settings.game.default_isolation.always=總是隔離 +settings.game.default_isolation.modded=僅隔離模組實例 +settings.game.default_isolation.never=從不隔離 settings.game.dimension=遊戲介面解析度大小 settings.game.exploration=瀏覽 settings.game.fullscreen=全螢幕 +settings.game.inherit=繼承 +settings.game.inherit_global=繼承全域設定 +settings.game.isolation=版本隔離 +settings.game.isolation.subtitle=啟用後,目前實例將使用獨立的遊戲執行路徑設定,而不是繼承全域設定。 settings.game.java_directory=遊戲 Java settings.game.java_directory.auto=自動選取合適的 Java settings.game.java_directory.auto.not_found=沒有合適的 Java @@ -1265,6 +1284,23 @@ settings.game.java_directory.invalid=Java 路徑不正確 settings.game.java_directory.version=指定 Java 版本 settings.game.java_directory.template=%s (%s) settings.game.management=管理 +settings.game.override=覆寫 +settings.game.override_global=覆寫全域設定 +settings.game.quick_play=快速遊玩 +settings.game.quick_play.multiplayer=多人連線 +settings.game.quick_play.none=無 +settings.game.quick_play.realms=Realms +settings.game.quick_play.singleplayer=單人遊戲 +settings.game.quick_play.subtitle=啟動遊戲後直接進入指定伺服器或世界 +settings.game.running_directory=遊戲執行路徑 +settings.game.running_directory.subtitle=留空則使用預設遊戲資料夾。 +settings.game.running_directory.subtitle.instance=啟用版本隔離時,留空則使用目前實例目錄;關閉時繼承全域執行路徑。 +settings.game.section.basic=基本設定 +settings.game.section.game=遊戲設定 +settings.game.window_type=遊戲視窗類型 +settings.game.window_type.fullscreen=全螢幕 +settings.game.window_type.maximized=最大化 +settings.game.window_type.windowed=視窗化 settings.game.working_directory=執行路徑 (建議使用模組時選取「各實例獨立」。修改後請自行移動相關遊戲檔案,如世界、模組設定等) settings.game.working_directory.choose=選取執行目錄 settings.game.working_directory.hint=在「執行路徑」選項中選取「各實例獨立」使目前實例獨立存放設定、世界、模組等資料。使用模組時建議開啟此選項以避免不同實例模組衝突。修改此選項後需自行移動世界等檔案。 @@ -1328,6 +1364,7 @@ settings.memory.allocate.manual=遊戲分配 %1$.1f GiB settings.memory.allocate.manual.exceeded=遊戲分配 %1$.1f GiB (%3$.1f GiB 可用) settings.memory.auto_allocate=自動分配 settings.memory.lower_bound=最低分配 +settings.memory.manual_allocate=手動選擇記憶體 settings.memory.unit.mib=MiB settings.memory.used_per_total=已使用 %1$.1f GiB / 總記憶體 %2$.1f GiB settings.physical_memory=實體記憶體大小 @@ -1338,7 +1375,16 @@ settings.take_effect_after_restart=重啟後生效 settings.type=實例遊戲設定類型 settings.type.global=全域遊戲設定 (未啟用「實例特定遊戲設定」的實例共用一套設定) settings.type.global.manage=全域遊戲設定 +settings.type.global.preset=預設 +settings.type.global.preset.create=新增預設 +settings.type.global.preset.default=使用預設項目 +settings.type.global.preset.manage_all=預設項目 +settings.type.global.preset.name=預設名稱 +settings.type.global.preset.rename=重新命名預設 +settings.type.global.preset.remove=刪除預設 +settings.type.global.preset.remove.confirm=確定要刪除預設「%s」嗎?使用該預設的實例將改用預設項目。 settings.type.global.edit=編輯全域遊戲設定 +settings.type.global.preset.new=預設 %d settings.type.special.enable=啟用實例特定遊戲設定 (不影響其他實例) settings.type.special.edit=編輯實例特定遊戲設定 settings.type.special.edit.hint=目前實例「%s」啟用了「實例特定遊戲設定」,本頁面選項不對目前實例生效。點擊連結以修改目前實例設定。 diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index 1392e328323..af04a4fda74 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -1150,8 +1150,10 @@ settings.advanced.dont_check_game_completeness=不检查游戏完整性 settings.advanced.dont_check_jvm_validity=不检查 Java 虚拟机与游戏的兼容性 settings.advanced.dont_patch_natives=不尝试自动替换本地库 settings.advanced.environment_variables=环境变量 +settings.advanced.environment_variables.subtitle=传递给游戏进程的键值对 settings.advanced.game_dir.default=默认 (".minecraft/") settings.advanced.game_dir.independent=各实例独立 (存放在 ".minecraft/versions/<实例名>/",除 assets、libraries 外) +settings.advanced.graphics=图形设置 settings.advanced.graphics_backend=图形 API settings.advanced.graphics_backend.default=默认 settings.advanced.graphics_backend.default.desc=遵循游戏设置 @@ -1168,6 +1170,10 @@ settings.advanced.jvm_args=Java 虚拟机参数 settings.advanced.jvm_args.prompt=\ · 若在“Java 虚拟机参数”输入的参数与默认参数相同,则不会添加;\n\ \ · 若在“Java 虚拟机参数”输入任何 GC 参数,默认参数的 G1 参数会被禁用;\n\ \ · 开启下方“不添加默认的 Java 虚拟机参数”可在启动游戏时不添加默认参数。 +settings.advanced.jvm_memory.deprecated=已弃用的 JVM 内存选项 +settings.advanced.jvm_memory.deprecated.subtitle=这些选项只为兼容旧版本保留 +settings.advanced.launch_options=高级选项 +settings.advanced.launch_options.subtitle=运行路径、游戏参数、环境变量与进程优先级 settings.advanced.launcher_visibility.close=游戏启动后结束启动器 settings.advanced.launcher_visibility.hide=游戏启动后隐藏启动器 settings.advanced.launcher_visibility.hide_and_reopen=隐藏启动器并在游戏结束后重新打开 @@ -1175,9 +1181,12 @@ settings.advanced.launcher_visibility.keep=保持启动器可见 settings.advanced.launcher_visible=启动器可见性 settings.advanced.minecraft_arguments=游戏参数 settings.advanced.minecraft_arguments.prompt=默认 +settings.advanced.natives=本地库 +settings.advanced.natives_settings=本机库设置 settings.advanced.natives_directory=本地库路径 (LWJGL) settings.advanced.natives_directory.choose=选择本地库路径 settings.advanced.natives_directory.custom=自定义 (由你提供游戏需要的本地库) +settings.advanced.natives_directory.custom.enabled=使用自定义本地库 settings.advanced.natives_directory.default=默认 (由启动器提供游戏本地库) settings.advanced.natives_directory.default.version_id=<实例名称> settings.advanced.no_jvm_args=不添加默认的 Java 虚拟机参数 @@ -1200,6 +1209,8 @@ settings.advanced.post_exit_command.prompt=将在游戏结束后调用 settings.advanced.renderer=渲染器/驱动 settings.advanced.renderer.default=默认 settings.advanced.renderer.default.desc=使用系统默认渲染器 +settings.advanced.renderer.opengl=OpenGL 渲染器/驱动 +settings.advanced.renderer.vulkan=Vulkan 渲染器/驱动 # Vulkan Renderers settings.advanced.renderer.lavapipe=Mesa Lavapipe settings.advanced.renderer.lavapipe.desc=软件 Vulkan 渲染器 @@ -1258,9 +1269,17 @@ settings.game.copy_global=复制全局游戏设置 settings.game.copy_global.copy_all=复制全部 settings.game.copy_global.copy_all.confirm=你确定要覆盖当前实例特定游戏设置吗?此操作无法撤销! settings.game.current=游戏 +settings.game.default_isolation=默认版本隔离策略 +settings.game.default_isolation.always=总是隔离 +settings.game.default_isolation.modded=仅隔离模组实例 +settings.game.default_isolation.never=从不隔离 settings.game.dimension=游戏窗口分辨率 settings.game.exploration=浏览 settings.game.fullscreen=全屏 +settings.game.inherit=继承 +settings.game.inherit_global=继承全局设置 +settings.game.isolation=版本隔离 +settings.game.isolation.subtitle=启用后,当前实例将使用独立的游戏运行路径设置,而不是继承全局设置。 settings.game.java_directory=游戏 Java settings.game.java_directory.auto=自动选择合适的 Java settings.game.java_directory.auto.not_found=没有合适的 Java @@ -1270,6 +1289,23 @@ settings.game.java_directory.invalid=Java 路径不正确 settings.game.java_directory.version=指定 Java 版本 settings.game.java_directory.template=%s (%s) settings.game.management=管理 +settings.game.override=覆盖 +settings.game.override_global=覆盖全局设置 +settings.game.quick_play=快速游玩 +settings.game.quick_play.multiplayer=多人联机 +settings.game.quick_play.none=无 +settings.game.quick_play.realms=领域服 +settings.game.quick_play.singleplayer=单人游戏 +settings.game.quick_play.subtitle=启动游戏后直接进入指定服务器或世界 +settings.game.running_directory=游戏运行路径 +settings.game.running_directory.subtitle=留空则使用默认游戏文件夹。 +settings.game.running_directory.subtitle.instance=启用版本隔离时,留空则使用当前实例目录;关闭时继承全局运行路径。 +settings.game.section.basic=基本设置 +settings.game.section.game=游戏设置 +settings.game.window_type=游戏窗口类型 +settings.game.window_type.fullscreen=全屏 +settings.game.window_type.maximized=最大化 +settings.game.window_type.windowed=窗口化 settings.game.working_directory=版本隔离 (建议使用模组时选择“各实例独立”。改后需移动世界、模组等相关游戏文件) settings.game.working_directory.choose=选择运行文件夹 settings.game.working_directory.hint=在“版本隔离”中选择“各实例独立”使当前实例独立存放设置、世界、模组等数据。使用模组时建议启用此选项以避免不同实例模组冲突。修改此选项后需自行移动世界等文件。 @@ -1333,6 +1369,7 @@ settings.memory.allocate.manual=游戏分配 %1$.1f GiB settings.memory.allocate.manual.exceeded=游戏分配 %1$.1f GiB (设备仅 %3$.1f GiB 可用) settings.memory.auto_allocate=自动分配内存 settings.memory.lower_bound=最低内存分配 +settings.memory.manual_allocate=手动选择内存 settings.memory.unit.mib=MiB settings.memory.used_per_total=已使用 %1$.1f GiB / 总内存 %2$.1f GiB settings.physical_memory=物理内存大小 @@ -1343,7 +1380,16 @@ settings.take_effect_after_restart=重启后生效 settings.type=实例游戏设置类型 settings.type.global=全局游戏设置 (未启用“实例特定游戏设置”的实例共用此设置) settings.type.global.manage=全局游戏设置 +settings.type.global.preset=预设 +settings.type.global.preset.create=新建预设 +settings.type.global.preset.default=使用默认预设 +settings.type.global.preset.manage_all=默认预设 +settings.type.global.preset.name=预设名称 +settings.type.global.preset.rename=重命名预设 +settings.type.global.preset.remove=删除预设 +settings.type.global.preset.remove.confirm=确定要删除预设“%s”吗?使用该预设的实例将回退到默认预设。 settings.type.global.edit=编辑全局游戏设置 +settings.type.global.preset.new=预设 %d settings.type.special.enable=启用实例特定游戏设置 (不影响其他游戏实例) settings.type.special.edit=编辑实例特定游戏设置 settings.type.special.edit.hint=当前游戏实例“%s”启用了“实例特定游戏设置”,因此本页面选项不对该实例生效。点击链接前往该实例的“游戏设置”页。 diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/setting/AccountStoragesTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/setting/AccountStoragesTest.java new file mode 100644 index 00000000000..decdfc8e3fe --- /dev/null +++ b/HMCL/src/test/java/org/jackhuang/hmcl/setting/AccountStoragesTest.java @@ -0,0 +1,100 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.setting; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.jackhuang.hmcl.util.gson.JsonSchema; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jetbrains.annotations.NotNullByDefault; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/// Tests for detached account storage lists. +@NotNullByDefault +public final class AccountStoragesTest { + /// Tests that account storages serialize as an object containing an accounts list. + @Test + public void serializesAccountsAsObjectList() { + AccountStorages accountStorages = AccountStorages.fromAccounts(List.of(Map.of( + "type", "offline", + "username", "Steve" + ))); + + JsonObject serialized = JsonParser.parseString( + JsonUtils.GSON.toJson(accountStorages, AccountStorages.class) + ).getAsJsonObject(); + + assertEquals(AccountStorages.CURRENT_SCHEMA.url(), + serialized.get(JsonSchema.PROPERTY_SCHEMA).getAsString()); + assertTrue(serialized.has("accounts")); + assertEquals(1, serialized.getAsJsonArray("accounts").size()); + assertEquals("offline", serialized.getAsJsonArray("accounts") + .get(0) + .getAsJsonObject() + .get("type") + .getAsString()); + } + + /// Tests wrapping legacy account list content in the current `game-accounts.json` model. + @Test + public void wrapsLegacyAccountListInCurrentModel() { + AccountStorages storages = AccountStorages.fromAccounts(List.of( + Map.of("type", "offline", "username", "Steve") + )); + + JsonObject serialized = JsonParser.parseString( + JsonUtils.GSON.toJson(storages, AccountStorages.class)).getAsJsonObject(); + + assertEquals(AccountStorages.CURRENT_SCHEMA.url(), serialized.get(JsonSchema.PROPERTY_SCHEMA).getAsString()); + JsonArray accounts = serialized.getAsJsonArray("accounts"); + assertEquals(1, accounts.size()); + assertEquals("offline", accounts.get(0).getAsJsonObject().get("type").getAsString()); + assertEquals("Steve", accounts.get(0).getAsJsonObject().get("username").getAsString()); + } + + /// Tests extracting account storages from a main config object. + @Test + public void extractsAccountsFromConfigJson() { + JsonObject settings = JsonParser.parseString(""" + { + "accounts": [ + { + "type": "offline", + "username": "Alex" + } + ], + "selectedAccount": "Alex" + } + """).getAsJsonObject(); + + AccountStorages accountStorages = LegacyConfigMigrator.extractAccountStorages(settings); + assertNotNull(accountStorages); + + assertFalse(settings.has("accounts")); + assertTrue(settings.has("selectedAccount")); + assertEquals(1, accountStorages.getAccounts().size()); + assertEquals("offline", accountStorages.getAccounts().get(0).get("type")); + assertEquals(AccountStorages.CURRENT_SCHEMA, accountStorages.getSchema()); + } +} diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/setting/AuthlibInjectorServerListTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/setting/AuthlibInjectorServerListTest.java new file mode 100644 index 00000000000..d5ede739916 --- /dev/null +++ b/HMCL/src/test/java/org/jackhuang/hmcl/setting/AuthlibInjectorServerListTest.java @@ -0,0 +1,104 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.setting; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.jetbrains.annotations.NotNullByDefault; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/// Tests for detached authlib-injector server lists. +@NotNullByDefault +public final class AuthlibInjectorServerListTest { + /// Tests that newly created server lists contain LittleSkin by default. + @Test + public void defaultListContainsLittleSkin() { + AuthlibInjectorServerList list = AuthlibInjectorServerList.createDefault(); + + assertEquals(1, list.getServers().size()); + assertEquals(AuthlibInjectorServerList.LITTLE_SKIN_URL, list.getServers().get(0).getUrl()); + } + + /// Tests extracting authlib-injector servers from an upstream/main config object. + @Test + public void extractsAuthlibInjectorServersFromLegacyConfigJson() { + JsonObject settings = JsonParser.parseString(""" + { + "authlibInjectorServers": [ + { + "url": "https://example.com/api/yggdrasil/" + } + ], + "addedLittleSkin": true, + "localization": "en" + } + """).getAsJsonObject(); + + AuthlibInjectorServerList list = LegacyConfigMigrator.extractAuthlibInjectorServers(settings); + + assertFalse(settings.has("authlibInjectorServers")); + assertFalse(settings.has("addedLittleSkin")); + assertTrue(settings.has("localization")); + + assertEquals(1, list.getServers().size()); + assertEquals("https://example.com/api/yggdrasil/", list.getServers().get(0).getUrl()); + assertEquals(AuthlibInjectorServerList.CURRENT_SCHEMA, list.getSchema()); + } + + /// Tests that legacy configs without `addedLittleSkin` receive LittleSkin during migration. + @Test + public void addsLittleSkinWhenLegacyFlagIsMissing() { + JsonObject settings = JsonParser.parseString(""" + { + "authlibInjectorServers": [ + { + "url": "https://example.com/api/yggdrasil/" + } + ] + } + """).getAsJsonObject(); + + AuthlibInjectorServerList list = LegacyConfigMigrator.extractAuthlibInjectorServers(settings); + + assertEquals(2, list.getServers().size()); + assertEquals("https://example.com/api/yggdrasil/", list.getServers().get(0).getUrl()); + assertEquals(AuthlibInjectorServerList.LITTLE_SKIN_URL, list.getServers().get(1).getUrl()); + } + + /// Tests that legacy configs with `addedLittleSkin=false` do not add a duplicate LittleSkin entry. + @Test + public void addsLittleSkinOnlyOnceWhenLegacyFlagIsFalse() { + JsonObject settings = JsonParser.parseString(""" + { + "authlibInjectorServers": [ + { + "url": "https://littleskin.cn/api/yggdrasil/" + } + ], + "addedLittleSkin": false + } + """).getAsJsonObject(); + + AuthlibInjectorServerList list = LegacyConfigMigrator.extractAuthlibInjectorServers(settings); + + assertEquals(1, list.getServers().size()); + assertEquals(AuthlibInjectorServerList.LITTLE_SKIN_URL, list.getServers().get(0).getUrl()); + } +} diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java new file mode 100644 index 00000000000..54a6e31cc58 --- /dev/null +++ b/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java @@ -0,0 +1,284 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.setting; + +import com.github.f4b6a3.uuid.alt.GUID; +import com.google.gson.JsonParseException; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.jackhuang.hmcl.util.PortablePath; +import org.jackhuang.hmcl.util.gson.JsonSchema; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jetbrains.annotations.NotNullByDefault; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; + +import static org.junit.jupiter.api.Assertions.*; + +/// Tests for detached game directory migration. +@NotNullByDefault +public final class GameDirectoriesTest { + /// Tests extracting legacy configuration data into a detached game directory store. + @Test + public void extractsConfigurationsFromLegacyConfigJson() { + GUID id = LegacyConfigMigrator.getLegacyProfileId("Dev"); + JsonObject settings = JsonParser.parseString(""" + { + "configurations": { + "Dev": { + "gameDir": ".minecraft", + "useRelativePath": true + } + } + } + """).getAsJsonObject(); + + GameDirectories gameDirectories = Objects.requireNonNull(LegacyConfigMigrator.extractGameDirectoriesFromConfigJson(settings)); + + assertFalse(settings.has("configurations")); + assertEquals(1, gameDirectories.getGameDirectories().size()); + assertEquals(id, gameDirectories.getGameDirectories().get(0).getId()); + assertEquals("Dev", gameDirectories.getGameDirectories().get(0).getName()); + assertEquals(".minecraft", gameDirectories.getGameDirectories().get(0).getPath().getPath()); + assertNull(gameDirectories.getGameDirectories().get(0).getLegacyGameSettings()); + } + + /// Tests extracting the migrated legacy game settings ID from a legacy profile. + @Test + public void extractsLegacyGameSettingsIdFromLegacyProfileGlobalSettings() { + GUID profileId = LegacyConfigMigrator.getLegacyProfileId("Dev"); + GUID legacyGameSettings = LegacyConfigMigrator.getLegacyGameSettingsId("Dev"); + JsonObject settings = JsonParser.parseString(""" + { + "configurations": { + "Dev": { + "gameDir": ".minecraft", + "global": { + "maxMemory": 2048 + } + } + } + } + """).getAsJsonObject(); + + GameDirectories gameDirectories = Objects.requireNonNull(LegacyConfigMigrator.extractGameDirectoriesFromConfigJson(settings)); + + Profile profile = gameDirectories.getGameDirectories().get(0); + assertEquals(profileId, profile.getId()); + assertEquals(legacyGameSettings, profile.getLegacyGameSettings()); + assertNotEquals(profile.getId(), profile.getLegacyGameSettings()); + } + + /// Tests that built-in profiles do not store names after migration. + @Test + public void removesBuiltInProfileNamesDuringMigration() { + JsonObject settings = JsonParser.parseString(""" + { + "configurations": { + "Default": { + "gameDir": ".minecraft" + }, + "Home": { + "gameDir": "/home/user/.minecraft" + }, + "Dev": { + "gameDir": "versions/Dev" + } + } + } + """).getAsJsonObject(); + + GameDirectories gameDirectories = Objects.requireNonNull(LegacyConfigMigrator.extractGameDirectoriesFromConfigJson(settings)); + + GUID defaultProfileId = LegacyConfigMigrator.getLegacyProfileId("Default"); + GUID homeProfileId = LegacyConfigMigrator.getLegacyProfileId("Home"); + Profile defaultProfile = gameDirectories.getGameDirectories().stream() + .filter(profile -> defaultProfileId.equals(profile.getId())) + .findFirst() + .orElseThrow(); + Profile homeProfile = gameDirectories.getGameDirectories().stream() + .filter(profile -> homeProfileId.equals(profile.getId())) + .findFirst() + .orElseThrow(); + Profile devProfile = gameDirectories.getGameDirectories().stream() + .filter(profile -> "Dev".equals(profile.getName())) + .findFirst() + .orElseThrow(); + + assertNull(defaultProfile.getName()); + assertNull(homeProfile.getName()); + assertEquals("Dev", devProfile.getName()); + } + + /// Tests migrating upstream/main selected version fields into the main config. + @Test + public void migratesLegacySelectedVersionsFromConfigurations() { + GUID id = LegacyConfigMigrator.getLegacyProfileId("Dev"); + assertEquals(5, id.version()); + JsonObject settings = JsonParser.parseString(""" + { + "last": "Dev", + "configurations": { + "Dev": { + "gameDir": ".minecraft", + "selectedMinecraftVersion": "1.20.1" + } + } + } + """).getAsJsonObject(); + + assertTrue(LegacyConfigMigrator.migrateLegacySelectedVersions(settings)); + GameDirectories gameDirectories = Objects.requireNonNull(LegacyConfigMigrator.extractGameDirectoriesFromConfigJson(settings)); + assertTrue(LegacyConfigMigrator.migrateLegacySelectedGameDirectory(settings)); + LauncherSettings config = Objects.requireNonNull(LauncherSettings.fromJson(settings)); + + assertFalse(settings.has("configurations")); + assertEquals(id, config.selectedGameDirectoryProperty().get()); + assertEquals("1.20.1", config.getSelectedInstance(id)); + + JsonObject serialized = JsonParser.parseString(config.toJson()).getAsJsonObject(); + assertEquals("1.20.1", serialized + .getAsJsonObject(LauncherSettings.PROPERTY_SELECTED_INSTANCE) + .get(id.toString()) + .getAsString()); + } + + /// Tests that profiles store their directory as a portable path. + @Test + public void storesProfilePath() { + GUID id = new GUID("123e4567-e89b-12d3-a456-426614174000"); + Profile profile = new Profile(id, "Dev", PortablePath.of("versions\\Dev")); + + JsonObject serialized = JsonUtils.GSON.toJsonTree(profile, Profile.class).getAsJsonObject(); + Profile deserialized = Objects.requireNonNull(JsonUtils.GSON.fromJson(serialized, Profile.class)); + + assertEquals("versions/Dev", serialized.get("path").getAsString()); + assertFalse(serialized.has("gameDir")); + assertFalse(serialized.has("useRelativePath")); + assertEquals("versions/Dev", deserialized.getPath().getPath()); + assertFalse(deserialized.getPath().isAbsolute()); + } + + /// Tests that profiles preserve migrated legacy game settings IDs. + @Test + public void storesLegacyGameSettingsId() { + GUID id = new GUID("123e4567-e89b-12d3-a456-426614174000"); + GUID legacyGameSettings = new GUID("123e4567-e89b-12d3-a456-426614174001"); + Profile profile = new Profile(id, "Dev", PortablePath.of("versions\\Dev"), legacyGameSettings); + + JsonObject serialized = JsonUtils.GSON.toJsonTree(profile, Profile.class).getAsJsonObject(); + Profile deserialized = Objects.requireNonNull(JsonUtils.GSON.fromJson(serialized, Profile.class)); + + assertEquals(legacyGameSettings.toString(), serialized.get("legacyGameSettings").getAsString()); + assertEquals(legacyGameSettings, deserialized.getLegacyGameSettings()); + } + + /// Tests that unnamed profiles are displayed by ID and serialized without a name. + @Test + public void displaysUnnamedProfileAsId() { + GUID id = new GUID("123e4567-e89b-12d3-a456-426614174000"); + Profile profile = new Profile(id, null, PortablePath.of("versions\\Dev")); + + JsonObject serialized = JsonUtils.GSON.toJsonTree(profile, Profile.class).getAsJsonObject(); + Profile deserialized = Objects.requireNonNull(JsonUtils.GSON.fromJson(serialized, Profile.class)); + + assertFalse(serialized.has("name")); + assertNull(deserialized.getName()); + assertEquals(id.toString(), Profiles.getProfileDisplayName(profile)); + } + + /// Tests that an explicit name overrides built-in display names. + @Test + public void displaysExplicitNameBeforeBuiltInName() { + GUID id = new GUID("123e4567-e89b-12d3-a456-426614174000"); + Profile profile = new Profile(id, "Custom Default", PortablePath.of(".minecraft")); + + assertEquals("Custom Default", Profiles.getProfileDisplayName(profile)); + } + + /// Tests that profiles must be deserialized with a non-nil ID. + @Test + public void rejectsNilProfileId() { + assertThrows(JsonParseException.class, () -> JsonUtils.GSON.fromJson(""" + { + "id": "00000000-0000-0000-0000-000000000000", + "name": "Dev", + "path": "versions/Dev" + } + """, Profile.class)); + } + + /// Tests that game directory files do not preserve the workspace-level selected directory. + @Test + public void doesNotStoreSelectedGameDirectoryInGameDirectories() { + JsonObject serialized = JsonParser.parseString(""" + { + "$schema": "https://schemas.glavo.site/hmcl/game-directories/1.0.0", + "selectedGameDirectory": "123e4567-e89b-12d3-a456-426614174000", + "directories": [] + } + """).getAsJsonObject(); + + GameDirectories gameDirectories = JsonUtils.GSON.fromJson(serialized, GameDirectories.class); + JsonObject rewritten = JsonParser.parseString(JsonUtils.GSON.toJson(gameDirectories, GameDirectories.class)) + .getAsJsonObject(); + + assertEquals(GameDirectories.CURRENT_SCHEMA, + JsonSchema.readFromMember(rewritten, JsonSchema.PROPERTY_SCHEMA)); + assertFalse(rewritten.has(LauncherSettings.PROPERTY_SELECTED_GAME_DIRECTORY)); + assertTrue(rewritten.has("directories")); + } + + /// Tests that patch-version schemas are preserved together with unknown fields. + @Test + public void preservesPatchSchemaAndUnknownFields(@TempDir Path tempDir) throws IOException { + Path location = tempDir.resolve("game-directories.json"); + Files.writeString(location, """ + { + "$schema": "https://schemas.glavo.site/hmcl/game-directories/1.0.1", + "futureField": { + "enabled": true + }, + "directories": [] + } + """); + + JsonSettingFile file = new JsonSettingFile<>( + location, + "game directories", + GameDirectories.class, + GameDirectories.CURRENT_SCHEMA, + GameDirectories::new); + + JsonSettingFile.LoadResult result = file.load(null); + assertTrue(result.allowSave()); + assertEquals(new JsonSchema("https://schemas.glavo.site/hmcl/game-directories/1.0.1"), + result.value().getSchema()); + + JsonObject rewritten = JsonParser.parseString(JsonUtils.GSON.toJson(result.value(), GameDirectories.class)) + .getAsJsonObject(); + assertEquals("https://schemas.glavo.site/hmcl/game-directories/1.0.1", + rewritten.get(JsonSchema.PROPERTY_SCHEMA).getAsString()); + assertTrue(rewritten.getAsJsonObject("futureField").get("enabled").getAsBoolean()); + } +} diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameSettingsPresetsTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameSettingsPresetsTest.java new file mode 100644 index 00000000000..6bfb9ca431f --- /dev/null +++ b/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameSettingsPresetsTest.java @@ -0,0 +1,111 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.setting; + +import com.github.f4b6a3.uuid.alt.GUID; +import com.google.gson.JsonParseException; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.jackhuang.hmcl.util.gson.JsonSchema; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jetbrains.annotations.NotNullByDefault; +import org.junit.jupiter.api.Test; + +import java.util.Objects; + +import static org.junit.jupiter.api.Assertions.*; + +/// Tests for detached game settings presets. +@NotNullByDefault +public final class GameSettingsPresetsTest { + /// Tests that the default preset selection belongs to LauncherSettings. + @Test + public void storesDefaultGameSettingsPresetInConfig() { + GUID id = new GUID("123e4567-e89b-12d3-a456-426614174000"); + LauncherSettings config = new LauncherSettings(); + + config.defaultGameSettingsPresetProperty().set(id); + JsonObject serialized = JsonParser.parseString(config.toJson()).getAsJsonObject(); + + assertEquals(id.toString(), serialized.get(LauncherSettings.PROPERTY_DEFAULT_GAME_SETTINGS_PRESET).getAsString()); + } + + /// Tests that presets must be deserialized with a non-nil ID. + @Test + public void rejectsNilPresetId() { + assertThrows(JsonParseException.class, () -> JsonUtils.GSON.fromJson(""" + { + "id": "00000000-0000-0000-0000-000000000000" + } + """, GameSettings.Preset.class)); + + assertThrows(JsonParseException.class, + () -> JsonUtils.GSON.fromJson("{}", GameSettings.Preset.class)); + } + + /// Tests that preset files do not preserve the workspace-level default preset selection. + @Test + public void doesNotStoreDefaultGameSettingsPresetInPresets() { + JsonObject serialized = JsonParser.parseString(""" + { + "$schema": "https://schemas.glavo.site/hmcl/game-settings/1.0.0", + "defaultGameSettingsPreset": "123e4567-e89b-12d3-a456-426614174000", + "presets": [] + } + """).getAsJsonObject(); + + GameSettingsPresets presets = JsonUtils.GSON.fromJson(serialized, GameSettingsPresets.class); + JsonObject rewritten = JsonParser.parseString(JsonUtils.GSON.toJson(presets, GameSettingsPresets.class)) + .getAsJsonObject(); + + assertEquals(GameSettingsPresets.CURRENT_SCHEMA, + JsonSchema.readFromMember(rewritten, JsonSchema.PROPERTY_SCHEMA)); + assertFalse(rewritten.has(LauncherSettings.PROPERTY_DEFAULT_GAME_SETTINGS_PRESET)); + assertTrue(rewritten.has("presets")); + assertFalse(rewritten.has("gameSettings")); + } + + /// Tests that legacy profile-level game settings migrate to IDs separate from profile IDs. + @Test + public void migratesLegacyProfileGlobalSettingsToSeparatePresetId() { + JsonObject settings = JsonParser.parseString(""" + { + "configurations": { + "Dev": { + "gameDir": ".minecraft", + "global": { + "maxMemory": 2048 + } + } + } + } + """).getAsJsonObject(); + JsonObject configurations = settings.getAsJsonObject("configurations").deepCopy(); + GameDirectories gameDirectories = Objects.requireNonNull(LegacyConfigMigrator.extractGameDirectoriesFromConfigJson(settings)); + GameSettingsPresets presets = new GameSettingsPresets(); + + LegacyConfigMigrator.migrateLegacyPresetSettings(gameDirectories, presets, configurations); + + assertEquals(1, presets.getPresets().size()); + Profile profile = gameDirectories.getGameDirectories().get(0); + GameSettings.Preset preset = presets.getPresets().get(0); + assertEquals(profile.getLegacyGameSettings(), preset.idProperty().getValue()); + assertNotEquals(profile.getId(), preset.idProperty().getValue()); + assertEquals(2048, preset.maxMemoryProperty().getValue()); + } +} diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/setting/LauncherSettingsMigrationTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/setting/LauncherSettingsMigrationTest.java new file mode 100644 index 00000000000..adbfe073075 --- /dev/null +++ b/HMCL/src/test/java/org/jackhuang/hmcl/setting/LauncherSettingsMigrationTest.java @@ -0,0 +1,421 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.setting; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.jackhuang.hmcl.util.gson.JsonSchema; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jetbrains.annotations.NotNullByDefault; +import org.junit.jupiter.api.Test; + +import java.net.Proxy; +import java.util.Objects; + +import static org.junit.jupiter.api.Assertions.*; + +/// Tests for legacy config migration into current launcher settings. +@NotNullByDefault +public final class LauncherSettingsMigrationTest { + /// Tests migrating legacy language fields into the current launcher settings field. + @Test + public void migratesLegacyLocalizationToLanguage() { + JsonObject settings = JsonParser.parseString(""" + { + "localization": "zh_CN" + } + """).getAsJsonObject(); + + LegacyConfigMigrator.migrateLegacyLanguage(settings); + LauncherSettings launcherSettings = Objects.requireNonNull(LauncherSettings.fromJson(settings)); + JsonObject serialized = JsonParser.parseString(launcherSettings.toJson()).getAsJsonObject(); + + assertFalse(settings.has("localization")); + assertEquals("zh-Hans", settings.get("language").getAsString()); + assertEquals("zh-Hans", launcherSettings.languageProperty().get().getName()); + assertFalse(serialized.has("localization")); + assertEquals("zh-Hans", serialized.get("language").getAsString()); + } + + /// Tests that legacy Traditional Chinese language values are migrated before locale deserialization. + @Test + public void migratesLegacyTraditionalChineseLanguage() { + JsonObject settings = JsonParser.parseString(""" + { + "localization": "zh" + } + """).getAsJsonObject(); + + LegacyConfigMigrator.migrateLegacyLanguage(settings); + LauncherSettings launcherSettings = Objects.requireNonNull(LauncherSettings.fromJson(settings)); + + assertEquals("zh-Hant", settings.get("language").getAsString()); + assertEquals("zh-Hant", launcherSettings.languageProperty().get().getName()); + } + + /// Tests migrating the legacy common directory field into the current launcher settings field. + @Test + public void migratesLegacyCommonPathToCommonDirectory() { + JsonObject settings = JsonParser.parseString(""" + { + "commonpath": "/home/user/.minecraft" + } + """).getAsJsonObject(); + + LegacyConfigMigrator.migrateLegacyCommonDirectory(settings); + LauncherSettings launcherSettings = Objects.requireNonNull(LauncherSettings.fromJson(settings)); + JsonObject serialized = JsonParser.parseString(launcherSettings.toJson()).getAsJsonObject(); + + assertFalse(settings.has("commonpath")); + assertEquals("/home/user/.minecraft", settings.get("commonDirectory").getAsString()); + assertEquals("/home/user/.minecraft", launcherSettings.commonDirectoryProperty().get()); + assertFalse(serialized.has("commonpath")); + assertEquals("/home/user/.minecraft", serialized.get("commonDirectory").getAsString()); + } + + /// Tests migrating the legacy common directory type field into the current launcher settings field. + @Test + public void migratesLegacyCommonDirTypeToCommonDirectoryType() { + JsonObject settings = JsonParser.parseString(""" + { + "commonDirType": "CUSTOM" + } + """).getAsJsonObject(); + + LegacyConfigMigrator.migrateLegacyCommonDirectoryType(settings); + LauncherSettings launcherSettings = Objects.requireNonNull(LauncherSettings.fromJson(settings)); + JsonObject serialized = JsonParser.parseString(launcherSettings.toJson()).getAsJsonObject(); + + assertFalse(settings.has("commonDirType")); + assertEquals("CUSTOM", settings.get("commonDirectoryType").getAsString()); + assertEquals(EnumCommonDirectory.CUSTOM, launcherSettings.commonDirectoryTypeProperty().get()); + assertFalse(serialized.has("commonDirType")); + assertEquals("CUSTOM", serialized.get("commonDirectoryType").getAsString()); + } + + /// Tests migrating legacy enum ordinal fields into stable enum names. + @Test + public void migratesLegacyEnumOrdinals() { + JsonObject settings = JsonParser.parseString(""" + { + "backgroundType": 3, + "proxyType": 2 + } + """).getAsJsonObject(); + + LegacyConfigMigrator.migrateLegacyEnumOrdinals(settings); + LauncherSettings launcherSettings = Objects.requireNonNull(LauncherSettings.fromJson(settings)); + JsonObject serialized = JsonParser.parseString(launcherSettings.toJson()).getAsJsonObject(); + + assertEquals("NETWORK", settings.get("backgroundType").getAsString()); + assertEquals("SOCKS", settings.get("proxyType").getAsString()); + assertEquals(EnumBackgroundImage.NETWORK, launcherSettings.backgroundImageTypeProperty().get()); + assertEquals(Proxy.Type.SOCKS, launcherSettings.proxyTypeProperty().get()); + assertEquals("NETWORK", serialized.get("backgroundType").getAsString()); + assertEquals("SOCKS", serialized.get("proxyType").getAsString()); + } + + /// Tests migrating legacy enum ordinal strings into stable enum names. + @Test + public void migratesLegacyEnumOrdinalStrings() { + JsonObject settings = JsonParser.parseString(""" + { + "backgroundType": "1", + "proxyType": "0" + } + """).getAsJsonObject(); + + LegacyConfigMigrator.migrateLegacyEnumOrdinals(settings); + LauncherSettings launcherSettings = Objects.requireNonNull(LauncherSettings.fromJson(settings)); + + assertEquals("CUSTOM", settings.get("backgroundType").getAsString()); + assertEquals("DIRECT", settings.get("proxyType").getAsString()); + assertEquals(EnumBackgroundImage.CUSTOM, launcherSettings.backgroundImageTypeProperty().get()); + assertEquals(Proxy.Type.DIRECT, launcherSettings.proxyTypeProperty().get()); + } + + /// Tests migrating legacy automatic download source fields into current download source fields. + @Test + public void migratesLegacyAutomaticDownloadSources() { + JsonObject settings = JsonParser.parseString(""" + { + "autoChooseDownloadType": true, + "versionListSource": "mirror", + "downloadType": "mojang" + } + """).getAsJsonObject(); + + LegacyConfigMigrator.migrateLegacyDownloadSources(settings); + LauncherSettings launcherSettings = Objects.requireNonNull(LauncherSettings.fromJson(settings)); + JsonObject serialized = JsonParser.parseString(launcherSettings.toJson()).getAsJsonObject(); + + assertFalse(settings.has("autoChooseDownloadType")); + assertFalse(settings.has("downloadType")); + assertEquals("MIRROR", settings.get("versionListSource").getAsString()); + assertEquals("MIRROR", settings.get("fileDownloadSource").getAsString()); + assertEquals(DownloadSource.MIRROR, launcherSettings.versionListSourceProperty().get()); + assertEquals(DownloadSource.MIRROR, launcherSettings.fileDownloadSourceProperty().get()); + assertFalse(serialized.has("autoChooseDownloadType")); + assertFalse(serialized.has("downloadType")); + } + + /// Tests migrating the legacy balanced automatic download source into the default source. + @Test + public void migratesLegacyBalancedDownloadSource() { + JsonObject settings = JsonParser.parseString(""" + { + "autoChooseDownloadType": true, + "versionListSource": "balanced" + } + """).getAsJsonObject(); + + LegacyConfigMigrator.migrateLegacyDownloadSources(settings); + LauncherSettings launcherSettings = Objects.requireNonNull(LauncherSettings.fromJson(settings)); + + assertEquals("DEFAULT", settings.get("versionListSource").getAsString()); + assertEquals("DEFAULT", settings.get("fileDownloadSource").getAsString()); + assertEquals(DownloadSource.DEFAULT, launcherSettings.versionListSourceProperty().get()); + assertEquals(DownloadSource.DEFAULT, launcherSettings.fileDownloadSourceProperty().get()); + } + + /// Tests migrating legacy direct download source fields into current download source fields. + @Test + public void migratesLegacyDirectDownloadSources() { + JsonObject settings = JsonParser.parseString(""" + { + "autoChooseDownloadType": false, + "versionListSource": "official", + "downloadType": "bmclapi" + } + """).getAsJsonObject(); + + LegacyConfigMigrator.migrateLegacyDownloadSources(settings); + LauncherSettings launcherSettings = Objects.requireNonNull(LauncherSettings.fromJson(settings)); + JsonObject serialized = JsonParser.parseString(launcherSettings.toJson()).getAsJsonObject(); + + assertFalse(settings.has("autoChooseDownloadType")); + assertFalse(settings.has("downloadType")); + assertEquals("MIRROR", settings.get("versionListSource").getAsString()); + assertEquals("MIRROR", settings.get("fileDownloadSource").getAsString()); + assertEquals(DownloadSource.MIRROR, launcherSettings.versionListSourceProperty().get()); + assertEquals(DownloadSource.MIRROR, launcherSettings.fileDownloadSourceProperty().get()); + assertFalse(serialized.has("autoChooseDownloadType")); + assertFalse(serialized.has("downloadType")); + } + + /// Tests migrating the legacy Mojang direct download source into the official source. + @Test + public void migratesLegacyMojangDownloadSource() { + JsonObject settings = JsonParser.parseString(""" + { + "autoChooseDownloadType": false, + "downloadType": "mojang" + } + """).getAsJsonObject(); + + LegacyConfigMigrator.migrateLegacyDownloadSources(settings); + LauncherSettings launcherSettings = Objects.requireNonNull(LauncherSettings.fromJson(settings)); + + assertEquals("OFFICIAL", settings.get("versionListSource").getAsString()); + assertEquals("OFFICIAL", settings.get("fileDownloadSource").getAsString()); + assertEquals(DownloadSource.OFFICIAL, launcherSettings.versionListSourceProperty().get()); + assertEquals(DownloadSource.OFFICIAL, launcherSettings.fileDownloadSourceProperty().get()); + } + + /// Tests migrating the legacy selected account string into a structured account reference. + @Test + public void migratesLegacySelectedAccountToReference() { + JsonObject settings = JsonParser.parseString(""" + { + "accounts": [ + { + "type": "offline", + "username": "Alex" + } + ], + "selectedAccount": "Alex:Alex" + } + """).getAsJsonObject(); + + AccountStorages accountStorages = Objects.requireNonNull(LegacyConfigMigrator.extractAccountStorages(settings)); + assertTrue(LegacyConfigMigrator.migrateLegacySelectedAccount(settings, accountStorages)); + LauncherSettings launcherSettings = Objects.requireNonNull(LauncherSettings.fromJson(settings)); + + JsonObject selectedAccount = Objects.requireNonNull(launcherSettings.selectedAccountProperty().get()); + assertEquals("local", selectedAccount.get("storage").getAsString()); + assertEquals("offline", selectedAccount.get("type").getAsString()); + assertEquals("Alex", selectedAccount.get("username").getAsString()); + } + + /// Tests migrating older selected account values that store only the username. + @Test + public void migratesLegacySelectedAccountUsernameToReference() { + JsonObject settings = JsonParser.parseString(""" + { + "accounts": [ + { + "type": "offline", + "username": "Alex" + } + ], + "selectedAccount": "Alex" + } + """).getAsJsonObject(); + + AccountStorages accountStorages = Objects.requireNonNull(LegacyConfigMigrator.extractAccountStorages(settings)); + assertTrue(LegacyConfigMigrator.migrateLegacySelectedAccount(settings, accountStorages)); + + JsonObject selectedAccount = settings.getAsJsonObject("selectedAccount"); + assertEquals("local", selectedAccount.get("storage").getAsString()); + assertEquals("offline", selectedAccount.get("type").getAsString()); + assertEquals("Alex", selectedAccount.get("username").getAsString()); + } + + /// Tests migrating legacy selected Microsoft account identifiers with hyphenated UUIDs. + @Test + public void migratesLegacySelectedMicrosoftAccountToReference() { + JsonObject settings = JsonParser.parseString(""" + { + "accounts": [ + { + "type": "microsoft", + "uuid": "123456781234123412341234567890ab", + "userid": "user-id" + } + ], + "selectedAccount": "microsoft:12345678-1234-1234-1234-1234567890ab" + } + """).getAsJsonObject(); + + AccountStorages accountStorages = Objects.requireNonNull(LegacyConfigMigrator.extractAccountStorages(settings)); + assertTrue(LegacyConfigMigrator.migrateLegacySelectedAccount(settings, accountStorages)); + + JsonObject selectedAccount = settings.getAsJsonObject("selectedAccount"); + assertEquals("local", selectedAccount.get("storage").getAsString()); + assertEquals("microsoft", selectedAccount.get("type").getAsString()); + assertEquals("123456781234123412341234567890ab", selectedAccount.get("uuid").getAsString()); + assertEquals("user-id", selectedAccount.get("userid").getAsString()); + } + + /// Tests serializing selected account references as JSON objects. + @Test + public void serializesSelectedAccountReferenceAsObject() { + LauncherSettings launcherSettings = new LauncherSettings(); + JsonObject selectedAccount = new JsonObject(); + selectedAccount.addProperty("storage", "user"); + selectedAccount.addProperty("type", "microsoft"); + selectedAccount.addProperty("uuid", "123456781234123412341234567890ab"); + launcherSettings.selectedAccountProperty().set(selectedAccount); + + JsonObject serialized = JsonParser.parseString(launcherSettings.toJson()).getAsJsonObject(); + + JsonObject serializedSelectedAccount = serialized.getAsJsonObject("selectedAccount"); + assertEquals("user", serializedSelectedAccount.get("storage").getAsString()); + assertEquals("microsoft", serializedSelectedAccount.get("type").getAsString()); + assertEquals("123456781234123412341234567890ab", serializedSelectedAccount.get("uuid").getAsString()); + } + + /// Tests that launcher settings serialization preserves a patch-version schema and unknown fields. + @Test + public void preservesPatchSchemaAndUnknownFields() { + LauncherSettings launcherSettings = Objects.requireNonNull(LauncherSettings.fromJson(JsonParser.parseString(""" + { + "$schema": "https://schemas.glavo.site/hmcl/settings/1.0.1", + "futureField": true + } + """).getAsJsonObject())); + + JsonObject serialized = JsonParser.parseString(launcherSettings.toJson()).getAsJsonObject(); + + assertEquals("https://schemas.glavo.site/hmcl/settings/1.0.1", + serialized.get(JsonSchema.PROPERTY_SCHEMA).getAsString()); + assertTrue(serialized.get("futureField").getAsBoolean()); + } + + /// Tests migrating the legacy workspace-wide automatic Java agent permission into game settings. + @Test + public void migratesLegacyAllowAutoAgentToGameSettings() { + JsonObject settings = JsonParser.parseString(""" + { + "allowAutoAgent": true + } + """).getAsJsonObject(); + + LauncherSettings launcherSettings = new LauncherSettings(); + GameSettingsPresets gameSettingsPresets = new GameSettingsPresets(); + + LegacyConfigMigrator.migrateLegacyAllowAutoAgent( + launcherSettings, + gameSettingsPresets, + settings.remove("allowAutoAgent")); + JsonObject serializedLauncherSettings = JsonParser.parseString(launcherSettings.toJson()).getAsJsonObject(); + JsonObject serializedGameSettings = JsonParser.parseString( + JsonUtils.GSON.toJson(gameSettingsPresets, GameSettingsPresets.class) + ).getAsJsonObject(); + + assertFalse(settings.has("allowAutoAgent")); + assertFalse(serializedLauncherSettings.has("allowAutoAgent")); + assertEquals(1, gameSettingsPresets.getPresets().size()); + + GameSettings.Preset preset = gameSettingsPresets.getPresets().get(0); + assertEquals(preset.idProperty().getValue(), launcherSettings.defaultGameSettingsPresetProperty().get()); + assertTrue(preset.allowAutoAgentProperty().getValue()); + assertTrue(serializedGameSettings + .getAsJsonArray("presets") + .get(0) + .getAsJsonObject() + .get("allowAutoAgent") + .getAsBoolean()); + } + + /// Tests migrating the legacy workspace-wide automatic game options switch into game settings. + @Test + public void migratesLegacyDisableAutoGameOptionsToGameSettings() { + JsonObject settings = JsonParser.parseString(""" + { + "disableAutoGameOptions": true + } + """).getAsJsonObject(); + + LauncherSettings launcherSettings = new LauncherSettings(); + GameSettingsPresets gameSettingsPresets = new GameSettingsPresets(); + + LegacyConfigMigrator.migrateLegacyDisableAutoGameOptions( + launcherSettings, + gameSettingsPresets, + settings.remove("disableAutoGameOptions")); + JsonObject serializedLauncherSettings = JsonParser.parseString(launcherSettings.toJson()).getAsJsonObject(); + JsonObject serializedGameSettings = JsonParser.parseString( + JsonUtils.GSON.toJson(gameSettingsPresets, GameSettingsPresets.class) + ).getAsJsonObject(); + + assertFalse(settings.has("disableAutoGameOptions")); + assertFalse(serializedLauncherSettings.has("disableAutoGameOptions")); + assertEquals(1, gameSettingsPresets.getPresets().size()); + + GameSettings.Preset preset = gameSettingsPresets.getPresets().get(0); + assertEquals(preset.idProperty().getValue(), launcherSettings.defaultGameSettingsPresetProperty().get()); + assertTrue(preset.disableAutoGameOptionsProperty().getValue()); + assertTrue(serializedGameSettings + .getAsJsonArray("presets") + .get(0) + .getAsJsonObject() + .get("disableAutoGameOptions") + .getAsBoolean()); + } +} diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/setting/LauncherStateTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/setting/LauncherStateTest.java new file mode 100644 index 00000000000..84479ff3028 --- /dev/null +++ b/HMCL/src/test/java/org/jackhuang/hmcl/setting/LauncherStateTest.java @@ -0,0 +1,72 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.setting; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.jetbrains.annotations.NotNullByDefault; +import org.junit.jupiter.api.Test; + +import java.util.Objects; + +import static org.junit.jupiter.api.Assertions.*; + +/// Tests for detached launcher state migration. +@NotNullByDefault +public final class LauncherStateTest { + /// Tests extracting runtime state fields from an upstream/main config object. + @Test + public void extractsLauncherStateFromLegacyConfigJson() { + JsonObject settings = JsonParser.parseString(""" + { + "x": 0.25, + "y": 0.5, + "width": 1280.0, + "height": 720.0, + "promptedVersion": "3.6.15", + "shownTips": { + "javaVersionTip": 21 + }, + "logLines": 5000, + "localization": "en" + } + """).getAsJsonObject(); + + LauncherState state = LegacyConfigMigrator.extractLauncherState(settings); + + assertFalse(settings.has("x")); + assertFalse(settings.has("y")); + assertFalse(settings.has("width")); + assertFalse(settings.has("height")); + assertFalse(settings.has("promptedVersion")); + assertFalse(settings.has("shownTips")); + assertTrue(settings.has("logLines")); + assertTrue(settings.has("localization")); + + assertEquals(0.25, state.getX()); + assertEquals(0.5, state.getY()); + assertEquals(1280.0, state.getWidth()); + assertEquals(720.0, state.getHeight()); + assertEquals("3.6.15", state.getPromptedVersion()); + assertEquals(21.0, state.getShownTips().get("javaVersionTip")); + assertEquals(LauncherState.CURRENT_SCHEMA, state.getSchema()); + + LauncherSettings config = Objects.requireNonNull(LauncherSettings.fromJson(settings)); + assertEquals(5000, config.logLinesProperty().get()); + } +} diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/setting/UserSettingsTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/setting/UserSettingsTest.java new file mode 100644 index 00000000000..daf88a3feb4 --- /dev/null +++ b/HMCL/src/test/java/org/jackhuang/hmcl/setting/UserSettingsTest.java @@ -0,0 +1,72 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.setting; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.jackhuang.hmcl.util.gson.JsonSchema; +import org.jetbrains.annotations.NotNullByDefault; +import org.junit.jupiter.api.Test; + +import java.util.Objects; + +import static org.junit.jupiter.api.Assertions.*; + +/// Tests for user settings serialization and legacy migration. +@NotNullByDefault +public final class UserSettingsTest { + /// Tests that new user settings are serialized with the current schema. + @Test + public void writesCurrentSchema() { + UserSettings settings = new UserSettings(); + + JsonObject serialized = JsonParser.parseString(settings.toJson()).getAsJsonObject(); + + assertEquals(UserSettings.CURRENT_SCHEMA.url(), serialized.get(JsonSchema.PROPERTY_SCHEMA).getAsString()); + } + + /// Tests that legacy global config content can be read as current user settings. + @Test + public void readsLegacyGlobalConfigFormat() { + UserSettings settings = Objects.requireNonNull(UserSettings.fromJson(""" + { + "agreementVersion": 1, + "terracottaAgreementVersion": 2, + "platformPromptVersion": 3, + "logRetention": 7, + "enableOfflineAccount": true, + "fontAntiAliasing": "gray", + "userJava": ["java-a"], + "disabledJava": ["java-b"] + } + """)); + + assertEquals(UserSettings.CURRENT_SCHEMA, settings.getSchema()); + assertEquals(1, settings.agreementVersionProperty().get()); + assertEquals(2, settings.terracottaAgreementVersionProperty().get()); + assertEquals(3, settings.platformPromptVersionProperty().get()); + assertEquals(7, settings.logRetentionProperty().get()); + assertTrue(settings.enableOfflineAccountProperty().get()); + assertEquals("gray", settings.fontAntiAliasingProperty().get()); + assertTrue(settings.getUserJava().contains("java-a")); + assertTrue(settings.getDisabledJava().contains("java-b")); + + JsonObject serialized = JsonParser.parseString(settings.toJson()).getAsJsonObject(); + assertEquals(UserSettings.CURRENT_SCHEMA.url(), serialized.get(JsonSchema.PROPERTY_SCHEMA).getAsString()); + } +} diff --git a/HMCLCore/build.gradle.kts b/HMCLCore/build.gradle.kts index 7035c76c479..6862832128f 100644 --- a/HMCLCore/build.gradle.kts +++ b/HMCLCore/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { api(libs.pci.ids) api(libs.hello.nbt) api(libs.weburl) + api(libs.uuid.creator) compileOnlyApi(libs.jetbrains.annotations) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/Account.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/Account.java index 3d40c983fe1..2ec175af273 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/Account.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/Account.java @@ -17,6 +17,8 @@ */ package org.jackhuang.hmcl.auth; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; import javafx.application.Platform; import javafx.beans.InvalidationListener; import javafx.beans.Observable; @@ -29,6 +31,7 @@ import org.jackhuang.hmcl.auth.yggdrasil.TextureType; import org.jackhuang.hmcl.util.ToStringBuilder; import org.jackhuang.hmcl.util.javafx.ObservableHelper; +import org.jetbrains.annotations.Contract; import java.nio.file.Path; import java.util.Map; @@ -66,6 +69,7 @@ public abstract class Account implements Observable { /** * Play offline. + * * @return the specific offline player's info. */ public abstract AuthInfo playOffline() throws AuthenticationException; @@ -97,7 +101,31 @@ public void setPortable(boolean value) { this.portable.set(value); } - public abstract String getIdentifier(); + /// Writes stable fields that identify this account into the given JSON object. + /// + /// The identifier object must not contain credentials or other secrets. It is used only to find the + /// same account again after account storages have been reloaded. + @Contract(mutates = "param1") + public abstract void toIdentifier(JsonObject json); + + /// Returns whether the given identifier object matches the stable identifier fields of this account. + /// + /// Extra members in the given object are ignored by this method so callers can add storage or type metadata. + @Contract(pure = true) + public boolean matchIdentifier(JsonObject json) { + JsonObject identifier = new JsonObject(); + toIdentifier(identifier); + + for (Map.Entry entry : identifier.asMap().entrySet()) { + String key = entry.getKey(); + JsonElement value = entry.getValue(); + + if (!value.equals(json.get(key))) + return false; + } + + return true; + } private final ObservableHelper helper = new ObservableHelper(this); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorAccount.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorAccount.java index 6be37269aba..cfb8f2a695a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorAccount.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorAccount.java @@ -17,6 +17,7 @@ */ package org.jackhuang.hmcl.auth.authlibinjector; +import com.google.gson.JsonObject; import org.jackhuang.hmcl.auth.*; import org.jackhuang.hmcl.auth.yggdrasil.CompleteGameProfile; import org.jackhuang.hmcl.auth.yggdrasil.TextureType; @@ -153,9 +154,11 @@ public AuthlibInjectorServer getServer() { return server; } + /// Writes the authlib-injector server URL and inherited Yggdrasil account identifier fields. @Override - public String getIdentifier() { - return server.getUrl() + ":" + super.getIdentifier(); + public void toIdentifier(JsonObject json) { + json.addProperty("serverBaseURL", server.getUrl()); + super.toIdentifier(json); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftAccount.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftAccount.java index aa061380530..6d0c3de0828 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftAccount.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/microsoft/MicrosoftAccount.java @@ -17,11 +17,13 @@ */ package org.jackhuang.hmcl.auth.microsoft; +import com.google.gson.JsonObject; import javafx.beans.binding.ObjectBinding; import org.jackhuang.hmcl.auth.*; import org.jackhuang.hmcl.auth.yggdrasil.Texture; import org.jackhuang.hmcl.auth.yggdrasil.TextureType; import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService; +import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter; import org.jackhuang.hmcl.util.javafx.BindingMapping; import java.nio.file.Path; @@ -77,9 +79,10 @@ public UUID getUUID() { return session.getProfile().getId(); } + /// Writes the Minecraft profile UUID used to identify this Microsoft account. @Override - public String getIdentifier() { - return "microsoft:" + getUUID(); + public void toIdentifier(JsonObject json) { + json.addProperty("uuid", UUIDTypeAdapter.fromUUID(getUUID())); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccount.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccount.java index 7e93fa9019c..a56c5c66790 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccount.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccount.java @@ -17,6 +17,7 @@ */ package org.jackhuang.hmcl.auth.offline; +import com.google.gson.JsonObject; import javafx.beans.binding.ObjectBinding; import org.jackhuang.hmcl.auth.Account; import org.jackhuang.hmcl.auth.AuthInfo; @@ -85,9 +86,10 @@ public String getCharacter() { return username; } + /// Writes the offline username used to identify this account. @Override - public String getIdentifier() { - return username + ":" + username; + public void toIdentifier(JsonObject json) { + json.addProperty("username", username); } public Skin getSkin() { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilAccount.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilAccount.java index f253eb40b37..b057c641350 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilAccount.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilAccount.java @@ -17,6 +17,7 @@ */ package org.jackhuang.hmcl.auth.yggdrasil; +import com.google.gson.JsonObject; import javafx.beans.binding.ObjectBinding; import org.jackhuang.hmcl.auth.*; import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter; @@ -97,9 +98,11 @@ public UUID getUUID() { return session.getSelectedProfile().getId(); } + /// Writes the username and selected character UUID used to identify this Yggdrasil account. @Override - public String getIdentifier() { - return getUsername() + ":" + getUUID(); + public void toIdentifier(JsonObject json) { + json.addProperty("username", getUsername()); + json.addProperty("uuid", UUIDTypeAdapter.fromUUID(getUUID())); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java index 3f2407a5ff0..19190428e97 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -110,17 +110,9 @@ public Path getArtifactFile(Version version, Artifact artifact) { return artifact.getPath(getBaseDirectory().resolve("libraries")); } - public GameDirectoryType getGameDirectoryType(String id) { - return GameDirectoryType.ROOT_FOLDER; - } - @Override public Path getRunDirectory(String id) { - return switch (getGameDirectoryType(id)) { - case VERSION_FOLDER -> getVersionRoot(id); - case ROOT_FOLDER -> getBaseDirectory(); - default -> throw new IllegalStateException(); - }; + return getBaseDirectory(); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchOptions.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchOptions.java index cd7d960d036..cdb26cbb432 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchOptions.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchOptions.java @@ -61,6 +61,8 @@ public class LaunchOptions implements Serializable { private boolean useNativeGLFW; private boolean useNativeOpenAL; private boolean enableDebugLogOutput; + private boolean allowAutoAgent; + private boolean disableAutoGameOptions; private boolean daemon; /** @@ -265,6 +267,15 @@ public boolean isEnableDebugLogOutput() { return enableDebugLogOutput; } + public boolean isAllowAutoAgent() { + return allowAutoAgent; + } + + /// Returns whether automatic game options generation is disabled. + public boolean isDisableAutoGameOptions() { + return disableAutoGameOptions; + } + /** * Will launcher keeps alive after game launched or not. */ @@ -469,5 +480,15 @@ public Builder setEnableDebugLogOutput(boolean u) { options.enableDebugLogOutput = u; return this; } + + public Builder setAllowAutoAgent(boolean allowAutoAgent) { + options.allowAutoAgent = allowAutoAgent; + return this; + } + + public Builder setDisableAutoGameOptions(boolean disableAutoGameOptions) { + options.disableAutoGameOptions = disableAutoGameOptions; + return this; + } } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameDirectoryType.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/QuickPlayType.java similarity index 64% rename from HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameDirectoryType.java rename to HMCLCore/src/main/java/org/jackhuang/hmcl/game/QuickPlayType.java index 1e2c5fe569e..44c9d85b87f 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameDirectoryType.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/QuickPlayType.java @@ -1,6 +1,6 @@ /* * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors + * Copyright (C) 2026 huangyuhui and contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,22 +17,13 @@ */ package org.jackhuang.hmcl.game; -/** - * Determines where game runs in and game files such as mods. - * - * @author huangyuhui - */ -public enum GameDirectoryType { - /** - * .minecraft - */ - ROOT_FOLDER, - /** - * .minecraft/versions/<version name> - */ - VERSION_FOLDER, - /** - * user customized directory. - */ - CUSTOM +/// @author Glavo +public enum QuickPlayType { + NONE, + + MULTIPLAYER, + + SINGLEPLAYER, + + REALMS, } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/Renderer.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/Renderer.java index 146747d6462..99b04648b00 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/Renderer.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/Renderer.java @@ -17,6 +17,10 @@ */ package org.jackhuang.hmcl.game; +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.platform.*; @@ -542,4 +546,22 @@ record Unknown(String name) implements Renderer { default boolean isSupported(Platform platform, @Nullable List cards) { return true; } + + final class Adapter extends TypeAdapter<@Nullable Renderer> { + + @Override + public void write(JsonWriter out, @Nullable Renderer value) throws IOException { + out.value(value != null ? value.name() : null); + } + + @Override + public @Nullable Renderer read(JsonReader in) throws IOException { + if (in.peek() == JsonToken.NULL) { + in.nextNull(); + return null; + } + + return Renderer.of(in.nextString()); + } + } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModAdviser.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModAdviser.java index 837bdbcb02d..1877f9d6e0d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModAdviser.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModAdviser.java @@ -50,7 +50,7 @@ enum ModSuggestion { "launcher_profiles.json", "launcher.pack.lzma", // Old Minecraft Launcher "launcher_accounts.json", "launcher_cef_log.txt", "launcher_log.txt", "launcher_msa_credentials.bin", "launcher_settings.json", "launcher_ui_state.json", "realms_persistence.json", "webcache2", "treatment_tags.json", // New Minecraft Launcher "clientId.txt", "PCL.ini", // Plain Craft Launcher - "backup", "pack.json", "launcher.jar", "cache", "modpack.cfg", "log4j2.xml", "hmclversion.cfg", // HMCL + ".hmcl", "backup", "pack.json", "launcher.jar", "cache", "modpack.cfg", "log4j2.xml", "hmclversion.cfg", "instance-game-settings.json", // HMCL "manifest.json", "minecraftinstance.json", ".curseclient", // Curse "modrinth.index.json", // Modrinth ".fabric", ".mixin.out", ".optifine", // Fabric/OptiFine diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/PortablePath.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/PortablePath.java new file mode 100644 index 00000000000..7b385853044 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/PortablePath.java @@ -0,0 +1,144 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.util; + +import com.google.gson.JsonParseException; +import com.google.gson.TypeAdapter; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; +import org.jackhuang.hmcl.util.gson.JsonSerializable; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Objects; + +/// Stores a path string together with whether the path is absolute. +/// +/// Relative paths use `/` as their portable separator. Absolute paths are kept as +/// provided so their platform-specific separators are preserved. +/// +/// @author Glavo +@JsonAdapter(PortablePath.Adapter.class) +@JsonSerializable +@NotNullByDefault +public final class PortablePath { + /// The separator used by relative portable paths. + public static final char SEPARATOR = '/'; + + /// Creates a portable path. + /// + /// @param path the path string + /// @return the portable path + public static PortablePath of(String path) { + Objects.requireNonNull(path); + + boolean absolute = isAbsolute(path); + return new PortablePath(absolute ? path : path.replace('\\', SEPARATOR), absolute); + } + + /// Creates a portable path from a [Path]. + /// + /// @param path the path to convert + /// @return the portable path + public static PortablePath fromPath(Path path) { + return of(Objects.requireNonNull(path).toString()); + } + + /// Returns whether the given path string is absolute. + private static boolean isAbsolute(String path) { + if (path.startsWith("/") || path.startsWith("\\")) { + return true; + } + + if (path.length() >= 2 && path.charAt(1) == ':') { + char ch = path.charAt(0); + return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z'); + } + + return false; + } + + /// The stored path string. + private final String path; + + /// Whether the stored path is absolute. + private final boolean absolute; + + /// Creates a portable path with a normalized relative path string and an absolute flag. + private PortablePath(String path, boolean absolute) { + this.path = path; + this.absolute = absolute; + } + + /// Returns the stored path string. + /// + /// @return the stored path string + public String getPath() { + return path; + } + + /// Returns whether this path is absolute. + /// + /// @return whether this path is absolute + public boolean isAbsolute() { + return absolute; + } + + /// Converts this portable path to a [Path] on the current platform. + /// + /// @return the converted path + public Path toPath() { + return Path.of(path); + } + + /// Returns the stored path string. + /// + /// @return the stored path string + @Override + public String toString() { + return path; + } + + /// Gson adapter that serializes portable paths as strings. + @NotNullByDefault + public static final class Adapter extends TypeAdapter<@Nullable PortablePath> { + /// Writes a portable path as its stored path string, or JSON null when the value is null. + @Override + public void write(JsonWriter out, @Nullable PortablePath value) throws IOException { + out.value(value == null ? null : value.getPath()); + } + + /// Reads a portable path from a string or JSON null. + @Override + public @Nullable PortablePath read(JsonReader in) throws IOException { + if (in.peek() == JsonToken.NULL) { + in.nextNull(); + return null; + } + if (in.peek() != JsonToken.STRING) { + throw new JsonParseException("PortablePath must be a string: " + in.peek()); + } + + return PortablePath.of(in.nextString()); + } + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/gson/EnumOrdinalDeserializer.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/gson/EnumOrdinalDeserializer.java deleted file mode 100644 index 73f2027b3fa..00000000000 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/gson/EnumOrdinalDeserializer.java +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.util.gson; - -import com.google.gson.*; -import com.google.gson.annotations.SerializedName; - -import java.lang.reflect.Type; -import java.util.HashMap; -import java.util.Map; - -/** - * A deserializer that supports deserializing strings and **numbers** into enums. - * - * @author yushijinhun - */ -public final class EnumOrdinalDeserializer> implements JsonDeserializer { - - private final Map mapping = new HashMap<>(); - - public EnumOrdinalDeserializer(Class enumClass) { - for (T constant : enumClass.getEnumConstants()) { - mapping.put(String.valueOf(constant.ordinal()), constant); - String name = constant.name(); - try { - SerializedName annotation = enumClass.getField(name).getAnnotation(SerializedName.class); - if (annotation != null) { - name = annotation.value(); - for (String alternate : annotation.alternate()) { - mapping.put(alternate, constant); - } - } - } catch (NoSuchFieldException e) { - throw new AssertionError(e); - } - mapping.put(name, constant); - } - } - - @Override - public T deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { - if (json == null || json.isJsonNull()) - return null; - - String name = json.getAsString(); - T value = mapping.get(name); - if (value == null) - throw new JsonParseException("No enum constant with name " + name); - return value; - } - -} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/gson/GUIDTypeAdapter.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/gson/GUIDTypeAdapter.java new file mode 100644 index 00000000000..59da3814739 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/gson/GUIDTypeAdapter.java @@ -0,0 +1,77 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2020 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.util.gson; + +import com.github.f4b6a3.uuid.alt.GUID; +import com.google.gson.JsonParseException; +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; + +/// Gson adapter for [GUID]. +/// +/// HMCL mainly uses [GUID] as a Gson-serialized replacement for [java.util.UUID] in +/// configuration models. A GUID is stored as the same 36-character canonical string +/// used by [java.util.UUID], while application code can keep the stronger [GUID] type. +/// +/// This adapter also accepts the compact 32-character UUID string form supported by +/// [UUIDTypeAdapter] when reading existing JSON values. +/// +/// @author Glavo +@JsonSerializable +@NotNullByDefault +public final class GUIDTypeAdapter extends TypeAdapter<@Nullable GUID> { + /// Shared stateless adapter instance. + public static final GUIDTypeAdapter INSTANCE = new GUIDTypeAdapter(); + + /// Creates a GUID type adapter. + private GUIDTypeAdapter() { + } + + /// Writes a GUID as a canonical UUID string, or JSON null when the value is null. + @Override + public void write(JsonWriter writer, @Nullable GUID value) throws IOException { + if (value == null) { + writer.nullValue(); + } else { + writer.value(value.toString()); + } + } + + /// Reads a GUID from a canonical or compact UUID string, or returns null for JSON null. + @Override + public @Nullable GUID read(JsonReader reader) throws IOException { + if (reader.peek() == JsonToken.NULL) { + reader.nextNull(); + return null; + } + + String value = reader.nextString(); + try { + return new GUID(UUIDTypeAdapter.fromString(value)); + } catch (IllegalArgumentException e) { + throw new JsonParseException("GUID malformed: " + value, e); + } + } + +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/gson/JsonSchema.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/gson/JsonSchema.java new file mode 100644 index 00000000000..56858c294fb --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/gson/JsonSchema.java @@ -0,0 +1,503 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.util.gson; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; +import com.google.gson.TypeAdapter; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.util.Objects; + +/// Stores a raw JSON schema string and, when possible, its parsed HMCL schema identifier. +/// +/// The JSON representation is always a string. HMCL-owned schemas use the following +/// fixed URL form, but other strings can still be represented as unparseable schemas: +/// +/// `https://schemas.glavo.site/hmcl//` +/// +/// Canonical URLs written by this class use `major.minor.patch` versions. Parsing +/// also accepts `major.minor` versions and treats the missing patch number as `0`. +/// +/// Parsed HMCL schemas use the following compatibility policy: +/// +/// - When the schema string is not parseable as an HMCL schema URL, the file must be rejected. +/// - When the schema ID differs from the expected schema, the file must be rejected. +/// - When the current code does not support the major version, the file must be rejected. +/// - When the minor version is newer, the file may be read but must not be overwritten. +/// - When only the patch version differs, the file may be read and saved while preserving the original schema string +/// and unknown serialized members. +/// +/// @param value the raw JSON schema string +/// @param parsed the parsed HMCL schema identifier, or `null` when the string is not parseable +/// @author Glavo +@JsonSerializable +@JsonAdapter(JsonSchema.Adapter.class) +@NotNullByDefault +public record JsonSchema(String value, @Nullable Parsed parsed) { + /// The JSON property name used for schema strings. + public static final String PROPERTY_SCHEMA = "$schema"; + + /// The HMCL schema URL prefix. + private static final String URL_PREFIX = "https://schemas.glavo.site/hmcl/"; + + /// @param value the raw JSON schema string + /// @param parsed the parsed HMCL schema identifier, or `null` when the string is not parseable + public JsonSchema { + Objects.requireNonNull(value); + if (parsed != null && !parsed.equals(parseSchemaUrl(value))) { + throw new IllegalArgumentException("Parsed schema does not match raw schema string: " + value); + } + } + + /// Creates a schema from any raw string value. + /// + /// @param value the raw JSON schema string + public JsonSchema(String value) { + this(value, parseSchemaUrl(value)); + } + + /// Creates a parsed HMCL schema from an ID and version. + /// + /// @param id the stable schema identifier + /// @param version the schema version + public JsonSchema(String id, Version version) { + this(new Parsed(id, version)); + } + + /// Creates a schema from a parsed HMCL schema identifier. + private JsonSchema(Parsed parsed) { + this(parsed.url(), parsed); + } + + /// Reads a schema string from the default schema member of a container object. + /// + /// @param object the container object that contains the schema member + /// @return the schema string and optional parsed identifier + /// @throws JsonParseException if the schema member is missing or is not a string + public static JsonSchema readFromMember(JsonObject object) throws JsonParseException { + return readFromMember(object, PROPERTY_SCHEMA); + } + + /// Reads a schema string from a member of a container object. + /// + /// @param object the container object that contains the schema member + /// @param memberName the JSON member name + /// @return the schema string and optional parsed identifier + /// @throws JsonParseException if the schema member is missing or is not a string + public static JsonSchema readFromMember(JsonObject object, String memberName) throws JsonParseException { + Objects.requireNonNull(object); + Objects.requireNonNull(memberName); + + return parseElement(object.get(memberName), "member `" + memberName + "`"); + } + + /// Reads and checks the default schema member of a JSON object. + /// + /// @param object the JSON object that contains the schema string + /// @param expected the schema supported by the current code + /// @return the schema check result + public static CheckResult check(JsonObject object, JsonSchema expected) { + return check(object, PROPERTY_SCHEMA, expected); + } + + /// Reads and checks a schema string JSON object member. + /// + /// @param object the JSON object that contains the schema string + /// @param memberName the JSON member name + /// @param expected the schema supported by the current code + /// @return the schema check result + public static CheckResult check(JsonObject object, String memberName, JsonSchema expected) { + Objects.requireNonNull(object); + Objects.requireNonNull(memberName); + Objects.requireNonNull(expected); + if (!expected.isParsed()) { + throw new IllegalArgumentException("Expected JSON schema must be parseable: " + expected); + } + + if (!object.has(memberName)) { + return new CheckResult(null, expected, CheckResult.Status.MISSING, null); + } + + JsonSchema actual; + try { + actual = readFromMember(object, memberName); + } catch (JsonParseException e) { + return new CheckResult(null, expected, CheckResult.Status.INVALID, String.valueOf(object.get(memberName))); + } + + @Nullable Parsed actualParsed = actual.parsed; + if (actualParsed == null) { + return new CheckResult(actual, expected, CheckResult.Status.UNPARSEABLE, null); + } + + return new CheckResult(actual, expected, actualParsed.id.equals(Objects.requireNonNull(expected.parsed).id) + ? CheckResult.Status.VALID + : CheckResult.Status.UNEXPECTED_ID, null); + } + + /// Parses a schema string from a JSON element. + private static JsonSchema parseElement(@Nullable JsonElement element, String source) throws JsonParseException { + if (!(element instanceof JsonPrimitive primitive) || !primitive.isString()) { + throw new JsonParseException("Invalid JSON schema " + source + ": " + element); + } + + return new JsonSchema(primitive.getAsString()); + } + + /// Parses an HMCL schema URL, returning `null` for any other string. + private static @Nullable Parsed parseSchemaUrl(String value) { + Objects.requireNonNull(value); + + if (!value.startsWith(URL_PREFIX)) { + return null; + } + + String path = value.substring(URL_PREFIX.length()); + int slash = path.indexOf('/'); + if (slash <= 0 || slash != path.lastIndexOf('/') || slash == path.length() - 1) { + return null; + } + + String id = path.substring(0, slash); + String versionString = path.substring(slash + 1); + if (!isValidId(id)) { + return null; + } + + if (versionString.isEmpty()) { + return null; + } + + Version version; + try { + version = Version.parse(versionString); + } catch (IllegalArgumentException e) { + return null; + } + + if (!isAcceptedVersionString(versionString, version)) { + return null; + } + + return new Parsed(id, version); + } + + /// Returns whether a raw version string is an accepted representation of a parsed version. + private static boolean isAcceptedVersionString(String versionString, Version version) { + return versionString.equals(version.toString()) + || (version.patch() == 0 && versionString.equals(version.major() + "." + version.minor())); + } + + /// Returns whether this schema string is parseable as an HMCL schema URL. + public boolean isParsed() { + return parsed != null; + } + + /// Returns the parsed HMCL schema ID, or `null` when the schema string is not parseable. + public @Nullable String id() { + return parsed != null ? parsed.id : null; + } + + /// Returns the parsed HMCL schema version, or `null` when the schema string is not parseable. + public @Nullable Version version() { + return parsed != null ? parsed.version : null; + } + + /// Returns the raw schema string. + public String url() { + return value; + } + + /// Returns the raw schema string. + @Override + public String toString() { + return value; + } + + /// Returns whether a schema ID is valid. + private static boolean isValidId(String id) { + if (id.isEmpty()) { + return false; + } + + char first = id.charAt(0); + if (first < 'a' || first > 'z') { + return false; + } + + for (int i = 1; i < id.length(); i++) { + char ch = id.charAt(i); + if (!((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-')) { + return false; + } + } + + return true; + } + + /// Parsed identifier for an HMCL schema URL. + /// + /// @param id the stable schema identifier + /// @param version the schema version + @NotNullByDefault + public record Parsed(String id, Version version) { + /// @param id the stable schema identifier + /// @param version the schema version + public Parsed { + Objects.requireNonNull(id); + Objects.requireNonNull(version); + + if (!isValidId(id)) { + throw new IllegalArgumentException("Invalid JSON schema ID: " + id); + } + } + + /// Returns the canonical schema URL. + public String url() { + return URL_PREFIX + id + "/" + version; + } + } + + /// Result of checking a serialized schema string against the schema supported by the current code. + /// + /// @param actual the schema string read from serialized data, or `null` when no schema string was read + /// @param expected the schema supported by the current code + /// @param status the schema check status + /// @param invalidValue the raw invalid JSON value text, or `null` when the member is a string or is missing + public record CheckResult(@Nullable JsonSchema actual, + JsonSchema expected, + Status status, + @Nullable String invalidValue) { + /// The schema check status. + public enum Status { + /// A schema member exists, was parsed successfully, and has the expected ID. + VALID, + + /// No schema member exists. + MISSING, + + /// A schema member exists but is not a string. + INVALID, + + /// A schema member exists as a string but cannot be parsed as an HMCL schema URL. + UNPARSEABLE, + + /// A schema member exists and was parsed, but its ID differs from the expected ID. + UNEXPECTED_ID + } + + /// Creates a schema check result. + /// + /// @param actual the schema string read from serialized data, or `null` when no schema string was read + /// @param expected the schema supported by the current code + /// @param status the schema check status + /// @param invalidValue the raw invalid JSON value text, or `null` when the member is a string or is missing + public CheckResult { + Objects.requireNonNull(expected); + Objects.requireNonNull(status); + if (!expected.isParsed()) { + throw new IllegalArgumentException("Expected JSON schema must be parseable: " + expected); + } + + if (status == Status.VALID || status == Status.UNEXPECTED_ID || status == Status.UNPARSEABLE) { + Objects.requireNonNull(actual); + } else if (actual != null) { + throw new IllegalArgumentException("Only present JSON schema checks may have an actual schema"); + } + + if ((status == Status.VALID || status == Status.UNEXPECTED_ID) && !Objects.requireNonNull(actual).isParsed()) { + throw new IllegalArgumentException("Only parsed JSON schema checks may be valid or use an unexpected ID"); + } + + if (status == Status.UNPARSEABLE && Objects.requireNonNull(actual).isParsed()) { + throw new IllegalArgumentException("Only unparseable JSON schema strings may have the unparseable status"); + } + + if (status == Status.INVALID) { + Objects.requireNonNull(invalidValue); + } else if (invalidValue != null) { + throw new IllegalArgumentException("Only invalid JSON schema checks may have an invalid value"); + } + } + + /// Returns whether the serialized data does not contain a schema member. + public boolean isMissing() { + return status == Status.MISSING; + } + + /// Returns whether the serialized data contains a non-string schema member. + public boolean isInvalid() { + return status == Status.INVALID; + } + + /// Returns whether the serialized data contains a schema string that is not parseable as an HMCL schema URL. + public boolean isUnparseable() { + return status == Status.UNPARSEABLE; + } + + /// Returns whether the serialized data uses an unexpected schema ID. + public boolean isUnexpectedId() { + return status == Status.UNEXPECTED_ID; + } + + /// Returns whether the serialized schema uses a major version unsupported by the current code. + public boolean hasUnsupportedMajorVersion() { + return status == Status.VALID + && Objects.requireNonNull(actual).parsed.version.major() != Objects.requireNonNull(expected.parsed).version.major(); + } + + /// Returns whether the serialized schema has a newer minor version than the supported schema. + public boolean hasNewerMinorVersion() { + return status == Status.VALID + && Objects.requireNonNull(actual).parsed.version.major() == Objects.requireNonNull(expected.parsed).version.major() + && actual.parsed.version.minor() > expected.parsed.version.minor(); + } + + /// Returns whether the serialized schema has the same major and minor version as the supported schema. + public boolean hasSameMajorAndMinorVersion() { + return status == Status.VALID + && Objects.requireNonNull(actual).parsed.version.major() == Objects.requireNonNull(expected.parsed).version.major() + && actual.parsed.version.minor() == expected.parsed.version.minor(); + } + } + + /// Semantic version marker for a serialized JSON schema. + /// + /// The string representation is the strict `major.minor.patch` form. + /// + /// @param major the major schema version + /// @param minor the minor schema version + /// @param patch the patch schema version + /// @author Glavo + @NotNullByDefault + public record Version(int major, int minor, int patch) implements Comparable { + /// @param major the major schema version + /// @param minor the minor schema version + /// @param patch the patch schema version + public Version { + if (major < 0) throw new IllegalArgumentException("Major version must be non-negative: " + major); + if (minor < 0) throw new IllegalArgumentException("Minor version must be non-negative: " + minor); + if (patch < 0) throw new IllegalArgumentException("Patch version must be non-negative: " + patch); + } + + /// Parses a schema version string. + /// + /// @param version the version string in `major.minor` or `major.minor.patch` form + /// @return the parsed schema version + /// @throws IllegalArgumentException if the version string is invalid + public static Version parse(String version) { + int firstDot = version.indexOf('.'); + int secondDot = version.indexOf('.', firstDot + 1); + if (firstDot <= 0 + || firstDot == version.length() - 1 + || (secondDot >= 0 && (secondDot <= firstDot + 1 + || secondDot != version.lastIndexOf('.') + || secondDot == version.length() - 1))) { + throw new IllegalArgumentException("Invalid JSON schema version: " + version); + } + + try { + int major = parsePart(version, 0, firstDot); + if (secondDot >= 0) { + return new Version( + major, + parsePart(version, firstDot + 1, secondDot), + parsePart(version, secondDot + 1, version.length())); + } else { + return new Version( + major, + parsePart(version, firstDot + 1, version.length()), + 0); + } + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid JSON schema version: " + version, e); + } + } + + /// Parses a decimal version part. + private static int parsePart(String version, int start, int end) { + for (int i = start; i < end; i++) { + char ch = version.charAt(i); + if (ch < '0' || ch > '9') { + throw new IllegalArgumentException("Invalid JSON schema version: " + version); + } + } + + return Integer.parseInt(version.substring(start, end)); + } + + /// Compares this version with another schema version. + /// + /// @param o the other version to compare to + /// @return a negative integer, zero, or a positive integer as this version + /// is less than, equal to, or greater than the specified version + @Override + public int compareTo(Version o) { + if (major != o.major) { + return Integer.compare(major, o.major); + } else if (minor != o.minor) { + return Integer.compare(minor, o.minor); + } else { + return Integer.compare(patch, o.patch); + } + } + + /// Returns the canonical `major.minor.patch` string representation. + @Override + public String toString() { + return major + "." + minor + "." + patch; + } + } + + /// Gson adapter for the JSON string representation of [JsonSchema]. + /// + /// Null JSON values are preserved as null. Non-string values are rejected because + /// schemas are intentionally serialized as raw strings. + @NotNullByDefault + public static final class Adapter extends TypeAdapter<@Nullable JsonSchema> { + /// Writes the schema as a raw string, or JSON null when the value is null. + @Override + public void write(JsonWriter out, @Nullable JsonSchema value) throws IOException { + if (value != null) { + out.value(value.value); + } else { + out.nullValue(); + } + } + + /// Reads a schema from a raw string or null JSON token. + @Override + public @Nullable JsonSchema read(JsonReader in) throws IOException { + JsonElement element = JsonParser.parseReader(in); + if (element.isJsonNull()) { + return null; + } + + return parseElement(element, "value"); + } + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/gson/JsonUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/gson/JsonUtils.java index bb130fb885d..4f37d195dfd 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/gson/JsonUtils.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/gson/JsonUtils.java @@ -17,11 +17,10 @@ */ package org.jackhuang.hmcl.util.gson; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.google.gson.JsonParseException; -import com.google.gson.JsonSyntaxException; +import com.github.f4b6a3.uuid.alt.GUID; +import com.google.gson.*; import com.google.gson.reflect.TypeToken; +import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.UnknownNullability; @@ -45,7 +44,7 @@ /// - [GSON] — pretty-printing instance with all standard HMCL type adapters registered. /// - [UGLY_GSON] — compact (non-pretty) instance with the same adapter set minus /// complex-map-key serialization and the built-in type adapters for [java.time.Instant], -/// [java.util.UUID], and [java.nio.file.Path]. +/// [java.util.UUID], [GUID], and [java.nio.file.Path]. /// /// All `fromJson` / `fromJsonFile` / `fromJsonFully` overloads return `null` when the /// JSON literal `null` is encountered. The `fromNonNull*` variants throw @@ -63,13 +62,13 @@ public final class JsonUtils { /// Configured with: /// - Pretty printing enabled. /// - Complex map key serialization enabled. - /// - Type adapters for [java.time.Instant], [java.util.UUID], and [java.nio.file.Path]. + /// - Type adapters for [java.time.Instant], [java.util.UUID], [GUID], and [java.nio.file.Path]. /// - [ValidationTypeAdapterFactory], [LowerCaseEnumTypeAdapterFactory], and /// [JsonTypeAdapterFactory]. public static final Gson GSON = defaultGsonBuilder().create(); /// A compact [Gson] instance without pretty printing and without the extra - /// type adapters for [java.time.Instant], [java.util.UUID], and [java.nio.file.Path]. + /// type adapters for [java.time.Instant], [java.util.UUID], [GUID], and [java.nio.file.Path]. /// /// Configured with: /// - [JsonTypeAdapterFactory] @@ -128,6 +127,97 @@ private JsonUtils() { return (TypeToken>) TypeToken.getParameterized(Map.class, keyType, valueType.getType()); } + /// Reads a JSON primitive element as a string. + /// + /// @return the string value, or `null` if the element is absent or not primitive + public static @Nullable String getString(@Nullable JsonElement element) { + if (!(element instanceof JsonPrimitive primitive)) { + return null; + } + + try { + return primitive.getAsString(); + } catch (RuntimeException ignored) { + return null; + } + } + + /// Reads a string member from a JSON object. + /// + /// @return the string value, or `null` if the key is missing or not a string + public static @Nullable String getString(@Nullable JsonObject object, String key) { + return object != null ? getString(object.get(key)) : null; + } + + /// Reads a string member from a JSON object. + /// + /// @return the string value, or `defaultValue` if the key is missing or not a string + @Contract("_,_,!null->!null") + public static @Nullable String getString(@Nullable JsonObject object, String key, @Nullable String defaultValue) { + @Nullable String value = getString(object, key); + return value != null ? value : defaultValue; + } + + /// Reads a string value from a map decoded from JSON. + /// + /// @return the string value, or `null` if the key is missing or not a string + public static @Nullable String getString(Map map, String key) { + Object value = map.get(key); + return value instanceof String string ? string : null; + } + + /// Reads a JSON primitive element as a boolean. + public static boolean getBoolean(@Nullable JsonElement element, boolean defaultValue) { + if (!(element instanceof JsonPrimitive primitive)) { + return defaultValue; + } + + try { + return primitive.getAsBoolean(); + } catch (RuntimeException ignored) { + return defaultValue; + } + } + + /// Reads a boolean member from a JSON object. + public static boolean getBoolean(@Nullable JsonObject object, String key, boolean defaultValue) { + return object != null ? getBoolean(object.get(key), defaultValue) : defaultValue; + } + + /// Reads a JSON element as an integer from either a number or a numeric string. + public static @Nullable Integer getInteger(@Nullable JsonElement element) { + if (!(element instanceof JsonPrimitive primitive)) { + return null; + } + + try { + if (primitive.isNumber()) { + return primitive.getAsInt(); + } + if (primitive.isString()) { + return Integer.parseInt(primitive.getAsString()); + } + } catch (NumberFormatException ignored) { + } + return null; + } + + /// Reads an integer member from a JSON object. + public static int getInt(@Nullable JsonObject object, String key, int defaultValue) { + @Nullable Integer value = object != null ? getInteger(object.get(key)) : null; + return value != null ? value : defaultValue; + } + + /// Reads an optional integer member from a JSON object. + public static @Nullable Integer getNullableInt(@Nullable JsonObject object, String key) { + return object != null ? getInteger(object.get(key)) : null; + } + + /// Returns a JSON primitive member, or `null` if the object is absent or the member is not primitive. + public static @Nullable JsonPrimitive getPrimitive(@Nullable JsonObject object, String key) { + return object != null && object.get(key) instanceof JsonPrimitive primitive ? primitive : null; + } + /// Deserializes the JSON string into an object of the given class using the provided [Gson] /// instance. /// @@ -503,6 +593,39 @@ public static void writeToJsonFile(Path file, @Nullable Object value) throws IOE } } + /// Performs a deep clone of `value` by round-tripping it through JSON serialization using + /// the provided [Gson] instance. + /// + /// The value is first serialized to a [com.google.gson.JsonElement] via + /// [Gson#toJsonTree], then immediately deserialized back into a new instance of `T`. + /// The result is therefore structurally equal to `value` but is an independent copy with + /// no shared mutable state. + /// + /// @param the type of the value to clone; may be `null` (see return) + /// @param gson the [Gson] instance to use for serialization and deserialization + /// @param value the object to clone, or `null` + /// @param type a [TypeToken] describing the runtime type of `value` + /// @return a deep clone of `value`, or `null` if `value` is `null` + public static T clone(Gson gson, T value, TypeToken type) { + if (value == null) + return null; + + return gson.fromJson(gson.toJsonTree(value), type); + } + + /// Performs a deep clone of `value` by round-tripping it through JSON serialization using + /// [GSON]. + /// + /// Delegates to [#clone(Gson, Object, TypeToken)] with [GSON] as the serializer. + /// + /// @param the type of the value to clone; may be `null` (see return) + /// @param value the object to clone, or `null` + /// @param type a [TypeToken] describing the runtime type of `value` + /// @return a deep clone of `value`, or `null` if `value` is `null` + public static T clone(T value, TypeToken type) { + return clone(GSON, value, type); + } + /// Creates and returns a pre-configured [GsonBuilder] used to construct [GSON]. /// /// The builder has the following configuration applied: @@ -524,6 +647,7 @@ public static GsonBuilder defaultGsonBuilder() { .setPrettyPrinting() .registerTypeAdapter(Instant.class, InstantTypeAdapter.INSTANCE) .registerTypeAdapter(UUID.class, UUIDTypeAdapter.INSTANCE) + .registerTypeAdapter(GUID.class, GUIDTypeAdapter.INSTANCE) .registerTypeAdapter(Path.class, PathTypeAdapter.INSTANCE) .registerTypeAdapterFactory(ValidationTypeAdapterFactory.INSTANCE) .registerTypeAdapterFactory(LowerCaseEnumTypeAdapterFactory.INSTANCE) diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/util/PortablePathTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/PortablePathTest.java new file mode 100644 index 00000000000..c728326bd3f --- /dev/null +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/PortablePathTest.java @@ -0,0 +1,90 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.util; + +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jetbrains.annotations.NotNullByDefault; +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +/// Tests for [PortablePath]. +@NotNullByDefault +public final class PortablePathTest { + /// Tests that relative paths use portable separators. + @Test + public void normalizesRelativeSeparators() { + PortablePath path = PortablePath.of("versions\\1.20.1"); + + assertEquals("versions/1.20.1", path.getPath()); + assertFalse(path.isAbsolute()); + } + + /// Tests that absolute paths keep their original separators. + @Test + public void preservesAbsoluteSeparators() { + PortablePath windowsPath = PortablePath.of("C:\\Users\\Name"); + PortablePath posixPath = PortablePath.of("/home/user"); + + assertEquals("C:\\Users\\Name", windowsPath.getPath()); + assertTrue(windowsPath.isAbsolute()); + assertEquals("/home/user", posixPath.getPath()); + assertTrue(posixPath.isAbsolute()); + } + + /// Tests that the string representation is the stored path. + @Test + public void returnsPathAsString() { + PortablePath path = PortablePath.of("game\\dir"); + + assertEquals(path.getPath(), path.toString()); + } + + /// Tests JSON serialization as a path string. + @Test + public void serializesAsString() { + PortablePath path = PortablePath.of("game\\dir"); + + assertEquals("\"game/dir\"", JsonUtils.GSON.toJson(path, PortablePath.class)); + assertEquals("game/dir", JsonUtils.GSON.fromJson("\"game/dir\"", PortablePath.class).getPath()); + } + + /// Tests conversion from and to relative [Path] values. + @Test + public void convertsRelativePath() { + Path path = Path.of("versions", "1.20.1"); + PortablePath portablePath = PortablePath.fromPath(path); + + assertEquals("versions/1.20.1", portablePath.getPath()); + assertFalse(portablePath.isAbsolute()); + assertEquals(path, portablePath.toPath()); + } + + /// Tests conversion from and to absolute [Path] values. + @Test + public void convertsAbsolutePath() { + Path path = Path.of(".").toAbsolutePath(); + PortablePath portablePath = PortablePath.fromPath(path); + + assertEquals(path.toString(), portablePath.getPath()); + assertTrue(portablePath.isAbsolute()); + assertEquals(path, portablePath.toPath()); + } +} diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/util/gson/GUIDTypeAdapterTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/gson/GUIDTypeAdapterTest.java new file mode 100644 index 00000000000..debbd015cd9 --- /dev/null +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/gson/GUIDTypeAdapterTest.java @@ -0,0 +1,69 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.util.gson; + +import com.github.f4b6a3.uuid.alt.GUID; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import org.jetbrains.annotations.NotNullByDefault; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/// Tests for [GUIDTypeAdapter]. +@NotNullByDefault +public final class GUIDTypeAdapterTest { + /// Gson instance with only the GUID adapter under test registered. + private static final Gson GSON = new GsonBuilder() + .registerTypeAdapter(GUID.class, GUIDTypeAdapter.INSTANCE) + .create(); + + /// Tests that GUID wraps a UUID value without changing its bits. + @Test + public void wrapsUUID() { + UUID uuid = UUID.fromString("123e4567-e89b-12d3-a456-426614174000"); + GUID guid = new GUID(uuid); + + assertEquals(uuid, guid.toUUID()); + assertEquals(uuid.version(), guid.version()); + assertEquals(uuid.toString(), guid.toString()); + assertEquals(guid, new GUID(uuid.toString())); + } + + /// Tests GUID serialization as a canonical UUID string. + @Test + public void serializesAsUUIDString() { + GUID guid = new GUID("123e4567-e89b-12d3-a456-426614174000"); + + assertEquals("\"123e4567-e89b-12d3-a456-426614174000\"", GSON.toJson(guid, GUID.class)); + assertEquals(guid, GSON.fromJson("\"123e4567-e89b-12d3-a456-426614174000\"", GUID.class)); + assertEquals(guid, GSON.fromJson("\"123e4567e89b12d3a456426614174000\"", GUID.class)); + assertNull(GSON.fromJson("null", GUID.class)); + } + + /// Tests that the shared Gson instance registers the GUID adapter. + @Test + public void sharedGsonRegistersAdapter() { + GUID guid = new GUID("123e4567-e89b-12d3-a456-426614174000"); + + assertEquals("\"123e4567-e89b-12d3-a456-426614174000\"", JsonUtils.GSON.toJson(guid, GUID.class)); + assertEquals(guid, JsonUtils.GSON.fromJson("\"123e4567-e89b-12d3-a456-426614174000\"", GUID.class)); + } +} diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/util/gson/JsonSchemaTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/gson/JsonSchemaTest.java new file mode 100644 index 00000000000..8aa42defba9 --- /dev/null +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/gson/JsonSchemaTest.java @@ -0,0 +1,152 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.util.gson; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.jetbrains.annotations.NotNullByDefault; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/// Tests for JSON schema URL parsing and compatibility checks. +@NotNullByDefault +public final class JsonSchemaTest { + /// Tests reading schema URL strings. + @Test + public void readsSchema() { + JsonObject object = new JsonObject(); + object.addProperty(JsonSchema.PROPERTY_SCHEMA, schemaUrl("settings", "3.0.1")); + + JsonSchema schema = JsonSchema.readFromMember(object); + + assertTrue(schema.isParsed()); + assertEquals("settings", schema.id()); + assertEquals(new JsonSchema.Version(3, 0, 1), schema.version()); + assertEquals(schemaUrl("settings", "3.0.1"), schema.url()); + assertEquals(schemaUrl("settings", "3.0.1"), schema.toString()); + } + + /// Tests reading schema URLs with an omitted patch number. + @Test + public void readsPatchlessSchema() { + JsonObject object = new JsonObject(); + object.addProperty(JsonSchema.PROPERTY_SCHEMA, schemaUrl("settings", "3.0")); + + JsonSchema schema = JsonSchema.readFromMember(object); + + assertTrue(schema.isParsed()); + assertEquals("settings", schema.id()); + assertEquals(new JsonSchema.Version(3, 0, 0), schema.version()); + assertEquals(schemaUrl("settings", "3.0"), schema.url()); + } + + /// Tests reading schema strings that are not HMCL schema URLs. + @Test + public void readsUnparseableSchemaString() { + JsonObject object = new JsonObject(); + object.addProperty(JsonSchema.PROPERTY_SCHEMA, "https://json-schema.org/draft/2020-12/schema"); + + JsonSchema schema = JsonSchema.readFromMember(object); + + assertFalse(schema.isParsed()); + assertNull(schema.id()); + assertNull(schema.version()); + assertEquals("https://json-schema.org/draft/2020-12/schema", schema.url()); + } + + /// Tests serialization of schema URL strings. + @Test + public void serializesSchema() { + JsonSchema schema = new JsonSchema("settings", new JsonSchema.Version(3, 0, 0)); + + JsonElement serialized = JsonParser.parseString(JsonUtils.GSON.toJson(schema)); + + assertEquals(schemaUrl("settings", "3.0.0"), serialized.getAsString()); + assertEquals(schema, JsonUtils.GSON.fromJson(serialized, JsonSchema.class)); + } + + /// Tests serialization of arbitrary schema strings. + @Test + public void serializesUnparseableSchemaString() { + JsonSchema schema = new JsonSchema("custom-schema"); + + JsonElement serialized = JsonParser.parseString(JsonUtils.GSON.toJson(schema)); + + assertEquals("custom-schema", serialized.getAsString()); + assertEquals(schema, JsonUtils.GSON.fromJson(serialized, JsonSchema.class)); + } + + /// Tests schema URL compatibility check statuses. + @Test + public void checksSchema() { + JsonSchema expected = new JsonSchema("settings", new JsonSchema.Version(3, 0, 1)); + JsonObject object = new JsonObject(); + + JsonSchema.CheckResult missing = JsonSchema.check(object, expected); + assertTrue(missing.isMissing()); + + object.addProperty(JsonSchema.PROPERTY_SCHEMA, "hmcl.config/3.x"); + JsonSchema.CheckResult unparseable = JsonSchema.check(object, expected); + assertTrue(unparseable.isUnparseable()); + assertEquals("hmcl.config/3.x", unparseable.actual().url()); + + object.add(JsonSchema.PROPERTY_SCHEMA, new JsonObject()); + JsonSchema.CheckResult invalid = JsonSchema.check(object, expected); + assertTrue(invalid.isInvalid()); + assertEquals("{}", invalid.invalidValue()); + + object.addProperty(JsonSchema.PROPERTY_SCHEMA, schemaUrl("settings", "3.x")); + JsonSchema.CheckResult invalidVersion = JsonSchema.check(object, expected); + assertTrue(invalidVersion.isUnparseable()); + assertEquals(schemaUrl("settings", "3.x"), invalidVersion.actual().url()); + + object.addProperty(JsonSchema.PROPERTY_SCHEMA, schemaUrl("settings", "3.0")); + JsonSchema.CheckResult patchless = JsonSchema.check(object, expected); + assertTrue(patchless.hasSameMajorAndMinorVersion()); + assertFalse(patchless.hasNewerMinorVersion()); + assertFalse(patchless.hasUnsupportedMajorVersion()); + + object.addProperty(JsonSchema.PROPERTY_SCHEMA, schemaUrl("game-settings", "1.0.0")); + JsonSchema.CheckResult unexpected = JsonSchema.check(object, expected); + assertTrue(unexpected.isUnexpectedId()); + assertEquals("game-settings", unexpected.actual().id()); + + object.addProperty(JsonSchema.PROPERTY_SCHEMA, schemaUrl("settings", "3.0.2")); + JsonSchema.CheckResult newerPatch = JsonSchema.check(object, expected); + assertTrue(newerPatch.hasSameMajorAndMinorVersion()); + assertFalse(newerPatch.hasNewerMinorVersion()); + assertFalse(newerPatch.hasUnsupportedMajorVersion()); + + object.addProperty(JsonSchema.PROPERTY_SCHEMA, schemaUrl("settings", "3.1.0")); + JsonSchema.CheckResult newerMinor = JsonSchema.check(object, expected); + assertTrue(newerMinor.hasNewerMinorVersion()); + assertFalse(newerMinor.hasUnsupportedMajorVersion()); + + object.addProperty(JsonSchema.PROPERTY_SCHEMA, schemaUrl("settings", "4.0.0")); + JsonSchema.CheckResult newerMajor = JsonSchema.check(object, expected); + assertTrue(newerMajor.hasUnsupportedMajorVersion()); + assertFalse(newerMajor.hasNewerMinorVersion()); + } + + /// Creates a schema URL string. + private static String schemaUrl(String id, String version) { + return "https://schemas.glavo.site/hmcl/" + id + "/" + version; + } +} diff --git a/PLANS.md b/PLANS.md new file mode 100644 index 00000000000..18e7ac7a6c1 --- /dev/null +++ b/PLANS.md @@ -0,0 +1,50 @@ +# Settings Migration Notes + +## Storage Layout + +- `settings.json`: workspace launcher settings. +- `launcher-state.json`: workspace UI/runtime state. +- `authlib-injector-servers.json`: workspace authlib-injector server list. +- `game-directories.json`: workspace game directory profiles in the `directories` list. +- `user-game-directories.json`: shared game directory profiles in the `directories` list. +- `game-settings.json`: workspace `GameSettings.Preset` entries. +- `game-accounts.json`: workspace portable account storages in the `accounts` list. +- `user-game-accounts.json`: shared account storages in the `accounts` list. +- `versions//.hmcl/instance-game-settings.json`: instance-specific game settings. + +## Migration Scope + +- Only configuration formats used by `upstream/main` need migration support. +- File formats introduced by this branch are still unstable and do not need compatibility with earlier revisions of this branch. +- Legacy config files are read as migration inputs and must not be rewritten. +- Detached settings files should be created and saved only through the new storage model. + +## Migration Rules + +- Legacy `hmcl.json` and `.hmcl.json` are read as workspace migration inputs. +- Legacy `accounts` fields in the workspace config are extracted into `game-accounts.json`. +- Legacy shared `accounts.json` is used only as a migration input when `user-game-accounts.json` does not exist. +- Legacy `authlibInjectorServers` and `addedLittleSkin` fields are extracted into `authlib-injector-servers.json`. +- Legacy profile data from `configurations` is converted into `game-directories.json`. +- Legacy profile global settings are converted into `GameSettings.Preset` entries. +- Legacy per-instance `hmclversion.cfg` files are converted into `GameSettings.Instance` data. + +## Schema Policy + +- Detached settings files use `$schema` with `https://schemas.glavo.site/hmcl//`. +- Schema versions are written as `major.minor.patch`; `major.minor` may be accepted as patch `0` when reading. +- Unsupported major versions are rejected. +- Newer minor versions may be read but must not be overwritten. +- Patch-compatible files may be saved while preserving the original schema string and unknown serialized members. + +## Settings Semantics + +- Instance settings resolve effective values from their selected parent preset. +- Inheritable fields preserve explicit overrides and default to parent values when not overridden. + +## Verification Focus + +- Loading an old config should create detached files without losing selected account, selected directory, or selected instance state. +- Editing accounts should update `game-accounts.json`, not `settings.json`. +- Existing shared `accounts.json` should migrate to `user-game-accounts.json`. +- Launch, export, install, and settings UI flows should read effective `GameSettings` values. diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml index 37553e27c78..2badd6171bb 100644 --- a/config/checkstyle/checkstyle.xml +++ b/config/checkstyle/checkstyle.xml @@ -134,10 +134,15 @@ + + + + + - \ No newline at end of file + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6331a592033..1849764acf4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ terracotta = "0.4.2" nayuki-qrcodegen = "1.8.0" jwebp = "0.2.0" weburl = "0.2.0" +uuid-creator = "6.1.0" # testing junit = "6.0.1" @@ -60,6 +61,7 @@ monet-fx = { module = "org.glavo:MonetFX", version.ref = "monet-fx" } nayuki-qrcodegen = { module = "io.nayuki:qrcodegen", version.ref = "nayuki-qrcodegen" } jwebp = { module = "org.glavo:webp", version.ref = "jwebp" } weburl = { module = "org.glavo:weburl", version.ref = "weburl" } +uuid_creator = { module = "com.github.f4b6a3:uuid-creator", version.ref = "uuid-creator" } # testing junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }