diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java b/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java index c81379dbc5f..bc703be99f6 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java @@ -23,6 +23,7 @@ import org.jackhuang.hmcl.util.platform.OperatingSystem; import org.jetbrains.annotations.Nullable; +import java.nio.file.Files; import java.nio.file.Path; import java.util.EnumSet; @@ -133,4 +134,32 @@ else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) return null; } } + + /// Directory under [HMCL_LOCAL_HOME] that holds a launcher-bundled modpack for automatic install. + public static final String BUNDLED_MODPACK_DIRECTORY_NAME = "modpack"; + + /// Returns the directory for a launcher-bundled modpack (`[HMCL_LOCAL_HOME]/modpack`). + public static Path getBundledModpackDirectory() { + return HMCL_LOCAL_HOME.resolve(BUNDLED_MODPACK_DIRECTORY_NAME); + } + + /// Returns the bundled modpack package under [getBundledModpackDirectory], if present. + /// + /// Prefers `modpack.zip` over `modpack.mrpack` when both exist. Presence of the package is the + /// signal to offer automatic install; the file is removed after a successful install. + /// + /// @return the modpack path, or `null` when no package is present + public static @Nullable Path findBundledModpackFile() { + Path directory = getBundledModpackDirectory(); + Path zipModpack = directory.resolve("modpack.zip"); + if (Files.isRegularFile(zipModpack)) { + return zipModpack; + } + Path mrpackModpack = directory.resolve("modpack.mrpack"); + if (Files.isRegularFile(mrpackModpack)) { + return mrpackModpack; + } + return null; + } + } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java new file mode 100644 index 00000000000..c95a3f39880 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java @@ -0,0 +1,851 @@ +/* + * 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.game; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.reflect.TypeToken; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.ReadOnlyObjectPropertyBase; +import javafx.scene.image.Image; +import org.jackhuang.hmcl.Metadata; +import org.jackhuang.hmcl.addon.mod.ModLoaderType; +import org.jackhuang.hmcl.java.JavaRuntime; +import org.jackhuang.hmcl.modpack.ModpackConfiguration; +import org.jackhuang.hmcl.modpack.ModpackProvider; +import org.jackhuang.hmcl.setting.*; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.util.FileSaver; +import org.jackhuang.hmcl.util.Lang; +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.io.FileUtils; +import org.jackhuang.hmcl.util.platform.SystemInfo; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.lang.ref.WeakReference; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.stream.Collectors; + +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-specific game instance that owns instance-local settings and run-directory policy. +@NotNullByDefault +public class HMCLGameInstance extends DefaultGameInstance { + + /// Whether the instance-local game settings file has already been inspected. + private boolean gameSettingsLoaded; + + /// Whether the instance-local game settings file cannot be overwritten safely. + private boolean gameSettingsReadOnly; + + /// Cached instance-local game settings, or `null` when none exist after loading. + private GameSettings.@Nullable Instance gameSettings; + + /// Creates a registered instance bound to the given repository snapshot. + /// + /// @param snapshot the repository snapshot that owns this instance + /// @param id the instance id + /// @param manifest the stored instance manifest + protected HMCLGameInstance(DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest) { + this(snapshot, id, manifest, (Path) null); + } + + /// Creates a registered instance with an optional non-conventional manifest path. + /// + /// @param snapshot the repository snapshot that owns this instance + /// @param id the instance id + /// @param manifest the stored instance manifest + /// @param manifestFile the actual manifest JSON path, or `null` for the layout default + protected HMCLGameInstance( + DefaultGameRepositorySnapshot snapshot, + GameInstanceID id, + GameInstanceManifest manifest, + @Nullable Path manifestFile) { + super(snapshot, id, manifest, manifestFile); + } + + /// Creates an instance that shares mutable instance-local state with another instance. + /// + /// Used when the repository clones a snapshot so that settings and the icon property remain + /// available on the new wrapper. + private HMCLGameInstance( + DefaultGameRepositorySnapshot snapshot, + GameInstanceID id, + GameInstanceManifest manifest, + HMCLGameInstance shareState) { + super(snapshot, id, manifest, shareState); + this.gameSettingsLoaded = shareState.gameSettingsLoaded; + this.gameSettingsReadOnly = shareState.gameSettingsReadOnly; + this.gameSettings = shareState.gameSettings; + } + + @Override + protected HMCLGameInstance withNewSnapshot(DefaultGameRepositorySnapshot newSnapshot) { + return new HMCLGameInstance(newSnapshot, id, manifest, this); + } + + @Override + protected HMCLGameInstance withManifest(DefaultGameRepositorySnapshot newSnapshot, GameInstanceManifest manifest) { + return new HMCLGameInstance(newSnapshot, id, manifest, this); + } + + @Override + public HMCLGameRepository getRepository() { + return (HMCLGameRepository) super.getRepository(); + } + + @Override + public HMCLGameRepositoryLayout getLayout() { + return (HMCLGameRepositoryLayout) super.getLayout(); + } + + /// Returns the HMCL modpack configuration file for this instance. + /// + /// @return the `modpack.cfg` path in the instance root + @Override + public Path getModpackConfigurationFile() { + return getLayout().getModpackConfigurationFile(getId()); + } + + /// Returns whether this instance has an HMCL modpack configuration file. + /// + /// @return whether [#getModpackConfigurationFile()] exists + public boolean isModpack() { + return Files.exists(getModpackConfigurationFile()); + } + + /// Reads this instance's HMCL modpack configuration. + /// + /// @return the parsed configuration, or `null` when the file does not exist + /// @throws IOException if the configuration cannot be read + public @Nullable ModpackConfiguration readModpackConfiguration() throws IOException { + Path file = getModpackConfigurationFile(); + if (Files.notExists(file)) { + return null; + } + try { + return JsonUtils.fromJsonFile(file, ModpackConfiguration.class); + } catch (JsonParseException e) { + throw new IOException("Malformed modpack configuration: " + file, e); + } + } + + @Override + public Path getRunDirectory() { + return getRepository().computeRunDirectory(getId(), isModpack(), getSettings()); + } + + /// Returns the loaded instance-local game settings, loading them on first access. + /// + /// @return the settings, or `null` when no local settings exist after loading + public @Nullable GameSettings.Instance getSettings() { + ensureGameSettingsLoaded(); + return gameSettings; + } + + /// Returns the instance-local game settings, creating an empty settings object when absent. + /// + /// @return the settings, or `null` when the settings file is read-only and no settings are loaded + public @Nullable GameSettings.Instance getSettingsOrCreate() { + GameSettings.Instance setting = getSettings(); + if (setting == null) { + setting = createSettings(); + } + return setting; + } + + /// Resolves this instance's effective settings against its selected parent preset. + /// + /// @return the effective settings + public GameSettings.Effective getEffectiveSettings() { + @Nullable GameSettings.Instance setting = getSettings(); + return GameSettings.resolve(getRepository().getParentGameSettings(setting), setting); + } + + /// Applies the selected parent preset's default isolation policy to this instance. + public void applyDefaultIsolationSetting() { + @Nullable GameSettings.Instance instanceSetting = getSettings(); + GameSettings.Preset preset = getRepository().getParentGameSettings(instanceSetting); + DefaultIsolationType type = Lang.requireNonNullElse( + preset.defaultIsolationTypeProperty().getValue(), DefaultIsolationType.MODDED); + boolean isolated = switch (type) { + case NEVER -> false; + case ALWAYS -> true; + case MODDED -> getResolvedManifest().isModded(); + }; + + if (isolated) { + @Nullable GameSettings.Instance setting = + instanceSetting != null ? instanceSetting : getSettingsOrCreate(); + if (setting != null + && setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY)) { + saveSettings(); + } + } + } + + /// Creates empty instance-local game settings when none are loaded. + /// + /// @return the settings, or `null` when settings are read-only or already present in a non-creatable state + public @Nullable GameSettings.Instance createSettings() { + ensureGameSettingsLoaded(); + if (gameSettingsReadOnly) { + return null; + } + if (gameSettings != null) { + return gameSettings; + } + return initSettings(new GameSettings.Instance(), true); + } + + /// Returns whether the instance-local game settings file cannot be overwritten safely. + /// + /// @return whether the settings are loaded in read-only mode + public boolean isSettingsReadOnly() { + ensureGameSettingsLoaded(); + return gameSettingsReadOnly; + } + + /// Backs up and overwrites the instance-local game settings file with the currently loaded settings. + public void forceOverwriteSettings() { + ensureGameSettingsLoaded(); + + GameSettings.Instance setting = gameSettings; + if (setting == null) { + setting = new GameSettings.Instance(); + gameSettings = setting; + gameSettingsLoaded = true; + } + + boolean installAutoSave = !setting.isSavable(); + Path file = getGameSettingsFile().toAbsolutePath().normalize(); + SettingFileUtils.backupInvalidConfig(file); + setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA); + setting.setSavable(true); + setting.setBackupOnNextSave(false); + gameSettingsReadOnly = false; + saveSettings(); + if (installAutoSave) { + setting.addListener(a -> saveSettings()); + } + } + + /// Saves the currently loaded instance-local game settings asynchronously when writable. + public void saveSettings() { + if (gameSettings == null || gameSettingsReadOnly) { + return; + } + + GameSettings.Instance setting = gameSettings; + Path file = getGameSettingsFile().toAbsolutePath().normalize(); + try { + Files.createDirectories(file.getParent()); + } catch (IOException e) { + LOG.warning("Failed to create directory: " + file.getParent(), e); + } + + if (setting.isBackupOnNextSave()) { + setting.setBackupOnNextSave(false); + SettingFileUtils.backupInvalidConfig(file); + } + FileSaver.save(file, LauncherSettings.SETTINGS_GSON.toJson(setting)); + } + + /// Saves the currently loaded instance-local game settings synchronously when writable. + /// + /// @throws IOException if saving the file fails + public void saveSettingsSync() throws IOException { + if (gameSettings == null || gameSettingsReadOnly) { + return; + } + + GameSettings.Instance setting = gameSettings; + Path file = getGameSettingsFile().toAbsolutePath().normalize(); + Files.createDirectories(file.getParent()); + if (setting.isBackupOnNextSave()) { + setting.setBackupOnNextSave(false); + SettingFileUtils.backupInvalidConfig(file); + } + FileUtils.saveSafely(file, LauncherSettings.SETTINGS_GSON.toJson(setting)); + } + + /// Initializes this instance with the given settings object. + /// + /// @param setting the settings to install + /// @param allowSave whether the settings may be written back to disk + /// @return the installed settings + public GameSettings.Instance initSettings(GameSettings.Instance setting, boolean allowSave) { + normalizeRunningDirectoryOverride(setting); + setting.setSavable(allowSave); + gameSettingsLoaded = true; + gameSettings = setting; + setting.iconProperty().addListener(observable -> invalidateIconImage()); + if (allowSave) { + gameSettingsReadOnly = false; + setting.addListener(a -> saveSettings()); + } else { + gameSettingsReadOnly = true; + } + return setting; + } + + /// Returns a deep copy of the currently loaded settings, or a new settings object that inherits + /// the effective parent preset when no local settings exist. + /// + /// @return a detached copy suitable for installing into another instance + public GameSettings.Instance copySettings() { + @Nullable GameSettings.Instance setting = getSettings(); + if (setting != null) { + return JsonUtils.clone(LauncherSettings.SETTINGS_GSON, setting, TypeToken.get(GameSettings.Instance.class)); + } + + GameSettings.Instance copied = new GameSettings.Instance(); + copied.parentProperty().setValue( + getEffectiveSettings().getPreset().idProperty().getValue()); + return copied; + } + + /// Returns the first custom icon file found in this instance's root directory. + /// + /// @return the icon file, or empty when no supported icon file exists + public @Nullable Path getIconFile() { + for (String extension : FXUtils.IMAGE_EXTENSIONS) { + Path file = getInstanceRoot().resolve("icon." + extension); + if (Files.exists(file)) { + return file; + } + } + return null; + } + + /// Replaces this instance's custom icon file. + /// + /// Existing supported icon files are removed before `iconFile` is copied. + /// + /// @param iconFile the source icon file + /// @throws IOException if the icon cannot be copied + /// @throws IllegalArgumentException if the file extension is unsupported + public void setIconFile(Path iconFile) throws IOException { + String extension = FileUtils.getExtension(iconFile).toLowerCase(Locale.ROOT); + if (!FXUtils.IMAGE_EXTENSIONS.contains(extension)) { + throw new IllegalArgumentException("Unsupported icon file: " + extension); + } + + clearIconFiles(); + FileUtils.copyFile(iconFile, getInstanceRoot().resolve("icon." + extension)); + invalidateIconImage(); + } + + /// Deletes all supported custom icon files for this instance. + /// + /// Individual deletion failures are logged and do not stop later files from being attempted. + public void deleteIconFile() { + clearIconFiles(); + invalidateIconImage(); + } + + private void clearIconFiles() { + for (String extension : FXUtils.IMAGE_EXTENSIONS) { + Path file = getInstanceRoot().resolve("icon." + extension); + try { + Files.deleteIfExists(file); + } catch (IOException e) { + LOG.warning("Failed to delete instance icon file: " + file, e); + } + } + } + + /// Soft-cached icon image for this instance id. + /// + /// Shared across COW snapshot wrappers. The computed [Image] is retained only via a + /// [WeakReference], so it can be reclaimed under memory pressure when nothing else holds it. + private @Nullable WeakCachedIconImageProperty iconImage; + + /// Returns the observable icon image for this instance. + /// + /// The image is stored in a [WeakReference] cache: when nothing else strongly references it + /// (for example no UI node is displaying it), the JVM may reclaim the [Image] under memory + /// pressure. The next [#getIconImage] reloads it. + /// + /// @return the icon image property + public ReadOnlyObjectProperty iconImageProperty() { + if (iconImage == null) { + iconImage = new WeakCachedIconImageProperty(); + } + return iconImage; + } + + /// Returns the icon image selected for this instance. + /// + /// Equivalent to [ReadOnlyObjectProperty#get()] on [#iconImageProperty]. + /// + /// @return the selected or derived icon image + public Image getIconImage() { + return iconImageProperty().get(); + } + + /// Drops the soft-cached icon image and notifies observers. + public void invalidateIconImage() { + ((WeakCachedIconImageProperty) iconImageProperty()).invalidate(); + } + + /// Soft-cached read-only icon property compatible with JavaFX versions before 19. + private final class WeakCachedIconImageProperty extends ReadOnlyObjectPropertyBase { + private @Nullable WeakReference cache; + + @Override + public Object getBean() { + return HMCLGameInstance.this; + } + + @Override + public String getName() { + return "iconImage"; + } + + @Override + public Image get() { + WeakReference current = cache; + Image image = current != null ? current.get() : null; + if (image != null) { + return image; + } + + image = computeIconImage(); + cache = new WeakReference<>(image); + return image; + } + + /// Computes the icon image from settings, custom files, and the launch manifest. + /// + /// @return the selected or derived icon image + private Image computeIconImage() { + @Nullable GameSettings.Instance setting = getSettings(); + GameInstanceIconType iconType = setting != null + ? Lang.requireNonNullElse(setting.iconProperty().getValue(), GameInstanceIconType.DEFAULT) + : GameInstanceIconType.DEFAULT; + if (iconType != GameInstanceIconType.DEFAULT) { + return iconType.getIcon(); + } + + @Nullable Path iconFile = getIconFile(); + if (iconFile != null) { + try { + return FXUtils.loadImage(iconFile, 64, 64, true, true); + } catch (Exception e) { + LOG.warning("Failed to load instance icon for " + getId(), e); + } + } + + GameInstanceManifest.Resolved resolvedManifest = getResolvedManifest(); + if (resolvedManifest.isModded()) { + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(resolvedManifest, null); + for (ModLoaderType type : ModLoaderType.values()) { + if (analyzer.has(type)) { + return GameInstanceIconType.getIconType(type).getIcon(); + } + } + + if (analyzer.has(GameComponentType.OPTIFINE)) + return GameInstanceIconType.OPTIFINE.getIcon(); + } + + GameVersionNumber version = getVersion(); + if (!version.equals(GameVersionNumber.unknown())) { + if (version.isAprilFools()) { + return GameInstanceIconType.APRIL_FOOLS.getIcon(); + } else if (version instanceof GameVersionNumber.LegacySnapshot) { + return GameInstanceIconType.COMMAND.getIcon(); + } else if (version instanceof GameVersionNumber.Old) { + return GameInstanceIconType.CRAFT_TABLE.getIcon(); + } + } + return GameInstanceIconType.GRASS.getIcon(); + } + + /// Clears the weak cache and notifies listeners. + void invalidate() { + cache = null; + fireValueChangedEvent(); + } + } + + /// Creates the marker indicating that the most recent launch ended abnormally. + public void markLaunchedAbnormally() { + try { + Files.createFile(getInstanceRoot().resolve(".abnormal")); + } catch (IOException ignored) { + } + } + + /// Deletes the abnormal-launch marker when present. + /// + /// @return whether a regular marker file was present + public boolean unmarkLaunchedAbnormally() { + Path file = getInstanceRoot().resolve(".abnormal"); + if (!Files.isRegularFile(file)) { + return false; + } + + try { + Files.delete(file); + } catch (IOException e) { + LOG.warning("Failed to delete abnormal launch marker: " + file, e); + } + return true; + } + + private void ensureGameSettingsLoaded() { + if (!gameSettingsLoaded) { + loadGameSettings(); + } + } + + private void loadGameSettings() { + gameSettingsLoaded = true; + LoadResult result = loadGameSettingsFile(getGameSettingsFile()); + if (result.setting() != null) { + initSettings(result.setting(), result.allowSave()); + return; + } + if (!result.allowSave()) { + gameSettingsReadOnly = true; + return; + } + + @Nullable GameSettingsPresetID legacyParent = getRepository().getGameDirectory().getLegacyGameSettings(); + if (SettingsManager.getGameSettings(legacyParent) == null) { + legacyParent = null; + } + + LegacyGameSettingsMigrator.InstanceMigrationResult migrationResult = + LegacyGameSettingsMigrator.migrateInstanceGameSettings( + getRepository(), id, legacyParent); + if (migrationResult != null) { + initSettings(migrationResult.setting(), true); + try { + saveSettingsSync(); + migrationResult.saveReceipt(); + } catch (IOException e) { + LOG.warning("Failed to save migrated instance game settings for " + id, e); + } + } + } + + private Path getGameSettingsFile() { + return getLayout().getInstanceGameSettingsFile(id); + } + + public LaunchOptions.Builder getLaunchOptions(JavaRuntime javaVersion, Path gameDir, List javaAgents, List javaArguments, boolean makeLaunchScript) { + GameSettings.Effective vs = getEffectiveSettings(); + boolean noJVMOptions = vs.getInheritable(GameSettings::noJVMOptionsProperty); + boolean autoMemory = vs.getInheritable(GameSettings::autoMemoryProperty); + GameVersionNumber gameVersionNumber = getVersion(); + + @Nullable Integer maxMemory; + if (autoMemory) { + maxMemory = noJVMOptions + ? null + : Math.toIntExact(HMCLGameRepository.getAutoAllocatedMemory(SystemInfo.getPhysicalMemoryStatus().available()) / 1024L / 1024L); + } else { + maxMemory = vs.getMaxMemory(); + } + + LaunchOptions.Builder builder = new LaunchOptions.Builder() + .setInstanceId(getId()) + .setGameDir(gameDir) + .setJava(javaVersion) + .setVersionType(Metadata.TITLE) + .setVersionName(getId().id()) + .setProfileName(Metadata.TITLE) + .setGameArguments(StringUtils.tokenize(vs.getInheritable(GameSettings::gameArgumentsProperty))) + .setOverrideJavaArguments(StringUtils.tokenize(vs.getInheritable(GameSettings::jvmOptionsProperty))) + .setMaxMemory(maxMemory) + .setMinMemory(vs.getInheritable(GameSettings::minMemoryProperty)) + .setMetaspace(Lang.toIntOrNull(vs.getInheritable(GameSettings::permSizeProperty))) + .setEnvironmentVariables( + Lang.mapOf(StringUtils.tokenize(vs.getInheritable(GameSettings::environmentVariablesProperty)) + .stream() + .map(it -> { + int idx = it.indexOf('='); + return idx >= 0 ? pair(it.substring(0, idx), it.substring(idx + 1)) : pair(it, ""); + }) + .collect(Collectors.toList()) + ) + ) + .setWidth(vs.getWidth()) + .setHeight(vs.getHeight()) + .setFullscreen(vs.getInheritable(GameSettings::windowTypeProperty) == GameWindowType.FULLSCREEN) + .setWrapper(vs.getInheritable(GameSettings::commandWrapperProperty)) + .setProxyOption(getProxyOption()) + .setPreLaunchCommand(vs.getInheritable(GameSettings::preLaunchCommandProperty)) + .setPostExitCommand(vs.getInheritable(GameSettings::postExitCommandProperty)) + .setNoGeneratedJVMArgs(noJVMOptions) + .setNoGeneratedOptimizingJVMArgs(vs.getInheritable(GameSettings::noOptimizingJVMOptionsProperty)) + .setUseCustomNatives(vs.getInheritable(GameSettings::useCustomNativesProperty)) + .setNativesDir(vs.getInheritable(GameSettings::nativesDirectoryProperty)) + .setProcessPriority(vs.getInheritable(GameSettings::processPriorityProperty)) + .setGraphicsBackend(vs.getInheritable(GameSettings::graphicsBackendProperty)) + .setRenderer(vs.getRenderer(gameVersionNumber)) + .setEnableDebugLogOutput(vs.getInheritable(GameSettings::enableDebugLogOutputProperty)) + .setAllowAutoAgent(vs.getInheritable(GameSettings::allowAutoAgentProperty)) + .setDisableAutoGameOptions(vs.getInheritable(GameSettings::disableAutoGameOptionsProperty)) + .setUseNativeGLFW(vs.getInheritable(GameSettings::useNativeGLFWProperty)) + .setUseNativeOpenAL(vs.getInheritable(GameSettings::useNativeOpenALProperty)) + .setDaemon(!makeLaunchScript && vs.getInheritable(GameSettings::launcherVisibilityProperty).isDaemon()) + .setJavaAgents(javaAgents) + .setJavaArguments(javaArguments); + + QuickPlayOption quickPlayOption = vs.getQuickPlayOption(); + if (quickPlayOption != null) { + builder.setQuickPlayOption(quickPlayOption); + } + + Path json = getModpackConfigurationFile(); + if (Files.exists(json)) { + try { + String jsonText = Files.readString(json); + ModpackConfiguration modpackConfiguration = JsonUtils.GSON.fromJson(jsonText, ModpackConfiguration.class); + ModpackProvider provider = ModpackHelper.getProviderByType(modpackConfiguration.getType()); + if (provider != null) provider.injectLaunchOptions(jsonText, builder); + } catch (IOException | JsonParseException e) { + LOG.warning("Failed to parse modpack configuration file " + json, e); + } + } + + if (autoMemory && builder.getJavaArguments().stream().anyMatch(it -> it.startsWith("-Xmx"))) + builder.setMaxMemory(null); + + return builder; + } + + private static ProxyOption getProxyOption() { + return switch (settings().proxyTypeProperty().get()) { + case SYSTEM -> ProxyOption.Default.INSTANCE; + case DIRECT -> ProxyOption.Direct.INSTANCE; + case HTTP, SOCKS -> { + String proxyHost = settings().proxyHostProperty().get(); + int proxyPort = settings().proxyPortProperty().get(); + + if (StringUtils.isBlank(proxyHost) || proxyPort < 0 || proxyPort > 0xFFFF) { + yield ProxyOption.Default.INSTANCE; + } + + String proxyUser = settings().proxyUserProperty().get(); + String proxyPass = settings().proxyPasswordProperty().get(); + + if (StringUtils.isBlank(proxyUser)) { + proxyUser = null; + proxyPass = null; + } else if (proxyPass == null) { + proxyPass = ""; + } + + if (settings().proxyTypeProperty().get() == ProxyType.HTTP) { + yield new ProxyOption.Http(proxyHost, proxyPort, proxyUser, proxyPass); + } else { + yield new ProxyOption.Socks(proxyHost, proxyPort, proxyUser, proxyPass); + } + } + }; + } + + /// Loads a new-format instance game settings file. + private static LoadResult loadGameSettingsFile(Path file) { + if (!Files.exists(file)) { + return new LoadResult(null, true); + } + + try { + JsonObject jsonObject = JsonUtils.fromJsonFile(LauncherSettings.SETTINGS_GSON, file, JsonObject.class); + if (jsonObject == null) { + LOG.warning("Instance game settings are empty: " + file); + GameSettings.Instance fallback = new GameSettings.Instance(); + return new LoadResult(fallback, true); + } + + JsonSchema.CompatibilityResult schemaResult = + JsonSchema.check(jsonObject, GameSettings.Instance.CURRENT_SCHEMA); + switch (schemaResult.status()) { + case MISSING -> LOG.warning("Missing schema in instance game settings: " + file); + case INVALID -> LOG.warning("Invalid schema in instance game settings: %s, Actual: %s".formatted(file, schemaResult.invalidValue())); + case UNPARSEABLE -> LOG.warning("Unparseable schema in instance game settings: %s, Actual: %s".formatted(file, schemaResult.actual())); + case UNEXPECTED_ID -> LOG.warning("Unexpected instance game settings schema. Expected: %s, Actual: %s".formatted(GameSettings.Instance.CURRENT_SCHEMA, schemaResult.actual())); + case UNSUPPORTED_MAJOR, READ_ONLY_PRESERVE_SCHEMA -> LOG.warning("Unsupported instance game settings schema. Expected: %s, Actual: %s".formatted(GameSettings.Instance.CURRENT_SCHEMA, schemaResult.actual())); + case READ_WRITE, READ_WRITE_PRESERVE_SCHEMA -> { + } + } + if (!schemaResult.readable()) { + GameSettings.Instance fallback = new GameSettings.Instance(); + fallback.setSavable(false); + return new LoadResult(fallback, false); + } + + GameSettings.@Nullable Instance setting = + LauncherSettings.SETTINGS_GSON.fromJson(jsonObject, GameSettings.Instance.class); + if (setting == null) { + LOG.warning("Instance game settings deserialized to null: " + file); + GameSettings.Instance fallback = new GameSettings.Instance(); + fallback.setBackupOnNextSave(true); + return new LoadResult(fallback, true); + } + if (!schemaResult.preserveSchema() && !GameSettings.Instance.CURRENT_SCHEMA.equals(setting.getSchema())) { + setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA); + } + return new LoadResult(setting, schemaResult.allowSave()); + } catch (JsonParseException ex) { + LOG.warning("Failed to parse game setting " + file, ex); + GameSettings.Instance fallback = new GameSettings.Instance(); + fallback.setBackupOnNextSave(true); + return new LoadResult(fallback, true); + } catch (Exception ex) { + LOG.warning("Failed to load game setting " + file, ex); + return new LoadResult(null, false); + } + } + + /// Keeps old local custom running directories effective under the new source-selection model. + private static void normalizeRunningDirectoryOverride(GameSettings.Instance setting) { + if (StringUtils.isNotBlank(setting.runningDirectoryProperty().getValue())) { + setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY); + } + } + + /// Result of loading an instance-specific game settings file. + /// + /// @param setting the loaded instance settings, or `null` when unavailable + /// @param allowSave whether the file may be overwritten + private record LoadResult(@Nullable GameSettings.Instance setting, boolean allowSave) { + } + + /// Optional reference to an HMCL game instance bound to a repository. + /// + /// Replaces the former `(repository, instanceId)` pair for UI and service context that may or + /// may not have a selected instance. When present, [#instance()] is a snapshot member and may + /// become stale after the repository publishes a new snapshot; call [#refreshed()] to re-resolve + /// from the current snapshot while preserving repository context. + @NotNullByDefault + public static final class Optional { + private final HMCLGameRepository repository; + private final @Nullable HMCLGameInstance instance; + + /// Creates an empty optional bound only to a repository. + /// + /// @param repository the repository + public Optional(HMCLGameRepository repository) { + this.repository = Objects.requireNonNull(repository); + this.instance = null; + } + + /// Creates an optional that holds the given instance. + /// + /// @param instance the instance + public Optional(HMCLGameInstance instance) { + this.repository = instance.getRepository(); + this.instance = instance; + } + + /// Creates an optional by resolving `instanceId` from the repository's current snapshot. + /// + /// @param repository the repository + /// @param instanceId the instance id, or `null` for an empty optional + /// @return an optional that is empty when `instanceId` is null or not registered + public static Optional of(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) { + if (instanceId == null) { + return new Optional(repository); + } + HMCLGameInstance instance = repository.findInstance(instanceId); + return instance != null ? new Optional(instance) : new Optional(repository); + } + + /// Creates an optional that holds the given instance. + /// + /// @param instance the instance + /// @return the optional + public static Optional of(HMCLGameInstance instance) { + return new Optional(instance); + } + + /// Creates an empty optional bound only to a repository. + /// + /// @param repository the repository + /// @return the empty optional + public static Optional empty(HMCLGameRepository repository) { + return new Optional(repository); + } + + /// Returns the repository associated with this optional. + /// + /// @return the repository + public HMCLGameRepository repository() { + return repository; + } + + /// Returns the held instance, if any. + /// + /// @return the instance, or `null` when empty + @Contract(pure = true) + public @Nullable HMCLGameInstance instance() { + return instance; + } + + /// Returns the held instance id, if any. + /// + /// @return the instance id, or `null` when empty + @Contract(pure = true) + public @Nullable GameInstanceID instanceId() { + return instance != null ? instance.getId() : null; + } + + /// Returns whether an instance is present. + /// + /// @return whether [#instance()] is non-null + public boolean isPresent() { + return instance != null; + } + + /// Returns whether no instance is present. + /// + /// @return whether [#instance()] is null + public boolean isEmpty() { + return instance == null; + } + + /// Re-resolves the held instance id from the repository's current snapshot. + /// + /// @return this optional when empty; otherwise a fresh optional for the same id + public Optional refreshed() { + if (instance == null) { + return this; + } + return of(repository, instance.getId()); + } + } +} 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 c21dae06ef3..e13cd3d5d31 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java @@ -42,16 +42,27 @@ */ public final class HMCLGameLauncher extends DefaultLauncher { - public HMCLGameLauncher(GameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options) { - this(repository, manifest, authInfo, options, null); + /// Creates a launcher with daemon process monitors. + /// + /// @param instance the instance being launched + /// @param manifest the effective launch-time manifest + /// @param authInfo authentication information for the game process + /// @param options launch options + /// @param listener process listener, or `null` to inherit IO + public HMCLGameLauncher(GameInstance instance, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener) { + this(instance, manifest, authInfo, options, listener, true); } - public HMCLGameLauncher(GameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener) { - this(repository, manifest, authInfo, options, listener, true); - } - - public HMCLGameLauncher(GameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) { - super(repository, manifest, authInfo, options, listener, daemon); + /// Creates a launcher for the given instance and launch plan. + /// + /// @param instance the instance being launched + /// @param manifest the effective launch-time manifest + /// @param authInfo authentication information for the game process + /// @param options launch options + /// @param listener process listener, or `null` to inherit IO + /// @param daemon whether monitors should be daemon threads + public HMCLGameLauncher(GameInstance instance, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) { + super(instance, manifest, authInfo, options, listener, daemon); } @Override @@ -66,7 +77,7 @@ private void generateOptionsTxt() { if (options.isDisableAutoGameOptions()) return; - Path runDir = repository.getRunDirectory(manifest.id()); + Path runDir = instance.getRunDirectory(); Path optionsFile = runDir.resolve("options.txt"); Path configFolder = runDir.resolve("config"); @@ -91,8 +102,8 @@ private void generateOptionsTxt() { * 1.11 ~ 1.12 : zh_cn works fine, zh_CN will display Chinese but the language setting will incorrectly show English as selected * 1.13+ : zh_cn works fine, zh_CN will automatically switch to English */ - GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(repository.getGameVersion(manifest)); - if (gameVersion.compareTo("1.1") < 0) + GameVersionNumber gameVersion = instance.getVersion(); + if (gameVersion == GameVersionNumber.unknown() || gameVersion.compareTo("1.1") < 0) return; String lang = normalizedLanguageTag(locale, gameVersion); @@ -180,7 +191,7 @@ private Path extractLwjglUnsafeAgent() throws IOException { Library library = new Library(new Artifact("org.glavo", "lwjgl-unsafe-agent", agentVersion)); String fileName = library.artifact().getFileName(); - Path agentPath = repository.getLibraryFile(manifest, library).toAbsolutePath().normalize(); + Path agentPath = instance.getLayout().getLibraryFile(instance.getId(), library).toAbsolutePath().normalize(); if (agentPath.toString().contains("=")) { throw new IOException("Invalid library path: " + agentPath); } 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 0b704310602..449593e4665 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -17,141 +17,178 @@ */ package org.jackhuang.hmcl.game; -import com.google.gson.JsonObject; -import com.google.gson.JsonParseException; -import com.google.gson.reflect.TypeToken; -import javafx.beans.binding.Binding; import javafx.beans.binding.Bindings; import javafx.beans.binding.ObjectBinding; -import javafx.scene.image.Image; -import org.jackhuang.hmcl.Metadata; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.ReadOnlyObjectWrapper; import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.download.LibraryAnalyzer; -import org.jackhuang.hmcl.event.Event; -import org.jackhuang.hmcl.event.EventManager; -import org.jackhuang.hmcl.java.JavaRuntime; import org.jackhuang.hmcl.modpack.ModAdviser; import org.jackhuang.hmcl.modpack.Modpack; -import org.jackhuang.hmcl.modpack.ModpackConfiguration; -import org.jackhuang.hmcl.modpack.ModpackProvider; -import org.jackhuang.hmcl.setting.LauncherSettings; import org.jackhuang.hmcl.setting.SettingsManager; import org.jackhuang.hmcl.setting.DefaultIsolationType; import org.jackhuang.hmcl.setting.DownloadProviders; import org.jackhuang.hmcl.setting.GameSettings; -import org.jackhuang.hmcl.setting.GameWindowType; -import org.jackhuang.hmcl.setting.LegacyGameSettingsMigrator; import org.jackhuang.hmcl.setting.GameDirectory; -import org.jackhuang.hmcl.setting.ProxyType; -import org.jackhuang.hmcl.setting.SettingFileUtils; +import org.jackhuang.hmcl.setting.LauncherSettings; +import org.jackhuang.hmcl.setting.LegacyGameSettingsMigrator; import org.jackhuang.hmcl.setting.GameSettingsPresetID; -import org.jackhuang.hmcl.setting.GameInstanceIconType; -import org.jackhuang.hmcl.ui.FXUtils; -import org.jackhuang.hmcl.util.FileSaver; import org.jackhuang.hmcl.util.Lang; -import org.jackhuang.hmcl.util.gson.JsonSchema; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.platform.OperatingSystem; -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; import java.nio.file.Files; -import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.time.Instant; import java.util.*; -import java.util.stream.Collectors; import java.util.stream.Stream; 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 GameDirectory and per-instance game settings. @NotNullByDefault public final class HMCLGameRepository extends DefaultGameRepository { - /// References an optional game instance in a repository. - /// - /// @param repository the owning game repository - /// @param instanceId the game instance ID, or `null` when only repository context is available - @NotNullByDefault - public record InstanceReference(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) { - } - - /// Directory under the instance root that stores HMCL-managed instance metadata. - private static final String INSTANCE_METADATA_DIRECTORY = ".hmcl"; - - /// Directory under the instance metadata directory that stores instance configuration files. - private static final String INSTANCE_CONFIG_DIRECTORY = "config"; - - /// Directory under the instance metadata directory that stores instance state files. - private static final String INSTANCE_STATE_DIRECTORY = "state"; - - /// Current file name for instance-specific game settings. - private static final String INSTANCE_GAME_SETTINGS_FILENAME = "instance-game-settings.json"; - /// The persistent game directory for this repository. private final GameDirectory gameDirectory; /// The selected instance ID persisted for this repository's game directory. - private final ObjectBinding<@Nullable GameInstanceID> selectedInstance; - - // instance game settings - private final Map instanceGameSettings = new HashMap<>(); - /// Instance IDs whose local game settings file has already been checked. - private final Set loadedInstanceGameSettings = new HashSet<>(); - private final Set readOnlyInstanceGameSettings = new HashSet<>(); - private final Set beingModpackInstances = new HashSet<>(); + private final ObjectBinding<@Nullable GameInstanceID> selectedInstanceId; - public final EventManager onInstanceIconChanged = new EventManager<>(); + /// The selected instance resolved from the current repository snapshot. + private final ReadOnlyObjectWrapper<@Nullable HMCLGameInstance> selectedInstance; /// Creates a repository backed by the given game directory. + /// + /// @param gameDirectory the persistent game directory represented by this repository public HMCLGameRepository(GameDirectory gameDirectory) { super(gameDirectory.getPath().toPath()); this.gameDirectory = gameDirectory; - this.selectedInstance = Bindings.valueAt(settings().getSelectedInstance(), gameDirectory.getId()); + this.selectedInstanceId = Bindings.valueAt(settings().getSelectedInstance(), gameDirectory.getId()); + this.selectedInstance = new ReadOnlyObjectWrapper<>(this, "selectedInstance"); + this.selectedInstance.bind(Bindings.createObjectBinding( + this::resolveSelectedInstance, + selectedInstanceId, + snapshotProperty())); gameDirectory.pathProperty().addListener((a, b, newValue) -> changeDirectory(newValue.toPath())); } + @Override + protected HMCLGameRepositoryLayout createLayout(Path baseDirectory) { + return new HMCLGameRepositoryLayout(baseDirectory); + } + + @Override + protected HMCLGameRepositorySnapshot createSnapshot(DefaultGameRepositoryLayout layout) { + return new HMCLGameRepositorySnapshot(this, (HMCLGameRepositoryLayout) layout); + } + + @Override + protected HMCLGameInstance createInstance( + DefaultGameRepositorySnapshot snapshot, + GameInstanceID id, + GameInstanceManifest manifest, + @Nullable Path manifestFile) { + return new HMCLGameInstance(snapshot, id, manifest, manifestFile); + } + + @Override + public HMCLGameRepositorySnapshot getSnapshot() { + return (HMCLGameRepositorySnapshot) super.getSnapshot(); + } + + @Override + @SuppressWarnings("unchecked") + public ReadOnlyObjectProperty snapshotProperty() { + return (ReadOnlyObjectProperty) super.snapshotProperty(); + } + + @Override + public HMCLGameRepositoryLayout getLayout() { + return (HMCLGameRepositoryLayout) super.getLayout(); + } + + @Override + public HMCLGameInstance getInstance(GameInstanceID id) throws NoSuchGameInstanceException { + return (HMCLGameInstance) super.getInstance(id); + } + + /// Returns the indexed instance for the given id, or `null` when it is not loaded. + /// + /// @param id the instance id + /// @return the instance, or `null` when absent + public @Nullable HMCLGameInstance findInstance(GameInstanceID id) { + return (HMCLGameInstance) getSnapshot().findInstance(id); + } + /// Returns the persistent game directory for this repository. public GameDirectory getGameDirectory() { return gameDirectory; } - /// Returns the selected instance ID property for this repository's game directory. - public Binding<@Nullable GameInstanceID> selectedInstanceProperty() { - return selectedInstance; + /// Returns the selected instance resolved from the current repository snapshot. + /// + /// The property is `null` when the persisted selection is absent or is not registered in the + /// current snapshot. Publishing a new snapshot replaces the value with that snapshot's member, + /// even when the selected ID is unchanged. + /// + /// @return the read-only selected-instance property + public ReadOnlyObjectProperty<@Nullable HMCLGameInstance> selectedInstanceProperty() { + return selectedInstance.getReadOnlyProperty(); } - /// Returns the selected instance ID for this repository's game directory. - public @Nullable GameInstanceID getSelectedInstance() { + /// Returns the selected instance from the current repository snapshot. + /// + /// @return the selected instance, or `null` when no registered instance is selected + public @Nullable HMCLGameInstance getSelectedInstance() { return selectedInstance.get(); } - /// Sets the selected instance ID for this repository's game directory. - public void setSelectedInstance(@Nullable GameInstanceID instanceId) { - settings().setSelectedInstance(gameDirectory.getId(), instanceId); + /// Persists an instance as this repository's current selection. + /// + /// A stale snapshot member from this repository is accepted; the observable property resolves + /// its ID against the current snapshot. + /// + /// @param instance the instance to select, or `null` to clear the selection + /// @throws IllegalArgumentException if `instance` belongs to another repository + public void setSelectedInstance(@Nullable HMCLGameInstance instance) { + if (instance != null && instance.getRepository() != this) { + throw new IllegalArgumentException("Selected instance belongs to another repository"); + } + settings().setSelectedInstance(gameDirectory.getId(), instance != null ? instance.getId() : null); } - /// Refreshes the selected instance ID after instances are loaded. + /// Restores a valid selected instance after repository instances are loaded. + /// + /// If the persisted ID is not registered, the first indexed instance is selected. If the + /// repository is empty, the persisted selection is cleared. public void refreshSelectedInstance() { - @Nullable GameInstanceID selectedInstance = settings().getSelectedInstance(gameDirectory.getId()); - @Nullable GameInstanceID refreshedInstance = selectedInstance; - if (refreshedInstance == null || !hasInstance(refreshedInstance)) { - refreshedInstance = getInstanceManifests().isEmpty() ? null : getInstanceManifests().iterator().next().id(); + @Nullable GameInstanceID persistedId = selectedInstanceId.get(); + @Nullable HMCLGameInstance refreshedInstance = persistedId != null ? findInstance(persistedId) : null; + if (refreshedInstance == null) { + refreshedInstance = getSnapshot().getInstances().stream().findFirst().orElse(null); } - if (!Objects.equals(selectedInstance, refreshedInstance)) { + + @Nullable GameInstanceID refreshedId = refreshedInstance != null ? refreshedInstance.getId() : null; + if (!Objects.equals(persistedId, refreshedId)) { setSelectedInstance(refreshedInstance); } } + /// Resolves the persisted selected ID from the current repository snapshot. + /// + /// @return the current snapshot member, or `null` when the selected ID is absent or unregistered + private @Nullable HMCLGameInstance resolveSelectedInstance() { + @Nullable GameInstanceID instanceId = selectedInstanceId.get(); + return instanceId != null ? findInstance(instanceId) : null; + } + /// Returns a dependency manager using the currently selected download provider. public DefaultDependencyManager getDependency() { return getDependency(DownloadProviders.getDownloadProvider()); @@ -162,70 +199,125 @@ public DefaultDependencyManager getDependency(DownloadProvider downloadProvider) return new DefaultDependencyManager(this, downloadProvider, HMCLCacheRepository.REPOSITORY); } - @Override - public Path getRunDirectory(GameInstanceID instanceId) { - if (beingModpackInstances.contains(instanceId) || isModpack(instanceId)) { - return getInstanceRoot(instanceId); + /// Resolves the run directory from modpack state and local settings. + /// + /// @param instanceId the instance id + /// @param modpack whether the instance is an HMCL modpack (`modpack.cfg` present) + /// @param localSetting the instance-local settings, or `null` when absent + /// @return the run directory + Path computeRunDirectory( + GameInstanceID instanceId, + boolean modpack, + GameSettings.@Nullable Instance localSetting) { + Path instanceRoot = getLayout().getInstanceRoot(instanceId); + if (modpack) { + return instanceRoot; } - GameSettings.Instance localSetting = getInstanceGameSettings(instanceId); boolean useInstanceRunningDirectory = - localSetting != null && localSetting.getOverrideProperties().contains(GameSettings.PROPERTY_RUNNING_DIRECTORY); + localSetting != null + && localSetting.getOverrideProperties().contains(GameSettings.PROPERTY_RUNNING_DIRECTORY); - String runningDirectory = getSelectedRunningDirectory(localSetting, useInstanceRunningDirectory); + String runningDirectory = selectedRunningDirectory(localSetting, useInstanceRunningDirectory); if (StringUtils.isBlank(runningDirectory)) { - return useInstanceRunningDirectory ? getInstanceRoot(instanceId) : super.getRunDirectory(instanceId); + return useInstanceRunningDirectory ? instanceRoot : getBaseDirectory(); } try { return Path.of(runningDirectory); - } catch (InvalidPathException ignored) { - return getInstanceRoot(instanceId); + } catch (Exception ignored) { + return instanceRoot; } } - /// Returns the running directory string selected by the current source. - private String getSelectedRunningDirectory( - @Nullable GameSettings.Instance localSetting, + private String selectedRunningDirectory( + GameSettings.@Nullable Instance localSetting, boolean useInstanceRunningDirectory) { if (useInstanceRunningDirectory) { if (localSetting == null) { return ""; } - - //noinspection DataFlowIssue return Objects.requireNonNullElse(localSetting.runningDirectoryProperty().getValue(), ""); } GameSettings.Preset parent = getParentGameSettings(localSetting); - //noinspection DataFlowIssue return Objects.requireNonNullElse(parent.runningDirectoryProperty().getValue(), ""); } - public Stream getDisplayInstanceManifests() { - return getInstanceManifests().stream() - .filter(v -> !v.isHidden()) - .sorted(Comparator.comparing((GameInstanceManifest v) -> Lang.requireNonNullElse(v.releaseTime(), Instant.EPOCH)) - .thenComparing(v -> VersionNumber.asVersion(v.id().id()))); + /// Reads instance-local settings from disk without requiring a registered snapshot member. + /// + /// Used for install-time path resolution and migration before the instance is indexed. Does not + /// publish a snapshot entry. + /// + /// @param instanceId the instance id + /// @return the loaded settings, or `null` when none can be loaded + private GameSettings.@Nullable Instance peekInstanceGameSettings(GameInstanceID instanceId) { + Path file = getLayout().getInstanceGameSettingsFile(instanceId); + if (!Files.isRegularFile(file)) { + return null; + } + try { + return LauncherSettings.SETTINGS_GSON + .fromJson(Files.readString(file), GameSettings.Instance.class); + } catch (Exception e) { + LOG.warning("Failed to peek instance game settings: " + file, e); + return null; + } } - @Override - protected void refreshImpl() { - instanceGameSettings.clear(); - loadedInstanceGameSettings.clear(); - readOnlyInstanceGameSettings.clear(); - super.refreshImpl(); - getInstanceManifests().stream().map(GameInstanceManifest::id).forEach(this::loadInstanceGameSettings); + /// Writes instance-local settings to disk for an id that may not yet be registered. + /// + /// @param instanceId the instance id + /// @param setting the settings to write + /// @throws IOException if the file cannot be written + private void writeInstanceGameSettings(GameInstanceID instanceId, GameSettings.Instance setting) + throws IOException { + Path file = getLayout().getInstanceGameSettingsFile(instanceId).toAbsolutePath().normalize(); + Files.createDirectories(file.getParent()); + setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA); + FileUtils.saveSafely(file, LauncherSettings.SETTINGS_GSON.toJson(setting)); + } - try { - Path file = getBaseDirectory().resolve("launcher_profiles.json"); - if (!Files.exists(file) && !getInstanceManifests().isEmpty()) { - Files.createDirectories(file.getParent()); - Files.writeString(file, PROFILE); + /// Ensures the instance uses an isolated running directory under its instance root. + /// + /// When the instance is already registered, settings are updated through + /// [HMCLGameInstance]. Otherwise the isolation flag is written to the instance settings file + /// so a later [HMCLGameInstance#getRunDirectory] sees the isolated path. + /// + /// @param instanceId the instance id + public void ensureIsolatedRunningDirectory(GameInstanceID instanceId) { + HMCLGameInstance instance = findInstance(instanceId); + if (instance != null) { + if (instance.isSettingsReadOnly()) { + return; + } + GameSettings.Instance setting = instance.getSettingsOrCreate(); + if (setting != null + && setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY)) { + instance.saveSettings(); } - } catch (IOException ex) { - LOG.warning("Unable to create launcher_profiles.json, Forge/LiteLoader installer will not work.", ex); + return; } + + GameSettings.Instance setting = peekInstanceGameSettings(instanceId); + if (setting == null) { + setting = new GameSettings.Instance(); + } + if (setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY)) { + try { + writeInstanceGameSettings(instanceId, setting); + } catch (IOException e) { + LOG.warning("Failed to write isolated running directory for " + instanceId, e); + } + } + } + + public Stream getDisplayInstances() { + return getSnapshot().getInstances().stream() + .filter(it -> !it.getManifest().isHidden()) + .sorted(Comparator.comparing((HMCLGameInstance instance) -> Lang.requireNonNullElse(instance.getLaunchManifest().releaseTime(), Instant.EPOCH)) + .thenComparing(DefaultGameInstance::getVersion) + .thenComparing(instance -> VersionNumber.asVersion(instance.getId().id()))); } public void changeDirectory(Path newDirectory) { @@ -240,25 +332,12 @@ private void clean(Path directory) throws IOException { public void clean(GameInstanceID instanceId) throws IOException { clean(getBaseDirectory()); - clean(getRunDirectory(instanceId)); - } - - /// Removes an instance from disk and clears its cached HMCL settings state. - @Override - public boolean removeInstanceFromDisk(GameInstanceID instanceId) { - boolean removed = super.removeInstanceFromDisk(instanceId); - if (removed) { - instanceGameSettings.remove(instanceId); - loadedInstanceGameSettings.remove(instanceId); - readOnlyInstanceGameSettings.remove(instanceId); - beingModpackInstances.remove(instanceId); - } - return removed; + clean(getInstance(instanceId).getRunDirectory()); } public void duplicateInstance(GameInstanceID srcId, GameInstanceID dstId, boolean copySaves) throws IOException { - Path srcDir = getInstanceRoot(srcId); - Path dstDir = getInstanceRoot(dstId); + Path srcDir = getLayout().getInstanceRoot(srcId); + Path dstDir = getLayout().getInstanceRoot(dstId); GameInstanceManifest fromManifest = getInstanceManifest(srcId); @@ -285,247 +364,57 @@ public void duplicateInstance(GameInstanceID srcId, GameInstanceID dstId, boolea JsonUtils.writeToJsonFile(toJson, fromManifest.withId(dstId).withJar(dstId)); + Path srcGameDir = getInstance(srcId).getRunDirectory(); boolean copyOriginalGameDir; try { - copyOriginalGameDir = !Files.isSameFile(getRunDirectory(srcId), getInstanceRoot(srcId)); + copyOriginalGameDir = !Files.isSameFile(srcGameDir, getLayout().getInstanceRoot(srcId)); } catch (IOException e) { copyOriginalGameDir = true; } - Path srcGameDir = getRunDirectory(srcId); - - GameSettings.Instance newGameSettings = copyInstanceGameSettings(srcId); + GameSettings.Instance newGameSettings = getInstance(srcId).copySettings(); newGameSettings.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY); newGameSettings.runningDirectoryProperty().setValue(""); - initInstanceGameSettings(dstId, newGameSettings); - saveGameSettingsSync(dstId); + writeInstanceGameSettings(dstId, newGameSettings); - Path dstGameDir = getRunDirectory(dstId); + Path dstGameDir = computeRunDirectory(dstId, false, newGameSettings); if (copyOriginalGameDir) FileUtils.copyDirectory(srcGameDir, dstGameDir, path -> Modpack.acceptFile(path, blackList, null)); - } - - private GameSettings.Instance copyInstanceGameSettings(GameInstanceID instanceId) { - GameSettings.Instance setting = getInstanceGameSettings(instanceId); - 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(instanceId).getPreset().idProperty().getValue()); - return copied; + refresh(); } - /// Returns the HMCL-managed metadata directory under the instance root. + /// Returns instance-local settings for a registered instance ID, creating empty settings when + /// the settings file is absent and writable. /// - /// This directory stores instance-scoped files owned by HMCL. - public Path getInstanceMetadataDirectory(GameInstanceID instanceId) { - return getInstanceRoot(instanceId).resolve(INSTANCE_METADATA_DIRECTORY); - } - - /// Returns the HMCL-managed configuration directory under the instance metadata directory. - public Path getInstanceConfigDirectory(GameInstanceID instanceId) { - return getInstanceMetadataDirectory(instanceId).resolve(INSTANCE_CONFIG_DIRECTORY); - } - - /// Returns the HMCL-managed state directory under the instance metadata directory. - public Path getInstanceStateDirectory(GameInstanceID instanceId) { - return getInstanceMetadataDirectory(instanceId).resolve(INSTANCE_STATE_DIRECTORY); - } - - /// Returns the current local game settings path under the instance configuration directory. - private Path getInstanceGameSettingsFile(GameInstanceID instanceId) { - return getInstanceConfigDirectory(instanceId).resolve(INSTANCE_GAME_SETTINGS_FILENAME); - } - - private void loadInstanceGameSettings(GameInstanceID instanceId) { - loadedInstanceGameSettings.add(instanceId); - InstanceGameSettingsLoadResult result = loadGameSettingsFile(getInstanceGameSettingsFile(instanceId)); - if (result.setting() != null) { - initInstanceGameSettings(instanceId, result.setting(), result.allowSave()); - return; - } - if (!result.allowSave()) { - readOnlyInstanceGameSettings.add(instanceId); - return; - } - - @Nullable GameSettingsPresetID legacyParent = gameDirectory.getLegacyGameSettings(); - if (SettingsManager.getGameSettings(legacyParent) == null) { - legacyParent = null; - } - - LegacyGameSettingsMigrator.InstanceMigrationResult migrationResult = - LegacyGameSettingsMigrator.migrateInstanceGameSettings( - this, instanceId, - legacyParent); - if (migrationResult != null) { - initInstanceGameSettings(instanceId, migrationResult.setting()); - try { - saveGameSettingsSync(instanceId); - migrationResult.saveReceipt(); - } catch (IOException e) { - LOG.warning("Failed to save migrated instance game settings for " + instanceId, e); - } - return; - } - } - - /// Loads a new-format instance game settings file. - private InstanceGameSettingsLoadResult loadGameSettingsFile(Path file) { - if (!Files.exists(file)) { - return new InstanceGameSettingsLoadResult(null, true); - } - - try { - JsonObject jsonObject = JsonUtils.fromJsonFile(LauncherSettings.SETTINGS_GSON, file, JsonObject.class); - if (jsonObject == null) { - LOG.warning("Instance game settings are empty: " + file); - GameSettings.Instance fallback = new GameSettings.Instance(); - return new InstanceGameSettingsLoadResult(fallback, true); - } - - JsonSchema.CompatibilityResult schemaResult = - JsonSchema.check(jsonObject, GameSettings.Instance.CURRENT_SCHEMA); - switch (schemaResult.status()) { - case MISSING -> LOG.warning("Missing schema in instance game settings: " + file); - case INVALID -> LOG.warning("Invalid schema in instance game settings: " - + file + ", Actual: " + schemaResult.invalidValue()); - case UNPARSEABLE -> LOG.warning("Unparseable schema in instance game settings: " - + file + ", Actual: " + schemaResult.actual()); - case UNEXPECTED_ID -> LOG.warning("Unexpected instance game settings schema. Expected: " - + GameSettings.Instance.CURRENT_SCHEMA + ", Actual: " + schemaResult.actual()); - case UNSUPPORTED_MAJOR, READ_ONLY_PRESERVE_SCHEMA -> - LOG.warning("Unsupported instance game settings schema. Expected: " - + GameSettings.Instance.CURRENT_SCHEMA + ", Actual: " + schemaResult.actual()); - case READ_WRITE, READ_WRITE_PRESERVE_SCHEMA -> { - } - } - if (!schemaResult.readable()) { - GameSettings.Instance fallback = new GameSettings.Instance(); - fallback.setSavable(false); - return new InstanceGameSettingsLoadResult(fallback, false); - } - - GameSettings.Instance setting = - LauncherSettings.SETTINGS_GSON.fromJson(jsonObject, GameSettings.Instance.class); - if (setting == null) { - LOG.warning("Instance game settings deserialized to null: " + file); - GameSettings.Instance fallback = new GameSettings.Instance(); - fallback.setBackupOnNextSave(true); - return new InstanceGameSettingsLoadResult(fallback, true); - } - if (!schemaResult.preserveSchema() && !GameSettings.Instance.CURRENT_SCHEMA.equals(setting.getSchema())) { - setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA); - } - return new InstanceGameSettingsLoadResult(setting, schemaResult.allowSave()); - } catch (JsonParseException ex) { - LOG.warning("Failed to parse game setting " + file, ex); - GameSettings.Instance fallback = new GameSettings.Instance(); - fallback.setBackupOnNextSave(true); - return new InstanceGameSettingsLoadResult(fallback, true); - } catch (Exception ex) { - LOG.warning("Failed to load game setting " + file, ex); - return new InstanceGameSettingsLoadResult(null, false); - } - } - - public @Nullable GameSettings.Instance createInstanceGameSettings(GameInstanceID instanceId) { - if (!hasInstance(instanceId)) { - return null; - } - if (readOnlyInstanceGameSettings.contains(instanceId)) { + /// Code that already has an [HMCLGameInstance] should use + /// [HMCLGameInstance#getSettingsOrCreate()] instead. + /// + /// @param instanceId the registered instance ID + /// @return the settings, or `null` when the instance is not registered or settings are unavailable + public @Nullable GameSettings.Instance getInstanceGameSettingsOrCreate(GameInstanceID instanceId) { + HMCLGameInstance instance = findInstance(instanceId); + if (instance == null) { return null; } - if (instanceGameSettings.containsKey(instanceId)) { - return getInstanceGameSettings(instanceId); - } - - GameSettings.Instance setting = new GameSettings.Instance(); - return initInstanceGameSettings(instanceId, setting); - } - - private GameSettings.Instance initInstanceGameSettings(GameInstanceID instanceId, GameSettings.Instance setting) { - return initInstanceGameSettings(instanceId, setting, true); - } - - private GameSettings.Instance initInstanceGameSettings(GameInstanceID instanceId, GameSettings.Instance setting, boolean allowSave) { - normalizeRunningDirectoryOverride(setting); - setting.setSavable(allowSave); - loadedInstanceGameSettings.add(instanceId); - instanceGameSettings.put(instanceId, setting); - if (allowSave) { - readOnlyInstanceGameSettings.remove(instanceId); - setting.addListener(a -> saveGameSettings(instanceId)); - } else { - readOnlyInstanceGameSettings.add(instanceId); - } - 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.runningDirectoryProperty().getValue())) { - setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY); - } - } - - @Nullable - public GameSettings.Instance getInstanceGameSettings(GameInstanceID instanceId) { - if (!loadedInstanceGameSettings.contains(instanceId)) { - loadInstanceGameSettings(instanceId); - } - return instanceGameSettings.get(instanceId); - } - - @Nullable - public GameSettings.Instance getInstanceGameSettingsOrCreate(GameInstanceID instanceId) { - GameSettings.Instance setting = getInstanceGameSettings(instanceId); - if (setting == null) { - setting = createInstanceGameSettings(instanceId); - } - return setting; + return instance.getSettingsOrCreate(); } - /// Returns whether the instance-specific game settings file cannot be overwritten safely. + /// Returns instance-local settings for a registered instance ID. /// - /// @param instanceId the instance ID - /// @return whether the instance settings are loaded in read-only mode - public boolean isInstanceGameSettingsReadOnly(GameInstanceID instanceId) { - if (!loadedInstanceGameSettings.contains(instanceId)) { - loadInstanceGameSettings(instanceId); - } - - return readOnlyInstanceGameSettings.contains(instanceId); - } - - /// Backs up and overwrites the instance-specific game settings file with the currently loaded settings. + /// When the instance is not yet indexed, settings are loaded from disk (including lazy legacy + /// migration) without publishing a snapshot entry. Callers that already have an + /// [HMCLGameInstance] should use [HMCLGameInstance#getSettings()] instead. /// /// @param instanceId the instance ID - public void forceOverwriteInstanceGameSettings(GameInstanceID instanceId) { - if (!loadedInstanceGameSettings.contains(instanceId)) { - loadInstanceGameSettings(instanceId); - } - - GameSettings.Instance setting = instanceGameSettings.get(instanceId); - if (setting == null) { - setting = new GameSettings.Instance(); - instanceGameSettings.put(instanceId, setting); - loadedInstanceGameSettings.add(instanceId); - } - - boolean installAutoSave = !setting.isSavable(); - Path file = getInstanceGameSettingsFile(instanceId).toAbsolutePath().normalize(); - SettingFileUtils.backupInvalidConfig(file); - setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA); - setting.setSavable(true); - setting.setBackupOnNextSave(false); - readOnlyInstanceGameSettings.remove(instanceId); - saveGameSettings(instanceId); - if (installAutoSave) { - setting.addListener(a -> saveGameSettings(instanceId)); + /// @return the settings, or `null` when no local settings exist + public @Nullable GameSettings.Instance getInstanceGameSettings(GameInstanceID instanceId) { + HMCLGameInstance instance = findInstance(instanceId); + if (instance != null) { + return instance.getSettings(); } + return loadOrMigrateInstanceGameSettings(instanceId); } /// Returns the explicit parent preset of the instance, falling back to the default preset. @@ -535,31 +424,15 @@ public GameSettings.Preset getParentGameSettings(@Nullable GameSettings.Instance return parentSetting != null ? parentSetting : SettingsManager.getDefaultGameSettingsPresetOrCreate(); } + /// Resolves effective settings for a registered instance ID. + /// + /// Instance-oriented callers should use [HMCLGameInstance#getEffectiveSettings()] instead. + /// + /// @param instanceId the registered instance ID + /// @return the effective settings + /// @throws NoSuchGameInstanceException if the instance is not registered public GameSettings.Effective getEffectiveGameSettings(GameInstanceID instanceId) { - GameSettings.Instance instance = getInstanceGameSettings(instanceId); - return GameSettings.resolve(getParentGameSettings(instance), instance); - } - - public void applyDefaultIsolationSetting(GameInstanceID instanceId) { - if (!hasInstance(instanceId)) { - return; - } - - GameSettings.Instance instanceSetting = getInstanceGameSettings(instanceId); - GameSettings.Preset preset = getParentGameSettings(instanceSetting); - DefaultIsolationType type = Lang.requireNonNullElse(preset.defaultIsolationTypeProperty().getValue(), DefaultIsolationType.MODDED); - boolean isolated = switch (type) { - case NEVER -> false; - case ALWAYS -> true; - case MODDED -> LibraryAnalyzer.isModded(getResolvedInstanceManifest(instanceId)); - }; - - if (isolated) { - GameSettings.Instance setting = instanceSetting != null ? instanceSetting : getInstanceGameSettingsOrCreate(instanceId); - if (setting != null && setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY)) { - saveGameSettings(instanceId); - } - } + return getInstance(instanceId).getEffectiveSettings(); } /// Returns whether a new instance should use an isolated running directory under the default isolation settings. @@ -574,289 +447,50 @@ public boolean shouldIsolateNewInstance(boolean modded) { } /// Applies default isolation to a new instance before its manifest is saved. + /// + /// Writes the isolation flag to the instance settings file so a later + /// [HMCLGameInstance#getRunDirectory] returns the instance root. public void applyDefaultIsolationSettingForNewInstance(GameInstanceID instanceId, boolean modded) { - if (!shouldIsolateNewInstance(modded) || readOnlyInstanceGameSettings.contains(instanceId)) { + if (!shouldIsolateNewInstance(modded)) { return; } - - GameSettings.Instance setting = getInstanceGameSettings(instanceId); - if (setting == null) { - setting = initInstanceGameSettings(instanceId, new GameSettings.Instance()); - } - if (setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY)) { - saveGameSettings(instanceId); - } - } - - public Optional getInstanceIconFile(GameInstanceID instanceId) { - Path root = getInstanceRoot(instanceId); - - for (String extension : FXUtils.IMAGE_EXTENSIONS) { - Path file = root.resolve("icon." + extension); - if (Files.exists(file)) { - return Optional.of(file); - } - } - - return Optional.empty(); + ensureIsolatedRunningDirectory(instanceId); } - public void setInstanceIconFile(GameInstanceID instanceId, Path iconFile) throws IOException { - String ext = FileUtils.getExtension(iconFile).toLowerCase(Locale.ROOT); - if (!FXUtils.IMAGE_EXTENSIONS.contains(ext)) { - throw new IllegalArgumentException("Unsupported icon file: " + ext); + /// Loads settings from disk for an unregistered id, running legacy migration when needed. + private GameSettings.@Nullable Instance loadOrMigrateInstanceGameSettings(GameInstanceID instanceId) { + Path file = getLayout().getInstanceGameSettingsFile(instanceId); + if (Files.isRegularFile(file)) { + return peekInstanceGameSettings(instanceId); } - deleteIconFile(instanceId); - - FileUtils.copyFile(iconFile, getInstanceRoot(instanceId).resolve("icon." + ext)); - } - - public void deleteIconFile(GameInstanceID instanceId) { - Path root = getInstanceRoot(instanceId); - for (String extension : FXUtils.IMAGE_EXTENSIONS) { - Path file = root.resolve("icon." + extension); - try { - Files.deleteIfExists(file); - } catch (IOException e) { - LOG.warning("Failed to delete icon file: " + file, e); - } + @Nullable GameSettingsPresetID legacyParent = getGameDirectory().getLegacyGameSettings(); + if (SettingsManager.getGameSettings(legacyParent) == null) { + legacyParent = null; } - } - - public Image getInstanceIconImage(@Nullable GameInstanceID instanceId) { - if (instanceId == null || !isLoaded()) - return GameInstanceIconType.DEFAULT.getIcon(); - - GameSettings.Instance setting = getInstanceGameSettings(instanceId); - GameInstanceIconType iconType = setting != null ? Lang.requireNonNullElse(setting.iconProperty().getValue(), GameInstanceIconType.DEFAULT) : GameInstanceIconType.DEFAULT; - - if (iconType == GameInstanceIconType.DEFAULT) { - GameInstanceManifest.Resolved resolvedInstanceManifest = getResolvedInstanceManifest(instanceId); - Optional iconFile = getInstanceIconFile(instanceId); - if (iconFile.isPresent()) { - try { - return FXUtils.loadImage(iconFile.get(), 64, 64, true, true); - } catch (Exception e) { - LOG.warning("Failed to load instance icon of " + instanceId, e); - } - } - if (LibraryAnalyzer.isModded(resolvedInstanceManifest)) { - LibraryAnalyzer libraryAnalyzer = LibraryAnalyzer.analyze(resolvedInstanceManifest, null); - if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.FABRIC)) - return GameInstanceIconType.FABRIC.getIcon(); - else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.QUILT)) - return GameInstanceIconType.QUILT.getIcon(); - else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.LEGACY_FABRIC)) - return GameInstanceIconType.LEGACY_FABRIC.getIcon(); - else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE)) - return GameInstanceIconType.NEO_FORGE.getIcon(); - else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.FORGE)) - return GameInstanceIconType.FORGE.getIcon(); - else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.CLEANROOM)) - return GameInstanceIconType.CLEANROOM.getIcon(); - else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.LITELOADER)) - return GameInstanceIconType.CHICKEN.getIcon(); - else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.OPTIFINE)) - return GameInstanceIconType.OPTIFINE.getIcon(); - } - - String gameVersion = getGameVersion(resolvedInstanceManifest.launchManifest()).orElse(null); - if (gameVersion != null) { - GameVersionNumber versionNumber = GameVersionNumber.asGameVersion(gameVersion); - if (versionNumber.isAprilFools()) { - return GameInstanceIconType.APRIL_FOOLS.getIcon(); - } else if (versionNumber instanceof GameVersionNumber.LegacySnapshot) { - return GameInstanceIconType.COMMAND.getIcon(); - } else if (versionNumber instanceof GameVersionNumber.Old) { - return GameInstanceIconType.CRAFT_TABLE.getIcon(); - } - } - return GameInstanceIconType.GRASS.getIcon(); - } else { - return iconType.getIcon(); + LegacyGameSettingsMigrator.InstanceMigrationResult migrationResult = + LegacyGameSettingsMigrator.migrateInstanceGameSettings(this, instanceId, legacyParent); + if (migrationResult == null) { + return null; } - } - public void saveGameSettings(GameInstanceID instanceId) { - if (!instanceGameSettings.containsKey(instanceId) || readOnlyInstanceGameSettings.contains(instanceId)) - return; - GameSettings.Instance setting = instanceGameSettings.get(instanceId); - if (setting == null) { - return; - } - Path file = getInstanceGameSettingsFile(instanceId).toAbsolutePath().normalize(); try { - Files.createDirectories(file.getParent()); + writeInstanceGameSettings(instanceId, migrationResult.setting()); + migrationResult.saveReceipt(); } catch (IOException e) { - LOG.warning("Failed to create directory: " + file.getParent(), e); + LOG.warning("Failed to save migrated instance game settings for " + instanceId, e); } - - if (setting.isBackupOnNextSave()) { - setting.setBackupOnNextSave(false); - SettingFileUtils.backupInvalidConfig(file); - } - FileSaver.save(file, LauncherSettings.SETTINGS_GSON.toJson(setting)); + return migrationResult.setting(); } - /// Saves instance-specific game settings synchronously. - /// - /// @param instanceId the instance ID - /// @throws IOException if saving the file fails - private void saveGameSettingsSync(GameInstanceID instanceId) throws IOException { - if (!instanceGameSettings.containsKey(instanceId) || readOnlyInstanceGameSettings.contains(instanceId)) { - return; - } - - GameSettings.Instance setting = instanceGameSettings.get(instanceId); - if (setting == null) { - return; - } - - Path file = getInstanceGameSettingsFile(instanceId).toAbsolutePath().normalize(); - Files.createDirectories(file.getParent()); - if (setting.isBackupOnNextSave()) { - setting.setBackupOnNextSave(false); - SettingFileUtils.backupInvalidConfig(file); - } - FileUtils.saveSafely(file, LauncherSettings.SETTINGS_GSON.toJson(setting)); - } - - /// Result of loading an instance-specific game settings file. - /// - /// @param setting the loaded instance settings, or `null` when unavailable - /// @param allowSave whether the file may be overwritten - private record InstanceGameSettingsLoadResult( - @Nullable GameSettings.Instance setting, - boolean allowSave) { - } - - public LaunchOptions.Builder getLaunchOptions(GameInstanceID instanceId, JavaRuntime javaVersion, Path gameDir, List javaAgents, List javaArguments, boolean makeLaunchScript) { - GameSettings.Effective vs = getEffectiveGameSettings(instanceId); - boolean noJVMOptions = vs.getInheritable(GameSettings::noJVMOptionsProperty); - boolean autoMemory = vs.getInheritable(GameSettings::autoMemoryProperty); - GameVersionNumber gameVersionNumber = GameVersionNumber.asGameVersion(getGameVersion(instanceId)); - - @Nullable Integer maxMemory; - if (autoMemory) { - maxMemory = noJVMOptions - ? null - : Math.toIntExact(getAutoAllocatedMemory(SystemInfo.getPhysicalMemoryStatus().available()) / 1024L / 1024L); - } else { - maxMemory = vs.getMaxMemory(); - } - - LaunchOptions.Builder builder = new LaunchOptions.Builder() - .setInstanceId(instanceId) - .setGameDir(gameDir) - .setJava(javaVersion) - .setVersionType(Metadata.TITLE) - .setVersionName(instanceId.id()) - .setProfileName(Metadata.TITLE) - .setGameArguments(StringUtils.tokenize(vs.getInheritable(GameSettings::gameArgumentsProperty))) - .setOverrideJavaArguments(StringUtils.tokenize(vs.getInheritable(GameSettings::jvmOptionsProperty))) - .setMaxMemory(maxMemory) - .setMinMemory(vs.getInheritable(GameSettings::minMemoryProperty)) - .setMetaspace(Lang.toIntOrNull(vs.getInheritable(GameSettings::permSizeProperty))) - .setEnvironmentVariables( - Lang.mapOf(StringUtils.tokenize(vs.getInheritable(GameSettings::environmentVariablesProperty)) - .stream() - .map(it -> { - int idx = it.indexOf('='); - return idx >= 0 ? pair(it.substring(0, idx), it.substring(idx + 1)) : pair(it, ""); - }) - .collect(Collectors.toList()) - ) - ) - .setWidth(vs.getWidth()) - .setHeight(vs.getHeight()) - .setFullscreen(vs.getInheritable(GameSettings::windowTypeProperty) == GameWindowType.FULLSCREEN) - .setWrapper(vs.getInheritable(GameSettings::commandWrapperProperty)) - .setProxyOption(getProxyOption()) - .setPreLaunchCommand(vs.getInheritable(GameSettings::preLaunchCommandProperty)) - .setPostExitCommand(vs.getInheritable(GameSettings::postExitCommandProperty)) - .setNoGeneratedJVMArgs(noJVMOptions) - .setNoGeneratedOptimizingJVMArgs(vs.getInheritable(GameSettings::noOptimizingJVMOptionsProperty)) - .setUseCustomNatives(vs.getInheritable(GameSettings::useCustomNativesProperty)) - .setNativesDir(vs.getInheritable(GameSettings::nativesDirectoryProperty)) - .setProcessPriority(vs.getInheritable(GameSettings::processPriorityProperty)) - .setGraphicsBackend(vs.getInheritable(GameSettings::graphicsBackendProperty)) - .setRenderer(vs.getRenderer(gameVersionNumber)) - .setEnableDebugLogOutput(vs.getInheritable(GameSettings::enableDebugLogOutputProperty)) - .setAllowAutoAgent(vs.getInheritable(GameSettings::allowAutoAgentProperty)) - .setDisableAutoGameOptions(vs.getInheritable(GameSettings::disableAutoGameOptionsProperty)) - .setUseNativeGLFW(vs.getInheritable(GameSettings::useNativeGLFWProperty)) - .setUseNativeOpenAL(vs.getInheritable(GameSettings::useNativeOpenALProperty)) - .setDaemon(!makeLaunchScript && vs.getInheritable(GameSettings::launcherVisibilityProperty).isDaemon()) - .setJavaAgents(javaAgents) - .setJavaArguments(javaArguments); - - QuickPlayOption quickPlayOption = vs.getQuickPlayOption(); - if (quickPlayOption != null) { - builder.setQuickPlayOption(quickPlayOption); - } - - Path json = getModpackConfiguration(instanceId); - if (Files.exists(json)) { - try { - String jsonText = Files.readString(json); - ModpackConfiguration modpackConfiguration = JsonUtils.GSON.fromJson(jsonText, ModpackConfiguration.class); - ModpackProvider provider = ModpackHelper.getProviderByType(modpackConfiguration.getType()); - if (provider != null) provider.injectLaunchOptions(jsonText, builder); - } catch (IOException | JsonParseException e) { - LOG.warning("Failed to parse modpack configuration file " + json, e); - } - } - - if (autoMemory && builder.getJavaArguments().stream().anyMatch(it -> it.startsWith("-Xmx"))) - builder.setMaxMemory(null); - - return builder; - } - - @Override - public Path getModpackConfiguration(GameInstanceID instanceId) { - return getInstanceRoot(instanceId).resolve("modpack.cfg"); - } - - public void markInstanceAsModpack(GameInstanceID instanceId) { - beingModpackInstances.add(instanceId); - } - - public void undoMark(GameInstanceID instanceId) { - beingModpackInstances.remove(instanceId); - } - - public void markInstanceLaunchedAbnormally(GameInstanceID instanceId) { - try { - Files.createFile(getInstanceRoot(instanceId).resolve(".abnormal")); - } catch (IOException ignored) { - } - } - - public boolean unmarkInstanceLaunchedAbnormally(GameInstanceID instanceId) { - Path file = getInstanceRoot(instanceId).resolve(".abnormal"); - if (Files.isRegularFile(file)) { - try { - Files.delete(file); - } catch (IOException e) { - LOG.warning("Failed to delete abnormal mark file: " + file, e); - } - - return true; - } else { - return false; - } - } - - private static final String PROFILE = "{\"selectedProfile\": \"(Default)\",\"profiles\": {\"(Default)\": {\"name\": \"(Default)\"}},\"clientToken\": \"88888888-8888-8888-8888-888888888888\"}"; - - // These instance ids are forbidden because they may conflict with modpack configuration filenames private static final Set FORBIDDEN_INSTANCE_IDS = Set.of("modpack", "minecraftinstance", "manifest"); public static boolean isValidInstanceId(String id) { + if (!GameInstanceID.isValid(id)) + return false; + if (FORBIDDEN_INSTANCE_IDS.contains(id)) return false; @@ -881,8 +515,8 @@ public boolean instanceIdConflicts(String instanceId) { public boolean instanceIdConflicts(GameInstanceID id) { if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { // on Windows, filenames are case-insensitive - for (GameInstanceManifest manifest : getInstanceManifests()) { - if (manifest.id().toString().equalsIgnoreCase(id.toString())) { + for (HMCLGameInstance instance : getSnapshot().getInstances()) { + if (instance.getId().toString().equalsIgnoreCase(id.toString())) { return true; } } @@ -908,35 +542,4 @@ public static long getAutoAllocatedMemory(long available) { 16L * 1024 * 1024 * 1024); return suggested; } - - public static ProxyOption getProxyOption() { - return switch (settings().proxyTypeProperty().get()) { - case SYSTEM -> ProxyOption.Default.INSTANCE; - case DIRECT -> ProxyOption.Direct.INSTANCE; - case HTTP, SOCKS -> { - String proxyHost = settings().proxyHostProperty().get(); - int proxyPort = settings().proxyPortProperty().get(); - - if (StringUtils.isBlank(proxyHost) || proxyPort < 0 || proxyPort > 0xFFFF) { - yield ProxyOption.Default.INSTANCE; - } - - String proxyUser = settings().proxyUserProperty().get(); - String proxyPass = settings().proxyPasswordProperty().get(); - - if (StringUtils.isBlank(proxyUser)) { - proxyUser = null; - proxyPass = null; - } else if (proxyPass == null) { - proxyPass = ""; - } - - if (settings().proxyTypeProperty().get() == ProxyType.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/HMCLGameRepositoryLayout.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepositoryLayout.java new file mode 100644 index 00000000000..0bd88cb70b6 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepositoryLayout.java @@ -0,0 +1,63 @@ +/* + * 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.game; + +import org.jetbrains.annotations.NotNullByDefault; + +import java.nio.file.Path; + +@NotNullByDefault +public final class HMCLGameRepositoryLayout extends DefaultGameRepositoryLayout { + /// Directory under the instance root that stores HMCL-managed instance metadata. + private static final String INSTANCE_METADATA_DIRECTORY = ".hmcl"; + + /// Directory under the instance metadata directory that stores instance configuration files. + private static final String INSTANCE_CONFIG_DIRECTORY = "config"; + + /// Directory under the instance metadata directory that stores instance state files. + private static final String INSTANCE_STATE_DIRECTORY = "state"; + + /// Current file name for instance-specific game settings. + private static final String INSTANCE_GAME_SETTINGS_FILENAME = "instance-game-settings.json"; + + public HMCLGameRepositoryLayout(Path baseDirectory) { + super(baseDirectory); + } + + /// Returns the HMCL-managed metadata directory under the instance root. + /// + /// This directory stores instance-scoped files owned by HMCL. + public Path getInstanceMetadataDirectory(GameInstanceID instanceId) { + return getInstanceRoot(instanceId).resolve(INSTANCE_METADATA_DIRECTORY); + } + + /// Returns the HMCL-managed configuration directory under the instance metadata directory. + public Path getInstanceConfigDirectory(GameInstanceID instanceId) { + return getInstanceMetadataDirectory(instanceId).resolve(INSTANCE_CONFIG_DIRECTORY); + } + + /// Returns the HMCL-managed state directory under the instance metadata directory. + public Path getInstanceStateDirectory(GameInstanceID instanceId) { + return getInstanceMetadataDirectory(instanceId).resolve(INSTANCE_STATE_DIRECTORY); + } + + /// Returns the current local game settings path under the instance configuration directory. + public Path getInstanceGameSettingsFile(GameInstanceID instanceId) { + return getInstanceConfigDirectory(instanceId).resolve(INSTANCE_GAME_SETTINGS_FILENAME); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepositorySnapshot.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepositorySnapshot.java new file mode 100644 index 00000000000..1278547ade9 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepositorySnapshot.java @@ -0,0 +1,60 @@ +/* + * 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.game; + +import org.jetbrains.annotations.NotNullByDefault; + +import java.util.Collection; + +/// HMCL repository snapshot, parallel to [HMCLGameInstance] in the instance hierarchy. +@NotNullByDefault +public class HMCLGameRepositorySnapshot extends DefaultGameRepositorySnapshot { + /// Creates an empty unsealed HMCL snapshot. + /// + /// @param repository the owning repository + /// @param layout the HMCL layout for this snapshot + public HMCLGameRepositorySnapshot(HMCLGameRepository repository, HMCLGameRepositoryLayout layout) { + super(repository, layout); + } + + @Override + public HMCLGameRepository getRepository() { + return (HMCLGameRepository) super.getRepository(); + } + + @Override + public HMCLGameRepositoryLayout getLayout() { + return (HMCLGameRepositoryLayout) super.getLayout(); + } + + @Override + protected HMCLGameRepositorySnapshot newEmpty() { + return new HMCLGameRepositorySnapshot(getRepository(), getLayout()); + } + + @Override + public HMCLGameRepositorySnapshot clone() { + return (HMCLGameRepositorySnapshot) super.clone(); + } + + @SuppressWarnings("unchecked") + @Override + public Collection getInstances() { + return (Collection) super.getInstances(); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java index a8f54902790..a7a3a03a5fe 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java @@ -19,7 +19,6 @@ import com.google.gson.JsonParseException; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.modpack.MinecraftInstanceTask; import org.jackhuang.hmcl.modpack.Modpack; import org.jackhuang.hmcl.modpack.ModpackConfiguration; @@ -51,8 +50,8 @@ public HMCLModpackInstallTask(HMCLGameRepository repository, Path zipFile, Modpa this.instanceId = instanceId; this.modpack = modpack; - Path run = repository.getRunDirectory(this.instanceId); - Path json = repository.getModpackConfiguration(this.instanceId); + Path run = repository.getLayout().getInstanceRoot(this.instanceId); + Path json = repository.getLayout().getModpackConfigurationFile(this.instanceId); if (repository.hasInstance(this.instanceId) && Files.notExists(json)) throw new IllegalArgumentException("Instance " + instanceId + " already exists"); @@ -73,7 +72,7 @@ public HMCLModpackInstallTask(HMCLGameRepository repository, Path zipFile, Modpa } catch (JsonParseException | IOException ignore) { } dependents.add(new ModpackInstallTask<>(zipFile, run, modpack.getEncoding(), Collections.singletonList("/minecraft"), it -> !"pack.json".equals(it), config)); - dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), Collections.singletonList("/minecraft"), modpack, HMCLModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getModpackConfiguration(this.instanceId)).withStage("hmcl.modpack")); + dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), Collections.singletonList("/minecraft"), modpack, HMCLModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getLayout().getModpackConfigurationFile(this.instanceId)).withStage("hmcl.modpack")); } @Override @@ -90,14 +89,14 @@ public List> getDependents() { public void execute() throws Exception { String json = CompressingUtils.readTextZipEntry(zipFile, "minecraft/pack.json"); GameInstanceManifest originalManifest = JsonUtils.GSON.fromJson(json, GameInstanceManifest.class).withId(instanceId).withJar(null); - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(originalManifest, null); + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(originalManifest, null); Task libraryTask = Task.supplyAsync(() -> originalManifest); // reinstall libraries // libraries of Forge and OptiFine should be obtained by installation. - for (LibraryAnalyzer.LibraryMark mark : analyzer) { - if (LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId().equals(mark.getLibraryId())) + for (GameComponentAnalyzer.Mark mark : analyzer) { + if (mark.componentType() == GameComponentType.GAME) continue; - libraryTask = libraryTask.thenComposeAsync(version -> dependency.installLibraryAsync(modpack.getGameVersion(), version, mark.getLibraryId(), mark.getLibraryVersion())); + libraryTask = libraryTask.thenComposeAsync(version -> dependency.installLibraryAsync(modpack.getGameVersion(), version, mark.componentType().getPatchId(), mark.version())); } dependencies.add(libraryTask.thenComposeAsync(repository::saveAsync)); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackProvider.java index b9db4d364ce..00a1233ba59 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackProvider.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackProvider.java @@ -28,6 +28,7 @@ import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.CompressingUtils; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.charset.Charset; @@ -42,12 +43,12 @@ public String getName() { } @Override - public Task createCompletionTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId) { + public @Nullable Task createCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) { return null; } @Override - public Task createUpdateTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException { + public Task createUpdateTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException { if (!(modpack.getManifest() instanceof HMCLModpackManifest)) throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName()); @@ -55,7 +56,7 @@ public Task createUpdateTask(DefaultDependencyManager dependencyManager, Game throw new IllegalArgumentException("HMCLModpackProvider requires HMCLGameRepository"); } - return new ModpackUpdateTask(dependencyManager.getGameRepository(), instanceId, new HMCLModpackInstallTask(repository, zipFile, modpack, instanceId)); + return new ModpackUpdateTask(instance, new HMCLModpackInstallTask(repository, zipFile, modpack, instance.getId())); } @Override 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 ea79242cf17..4da6de1553b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -25,8 +25,7 @@ import org.jackhuang.hmcl.auth.offline.OfflineAccount; import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.download.LibraryAnalyzer; -import org.jackhuang.hmcl.download.MaintainTask; +import org.jackhuang.hmcl.download.LaunchManifestPreparation; import org.jackhuang.hmcl.download.game.*; import org.jackhuang.hmcl.java.JavaManager; import org.jackhuang.hmcl.java.JavaRuntime; @@ -84,9 +83,8 @@ public final class LauncherHelper { private static final String LWJGL_3_4_1_TIP = "lwjgl3.4.1-ffm"; - private final HMCLGameRepository repository; + private final HMCLGameInstance gameInstance; private Account account; - private final GameInstanceID selectedInstanceId; private Path scriptFile; private final GameSettings.Effective setting; private LauncherVisibility launcherVisibility; @@ -94,16 +92,23 @@ public final class LauncherHelper { private QuickPlayOption quickPlayOption; private boolean disableOfflineSkin = false; - public LauncherHelper(HMCLGameRepository repository, Account account, GameInstanceID selectedInstanceId) { - this.repository = Objects.requireNonNull(repository); + public LauncherHelper(HMCLGameInstance gameInstance, Account account) { + this.gameInstance = Objects.requireNonNull(gameInstance); this.account = Objects.requireNonNull(account); - this.selectedInstanceId = selectedInstanceId; - this.setting = repository.getEffectiveGameSettings(selectedInstanceId); + this.setting = gameInstance.getEffectiveSettings(); this.launcherVisibility = setting.getInheritable(GameSettings::launcherVisibilityProperty); this.showLogs = setting.getInheritable(GameSettings::showLogsProperty); this.launchingStepsPane.setTitle(i18n("instance.launch")); } + public HMCLGameInstance getGameInstance() { + return gameInstance; + } + + private HMCLGameRepository repository() { + return gameInstance.getRepository(); + } + private final TaskExecutorDialogPane launchingStepsPane = new TaskExecutorDialogPane(TaskCancellationAction.NORMAL); public Account getAccount() { @@ -134,7 +139,7 @@ public void setDisableOfflineSkin() { public void launch() { FXUtils.checkFxUserThread(); - LOG.info("Launching game version: " + selectedInstanceId); + LOG.info("Launching game instance: " + gameInstance.getId()); Controllers.dialog(launchingStepsPane); launch0(); @@ -145,48 +150,59 @@ public void makeLaunchScript(Path scriptFile) { launch(); } + /// Builds and executes the launch pipeline for the captured game instance. private void launch0() { // https://github.com/HMCL-dev/HMCL/pull/4121 PROCESSES.removeIf(it -> it.get() == null); + HMCLGameRepository repository = repository(); DefaultDependencyManager dependencyManager = repository.getDependency(); - AtomicReference version = new AtomicReference<>(MaintainTask.maintain(repository, repository.getResolvedInstanceManifest(selectedInstanceId).launchManifest())); - Optional gameVersion = repository.getGameVersion(version.get()); - boolean integrityCheck = repository.unmarkInstanceLaunchedAbnormally(selectedInstanceId); + AtomicReference version = new AtomicReference<>( + LaunchManifestPreparation.prepare( + repository, gameInstance.getResolvedManifest().launchManifest())); + GameVersionNumber gameVersion = gameInstance.getVersion(); + boolean integrityCheck = gameInstance.unmarkLaunchedAbnormally(); CountDownLatch launchingLatch = new CountDownLatch(1); List javaAgents = new ArrayList<>(0); List javaArguments = new ArrayList<>(0); AtomicReference javaVersionRef = new AtomicReference<>(); - TaskExecutor executor = checkGameState(repository, setting, version.get()) + TaskExecutor executor = checkGameState(gameInstance, setting, version.get()) .thenComposeAsync(java -> { javaVersionRef.set(Objects.requireNonNull(java)); - version.set(NativePatcher.patchNative(repository, version.get(), gameVersion.orElse(null), java, setting, javaArguments)); + version.set(NativePatcher.patchNative(gameInstance, version.get(), gameVersion, java, setting, javaArguments)); if (setting.getInheritable(GameSettings::notCheckGameProperty)) return null; return Task.allOf( - dependencyManager.checkGameCompletionAsync(version.get(), integrityCheck), + dependencyManager.checkGameCompletionAsync(gameInstance, version.get(), integrityCheck), Task.composeAsync(() -> { try { - ModpackConfiguration configuration = ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(selectedInstanceId)); - ModpackProvider provider = ModpackHelper.getProviderByType(configuration.getType()); + @Nullable ModpackConfiguration configuration = + gameInstance.readModpackConfiguration(); + if (configuration == null) return null; + @Nullable ModpackProvider provider = + ModpackHelper.getProviderByType(configuration.getType()); if (provider == null) return null; - else return provider.createCompletionTask(dependencyManager, selectedInstanceId); + else return provider.createCompletionTask( + dependencyManager, + gameInstance); } catch (IOException e) { return null; } }), Task.composeAsync(() -> { if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS - || !(setting.getRenderer(GameVersionNumber.asGameVersion(gameVersion)) instanceof Renderer.Driver renderer) + || !(setting.getRenderer(gameVersion) instanceof Renderer.Driver renderer) || renderer.mesaDriverName() == null) return null; Library lib = NativePatcher.getWindowsMesaLoader(java, renderer, OperatingSystem.SYSTEM_VERSION); if (lib == null) return null; - Path file = dependencyManager.getGameRepository().getLibraryFile(version.get(), lib); + GameRepository gameRepository = dependencyManager.getGameRepository(); + GameInstanceManifest manifest = version.get(); + Path file = gameRepository.getLayout().getLibraryFile(manifest.id(), lib); if (file.toAbsolutePath().toString().indexOf('=') >= 0) { LOG.warning("Invalid character '=' in the libraries directory path, unable to attach software renderer loader"); return null; @@ -205,10 +221,7 @@ private void launch0() { ); }).withStage("launch.state.dependencies") .thenComposeAsync(() -> { - if (gameVersion.isEmpty()) { - return null; - } - return new GameVerificationFixTask(dependencyManager, gameVersion.get(), version.get()); + return new GameVerificationFixTask(gameInstance, gameVersion, version.get()); }) .thenComposeAsync(() -> { if (setting.getInheritable(GameSettings::allowAutoAgentProperty) @@ -234,8 +247,8 @@ private void launch0() { }) .thenComposeAsync(() -> logIn(account).withStage("launch.state.logging_in")) .thenComposeAsync(authInfo -> Task.supplyAsync(() -> { - LaunchOptions.Builder launchOptionsBuilder = repository.getLaunchOptions( - selectedInstanceId, javaVersionRef.get(), repository.getBaseDirectory(), javaAgents, javaArguments, scriptFile != null); + LaunchOptions.Builder launchOptionsBuilder = gameInstance.getLaunchOptions( + javaVersionRef.get(), repository.getBaseDirectory(), javaAgents, javaArguments, scriptFile != null); if (disableOfflineSkin) { launchOptionsBuilder.setDaemon(false); } @@ -275,16 +288,16 @@ private void launch0() { LaunchOptions launchOptions = launchOptionsBuilder.create(); - LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(repository.getModsDirectory(selectedInstanceId), 10)); + LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(gameInstance.getModsDirectory(), 10)); return new HMCLGameLauncher( - repository, + gameInstance, version.get(), authInfo, launchOptions, launcherVisibility == LauncherVisibility.CLOSE ? null // Unnecessary to start listening to game process output when close launcher immediately after game launched. - : new HMCLProcessListener(repository, version.get(), authInfo, launchOptions, launchingLatch, gameVersion.isPresent()) + : new HMCLProcessListener(repository, version.get(), authInfo, launchOptions, launchingLatch, gameVersion.compareTo(GameVersionNumber.unknown()) != 0) ); }).thenComposeAsync(launcher -> { // launcher is prev task's result if (scriptFile == null) { @@ -423,9 +436,9 @@ public void onStop(boolean success, TaskExecutor executor) { executor.start(); } - private static Task checkGameState(HMCLGameRepository repository, GameSettings.Effective setting, GameInstanceManifest manifest) { - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(manifest, repository.getGameVersion(manifest).orElse(null)); - GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(analyzer.getVersion(LibraryAnalyzer.LibraryType.MINECRAFT)); + private static Task checkGameState(HMCLGameInstance gameInstance, GameSettings.Effective setting, GameInstanceManifest manifest) { + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(manifest, gameInstance.getVersion().toString()); + GameVersionNumber gameVersion = gameInstance.getVersion(); Task getJavaTask = Task.supplyAsync(() -> { try { @@ -456,9 +469,9 @@ private static Task checkGameState(HMCLGameRepository repository, G int targetJavaVersionMajor = Integer.parseInt(setting.getInheritable(GameSettings::customJavaVersionProperty)); GameJavaVersion minimumJavaVersion = null; if (gameVersion.compareTo("1.12.2") == 0) { - Optional cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM); - if (cleanroomVersion.isPresent()) { - minimumJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion.get()); + @Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM); + if (cleanroomVersion != null) { + minimumJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion); } } @@ -480,9 +493,9 @@ private static Task checkGameState(HMCLGameRepository repository, G } } else { if (gameVersion.compareTo("1.12.2") == 0) { - Optional cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM); - if (cleanroomVersion.isPresent()) { - targetJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion.get()); + @Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM); + if (cleanroomVersion != null) { + targetJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion); } } @@ -491,7 +504,7 @@ private static Task checkGameState(HMCLGameRepository repository, G } if (targetJavaVersion != null && supportedVersions.contains(targetJavaVersion)) { - downloadJava(targetJavaVersion, repository) + downloadJava(targetJavaVersion, gameInstance.getRepository()) .whenCompleteAsync((downloadedJava, exception) -> { if (exception == null) { future.complete(downloadedJava); @@ -554,10 +567,9 @@ private static Task checkGameState(HMCLGameRepository repository, G } else { GameJavaVersion gameJavaVersion; if (violatedMandatoryConstraints.contains(JavaVersionConstraint.CLEANROOM)) { - String cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM) - .orElse(""); + @Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM); - gameJavaVersion = !cleanroomVersion.isEmpty() + gameJavaVersion = cleanroomVersion != null ? GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion) : GameJavaVersion.JAVA_21; } else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.GAME_JSON)) @@ -568,7 +580,7 @@ else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA)) gameJavaVersion = null; if (gameJavaVersion != null) { - FXUtils.runInFX(() -> downloadJava(gameJavaVersion, repository).whenCompleteAsync((downloadedJava, throwable) -> { + FXUtils.runInFX(() -> downloadJava(gameJavaVersion, gameInstance.getRepository()).whenCompleteAsync((downloadedJava, throwable) -> { if (throwable == null) { setting.setJavaAutoSelected(); future.complete(downloadedJava); @@ -638,7 +650,7 @@ else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA)) break; case MODDED_JAVA_16: // Minecraft<=1.17.1+Forge[37.0.0,37.0.60) not compatible with Java 17 - String forgePatchVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.FORGE).orElse(null); + @Nullable String forgePatchVersion = analyzer.getVersion(GameComponentType.FORGE); if (forgePatchVersion != null && VersionNumber.compare(forgePatchVersion, "37.0.60") < 0) suggestions.add(i18n("launch.advice.forge37_0_60")); else @@ -651,8 +663,8 @@ else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA)) suggestions.add(i18n("launch.advice.modded_java", 21, gameVersion)); break; case CLEANROOM: { - String cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM).orElse(""); - if (!cleanroomVersion.isEmpty()) + @Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM); + if (cleanroomVersion != null) suggestions.add(i18n("launch.advice.cleanroom", GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion).majorVersion(), cleanroomVersion)); else suggestions.add(i18n("launch.advice.cleanroom", 21, "")); @@ -681,7 +693,7 @@ else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA)) suggestions.add(i18n("launch.advice.not_enough_space", totalMemorySizeMB)); } - VersionNumber forgeVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.FORGE) + VersionNumber forgeVersion = Optional.ofNullable(analyzer.getVersion(GameComponentType.FORGE)) .map(VersionNumber::asVersion) .orElse(null); @@ -1037,8 +1049,8 @@ public void onExit(int exitCode, ExitType exitType) { } if (exitType != ExitType.NORMAL) { - repository.markInstanceLaunchedAbnormally(manifest.id()); - runLater(() -> new GameCrashWindow(process, exitType, repository, manifest, launchOptions, logs).show()); + gameInstance.markLaunchedAbnormally(); + runLater(() -> new GameCrashWindow(process, exitType, gameInstance, launchOptions, logs).show()); } checkExit(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java index 90643edea93..ed6258d17fe 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java @@ -43,7 +43,8 @@ private LogExporter() { public static CompletableFuture exportLogs( Path zipFile, DefaultGameRepository repository, GameInstanceID instanceId, String logs, String launchScript, PathMatcher logMatcher) { - Path runDirectory = repository.getRunDirectory(instanceId); + DefaultGameInstance instance = repository.getSnapshot().findInstance(instanceId); + Path runDirectory = instance != null ? instance.getRunDirectory() : repository.getBaseDirectory(); Path baseDirectory = repository.getBaseDirectory(); List instances = new ArrayList<>(); 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 03dc54ad1b2..5171b312588 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/ModpackHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/ModpackHelper.java @@ -157,15 +157,11 @@ public static ModpackConfiguration readModpackConfiguration(Path file) throws } public static Task getInstallTask(HMCLGameRepository repository, ServerModpackManifest manifest, GameInstanceID instanceId, Modpack modpack) { - repository.markInstanceAsModpack(instanceId); + repository.ensureIsolatedRunningDirectory(instanceId); ExceptionalRunnable success = () -> { repository.refresh(); - GameSettings.Instance setting = repository.getInstanceGameSettingsOrCreate(instanceId); - repository.undoMark(instanceId); - if (setting != null) { - setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY); - } + repository.ensureIsolatedRunningDirectory(instanceId); }; ExceptionalConsumer failure = ex -> { @@ -200,16 +196,12 @@ public static Task getInstallManuallyCreatedModpackTask(Path zipFile, String }); } - public static Task getInstallTask(HMCLGameRepository repository, Path zipFile, GameInstanceID instanceId, Modpack modpack, String iconUrl) { - repository.markInstanceAsModpack(instanceId); + public static Task getInstallTask(HMCLGameRepository repository, Path zipFile, GameInstanceID instanceId, Modpack modpack, @Nullable String iconUrl) { + repository.ensureIsolatedRunningDirectory(instanceId); ExceptionalRunnable success = () -> { repository.refresh(); - GameSettings.Instance setting = repository.getInstanceGameSettingsOrCreate(instanceId); - repository.undoMark(instanceId); - if (setting != null) { - setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY); - } + repository.ensureIsolatedRunningDirectory(instanceId); }; ExceptionalConsumer failure = ex -> { @@ -238,7 +230,9 @@ else if (modpack.getManifest() instanceof McbbsModpackManifest) public static Task getUpdateTask(HMCLGameRepository repository, ServerModpackManifest manifest, Charset charset, GameInstanceID instanceId, ModpackConfiguration configuration) throws UnsupportedModpackException { switch (configuration.getType()) { case ServerModpackRemoteInstallTask.MODPACK_TYPE: - return new ModpackUpdateTask(repository, instanceId, new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, instanceId)) + return new ModpackUpdateTask( + repository.getInstance(instanceId), + new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, instanceId)) .thenComposeAsync(repository.refreshAsync()) .withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries"))); default: @@ -253,11 +247,11 @@ public static Task getUpdateTask(HMCLGameRepository repository, Path zipFile, throw new UnsupportedModpackException(); } if (modpack.getManifest() instanceof MultiMCInstanceConfiguration) - return provider.createUpdateTask(repository.getDependency(), instanceId, zipFile, modpack) + return provider.createUpdateTask(repository.getDependency(), repository.getInstance(instanceId), zipFile, modpack) .thenComposeAsync(() -> createMultiMCPostUpdateTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), instanceId)) .thenComposeAsync(repository.refreshAsync()); else - return provider.createUpdateTask(repository.getDependency(), instanceId, zipFile, modpack) + return provider.createUpdateTask(repository.getDependency(), repository.getInstance(instanceId), zipFile, modpack) .thenComposeAsync(repository.refreshAsync()); } 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 96b33964e9b..45f1fb975c7 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/java/JavaManager.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/java/JavaManager.java @@ -26,7 +26,7 @@ import javafx.beans.property.SimpleObjectProperty; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.download.LibraryAnalyzer; +import org.jackhuang.hmcl.game.GameComponentAnalyzer; import org.jackhuang.hmcl.game.GameJavaVersion; import org.jackhuang.hmcl.game.JavaVersionConstraint; import org.jackhuang.hmcl.game.GameInstanceManifest; @@ -321,7 +321,7 @@ public static JavaRuntime findSuitableJava(GameVersionNumber gameVersion, GameIn @Nullable public static JavaRuntime findSuitableJava(Collection javaRuntimes, GameVersionNumber gameVersion, GameInstanceManifest manifest) { - LibraryAnalyzer analyzer = manifest != null ? LibraryAnalyzer.analyze(manifest, gameVersion != null ? gameVersion.toString() : null) : null; + GameComponentAnalyzer analyzer = manifest != null ? GameComponentAnalyzer.analyze(manifest, gameVersion != null ? gameVersion.toString() : null) : null; boolean forceX86 = Architecture.SYSTEM_ARCH == Architecture.ARM64 && (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS || OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java index 0a40fb98728..1c3e8c98969 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java @@ -22,10 +22,9 @@ import javafx.collections.FXCollections; import javafx.collections.ObservableList; import org.jackhuang.hmcl.Metadata; -import org.jackhuang.hmcl.event.EventBus; -import org.jackhuang.hmcl.event.RefreshedGameInstancesEvent; -import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.game.HMCLGameRepositorySnapshot; import org.jackhuang.hmcl.util.PortablePath; import org.jackhuang.hmcl.util.i18n.I18n; import org.jackhuang.hmcl.util.i18n.LocalizedText; @@ -44,7 +43,6 @@ import java.util.function.Consumer; import static org.jackhuang.hmcl.setting.SettingsManager.*; -import static org.jackhuang.hmcl.ui.FXUtils.runInFX; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; /// Manages the merged runtime view of local and user game directories. @@ -141,13 +139,18 @@ private static boolean isGameDirectoryPath(GameDirectory gameDirectory, Portable /// The selected game repository, or `null` before the fallback game directory is resolved. private static final ObjectProperty<@UnknownNullability HMCLGameRepository> selectedRepository = new SimpleObjectProperty<>(GameDirectoryManager.class, "selectedRepository"); - /// The selected instance ID projected from the selected repository. - private static final ReadOnlyObjectWrapper selectedInstance = new ReadOnlyObjectWrapper<>(GameDirectoryManager.class, "selectedInstance"); + /// The selected instance projected from the selected repository's current snapshot. + private static final ReadOnlyObjectWrapper<@Nullable HMCLGameInstance> selectedInstance = + new ReadOnlyObjectWrapper<>(GameDirectoryManager.class, "selectedInstance"); /// Updates [#selectedInstance] when the selected repository changes its selected instance. - private static final ChangeListener selectedRepositoryInstanceListener = + private static final ChangeListener<@Nullable HMCLGameInstance> selectedRepositoryInstanceListener = (observable, oldValue, newValue) -> selectedInstance.set(newValue); + /// Reacts when the selected repository publishes a new snapshot. + private static final ChangeListener selectedRepositorySnapshotListener = + (observable, oldValue, newValue) -> onSelectedRepositorySnapshotChanged(); + /// Initializes game directory state from the stores loaded by [SettingsManager]. /// /// This method creates the built-in local and user-home game directories when required, rebuilds @@ -202,25 +205,32 @@ public static void init() { @Nullable HMCLGameRepository oldRepository = selectedRepository.get(); if (oldRepository != null) { oldRepository.selectedInstanceProperty().removeListener(selectedRepositoryInstanceListener); + oldRepository.snapshotProperty().removeListener(selectedRepositorySnapshotListener); } HMCLGameRepository repository = getOrCreateRepository(newValue); selectedRepository.set(repository); selectedInstance.set(repository.getSelectedInstance()); repository.selectedInstanceProperty().addListener(selectedRepositoryInstanceListener); + repository.snapshotProperty().addListener(selectedRepositorySnapshotListener); + if (repository.isLoaded()) { + onSelectedRepositorySnapshotChanged(); + } repository.refreshAsync().start(); }); selectedGameDirectory.set(currentGameDirectory != null ? currentGameDirectory : mergedGameDirectories.get(0)); + } - EventBus.EVENT_BUS.channel(RefreshedGameInstancesEvent.class).registerWeak(event -> { - runInFX(() -> { - @Nullable HMCLGameRepository repository = selectedRepository.get(); - if (repository != null && repository == event.getSource()) { - repository.refreshSelectedInstance(); - for (Consumer listener : versionsListeners) - listener.accept(repository); - } - }); - }); + /// Restores selection and notifies consumers after the selected repository publishes a loaded snapshot. + private static void onSelectedRepositorySnapshotChanged() { + @Nullable HMCLGameRepository repository = selectedRepository.get(); + if (repository == null || !repository.isLoaded()) { + return; + } + + repository.refreshSelectedInstance(); + for (Consumer listener : versionsListeners) { + listener.accept(repository); + } } /// Creates the built-in game directories only when no game directory exists. @@ -480,17 +490,26 @@ public static ObjectProperty selectedRepositoryProperty() { } /// Returns the selected instance property projected from the selected repository. - public static ReadOnlyObjectProperty<@Nullable GameInstanceID> selectedInstanceProperty() { + /// + /// The value is `null` when the selected repository has no registered selected instance. + /// + /// @return the read-only selected-instance property + public static ReadOnlyObjectProperty<@Nullable HMCLGameInstance> selectedInstanceProperty() { return selectedInstance.getReadOnlyProperty(); } - /// Returns the selected instance ID for the selected repository. - public static @Nullable GameInstanceID getSelectedInstance() { + /// Returns the selected instance from the selected repository's current snapshot. + /// + /// @return the selected instance, or `null` when none is registered + public static @Nullable HMCLGameInstance getSelectedInstance() { return getSelectedRepository().getSelectedInstance(); } - /// Sets the selected instance ID for the selected repository. - public static void setSelectedInstance(@Nullable GameInstanceID instance) { + /// Sets the selected instance for the selected repository. + /// + /// @param instance the instance to select, or `null` to clear the selection + /// @throws IllegalArgumentException if `instance` belongs to another repository + public static void setSelectedInstance(@Nullable HMCLGameInstance instance) { getSelectedRepository().setSelectedInstance(instance); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameInstanceIconType.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameInstanceIconType.java index 369bd815ec1..ad7149fdbee 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameInstanceIconType.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameInstanceIconType.java @@ -19,7 +19,9 @@ import javafx.scene.image.Image; import org.jackhuang.hmcl.addon.mod.ModLoaderType; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.ui.FXUtils; +import org.jetbrains.annotations.Nullable; public enum GameInstanceIconType { DEFAULT("/assets/img/grass.png"), @@ -54,6 +56,22 @@ public static GameInstanceIconType getIconType(ModLoaderType modLoaderType) { }; } + public static @Nullable GameInstanceIconType getIconType(GameComponentType componentType) { + return switch (componentType) { + case GAME -> GameInstanceIconType.GRASS; + case FABRIC, FABRIC_API -> GameInstanceIconType.FABRIC; + case LEGACY_FABRIC, LEGACY_FABRIC_API -> GameInstanceIconType.LEGACY_FABRIC; + case FORGE -> GameInstanceIconType.FORGE; + case CLEANROOM -> GameInstanceIconType.CLEANROOM; + case LITELOADER -> GameInstanceIconType.CHICKEN; + case OPTIFINE -> GameInstanceIconType.OPTIFINE; + case QUILT, QUILT_API -> GameInstanceIconType.QUILT; + case NEO_FORGE -> GameInstanceIconType.NEO_FORGE; + default -> null; + }; + } + + private final String resourceUrl; GameInstanceIconType(String resourceUrl) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/LegacyGameSettingsMigrator.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/LegacyGameSettingsMigrator.java index 3b34a007645..e72526cf004 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/LegacyGameSettingsMigrator.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/LegacyGameSettingsMigrator.java @@ -132,12 +132,12 @@ public static GameSettings.Preset toPreset(GameSettingsPresetID id, int autoName HMCLGameRepository repository, GameInstanceID instanceId, @Nullable GameSettingsPresetID parent) { - Path instanceRoot = repository.getInstanceRoot(instanceId); + Path instanceRoot = repository.getLayout().getInstanceRoot(instanceId); Path file = instanceRoot.resolve(LEGACY_INSTANCE_SETTINGS_FILENAME); if (!Files.exists(file)) { return null; } - Path receiptLocation = repository.getInstanceStateDirectory(instanceId) + Path receiptLocation = repository.getLayout().getInstanceStateDirectory(instanceId) .resolve(LEGACY_INSTANCE_SETTINGS_MIGRATION_RECEIPT_FILENAME); if (MigrationReceipt.matches(receiptLocation, file)) { LOG.info("Skipping already migrated legacy version setting " + file); 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 94db18eae42..cf2a49ab957 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java @@ -33,10 +33,15 @@ import javafx.util.Duration; import org.jackhuang.hmcl.Launcher; import org.jackhuang.hmcl.Metadata; +import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.game.LauncherHelper; +import org.jackhuang.hmcl.game.ModpackHelper; import org.jackhuang.hmcl.java.JavaManager; import org.jackhuang.hmcl.java.JavaRuntime; +import org.jackhuang.hmcl.modpack.Modpack; import org.jackhuang.hmcl.setting.*; +import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.task.TaskExecutor; import org.jackhuang.hmcl.ui.account.AccountListPage; @@ -56,6 +61,7 @@ import org.jackhuang.hmcl.util.*; import org.jackhuang.hmcl.util.i18n.I18n; import org.jackhuang.hmcl.util.i18n.SupportedLocale; +import org.jackhuang.hmcl.util.io.CompressingUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.platform.Architecture; import org.jackhuang.hmcl.util.platform.OperatingSystem; @@ -63,6 +69,8 @@ import org.jetbrains.annotations.Nullable; import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.file.Files; import java.nio.file.Path; import java.time.LocalDate; import java.util.List; @@ -370,6 +378,42 @@ public static void initialize(Stage stage) { }, updateShowTips); }, updateShowTips); } + + tryInstallBundledModpack(GameDirectoryManager.getSelectedRepository()); + } + + /// Offers automatic install when a package exists under `.hmcl/modpack/`. + /// + /// Called from [Controllers#initialize] after the UI is ready. Install does not wait for repository + /// refresh: instance paths come from the selected repository layout. The package file itself is the + /// install signal; it is deleted after a successful install so later startups do not re-prompt. + private static void tryInstallBundledModpack(HMCLGameRepository repository) { + @Nullable Path modpackFile = Metadata.findBundledModpackFile(); + if (modpackFile == null) { + return; + } + + LOG.info("Found bundled modpack at " + modpackFile + "; starting automatic install"); + + Controllers.taskDialog( + Task.composeAsync(Schedulers.io(), () -> { + Charset encoding = CompressingUtils.findSuitableEncoding(modpackFile); + Modpack modpack = ModpackHelper.readModpackManifest(modpackFile, encoding); + return ModpackHelper.getInstallTask( + repository, modpackFile, new GameInstanceID(modpack.getName()), modpack, null); + }) + .whenComplete(Schedulers.javafx(), (ignored, exception) -> { + if (exception != null) { + LOG.warning("Failed to install bundled modpack", exception); + return; + } + try { + Files.deleteIfExists(modpackFile); + } catch (IOException e) { + LOG.warning("Failed to delete bundled modpack: " + modpackFile, e); + } + }), i18n("modpack.installing"), TaskCancellationAction.NO_CANCEL + ); } public static void dialog(Region content) { @@ -553,7 +597,7 @@ public static void onHyperlinkAction(String href) { break; case "hmcl://game/launch": var repository = GameDirectoryManager.getSelectedRepository(); - Instances.launch(repository, repository.getSelectedInstance(), LauncherHelper::setKeep); + Instances.launch(repository.getSelectedInstance(), LauncherHelper::setKeep); break; } } else { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java index 4d1a23cf116..18488d36eb5 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java @@ -35,7 +35,6 @@ import javafx.stage.Stage; import kala.encdet.EncodingDetector; import org.jackhuang.hmcl.Metadata; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.launch.ProcessListener; import org.jackhuang.hmcl.setting.StyleSheets; @@ -73,17 +72,16 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG; public class GameCrashWindow extends Stage { - private final GameInstanceManifest manifest; + private final HMCLGameInstance gameInstance; private final String memory; private final String total_memory; private final String java; - private final LibraryAnalyzer analyzer; + private final GameComponentAnalyzer analyzer; private final TextFlow reasonTextFlow = new TextFlow(new Text(i18n("game.crash.reason.unknown"))); private final BooleanProperty loading = new SimpleBooleanProperty(); private final TextFlow feedbackTextFlow = new TextFlow(); private final ManagedProcess managedProcess; - private final DefaultGameRepository repository; private final ProcessListener.ExitType exitType; private final LaunchOptions launchOptions; private final View view; @@ -91,16 +89,15 @@ public class GameCrashWindow extends Stage { private final List logs; - public GameCrashWindow(ManagedProcess managedProcess, ProcessListener.ExitType exitType, DefaultGameRepository repository, GameInstanceManifest manifest, LaunchOptions launchOptions, List logs) { + public GameCrashWindow(ManagedProcess managedProcess, ProcessListener.ExitType exitType, HMCLGameInstance gameInstance, LaunchOptions launchOptions, List logs) { Themes.applyNativeDarkMode(this); this.managedProcess = managedProcess; this.exitType = exitType; - this.repository = repository; - this.manifest = manifest; + this.gameInstance = gameInstance; this.launchOptions = launchOptions; this.logs = logs; - this.analyzer = LibraryAnalyzer.analyze(manifest, repository.getGameVersion(manifest).orElse(null)); + this.analyzer = GameComponentAnalyzer.analyze(gameInstance.getResolvedManifest(), gameInstance.getVersion().toString()); memory = Optional.ofNullable(launchOptions.getMaxMemory()).map(i -> i + " " + i18n("settings.memory.unit.mib")).orElse("-"); @@ -142,7 +139,8 @@ private void analyzeCrashReport() { return pair(CrashReportAnalyzer.analyze(rawLog), crashReport != null ? CrashReportAnalyzer.findKeywordsFromCrashReport(crashReport) : new HashSet<>()); }), Task.supplyAsync(() -> { - Path latestLog = repository.getRunDirectory(manifest.id()).resolve("logs/latest.log"); + Path runDirectory = gameInstance.getRunDirectory(); + Path latestLog = runDirectory.resolve("logs/latest.log"); if (!Files.isReadable(latestLog)) { return pair(new HashSet(), new HashSet()); } @@ -291,7 +289,7 @@ private CompletableFuture exportGameCrashInfo() { } }); - return LogExporter.exportLogs(logFile, repository, launchOptions.getInstanceId(), logs, + return LogExporter.exportLogs(logFile, gameInstance.getRepository(), launchOptions.getInstanceId(), logs, new CommandBuilder().addAll(managedProcess.getCommands()).toString(), path -> { try { @@ -342,10 +340,10 @@ private final class View extends VBox { launcher.setTitle(i18n("launcher")); launcher.setSubtitle(Metadata.VERSION); - TwoLineListItem version = new TwoLineListItem(); - version.getStyleClass().setAll("two-line-item-second-large"); - version.setTitle(i18n("game.version")); - version.setSubtitle(GameCrashWindow.this.manifest.id().toString()); + TwoLineListItem instance = new TwoLineListItem(); + instance.getStyleClass().setAll("two-line-item-second-large"); + instance.setTitle(i18n("game.version")); + instance.setSubtitle(GameCrashWindow.this.gameInstance.getId().toString()); TwoLineListItem total_memory = new TwoLineListItem(); total_memory.getStyleClass().setAll("two-line-item-second-large"); @@ -372,7 +370,7 @@ private final class View extends VBox { arch.setTitle(i18n("system.architecture")); arch.setSubtitle(Architecture.SYSTEM_ARCH.getDisplayName()); - infoPane.getChildren().setAll(launcher, version, total_memory, memory, java, os, arch); + infoPane.getChildren().setAll(launcher, instance, total_memory, memory, java, os, arch); } HBox moddedPane = new HBox(8); @@ -380,15 +378,13 @@ private final class View extends VBox { moddedPane.setPadding(new Insets(8)); moddedPane.setAlignment(Pos.CENTER_LEFT); - for (LibraryAnalyzer.LibraryType type : LibraryAnalyzer.LibraryType.values()) { - if (!type.getPatchId().isEmpty()) { - analyzer.getVersion(type).ifPresent(ver -> { - TwoLineListItem item = new TwoLineListItem(); - item.getStyleClass().setAll("two-line-item-second-large"); - item.setTitle(i18n("install.installer." + type.getPatchId())); - item.setSubtitle(ver); - moddedPane.getChildren().add(item); - }); + for (GameComponentAnalyzer.Mark mark : analyzer) { + if (mark.version() != null) { + TwoLineListItem item = new TwoLineListItem(); + item.getStyleClass().setAll("two-line-item-second-large"); + item.setTitle(i18n("install.installer." + mark.componentType().getPatchId())); + item.setSubtitle(mark.version()); + moddedPane.getChildren().add(item); } } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/InstallerItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/InstallerItem.java index a747c1a34c9..cb4e2be088b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/InstallerItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/InstallerItem.java @@ -34,26 +34,22 @@ import javafx.scene.control.SkinBase; import javafx.scene.input.MouseButton; import javafx.scene.layout.*; -import org.jackhuang.hmcl.download.LibraryAnalyzer; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.setting.GameInstanceIconType; import org.jackhuang.hmcl.ui.construct.ImageContainer; import org.jackhuang.hmcl.ui.construct.RipplerContainer; import org.jackhuang.hmcl.util.i18n.I18n; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; +import java.util.*; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.*; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; /** * @author huangyuhui */ public class InstallerItem extends Control { - private final String id; + private final GameComponentType type; private final GameInstanceIconType iconType; private final Style style; private final ObjectProperty versionProperty = new SimpleObjectProperty<>(this, "version", null); @@ -83,30 +79,14 @@ public enum Style { CARD, } - public InstallerItem(LibraryAnalyzer.LibraryType id, Style style) { - this(id.getPatchId(), style); - } - - public InstallerItem(String id, Style style) { - this.id = id; + public InstallerItem(GameComponentType type, Style style) { + this.type = type; this.style = style; - - iconType = switch (id) { - case "game" -> GameInstanceIconType.GRASS; - case "fabric", "fabric-api" -> GameInstanceIconType.FABRIC; - case "legacyfabric", "legacyfabric-api" -> GameInstanceIconType.LEGACY_FABRIC; - case "forge" -> GameInstanceIconType.FORGE; - case "cleanroom" -> GameInstanceIconType.CLEANROOM; - case "liteloader" -> GameInstanceIconType.CHICKEN; - case "optifine" -> GameInstanceIconType.OPTIFINE; - case "quilt", "quilt-api" -> GameInstanceIconType.QUILT; - case "neoforge" -> GameInstanceIconType.NEO_FORGE; - default -> null; - }; + this.iconType = GameInstanceIconType.getIconType(type); } - public String getLibraryId() { - return id; + public GameComponentType getComponentType() { + return type; } public ObjectProperty versionProperty() { @@ -175,19 +155,19 @@ private void mutualIncompatible(Map> incompati } } - public InstallerItemGroup(String gameVersion, Style style) { - game = new InstallerItem(MINECRAFT, style); - InstallerItem fabric = new InstallerItem(FABRIC, style); - InstallerItem fabricApi = new InstallerItem(FABRIC_API, style); - InstallerItem forge = new InstallerItem(FORGE, style); - InstallerItem cleanroom = new InstallerItem(CLEANROOM, style); - InstallerItem legacyfabric = new InstallerItem(LEGACY_FABRIC, style); - InstallerItem legacyfabricApi = new InstallerItem(LEGACY_FABRIC_API, style); - InstallerItem neoForge = new InstallerItem(NEO_FORGE, style); - InstallerItem liteLoader = new InstallerItem(LITELOADER, style); - InstallerItem optiFine = new InstallerItem(OPTIFINE, style); - InstallerItem quilt = new InstallerItem(QUILT, style); - InstallerItem quiltApi = new InstallerItem(QUILT_API, style); + public InstallerItemGroup(GameVersionNumber gameVersion, Style style) { + game = new InstallerItem(GameComponentType.GAME, style); + InstallerItem fabric = new InstallerItem(GameComponentType.FABRIC, style); + InstallerItem fabricApi = new InstallerItem(GameComponentType.FABRIC_API, style); + InstallerItem forge = new InstallerItem(GameComponentType.FORGE, style); + InstallerItem cleanroom = new InstallerItem(GameComponentType.CLEANROOM, style); + InstallerItem legacyfabric = new InstallerItem(GameComponentType.LEGACY_FABRIC, style); + InstallerItem legacyfabricApi = new InstallerItem(GameComponentType.LEGACY_FABRIC_API, style); + InstallerItem neoForge = new InstallerItem(GameComponentType.NEO_FORGE, style); + InstallerItem liteLoader = new InstallerItem(GameComponentType.LITELOADER, style); + InstallerItem optiFine = new InstallerItem(GameComponentType.OPTIFINE, style); + InstallerItem quilt = new InstallerItem(GameComponentType.QUILT, style); + InstallerItem quiltApi = new InstallerItem(GameComponentType.QUILT_API, style); Map> incompatibleMap = new HashMap<>(); mutualIncompatible(incompatibleMap, forge, fabric, quilt, neoForge, cleanroom, legacyfabric); @@ -217,7 +197,7 @@ public InstallerItemGroup(String gameVersion, Style style) { for (InstallerItem other : incompatibleItems) { InstalledState otherVersion = other.versionProperty.get(); if (otherVersion != null) { - return new IncompatibleState(other.id, otherVersion.version); + return new IncompatibleState(other.type.getPatchId(), otherVersion.version); } } @@ -226,7 +206,7 @@ public InstallerItemGroup(String gameVersion, Style style) { } if (gameVersion != null) { - game.versionProperty.set(new InstalledState(gameVersion, false, false)); + game.versionProperty.set(new InstalledState(gameVersion.toString(), false, false)); } InstallerItem[] all = {game, forge, neoForge, liteLoader, optiFine, fabric, fabricApi, quilt, quiltApi, legacyfabric, legacyfabricApi, cleanroom}; @@ -235,19 +215,16 @@ public InstallerItemGroup(String gameVersion, Style style) { if (!item.resolvedStateProperty.isBound()) { item.resolvedStateProperty.bind(Bindings.createObjectBinding(() -> { InstalledState itemVersion = item.versionProperty.get(); - if (itemVersion != null) { - return itemVersion; - } - return InstallableState.INSTANCE; + return Objects.requireNonNullElse(itemVersion, InstallableState.INSTANCE); }, item.versionProperty)); } } if (gameVersion == null) { this.libraries = all; - } else if (gameVersion.equals("1.12.2")) { + } else if (gameVersion.compareTo("1.12.2") == 0) { this.libraries = new InstallerItem[]{game, forge, cleanroom, liteLoader, legacyfabric, legacyfabricApi, optiFine}; - } else if (GameVersionNumber.compare(gameVersion, "1.13.2") <= 0) { + } else if (gameVersion.compareTo("1.13.2") <= 0) { this.libraries = new InstallerItem[]{game, forge, liteLoader, optiFine, legacyfabric, legacyfabricApi}; } else { this.libraries = new InstallerItem[]{game, forge, neoForge, optiFine, fabric, fabricApi, quilt, quiltApi}; @@ -309,7 +286,7 @@ private static final class InstallerItemSkin extends SkinBase { nameLabel.getStyleClass().add("installer-item-name"); nameLabel.setMouseTransparent(true); pane.getChildren().add(nameLabel); - nameLabel.textProperty().set(I18n.hasKey("install.installer." + control.id) ? i18n("install.installer." + control.id) : control.id); + nameLabel.textProperty().set(I18n.hasKey("install.installer." + control.type.getPatchId()) ? i18n("install.installer." + control.type.getPatchId()) : control.type.getPatchId()); HBox.setMargin(nameLabel, new Insets(0, 4, 0, 4)); Label statusLabel = new Label(); @@ -355,7 +332,7 @@ private static final class InstallerItemSkin extends SkinBase { pane.getChildren().add(buttonsContainer); JFXButton removeButton = FXUtils.newToggleButton4(SVG.CLOSE); - if (control.id.equals(MINECRAFT.getPatchId())) { + if (control.type == GameComponentType.GAME) { removeButton.setVisible(false); } else { removeButton.visibleProperty().bind(Bindings.createBooleanBinding(() -> { 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 5ee8e6881cf..8003520fe78 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 @@ -30,7 +30,7 @@ import javafx.scene.layout.Priority; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.download.LibraryAnalyzer; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; @@ -41,6 +41,7 @@ import org.jackhuang.hmcl.ui.wizard.WizardController; import org.jackhuang.hmcl.ui.wizard.WizardPage; import org.jackhuang.hmcl.util.SettingsMap; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import static org.jackhuang.hmcl.setting.SettingsManager.state; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; @@ -58,18 +59,18 @@ public abstract class AbstractInstallersPage extends Control implements WizardPa public AbstractInstallersPage(WizardController controller, String gameVersion, DownloadProvider downloadProvider) { this.controller = controller; - this.group = new InstallerItem.InstallerItemGroup(gameVersion, getInstallerItemStyle()); + this.group = new InstallerItem.InstallerItemGroup(GameVersionNumber.asGameVersion(gameVersion), getInstallerItemStyle()); for (InstallerItem library : group.getLibraries()) { - String libraryId = library.getLibraryId(); - if (libraryId.equals(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId())) continue; + GameComponentType type = library.getComponentType(); + if (type == GameComponentType.GAME) continue; library.setOnInstall(() -> { 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))) { + && (type == GameComponentType.FABRIC_API + || type == GameComponentType.QUILT_API + || type == GameComponentType.LEGACY_FABRIC_API)) { Controllers.dialog(new MessageDialogPane.Builder( - i18n("install.installer.fabric-quilt-api.warning", i18n("install.installer." + libraryId)), + i18n("install.installer.fabric-quilt-api.warning", i18n("install.installer." + type.getPatchId())), i18n("message.warning"), MessageDialogPane.MessageType.WARNING ).ok(null).addCancel(i18n("button.do_not_show_again"), () -> state().getShownTips().put(FABRIC_QUILT_API_TIP, true)).build()); @@ -79,16 +80,16 @@ public AbstractInstallersPage(WizardController controller, String gameVersion, D controller.onNext( new VersionsPage( controller, - i18n("install.installer.choose", i18n("install.installer." + libraryId)), + i18n("install.installer.choose", i18n("install.installer." + type.getPatchId())), gameVersion, downloadProvider, - libraryId, + type.getPatchId(), () -> controller.onPrev(false, Navigation.NavigationDirection.PREVIOUS) ), Navigation.NavigationDirection.NEXT ); }); library.setOnRemove(() -> { - controller.getSettings().remove(libraryId); + controller.getSettings().remove(type.getPatchId()); reload(); }); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AdditionalInstallersPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AdditionalInstallersPage.java index 835b9d54b80..ea6124c7fc7 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AdditionalInstallersPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AdditionalInstallersPage.java @@ -21,8 +21,9 @@ import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.game.GameComponentAnalyzer; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.ui.InstallerItem; @@ -32,7 +33,6 @@ import java.util.Optional; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.MINECRAFT; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; class AdditionalInstallersPage extends AbstractInstallersPage { @@ -51,10 +51,11 @@ public AdditionalInstallersPage(String gameVersion, GameInstanceManifest manifes txtName.setEditable(false); for (InstallerItem library : group.getLibraries()) { - String libraryId = library.getLibraryId(); - if (libraryId.equals("game")) continue; + if (library.getComponentType() == GameComponentType.GAME) continue; library.setOnRemove(() -> { - controller.getSettings().put(libraryId, new UpdateInstallerWizardProvider.RemoveVersionAction(libraryId)); + controller.getSettings().put( + library.getComponentType().getPatchId(), + new UpdateInstallerWizardProvider.RemoveVersionAction(library.getComponentType())); reload(); }); } @@ -81,18 +82,18 @@ private String getVersion(String id) { @Override protected void reload() { GameInstanceManifest.Resolved resolvedManifest = repository.resolve(manifest); - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(resolvedManifest, repository.getGameVersion(manifest).orElse(null)); - String game = analyzer.getVersion(MINECRAFT).orElse(null); + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(resolvedManifest, repository.getGameVersion(manifest).orElse(null)); + String game = analyzer.getVersion(GameComponentType.GAME); String currentGameVersion = Lang.nonNull(getVersion("game"), game); boolean compatible = true; for (InstallerItem library : group.getLibraries()) { - String libraryId = library.getLibraryId(); - String version = analyzer.getVersion(libraryId).orElse(null); + String libraryId = library.getComponentType().getPatchId(); + String version = analyzer.getVersion(library.getComponentType()); String libraryVersion = Lang.requireNonNullElse(getVersion(libraryId), version); boolean alreadyInstalled = version != null && !(controller.getSettings().get(libraryId) instanceof UpdateInstallerWizardProvider.RemoveVersionAction); - if (!"game".equals(libraryId) && currentGameVersion != null && !currentGameVersion.equals(game) && getVersion(libraryId) == null && alreadyInstalled) { + if (library.getComponentType() != GameComponentType.GAME && currentGameVersion != null && !currentGameVersion.equals(game) && getVersion(libraryId) == null && alreadyInstalled) { // For third-party libraries, if game version is being changed, and the library is not being reinstalled, // warns the user that we should update the library. library.versionProperty().set(new InstallerItem.InstalledState(libraryVersion, false, true)); 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 e961c901bfd..35993618f28 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 @@ -25,7 +25,9 @@ import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository; import org.jackhuang.hmcl.download.*; import org.jackhuang.hmcl.download.game.GameRemoteVersion; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.setting.DownloadProviders; import org.jackhuang.hmcl.setting.GameDirectoryManager; @@ -45,7 +47,6 @@ import org.jackhuang.hmcl.ui.decorator.DecoratorPage; import org.jackhuang.hmcl.ui.instances.DownloadListPage; import org.jackhuang.hmcl.ui.instances.HMCLLocalizedDownloadListPage; -import org.jackhuang.hmcl.ui.instances.GameInstancePage; import org.jackhuang.hmcl.ui.instances.Instances; import org.jackhuang.hmcl.ui.wizard.Navigation; import org.jackhuang.hmcl.ui.wizard.WizardController; @@ -135,19 +136,18 @@ public DownloadPage(GameInstanceID uploadInstance) { private static Supplier loadVersionFor(Supplier nodeSupplier) { return () -> { T node = nodeSupplier.get(); - if (node instanceof GameInstancePage.GameInstanceLoadable loadable) { - loadable.loadInstance(GameDirectoryManager.getSelectedRepository(), null); + if (node instanceof DownloadListPage page) { + page.loadInstance(HMCLGameInstance.Optional.empty(GameDirectoryManager.getSelectedRepository())); } return node; }; } public static void download(DownloadProvider downloadProvider, HMCLGameRepository repository, @Nullable GameInstanceID instanceId, RemoteAddon.Version file, String subdirectoryName) { - if (instanceId == null) { - instanceId = repository.getSelectedInstance(); - } - - Path runDirectory = instanceId != null && repository.hasInstance(instanceId) ? repository.getRunDirectory(instanceId) : repository.getBaseDirectory(); + @Nullable HMCLGameInstance instance = instanceId != null + ? repository.findInstance(instanceId) + : repository.getSelectedInstance(); + Path runDirectory = instance != null ? instance.getRunDirectory() : repository.getBaseDirectory(); Set existingFiles; @@ -191,19 +191,19 @@ private void loadVersions(HMCLGameRepository repository) { if (repository.getGameDirectory() == GameDirectoryManager.getSelectedGameDirectory()) { listenerHolder.add(FXUtils.onWeakChangeAndOperate(GameDirectoryManager.selectedInstanceProperty(), version -> { if (modTab.isInitialized()) { - modTab.getNode().loadInstance(repository, null); + modTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository)); } if (modpackTab.isInitialized()) { - modpackTab.getNode().loadInstance(repository, null); + modpackTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository)); } if (resourcePackTab.isInitialized()) { - resourcePackTab.getNode().loadInstance(repository, null); + resourcePackTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository)); } if (shaderTab.isInitialized()) { - shaderTab.getNode().loadInstance(repository, null); + shaderTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository)); } if (worldTab.isInitialized()) { - worldTab.getNode().loadInstance(repository, null); + worldTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository)); } })); } @@ -305,7 +305,7 @@ public VanillaInstallWizardProvider(HMCLGameRepository repository, GameRemoteVer public void start(SettingsMap settings) { settings.put(ModpackPage.GAME_DIRECTORY, repository.getGameDirectory()); settings.put(ModpackPage.REPOSITORY, repository); - settings.put(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId(), gameVersion); + settings.put(GameComponentType.GAME.getPatchId(), gameVersion); } private Task finishVersionDownloadingAsync(SettingsMap settings) { @@ -313,10 +313,10 @@ private Task finishVersionDownloadingAsync(SettingsMap settings) { GameInstanceID instanceId = settings.get(AbstractInstallersPage.INSTANCE_ID); builder.name(instanceId); - builder.gameVersion(((RemoteVersion) settings.get(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId())).getGameVersion()); + builder.gameVersion(((RemoteVersion) settings.get(GameComponentType.GAME.getPatchId())).getGameVersion()); settings.asStringMap().forEach((key, value) -> { - if (!LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId().equals(key) + if (!GameComponentType.GAME.getPatchId().equals(key) && value instanceof RemoteVersion remoteVersion) builder.version(remoteVersion); }); @@ -324,8 +324,8 @@ private Task finishVersionDownloadingAsync(SettingsMap settings) { repository.applyDefaultIsolationSettingForNewInstance(instanceId, settings.isInstallingModdedVersion()); return builder.buildAsync().whenComplete(any -> { repository.refresh(); - repository.applyDefaultIsolationSetting(instanceId); - }).thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(instanceId)); + repository.getInstance(instanceId).applyDefaultIsolationSetting(); + }).thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(repository.getInstance(instanceId))); } @Override diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/InstallersPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/InstallersPage.java index 618f712930a..16d0abdde9a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/InstallersPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/InstallersPage.java @@ -18,8 +18,8 @@ package org.jackhuang.hmcl.ui.download; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.ui.Controllers; @@ -61,7 +61,7 @@ private String getVersion(String id) { protected void reload() { for (InstallerItem library : group.getLibraries()) { - String libraryId = library.getLibraryId(); + String libraryId = library.getComponentType().getPatchId(); if (controller.getSettings().containsKey(libraryId)) { library.versionProperty().set(new InstallerItem.InstalledState(getVersion(libraryId), false, false)); } else { @@ -116,29 +116,24 @@ private void setTxtNameWithLoaders() { StringBuilder nameBuilder = new StringBuilder(getTitle()); for (InstallerItem library : group.getLibraries()) { - String libraryId = library.getLibraryId().replace(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId(), ""); - if (!controller.getSettings().containsKey(libraryId)) { + if (library.getComponentType() == GameComponentType.GAME + || !controller.getSettings().containsKey(library.getComponentType().getPatchId())) continue; - } - LibraryAnalyzer.LibraryType libraryType = LibraryAnalyzer.LibraryType.fromPatchId(libraryId); - - if (libraryType != null) { - String loaderName = switch (libraryType) { - case FORGE -> "Forge"; - case NEO_FORGE -> "NeoForge"; - case CLEANROOM -> "Cleanroom"; - case LEGACY_FABRIC -> "LegacyFabric"; - case FABRIC -> "Fabric"; - case LITELOADER -> "LiteLoader"; - case QUILT -> "Quilt"; - case OPTIFINE -> "OptiFine"; - default -> null; - }; - - if (loaderName != null) - nameBuilder.append('-').append(loaderName); - } + String loaderName = switch (library.getComponentType()) { + case FORGE -> "Forge"; + case NEO_FORGE -> "NeoForge"; + case CLEANROOM -> "Cleanroom"; + case LEGACY_FABRIC -> "LegacyFabric"; + case FABRIC -> "Fabric"; + case LITELOADER -> "LiteLoader"; + case QUILT -> "Quilt"; + case OPTIFINE -> "OptiFine"; + default -> null; + }; + + if (loaderName != null) + nameBuilder.append('-').append(loaderName); } txtName.setText(nameBuilder.toString()); 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 94f650223bd..5788333781b 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 @@ -18,10 +18,7 @@ package org.jackhuang.hmcl.ui.download; import javafx.scene.Node; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.HMCLGameRepository; -import org.jackhuang.hmcl.game.ManuallyCreatedModpackException; -import org.jackhuang.hmcl.game.ModpackHelper; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.modpack.MismatchedModpackTypeException; import org.jackhuang.hmcl.modpack.Modpack; import org.jackhuang.hmcl.modpack.ModpackCompletionException; @@ -109,9 +106,9 @@ private Task finishModpackInstallingAsync(SettingsMap settings) { } try { if (serverModpackManifest != null) { - return ModpackHelper.getUpdateTask(repository, serverModpackManifest, modpack.getEncoding(), instanceId, ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(instanceId))); + return ModpackHelper.getUpdateTask(repository, serverModpackManifest, modpack.getEncoding(), instanceId, ModpackHelper.readModpackConfiguration(repository.getLayout().getModpackConfigurationFile(instanceId))); } else { - return ModpackHelper.getUpdateTask(repository, selected, modpack.getEncoding(), instanceId, ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(instanceId))); + return ModpackHelper.getUpdateTask(repository, selected, modpack.getEncoding(), instanceId, ModpackHelper.readModpackConfiguration(repository.getLayout().getModpackConfigurationFile(instanceId))); } } catch (UnsupportedModpackException | ManuallyCreatedModpackException e) { Controllers.dialog(i18n("modpack.unsupported"), i18n("message.error"), MessageType.ERROR); @@ -124,10 +121,10 @@ private Task finishModpackInstallingAsync(SettingsMap settings) { } else { if (serverModpackManifest != null) { return ModpackHelper.getInstallTask(repository, serverModpackManifest, instanceId, modpack) - .thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(instanceId)); + .thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(repository.getInstance(instanceId))); } else { return ModpackHelper.getInstallTask(repository, selected, instanceId, modpack, iconUrl) - .thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(instanceId)); + .thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(repository.getInstance(instanceId))); } } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/UpdateInstallerWizardProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/UpdateInstallerWizardProvider.java index 267da620fb9..cc81c7e1fba 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/UpdateInstallerWizardProvider.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/UpdateInstallerWizardProvider.java @@ -21,8 +21,9 @@ import org.jackhuang.hmcl.download.*; import org.jackhuang.hmcl.download.game.GameAssetIndexDownloadTask; import org.jackhuang.hmcl.download.game.LibraryDownloadException; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.setting.DownloadProviders; import org.jackhuang.hmcl.task.DownloadException; import org.jackhuang.hmcl.task.Task; @@ -46,22 +47,18 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public final class UpdateInstallerWizardProvider implements WizardProvider { - private final HMCLGameRepository repository; + private final HMCLGameInstance gameInstance; private final DefaultDependencyManager dependencyManager; - private final String gameVersion; - private final GameInstanceManifest manifest; private final String libraryId; private final String oldLibraryVersion; private final DownloadProvider downloadProvider; - public UpdateInstallerWizardProvider(@NotNull HMCLGameRepository repository, @NotNull String gameVersion, @NotNull GameInstanceManifest manifest, @NotNull String libraryId, @Nullable String oldLibraryVersion) { - this.repository = repository; - this.gameVersion = gameVersion; - this.manifest = manifest; + public UpdateInstallerWizardProvider(@NotNull HMCLGameInstance gameInstance, @NotNull String libraryId, @Nullable String oldLibraryVersion) { + this.gameInstance = gameInstance; this.libraryId = libraryId; this.oldLibraryVersion = oldLibraryVersion; this.downloadProvider = DownloadProviders.getDownloadProvider(); - this.dependencyManager = repository.getDependency(downloadProvider); + this.dependencyManager = gameInstance.getRepository().getDependency(downloadProvider); } @Override @@ -76,7 +73,7 @@ public Object finish(SettingsMap settings) { // We remove library but not save it, // so if installation failed will not break down current version. - Task ret = Task.supplyAsync(() -> manifest); + Task ret = Task.supplyAsync(gameInstance::getManifest); var hints = new ArrayList(); for (Object value : settings.asStringMap().values()) { if (value instanceof RemoteVersion remoteVersion) { @@ -87,23 +84,23 @@ public Object finish(SettingsMap settings) { hints.add(new Task.StagesHint("hmcl.install.assets")); } } else if (value instanceof RemoveVersionAction removeVersionAction) { - ret = ret.thenComposeAsync(version -> dependencyManager.removeLibraryAsync(version, removeVersionAction.libraryId)); + ret = ret.thenComposeAsync(version -> dependencyManager.removeLibraryAsync(version, removeVersionAction.componentType)); } } - return ret.thenComposeAsync(repository::saveAsync).thenComposeAsync(repository.refreshAsync()).withStagesHints(hints); + return ret.thenComposeAsync(gameInstance.getRepository()::saveAsync).thenComposeAsync(gameInstance.getRepository()::refreshAsync).withStagesHints(hints); } @Override public Node createPage(WizardController controller, int step, SettingsMap settings) { switch (step) { case 0: - return new VersionsPage(controller, i18n("install.installer.choose", i18n("install.installer." + libraryId)), gameVersion, downloadProvider, libraryId, () -> { + return new VersionsPage(controller, i18n("install.installer.choose", i18n("install.installer." + libraryId)), gameInstance.getVersion().toString(), downloadProvider, libraryId, () -> { if (oldLibraryVersion == null) { controller.onFinish(); } else if ("game".equals(libraryId)) { String newGameVersion = ((RemoteVersion) settings.get(libraryId)).getSelfVersion(); - controller.onNext(new AdditionalInstallersPage(newGameVersion, manifest, controller, repository, downloadProvider)); + controller.onNext(new AdditionalInstallersPage(newGameVersion, gameInstance.getManifest(), controller, gameInstance.getRepository(), downloadProvider)); } else { Controllers.confirm(i18n("install.change_version.confirm", i18n("install.installer." + libraryId), oldLibraryVersion, ((RemoteVersion) settings.get(libraryId)).getSelfVersion()), i18n("install.change_version"), controller::onFinish, controller::onCancel); @@ -181,10 +178,10 @@ public static void alertFailureMessage(Exception exception, Runnable next) { } public static class RemoveVersionAction { - private final String libraryId; + private final GameComponentType componentType; - public RemoveVersionAction(String libraryId) { - this.libraryId = libraryId; + public RemoveVersionAction(GameComponentType componentType) { + this.componentType = componentType; } } } 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 6ffb6676068..e930fc641a0 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 @@ -19,8 +19,7 @@ import javafx.scene.Node; import org.jackhuang.hmcl.Metadata; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.modpack.ModAdviser; import org.jackhuang.hmcl.modpack.ModpackExportInfo; import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackExportTask; @@ -37,6 +36,7 @@ import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.JarUtils; import org.jackhuang.hmcl.util.io.Zipper; +import org.jetbrains.annotations.Nullable; import java.nio.file.Files; import java.nio.file.Path; @@ -47,12 +47,10 @@ import static org.jackhuang.hmcl.setting.SettingsManager.settings; public final class ExportWizardProvider implements WizardProvider { - private final HMCLGameRepository repository; - private final GameInstanceID instanceId; + private final HMCLGameInstance gameInstance; - public ExportWizardProvider(HMCLGameRepository repository, GameInstanceID instanceId) { - this.repository = repository; - this.instanceId = instanceId; + public ExportWizardProvider(HMCLGameInstance gameInstance) { + this.gameInstance = gameInstance; } @Override @@ -71,11 +69,11 @@ public Object finish(SettingsMap settings) { } private Task exportWithLauncher(String modpackType, ModpackExportInfo exportInfo, Path modpackFile) { - Path launcherJar = JarUtils.thisJarPath(); + @Nullable Path launcherJar = JarUtils.thisJarPath(); boolean packWithLauncher = exportInfo.isPackWithLauncher() && launcherJar != null; return new Task<>() { - Path tempModpack; - Task exportTask; + @Nullable Path tempModpack; + @Nullable Task exportTask; { setSignificance(TaskSignificance.MODERATE); @@ -136,9 +134,12 @@ public void execute() throws Exception { zip.putTextFile( JsonUtils.GSON.toJson(exportedServers, AuthlibInjectorServerList.class), ".hmcl/config/authlib-injector-servers.json"); - zip.putFile(tempModpack, ModpackTypeSelectionPage.MODPACK_TYPE_MODRINTH.equals(modpackType) + + // Bundled package under .hmcl/modpack/ (not the process workdir). + String packageName = ModpackTypeSelectionPage.MODPACK_TYPE_MODRINTH.equals(modpackType) ? "modpack.mrpack" - : "modpack.zip"); + : "modpack.zip"; + zip.putFile(tempModpack, ".hmcl/" + Metadata.BUNDLED_MODPACK_DIRECTORY_NAME + "/" + packageName); for (String extension : FontManager.FONT_EXTENSIONS) { String fileName = "font." + extension; @@ -150,6 +151,13 @@ public void execute() throws Exception { } zip.putFile(launcherJar, launcherJar.getFileName().toString()); + } finally { + if (tempModpack != null) { + try { + Files.deleteIfExists(tempModpack); + } catch (Exception ignored) { + } + } } } }; @@ -157,7 +165,7 @@ public void execute() throws Exception { private Task exportAsMcbbs(ModpackExportInfo exportInfo, Path modpackFile) { return new Task() { - Task dependency = null; + @Nullable Task dependency; { setSignificance(TaskSignificance.MODERATE); @@ -165,7 +173,7 @@ private Task exportAsMcbbs(ModpackExportInfo exportInfo, Path modpackFile) { @Override public void execute() { - dependency = new McbbsModpackExportTask(repository, instanceId, exportInfo, modpackFile); + dependency = new McbbsModpackExportTask(resolveCurrentGameInstance(), exportInfo, modpackFile); } @Override @@ -177,7 +185,7 @@ public Collection> getDependencies() { private Task exportAsMultiMC(ModpackExportInfo exportInfo, Path modpackFile) { return new Task() { - Task dependency; + @Nullable Task dependency; { setSignificance(TaskSignificance.MODERATE); @@ -185,8 +193,9 @@ private Task exportAsMultiMC(ModpackExportInfo exportInfo, Path modpackFile) @Override public void execute() { - GameSettings.Effective setting = repository.getEffectiveGameSettings(instanceId); - dependency = new MultiMCModpackExportTask(repository, instanceId, exportInfo.getWhitelist(), + HMCLGameInstance instance = resolveCurrentGameInstance(); + GameSettings.Effective setting = instance.getEffectiveSettings(); + dependency = new MultiMCModpackExportTask(instance, exportInfo.getWhitelist(), new MultiMCInstanceConfiguration( "OneSix", exportInfo.getName() + "-" + exportInfo.getVersion(), @@ -225,7 +234,7 @@ public Collection> getDependencies() { private Task exportAsServer(ModpackExportInfo exportInfo, Path modpackFile) { return new Task() { - Task dependency; + @Nullable Task dependency; { setSignificance(TaskSignificance.MODERATE); @@ -233,7 +242,7 @@ private Task exportAsServer(ModpackExportInfo exportInfo, Path modpackFile) { @Override public void execute() { - dependency = new ServerModpackExportTask(repository, instanceId, exportInfo, modpackFile); + dependency = new ServerModpackExportTask(resolveCurrentGameInstance(), exportInfo, modpackFile); } @Override @@ -245,7 +254,7 @@ public Collection> getDependencies() { private Task exportAsModrinth(ModpackExportInfo exportInfo, Path modpackFile) { return new Task() { - Task dependency; + @Nullable Task dependency; { setSignificance(TaskSignificance.MODERATE); @@ -254,8 +263,7 @@ private Task exportAsModrinth(ModpackExportInfo exportInfo, Path modpackFile) @Override public void execute() { dependency = new ModrinthModpackExportTask( - repository, - instanceId, + resolveCurrentGameInstance(), exportInfo, modpackFile ); @@ -268,12 +276,19 @@ public Collection> getDependencies() { }; } + /// Returns the current registered snapshot for the instance selected by this wizard. + /// + /// @return the current registered instance + private HMCLGameInstance resolveCurrentGameInstance() { + return gameInstance.getRepository().getInstance(gameInstance.getId()); + } + @Override public Node createPage(WizardController controller, int step, SettingsMap settings) { return switch (step) { case 0 -> new ModpackTypeSelectionPage(controller); - case 1 -> new ModpackInfoPage(controller, repository, instanceId); - case 2 -> new ModpackFileSelectionPage(controller, repository, instanceId, ModAdviser::suggestMod); + case 1 -> new ModpackInfoPage(controller, gameInstance); + case 2 -> new ModpackFileSelectionPage(controller, gameInstance, ModAdviser::suggestMod); default -> throw new IllegalArgumentException("step"); }; } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackFileSelectionPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackFileSelectionPage.java index b273bb308af..c7e12fa9a6d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackFileSelectionPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackFileSelectionPage.java @@ -29,7 +29,7 @@ import javafx.scene.layout.HBox; import javafx.scene.layout.StackPane; import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.modpack.ModAdviser; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.ui.FXUtils; @@ -62,14 +62,15 @@ */ public final class ModpackFileSelectionPage extends BorderPane implements WizardPage { private final WizardController controller; - private final GameInstanceID instanceId; + private final HMCLGameInstance gameInstance; private final ModAdviser adviser; private @Nullable ModpackFileTreeItem rootNode; - public ModpackFileSelectionPage(WizardController controller, HMCLGameRepository repository, GameInstanceID instanceId, ModAdviser adviser) { + public ModpackFileSelectionPage(WizardController controller, HMCLGameInstance gameInstance, ModAdviser adviser) { this.controller = controller; - this.instanceId = instanceId; + this.gameInstance = gameInstance; this.adviser = adviser; + GameInstanceID instanceId = gameInstance.getId(); JFXTreeView treeView = new JFXTreeView<>(); treeView.setSelectionModel(new NoneMultipleSelectionModel<>()); @@ -97,17 +98,17 @@ public ModpackFileSelectionPage(WizardController controller, HMCLGameRepository btnNext.setOnAction(e -> onNext()); nextPane.getChildren().setAll(btnNext); - loadRoot(repository, treeView, placeholderPane, spinnerPane, btnNext); - spinnerPane.setOnFailedAction((__) -> loadRoot(repository, treeView, placeholderPane, spinnerPane, btnNext)); + loadRoot(treeView, placeholderPane, spinnerPane, btnNext); + spinnerPane.setOnFailedAction((__) -> loadRoot(treeView, placeholderPane, spinnerPane, btnNext)); this.setBottom(nextPane); } - private void loadRoot(HMCLGameRepository repository, JFXTreeView treeView, StackPane placeholderPane, SpinnerPane spinnerPane, JFXButton btnNext) { + private void loadRoot(JFXTreeView treeView, StackPane placeholderPane, SpinnerPane spinnerPane, JFXButton btnNext) { spinnerPane.setLoading(true); btnNext.setDisable(true); CompletableFuture - .supplyAsync(() -> getTreeItem(repository.getRunDirectory(instanceId), "minecraft", 0), Schedulers.io()) + .supplyAsync(() -> getTreeItem(gameInstance.getRunDirectory(), "minecraft", 0), Schedulers.io()) .whenCompleteAsync((root, throwable) -> { if (throwable == null) { if (root != null) { @@ -145,12 +146,12 @@ private ModpackFileTreeItem getTreeItem(Path file, String basePath, int level) { } if (fileName.startsWith("._")) // macOS system file state = ModAdviser.ModSuggestion.HIDDEN; - if (FileUtils.getNameWithoutExtension(file).equals(instanceId.toString())) + if (FileUtils.getNameWithoutExtension(file).equals(gameInstance.getId().toString())) state = ModAdviser.ModSuggestion.HIDDEN; } if (isDirectory) { - if (fileName.equals(instanceId + "-natives")) { // Ignore -natives + if (fileName.equals(gameInstance.getId() + "-natives")) { // Ignore -natives state = ModAdviser.ModSuggestion.HIDDEN; } if (level == 1 && fileName.startsWith("natives-")) { // Ignore natives-os-arch @@ -161,7 +162,7 @@ private ModpackFileTreeItem getTreeItem(Path file, String basePath, int level) { return null; } - ModpackFileTreeItem node = new ModpackFileTreeItem(level == 0 ? instanceId.toString() : StringUtils.substringAfterLast(basePath, '/'), basePath); + ModpackFileTreeItem node = new ModpackFileTreeItem(level == 0 ? gameInstance.getId().toString() : StringUtils.substringAfterLast(basePath, '/'), basePath); if (state == ModAdviser.ModSuggestion.SUGGESTED) node.setSelected(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 6634ca4ca86..98cc08c3033 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 @@ -35,8 +35,7 @@ import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.auth.Account; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.modpack.ModpackExportInfo; import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackManifest; import org.jackhuang.hmcl.setting.Accounts; @@ -65,9 +64,8 @@ public final class ModpackInfoPage extends Control implements WizardPage { private final WizardController controller; - private final HMCLGameRepository repository; + private final HMCLGameInstance gameInstance; private final ModpackExportInfo.Options options; - private final GameInstanceID instanceId; private final boolean canIncludeLauncher; private final ModpackExportInfo exportInfo = new ModpackExportInfo(); @@ -88,19 +86,18 @@ public final class ModpackInfoPage extends Control implements WizardPage { private final SimpleBooleanProperty noCreateRemoteFiles = new SimpleBooleanProperty(); private final SimpleBooleanProperty skipCurseForgeRemoteFiles = new SimpleBooleanProperty(); - public ModpackInfoPage(WizardController controller, HMCLGameRepository repository, GameInstanceID instanceId) { + public ModpackInfoPage(WizardController controller, HMCLGameInstance gameInstance) { this.controller = controller; - this.repository = repository; + this.gameInstance = gameInstance; this.options = controller.getSettings().get(MODPACK_INFO_OPTION); - this.instanceId = instanceId; if (this.options == null) throw new IllegalArgumentException("Settings.MODPACK_INFO_OPTION is required"); - name.set(instanceId.toString()); + name.set(gameInstance.getId().toString()); author.set(Optional.ofNullable(Accounts.getSelectedAccount()).map(Account::getProfileName).orElse("")); - GameSettings.Effective versionSetting = repository.getEffectiveGameSettings(this.instanceId); + GameSettings.Effective versionSetting = gameInstance.getEffectiveSettings(); minMemory.set(Optional.ofNullable(versionSetting.getInheritable(GameSettings::minMemoryProperty)).orElse(0)); launchArguments.set(versionSetting.getInheritable(GameSettings::gameArgumentsProperty)); javaArguments.set(versionSetting.getInheritable(GameSettings::jvmOptionsProperty)); @@ -213,7 +210,7 @@ public ModpackInfoPageSkin(ModpackInfoPage skinnable) { var instanceNamePane = new LineTextPane(); { instanceNamePane.setTitle(i18n("modpack.wizard.step.initialization.exported_version")); - instanceNamePane.setText(skinnable.instanceId.toString()); + instanceNamePane.setText(skinnable.gameInstance.getId().toString()); list.getContent().add(instanceNamePane); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/game/GameSettingsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/game/GameSettingsPage.java index 7685584beaa..99638853a00 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/game/GameSettingsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/game/GameSettingsPage.java @@ -17,6 +17,8 @@ */ package org.jackhuang.hmcl.ui.game; +import org.jackhuang.hmcl.game.HMCLGameInstance; + import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXComboBox; import com.jfoenix.controls.JFXSlider; @@ -27,6 +29,7 @@ import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.css.PseudoClass; @@ -76,7 +79,7 @@ /// @author Glavo @NotNullByDefault public final class GameSettingsPage extends StackPane - implements DecoratorPage, GameInstancePage.GameInstanceLoadable, PageAware { + implements DecoratorPage, PageAware { private static final Object INHERIT_BUTTON_TOOLTIP_KEY = new Object(); private static final PseudoClass PSEUDO_OVERRIDDEN = PseudoClass.getPseudoClass("overridden"); @@ -88,20 +91,13 @@ public final class GameSettingsPage extends StackPane private final ObjectProperty state = new SimpleObjectProperty<>(this, "state", new State("", null, false, false, false)); private final WeakListenerHolder holder = new WeakListenerHolder(); - /// The selected game directory. - private @Nullable GameDirectory gameDirectory; - - /// The selected repository. - private @Nullable HMCLGameRepository repository; - - /// The current instance ID. - private @Nullable GameInstanceID instanceId; + /// The current game instance when editing instance settings, or `null` for preset settings. + private final ObjectProperty<@Nullable HMCLGameInstance> gameInstance = + new SimpleObjectProperty<>(this, "gameInstance"); /// The current setting. private final ObjectProperty<@Nullable S> currentSetting = new SimpleObjectProperty<>(this, "setting"); - private final ObjectProperty currentGameVersionNumber = new SimpleObjectProperty<>(this, "currentGameVersionNumber", GameVersionNumber.unknown()); - private boolean updatingJavaSetting = false; private boolean updatingSelectedJava = false; private boolean updatingParentSetting = false; @@ -129,12 +125,24 @@ public final class GameSettingsPage extends StackPane private final InvalidationListener javaListener = o -> refreshJavaSettings(); private final InvalidationListener weakJavaListener = holder.weak(javaListener); - public GameSettingsPage(Class settingType) { + /// Creates a settings page. + /// + /// @param settingType [GameSettings.Instance] or [GameSettings.Preset] + /// @param instanceContext parent instance property for instance settings; ignored for presets and may be `null` + public GameSettingsPage( + Class settingType, + @Nullable ObservableValue instanceContext) { assert settingType == GameSettings.Preset.class || settingType == GameSettings.Instance.class; this.isPresetSetting = settingType == GameSettings.Preset.class; if (!isPresetSetting) { bindActiveParentSetting(); + Objects.requireNonNull(instanceContext, "instanceContext"); + holder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> { + if (current != null) { + loadInstance(current); + } + })); } this.scrollPane = new ScrollPane(); @@ -792,19 +800,22 @@ public GameSettingsPage(Class settingType) { highPerformancePane.setTitle(i18n("settings.advanced.renderer.gpu_preferences")); highPerformancePane.setSubtitle(i18n("settings.advanced.windows_only")); - this.currentGameVersionNumber.addListener((o, oldValue, newValue) -> { - boolean showBackendChoose = isPresetSetting || newValue.compareTo("26.2-snapshot-2") >= 0; + InvalidationListener updateGraphicsVisibility = o -> { + GameVersionNumber version = currentGameVersion(); + boolean showBackendChoose = isPresetSetting || version.compareTo("26.2-snapshot-2") >= 0; graphicsBackendPane.setVisible(showBackendChoose); graphicsBackendPane.setManaged(showBackendChoose); - boolean showOpenGL = GraphicsAPI.OPENGL.isSupported(newValue); + boolean showOpenGL = GraphicsAPI.OPENGL.isSupported(version); openGLRendererPane.setVisible(showOpenGL); openGLRendererPane.setManaged(showOpenGL); - boolean showVulkan = GraphicsAPI.VULKAN.isSupported(newValue); + boolean showVulkan = GraphicsAPI.VULKAN.isSupported(version); vulkanRendererPane.setVisible(showVulkan); vulkanRendererPane.setManaged(showVulkan); - }); + }; + this.gameInstance.addListener(updateGraphicsVisibility); + updateGraphicsVisibility.invalidated(this.gameInstance); } var nativeLibrarySettings = new ComponentList(); @@ -1860,16 +1871,20 @@ private void bindRunningDirectoryProperty( } private boolean isCurrentInstanceModpack() { - return repository != null && instanceId != null && repository.isModpack(instanceId); + HMCLGameInstance gameInstance = this.gameInstance.get(); + return gameInstance != null && gameInstance.isModpack(); } /// Returns the current instance version root displayed for modpack running directories. private String getCurrentInstanceVersionRoot() { - if (repository == null || instanceId == null) { - return ""; - } + HMCLGameInstance gameInstance = this.gameInstance.get(); + return gameInstance != null ? gameInstance.getInstanceRoot().toString() : ""; + } - return repository.getInstanceRoot(instanceId).toString(); + /// Returns the Minecraft version of the loaded instance, or [GameVersionNumber#unknown()] for presets. + private GameVersionNumber currentGameVersion() { + HMCLGameInstance gameInstance = this.gameInstance.get(); + return gameInstance != null ? gameInstance.getVersion() : GameVersionNumber.unknown(); } /// Keeps a listener attached to the current instance's parent preset property. @@ -2589,8 +2604,9 @@ private GameSettings getEffectiveInheritableSource( /// Returns the runtime parent preset for an instance, including the game directory's migrated preset fallback. private GameSettings.Preset getEffectiveParentGameSettings(GameSettings.Instance instance) { - if (repository != null) { - return repository.getParentGameSettings(instance); + HMCLGameInstance gameInstance = this.gameInstance.get(); + if (gameInstance != null) { + return gameInstance.getRepository().getParentGameSettings(instance); } return getExplicitParentGameSettings(instance); @@ -2629,26 +2645,21 @@ public ReadOnlyObjectProperty stateProperty() { } @SuppressWarnings("unchecked") - @Override - public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) { - this.gameDirectory = repository.getGameDirectory(); - this.repository = repository; - this.instanceId = instanceId; - - assert isPresetSetting == (instanceId == null); + public void loadInstance(HMCLGameInstance.Optional instance) { + HMCLGameInstance gameInstance = instance.instance(); + this.gameInstance.set(gameInstance); - if (instanceId != null) { - this.currentGameVersionNumber.set(GameVersionNumber.asGameVersion(repository.getGameVersion(instanceId))); + assert isPresetSetting == (gameInstance == null); - @Nullable GameSettings.Instance setting = repository.getInstanceGameSettingsOrCreate(instanceId); + if (gameInstance != null) { + @Nullable GameSettings.Instance setting = gameInstance.getSettingsOrCreate(); this.currentSetting.set((S) setting); setSettingsReadOnly( - setting == null || repository.isInstanceGameSettingsReadOnly(instanceId), + setting == null || gameInstance.isSettingsReadOnly(), i18n("settings.game.instance_settings.unsupported"), setting != null ? this::forceOverwriteInstanceGameSettings : null); loadIcon(); } else { - this.currentGameVersionNumber.set(GameVersionNumber.unknown()); this.currentSetting.set((S) SettingsManager.getDefaultGameSettingsPresetOrCreate()); setSettingsReadOnly( SettingsManager.isGameSettingsReadOnly(), @@ -2657,11 +2668,6 @@ public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID } } - /// Returns the loaded instance ID, or `null` when this page edits preset settings. - private @Nullable GameInstanceID getLoadedInstanceId() { - return instanceId == null ? null : instanceId; - } - /// Updates the page read-only state used when settings cannot be saved safely. /// /// @param readOnly whether the current settings should be displayed read-only @@ -2701,13 +2707,13 @@ private void setSettingsReadOnly(boolean readOnly, String message, @Nullable Run /// Backs up and overwrites the current instance's `instance-game-settings.json`. private void forceOverwriteInstanceGameSettings() { - @Nullable GameInstanceID loadedInstanceId = getLoadedInstanceId(); - if (repository == null || loadedInstanceId == null) { + HMCLGameInstance gameInstance = this.gameInstance.get(); + if (gameInstance == null) { return; } Controllers.confirmBackupAndOverwrite(i18n("settings.game.instance_settings.unsupported"), () -> { - repository.forceOverwriteInstanceGameSettings(loadedInstanceId); + gameInstance.forceOverwriteSettings(); setSettingsReadOnly(false, ""); }); } @@ -2721,11 +2727,12 @@ private void forceOverwriteGameSettings() { } private void loadIcon() { - @Nullable GameInstanceID loadedInstanceId = getLoadedInstanceId(); - if (repository == null || loadedInstanceId == null) + HMCLGameInstance gameInstance = this.gameInstance.get(); + if (gameInstance == null) { return; + } - iconPickerItem.setImage(repository.getInstanceIconImage(loadedInstanceId)); + iconPickerItem.setImage(gameInstance.getIconImage()); } /// Refreshes Java selection controls and keeps inherited parent Java properties observed. @@ -2789,17 +2796,19 @@ private void initializeSelectedJava() { private void initJavaSubtitle() { S setting = currentSetting.get(); - if (setting == null || gameDirectory == null) + if (setting == null) { return; + } initializeSelectedJava(); JavaVersionType javaVersionType = setting.javaTypeProperty().getValue(); - @Nullable GameInstanceID loadedInstanceId = getLoadedInstanceId(); - GameSettings.Effective effectiveSetting = loadedInstanceId != null && repository != null ? repository.getEffectiveGameSettings(loadedInstanceId) : null; + HMCLGameInstance gameInstance = this.gameInstance.get(); + @Nullable GameSettings.Effective effectiveSetting = + gameInstance != null ? gameInstance.getEffectiveSettings() : null; JavaVersionType effectiveJavaVersionType = effectiveSetting != null ? effectiveSetting.getInheritable(GameSettings::javaTypeProperty) : javaVersionType; boolean autoSelected = effectiveJavaVersionType == JavaVersionType.AUTO || effectiveJavaVersionType == JavaVersionType.VERSION; - if (instanceId == null && autoSelected) { + if (gameInstance == null && autoSelected) { javaSublist.setDescription(i18n("settings.game.java_directory.auto")); return; } @@ -2811,13 +2820,10 @@ private void initJavaSubtitle() { } if (JavaManager.isInitialized()) { - GameVersionNumber gameVersionNumber = this.currentGameVersionNumber.get(); - GameInstanceManifest manifest; - if (this.instanceId == null) { - manifest = null; - } else { - manifest = repository != null && loadedInstanceId != null ? repository.getResolvedInstanceManifest(loadedInstanceId).launchManifest() : null; - } + GameVersionNumber gameVersionNumber = currentGameVersion(); + GameInstanceManifest manifest = gameInstance != null + ? gameInstance.getResolvedManifest().launchManifest() + : null; try { JavaRuntime java = effectiveSetting != null @@ -2837,19 +2843,21 @@ private void initJavaSubtitle() { } private void onExploreIcon() { - if (repository == null || instanceId == null) + HMCLGameInstance gameInstance = this.gameInstance.get(); + if (gameInstance == null) { return; - - Controllers.dialog(new GameInstanceIconDialog(repository, instanceId, this::loadIcon)); + } + Controllers.dialog(new GameInstanceIconDialog(gameInstance, this::loadIcon)); } private void onDeleteIcon() { - @Nullable GameInstanceID loadedInstanceId = getLoadedInstanceId(); - if (repository == null || loadedInstanceId == null) + HMCLGameInstance gameInstance = this.gameInstance.get(); + if (gameInstance == null) { return; + } - repository.deleteIconFile(loadedInstanceId); - GameSettings.Instance localGameSettings = repository.getInstanceGameSettingsOrCreate(loadedInstanceId); + gameInstance.deleteIconFile(); + GameSettings.Instance localGameSettings = gameInstance.getSettingsOrCreate(); if (localGameSettings != null) { localGameSettings.iconProperty().setValue(GameInstanceIconType.DEFAULT); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadListPage.java index f39535a4c38..45daba98ed1 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadListPage.java @@ -38,9 +38,7 @@ import javafx.scene.input.KeyEvent; import javafx.scene.layout.*; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.addon.RemoteAddon; import org.jackhuang.hmcl.addon.RemoteAddonRepository; import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository; @@ -68,12 +66,12 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.selectedItemPropertyFor; -public class DownloadListPage extends Control implements DecoratorPage, GameInstancePage.GameInstanceLoadable { +public class DownloadListPage extends Control implements DecoratorPage { protected final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(); private final BooleanProperty loading = new SimpleBooleanProperty(false); private final BooleanProperty failed = new SimpleBooleanProperty(false); private final boolean instanceSelection; - private final ObjectProperty instanceReference = new SimpleObjectProperty<>(); + private final ObjectProperty instanceReference = new SimpleObjectProperty<>(); private final IntegerProperty pageOffset = new SimpleIntegerProperty(0); private final IntegerProperty pageCount = new SimpleIntegerProperty(-1); private final ListProperty items = new SimpleListProperty<>(this, "items", FXCollections.observableArrayList()); @@ -111,9 +109,8 @@ public ObservableList getActions() { return actions; } - @Override - public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) { - this.instanceReference.set(new HMCLGameRepository.InstanceReference(repository, instanceId)); + public void loadInstance(HMCLGameInstance.Optional instance) { + this.instanceReference.set(instance); setLoading(false); setFailed(false); @@ -124,10 +121,12 @@ public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID } if (instanceSelection) { - instances.setAll(repository.getDisplayInstanceManifests() - .map(GameInstanceManifest::id) + HMCLGameRepository repository = instance.repository(); + instances.setAll(repository.getDisplayInstances() + .map(DefaultGameInstance::getId) .toList()); - selectedInstance.set(repository.getSelectedInstance()); + @Nullable HMCLGameInstance repositorySelection = repository.getSelectedInstance(); + selectedInstance.set(repositorySelection != null ? repositorySelection.getId() : null); } } @@ -166,11 +165,13 @@ private void search(String userGameVersion, RemoteAddonRepository.Category categ int currentSearchID = searchID = searchID + 1; Task.supplyAsync(() -> { - HMCLGameRepository.InstanceReference instanceReference = this.instanceReference.get(); - if (instanceReference.instanceId() == null) { + HMCLGameInstance.Optional instanceReference = this.instanceReference.get(); + @Nullable HMCLGameInstance instance = instanceReference.instance(); + if (instance == null) { return userGameVersion; } else { - return instanceReference.repository().getGameVersion(instanceReference.instanceId()).orElse(""); + GameVersionNumber version = instance.getVersion(); + return version != GameVersionNumber.unknown() ? version.toString() : ""; } }).thenApplyAsync( gameVersion -> repository.search(downloadProvider, gameVersion, category, pageOffset, 50, searchFilter, sort, RemoteAddonRepository.SortOrder.DESC) @@ -217,10 +218,10 @@ protected String getLocalizedOfficialPage() { } } - protected HMCLGameRepository.InstanceReference getInstanceReference() { + protected HMCLGameInstance.Optional getInstanceOptional() { if (instanceSelection) { @Nullable GameInstanceID instanceId = selectedInstance.get(); - return new HMCLGameRepository.InstanceReference(instanceReference.get().repository(), instanceId); + return HMCLGameInstance.Optional.of(instanceReference.get().repository(), instanceId); } else { return instanceReference.get(); } @@ -570,7 +571,7 @@ protected ModDownloadListPageSkin(DownloadListPage control) { FXUtils.onClicked(wrapper, () -> { RemoteAddon item = getItem(); if (item != null) - Controllers.navigate(new DownloadPage(getSkinnable(), item, getSkinnable().getInstanceReference(), getSkinnable().callback)); + Controllers.navigate(new DownloadPage(getSkinnable(), item, getSkinnable().getInstanceOptional(), getSkinnable().callback)); }); setPrefWidth(0); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadPage.java index afddbe07639..1dba09c1b75 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadPage.java @@ -30,10 +30,7 @@ import javafx.scene.layout.*; import javafx.stage.FileChooser; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.download.LibraryAnalyzer; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.addon.mod.ModLoaderType; import org.jackhuang.hmcl.addon.RemoteAddon; import org.jackhuang.hmcl.addon.RemoteAddonRepository; @@ -67,14 +64,14 @@ public class DownloadPage extends Control implements DecoratorPage { private final ModTranslations translations; private final RemoteAddon addon; private final ModTranslations.Mod mod; - private final HMCLGameRepository.InstanceReference instanceReference; + private final HMCLGameInstance.Optional instanceReference; private final DownloadCallback callback; private final DownloadListPage page; private final RemoteAddon.Type type; private SimpleMultimap> versions; - public DownloadPage(DownloadListPage page, RemoteAddon addon, HMCLGameRepository.InstanceReference instanceReference, @Nullable DownloadCallback callback) { + public DownloadPage(DownloadListPage page, RemoteAddon addon, HMCLGameInstance.Optional instanceReference, @Nullable DownloadCallback callback) { this.page = page; this.repository = page.repository; this.addon = addon; @@ -128,7 +125,7 @@ public RemoteAddon getAddon() { return addon; } - public HMCLGameRepository.InstanceReference getInstanceReference() { + public HMCLGameInstance.Optional getInstanceOptional() { return instanceReference; } @@ -277,7 +274,7 @@ protected DownloadPageSkin(DownloadPage control) { if (gameVersion != null && control.versions.containsKey(gameVersion)) { List modVersions = control.versions.get(gameVersion); if (modVersions != null && !modVersions.isEmpty()) { - Set targetLoaders = LibraryAnalyzer.analyze(resolvedManifest, gameVersion).getModLoaders(); + Set targetLoaders = GameComponentAnalyzer.analyze(resolvedManifest, gameVersion).getModLoaders(); resolve: for (RemoteAddon.Version modVersion : modVersions) { @@ -373,7 +370,7 @@ private static final class DependencyAddonItem extends LineButton { public final RemoteAddon addon; - DependencyAddonItem(DownloadListPage page, RemoteAddon addon, HMCLGameRepository.InstanceReference instanceReference) { + DependencyAddonItem(DownloadListPage page, RemoteAddon addon, HMCLGameInstance.Optional instanceReference) { this.addon = addon; HBox pane = new HBox(8); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameAdvancedListItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameAdvancedListItem.java index bf9a3e6601d..bfceb45bed7 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameAdvancedListItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameAdvancedListItem.java @@ -17,35 +17,35 @@ */ package org.jackhuang.hmcl.ui.instances; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.WeakChangeListener; import javafx.geometry.Pos; -import org.jackhuang.hmcl.event.Event; -import org.jackhuang.hmcl.event.EventBus; -import org.jackhuang.hmcl.event.RefreshedGameInstancesEvent; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.HMCLGameRepository; +import javafx.scene.image.Image; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.setting.GameDirectoryManager; import org.jackhuang.hmcl.setting.GameInstanceIconType; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.WeakListenerHolder; import org.jackhuang.hmcl.ui.construct.AdvancedListItem; import org.jackhuang.hmcl.ui.construct.ImageContainer; - -import java.util.function.Consumer; +import org.jetbrains.annotations.Nullable; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public class GameAdvancedListItem extends AdvancedListItem { private final ImageContainer imageContainer; private final WeakListenerHolder holder = new WeakListenerHolder(); - private HMCLGameRepository repository; - @SuppressWarnings("unused") - private Consumer onInstanceIconChangedListener; - @SuppressWarnings({"unused", "FieldCanBeLocal"}) - private Consumer onRefreshedInstancesListener; + /// Strongly held so [WeakChangeListener] keeps delivering icon updates. + private final ChangeListener iconListener; + + private @Nullable WeakChangeListener weakIconListener; + private @Nullable ReadOnlyObjectProperty observedIcon; public GameAdvancedListItem() { this.imageContainer = new ImageContainer(LEFT_GRAPHIC_SIZE); + this.iconListener = (observable, oldImage, newImage) -> imageContainer.setImage(newImage); imageContainer.setMouseTransparent(true); AdvancedListItem.setAlignment(imageContainer, Pos.CENTER); setLeftGraphic(imageContainer); @@ -53,31 +53,28 @@ public GameAdvancedListItem() { holder.add(FXUtils.onWeakChangeAndOperate(GameDirectoryManager.selectedInstanceProperty(), this::loadInstance)); } - private void loadInstance(GameInstanceID instanceId) { - if (GameDirectoryManager.getSelectedRepository() != repository) { - repository = GameDirectoryManager.getSelectedRepository(); - if (repository != null) { - onInstanceIconChangedListener = repository.onInstanceIconChanged.registerWeak(event -> { - FXUtils.runInFX(() -> loadInstance(repository.getSelectedInstance())); - }); - if (!repository.isLoaded()) { - onRefreshedInstancesListener = EventBus.EVENT_BUS.channel(RefreshedGameInstancesEvent.class) - .registerWeak(event -> FXUtils.runInFX(() -> loadInstance(repository.getSelectedInstance()))); - return; - } - } - } - if (instanceId != null && repository != null) { - if (repository.hasInstance(instanceId)) { - setTitle(i18n("instance.manage.manage")); - setSubtitle(instanceId.toString()); - imageContainer.setImage(repository.getInstanceIconImage(instanceId)); - return; - } + private void loadInstance(@Nullable HMCLGameInstance instance) { + unbindIcon(); + if (instance != null) { + setTitle(i18n("instance.manage.manage")); + setSubtitle(instance.getId().toString()); + observedIcon = instance.iconImageProperty(); + weakIconListener = new WeakChangeListener<>(iconListener); + observedIcon.addListener(weakIconListener); + imageContainer.setImage(instance.getIconImage()); + return; } setTitle(i18n("instance.empty")); setSubtitle(i18n("instance.empty.add")); imageContainer.setImage(GameInstanceIconType.DEFAULT.getIcon()); } + + private void unbindIcon() { + if (observedIcon != null && weakIconListener != null) { + observedIcon.removeListener(weakIconListener); + } + observedIcon = null; + weakIconListener = null; + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstanceIconDialog.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstanceIconDialog.java index a81828116f4..77fda7e3d69 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstanceIconDialog.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstanceIconDialog.java @@ -21,9 +21,7 @@ import javafx.scene.image.ImageView; import javafx.scene.layout.FlowPane; import javafx.stage.FileChooser; -import org.jackhuang.hmcl.event.Event; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.setting.GameSettings; import org.jackhuang.hmcl.setting.GameInstanceIconType; import org.jackhuang.hmcl.ui.Controllers; @@ -31,6 +29,7 @@ import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.ui.construct.DialogPane; import org.jackhuang.hmcl.ui.construct.RipplerContainer; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.file.Path; @@ -39,16 +38,14 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public class GameInstanceIconDialog extends DialogPane { - private final HMCLGameRepository repository; - private final GameInstanceID instanceId; + private final HMCLGameInstance gameInstance; private final Runnable onFinish; - private final GameSettings.Instance setting; + private final GameSettings.@Nullable Instance setting; - public GameInstanceIconDialog(HMCLGameRepository repository, GameInstanceID instanceId, Runnable onFinish) { - this.repository = repository; - this.instanceId = instanceId; + public GameInstanceIconDialog(HMCLGameInstance gameInstance, Runnable onFinish) { + this.gameInstance = gameInstance; this.onFinish = onFinish; - this.setting = repository.getInstanceGameSettingsOrCreate(this.instanceId); + this.setting = gameInstance.getSettingsOrCreate(); setTitle(i18n("settings.icon")); FlowPane pane = new FlowPane(); @@ -79,7 +76,7 @@ private void exploreIcon() { Path selectedFile = Controllers.showOpenDialog(chooser); if (selectedFile != null) { try { - repository.setInstanceIconFile(instanceId, selectedFile); + gameInstance.setIconFile(selectedFile); if (setting != null) { setting.iconProperty().setValue(GameInstanceIconType.DEFAULT); @@ -119,7 +116,7 @@ private Node createIcon(GameInstanceIconType type) { @Override protected void onAccept() { - repository.onInstanceIconChanged.fireEvent(new Event(this)); + // Icon file / settings.iconProperty updates already invalidate iconImageProperty. onFinish.run(); super.onAccept(); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java index e438bae061b..aee7e753df2 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java @@ -21,16 +21,15 @@ import javafx.application.Platform; import javafx.beans.binding.Bindings; import javafx.beans.property.*; +import javafx.beans.value.ChangeListener; import javafx.event.Event; import javafx.event.EventType; -import javafx.scene.Node; import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; -import org.jackhuang.hmcl.event.EventBus; -import org.jackhuang.hmcl.event.EventPriority; -import org.jackhuang.hmcl.event.RefreshedGameInstancesEvent; import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.game.HMCLGameRepositorySnapshot; import org.jackhuang.hmcl.setting.GameSettings; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; @@ -48,8 +47,6 @@ import org.jetbrains.annotations.Nullable; import java.nio.file.Path; -import java.util.Optional; -import java.util.function.Supplier; import static org.jackhuang.hmcl.ui.FXUtils.runInFX; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; @@ -65,10 +62,19 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator private final TabHeader.Tab resourcePackTab = new TabHeader.Tab<>("resourcePackTab"); private final TransitionPane transitionPane = new TransitionPane(); private final BooleanProperty currentInstanceUpgradable = new SimpleBooleanProperty(); - private final ObjectProperty instanceReference = new SimpleObjectProperty<>(); + private final ObjectProperty instance = + new SimpleObjectProperty<>(this, "instance"); private final WeakListenerHolder listenerHolder = new WeakListenerHolder(); - private GameInstanceID preferredInstanceId = null; + /// Re-resolves the page context when its repository publishes a new snapshot. + private final ChangeListener repositorySnapshotListener = + (observable, oldValue, newValue) -> checkSelectedInstance(); + + /// Repository currently observed for snapshot publications. + private @Nullable HMCLGameRepository observedRepository; + + /// Last concrete instance displayed by this page. + private @Nullable GameInstanceID preferredInstanceId; public static class WorkingDirChangedEvent extends Event { public static final EventType EVENT_TYPE = new EventType<>(Event.ANY, "WORKING_DIR_CHANGED"); @@ -79,12 +85,13 @@ public WorkingDirChangedEvent() { } public GameInstancePage() { - gameSettingsTab.setNodeSupplier(loadInstanceFor(() -> new GameSettingsPage<>(GameSettings.Instance.class))); - installerListTab.setNodeSupplier(loadInstanceFor(InstallerListPage::new)); - modListTab.setNodeSupplier(loadInstanceFor(ModListPage::new)); - resourcePackTab.setNodeSupplier(loadInstanceFor(ResourcePackListPage::new)); - worldListTab.setNodeSupplier(loadInstanceFor(WorldListPage::new)); - schematicsTab.setNodeSupplier(loadInstanceFor(SchematicsPage::new)); + // Child tabs subscribe to instanceProperty() themselves and reload on change. + gameSettingsTab.setNodeSupplier(() -> new GameSettingsPage<>(GameSettings.Instance.class, instance)); + installerListTab.setNodeSupplier(() -> new InstallerListPage(instance)); + modListTab.setNodeSupplier(() -> new ModListPage(instance)); + resourcePackTab.setNodeSupplier(() -> new ResourcePackListPage(instance)); + worldListTab.setNodeSupplier(() -> new WorldListPage(instance)); + schematicsTab.setNodeSupplier(() -> new SchematicsPage(instance)); tab = new TabHeader(transitionPane, gameSettingsTab, installerListTab, modListTab, resourcePackTab, worldListTab, schematicsTab); tab.select(gameSettingsTab); @@ -92,31 +99,64 @@ public GameInstancePage() { addEventHandler(Navigator.NavigationEvent.NAVIGATED, this::onNavigated); addEventHandler(WorkingDirChangedEvent.EVENT_TYPE, event -> { - if (this.instanceReference.get() != null) { - if (installerListTab.isInitialized()) - installerListTab.getNode().loadInstance(getRepository(), getInstanceId()); - if (modListTab.isInitialized()) - modListTab.getNode().loadInstance(getRepository(), getInstanceId()); - if (resourcePackTab.isInitialized()) - resourcePackTab.getNode().loadInstance(getRepository(), getInstanceId()); - if (worldListTab.isInitialized()) - worldListTab.getNode().loadInstance(getRepository(), getInstanceId()); - if (schematicsTab.isInitialized()) - schematicsTab.getNode().loadInstance(getRepository(), getInstanceId()); + HMCLGameInstance.Optional current = this.instance.get(); + if (current != null) { + // Re-resolve so subscribed tabs reload from the current snapshot. + this.instance.set(current.refreshed()); } }); - listenerHolder.add(EventBus.EVENT_BUS.channel(RefreshedGameInstancesEvent.class).registerWeak(event -> checkSelectedInstance(), EventPriority.HIGHEST)); + // Page chrome that depends on the current instance. + listenerHolder.add(FXUtils.onWeakChange(instance, current -> { + observeRepository(current); + if (current == null) { + return; + } + HMCLGameInstance gameInstance = current.instance(); + currentInstanceUpgradable.set(gameInstance != null && gameInstance.isModpack()); + if (gameInstance != null) { + preferredInstanceId = gameInstance.getId(); + } + })); + } + + /// Observes snapshot publications for the repository associated with the current page context. + /// + /// @param current the current page context, or `null` when the page has no context + private void observeRepository(HMCLGameInstance.@Nullable Optional current) { + @Nullable HMCLGameRepository repository = current != null ? current.repository() : null; + if (repository == observedRepository) { + return; + } + + if (observedRepository != null) { + observedRepository.snapshotProperty().removeListener(repositorySnapshotListener); + } + observedRepository = repository; + if (repository != null) { + repository.snapshotProperty().addListener(repositorySnapshotListener); + } + } + + /// Returns the current instance context for this page and its tabs. + /// + /// Child tabs subscribe to this property and reload when it changes. The page only publishes + /// context; it does not push `loadInstance` into children. + /// + /// @return the observable instance context + public ReadOnlyObjectProperty instanceProperty() { + return instance; } private void checkSelectedInstance() { runInFX(() -> { - if (this.instanceReference.get() == null) return; - HMCLGameRepository repository = this.instanceReference.get().repository(); - @Nullable GameInstanceID instanceId = this.instanceReference.get().instanceId(); - if (instanceId == null || !repository.hasInstance(instanceId)) { + HMCLGameInstance.Optional current = this.instance.get(); + if (current == null) return; + current = current.refreshed(); + this.instance.set(current); + if (current.isEmpty()) { if (preferredInstanceId != null) { - loadInstance(preferredInstanceId, repository); + loadInstance(preferredInstanceId, current.repository()); } else { fireEvent(new PageCloseEvent()); } @@ -124,56 +164,28 @@ private void checkSelectedInstance() { }); } - private Supplier loadInstanceFor(Supplier nodeSupplier) { - return () -> { - T node = nodeSupplier.get(); - if (instanceReference.get() != null) { - if (node instanceof GameInstancePage.GameInstanceLoadable loadable) { - @Nullable GameInstanceID instanceId = instanceReference.get().instanceId(); - loadable.loadInstance(instanceReference.get().repository(), instanceId); - } - } - return node; - }; - } - public void showInstanceSettings() { tab.select(gameSettingsTab, false); } public void setInstance(GameInstanceID instanceId, HMCLGameRepository repository) { - this.instanceReference.set(new HMCLGameRepository.InstanceReference(repository, instanceId)); + this.instance.set(HMCLGameInstance.Optional.of(repository, instanceId)); } public void loadInstance(GameInstanceID instanceId, HMCLGameRepository repository) { // If we jumped to game list page and deleted this version // and back to this page, we should return to main page. - if (this.instanceReference.get() != null && (!getRepository().isLoaded() || + if (this.instance.get() != null && (!getRepository().isLoaded() || !getRepository().hasInstance(instanceId))) { Platform.runLater(() -> fireEvent(new PageCloseEvent())); return; } - setInstance(instanceId, repository); - preferredInstanceId = instanceId; - - if (gameSettingsTab.isInitialized()) - gameSettingsTab.getNode().loadInstance(repository, instanceId); - if (installerListTab.isInitialized()) - installerListTab.getNode().loadInstance(repository, instanceId); - if (modListTab.isInitialized()) - modListTab.getNode().loadInstance(repository, instanceId); - if (resourcePackTab.isInitialized()) - resourcePackTab.getNode().loadInstance(repository, instanceId); - if (worldListTab.isInitialized()) - worldListTab.getNode().loadInstance(repository, instanceId); - if (schematicsTab.isInitialized()) - schematicsTab.getNode().loadInstance(repository, instanceId); - currentInstanceUpgradable.set(repository.isModpack(instanceId)); + this.instance.set(HMCLGameInstance.Optional.of(repository, instanceId)); } private void onNavigated(Navigator.NavigationEvent event) { - if (this.instanceReference.get() == null) + if (this.instance.get() == null) throw new IllegalStateException(); // If we jumped to game list page and deleted this version @@ -188,11 +200,18 @@ private void onNavigated(Navigator.NavigationEvent event) { } private void onBrowse(String sub) { - FXUtils.openFolder(getRepository().getRunDirectory(getInstanceId()).resolve(sub)); + HMCLGameInstance gameInstance = requireGameInstance(); + if (gameInstance == null) { + return; + } + FXUtils.openFolder(gameInstance.getRunDirectory().resolve(sub)); } private void redownloadAssetIndex() { - Instances.updateGameAssets(getRepository(), getInstanceId()); + HMCLGameInstance gameInstance = requireGameInstance(); + if (gameInstance != null) { + Instances.updateGameAssets(gameInstance); + } } private void clearLibraries() { @@ -209,9 +228,9 @@ private void clearLibraries() { private void clearAssets() { Path assetsDir = getRepository().getBaseDirectory().resolve("assets"); - HMCLGameRepository.InstanceReference currentInstanceReference = instanceReference.get(); - Path resourcesDir = currentInstanceReference != null - ? getRepository().getRunDirectory(currentInstanceReference.instanceId()).resolve("resources") + HMCLGameInstance.Optional current = instance.get(); + Path resourcesDir = current != null && current.isPresent() + ? current.instance().getRunDirectory().resolve("resources") : null; Task.runAsync(Schedulers.io(), () -> { @@ -227,46 +246,79 @@ private void clearAssets() { } private void clearJunkFiles() { - Instances.cleanInstance(getRepository(), getInstanceId()); + HMCLGameInstance gameInstance = requireGameInstance(); + if (gameInstance != null) { + Instances.cleanInstance(gameInstance); + } } private void testGame() { - Instances.testGame(getRepository(), getInstanceId()); + HMCLGameInstance gameInstance = requireGameInstance(); + if (gameInstance != null) { + Instances.testGame(gameInstance); + } } private void updateGame() { - Instances.updateInstance(getRepository(), getInstanceId()); + HMCLGameInstance gameInstance = requireGameInstance(); + if (gameInstance != null) { + Instances.updateInstance(gameInstance); + } } private void generateLaunchScript() { - Instances.generateLaunchScript(getRepository(), getInstanceId()); + HMCLGameInstance gameInstance = requireGameInstance(); + if (gameInstance != null) { + Instances.generateLaunchScript(gameInstance); + } } private void export() { - Instances.exportInstance(getRepository(), getInstanceId()); + HMCLGameInstance gameInstance = requireGameInstance(); + if (gameInstance != null) { + Instances.exportInstance(gameInstance); + } } private void rename() { - Instances.renameInstance(getRepository(), getInstanceId()) - .thenApply(newInstanceId -> this.preferredInstanceId = new GameInstanceID(newInstanceId)); + HMCLGameInstance gameInstance = requireGameInstance(); + if (gameInstance != null) { + Instances.renameInstance(gameInstance) + .thenApply(newInstanceId -> this.preferredInstanceId = new GameInstanceID(newInstanceId)); + } } private void remove() { - Instances.deleteInstance(getRepository(), getInstanceId()); + HMCLGameInstance gameInstance = requireGameInstance(); + if (gameInstance != null) { + Instances.deleteInstance(gameInstance); + } } private void duplicate() { - Instances.duplicateInstance(getRepository(), getInstanceId()); + HMCLGameInstance gameInstance = requireGameInstance(); + if (gameInstance != null) { + Instances.duplicateInstance(gameInstance); + } + } + + private @Nullable HMCLGameInstance requireGameInstance() { + HMCLGameInstance.Optional current = instance.get(); + return current != null ? current.instance() : null; } public HMCLGameRepository getRepository() { - return Optional.ofNullable(instanceReference.get()).map(HMCLGameRepository.InstanceReference::repository).orElse(null); + HMCLGameInstance.Optional current = instance.get(); + return current != null ? current.repository() : null; } public @Nullable GameInstanceID getInstanceId() { - return Optional.ofNullable(instanceReference.get()) - .map(HMCLGameRepository.InstanceReference::instanceId) - .orElse(null); + HMCLGameInstance.Optional current = instance.get(); + return current != null ? current.instanceId() : null; + } + + public HMCLGameInstance.Optional getInstance() { + return instance.get(); } @Override @@ -350,7 +402,7 @@ protected Skin(GameInstancePage control) { control.state.bind(Bindings.createObjectBinding(() -> State.fromTitle(i18n("instance.manage.manage.title", getSkinnable().getInstanceId()), -1), - getSkinnable().instanceReference)); + getSkinnable().instance)); //control.transitionPane.getStyleClass().add("gray-background"); //FXUtils.setOverflowHidden(control.transitionPane, 8); @@ -358,12 +410,4 @@ protected Skin(GameInstancePage control) { } } - /// Loads page content for a game instance in a repository. - public interface GameInstanceLoadable { - /// Loads page content for the given repository and game instance. - /// - /// @param repository the repository containing the game instance - /// @param instanceId the game instance ID, or `null` when only repository context is available - void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId); - } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameItem.java index 99594fa77f7..f65c476f4b1 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameItem.java @@ -19,23 +19,20 @@ import javafx.beans.property.*; import javafx.scene.image.Image; -import org.jackhuang.hmcl.download.LibraryAnalyzer; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.modpack.ModpackConfiguration; import org.jackhuang.hmcl.setting.GameDirectory; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.util.i18n.I18n; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.util.Objects; -import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.MINECRAFT; import static org.jackhuang.hmcl.util.Lang.threadPool; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -43,9 +40,7 @@ public class GameItem { private static final ThreadPoolExecutor POOL_VERSION_RESOLVE = threadPool("VersionResolve", true, 1, 10, TimeUnit.SECONDS); - protected final HMCLGameRepository repository; - protected final String id; - protected final GameInstanceID instanceId; + protected final HMCLGameInstance gameInstance; private boolean initialized = false; private StringProperty title; @@ -53,22 +48,28 @@ public class GameItem { private StringProperty subtitle; private ObjectProperty image; - public GameItem(HMCLGameRepository repository, GameInstanceID instanceId) { - this.repository = repository; - this.id = instanceId.toString(); - this.instanceId = instanceId; + public GameItem(HMCLGameInstance gameInstance) { + this.gameInstance = gameInstance; } public GameDirectory getGameDirectory() { - return repository.getGameDirectory(); + return gameInstance.getRepository().getGameDirectory(); } public HMCLGameRepository getRepository() { - return repository; + return gameInstance.getRepository(); + } + + public GameInstanceID getInstanceId() { + return gameInstance.getId(); + } + + public HMCLGameInstance getGameInstance() { + return gameInstance; } public String getId() { - return id; + return gameInstance.getId().toString(); } private void init() { @@ -86,15 +87,16 @@ record Result(@Nullable String gameVersion, @Nullable String tag) { CompletableFuture.supplyAsync(() -> { // GameVersion.minecraftVersion() is a time-costing job (up to ~200 ms) - Optional gameVersion = repository.getGameVersion(instanceId); - String modPackVersion = null; + GameVersionNumber version = gameInstance.getVersion(); + @Nullable String gameVersion = version == GameVersionNumber.unknown() ? null : version.toString(); + @Nullable String modPackVersion = null; try { - ModpackConfiguration config = repository.readModpackConfiguration(instanceId); + @Nullable ModpackConfiguration config = gameInstance.readModpackConfiguration(); modPackVersion = config != null ? config.getVersion() : null; } catch (IOException e) { - LOG.warning("Failed to read modpack configuration from " + id, e); + LOG.warning("Failed to read modpack configuration from " + getId(), e); } - return new Result(gameVersion.orElse(null), modPackVersion); + return new Result(gameVersion, modPackVersion); }, POOL_VERSION_RESOLVE).whenCompleteAsync((result, exception) -> { if (exception == null) { if (result.tag != null) { @@ -102,26 +104,25 @@ record Result(@Nullable String gameVersion, @Nullable String tag) { } StringBuilder libraries = new StringBuilder(Objects.requireNonNullElse(result.gameVersion, i18n("message.unknown"))); - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(repository.getResolvedInstanceManifest(instanceId), result.gameVersion); - for (LibraryAnalyzer.LibraryMark mark : analyzer) { - String libraryId = mark.getLibraryId(); - String libraryVersion = mark.getLibraryVersion(); - if (libraryId.equals(MINECRAFT.getPatchId())) continue; - if (I18n.hasKey("install.installer." + libraryId)) { - libraries.append(", ").append(i18n("install.installer." + libraryId)); - if (libraryVersion != null) - libraries.append(": ").append(libraryVersion.replaceAll("(?i)" + libraryId, "")); + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(gameInstance.getResolvedManifest(), result.gameVersion); + for (GameComponentAnalyzer.Mark mark : analyzer) { + if (mark.componentType() == GameComponentType.GAME) continue; + + if (I18n.hasKey("install.installer." + mark.componentType().getPatchId())) { + libraries.append(", ").append(i18n("install.installer." + mark.componentType().getPatchId())); + if (mark.version() != null) + libraries.append(": ").append(mark.version().replaceAll("(?i)" + mark.componentType().getPatchId(), "")); } } subtitle.set(libraries.toString()); } else { - LOG.warning("Failed to read version info from " + id, exception); + LOG.warning("Failed to read version info from " + getId(), exception); } }, Schedulers.javafx()); - title.set(id); - image.set(repository.getInstanceIconImage(instanceId)); + title.set(getId()); + image.set(gameInstance.getIconImage()); } public ReadOnlyStringProperty titleProperty() { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListCell.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListCell.java index 7a7d9ecc5d6..d69aa6b841c 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListCell.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListCell.java @@ -29,7 +29,6 @@ import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import javafx.scene.layout.Region; -import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.ui.construct.*; @@ -70,7 +69,7 @@ public void fire() { fireEvent(new ActionEvent()); GameListItem item = GameListCell.this.getItem(); if (item != null) { - item.getRepository().setSelectedInstance(new GameInstanceID(item.getId())); + item.getRepository().setSelectedInstance(item.getGameInstance()); } } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListItem.java index 16ae0a4cc62..402e0124e43 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListItem.java @@ -22,22 +22,25 @@ import javafx.beans.property.ReadOnlyBooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.setting.GameDirectoryManager; - -import java.util.Objects; +import org.jetbrains.annotations.Nullable; public class GameListItem extends GameItem { private final boolean isModpack; private final BooleanProperty selected = new SimpleBooleanProperty(this, "selected"); - public GameListItem(HMCLGameRepository repository, GameInstanceID instanceId) { - super(repository, instanceId); - this.isModpack = repository.isModpack(instanceId); + public GameListItem(HMCLGameInstance gameInstance) { + super(gameInstance); + HMCLGameRepository repository = gameInstance.getRepository(); + GameInstanceID instanceId = gameInstance.getId(); + this.isModpack = gameInstance.isModpack(); selected.bind(Bindings.createBooleanBinding( () -> { if (repository.getGameDirectory() != GameDirectoryManager.getSelectedGameDirectory()) return false; - return Objects.equals(repository.getSelectedInstance(), instanceId); + @Nullable HMCLGameInstance selectedInstance = repository.getSelectedInstance(); + return selectedInstance != null && selectedInstance.getId().equals(instanceId); }, GameDirectoryManager.selectedGameDirectoryProperty(), GameDirectoryManager.selectedInstanceProperty())); @@ -48,39 +51,39 @@ public ReadOnlyBooleanProperty selectedProperty() { } public void rename() { - Instances.renameInstance(repository, instanceId); + Instances.renameInstance(gameInstance); } public void duplicate() { - Instances.duplicateInstance(repository, instanceId); + Instances.duplicateInstance(gameInstance); } public void remove() { - Instances.deleteInstance(repository, instanceId); + Instances.deleteInstance(gameInstance); } public void export() { - Instances.exportInstance(repository, instanceId); + Instances.exportInstance(gameInstance); } public void browse() { - Instances.openFolder(repository, instanceId); + Instances.openFolder(gameInstance); } public void testGame() { - Instances.testGame(repository, instanceId); + Instances.testGame(gameInstance); } public void launch() { - Instances.launch(repository, instanceId); + Instances.launch(gameInstance); } public void modifyGameSettings() { - Instances.modifyGameSettings(repository, instanceId); + Instances.modifyGameSettings(gameInstance); } public void generateLaunchScript() { - Instances.generateLaunchScript(repository, instanceId); + Instances.generateLaunchScript(gameInstance); } public boolean canUpdate() { @@ -88,6 +91,6 @@ public boolean canUpdate() { } public void update() { - Instances.updateInstance(repository, instanceId); + Instances.updateInstance(gameInstance); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPage.java index 55a6118b394..3e6635c851b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPage.java @@ -156,11 +156,13 @@ private void loadVersions(HMCLGameRepository repository) { setLoading(true); setFailedReason(null); - List versionItems = repository.getDisplayInstanceManifests().map(instance -> new GameListItem(repository, instance.id())).toList(); + List instanceItems = repository.getDisplayInstances() + .map(GameListItem::new) + .toList(); - sourceList.setAll(versionItems); + sourceList.setAll(instanceItems); - if (versionItems.isEmpty()) { + if (instanceItems.isEmpty()) { setFailedReason(i18n("instance.empty.hint")); } @@ -176,12 +178,12 @@ private Predicate createPredicate(String searchText) { String regex = searchText.substring("regex:".length()); try { Pattern pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE); - return item -> pattern.matcher(item.id).find(); + return item -> pattern.matcher(item.getId()).find(); } catch (PatternSyntaxException e) { return item -> false; } } else { - return item -> item.id.toLowerCase(Locale.ROOT).contains(searchText.toLowerCase(Locale.ROOT)); + return item -> item.getId().toLowerCase(Locale.ROOT).contains(searchText.toLowerCase(Locale.ROOT)); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPopupMenu.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPopupMenu.java index b95dd77da72..29e26bd7fe0 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPopupMenu.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPopupMenu.java @@ -33,9 +33,7 @@ import javafx.scene.layout.BorderPane; import javafx.scene.layout.Region; import javafx.scene.layout.StackPane; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.construct.ImageContainer; import org.jackhuang.hmcl.ui.construct.RipplerContainer; @@ -46,24 +44,40 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +/// Displays game instances in a popup selection list. +/// /// @author Glavo public final class GameListPopupMenu extends StackPane { /// Shows an instance selection popup relative to its owner. + /// + /// @param owner the node used to position the popup + /// @param vAlign the popup's vertical alignment relative to `owner` + /// @param hAlign the popup's horizontal alignment relative to `owner` + /// @param initOffsetX the horizontal offset from the aligned position + /// @param initOffsetY the vertical offset from the aligned position + /// @param instances the instances to copy into the popup, in display order public static void show(Node owner, JFXPopup.PopupVPosition vAlign, JFXPopup.PopupHPosition hAlign, double initOffsetX, double initOffsetY, - HMCLGameRepository repository, List versions) { - showAndGetPopup(owner, vAlign, hAlign, initOffsetX, initOffsetY, repository, versions); + List instances) { + showAndGetPopup(owner, vAlign, hAlign, initOffsetX, initOffsetY, instances); } /// Shows and returns an instance selection popup relative to its owner. + /// + /// @param owner the node used to position the popup + /// @param vAlign the popup's vertical alignment relative to `owner` + /// @param hAlign the popup's horizontal alignment relative to `owner` + /// @param initOffsetX the horizontal offset from the aligned position + /// @param initOffsetY the vertical offset from the aligned position + /// @param instances the instances to copy into the popup, in display order + /// @return the shown popup public static JFXPopup showAndGetPopup(Node owner, JFXPopup.PopupVPosition vAlign, JFXPopup.PopupHPosition hAlign, double initOffsetX, double initOffsetY, - HMCLGameRepository repository, List versions) { + List instances) { GameListPopupMenu menu = new GameListPopupMenu(); - menu.getItems().setAll(versions.stream() - .filter(it -> repository.hasInstance(it.id())) - .map(it -> new GameItem(repository, it.id())) + menu.getItems().setAll(instances.stream() + .map(GameItem::new) .toList()); JFXPopup popup = new JFXPopup(menu); popup.show(owner, vAlign, hAlign, initOffsetX, initOffsetY); @@ -138,7 +152,7 @@ public Cell(ListView listView) { FXUtils.onClicked(rootPane, () -> { GameItem item = getItem(); if (item != null) { - item.getRepository().setSelectedInstance(new GameInstanceID(item.getId())); + item.getRepository().setSelectedInstance(item.getGameInstance()); if (getScene().getWindow() instanceof JFXPopup popup) popup.hide(); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/InstallerListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/InstallerListPage.java index d97a864bf25..a2a7a7f9ebd 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/InstallerListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/InstallerListPage.java @@ -17,13 +17,12 @@ */ package org.jackhuang.hmcl.ui.instances; -import javafx.application.Platform; +import javafx.beans.value.ObservableValue; import javafx.scene.Node; import javafx.scene.control.Skin; import javafx.stage.FileChooser; -import org.jackhuang.hmcl.download.LibraryAnalyzer; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.GameInstanceManifest; +import org.jackhuang.hmcl.game.GameComponentAnalyzer; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; @@ -39,22 +38,30 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; -import java.util.concurrent.CompletableFuture; +import java.util.Objects; import static org.jackhuang.hmcl.ui.FXUtils.runInFX; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; -public class InstallerListPage extends ListPageBase implements GameInstancePage.GameInstanceLoadable { - private HMCLGameRepository repository; - private GameInstanceID instanceId; - private GameInstanceManifest manifest; - private String gameVersion; +public class InstallerListPage extends ListPageBase { + private final WeakListenerHolder listenerHolder = new WeakListenerHolder(); + private @Nullable HMCLGameInstance gameInstance; - { + /// Creates an installer list that reloads when `instanceContext` changes. + /// + /// @param instanceContext the parent page's instance property + public InstallerListPage(ObservableValue instanceContext) { + Objects.requireNonNull(instanceContext, "instanceContext"); FXUtils.applyDragListener(this, it -> Arrays.asList("jar", "exe").contains(FileUtils.getExtension(it)), mods -> { if (!mods.isEmpty()) doInstallOffline(mods.get(0)); }); + + listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> { + if (current != null) { + loadInstance(current); + } + })); } @Override @@ -62,78 +69,74 @@ protected Skin createDefaultSkin() { return new InstallerListPageSkin(); } - @Override - public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) { - this.repository = repository; - this.instanceId = instanceId; - this.manifest = repository.getInstanceManifest(instanceId); - this.gameVersion = null; - - CompletableFuture.supplyAsync(() -> { - gameVersion = repository.getGameVersion(manifest).orElse(null); - - return LibraryAnalyzer.analyze(repository.getResolvedInstanceManifest(instanceId), gameVersion); - }).thenAcceptAsync(analyzer -> { + public void loadInstance(HMCLGameInstance.Optional instance) { + this.gameInstance = instance.instance(); + if (gameInstance == null) { itemsProperty().clear(); + return; + } - InstallerItem.InstallerItemGroup group = new InstallerItem.InstallerItemGroup(gameVersion, InstallerItem.Style.LIST_ITEM); + HMCLGameRepository repository = gameInstance.getRepository(); - // Conventional libraries: game, fabric, legacyfabric, forge, cleanroom, neoforge, liteloader, optifine - for (InstallerItem item : group.getLibraries()) { - String libraryId = item.getLibraryId(); + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(gameInstance.getResolvedManifest(), gameInstance.getVersion().toString()); - // Skip fabric-api and quilt-api and legacyfabric-api - if (libraryId.endsWith("-api")) { - continue; - } + itemsProperty().clear(); - String libraryVersion = analyzer.getVersion(libraryId).orElse(null); + InstallerItem.InstallerItemGroup group = new InstallerItem.InstallerItemGroup(gameInstance.getVersion(), InstallerItem.Style.LIST_ITEM); - if (libraryVersion != null) { - item.versionProperty().set(new InstallerItem.InstalledState( - libraryVersion, - analyzer.getLibraryStatus(libraryId) != LibraryAnalyzer.LibraryMark.LibraryStatus.CLEAR, - false - )); - } else { - item.versionProperty().set(null); - } + // Conventional libraries: game, fabric, legacyfabric, forge, cleanroom, neoforge, liteloader, optifine + for (InstallerItem item : group.getLibraries()) { - item.setOnInstall(() -> { - Controllers.getDecorator().startWizard(new UpdateInstallerWizardProvider(repository, gameVersion, manifest, libraryId, libraryVersion)); - }); + // Skip fabric-api and quilt-api and legacyfabric-api + if (item.getComponentType().getPatchId().endsWith("-api")) { + continue; + } - item.setOnRemove(() -> repository.getDependency().removeLibraryAsync(manifest, libraryId) - .thenComposeAsync(repository::saveAsync) - .withComposeAsync(repository.refreshAsync()) - .withRunAsync(Schedulers.javafx(), () -> loadInstance(this.repository, this.instanceId)) - .start()); + String libraryVersion = analyzer.getVersion(item.getComponentType()); - itemsProperty().add(item); + if (libraryVersion != null) { + item.versionProperty().set(new InstallerItem.InstalledState( + libraryVersion, + !analyzer.isClear(item.getComponentType()), + false + )); + } else { + item.versionProperty().set(null); } - // other third-party libraries which are unable to manage. - for (LibraryAnalyzer.LibraryMark mark : analyzer) { - String libraryId = mark.getLibraryId(); - String libraryVersion = mark.getLibraryVersion(); - if ("mcbbs".equals(libraryId)) - continue; - - // we have done this library above. - if (LibraryAnalyzer.LibraryType.fromPatchId(libraryId) != null) - continue; - - InstallerItem installerItem = new InstallerItem(libraryId, InstallerItem.Style.LIST_ITEM); - installerItem.versionProperty().set(new InstallerItem.InstalledState(libraryVersion, false, false)); - installerItem.setOnRemove(() -> repository.getDependency().removeLibraryAsync(manifest, libraryId) - .thenComposeAsync(repository::saveAsync) - .withComposeAsync(repository.refreshAsync()) - .withRunAsync(Schedulers.javafx(), () -> loadInstance(this.repository, this.instanceId)) - .start()); - - itemsProperty().add(installerItem); - } - }, Platform::runLater); + item.setOnInstall(() -> { + Controllers.getDecorator().startWizard(new UpdateInstallerWizardProvider(gameInstance, item.getComponentType().getPatchId(), libraryVersion)); + }); + + item.setOnRemove(() -> repository.getDependency().removeLibraryAsync(gameInstance.getManifest(), item.getComponentType()) + .thenComposeAsync(repository::saveAsync) + .withComposeAsync(repository.refreshAsync()) + .withRunAsync(Schedulers.javafx(), () -> reloadCurrentInstance()) + .start()); + + itemsProperty().add(item); + } + + // other third-party libraries which are unable to manage. + for (GameComponentAnalyzer.Mark mark : analyzer) { + // we have done this library above. + + InstallerItem installerItem = new InstallerItem(mark.componentType(), InstallerItem.Style.LIST_ITEM); + installerItem.versionProperty().set(new InstallerItem.InstalledState(mark.version(), false, false)); + installerItem.setOnRemove(() -> repository.getDependency().removeLibraryAsync(gameInstance.getManifest(), mark.componentType()) + .thenComposeAsync(repository::saveAsync) + .withComposeAsync(repository.refreshAsync()) + .withRunAsync(Schedulers.javafx(), this::reloadCurrentInstance) + .start()); + + itemsProperty().add(installerItem); + } + } + + private void reloadCurrentInstance() { + if (gameInstance != null) { + loadInstance(HMCLGameInstance.Optional.of(gameInstance.getRepository(), gameInstance.getId())); + } } public void installOffline() { @@ -144,7 +147,12 @@ public void installOffline() { } private void doInstallOffline(Path file) { - Task task = repository.getDependency().installLibraryAsync(manifest, file) + if (gameInstance == null) { + return; + } + + HMCLGameRepository repository = gameInstance.getRepository(); + Task task = repository.getDependency().installLibraryAsync(gameInstance.getManifest(), file) .thenComposeAsync(repository::saveAsync) .thenComposeAsync(repository.refreshAsync()); task.setName(i18n("install.installer.install_offline")); @@ -153,7 +161,7 @@ private void doInstallOffline(Path file) { public void onStop(boolean success, TaskExecutor executor) { runInFX(() -> { if (success) { - loadInstance(repository, instanceId); + reloadCurrentInstance(); Controllers.dialog(i18n("install.success")); } else { if (executor.getException() == null) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/Instances.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/Instances.java index 4d62e3263b3..b97821079af 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/Instances.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/Instances.java @@ -28,11 +28,7 @@ import org.jackhuang.hmcl.download.game.GameAssetDownloadTask; import org.jackhuang.hmcl.download.game.GameDownloadTask; import org.jackhuang.hmcl.download.game.GameLibrariesTask; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.HMCLGameRepository; -import org.jackhuang.hmcl.game.LauncherHelper; -import org.jackhuang.hmcl.game.QuickPlayOption; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.setting.*; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.Schedulers; @@ -52,6 +48,7 @@ import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.platform.OperatingSystem; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.net.URI; @@ -119,9 +116,11 @@ public static void downloadModpackImpl(DownloadProvider downloadProvider, HMCLGa ); } - public static void deleteInstance(HMCLGameRepository repository, GameInstanceID instanceId) { - boolean isIndependent = repository.getRunDirectory(instanceId).toAbsolutePath().normalize() - .equals(repository.getInstanceRoot(instanceId).toAbsolutePath().normalize()); + public static void deleteInstance(HMCLGameInstance gameInstance) { + HMCLGameRepository repository = gameInstance.getRepository(); + GameInstanceID instanceId = gameInstance.getId(); + boolean isIndependent = gameInstance.getRunDirectory().toAbsolutePath().normalize() + .equals(gameInstance.getInstanceRoot().toAbsolutePath().normalize()); String message = isIndependent ? i18n("instance.manage.remove.confirm.independent", instanceId) : i18n("instance.manage.remove.confirm.trash", instanceId, instanceId + "_removed"); @@ -139,7 +138,9 @@ public static void deleteInstance(HMCLGameRepository repository, GameInstanceID Controllers.confirmAction(message, i18n("message.warning"), MessageDialogPane.MessageType.WARNING, deleteButton); } - public static CompletableFuture renameInstance(HMCLGameRepository repository, GameInstanceID instanceId) { + public static CompletableFuture renameInstance(HMCLGameInstance gameInstance) { + HMCLGameRepository repository = gameInstance.getRepository(); + GameInstanceID instanceId = gameInstance.getId(); return Controllers.prompt(i18n("instance.manage.rename.message"), (newName, handler) -> { if (newName.equals(instanceId.toString())) { handler.resolve(); @@ -152,7 +153,7 @@ public static CompletableFuture renameInstance(HMCLGameRepository reposi repository.refreshAsync() .thenRunAsync(Schedulers.javafx(), () -> { if (repository.hasInstance(newInstanceId)) { - repository.setSelectedInstance(newInstanceId); + repository.setSelectedInstance(repository.getInstance(newInstanceId)); } }).start(); } else { @@ -163,12 +164,12 @@ public static CompletableFuture renameInstance(HMCLGameRepository reposi new Validator(i18n("install.new_game.already_exists"), newVersionName -> !repository.instanceIdConflicts(newVersionName) || newVersionName.equals(instanceId.toString()))); } - public static void exportInstance(HMCLGameRepository repository, GameInstanceID instanceId) { - Controllers.getDecorator().startWizard(new ExportWizardProvider(repository, instanceId), i18n("modpack.wizard")); + public static void exportInstance(HMCLGameInstance gameInstance) { + Controllers.getDecorator().startWizard(new ExportWizardProvider(gameInstance), i18n("modpack.wizard")); } - public static void openFolder(HMCLGameRepository repository, GameInstanceID instanceId) { - FXUtils.openFolder(repository.getRunDirectory(instanceId)); + public static void openFolder(HMCLGameInstance gameInstance) { + FXUtils.openFolder(gameInstance.getRunDirectory()); } public static void installFromJson(HMCLGameRepository repository, Path file) { @@ -203,7 +204,7 @@ public static void installFromJson(HMCLGameRepository repository, Path file) { .thenRunAsync(repository::refresh) .whenComplete(Schedulers.javafx(), (exception) -> { if (exception == null) { - repository.setSelectedInstance(new GameInstanceID(result)); + repository.setSelectedInstance(repository.getInstance(instanceId)); } else { Controllers.dialog( DownloadProviders.localizeErrorMessage(exception), i18n("install.failed"), MessageDialogPane.MessageType.ERROR); @@ -212,7 +213,9 @@ public static void installFromJson(HMCLGameRepository repository, Path file) { }, FileUtils.getNameWithoutExtension(file), new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidInstanceId), new Validator(i18n("install.new_game.already_exists"), newVersionName -> !repository.instanceIdConflicts(newVersionName))); } - public static void duplicateInstance(HMCLGameRepository repository, GameInstanceID instanceId) { + public static void duplicateInstance(HMCLGameInstance gameInstance) { + HMCLGameRepository repository = gameInstance.getRepository(); + GameInstanceID instanceId = gameInstance.getId(); Controllers.prompt( new PromptDialogPane.Builder(i18n("instance.manage.duplicate.prompt"), (res, handler) -> { String newInstanceName = ((PromptDialogPane.Builder.StringQuestion) res.get(1)).getValue(); @@ -238,33 +241,35 @@ public static void duplicateInstance(HMCLGameRepository repository, GameInstance .addQuestion(new PromptDialogPane.Builder.BooleanQuestion(i18n("instance.manage.duplicate.duplicate_save"), false))); } - public static void updateInstance(HMCLGameRepository repository, GameInstanceID instanceId) { - Controllers.getDecorator().startWizard(new ModpackInstallWizardProvider(repository, instanceId)); + public static void updateInstance(HMCLGameInstance gameInstance) { + Controllers.getDecorator().startWizard(new ModpackInstallWizardProvider(gameInstance.getRepository(), gameInstance.getId())); } - public static void updateGameAssets(HMCLGameRepository repository, GameInstanceID instanceId) { - TaskExecutor executor = new GameAssetDownloadTask(repository.getDependency(), repository.getInstanceManifest(instanceId), GameAssetDownloadTask.DOWNLOAD_INDEX_FORCIBLY, true) - .executor(); + public static void updateGameAssets(HMCLGameInstance gameInstance) { + TaskExecutor executor = new GameAssetDownloadTask( + gameInstance.getRepository().getDependency(), + gameInstance.getManifest(), + GameAssetDownloadTask.DOWNLOAD_INDEX_FORCIBLY, + true).executor(); Controllers.taskDialog(executor, i18n("instance.manage.redownload_assets_index"), TaskCancellationAction.NO_CANCEL); executor.start(); } - public static void cleanInstance(HMCLGameRepository repository, GameInstanceID instanceId) { + public static void cleanInstance(HMCLGameInstance gameInstance) { try { - repository.clean(instanceId); + gameInstance.getRepository().clean(gameInstance.getId()); } catch (IOException e) { LOG.warning("Unable to clean game directory", e); } } @SafeVarargs - public static void generateLaunchScript(HMCLGameRepository repository, GameInstanceID instanceId, Consumer... injecters) { - if (!checkVersionForLaunching(repository, instanceId)) - return; + public static void generateLaunchScript(HMCLGameInstance gameInstance, Consumer... injecters) { ensureSelectedAccount(account -> { + Path runDirectory = gameInstance.getRunDirectory(); FileChooser chooser = new FileChooser(); - if (Files.isDirectory(repository.getRunDirectory(instanceId))) - chooser.setInitialDirectory(repository.getRunDirectory(instanceId).toFile()); + if (Files.isDirectory(runDirectory)) + chooser.setInitialDirectory(runDirectory.toFile()); chooser.setTitle(i18n("instance.launch_script.save")); if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) { chooser.getExtensionFilters().add( @@ -282,7 +287,7 @@ public static void generateLaunchScript(HMCLGameRepository repository, GameInsta file = file.resolveSibling(file.getFileName().toString() + "." + defaultExt); } - LauncherHelper launcherHelper = new LauncherHelper(repository, account, instanceId); + LauncherHelper launcherHelper = new LauncherHelper(gameInstance, account); for (Consumer injecter : injecters) { injecter.accept(launcherHelper); } @@ -306,12 +311,29 @@ private static String getDefaultScriptExtension() { }; } + /// Launches the given instance after ensuring that an account is selected. + /// + /// If `gameInstance` is `null`, an error dialog is shown and no account selection or launch is + /// attempted. + /// + /// @param gameInstance the instance to launch, or `null` when no instance is available + /// @param injecters callbacks that configure the launcher before launch @SafeVarargs - public static void launch(HMCLGameRepository repository, GameInstanceID instanceId, Consumer... injecters) { - if (!checkVersionForLaunching(repository, instanceId)) + public static void launch(@Nullable HMCLGameInstance gameInstance, Consumer... injecters) { + if (gameInstance == null) { + JFXButton gotoDownload = new JFXButton(i18n("instance.empty.launch.goto_download")); + gotoDownload.getStyleClass().add("dialog-accept"); + gotoDownload.setOnAction(e -> Controllers.navigate(Controllers.getDownloadPage())); + + Controllers.confirmAction(i18n("instance.empty.launch"), i18n("launch.failed"), + MessageDialogPane.MessageType.ERROR, + gotoDownload, + null); return; + } + ensureSelectedAccount(account -> { - LauncherHelper launcherHelper = new LauncherHelper(repository, account, instanceId); + LauncherHelper launcherHelper = new LauncherHelper(gameInstance, account); for (Consumer injecter : injecters) { injecter.accept(launcherHelper); } @@ -319,43 +341,20 @@ public static void launch(HMCLGameRepository repository, GameInstanceID instance }); } - public static void testGame(HMCLGameRepository repository, GameInstanceID instanceId) { - launch(repository, instanceId, LauncherHelper::setTestMode); + public static void testGame(HMCLGameInstance gameInstance) { + launch(gameInstance, LauncherHelper::setTestMode); } - public static void launchAndEnterWorld(HMCLGameRepository repository, GameInstanceID instanceId, String worldFolderName) { - launch(repository, instanceId, launcherHelper -> + public static void launchAndEnterWorld(HMCLGameInstance gameInstance, String worldFolderName) { + launch(gameInstance, launcherHelper -> launcherHelper.setQuickPlayOption(new QuickPlayOption.SinglePlayer(worldFolderName))); } - public static void generateLaunchScriptForQuickEnterWorld(HMCLGameRepository repository, GameInstanceID instanceId, String worldFolderName) { - generateLaunchScript(repository, instanceId, launcherHelper -> + public static void generateLaunchScriptForQuickEnterWorld(HMCLGameInstance gameInstance, String worldFolderName) { + generateLaunchScript(gameInstance, launcherHelper -> launcherHelper.setQuickPlayOption(new QuickPlayOption.SinglePlayer(worldFolderName))); } - private static boolean checkVersionForLaunching(HMCLGameRepository repository, GameInstanceID instanceId) { - boolean unavailable; - if (instanceId == null || !repository.isLoaded()) { - unavailable = true; - } else { - unavailable = !repository.hasInstance(instanceId); - } - - if (unavailable) { - JFXButton gotoDownload = new JFXButton(i18n("instance.empty.launch.goto_download")); - gotoDownload.getStyleClass().add("dialog-accept"); - gotoDownload.setOnAction(e -> Controllers.navigate(Controllers.getDownloadPage())); - - Controllers.confirmAction(i18n("instance.empty.launch"), i18n("launch.failed"), - MessageDialogPane.MessageType.ERROR, - gotoDownload, - null); - return false; - } else { - return true; - } - } - private static void ensureSelectedAccount(Consumer action) { Account account = Accounts.getSelectedAccount(); if (SettingsManager.isNewlyCreated() && !AuthlibInjectorServers.getServers().isEmpty() && @@ -391,10 +390,11 @@ public static void modifyGlobalSettings(HMCLGameRepository repository) { Controllers.navigate(Controllers.getSettingsPage()); } - public static void modifyGameSettings(HMCLGameRepository repository, GameInstanceID instanceId) { - Controllers.getGameInstancePage().setInstance(instanceId, repository); + public static void modifyGameSettings(HMCLGameInstance gameInstance) { + Controllers.getGameInstancePage().setInstance(gameInstance.getId(), gameInstance.getRepository()); Controllers.getGameInstancePage().showInstanceSettings(); // VersionPage.loadVersion will be invoked after navigation Controllers.navigate(Controllers.getGameInstancePage()); } + } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java index cc11d1d7125..f95f745c893 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java @@ -17,13 +17,11 @@ */ package org.jackhuang.hmcl.ui.instances; +import javafx.beans.value.ObservableValue; import javafx.collections.ObservableList; import javafx.scene.control.Skin; import javafx.stage.FileChooser; -import org.jackhuang.hmcl.download.LibraryAnalyzer; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.addon.mod.LocalModFile; import org.jackhuang.hmcl.addon.mod.ModLoaderType; import org.jackhuang.hmcl.addon.mod.ModManager; @@ -34,16 +32,19 @@ import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.ListPageBase; +import org.jackhuang.hmcl.ui.WeakListenerHolder; import org.jackhuang.hmcl.ui.construct.MessageDialogPane; import org.jackhuang.hmcl.ui.construct.PageAware; import org.jackhuang.hmcl.util.TaskCancellationAction; import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.io.UncheckedIOException; import java.nio.file.Path; import java.util.*; +import java.util.Objects; import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.locks.ReentrantLock; @@ -51,17 +52,21 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; -public final class ModListPage extends ListPageBase implements GameInstancePage.GameInstanceLoadable, PageAware { +public final class ModListPage extends ListPageBase implements PageAware { private final ReentrantLock lock = new ReentrantLock(); + private final WeakListenerHolder listenerHolder = new WeakListenerHolder(); private ModManager modManager; - private HMCLGameRepository repository; - private GameInstanceID instanceId; + private @Nullable HMCLGameInstance gameInstance; private String gameVersion; final EnumSet supportedLoaders = EnumSet.noneOf(ModLoaderType.class); - public ModListPage() { + /// Creates a mod list that reloads when `instanceContext` changes. + /// + /// @param instanceContext the parent page's instance property + public ModListPage(ObservableValue instanceContext) { + Objects.requireNonNull(instanceContext, "instanceContext"); FXUtils.applyDragListener(this, it -> ModManager.MOD_EXTENSIONS.contains(FileUtils.getExtension(it).toLowerCase(Locale.ROOT)), mods -> { mods.forEach(it -> { try { @@ -72,6 +77,12 @@ public ModListPage() { }); loadMods(modManager); }); + + listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> { + if (current != null) { + loadInstance(current); + } + })); } @Override @@ -83,15 +94,16 @@ public void refresh() { loadMods(modManager); } - @Override - public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) { - this.repository = repository; - this.instanceId = instanceId; + public void loadInstance(HMCLGameInstance.Optional instance) { + this.gameInstance = instance.instance(); + if (gameInstance == null) { + return; + } - GameInstanceManifest resolved = repository.getResolvedInstanceManifest(instanceId).standaloneManifest(); - this.gameVersion = repository.getGameVersion(resolved).orElse(null); + GameInstanceManifest resolved = gameInstance.getResolvedManifest().standaloneManifest(); + this.gameVersion = gameInstance.getRepository().getGameVersion(resolved).orElse(null); - loadMods(repository.getModManager(instanceId)); + loadMods(gameInstance.getModManager()); } private void loadMods(ModManager modManager) { @@ -131,13 +143,13 @@ private void loadMods(ModManager modManager) { private void updateSupportedLoaders(ModManager modManager) { supportedLoaders.clear(); - LibraryAnalyzer analyzer = modManager.getLibraryAnalyzer(); + GameComponentAnalyzer analyzer = modManager.getComponentAnalyzer(); if (analyzer == null) { Collections.addAll(supportedLoaders, ModLoaderType.values()); return; } - for (LibraryAnalyzer.LibraryType type : LibraryAnalyzer.LibraryType.values()) { + for (GameComponentType type : GameComponentType.MOD_LOADERS) { if (type.isModLoader() && analyzer.has(type)) { ModLoaderType modLoaderType = type.getModLoaderType(); if (modLoaderType != null) { @@ -149,26 +161,26 @@ private void updateSupportedLoaders(ModManager modManager) { } } - if (analyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE) && "1.20.1".equals(gameVersion)) { + if (analyzer.has(GameComponentType.NEO_FORGE) && "1.20.1".equals(gameVersion)) { supportedLoaders.add(ModLoaderType.FORGE); } - if (analyzer.has(LibraryAnalyzer.LibraryType.QUILT)) { + if (analyzer.has(GameComponentType.QUILT)) { supportedLoaders.add(ModLoaderType.FABRIC); } - if (analyzer.has(LibraryAnalyzer.LibraryType.LEGACY_FABRIC)) { + if (analyzer.has(GameComponentType.LEGACY_FABRIC)) { supportedLoaders.add(ModLoaderType.FABRIC); } - if (analyzer.has(LibraryAnalyzer.LibraryType.FABRIC) && modManager.hasMod("kilt", ModLoaderType.FABRIC)) { + if (analyzer.has(GameComponentType.FABRIC) && modManager.hasMod("kilt", ModLoaderType.FABRIC)) { supportedLoaders.add(ModLoaderType.FORGE); supportedLoaders.add(ModLoaderType.NEO_FORGE); } // Sinytra Connector - if (analyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE) && (modManager.hasMod("connector", ModLoaderType.NEO_FORGE) || modManager.hasMod("connectormod", ModLoaderType.NEO_FORGE)) - || "1.20.1".equals(gameVersion) && analyzer.has(LibraryAnalyzer.LibraryType.FORGE) && modManager.hasMod("connectormod", ModLoaderType.FORGE)) { + if (analyzer.has(GameComponentType.NEO_FORGE) && (modManager.hasMod("connector", ModLoaderType.NEO_FORGE) || modManager.hasMod("connectormod", ModLoaderType.NEO_FORGE)) + || "1.20.1".equals(gameVersion) && analyzer.has(GameComponentType.FORGE) && modManager.hasMod("connectormod", ModLoaderType.FORGE)) { supportedLoaders.add(ModLoaderType.FABRIC); } } @@ -235,19 +247,25 @@ void disableSelected(ObservableList selectedItems } public void openModFolder() { - FXUtils.openFolder(repository.getRunDirectory(instanceId).resolve("mods")); + if (gameInstance != null) { + FXUtils.openFolder(gameInstance.getModsDirectory()); + } } public void checkUpdates(Collection mods) { Objects.requireNonNull(mods); - if (isLoading()) { + if (isLoading() || gameInstance == null) { return; } + HMCLGameInstance gameInstance = this.gameInstance; Runnable action = () -> Controllers.taskDialog(Task .composeAsync(() -> { - Optional gameVersion = repository.getGameVersion(instanceId); - return gameVersion.map(g -> new AddonCheckUpdatesTask<>(DownloadProviders.getDownloadProvider(), g, mods)).orElse(null); + GameVersionNumber version = gameInstance.getVersion(); + return version != GameVersionNumber.unknown() + ? new AddonCheckUpdatesTask<>( + DownloadProviders.getDownloadProvider(), version.toString(), mods) + : null; }) .whenComplete(Schedulers.javafx(), (result, exception) -> { if (exception instanceof CancellationException) return; @@ -262,7 +280,7 @@ public void checkUpdates(Collection mods) { .withStagesHints("update.checking"), i18n("addon.check_update"), TaskCancellationAction.NORMAL); - if (repository.isModpack(instanceId)) { + if (gameInstance.isModpack()) { Controllers.confirm( i18n("mods.update_modpack_mod.warning"), null, MessageDialogPane.MessageType.WARNING, @@ -273,7 +291,10 @@ public void checkUpdates(Collection mods) { } public void download() { - Controllers.getDownloadPage().showModDownloads().selectInstance(instanceId); + if (gameInstance == null) { + return; + } + Controllers.getDownloadPage().showModDownloads().selectInstance(gameInstance.getId()); Controllers.navigate(Controllers.getDownloadPage()); } @@ -287,14 +308,14 @@ public void rollback(LocalModFile from, LocalModFile to) { } public GameDirectory getGameDirectory() { - return this.repository.getGameDirectory(); + return gameInstance != null ? gameInstance.getRepository().getGameDirectory() : null; } public HMCLGameRepository getRepository() { - return this.repository; + return gameInstance != null ? gameInstance.getRepository() : null; } public GameInstanceID getInstanceId() { - return this.instanceId; + return gameInstance != null ? gameInstance.getId() : null; } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPageSkin.java index 154705397d1..e3645aab8e9 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPageSkin.java @@ -44,7 +44,7 @@ import org.jackhuang.hmcl.addon.mod.ModLoaderType; import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository; import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository; -import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.setting.DownloadProviders; import org.jackhuang.hmcl.setting.GameInstanceIconType; import org.jackhuang.hmcl.task.Schedulers; @@ -486,7 +486,7 @@ final class ModInfoDialog extends JFXDialogLayout { Controllers.navigate(new DownloadPage( repository instanceof CurseForgeRemoteAddonRepository ? HMCLLocalizedDownloadListPage.ofCurseForgeMod(null, false) : HMCLLocalizedDownloadListPage.ofModrinthMod(null, false), remoteAddon, - new HMCLGameRepository.InstanceReference(ModListPageSkin.this.getSkinnable().getRepository(), ModListPageSkin.this.getSkinnable().getInstanceId()), + HMCLGameInstance.Optional.of(ModListPageSkin.this.getSkinnable().getRepository(), ModListPageSkin.this.getSkinnable().getInstanceId()), org.jackhuang.hmcl.ui.download.DownloadPage.FOR_MOD )); }); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ResourcePackListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ResourcePackListPage.java index f98fa0fc46e..54813f4910a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ResourcePackListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ResourcePackListPage.java @@ -23,6 +23,7 @@ import javafx.beans.binding.Bindings; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.value.ObservableValue; import javafx.css.PseudoClass; import javafx.geometry.Insets; import javafx.geometry.Pos; @@ -43,9 +44,9 @@ import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository; import org.jackhuang.hmcl.addon.resourcepack.ResourcePackFile; import org.jackhuang.hmcl.addon.resourcepack.ResourcePackManager; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.setting.DownloadProviders; +import org.jackhuang.hmcl.setting.GameDirectoryManager; import org.jackhuang.hmcl.setting.SettingsManager; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; @@ -53,18 +54,21 @@ import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.ListPageBase; import org.jackhuang.hmcl.ui.SVG; +import org.jackhuang.hmcl.ui.WeakListenerHolder; import org.jackhuang.hmcl.ui.animation.ContainerAnimations; import org.jackhuang.hmcl.ui.animation.TransitionPane; import org.jackhuang.hmcl.ui.construct.*; import org.jackhuang.hmcl.util.Pair; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.TaskCancellationAction; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.file.Path; import java.util.*; +import java.util.Objects; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Predicate; import java.util.stream.Stream; @@ -76,7 +80,7 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; -public final class ResourcePackListPage extends ListPageBase implements GameInstancePage.GameInstanceLoadable { +public final class ResourcePackListPage extends ListPageBase { private static final String TIP_KEY = "resourcePackWarning"; private static @Nullable String getWarning(ResourcePackFile.Compatibility compatibility) { @@ -90,16 +94,26 @@ public final class ResourcePackListPage extends ListPageBase instanceContext) { + Objects.requireNonNull(instanceContext, "instanceContext"); FXUtils.applyDragListener(this, ResourcePackFile::isFileResourcePack, this::addFiles); + + listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> { + if (current != null) { + loadInstance(current); + } + })); } @Override @@ -107,11 +121,16 @@ protected Skin createDefaultSkin() { return new ResourcePackListPageSkin(this); } - @Override - public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) { - this.repository = repository; - this.instanceId = instanceId; - this.resourcePackManager = new ResourcePackManager(repository, instanceId); + public void loadInstance(HMCLGameInstance.Optional instance) { + this.gameInstance = instance.instance(); + if (gameInstance == null) { + this.resourcePackManager = null; + this.resourcePackDirectory = null; + getItems().clear(); + return; + } + + this.resourcePackManager = gameInstance.getResourcePackManager(); this.resourcePackDirectory = this.resourcePackManager.getDirectory(); refresh(); @@ -185,7 +204,10 @@ public void onAddFiles() { } private void onDownload() { - Controllers.getDownloadPage().showResourcePackDownloads().selectInstance(instanceId); + if (gameInstance == null) { + return; + } + Controllers.getDownloadPage().showResourcePackDownloads().selectInstance(gameInstance.getId()); Controllers.navigate(Controllers.getDownloadPage()); } @@ -232,10 +254,18 @@ private void removeSelected(List selectedItems) { } public void checkUpdates(Collection resourcePacks) { + HMCLGameInstance gameInstance = this.gameInstance; + if (gameInstance == null) { + return; + } + Runnable action = () -> Controllers.taskDialog(Task .composeAsync(() -> { - Optional gameVersion = repository.getGameVersion(instanceId); - return gameVersion.map(g -> new AddonCheckUpdatesTask<>(DownloadProviders.getDownloadProvider(), g, resourcePacks)).orElse(null); + GameVersionNumber version = gameInstance.getVersion(); + return version != GameVersionNumber.unknown() + ? new AddonCheckUpdatesTask<>(DownloadProviders.getDownloadProvider(), + version.toString(), resourcePacks) + : null; }) .whenComplete(Schedulers.javafx(), (result, exception) -> { if (exception != null || result == null) { @@ -249,7 +279,7 @@ public void checkUpdates(Collection resourcePacks) { .withStagesHints("update.checking"), i18n("addon.check_update"), TaskCancellationAction.NORMAL); - if (repository.isModpack(instanceId)) { + if (gameInstance.isModpack()) { Controllers.confirm( i18n("resourcepack.update_in_modpack.warning"), null, MessageDialogPane.MessageType.WARNING, @@ -645,7 +675,9 @@ private static final class ResourcePackInfoDialog extends JFXDialogLayout { ? HMCLLocalizedDownloadListPage.ofCurseForgeResourcePack(null, false) : HMCLLocalizedDownloadListPage.ofModrinthResourcePack(null, false), remoteAddon, - new HMCLGameRepository.InstanceReference(page.repository, page.instanceId), + page.gameInstance != null + ? HMCLGameInstance.Optional.of(page.gameInstance) + : HMCLGameInstance.Optional.empty(GameDirectoryManager.getSelectedRepository()), org.jackhuang.hmcl.ui.download.DownloadPage.FOR_RESOURCE_PACK )); }); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/SchematicsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/SchematicsPage.java index b7e21447c37..0a315136e36 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/SchematicsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/SchematicsPage.java @@ -20,6 +20,7 @@ import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXDialogLayout; import com.jfoenix.controls.JFXListView; +import javafx.beans.value.ObservableValue; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Node; @@ -34,8 +35,7 @@ import javafx.scene.layout.HBox; import javafx.scene.layout.StackPane; import javafx.stage.FileChooser; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.schematic.LitematicFile; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; @@ -54,6 +54,7 @@ import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.util.*; +import java.util.Objects; import java.util.stream.Stream; import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; @@ -63,7 +64,7 @@ /** * @author Glavo */ -public final class SchematicsPage extends ListPageBase implements GameInstancePage.GameInstanceLoadable { +public final class SchematicsPage extends ListPageBase { private static String translateAuthorName(String author) { if (I18n.isUseChinese() && "hsds".equals(author)) { @@ -72,14 +73,25 @@ private static String translateAuthorName(String author) { return author; } + private final WeakListenerHolder listenerHolder = new WeakListenerHolder(); private Path schematicsDirectory; private DirItem currentDirectory; - public SchematicsPage() { + /// Creates a schematics list that reloads when `instanceContext` changes. + /// + /// @param instanceContext the parent page's instance property + public SchematicsPage(ObservableValue instanceContext) { + Objects.requireNonNull(instanceContext, "instanceContext"); FXUtils.applyDragListener(this, file -> currentDirectory != null && Files.isRegularFile(file) && FileUtils.getName(file).endsWith(".litematic"), this::addFiles ); + + listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> { + if (current != null) { + loadInstance(current); + } + })); } @Override @@ -87,9 +99,9 @@ protected Skin createDefaultSkin() { return new SchematicsPageSkin(); } - @Override - public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) { - this.schematicsDirectory = repository.getSchematicsDirectory(instanceId); + public void loadInstance(HMCLGameInstance.Optional instance) { + HMCLGameInstance gameInstance = instance.instance(); + this.schematicsDirectory = gameInstance != null ? gameInstance.getSchematicsDirectory() : null; refresh(); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldListPage.java index fe02c80c66c..b77c0a00a62 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldListPage.java @@ -24,6 +24,7 @@ import javafx.beans.property.BooleanProperty; import javafx.beans.property.ReadOnlyBooleanProperty; import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.value.ObservableValue; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Node; @@ -36,8 +37,7 @@ import javafx.scene.layout.HBox; import javafx.scene.layout.StackPane; import javafx.stage.FileChooser; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.World; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; @@ -56,7 +56,7 @@ import java.time.Instant; import java.util.Arrays; import java.util.List; -import java.util.Optional; +import java.util.Objects; import static org.jackhuang.hmcl.ui.FXUtils.determineOptimalPopupPosition; import static org.jackhuang.hmcl.util.StringUtils.parseColorEscapes; @@ -64,23 +64,33 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; -public final class WorldListPage extends ListPageBase implements GameInstancePage.GameInstanceLoadable { +public final class WorldListPage extends ListPageBase { private final BooleanProperty showAll = new SimpleBooleanProperty(this, "showAll", false); + private final WeakListenerHolder listenerHolder = new WeakListenerHolder(); private Path savesDir; private List worlds; - private HMCLGameRepository repository; - private GameInstanceID instanceId; + private @Nullable HMCLGameInstance gameInstance; private final BooleanProperty supportQuickPlay = new SimpleBooleanProperty(this, "supportQuickPlay", false); private int refreshCount = 0; - public WorldListPage() { + /// Creates a world list that reloads when `instanceContext` changes. + /// + /// @param instanceContext the parent page's instance property + public WorldListPage(ObservableValue instanceContext) { + Objects.requireNonNull(instanceContext, "instanceContext"); FXUtils.applyDragListener(this, it -> "zip".equals(FileUtils.getExtension(it)), modpacks -> { installWorld(modpacks.get(0)); }); showAll.addListener(e -> updateWorldList()); + + listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> { + if (current != null) { + loadInstance(current); + } + })); } @Override @@ -88,21 +98,19 @@ protected Skin createDefaultSkin() { return new WorldListPageSkin(); } - @Override - public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) { - this.repository = repository; - this.instanceId = instanceId; - this.savesDir = repository.getSavesDirectory(instanceId); + public void loadInstance(HMCLGameInstance.Optional instance) { + this.gameInstance = instance.instance(); + this.savesDir = gameInstance != null ? gameInstance.getSavesDirectory() : null; refresh(); } private void updateWorldList() { - if (worlds == null) { + if (worlds == null || gameInstance == null) { getItems().clear(); } else if (showAll.get()) { getItems().setAll(worlds); } else { - GameVersionNumber gameVersion = repository.getGameVersion(instanceId).map(GameVersionNumber::asGameVersion).orElse(null); + GameVersionNumber gameVersion = gameInstance.getVersion(); getItems().setAll(worlds.stream() .filter(world -> world.getGameVersion() == null || world.getGameVersion().equals(gameVersion)) .toList()); @@ -110,15 +118,16 @@ private void updateWorldList() { } public void refresh() { - if (repository == null || instanceId == null) + if (gameInstance == null || savesDir == null) return; int currentRefresh = ++refreshCount; + HMCLGameInstance gameInstance = this.gameInstance; setLoading(true); Task.supplyAsync(Schedulers.io(), () -> { // Ensure the game version number is parsed - repository.getGameVersion(instanceId); + gameInstance.getVersion(); return World.getWorlds(savesDir); }).whenComplete(Schedulers.javafx(), (result, exception) -> { if (refreshCount != currentRefresh) { @@ -126,8 +135,7 @@ public void refresh() { return; } - Optional gameVersion = repository.getGameVersion(instanceId); - supportQuickPlay.set(World.supportQuickPlay(GameVersionNumber.asGameVersion(gameVersion))); + supportQuickPlay.set(World.supportQuickPlay(gameInstance.getVersion())); worlds = result; updateWorldList(); @@ -180,7 +188,9 @@ else if (e instanceof IOException && e.getCause() instanceof InvalidPathExceptio } private void showManagePage(World world) { - Controllers.navigate(new WorldManagePage(world, repository, instanceId)); + if (gameInstance != null) { + Controllers.navigate(new WorldManagePage(world, gameInstance)); + } } public void export(World world) { @@ -200,11 +210,15 @@ public void reveal(World world) { } public void launch(World world) { - Instances.launchAndEnterWorld(repository, instanceId, world.getFileName()); + if (gameInstance != null) { + Instances.launchAndEnterWorld(gameInstance, world.getFileName()); + } } public void generateLaunchScript(World world) { - Instances.generateLaunchScriptForQuickEnterWorld(repository, instanceId, world.getFileName()); + if (gameInstance != null) { + Instances.generateLaunchScriptForQuickEnterWorld(gameInstance, world.getFileName()); + } } public BooleanProperty showAllProperty() { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldManagePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldManagePage.java index fcc89fb16d9..af2113475ee 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldManagePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldManagePage.java @@ -24,8 +24,7 @@ import javafx.scene.layout.BorderPane; import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.World; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; @@ -36,13 +35,11 @@ import org.jackhuang.hmcl.ui.decorator.DecoratorPage; import org.jackhuang.hmcl.util.ChunkBaseApp; import org.jackhuang.hmcl.util.StringUtils; -import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.NotNull; import java.io.IOException; import java.nio.channels.FileChannel; import java.nio.file.Path; -import java.util.Optional; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -54,8 +51,7 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco private final World world; private final Path backupsDir; - private final HMCLGameRepository repository; - private final GameInstanceID instanceId; + private final HMCLGameInstance gameInstance; private final boolean supportQuickPlay; private FileChannel sessionLockChannel; @@ -70,11 +66,10 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco private final TabHeader.Tab worldBackupsTab = new TabHeader.Tab<>("worldBackupsPage"); private final TabHeader.Tab dataPackTab = new TabHeader.Tab<>("dataPackListPage"); - public WorldManagePage(World world, HMCLGameRepository repository, GameInstanceID instanceId) { + public WorldManagePage(World world, HMCLGameInstance gameInstance) { this.world = world; - this.backupsDir = repository.getBackupsDirectory(instanceId); - this.repository = repository; - this.instanceId = instanceId; + this.gameInstance = gameInstance; + this.backupsDir = gameInstance.getBackupsDirectory(); updateSessionLockChannel(); @@ -91,8 +86,7 @@ public WorldManagePage(World world, HMCLGameRepository repository, GameInstanceI this.state = new SimpleObjectProperty<>(new State(i18n("world.manage.title", StringUtils.parseColorEscapes(world.getWorldName())), null, true, true, true)); - Optional gameVersion = repository.getGameVersion(instanceId); - supportQuickPlay = World.supportQuickPlay(GameVersionNumber.asGameVersion(gameVersion)); + supportQuickPlay = World.supportQuickPlay(gameInstance.getVersion()); this.addEventHandler(Navigator.NavigationEvent.EXITED, this::onExited); this.addEventHandler(Navigator.NavigationEvent.NAVIGATED, this::onNavigated); @@ -151,11 +145,11 @@ public void onExited(Navigator.NavigationEvent event) { public void launch() { fireEvent(new PageCloseEvent()); - Instances.launchAndEnterWorld(repository, instanceId, world.getFileName()); + Instances.launchAndEnterWorld(gameInstance, world.getFileName()); } public void generateLaunchScript() { - Instances.generateLaunchScriptForQuickEnterWorld(repository, instanceId, world.getFileName()); + Instances.generateLaunchScriptForQuickEnterWorld(gameInstance, world.getFileName()); } @Override 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 0de3963470d..9493dd9fcec 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 @@ -20,6 +20,7 @@ import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.ReadOnlyObjectWrapper; import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.setting.GameSettings; import org.jackhuang.hmcl.setting.GameDirectoryManager; import org.jackhuang.hmcl.ui.FXUtils; @@ -48,7 +49,7 @@ public class LauncherSettingsPage extends DecoratorAnimatedPage implements Decor private final TransitionPane transitionPane = new TransitionPane(); public LauncherSettingsPage() { - gameTab.setNodeSupplier(() -> new GameSettingsPage<>(GameSettings.Preset.class)); + gameTab.setNodeSupplier(() -> new GameSettingsPage<>(GameSettings.Preset.class, null)); javaManagementTab.setNodeSupplier(JavaManagementPage::new); settingsTab.setNodeSupplier(SettingsPage::new); personalizationTab.setNodeSupplier(PersonalizationPage::new); @@ -59,7 +60,7 @@ public LauncherSettingsPage() { tab = new TabHeader(transitionPane, gameTab, javaManagementTab, settingsTab, personalizationTab, downloadTab, helpTab, feedbackTab, aboutTab); tab.select(gameTab); - addEventHandler(Navigator.NavigationEvent.NAVIGATED, event -> gameTab.getNode().loadInstance(GameDirectoryManager.getSelectedRepository(), null)); + addEventHandler(Navigator.NavigationEvent.NAVIGATED, event -> gameTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(GameDirectoryManager.getSelectedRepository()))); AdvancedListBox sideBar = new AdvancedListBox() .addNavigationDrawerTab(tab, gameTab, i18n("settings.type.global.manage"), SVG.STADIA_CONTROLLER, SVG.STADIA_CONTROLLER_FILL) @@ -89,7 +90,7 @@ public void onPageHidden() { } public void showGameSettings(HMCLGameRepository repository) { - gameTab.getNode().loadInstance(repository, null); + gameTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository)); tab.select(gameTab, false); } 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 72d5aff3710..3e833d78ae8 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 @@ -24,6 +24,7 @@ import javafx.animation.RotateTransition; import javafx.animation.Timeline; import javafx.beans.property.*; +import javafx.beans.value.ObservableValue; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.event.EventHandler; @@ -46,11 +47,8 @@ import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.download.VersionList; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.setting.DownloadProviders; -import org.jackhuang.hmcl.setting.GameDirectory; import org.jackhuang.hmcl.setting.GameDirectoryManager; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; @@ -76,9 +74,9 @@ import org.jackhuang.hmcl.util.platform.Platform; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.UnmodifiableView; import java.io.IOException; -import java.util.List; import java.util.Objects; import java.util.concurrent.CancellationException; import java.util.function.Consumer; @@ -89,17 +87,27 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; +/// Displays the launcher home controls for the currently selected game repository. public final class MainPage extends StackPane implements DecoratorPage { private static final String ANNOUNCEMENT = "announcement"; private final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(); - private final ObjectProperty<@Nullable GameInstanceID> currentGame = new SimpleObjectProperty<>(this, "currentGame"); + private final ObjectProperty<@Nullable HMCLGameInstance> currentGame = new SimpleObjectProperty<>(this, "currentGame"); private final BooleanProperty showUpdate = new SimpleBooleanProperty(this, "showUpdate"); private final BooleanProperty showUpdateDialog = new SimpleBooleanProperty(this, "showUpdateDialog"); private final ObjectProperty latestVersion = new SimpleObjectProperty<>(this, "latestVersion"); - private final ObservableList versions = FXCollections.observableArrayList(); - private HMCLGameRepository repository; + /// Mutable storage for visible instances from the selected repository's current snapshot. + private final ObservableList mutableInstances = FXCollections.observableArrayList(); + + /// Read-only observable view of [#mutableInstances]. + private final @UnmodifiableView ObservableList instances = + FXCollections.unmodifiableObservableList(mutableInstances); + + /// Current snapshot of the repository selected by [GameDirectoryManager]. + private final ObservableValue selectedRepositorySnapshot = + BindingMapping.of(GameDirectoryManager.selectedRepositoryProperty()) + .flatMap(HMCLGameRepository::snapshotProperty); private TransitionPane announcementPane; private final StackPane updatePane; @@ -211,10 +219,12 @@ public final class MainPage extends StackPane implements DecoratorPage { HBox launchPane = new HBox(); launchPane.getStyleClass().add("launch-pane"); - FXUtils.onScroll(launchPane, versions, list -> { - GameInstanceID currentId = getCurrentGame(); - return Lang.indexWhere(list, instance -> instance.id().equals(currentId)); - }, it -> repository.setSelectedInstance(it.id())); + FXUtils.onChangeAndOperate(selectedRepositorySnapshot, ignored -> mutableInstances.setAll(GameDirectoryManager.getSelectedRepository().getDisplayInstances().toList())); + FXUtils.onScroll(launchPane, instances, list -> { + @Nullable HMCLGameInstance currentGame = getCurrentGame(); + @Nullable GameInstanceID currentId = currentGame != null ? currentGame.getId() : null; + return Lang.indexWhere(list, instance -> instance.getId().equals(currentId)); + }, instance -> instance.getRepository().setSelectedInstance(instance)); StackPane.setAlignment(launchPane, Pos.BOTTOM_RIGHT); { @@ -233,7 +243,7 @@ public final class MainPage extends StackPane implements DecoratorPage { private Tooltip tooltip; @Override - public void accept(@Nullable GameInstanceID currentGame) { + public void accept(@Nullable HMCLGameInstance currentGame) { if (currentGame == null) { launchLabel.setText(i18n("instance.launch.empty")); currentLabel.setText(null); @@ -244,7 +254,7 @@ public void accept(@Nullable GameInstanceID currentGame) { FXUtils.installFastTooltip(launchButton, tooltip); } else { launchLabel.setText(i18n("instance.launch")); - currentLabel.setText(currentGame.toString()); + currentLabel.setText(currentGame.getId().toString()); graphic.getChildren().setAll(launchLabel, currentLabel); FXUtils.setOnActionWithCooldown(launchButton, MainPage.this::launch); if (tooltip != null) @@ -265,7 +275,7 @@ public void accept(@Nullable GameInstanceID currentGame) { JFXPopup.PopupHPosition.RIGHT, 0, -menuButton.getHeight(), - repository, versions + instances ); Node graphic = menuButton.getGraphic(); @@ -342,7 +352,7 @@ private void doAnimation(boolean show) { private void launch() { HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository(); - Instances.launch(repository, repository.getSelectedInstance()); + Instances.launch(repository.getSelectedInstance()); } private void launchNoGame() { @@ -374,7 +384,8 @@ private void launchNoGame() { .whenComplete(any -> GameDirectoryManager.getSelectedRepository().refresh()) .whenComplete(Schedulers.javafx(), (result, exception) -> { if (exception == null) { - GameDirectoryManager.getSelectedRepository().setSelectedInstance(instanceHolder.value); + HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository(); + repository.setSelectedInstance(repository.getInstance(instanceHolder.value)); launch(); } else if (exception instanceof CancellationException) { Controllers.showToast(i18n("message.cancelled")); @@ -406,28 +417,35 @@ public ReadOnlyObjectWrapper stateProperty() { return state; } - public GameDirectory getGameDirectory() { - return repository.getGameDirectory(); - } - - public HMCLGameRepository getRepository() { - return repository; - } - - public GameInstanceID getCurrentGame() { + /// Returns the instance shown by the launch controls. + /// + /// @return the current instance, or `null` when no instance is selected + public @Nullable HMCLGameInstance getCurrentGame() { return currentGame.get(); } - public ObjectProperty<@Nullable GameInstanceID> currentGameProperty() { + /// Returns the property for the instance shown by the launch controls. + /// + /// @return the current-instance property + public ObjectProperty<@Nullable HMCLGameInstance> currentGameProperty() { return currentGame; } - public void setCurrentGame(@Nullable GameInstanceID currentGame) { + /// Sets the instance shown by the launch controls. + /// + /// @param currentGame the instance to show, or `null` to show the empty state + public void setCurrentGame(@Nullable HMCLGameInstance currentGame) { this.currentGame.set(currentGame); } - public ObservableList getVersions() { - return versions; + /// Returns the observable instances displayed by launch-selection controls. + /// + /// The list is updated from the selected repository's published snapshot and contains no hidden + /// instances. The returned view cannot be mutated. + /// + /// @return the observable launch-menu instances + public @UnmodifiableView ObservableList getInstances() { + return instances; } public boolean isShowUpdate() { @@ -466,9 +484,4 @@ public void setLatestVersion(RemoteVersion latestVersion) { this.latestVersion.set(latestVersion); } - public void initVersions(HMCLGameRepository repository, List versions) { - FXUtils.checkFxUserThread(); - this.repository = repository; - this.versions.setAll(versions); - } } 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 97478cf94f5..f52c6a2fea0 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 @@ -20,18 +20,11 @@ import com.jfoenix.controls.JFXPopup; import javafx.beans.property.ReadOnlyObjectProperty; import javafx.scene.layout.Region; -import org.jackhuang.hmcl.Metadata; -import org.jackhuang.hmcl.event.EventBus; -import org.jackhuang.hmcl.event.RefreshedGameInstancesEvent; import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.ModpackHelper; import org.jackhuang.hmcl.setting.Accounts; -import org.jackhuang.hmcl.setting.GameDirectory; import org.jackhuang.hmcl.setting.GameDirectoryManager; -import org.jackhuang.hmcl.task.Schedulers; -import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.terracotta.TerracottaMetadata; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; @@ -53,21 +46,13 @@ import org.jackhuang.hmcl.upgrade.UpdateChecker; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.StringUtils; -import org.jackhuang.hmcl.util.TaskCancellationAction; -import org.jackhuang.hmcl.util.io.CompressingUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.platform.*; -import org.jackhuang.hmcl.util.versioning.VersionNumber; +import org.jetbrains.annotations.Nullable; -import java.nio.file.Files; import java.nio.file.Path; -import java.time.Instant; -import java.util.Comparator; -import java.util.List; import java.util.Locale; -import java.util.stream.Collectors; -import static org.jackhuang.hmcl.ui.FXUtils.runInFX; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -75,13 +60,6 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage { private MainPage mainPage = null; public RootPage() { - EventBus.EVENT_BUS.channel(RefreshedGameInstancesEvent.class) - .register(event -> onRefreshedVersions((HMCLGameRepository) event.getSource())); - - HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository(); - if (repository.isLoaded()) - onRefreshedVersions(GameDirectoryManager.getSelectedRepository()); - getStyleClass().remove("gray-background"); getLeft().getStyleClass().add("gray-background"); } @@ -122,20 +100,6 @@ public MainPage getMainPage() { FXUtils.onChangeAndOperate(GameDirectoryManager.selectedInstanceProperty(), mainPage::setCurrentGame); mainPage.latestVersionProperty().bind(UpdateChecker.latestVersionProperty()); - - GameDirectoryManager.registerVersionsListener(repository -> { - GameDirectory gameDirectory = repository.getGameDirectory(); - List children = repository.getInstanceManifests().parallelStream() - .filter(version -> !version.isHidden()) - .sorted(Comparator - .comparing((GameInstanceManifest manifest) -> Lang.requireNonNullElse(manifest.releaseTime(), Instant.EPOCH)) - .thenComparing(manifest -> VersionNumber.asVersion(repository.getGameVersion(manifest).orElse(manifest.id().toString())))) - .collect(Collectors.toList()); - runInFX(() -> { - if (gameDirectory == GameDirectoryManager.getSelectedGameDirectory()) - mainPage.initVersions(repository, children); - }); - }); this.mainPage = mainPage; } return mainPage; @@ -155,17 +119,18 @@ protected Skin(RootPage control) { // second item in left sidebar GameAdvancedListItem gameListItem = new GameAdvancedListItem(); gameListItem.setOnAction(e -> { - GameInstanceID instanceId = GameDirectoryManager.getSelectedRepository().getSelectedInstance(); - if (instanceId == null) { + @Nullable HMCLGameInstance instance = GameDirectoryManager.getSelectedRepository().getSelectedInstance(); + if (instance == null) { Controllers.navigate(Controllers.getGameListPage()); } else { - Instances.modifyGameSettings(GameDirectoryManager.getSelectedRepository(), instanceId); + Instances.modifyGameSettings(instance); } }); - FXUtils.onScroll(gameListItem, getSkinnable().getMainPage().getVersions(), list -> { - GameInstanceID currentId = getSkinnable().getMainPage().getCurrentGame(); - return Lang.indexWhere(list, instance -> instance.id().equals(currentId)); - }, it -> getSkinnable().getMainPage().getRepository().setSelectedInstance(it.id())); + FXUtils.onScroll(gameListItem, getSkinnable().getMainPage().getInstances(), list -> { + @Nullable HMCLGameInstance currentGame = getSkinnable().getMainPage().getCurrentGame(); + @Nullable GameInstanceID currentId = currentGame != null ? currentGame.getId() : null; + return Lang.indexWhere(list, instance -> instance.getId().equals(currentId)); + }, instance -> instance.getRepository().setSelectedInstance(instance)); if (AnimationUtils.isAnimationEnabled()) { FXUtils.prepareOnMouseEnter(gameListItem, Controllers::prepareGameInstancePage); } @@ -252,44 +217,8 @@ public void showGameListPopupMenu(Region gameListItem) { JFXPopup.PopupHPosition.LEFT, gameListItem.getWidth(), 0, - getSkinnable().getMainPage().getRepository(), - getSkinnable().getMainPage().getVersions()); + getSkinnable().getMainPage().getInstances()); } } - private boolean checkedModpack = false; - - private void onRefreshedVersions(HMCLGameRepository repository) { - runInFX(() -> { - if (!checkedModpack) { - checkedModpack = true; - - if (repository.getInstanceCount() == 0) { - Path zipModpack = Metadata.CURRENT_DIRECTORY.resolve("modpack.zip"); - Path mrpackModpack = Metadata.CURRENT_DIRECTORY.resolve("modpack.mrpack"); - - Path modpackFile; - if (Files.exists(zipModpack)) { - modpackFile = zipModpack; - } else if (Files.exists(mrpackModpack)) { - modpackFile = mrpackModpack; - } else { - modpackFile = null; - } - - if (modpackFile != null) { - Task.supplyAsync(() -> CompressingUtils.findSuitableEncoding(modpackFile)) - .thenApplyAsync(encoding -> ModpackHelper.readModpackManifest(modpackFile, encoding)) - .thenApplyAsync(modpack -> ModpackHelper - .getInstallTask(repository, modpackFile, new GameInstanceID(modpack.getName()), modpack, null) - .executor()) - .thenAcceptAsync(Schedulers.javafx(), executor -> { - Controllers.taskDialog(executor, i18n("modpack.installing"), TaskCancellationAction.NO_CANCEL); - executor.start(); - }).start(); - } - } - } - }); - } } 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 d8478cfa11a..6a513447eba 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 @@ -221,7 +221,7 @@ public TerracottaControllerPage() { MessageDialogPane.MessageType.QUESTION ).addAction(i18n("instance.launch"), () -> { var repository = GameDirectoryManager.getSelectedRepository(); - Instances.launch(repository, repository.getSelectedInstance(), launcherHelper -> { + Instances.launch(repository.getSelectedInstance(), 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 e4ea34fda53..a128097c2a2 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 @@ -26,6 +26,7 @@ import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.setting.*; import org.jackhuang.hmcl.terracotta.TerracottaMetadata; import org.jackhuang.hmcl.ui.Controllers; @@ -41,6 +42,7 @@ import org.jackhuang.hmcl.ui.instances.GameListPopupMenu; import org.jackhuang.hmcl.ui.instances.Instances; import org.jackhuang.hmcl.util.Lang; +import org.jetbrains.annotations.Nullable; import static org.jackhuang.hmcl.setting.SettingsManager.userState; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; @@ -54,7 +56,7 @@ public class TerracottaPage extends DecoratorAnimatedPage implements DecoratorPa private final TransitionPane transitionPane = new TransitionPane(); @SuppressWarnings("unused") - private ChangeListener instanceChangeListenerHolder; + private @Nullable ChangeListener<@Nullable HMCLGameInstance> instanceChangeListenerHolder; public TerracottaPage() { statusPage.setNodeSupplier(TerracottaControllerPage::new); @@ -79,27 +81,28 @@ public TerracottaPage() { .add(accountListItem) .addNavigationDrawerItem(i18n("instance.launch"), SVG.ROCKET_LAUNCH, () -> { var repository = GameDirectoryManager.getSelectedRepository(); - Instances.launch(repository, repository.getSelectedInstance(), launcherHelper -> { + Instances.launch(repository.getSelectedInstance(), launcherHelper -> { launcherHelper.setKeep(); launcherHelper.setDisableOfflineSkin(); }); }, item -> { instanceChangeListenerHolder = FXUtils.onWeakChangeAndOperate(GameDirectoryManager.selectedInstanceProperty(), - instanceName -> item.setSubtitle(instanceName != null ? instanceName.toString() : i18n("instance.empty")) + instance -> item.setSubtitle(instance != null ? instance.getId().toString() : i18n("instance.empty")) ); MainPage mainPage = Controllers.getRootPage().getMainPage(); - FXUtils.onScroll(item, mainPage.getVersions(), list -> { - GameInstanceID currentId = mainPage.getCurrentGame(); - return Lang.indexWhere(list, instance -> instance.id().equals(currentId)); - }, it -> mainPage.getRepository().setSelectedInstance(it.id())); + FXUtils.onScroll(item, mainPage.getInstances(), list -> { + @Nullable HMCLGameInstance currentGame = mainPage.getCurrentGame(); + @Nullable GameInstanceID currentId = currentGame != null ? currentGame.getId() : null; + return Lang.indexWhere(list, instance -> instance.getId().equals(currentId)); + }, instance -> instance.getRepository().setSelectedInstance(instance)); FXUtils.onSecondaryButtonClicked(item, () -> GameListPopupMenu.show(item, JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT, item.getWidth(), 0, - mainPage.getRepository(), mainPage.getVersions())); + mainPage.getInstances())); }) .addNavigationDrawerItem(i18n("terracotta.feedback.title"), SVG.FEEDBACK, () -> FXUtils.openLink(TerracottaMetadata.FEEDBACK_LINK)); BorderPane.setMargin(toolbar, new Insets(0, 0, 12, 0)); 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 97450b4cc55..b248cc7fe62 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/util/NativePatcher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/util/NativePatcher.java @@ -73,13 +73,14 @@ public static boolean needPatchMemoryUtil(GameInstanceManifest manifest, int jav ); } - public static GameInstanceManifest patchNative(DefaultGameRepository repository, - GameInstanceManifest manifest, String gameVersion, + public static GameInstanceManifest patchNative(DefaultGameInstance instance, + GameInstanceManifest manifest, + @NotNull GameVersionNumber gameVersion, JavaRuntime javaVersion, GameSettings.Effective settings, List javaArguments) { if (settings.getInheritable(GameSettings::useCustomNativesProperty)) { - if (gameVersion != null && GameVersionNumber.compare(gameVersion, "1.19") < 0) + if (gameVersion.compareTo("1.19") < 0) return manifest; ArrayList newLibraries = new ArrayList<>(); @@ -99,8 +100,8 @@ public static GameInstanceManifest patchNative(DefaultGameRepository repository, final boolean useNativeGLFW = settings.getInheritable(GameSettings::useNativeGLFWProperty); final boolean useNativeOpenAL = settings.getInheritable(GameSettings::useNativeOpenALProperty); - if (OperatingSystem.CURRENT_OS.isLinuxOrBSD() && (useNativeGLFW || useNativeOpenAL) - && gameVersion != null && GameVersionNumber.compare(gameVersion, "1.19") >= 0) { + if (OperatingSystem.CURRENT_OS.isLinuxOrBSD() + && (useNativeGLFW || useNativeOpenAL) && gameVersion.compareTo("1.19") >= 0) { manifest = manifest.withLibraries(manifest.getLibraries().stream() .filter(library -> { @@ -122,7 +123,6 @@ public static GameInstanceManifest patchNative(DefaultGameRepository repository, OperatingSystem os = javaVersion.getPlatform().getOperatingSystem(); Architecture arch = javaVersion.getArchitecture(); - GameVersionNumber gameVersionNumber = gameVersion != null ? GameVersionNumber.asGameVersion(gameVersion) : null; if (settings.getInheritable(GameSettings::notPatchNativesProperty)) return manifest; @@ -131,8 +131,7 @@ public static GameInstanceManifest patchNative(DefaultGameRepository repository, return manifest; if (arch == Architecture.ARM64 && (os == OperatingSystem.MACOS || os == OperatingSystem.WINDOWS) - && gameVersionNumber != null - && gameVersionNumber.compareTo("1.19") >= 0) + && gameVersion.compareTo("1.19") >= 0) return manifest; Map replacements = getNatives(javaVersion.getPlatform()); @@ -172,7 +171,7 @@ public static GameInstanceManifest patchNative(DefaultGameRepository repository, } if (lwjglVersionChanged) { - ModManager modManager = repository.getModManager(manifest.id()); + ModManager modManager = instance.getModManager(); try { for (LocalModFile mod : modManager.getLocalFiles()) { if ("sodium".equals(mod.getId())) { diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java index f9a022c0584..92e89fe39c6 100644 --- a/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java +++ b/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java @@ -23,9 +23,12 @@ import com.google.gson.JsonParseException; import com.google.gson.JsonParser; import javafx.beans.property.ObjectProperty; +import javafx.beans.property.ReadOnlyObjectProperty; import javafx.collections.ObservableList; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.GameInstanceManifest; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.util.FileSaver; import org.jackhuang.hmcl.util.PortablePath; @@ -33,6 +36,7 @@ import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.i18n.LocalizedText; import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -41,6 +45,7 @@ import java.nio.file.FileSystem; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Objects; @@ -433,10 +438,10 @@ public void repositoryDirectoryFollowsGameDirectoryPath() throws ReflectiveOpera } } - /// Tests that new isolated installing instances resolve content directories under the version root before metadata is saved. + /// Tests that isolation settings written before install make a registered instance use the version root. @Test - public void newIsolatedInstallingInstanceUsesVersionRootBeforeVersionExists(@TempDir Path tempDirectory) - throws ReflectiveOperationException { + public void newIsolatedInstallingInstanceUsesVersionRootAfterPlaceholderSave(@TempDir Path tempDirectory) + throws Exception { GameSettingsPresetID defaultPresetId = GameSettingsPresetID.parse("game-settings-preset:123e4567-e89b-12d3-a456-426614174002"); GameSettings.Preset defaultPreset = new GameSettings.Preset(defaultPresetId); @@ -459,15 +464,17 @@ public void newIsolatedInstallingInstanceUsesVersionRootBeforeVersionExists(@Tem GameInstanceID id = new GameInstanceID("1.21.11-fabric"); assertFalse(repository.hasInstance(id)); - assertEquals(repository.getBaseDirectory(), repository.getRunDirectory(id)); + // Isolation is configured first; install then registers a placeholder instance. repository.applyDefaultIsolationSettingForNewInstance(id, true); + repository.saveAsync(new GameInstanceManifest(id)).run(); - assertEquals(repository.getInstanceRoot(id), repository.getRunDirectory(id)); - assertEquals(repository.getInstanceRoot(id).resolve("mods"), repository.getModsDirectory(id)); + HMCLGameInstance instance = repository.getInstance(id); + assertEquals(repository.getLayout().getInstanceRoot(id), instance.getRunDirectory()); + assertEquals(repository.getLayout().getInstanceRoot(id).resolve("mods"), instance.getModsDirectory()); assertTrue(repository.removeInstanceFromDisk(id)); - assertEquals(repository.getBaseDirectory(), repository.getRunDirectory(id)); + assertFalse(repository.hasInstance(id)); } } @@ -529,7 +536,7 @@ public void legacyInstanceSettingsMigrationStoresLegacyGameDirectoryPresetAsPare settings().defaultGameSettingsPresetProperty().set(defaultPresetId); HMCLGameRepository repository = new HMCLGameRepository(gameDirectory); GameInstanceID instanceId = new GameInstanceID("1.20.1"); - Path versionRoot = repository.getInstanceRoot(instanceId); + Path versionRoot = repository.getLayout().getInstanceRoot(instanceId); Files.createDirectories(versionRoot); Files.writeString(versionRoot.resolve("hmclversion.cfg"), """ { @@ -571,7 +578,7 @@ public void startupMigrationSkipsLegacyInstanceSettingsFile(@TempDir Path tempDi HMCLGameRepository repository = new HMCLGameRepository(gameDirectory); writeVersionJson(repository, "1.20.1"); GameInstanceID instanceId = new GameInstanceID("1.20.1"); - Path versionRoot = repository.getInstanceRoot(instanceId); + Path versionRoot = repository.getLayout().getInstanceRoot(instanceId); Files.writeString(versionRoot.resolve(LegacyGameSettingsMigrator.LEGACY_INSTANCE_SETTINGS_FILENAME), """ { "usesGlobal": true @@ -580,7 +587,7 @@ public void startupMigrationSkipsLegacyInstanceSettingsFile(@TempDir Path tempDi LegacyConfigMigrator.migrateLegacyInstanceGameSettings(localDirectories, presets); - assertFalse(Files.exists(repository.getInstanceConfigDirectory(instanceId) + assertFalse(Files.exists(repository.getLayout().getInstanceConfigDirectory(instanceId) .resolve(LegacyGameSettingsMigrator.INSTANCE_GAME_SETTINGS_FILENAME))); GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettings(instanceId)); assertEquals(legacyPresetId, setting.parentProperty().getValue()); @@ -660,6 +667,102 @@ public void newInstanceAfterMigrationDoesNotUseLegacyGameDirectoryParent(@TempDi } } + /// Tests that HMCL-specific instance files are managed through [HMCLGameInstance]. + @Test + public void instanceOwnsHmclSpecificFiles(@TempDir Path tempDirectory) throws Exception { + GameDirectory gameDirectory = new GameDirectory( + GameDirectoryID.generate(), + LocalizedText.plain("Dev"), + PortablePath.of(tempDirectory.toString())); + GameDirectories localDirectories = new GameDirectories(); + localDirectories.getGameDirectories().add(gameDirectory); + GameDirectories userDirectories = new GameDirectories(); + + try (GameDirectoryEnvironment ignored = + new GameDirectoryEnvironment(localDirectories, userDirectories)) { + HMCLGameRepository repository = new HMCLGameRepository(gameDirectory); + GameInstanceID instanceId = new GameInstanceID("1.20.1"); + repository.saveAsync(new GameInstanceManifest(instanceId)).run(); + HMCLGameInstance instance = repository.getInstance(instanceId); + + Path configurationFile = instance.getInstanceRoot().resolve("modpack.cfg"); + assertEquals(configurationFile, instance.getModpackConfigurationFile()); + assertFalse(instance.isModpack()); + Files.writeString(configurationFile, "{}"); + assertTrue(instance.isModpack()); + + Path abnormalMarker = instance.getInstanceRoot().resolve(".abnormal"); + instance.markLaunchedAbnormally(); + assertTrue(Files.isRegularFile(abnormalMarker)); + assertTrue(instance.unmarkLaunchedAbnormally()); + assertFalse(Files.exists(abnormalMarker)); + + Path sourceIcon = tempDirectory.resolve("source.png"); + Files.write(sourceIcon, new byte[]{1, 2, 3}); + instance.setIconFile(sourceIcon); + assertEquals(instance.getInstanceRoot().resolve("icon.png"), instance.getIconFile()); + instance.deleteIconFile(); + assertNull(instance.getIconFile()); + } + } + + /// Tests that repository selection exposes the current snapshot member while persisting its ID. + @Test + public void selectedInstanceTracksRepositorySnapshots(@TempDir Path tempDirectory) + throws Exception { + GameDirectory gameDirectory = new GameDirectory( + GameDirectoryID.generate(), + LocalizedText.plain("Dev"), + PortablePath.of(tempDirectory.toString())); + GameDirectories localDirectories = new GameDirectories(); + localDirectories.getGameDirectories().add(gameDirectory); + GameDirectories userDirectories = new GameDirectories(); + + try (GameDirectoryEnvironment ignored = + new GameDirectoryEnvironment(localDirectories, userDirectories)) { + HMCLGameRepository repository = new HMCLGameRepository(gameDirectory); + GameInstanceID firstId = new GameInstanceID("1.20.1"); + GameInstanceID secondId = new GameInstanceID("1.21.1"); + GameInstanceManifest firstManifest = new GameInstanceManifest(firstId); + repository.saveAsync(firstManifest).run(); + repository.saveAsync(new GameInstanceManifest(secondId)).run(); + + ReadOnlyObjectProperty<@Nullable HMCLGameInstance> selectedInstance = + repository.selectedInstanceProperty(); + List observedSelections = new ArrayList<>(); + selectedInstance.addListener((observable, oldValue, newValue) -> { + if (newValue != null) { + observedSelections.add(newValue); + } + }); + HMCLGameInstance firstInstance = repository.getInstance(firstId); + repository.setSelectedInstance(firstInstance); + + assertSame(firstInstance, selectedInstance.get()); + assertSame(firstInstance, observedSelections.getLast()); + assertEquals(firstId, settings().getSelectedInstance(gameDirectory.getId())); + + repository.saveAsync(firstManifest).run(); + HMCLGameInstance refreshedFirstInstance = repository.getInstance(firstId); + assertNotSame(firstInstance, refreshedFirstInstance); + assertSame(refreshedFirstInstance, selectedInstance.get()); + assertSame(refreshedFirstInstance, observedSelections.getLast()); + repository.setSelectedInstance(firstInstance); + assertSame(refreshedFirstInstance, selectedInstance.get()); + + settings().setSelectedInstance(gameDirectory.getId(), secondId); + assertSame(repository.getInstance(secondId), selectedInstance.get()); + + settings().setSelectedInstance(gameDirectory.getId(), new GameInstanceID("missing")); + assertNull(selectedInstance.get()); + repository.refreshSelectedInstance(); + HMCLGameInstance fallbackInstance = assertDoesNotThrow(() -> + Objects.requireNonNull(repository.getSelectedInstance())); + assertSame(repository.getInstance(fallbackInstance.getId()), fallbackInstance); + assertEquals(fallbackInstance.getId(), settings().getSelectedInstance(gameDirectory.getId())); + } + } + /// Temporary static state override for game directory tests. private static final class GameDirectoryEnvironment implements AutoCloseable { /// The reflected SettingsManager local game directories field. @@ -839,7 +942,8 @@ public void close() throws ReflectiveOperationException { /// Writes a minimal valid version json for repository refresh tests. private static void writeVersionJson(HMCLGameRepository repository, String id) throws IOException { - Path versionRoot = repository.getInstanceRoot(new GameInstanceID(id)); + GameInstanceID instanceId = new GameInstanceID(id); + Path versionRoot = repository.getLayout().getInstanceRoot(instanceId); Files.createDirectories(versionRoot); Files.writeString(versionRoot.resolve(id + ".json"), """ { diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/ui/GameCrashWindowTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/ui/GameCrashWindowTest.java deleted file mode 100644 index 0fb1e46b021..00000000000 --- a/HMCL/src/test/java/org/jackhuang/hmcl/ui/GameCrashWindowTest.java +++ /dev/null @@ -1,71 +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.ui; - -import org.jackhuang.hmcl.JavaFXLauncher; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.LaunchOptions; -import org.jackhuang.hmcl.java.JavaInfo; -import org.jackhuang.hmcl.game.Log; -import org.jackhuang.hmcl.launch.ProcessListener; -import org.jackhuang.hmcl.java.JavaRuntime; -import org.jackhuang.hmcl.util.platform.ManagedProcess; -import org.jackhuang.hmcl.util.platform.Platform; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; - -import java.io.File; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Arrays; -import java.util.concurrent.CountDownLatch; -import java.util.stream.Collectors; - -public class GameCrashWindowTest { - - @Test - @Disabled - public void test() throws Exception { - JavaFXLauncher.start(); - - ManagedProcess process = new ManagedProcess(null, Arrays.asList("commands", "2")); - - String logs = Files.readString(new File("../HMCLCore/src/test/resources/logs/too_old_java.txt").toPath()); - - CountDownLatch latch = new CountDownLatch(1); - FXUtils.runInFX(() -> { - Path workingPath = Path.of(System.getProperty("user.dir")); - - GameCrashWindow window = new GameCrashWindow(process, ProcessListener.ExitType.APPLICATION_ERROR, null, - new GameInstanceManifest(new GameInstanceID("Classic")), - new LaunchOptions.Builder() - .setJava(new JavaRuntime(workingPath, new JavaInfo(Platform.SYSTEM_PLATFORM, "16", null), false, false)) - .setGameDir(workingPath) - .create(), - Arrays.stream(logs.split("\\n")) - .map(Log::new) - .collect(Collectors.toList())); - - window.showAndWait(); - - latch.countDown(); - }); - latch.await(); - } -} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/LocalAddonManager.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/LocalAddonManager.java index 0946df626fb..8a81e1fe356 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/LocalAddonManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/LocalAddonManager.java @@ -17,8 +17,7 @@ */ package org.jackhuang.hmcl.addon; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.GameRepository; +import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jetbrains.annotations.NotNull; @@ -34,41 +33,70 @@ import java.util.Set; import java.util.concurrent.locks.ReentrantLock; +/// Manages local addon files for a single [DefaultGameInstance] snapshot member. +/// +/// Each manager is bound to one instance wrapper and must not be shared across repository snapshot +/// copies. Callers obtain a manager from the current instance after a refresh or COW publish. +/// +/// @param the local addon file type managed by this manager public abstract class LocalAddonManager { + /// File-name suffix used for disabled addon files. public static final String DISABLED_EXTENSION = ".disabled"; + + /// File-name suffix used for backed-up (old) addon files. public static final String OLD_EXTENSION = ".old"; + /// Returns the display file name of an addon path with disable/old suffixes stripped. + /// + /// @param file the addon file path + /// @return the file name without [#DISABLED_EXTENSION] or [#OLD_EXTENSION] public static String getLocalAddonName(Path file) { return StringUtils.removeSuffix(FileUtils.getName(file), DISABLED_EXTENSION, OLD_EXTENSION); } + /// Lock guarding [#localFiles] and subclass mutable state. protected final ReentrantLock lock = new ReentrantLock(); + /// Loaded local addon files for the bound instance. protected final Set<@NotNull T> localFiles = new LinkedHashSet<>(); - protected final GameRepository repository; - protected final GameInstanceID instanceId; - - public LocalAddonManager(GameRepository gameRepository, GameInstanceID instanceId) { - this.repository = gameRepository; - this.instanceId = instanceId; - } + /// The snapshot member this manager serves. + protected final DefaultGameInstance instance; - public GameRepository getRepository() { - return repository; + /// Creates a manager bound to the given instance. + /// + /// @param instance the snapshot member whose addon directory this manager operates on + public LocalAddonManager(DefaultGameInstance instance) { + this.instance = instance; } - public GameInstanceID getInstanceId() { - return instanceId; + /// Returns the instance this manager is bound to. + /// + /// @return the bound [DefaultGameInstance] + public DefaultGameInstance getInstance() { + return instance; } + /// Returns the directory that stores local addon files for the bound instance. + /// + /// @return the addon directory path public abstract Path getDirectory(); + /// Reloads local addon files from disk into [#localFiles]. + /// + /// @throws IOException if the directory cannot be listed or a required instance path cannot be read public abstract void refresh() throws IOException; + /// Returns the comparator used to order [#getLocalFiles()]. + /// + /// @return the sort order for local addon files public abstract Comparator getComparator(); + /// Returns the currently loaded local addon files, sorted by [#getComparator()]. + /// + /// @return an unmodifiable sorted list of local addon files + /// @throws IOException if loading is required and fails public @Unmodifiable List getLocalFiles() throws IOException { lock.lock(); try { @@ -78,6 +106,15 @@ public GameInstanceID getInstanceId() { } } + /// Marks an addon file as old (backed up) or restores it from the old location. + /// + /// When `old` is `true`, the file is renamed with [#OLD_EXTENSION] and removed from + /// [#localFiles]. When `old` is `false`, the suffix is removed and the file is re-added. + /// + /// @param modFile the local addon file to update + /// @param old whether the file should be treated as a backup + /// @return the path after the rename + /// @throws IOException if the file cannot be moved public Path setOld(T modFile, boolean old) throws IOException { lock.lock(); try { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/ModManager.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/ModManager.java index ab60fac8b29..1c955f6ab57 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/ModManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/ModManager.java @@ -21,10 +21,9 @@ import org.jackhuang.hmcl.addon.LocalAddonFile; import org.jackhuang.hmcl.addon.LocalAddonManager; import org.jackhuang.hmcl.addon.meta.*; -import org.jackhuang.hmcl.download.LibraryAnalyzer; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.GameRepository; -import org.jackhuang.hmcl.game.NoSuchGameInstanceException; +import org.jackhuang.hmcl.game.DefaultGameInstance; +import org.jackhuang.hmcl.game.GameComponentAnalyzer; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.util.Pair; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.io.CompressingUtils; @@ -66,20 +65,23 @@ private interface ModMetadataReader { } private final HashMap, LocalMod> localMods = new HashMap<>(); - private LibraryAnalyzer analyzer; + private GameComponentAnalyzer analyzer; private boolean loaded = false; - public ModManager(GameRepository repository, GameInstanceID id) { - super(repository, id); + /// Creates a mod manager for the given instance. + /// + /// @param instance the snapshot member whose mods directory this manager operates on + public ModManager(DefaultGameInstance instance) { + super(instance); } @Override public Path getDirectory() { - return repository.getModsDirectory(instanceId); + return instance.getModsDirectory(); } - public LibraryAnalyzer getLibraryAnalyzer() { + public GameComponentAnalyzer getComponentAnalyzer() { return analyzer; } @@ -180,14 +182,10 @@ public void refresh() throws IOException { localFiles.clear(); localMods.clear(); - try { - analyzer = LibraryAnalyzer.analyze(getRepository().getResolvedInstanceManifest(instanceId), null); - } catch (NoSuchGameInstanceException e) { - throw new IOException(e); - } + analyzer = GameComponentAnalyzer.analyze(instance.getResolvedManifest(), null); - boolean supportSubfolders = analyzer.has(LibraryAnalyzer.LibraryType.FORGE) - || analyzer.has(LibraryAnalyzer.LibraryType.QUILT); + boolean supportSubfolders = analyzer.has(GameComponentType.FORGE) + || analyzer.has(GameComponentType.QUILT); if (Files.isDirectory(getDirectory())) { try (DirectoryStream modsDirectoryStream = Files.newDirectoryStream(getDirectory())) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/resourcepack/ResourcePackManager.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/resourcepack/ResourcePackManager.java index aee0e34e31e..cb7a39e99c2 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/resourcepack/ResourcePackManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/resourcepack/ResourcePackManager.java @@ -19,9 +19,8 @@ import com.google.gson.annotations.SerializedName; import kala.encdet.EncodingDetector; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.GameRepository; import org.jackhuang.hmcl.addon.LocalAddonManager; +import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.addon.meta.PackMcMeta; import org.jackhuang.hmcl.util.Pair; import org.jackhuang.hmcl.util.StringUtils; @@ -222,10 +221,13 @@ private static List deserializePackList(String json) { private boolean loaded = false; - public ResourcePackManager(GameRepository repository, GameInstanceID instanceId) { - super(repository, instanceId); - this.resourcePackDirectory = this.repository.getResourcePackDirectory(this.instanceId); - this.optionsFile = repository.getRunDirectory(instanceId).resolve("options.txt"); + /// Creates a resource-pack manager for the given instance. + /// + /// @param instance the snapshot member whose resource packs this manager operates on + public ResourcePackManager(DefaultGameInstance instance) { + super(instance); + this.resourcePackDirectory = instance.getResourcePackDirectory(); + this.optionsFile = instance.getRunDirectory().resolve("options.txt"); } private @Nullable Charset optionsFileEncoding; @@ -279,7 +281,7 @@ public GameVersionNumber getMinecraftVersion() { lock.lock(); try { if (minecraftVersion == null) { - minecraftVersion = GameVersionNumber.asGameVersion(repository.getGameVersion(instanceId)); + minecraftVersion = instance.getVersion(); supportsNewOptionsFormat = isMcVersionSupportsNewOptionsFormat(minecraftVersion); } } finally { @@ -295,7 +297,7 @@ public PackMcMeta.PackVersion getRequiredVersion() { lock.lock(); try { if (requiredVersion == null) - requiredVersion = getPackVersion(getMinecraftVersion(), repository.getInstanceJar(instanceId)); + requiredVersion = getPackVersion(getMinecraftVersion(), instance.getInstanceJarFile()); } finally { lock.unlock(); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java index 3373165ff9f..5ba55265c70 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java @@ -24,39 +24,54 @@ import org.jackhuang.hmcl.download.game.GameLibrariesTask; import org.jackhuang.hmcl.download.neoforge.NeoForgeInstallTask; import org.jackhuang.hmcl.download.optifine.OptiFineInstallTask; -import org.jackhuang.hmcl.game.Artifact; -import org.jackhuang.hmcl.game.DefaultGameRepository; -import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.Library; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.concurrent.atomic.AtomicReference; import java.util.regex.Matcher; import java.util.regex.Pattern; -/** - * Note: This class has no state. - * - * @author huangyuhui - */ +/// Provides downloads and game-component installation for one game repository. public class DefaultDependencyManager extends AbstractDependencyManager { + /// The repository whose layout and registered instances are managed. private final DefaultGameRepository repository; + + /// The provider used to resolve remote download URLs and version lists. private final DownloadProvider downloadProvider; + + /// The cache used to source and retain downloaded artifacts. private final DefaultCacheRepository cacheRepository; + /// Creates a dependency manager for a repository and download context. + /// + /// @param repository the associated game repository + /// @param downloadProvider the remote download provider + /// @param cacheRepository the artifact cache public DefaultDependencyManager(DefaultGameRepository repository, DownloadProvider downloadProvider, DefaultCacheRepository cacheRepository) { this.repository = repository; this.downloadProvider = downloadProvider; this.cacheRepository = cacheRepository; } + /// Ensures that an instance belongs to this manager's repository. + /// + /// @param instance the instance to validate + /// @throws IllegalArgumentException if the instance belongs to another repository + public void validateGameInstance(GameInstance instance) { + if (instance.getRepository() != repository) { + throw new IllegalArgumentException("Game instance and dependency manager belong to different repositories"); + } + } + @Override public DefaultGameRepository getGameRepository() { return repository; @@ -78,15 +93,20 @@ public GameBuilder newGameBuilder() { } @Override - public Task checkGameCompletionAsync(GameInstanceManifest manifest, boolean integrityCheck) { + public Task checkGameCompletionAsync( + GameInstance instance, + GameInstanceManifest manifest, + boolean integrityCheck) { + validateGameInstance(instance); + return Task.allOf( Task.composeAsync(() -> { - Path versionJar = repository.getInstanceJar(manifest); + Path versionJar = instance.getInstanceJarFile(); return Files.notExists(versionJar) || FileUtils.size(versionJar) == 0L - ? new GameDownloadTask(this, null, manifest) + ? new GameDownloadTask(this, null, manifest, versionJar) : null; - }).thenComposeAsync(checkPatchCompletionAsync(manifest, integrityCheck)), + }).thenComposeAsync(checkPatchCompletionAsync(instance, manifest, integrityCheck)), new GameAssetDownloadTask(this, manifest, GameAssetDownloadTask.DOWNLOAD_INDEX_IF_NECESSARY, integrityCheck) .setSignificance(Task.TaskSignificance.MODERATE), new GameLibrariesTask(this, manifest, integrityCheck) @@ -99,31 +119,36 @@ public Task checkLibraryCompletionAsync(GameInstanceManifest manifest, boolea } @Override - public Task checkPatchCompletionAsync(GameInstanceManifest manifest, boolean integrityCheck) { + public Task checkPatchCompletionAsync( + GameInstance instance, + GameInstanceManifest manifest, + boolean integrityCheck) { + validateGameInstance(instance); + return Task.composeAsync(() -> { List> tasks = new ArrayList<>(0); - String gameVersion = repository.getGameVersion(manifest).orElse(null); - if (gameVersion == null) return null; + GameVersionNumber detectedVersion = instance.getVersion(); + if (detectedVersion == GameVersionNumber.unknown()) return null; + String gameVersion = detectedVersion.toString(); - GameInstanceManifest original = repository.getInstanceManifest(manifest.id()); - GameInstanceManifest.Resolved resolvedInstanceManifest = repository.getResolvedInstanceManifest(manifest.id()); + GameInstanceManifest original = instance.getManifest(); + GameInstanceManifest.Resolved resolvedInstanceManifest = instance.getResolvedManifest(); - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(resolvedInstanceManifest, gameVersion); - for (LibraryAnalyzer.LibraryType type : LibraryAnalyzer.LibraryType.values()) { + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(resolvedInstanceManifest, gameVersion); + for (GameComponentType type : GameComponentType.values()) { if (!analyzer.has(type)) continue; - if (type == LibraryAnalyzer.LibraryType.OPTIFINE) { - String optifinePatchVersion = analyzer.getVersion(type) - .map(optifineVersion -> { + if (type == GameComponentType.OPTIFINE) { + String optifinePatchVersion = Optional.ofNullable(analyzer.getVersion(type)) .map(optifineVersion -> { Matcher matcher = Pattern.compile("^([0-9.]+)_(?HD_.+)$").matcher(optifineVersion); return matcher.find() ? matcher.group("optifine") : optifineVersion; }) .orElseGet(() -> resolvedInstanceManifest.standaloneManifest().getPatches().stream() .filter(patch -> "optifine".equals(patch.id())) .findAny() - .map(gameInstancePatch -> gameInstancePatch.version()) + .map(GameInstancePatch::version) .orElse(null)); boolean needsReInstallation = manifest.getLibraries().stream() @@ -136,7 +161,7 @@ public Task checkPatchCompletionAsync(GameInstanceManifest manifest, boolean if (GameLibrariesTask.shouldDownloadLibrary(repository, manifest, installer, integrityCheck)) { tasks.add(installLibraryAsync(gameVersion, original, "optifine", optifinePatchVersion)); } else { - tasks.add(OptiFineInstallTask.install(this, original, repository.getLibraryFile(manifest, installer))); + tasks.add(OptiFineInstallTask.install(this, original, repository.getLayout().getLibraryFile(manifest.id(), installer))); } } } @@ -157,23 +182,45 @@ public Task installLibraryAsync(String gameVersion, GameIn @Override public Task installLibraryAsync(GameInstanceManifest baseVersion, RemoteVersion libraryVersion) { - AtomicReference removedLibraryVersion = new AtomicReference<>(); + AtomicReference removedLibraryManifest = new AtomicReference<>(); - return removeLibraryAsync(baseVersion, libraryVersion.getLibraryId()) - .thenComposeAsync(version -> { - removedLibraryVersion.set(version); - return libraryVersion.getInstallTask(this, version); + return removeLibraryAsync(baseVersion, libraryVersion.getComponentType()) + .thenComposeAsync(manifest -> { + removedLibraryManifest.set(manifest); + return libraryVersion.getInstallTask(this, manifest, modsDirectoryFor(manifest)); }) .thenApplyAsync(patch -> { if (patch == null) { - return removedLibraryVersion.get(); + return removedLibraryManifest.get(); } else { - return removedLibraryVersion.get().addPatch(patch); + return removedLibraryManifest.get().addPatch(patch); } }) .withStage(String.format("hmcl.install.%s:%s", libraryVersion.getLibraryId(), libraryVersion.getSelfVersion())); } + /// Resolves the mods directory for the instance identified by `manifest`. + /// + /// Prefer the registered [GameInstance] when present so isolation/run-directory policy is + /// honored. Falls back to the shared repository base directory when the instance is not yet + /// indexed (should be rare after [org.jackhuang.hmcl.download.DefaultGameBuilder] registers a + /// placeholder instance). + /// + /// @param manifest the install target manifest + /// @return the mods directory path + private Path modsDirectoryFor(GameInstanceManifest manifest) { + DefaultGameInstance instance = repository.getSnapshot().findInstance(manifest.id()); + if (instance != null) { + return instance.getModsDirectory(); + } + return repository.getBaseDirectory().resolve("mods"); + } + + /// Creates a task that detects and runs a supported local library installer. + /// + /// @param oldVersion the manifest to which the installed patch will be added + /// @param installer the local installer jar + /// @return the task producing the updated manifest public Task installLibraryAsync(GameInstanceManifest oldVersion, Path installer) { return Task .composeAsync(() -> { @@ -202,25 +249,25 @@ public Task installLibraryAsync(GameInstanceManifest oldVe .thenApplyAsync(patch -> patch == null ? oldVersion : oldVersion.addPatch(patch)); } + /// Indicates that a local library installer is not recognized by any supported installer. public static class UnsupportedLibraryInstallerException extends Exception { + + /// Creates an unsupported-installer exception. + public UnsupportedLibraryInstallerException() { + } } - /** - * Remove installed library. - * Will try to remove libraries and patches. - * - * @param manifest not resolved instance manifest - * @param libraryId forge/liteloader/optifine/fabric - * @return task to remove the specified library - */ - public Task removeLibraryAsync(GameInstanceManifest manifest, String libraryId) { - // MaintainTask requires version that does not inherits from any version. - // If we want to remove a library in dependent version, we should keep the dependents not changed - // So resolving this game version to preserve all information in this version.json is necessary. + /// Creates a task that removes a loader's libraries and patch from a manifest. + /// + /// @param manifest the unresolved instance manifest + /// @param componentType the patch identifier, such as `forge`, `optifine`, or `fabric` + /// @return the task producing the updated independent manifest + public Task removeLibraryAsync(GameInstanceManifest manifest, GameComponentType componentType) { + // Library removal operates on a standalone manifest so inherited launch metadata is retained. return Task.supplyAsync(() -> { GameInstanceManifest independentVersion = repository.resolve(manifest).standaloneManifest(); String gameVersion = repository.getGameVersion(independentVersion).orElse(null); - return LibraryAnalyzer.analyze(independentVersion, gameVersion).removeLibrary(libraryId).build(); + return GameComponentAnalyzer.analyze(independentVersion, gameVersion).removeLibrary(componentType); }); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java index e1ac56b747b..31397baf97a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java @@ -44,7 +44,10 @@ public DefaultDependencyManager getDependencyManager() { public Task buildAsync() { var hints = new ArrayList(); - Task libraryTask = Task.supplyAsync(() -> new GameInstanceManifest(name)); + // Register a placeholder instance first so install tasks can resolve run/mods directories + // through GameInstance instead of repository-level path helpers. + Task libraryTask = dependencyManager.getGameRepository() + .saveAsync(new GameInstanceManifest(name)); libraryTask = libraryTask.thenComposeAsync(libraryTaskHelper(gameVersion, "game", gameVersion)); hints.add(new Task.StagesHint("hmcl.install.game:" + gameVersion)); hints.add(new Task.StagesHint("hmcl.install.libraries")); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DependencyManager.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DependencyManager.java index 16a6f43263d..73b75c8df93 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DependencyManager.java @@ -17,87 +17,83 @@ */ package org.jackhuang.hmcl.download; +import org.jackhuang.hmcl.game.GameInstance; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameRepository; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.CacheRepository; -/** - * Do everything that will connect to Internet. - * Downloading Minecraft files. - * - * @author huangyuhui - */ +/// Provides repository-scoped services for downloading and installing game components. public interface DependencyManager { - /** - * The relied game repository. - */ + /// Returns the game repository used for path resolution and instance updates. + /// + /// @return the associated game repository GameRepository getGameRepository(); - /** - * The cache repository - */ + /// Returns the cache repository used by downloads. + /// + /// @return the associated cache repository CacheRepository getCacheRepository(); - /** - * Check if the game is complete. - * Check libraries, assets files and so on. - * - * @return the task to check game completion. - */ - Task checkGameCompletionAsync(GameInstanceManifest manifest, boolean integrityCheck); + /// Creates a task that completes the files required to launch an instance. + /// + /// The instance fixes snapshot-bound identity and storage paths. `manifest` is the effective + /// launch manifest and may differ from [GameInstance#getManifest()] after launch-time + /// maintenance or patching. The instance must belong to [#getGameRepository()]. + /// + /// @param instance the fixed registered instance being prepared + /// @param manifest the effective launch manifest to inspect + /// @param integrityCheck whether existing files must be verified + /// @return the completion task + /// @throws IllegalArgumentException if `instance` belongs to another repository + Task checkGameCompletionAsync(GameInstance instance, GameInstanceManifest manifest, boolean integrityCheck); - /** - * Check if libraries of this version in complete. - * If not, download missing libraries if possible. - * - * @return the task to check game completion. - */ + /// Creates a task that completes the libraries declared by a manifest. + /// + /// @param manifest the manifest whose libraries are checked + /// @param integrityCheck whether existing libraries must be verified + /// @return the library-completion task Task checkLibraryCompletionAsync(GameInstanceManifest manifest, boolean integrityCheck); - /** - * Check if patches of this version in complete. - * If not, reinstall the patch if possible. - * - * @param manifest the version to be checked - * @param integrityCheck check if some libraries are corrupt. - * @return the task to check patches completion. - */ - Task checkPatchCompletionAsync(GameInstanceManifest manifest, boolean integrityCheck); + /// Creates a task that repairs installable patches required by an instance. + /// + /// The stored and resolved manifests used to identify installed patches are read from + /// `instance`; `manifest` supplies the effective launch-time library set. The instance must + /// belong to [#getGameRepository()]. + /// + /// @param instance the fixed registered instance being prepared + /// @param manifest the effective launch manifest to inspect + /// @param integrityCheck whether existing patch libraries must be verified + /// @return the patch-completion task + /// @throws IllegalArgumentException if `instance` belongs to another repository + Task checkPatchCompletionAsync(GameInstance instance, GameInstanceManifest manifest, boolean integrityCheck); - /** - * The builder to build a brand new game then libraries such as Forge, LiteLoader and OptiFine. - */ + /// Creates a builder for installing a new game instance and optional loaders. + /// + /// @return a new game builder GameBuilder newGameBuilder(); - /** - * Install a library to a version. - * **Note**: Installing a library may change the version.json. - * - * @param gameVersion the Minecraft version that the library relies on. - * @param baseVersion the version.json. - * @param libraryId the type of being installed library. i.e. "forge", "liteloader", "optifine" - * @param libraryVersion the version of being installed library. - * @return the task to install the specific library. - */ + /// Creates a task that installs a loader or patch into a base manifest. + /// + /// @param gameVersion the Minecraft version required by the library + /// @param baseVersion the base manifest + /// @param libraryId the registered library type, such as `forge` or `optifine` + /// @param libraryVersion the library version to install + /// @return the installation task Task installLibraryAsync(String gameVersion, GameInstanceManifest baseVersion, String libraryId, String libraryVersion); - /** - * Install a library to a version. - * **Note**: Installing a library may change the version.json. - * - * @param baseVersion the version.json. - * @param libraryVersion the remote version of being installed library. - * @return the task to install the specific library. - */ + /// Creates a task that installs a remote loader or patch into a base manifest. + /// + /// @param baseVersion the base manifest + /// @param libraryVersion the remote library version to install + /// @return the installation task Task installLibraryAsync(GameInstanceManifest baseVersion, RemoteVersion libraryVersion); - /** - * Get registered version list. - * - * @param id the id of version list. i.e. game, forge, liteloader, optifine - * @throws IllegalArgumentException if the version list of specific id is not found. - */ + /// Returns a registered remote-version list. + /// + /// @param id the list identifier, such as `game`, `forge`, or `optifine` + /// @return the registered version list + /// @throws IllegalArgumentException if no list is registered for `id` VersionList getVersionList(String id); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LaunchManifestPreparation.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LaunchManifestPreparation.java new file mode 100644 index 00000000000..d8078c85d7e --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LaunchManifestPreparation.java @@ -0,0 +1,133 @@ +/* + * 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.download; + +import org.jackhuang.hmcl.game.*; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.versioning.VersionNumber; +import org.jetbrains.annotations.NotNullByDefault; + +import java.io.File; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +/// Applies launch-manifest argument adjustments that depend on the installed filesystem. +@NotNullByDefault +public final class LaunchManifestPreparation { + /// Prevents construction of this utility class. + private LaunchManifestPreparation() { + } + + /// Prepares a normalized launch manifest using the current library files. + /// + /// The input must not contain inheritance or pending patches. The returned manifest may replace + /// an old BootstrapLauncher ignore list but retains the input library list. + /// + /// @param repository the repository that owns the installed libraries + /// @param manifest the normalized launch manifest + /// @return the manifest to use for this launch attempt + /// @throws IllegalArgumentException if the manifest is not structurally resolved + public static GameInstanceManifest prepare( + GameRepository repository, + GameInstanceManifest manifest) { + if (manifest.inheritsFrom() != null || !manifest.getPatches().isEmpty()) { + throw new IllegalArgumentException("Launch manifest must be structurally resolved"); + } + + return prepareBootstrapLauncher(repository, manifest); + } + + /// Replaces unsafe substring-based ignore-list entries used by old BootstrapLauncher versions. + /// + /// @param repository the repository that resolves installed classpath entries + /// @param manifest the normalized launch manifest + /// @return the adjusted manifest + private static GameInstanceManifest prepareBootstrapLauncher( + GameRepository repository, + GameInstanceManifest manifest) { + if (!GameComponentAnalyzer.BOOTSTRAP_LAUNCHER_MAIN.equals(manifest.mainClass())) { + return manifest; + } + + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(manifest, null); + if (!analyzer.has(GameComponentType.FORGE) && !analyzer.has(GameComponentType.NEO_FORGE)) { + return manifest; + } + + if (Optional.ofNullable(analyzer.getVersion(GameComponentType.BOOTSTRAP_LAUNCHER)) + .filter(version -> VersionNumber.compare(version, "0.1.17") < 0) + .isEmpty()) { + return manifest; + } + + GameInstanceLibraryBuilder builder = new GameInstanceLibraryBuilder(manifest); + List jvmArguments = builder.getMutableJvmArguments(); + for (int i = 0; i < jvmArguments.size(); i++) { + Argument argument = jvmArguments.get(i); + if (argument instanceof StringArgument) { + String value = argument.toString(); + if (value.startsWith("-DignoreList=")) { + jvmArguments.set(i, new StringArgument( + "-DignoreList=" + updateIgnoreList( + repository, + manifest, + value.substring("-DignoreList=".length())))); + } + } + } + return builder.build(); + } + + /// Converts an old BootstrapLauncher ignore list to exact installed classpath entries. + /// + /// @param repository the repository that resolves installed classpath entries + /// @param manifest the launch manifest + /// @param ignoreList the original comma-separated substring list + /// @return the exact comma-separated ignore list + private static String updateIgnoreList( + GameRepository repository, + GameInstanceManifest manifest, + String ignoreList) { + String[] ignoredSubstrings = ignoreList.split(","); + List exactEntries = new ArrayList<>(); + exactEntries.add("${primary_jar}"); + + Path libraryDirectory = repository.getLayout().getLibrariesDirectory().toAbsolutePath().normalize(); + for (String classpathName : repository.getClasspath(manifest)) { + Path classpathFile = Paths.get(classpathName).toAbsolutePath(); + String fileName = classpathFile.getFileName().toString(); + if (Stream.of(ignoredSubstrings).anyMatch(fileName::contains)) { + String absolutePath; + if (classpathFile.startsWith(libraryDirectory)) { + absolutePath = "${library_directory}${file_separator}" + + libraryDirectory.relativize(classpathFile).toString() + .replace(File.separator, "${file_separator}"); + } else { + absolutePath = classpathFile.toString(); + } + exactEntries.add(StringUtils.substringBefore(absolutePath, ",")); + } + } + return String.join(",", exactEntries); + } + +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LibraryAnalyzer.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LibraryAnalyzer.java deleted file mode 100644 index 055644f3840..00000000000 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LibraryAnalyzer.java +++ /dev/null @@ -1,481 +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.download; - -import org.intellij.lang.annotations.Language; -import org.jackhuang.hmcl.game.*; -import org.jackhuang.hmcl.addon.mod.ModLoaderType; -import org.jackhuang.hmcl.util.Pair; -import org.jackhuang.hmcl.util.versioning.VersionNumber; -import org.jackhuang.hmcl.util.versioning.VersionRange; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.*; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.stream.Collectors; - -import static org.jackhuang.hmcl.util.Pair.pair; - -public final class LibraryAnalyzer implements Iterable { - private GameInstanceManifest manifest; - private final Map> libraries; - - private LibraryAnalyzer(GameInstanceManifest manifest, Map> libraries) { - this.manifest = manifest; - this.libraries = libraries; - } - - public Optional getVersion(LibraryType type) { - return getVersion(type.getPatchId()); - } - - public Optional getVersion(String type) { - return Optional.ofNullable(libraries.get(type)).map(Pair::getValue); - } - - public Optional getLibrary(LibraryType type) { - return Optional.ofNullable(libraries.get(type.getPatchId())).map(Pair::getKey); - } - - /** - * If a library is provided in $.patches, it's structure is so clear that we can do any operation. - * Otherwise, we must guess how are these libraries mixed. - * Maybe a guessing implementation will be provided in the future. But by now, we simply set it to JUST_EXISTED. - */ - public LibraryMark.LibraryStatus getLibraryStatus(String type) { - return manifest.hasPatch(type) ? LibraryMark.LibraryStatus.CLEAR : LibraryMark.LibraryStatus.JUST_EXISTED; - } - - @NotNull - @Override - public Iterator iterator() { - return new Iterator() { - Iterator>> impl = libraries.entrySet().iterator(); - - @Override - public boolean hasNext() { - return impl.hasNext(); - } - - @Override - public LibraryMark next() { - Map.Entry> entry = impl.next(); - return new LibraryMark(entry.getKey(), entry.getValue().getValue(), getLibraryStatus(entry.getKey())); - } - }; - } - - public boolean has(LibraryType type) { - return has(type.getPatchId()); - } - - public boolean has(String type) { - return libraries.containsKey(type); - } - - public boolean hasModLoader() { - return libraries.keySet().stream().map(LibraryType::fromPatchId) - .filter(Objects::nonNull) - .anyMatch(LibraryType::isModLoader); - } - - public boolean hasModLauncher() { - return LibraryAnalyzer.MOD_LAUNCHER_MAIN.equals(manifest.mainClass()) || manifest.getPatches().stream().anyMatch( - patch -> LibraryAnalyzer.MOD_LAUNCHER_MAIN.equals(patch.mainClass()) - ); - } - - private GameInstanceManifest removingMatchedLibrary(GameInstanceManifest manifest, String libraryId) { - LibraryType type = LibraryType.fromPatchId(libraryId); - if (type == null) return manifest; - - List libraries = new ArrayList<>(); - List rawLibraries = manifest.getLibraries(); - for (Library library : rawLibraries) { - if (type.matchLibrary(library, rawLibraries)) { - // skip - } else { - libraries.add(library); - } - } - return manifest.withLibraries(libraries); - } - - private GameInstancePatch removingMatchedLibrary(GameInstancePatch patch, String libraryId) { - LibraryType type = LibraryType.fromPatchId(libraryId); - if (type == null) return patch; - - List libraries = new ArrayList<>(); - List rawLibraries = patch.getLibraries(); - for (Library library : rawLibraries) { - if (type.matchLibrary(library, rawLibraries)) { - // skip - } else { - libraries.add(library); - } - } - return patch.withLibraries(libraries); - } - - /** - * Remove library by library id - * - * @param libraryId patch id or "forge"/"optifine"/"liteloader"/"fabric"/"quilt"/"neoforge"/"cleanroom" - * @return this - */ - public LibraryAnalyzer removeLibrary(String libraryId) { - if (!has(libraryId)) return this; - GameInstanceManifest manifest = removingMatchedLibrary(this.manifest, libraryId); - this.manifest = manifest.withPatches(this.manifest.getPatches().stream() - .filter(patch -> !libraryId.equals(patch.id())) - .map(patch -> removingMatchedLibrary(patch, libraryId)) - .collect(Collectors.toList())); - return this; - } - - public GameInstanceManifest build() { - return manifest; - } - - public static LibraryAnalyzer analyze(GameInstanceManifest.Resolved resolved, String gameVersion) { - Map> libraries = new HashMap<>(); - - if (gameVersion != null) { - libraries.put(LibraryType.MINECRAFT.getPatchId(), pair(null, gameVersion)); - } - - List rawLibraries = resolved.launchManifest().getLibraries(); - for (Library library : rawLibraries) { - for (LibraryType type : LibraryType.values()) { - if (type.matchLibrary(library, rawLibraries)) { - libraries.put(type.getPatchId(), pair(library, type.patchVersion(resolved.standaloneManifest(), library.version()))); - break; - } - } - } - - for (GameInstancePatch patch : resolved.standaloneManifest().getPatches()) { - if (patch.isHidden()) continue; - libraries.put(patch.id(), pair(null, patch.version())); - } - - return new LibraryAnalyzer(resolved.standaloneManifest(), libraries); - } - - public static LibraryAnalyzer analyze(GameInstanceManifest manifest, String gameVersion) { - if (manifest.inheritsFrom() != null) - throw new IllegalArgumentException("LibraryAnalyzer can only analyze independent game version"); - - Map> libraries = new HashMap<>(); - - if (gameVersion != null) { - libraries.put(LibraryType.MINECRAFT.getPatchId(), pair(null, gameVersion)); - } - - List rawLibraries = manifest.getLibraries(); - for (Library library : rawLibraries) { - for (LibraryType type : LibraryType.values()) { - if (type.matchLibrary(library, rawLibraries)) { - libraries.put(type.getPatchId(), pair(library, type.patchVersion(manifest, library.version()))); - break; - } - } - } - - for (GameInstancePatch patch : manifest.getPatches()) { - if (patch.isHidden()) continue; - libraries.put(patch.id(), pair(null, patch.version())); - } - - return new LibraryAnalyzer(manifest, libraries); - } - - public static boolean isModded(GameInstanceManifest.Resolved resolved) { - String mainClass = resolved.launchManifest().mainClass(); - return mainClass != null && (LAUNCH_WRAPPER_MAIN.equals(mainClass) - || mainClass.startsWith("net.minecraftforge") - || mainClass.startsWith("net.neoforged") - || mainClass.startsWith("top.outlands") //Cleanroom - || mainClass.startsWith("net.fabricmc") - || mainClass.startsWith("org.quiltmc") - || mainClass.startsWith("cpw.mods")); - } - - public Set getModLoaders() { - return Arrays.stream(LibraryType.values()) - .filter(LibraryType::isModLoader) - .filter(this::has) - .map(LibraryType::getModLoaderType) - .filter(Objects::nonNull) - .collect(Collectors.toSet()); - } - - public enum LibraryType { - MINECRAFT(true, "game", "^$", "^$", null), - LEGACY_FABRIC(true, "legacyfabric", "net\\.fabricmc", "fabric-loader", ModLoaderType.LEGACY_FABRIC) { - @Override - protected boolean matchLibrary(Library library, List libraries) { - if (!super.matchLibrary(library, libraries)) { - return false; - } - for (Library l : libraries) { - if ("net.legacyfabric".equals(l.groupId())) { - return true; - } - } - return false; - } - }, - LEGACY_FABRIC_API(false, "legacyfabric-api", "net\\.legacyfabric", "legacyfabric-api", null), - FABRIC(true, "fabric", "net\\.fabricmc", "fabric-loader", ModLoaderType.FABRIC) { - @Override - protected boolean matchLibrary(Library library, List libraries) { - if (!super.matchLibrary(library, libraries)) { - return false; - } - for (Library l : libraries) { - if ("net.legacyfabric".equals(l.groupId())) { - return false; - } - } - return true; - } - }, - FABRIC_API(true, "fabric-api", "net\\.fabricmc", "fabric-api", null), - FORGE(true, "forge", "net\\.minecraftforge", "(forge|fmlloader)", ModLoaderType.FORGE) { - private final Pattern FORGE_VERSION_MATCHER = Pattern.compile("^([0-9.]+)-(?[0-9.]+)(-([0-9.]+))?$"); - - @Override - protected String patchVersion(GameInstanceManifest manifest, String libraryVersion) { - Matcher matcher = FORGE_VERSION_MATCHER.matcher(libraryVersion); - if (matcher.find()) { - return matcher.group("forge"); - } - return super.patchVersion(manifest, libraryVersion); - } - - @Override - protected boolean matchLibrary(Library library, List libraries) { - for (Library l : libraries) { - if (NEO_FORGE.matchLibrary(l, libraries)) { - return false; - } - } - return super.matchLibrary(library, libraries); - } - }, - CLEANROOM(true, "cleanroom", "com\\.cleanroommc", "cleanroom", ModLoaderType.CLEANROOM), - NEO_FORGE(true, "neoforge", "net\\.neoforged\\.fancymodloader", "(core|loader)", ModLoaderType.NEO_FORGE) { - private final Pattern NEO_FORGE_VERSION_MATCHER = Pattern.compile("^([0-9.]+)-(?[0-9.]+)(-([0-9.]+))?$"); - - @Override - protected String patchVersion(GameInstanceManifest manifest, String libraryVersion) { - String res = scanVersion(manifest); - if (res != null) { - return res; - } - - for (GameInstancePatch patch : manifest.getPatches()) { - res = scanPatch(patch); - if (res != null) { - return res; - } - } - - Matcher matcher = NEO_FORGE_VERSION_MATCHER.matcher(libraryVersion); - if (matcher.find()) { - return matcher.group("forge"); - } - - return super.patchVersion(manifest, libraryVersion); - } - - private String scanVersion(GameInstanceManifest manifest) { - if (manifest.arguments() == null) { - return null; - } - List gameArguments = manifest.arguments().game(); - if (gameArguments == null) { - return null; - } - - for (int i = 0; i < gameArguments.size() - 1; i++) { - Argument argument = gameArguments.get(i); - if (argument instanceof StringArgument) { - String argumentValue = ((StringArgument) argument).argument(); - if ("--fml.neoForgeVersion".equals(argumentValue) || "--fml.forgeVersion".equals(argumentValue)) { - Argument next = gameArguments.get(i + 1); - if (next instanceof StringArgument) { - return ((StringArgument) next).argument(); - } - return null; // Normally, there should not be two --fml.neoForgeVersion argument. - } - } - } - return null; - } - - private String scanPatch(GameInstancePatch patch) { - Arguments optArgument = patch.arguments(); - if (optArgument == null) { - return null; - } - List gameArguments = optArgument.game(); - if (gameArguments == null) { - return null; - } - - for (int i = 0; i < gameArguments.size() - 1; i++) { - Argument argument = gameArguments.get(i); - if (argument instanceof StringArgument) { - String argumentValue = ((StringArgument) argument).argument(); - if ("--fml.neoForgeVersion".equals(argumentValue) || "--fml.forgeVersion".equals(argumentValue)) { - Argument next = gameArguments.get(i + 1); - if (next instanceof StringArgument) { - return ((StringArgument) next).argument(); - } - return null; - } - } - } - return null; - } - - }, - LITELOADER(true, "liteloader", "com\\.mumfrey", "liteloader", ModLoaderType.LITE_LOADER), - OPTIFINE(false, "optifine", "(net\\.)?optifine", "^(?!.*launchwrapper).*$", null), - QUILT(true, "quilt", "org\\.quiltmc", "quilt-loader", ModLoaderType.QUILT), - QUILT_API(true, "quilt-api", "org\\.quiltmc", "quilt-api", null), - BOOTSTRAP_LAUNCHER(false, "", "cpw\\.mods", "bootstraplauncher", null); - - private final boolean modLoader; - private final String patchId; - private final Pattern group, artifact; - private final ModLoaderType modLoaderType; - - private static final Map PATCH_ID_MAP = new HashMap<>(); - - static { - for (LibraryType type : values()) { - PATCH_ID_MAP.put(type.getPatchId(), type); - } - } - - LibraryType(boolean modLoader, String patchId, @Language("RegExp") String group, @Language("RegExp") String artifact, ModLoaderType modLoaderType) { - this.modLoader = modLoader; - this.patchId = patchId; - this.group = Pattern.compile(group); - this.artifact = Pattern.compile(artifact); - this.modLoaderType = modLoaderType; - } - - public boolean isModLoader() { - return modLoader; - } - - public String getPatchId() { - return patchId; - } - - public ModLoaderType getModLoaderType() { - return modLoaderType; - } - - public static LibraryType fromPatchId(String patchId) { - return PATCH_ID_MAP.get(patchId); - } - - protected boolean matchLibrary(Library library, List libraries) { - return group.matcher(library.groupId()).matches() && artifact.matcher(library.artifactId()).matches(); - } - - protected String patchVersion(GameInstanceManifest manifest, String libraryVersion) { - return libraryVersion; - } - } - - public final static class LibraryMark { - /** - * If a library is provided in $.patches, it's structure is so clear that we can do any operation. - * Otherwise, we must guess how are these libraries mixed. - * Maybe a guessing implementation will be provided in the future. But by now, we simply set it to JUST_EXISTED. - */ - public enum LibraryStatus { - CLEAR, UNSURE, JUST_EXISTED - } - - private final String libraryId; - private final String libraryVersion; - /** - * If this version is installed by HMCL, instead of external process, - * which means $.patches contains this library, structureClear is true. - */ - private final LibraryStatus status; - - private LibraryMark(@NotNull String libraryId, @Nullable String libraryVersion, LibraryStatus status) { - this.libraryId = libraryId; - this.libraryVersion = libraryVersion; - this.status = status; - } - - @NotNull - public String getLibraryId() { - return libraryId; - } - - @Nullable - public String getLibraryVersion() { - return libraryVersion; - } - - public LibraryStatus getStatus() { - return status; - } - } - - public static final String VANILLA_MAIN = "net.minecraft.client.main.Main"; - public static final String LAUNCH_WRAPPER_MAIN = "net.minecraft.launchwrapper.Launch"; - public static final String MOD_LAUNCHER_MAIN = "cpw.mods.modlauncher.Launcher"; - public static final String BOOTSTRAP_LAUNCHER_MAIN = "cpw.mods.bootstraplauncher.BootstrapLauncher"; - public static final String FORGE_BOOTSTRAP_MAIN = "net.minecraftforge.bootstrap.ForgeBootstrap"; - public static final String NEO_FORGE_BOOTSTRAP_MAIN = "net.neoforged.fml.startup.Client"; - - public static final Set FORGE_OPTIFINE_MAIN = Set.of( - LibraryAnalyzer.VANILLA_MAIN, - LibraryAnalyzer.LAUNCH_WRAPPER_MAIN, - LibraryAnalyzer.MOD_LAUNCHER_MAIN, - LibraryAnalyzer.BOOTSTRAP_LAUNCHER_MAIN, - LibraryAnalyzer.FORGE_BOOTSTRAP_MAIN, - LibraryAnalyzer.NEO_FORGE_BOOTSTRAP_MAIN - ); - - public static final VersionRange FORGE_OPTIFINE_BROKEN_RANGE = VersionNumber.between("48.0.0", "49.0.50"); - - public static final String[] FORGE_TWEAKERS = new String[]{ - "net.minecraftforge.legacy._1_5_2.LibraryFixerTweaker", // 1.5.2 - "cpw.mods.fml.common.launcher.FMLTweaker", // 1.6.1 ~ 1.7.10 - "net.minecraftforge.fml.common.launcher.FMLTweaker" // 1.8 ~ 1.12.2 - }; - public static final String[] OPTIFINE_TWEAKERS = new String[]{ - "optifine.OptiFineTweaker", - "optifine.OptiFineForgeTweaker" - }; - public static final String LITELOADER_TWEAKER = "com.mumfrey.liteloader.launch.LiteLoaderTweaker"; -} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/MaintainTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/MaintainTask.java deleted file mode 100644 index 770137a8df0..00000000000 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/MaintainTask.java +++ /dev/null @@ -1,341 +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.download; - -import org.jackhuang.hmcl.game.*; -import org.jackhuang.hmcl.task.Task; -import org.jackhuang.hmcl.util.SimpleMultimap; -import org.jackhuang.hmcl.util.StringUtils; -import org.jackhuang.hmcl.util.gson.JsonUtils; -import org.jackhuang.hmcl.util.versioning.VersionNumber; - -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.StandardCopyOption; -import java.util.*; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.*; -import static org.jackhuang.hmcl.util.logging.Logger.LOG; - -public class MaintainTask extends Task { - private final GameRepository repository; - private final GameInstanceManifest manifest; - - public MaintainTask(GameRepository repository, GameInstanceManifest manifest) { - this.repository = repository; - this.manifest = manifest; - - if (manifest.inheritsFrom() != null) - throw new IllegalArgumentException("MaintainTask requires independent game version"); - } - - @Override - public void execute() { - setResult(maintain(repository, manifest)); - } - - public static GameInstanceManifest maintain(GameRepository repository, GameInstanceManifest manifest) { - if (manifest.inheritsFrom() != null) - throw new IllegalArgumentException("MaintainTask requires independent game version"); - - String mainClass = manifest.resolve(repository).mainClass(); - - if (mainClass != null && mainClass.equals(LibraryAnalyzer.LAUNCH_WRAPPER_MAIN)) { - manifest = maintainOptiFineLibrary(repository, maintainGameWithLaunchWrapper(repository, unique(manifest), true), false); - } else if (mainClass != null && mainClass.equals(LibraryAnalyzer.MOD_LAUNCHER_MAIN)) { - // Forge 1.13 and OptiFine - manifest = maintainOptiFineLibrary(repository, maintainGameWithCpwModLauncher(repository, unique(manifest)), true); - } else if (mainClass != null && mainClass.equals(LibraryAnalyzer.BOOTSTRAP_LAUNCHER_MAIN)) { - // Forge 1.17 - manifest = maintainGameWithCpwBoostrapLauncher(repository, unique(manifest)); - } else { - // Vanilla Minecraft does not need maintain - // Fabric does not need maintain, nothing compatible with fabric now. - manifest = maintainOptiFineLibrary(repository, unique(manifest), false); - } - - List libraries = manifest.getLibraries(); - if (!libraries.isEmpty()) { - // HMCL once use log4j-patch to prevent virus. But now, we only modify log4j2.xml. - // Therefore, we remove this library. - Library library = libraries.get(0); - if ("org.glavo".equals(library.groupId()) - && ("log4j-patch".equals(library.artifactId()) || "log4j-patch-beta9".equals(library.artifactId())) - && "1.0".equals(library.version()) - && library.getDownload() == null) { - manifest = manifest.withLibraries(libraries.subList(1, libraries.size())); - } - } - - return manifest; - } - - public static GameInstanceManifest maintainPreservingPatches(GameRepository repository, GameInstanceManifest manifest) { - if (!manifest.isResolvedPreservingPatches()) - throw new IllegalArgumentException("MaintainTask requires independent game version"); - GameInstanceManifest newVersion = maintain(repository, manifest.resolve(repository)); - return newVersion.withPatches(manifest.getPatches()); - } - - private static GameInstanceManifest maintainGameWithLaunchWrapper(GameRepository repository, GameInstanceManifest manifest, boolean reorderTweakClass) { - LibraryAnalyzer libraryAnalyzer = LibraryAnalyzer.analyze(manifest, null); - GameInstanceLibraryBuilder builder = new GameInstanceLibraryBuilder(manifest); - String mainClass = null; - - // Installing Forge will override the Minecraft arguments in json, so LiteLoader and OptiFine Tweaker are being re-added. - if (libraryAnalyzer.has(LITELOADER) && !libraryAnalyzer.hasModLauncher()) { - builder.replaceTweakClass(LibraryAnalyzer.LITELOADER_TWEAKER, LibraryAnalyzer.LITELOADER_TWEAKER, !reorderTweakClass, reorderTweakClass); - } else { - builder.removeTweakClass(LibraryAnalyzer.LITELOADER_TWEAKER); - } - - if (libraryAnalyzer.has(OPTIFINE)) { - if (!libraryAnalyzer.has(LITELOADER) && !libraryAnalyzer.has(FORGE)) { - if (builder.hasTweakClass(LibraryAnalyzer.OPTIFINE_TWEAKERS[1])) { - builder.replaceTweakClass(LibraryAnalyzer.OPTIFINE_TWEAKERS[1], LibraryAnalyzer.OPTIFINE_TWEAKERS[0], !reorderTweakClass, reorderTweakClass); - } - } else { - if (libraryAnalyzer.hasModLauncher()) { - // If ModLauncher installed, we use ModLauncher in place of LaunchWrapper. - mainClass = LibraryAnalyzer.MOD_LAUNCHER_MAIN; - for (String optiFineTweaker : LibraryAnalyzer.OPTIFINE_TWEAKERS) { - builder.removeTweakClass(optiFineTweaker); - } - } else if (builder.hasTweakClass(LibraryAnalyzer.OPTIFINE_TWEAKERS[0])) { - // If forge or LiteLoader installed, OptiFine Forge Tweaker is needed. - builder.replaceTweakClass(LibraryAnalyzer.OPTIFINE_TWEAKERS[0], LibraryAnalyzer.OPTIFINE_TWEAKERS[1], !reorderTweakClass, reorderTweakClass); - } - - } - } else { - for (String optiFineTweaker : LibraryAnalyzer.OPTIFINE_TWEAKERS) { - builder.removeTweakClass(optiFineTweaker); - } - } - - boolean hasForge = libraryAnalyzer.has(FORGE), hasModLauncher = libraryAnalyzer.hasModLauncher(); - for (String forgeTweaker : LibraryAnalyzer.FORGE_TWEAKERS) { - if (!hasForge) { - builder.removeTweakClass(forgeTweaker); - } else if (!hasModLauncher && builder.hasTweakClass(forgeTweaker)) { - builder.replaceTweakClass(forgeTweaker, forgeTweaker, !reorderTweakClass, reorderTweakClass); - } - } - - GameInstanceManifest ret = builder.build(); - return mainClass == null ? ret : ret.withMainClass(mainClass); - } - - private static GameInstanceManifest maintainGameWithCpwModLauncher(GameRepository repository, GameInstanceManifest manifest) { - LibraryAnalyzer libraryAnalyzer = LibraryAnalyzer.analyze(manifest, null); - GameInstanceLibraryBuilder builder = new GameInstanceLibraryBuilder(manifest); - - if (!libraryAnalyzer.has(FORGE)) return manifest; - - if (libraryAnalyzer.has(OPTIFINE)) { - Library hmclTransformerDiscoveryService = new Library(new Artifact("org.jackhuang.hmcl", "transformer-discovery-service", "1.0")); - Optional optiFine = manifest.getLibraries().stream().filter(library -> library.is("optifine", "OptiFine")).findAny(); - boolean libraryExisting = manifest.getLibraries().stream().anyMatch(library -> library.is("org.jackhuang.hmcl", "transformer-discovery-service")); - optiFine.ifPresent(library -> { - builder.addJvmArgument("-Dhmcl.transformer.candidates=${library_directory}/" + library.getPath()); - if (!libraryExisting) builder.addLibrary(hmclTransformerDiscoveryService); - Path libraryPath = repository.getLibraryFile(manifest, hmclTransformerDiscoveryService); - try (InputStream input = MaintainTask.class.getResourceAsStream("/assets/game/HMCLTransformerDiscoveryService-1.0.jar")) { - Files.createDirectories(libraryPath.getParent()); - Files.copy(Objects.requireNonNull(input, "Bundled HMCLTransformerDiscoveryService is missing."), libraryPath, StandardCopyOption.REPLACE_EXISTING); - } catch (IOException | NullPointerException e) { - LOG.warning("Unable to unpack HMCLTransformerDiscoveryService", e); - } - }); - } - - return builder.build(); - } - - private static String updateIgnoreList(GameRepository repository, GameInstanceManifest manifest, String ignoreList) { - String[] ignores = ignoreList.split(","); - List newIgnoreList = new ArrayList<>(); - - // To resolve the problem that name of primary jar may conflict with the module naming convention, - // we need to manually ignore ${primary_jar}. - newIgnoreList.add("${primary_jar}"); - - Path libraryDirectory = repository.getLibrariesDirectory(manifest).toAbsolutePath().normalize(); - - // The default ignoreList is too loose and may cause some problems, we replace them with the absolute version. - // For example, if "client-extra" is in ignoreList, and game directory contains "client-extra" component, all - // libraries will be ignored, which is not expected. - for (String classpathName : repository.getClasspath(manifest)) { - Path classpathFile = Paths.get(classpathName).toAbsolutePath(); - String fileName = classpathFile.getFileName().toString(); - if (Stream.of(ignores).anyMatch(fileName::contains)) { - // This library should be ignored for Jigsaw module finding by Forge. - String absolutePath; - if (classpathFile.startsWith(libraryDirectory)) { - // Note: It's assumed using "/" instead of File.separator in classpath - absolutePath = "${library_directory}${file_separator}" + libraryDirectory.relativize(classpathFile).toString().replace(File.separator, "${file_separator}"); - } else { - absolutePath = classpathFile.toString(); - } - newIgnoreList.add(StringUtils.substringBefore(absolutePath, ",")); - } - } - return String.join(",", newIgnoreList); - } - - // Fix wrong configurations when launching 1.17+ with Forge. - private static GameInstanceManifest maintainGameWithCpwBoostrapLauncher(GameRepository repository, GameInstanceManifest manifest) { - LibraryAnalyzer libraryAnalyzer = LibraryAnalyzer.analyze(manifest, null); - GameInstanceLibraryBuilder builder = new GameInstanceLibraryBuilder(manifest); - - if (!libraryAnalyzer.has(FORGE) && !libraryAnalyzer.has(NEO_FORGE)) return manifest; - - Optional bslVersion = libraryAnalyzer.getVersion(BOOTSTRAP_LAUNCHER); - - if (bslVersion.isPresent()) { - if (VersionNumber.compare(bslVersion.get(), "0.1.17") < 0) { - // The default ignoreList will be applied to all components of libraries in classpath, - // so if game directory located in some directory like /Users/asm, all libraries will be ignored, - // which is not expected. We fix this here. - List jvm = builder.getMutableJvmArguments(); - for (int i = 0; i < jvm.size(); i++) { - Argument jvmArg = jvm.get(i); - if (jvmArg instanceof StringArgument) { - String jvmArgStr = jvmArg.toString(); - if (jvmArgStr.startsWith("-DignoreList=")) { - jvm.set(i, new StringArgument("-DignoreList=" + updateIgnoreList(repository, manifest, jvmArgStr.substring("-DignoreList=".length())))); - } - } - } - } else { - // bootstraplauncher 0.1.17 will only apply ignoreList to file name of libraries in classpath. - // So we only fixes name of primary jar. - List jvm = builder.getMutableJvmArguments(); - for (int i = 0; i < jvm.size(); i++) { - Argument jvmArg = jvm.get(i); - if (jvmArg instanceof StringArgument) { - String jvmArgStr = jvmArg.toString(); - if (jvmArgStr.startsWith("-DignoreList=")) { - jvm.set(i, new StringArgument(jvmArgStr + ",${primary_jar_name}")); - } - } - } - } - } - - return builder.build(); - } - - private static GameInstanceManifest maintainOptiFineLibrary(GameRepository repository, GameInstanceManifest manifest, boolean remove) { - LibraryAnalyzer libraryAnalyzer = LibraryAnalyzer.analyze(manifest, null); - List libraries = new ArrayList<>(manifest.getLibraries()); - - if (libraryAnalyzer.has(OPTIFINE)) { - if (libraryAnalyzer.has(LITELOADER) || libraryAnalyzer.has(FORGE)) { - // If forge or LiteLoader installed, OptiFine Forge Tweaker is needed. - // And we should load the installer jar instead of patch jar. - if (repository != null) { - for (int i = 0; i < manifest.getLibraries().size(); ++i) { - Library library = libraries.get(i); - if (library.is("optifine", "OptiFine")) { - Library newLibrary = new Library(new Artifact("optifine", "OptiFine", library.version(), "installer")); - if (Files.exists(repository.getLibraryFile(manifest, newLibrary))) { - libraries.set(i, null); - // OptiFine should be loaded after Forge in classpath. - // Although we have altered priority of OptiFine higher than Forge, - // there still exists a situation that Forge is installed without patch. - // Here we manually alter the position of OptiFine library in classpath. - if (!remove) libraries.add(newLibrary); - } - } - - if (library.is("optifine", "launchwrapper-of")) { - // With MinecraftForge installed, the custom launchwrapper installed by OptiFine will conflicts - // with the one installed by MinecraftForge or LiteLoader or ModLoader. - // Simply removing it works. - libraries.set(i, null); - } - } - } - } - } - - return manifest.withLibraries(libraries.stream().filter(Objects::nonNull).collect(Collectors.toList())); - } - - public static GameInstanceManifest unique(GameInstanceManifest manifest) { - List libraries = new ArrayList<>(); - - SimpleMultimap> multimap = new SimpleMultimap<>(HashMap::new, ArrayList::new); - - for (Library library : manifest.getLibraries()) { - String id = library.groupId() + ":" + library.artifactId(); - VersionNumber number = VersionNumber.asVersion(library.version()); - String serialized = JsonUtils.GSON.toJson(library); - - if (multimap.containsKey(id)) { - boolean duplicate = false; - for (int otherLibraryIndex : multimap.get(id)) { - Library otherLibrary = libraries.get(otherLibraryIndex); - VersionNumber otherNumber = VersionNumber.asVersion(otherLibrary.version()); - if (CompatibilityRule.equals(library.rules(), otherLibrary.rules())) { // rules equal, ignore older version. - boolean flag = true; - if (number.compareTo(otherNumber) > 0) { // if this library is newer - // replace [otherLibrary] with [library] - libraries.set(otherLibraryIndex, library); - } else if (number.compareTo(otherNumber) == 0) { // same library id. - // prevent from duplicated libraries - if (library.equals(otherLibrary)) { - String otherSerialized = JsonUtils.GSON.toJson(otherLibrary); - // A trick, the library that has more information is better, which can be - // considered whose serialized JSON text will be longer. - if (serialized.length() > otherSerialized.length()) { - libraries.set(otherLibraryIndex, library); - } - } else { - // for text2speech, which have same library id as well as version number, - // but its library and native library does not equal - flag = false; - } - } - if (flag) { - duplicate = true; - break; - } - } - } - - if (!duplicate) { - multimap.put(id, libraries.size()); - libraries.add(library); - } - } else { - multimap.put(id, libraries.size()); - libraries.add(library); - } - } - - return manifest.withLibraries(libraries); - } -} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/RemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/RemoteVersion.java index c3bf1da8e08..5c38a5a4c5b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/RemoteVersion.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/RemoteVersion.java @@ -17,12 +17,14 @@ */ package org.jackhuang.hmcl.download; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.ToStringBuilder; import org.jackhuang.hmcl.util.versioning.VersionNumber; +import java.nio.file.Path; import java.time.Instant; import java.util.List; import java.util.Objects; @@ -34,6 +36,7 @@ */ public class RemoteVersion implements Comparable { + private final GameComponentType componentType; private final String libraryId; private final String gameVersion; private final String selfVersion; @@ -48,8 +51,8 @@ public class RemoteVersion implements Comparable { * @param selfVersion the version string of the remote version. * @param urls the installer or universal jar original URL. */ - public RemoteVersion(String libraryId, String gameVersion, String selfVersion, Instant releaseDate, List urls) { - this(libraryId, gameVersion, selfVersion, releaseDate, Type.UNCATEGORIZED, urls); + public RemoteVersion(GameComponentType componentType, String gameVersion, String selfVersion, Instant releaseDate, List urls) { + this(componentType, gameVersion, selfVersion, releaseDate, Type.UNCATEGORIZED, urls); } /** @@ -59,8 +62,9 @@ public RemoteVersion(String libraryId, String gameVersion, String selfVersion, I * @param selfVersion the version string of the remote version. * @param urls the installer or universal jar URL. */ - public RemoteVersion(String libraryId, String gameVersion, String selfVersion, Instant releaseDate, Type type, List urls) { - this.libraryId = Objects.requireNonNull(libraryId); + public RemoteVersion(GameComponentType componentType, String gameVersion, String selfVersion, Instant releaseDate, Type type, List urls) { + this.componentType = Objects.requireNonNull(componentType); + this.libraryId = componentType.getPatchId(); this.gameVersion = Objects.requireNonNull(gameVersion); this.selfVersion = Objects.requireNonNull(selfVersion); this.releaseDate = releaseDate; @@ -68,8 +72,12 @@ public RemoteVersion(String libraryId, String gameVersion, String selfVersion, I this.type = Objects.requireNonNull(type); } + public GameComponentType getComponentType() { + return componentType; + } + public String getLibraryId() { - return libraryId; + return getComponentType().getPatchId(); } public String getGameVersion() { @@ -100,6 +108,23 @@ public Task getInstallTask(DefaultDependencyManager dependenc throw new UnsupportedOperationException(this + " cannot be installed yet"); } + /// Creates an install task with an explicit mods directory for libraries that download into the + /// instance run tree (for example Fabric/Quilt API). + /// + /// The default implementation ignores `modsDirectory` and delegates to + /// [#getInstallTask(DefaultDependencyManager, GameInstanceManifest)]. + /// + /// @param dependencyManager the dependency manager + /// @param baseVersion the manifest being installed into + /// @param modsDirectory the mods directory of the target instance run directory + /// @return the install task + public Task getInstallTask( + DefaultDependencyManager dependencyManager, + GameInstanceManifest baseVersion, + Path modsDirectory) { + return getInstallTask(dependencyManager, baseVersion); + } + @Override public boolean equals(Object obj) { return obj instanceof RemoteVersion && Objects.equals(selfVersion, ((RemoteVersion) obj).selfVersion); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/cleanroom/CleanroomInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/cleanroom/CleanroomInstallTask.java index a7d7ff82733..92dd874bf46 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/cleanroom/CleanroomInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/cleanroom/CleanroomInstallTask.java @@ -18,11 +18,11 @@ package org.jackhuang.hmcl.download.cleanroom; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.UnsupportedInstallationException; import org.jackhuang.hmcl.download.VersionMismatchException; import org.jackhuang.hmcl.download.forge.ForgeNewInstallProfile; import org.jackhuang.hmcl.download.forge.ForgeNewInstallTask; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.task.FileDownloadTask; @@ -113,9 +113,11 @@ public Collection> getDependencies() { @Override public void execute() throws IOException, VersionMismatchException, UnsupportedInstallationException { if (selfVersion == null) { - task = new ForgeNewInstallTask(dependencyManager, manifest, remote.getSelfVersion(), installer).thenApplyAsync((version) -> version.withId(LibraryAnalyzer.LibraryType.CLEANROOM.getPatchId())); + task = new ForgeNewInstallTask(dependencyManager, manifest, remote.getSelfVersion(), installer) + .thenApplyAsync((version) -> version.withId(GameComponentType.CLEANROOM)); } else { - task = new ForgeNewInstallTask(dependencyManager, manifest, selfVersion, installer).thenApplyAsync((version) -> version.withId(LibraryAnalyzer.LibraryType.CLEANROOM.getPatchId())); + task = new ForgeNewInstallTask(dependencyManager, manifest, selfVersion, installer) + .thenApplyAsync((version) -> version.withId(GameComponentType.CLEANROOM)); } } @@ -125,7 +127,7 @@ public static Task install(DefaultDependencyManager dependenc try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(installer)) { String installProfileText = Files.readString(fs.getPath("install_profile.json")); Map installProfile = JsonUtils.fromNonNullJson(installProfileText, Map.class); - if (LibraryAnalyzer.LibraryType.CLEANROOM.getPatchId().equals(installProfile.get("profile"))) { + if (GameComponentType.CLEANROOM.getPatchId().equals(installProfile.get("profile"))) { ForgeNewInstallProfile profile = JsonUtils.fromNonNullJson(installProfileText, ForgeNewInstallProfile.class); if (!gameVersion.get().equals(profile.getMinecraft())) throw new VersionMismatchException(profile.getMinecraft(), gameVersion.get()); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/cleanroom/CleanroomRemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/cleanroom/CleanroomRemoteVersion.java index 3f88ef351cd..e7a3d073922 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/cleanroom/CleanroomRemoteVersion.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/cleanroom/CleanroomRemoteVersion.java @@ -18,8 +18,8 @@ package org.jackhuang.hmcl.download.cleanroom; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.task.Task; @@ -29,7 +29,7 @@ public class CleanroomRemoteVersion extends RemoteVersion { public CleanroomRemoteVersion(String gameVersion, String selfVersion, Instant releaseDate, List url) { - super(LibraryAnalyzer.LibraryType.CLEANROOM.getPatchId(), gameVersion, selfVersion, releaseDate, url); + super(GameComponentType.CLEANROOM, gameVersion, selfVersion, releaseDate, url); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricAPIInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricAPIInstallTask.java index 6e3c67e8521..03193f98704 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricAPIInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricAPIInstallTask.java @@ -24,6 +24,7 @@ import org.jackhuang.hmcl.task.Task; import java.io.IOException; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -38,12 +39,22 @@ public final class FabricAPIInstallTask extends Task { private final DefaultDependencyManager dependencyManager; private final GameInstanceManifest manifest; private final FabricAPIRemoteVersion remote; + private final Path modsDirectory; private final List> dependencies = new ArrayList<>(1); - public FabricAPIInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest manifest, FabricAPIRemoteVersion remoteVersion) { + /// @param dependencyManager the dependency manager + /// @param manifest the manifest being installed into + /// @param remoteVersion the Fabric API remote version + /// @param modsDirectory the target mods directory (must already be resolved by the caller) + public FabricAPIInstallTask( + DefaultDependencyManager dependencyManager, + GameInstanceManifest manifest, + FabricAPIRemoteVersion remoteVersion, + Path modsDirectory) { this.dependencyManager = dependencyManager; this.manifest = manifest; this.remote = remoteVersion; + this.modsDirectory = modsDirectory; } @Override @@ -60,7 +71,7 @@ public boolean isRelyingOnDependencies() { public void execute() throws IOException { dependencies.add(new FileDownloadTask( remote.getVersion().file().url(), - dependencyManager.getGameRepository().getModsDirectory(manifest.id()).resolve("fabric-api-" + remote.getVersion().version() + ".jar"), + modsDirectory.resolve("fabric-api-" + remote.getVersion().version() + ".jar"), remote.getVersion().file().getIntegrityCheck()) ); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricAPIRemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricAPIRemoteVersion.java index db2e955ccc3..03b287370cb 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricAPIRemoteVersion.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricAPIRemoteVersion.java @@ -18,13 +18,14 @@ package org.jackhuang.hmcl.download.fabric; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.addon.RemoteAddon; import org.jackhuang.hmcl.task.Task; +import java.nio.file.Path; import java.time.Instant; import java.util.List; @@ -40,7 +41,7 @@ public class FabricAPIRemoteVersion extends RemoteVersion { * @param urls the installer or universal jar original URL. */ FabricAPIRemoteVersion(String gameVersion, String selfVersion, String fullVersion, Instant datePublished, RemoteAddon.Version version, List urls) { - super(LibraryAnalyzer.LibraryType.FABRIC_API.getPatchId(), gameVersion, selfVersion, datePublished, urls); + super(GameComponentType.FABRIC_API, gameVersion, selfVersion, datePublished, urls); this.fullVersion = fullVersion; this.version = version; @@ -56,8 +57,11 @@ public RemoteAddon.Version getVersion() { } @Override - public Task getInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest baseVersion) { - return new FabricAPIInstallTask(dependencyManager, baseVersion, this); + public Task getInstallTask( + DefaultDependencyManager dependencyManager, + GameInstanceManifest baseVersion, + Path modsDirectory) { + return new FabricAPIInstallTask(dependencyManager, baseVersion, this, modsDirectory); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricInstallTask.java index 72c8764cee6..88206744c19 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricInstallTask.java @@ -20,14 +20,9 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.UnsupportedInstallationException; import org.jackhuang.hmcl.download.game.GameLibrariesTask; -import org.jackhuang.hmcl.game.Arguments; -import org.jackhuang.hmcl.game.Artifact; -import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.GameInstancePatch; -import org.jackhuang.hmcl.game.Library; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.task.GetTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.JsonSerializable; @@ -127,7 +122,7 @@ private GameInstancePatch getPatch(FabricInfo fabricInfo, String gameVersion, St libraries.add(new Library(Artifact.fromDescriptor(fabricInfo.intermediary.maven), "https://maven.fabricmc.net/", null)); libraries.add(new Library(Artifact.fromDescriptor(fabricInfo.loader.maven), "https://maven.fabricmc.net/", null)); - return new GameInstancePatch(LibraryAnalyzer.LibraryType.FABRIC.getPatchId(), loaderVersion, GameInstancePatch.PRIORITY_LOADER, arguments, mainClass, libraries); + return new GameInstancePatch(GameComponentType.FABRIC.getPatchId(), loaderVersion, GameInstancePatch.PRIORITY_LOADER, arguments, mainClass, libraries); } @JsonSerializable diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricRemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricRemoteVersion.java index 857c593eeaf..a95fdd8ab1b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricRemoteVersion.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricRemoteVersion.java @@ -18,8 +18,8 @@ package org.jackhuang.hmcl.download.fabric; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.task.Task; @@ -35,7 +35,7 @@ public class FabricRemoteVersion extends RemoteVersion { * @param urls the installer or universal jar original URL. */ FabricRemoteVersion(String gameVersion, String selfVersion, List urls) { - super(LibraryAnalyzer.LibraryType.FABRIC.getPatchId(), gameVersion, selfVersion, null, urls); + super(GameComponentType.FABRIC, gameVersion, selfVersion, null, urls); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeInstallTask.java index bff618bfb83..5409344e908 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeInstallTask.java @@ -18,6 +18,7 @@ package org.jackhuang.hmcl.download.forge; import org.jackhuang.hmcl.download.*; +import org.jackhuang.hmcl.game.GameComponentAnalyzer; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.task.FileDownloadTask; @@ -102,7 +103,7 @@ public void execute() throws IOException, VersionMismatchException, UnsupportedI String originalMainClass = manifest.resolve(dependencyManager.getGameRepository()).mainClass(); if (GameVersionNumber.compare("1.13", remote.getGameVersion()) <= 0) { // Forge 1.13 is not compatible with fabric. - if (!LibraryAnalyzer.FORGE_OPTIFINE_MAIN.contains(originalMainClass)) + if (!GameComponentAnalyzer.FORGE_OPTIFINE_MAIN.contains(originalMainClass)) throw new UnsupportedInstallationException(UNSUPPORTED_LAUNCH_WRAPPER); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java index 50b18ac8a33..8ca59578786 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java @@ -19,17 +19,10 @@ import org.jackhuang.hmcl.download.ArtifactMalformedException; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.forge.ForgeNewInstallProfile.Processor; import org.jackhuang.hmcl.download.game.GameLibrariesTask; import org.jackhuang.hmcl.download.game.GameInstanceJsonDownloadTask; -import org.jackhuang.hmcl.game.Artifact; -import org.jackhuang.hmcl.game.DefaultGameRepository; -import org.jackhuang.hmcl.game.DownloadInfo; -import org.jackhuang.hmcl.game.DownloadType; -import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.GameInstancePatch; -import org.jackhuang.hmcl.game.Library; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.DigestUtils; @@ -116,7 +109,7 @@ public void execute() throws Exception { return; } - Path jar = gameRepository.getArtifactFile(manifest, processor.getJar()); + Path jar = gameRepository.getLayout().getArtifactFile(processor.getJar()); if (!Files.isRegularFile(jar)) throw new FileNotFoundException("Game processor file not found, should be downloaded in preprocess"); @@ -134,7 +127,7 @@ public void execute() throws Exception { List classpath = new ArrayList<>(processor.getClasspath().size() + 1); for (Artifact artifact : processor.getClasspath()) { - Path file = gameRepository.getArtifactFile(manifest, artifact); + Path file = gameRepository.getLayout().getArtifactFile(artifact); if (!Files.isRegularFile(file)) throw new Exception("Game processor dependency missing"); classpath.add(file.toString()); @@ -206,7 +199,7 @@ public void execute() throws Exception { private final String selfVersion; private Path tempDir; - private AtomicInteger processorDoneCount = new AtomicInteger(0); + private final AtomicInteger processorDoneCount = new AtomicInteger(0); public ForgeNewInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest manifest, String selfVersion, Path installer) { this.dependencyManager = dependencyManager; @@ -268,7 +261,7 @@ private String parseLiteral(String literal, Map url) { - super(LibraryAnalyzer.LibraryType.FORGE.getPatchId(), gameVersion, selfVersion, releaseDate, url); + super(GameComponentType.FORGE, gameVersion, selfVersion, releaseDate, url); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameAssetDownloadTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameAssetDownloadTask.java index 8d87159a23c..d0e72cc3397 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameAssetDownloadTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameAssetDownloadTask.java @@ -59,7 +59,9 @@ public GameAssetDownloadTask(AbstractDependencyManager dependencyManager, GameIn this.dependencyManager = dependencyManager; this.manifest = manifest.resolve(dependencyManager.getGameRepository()); this.assetIndexInfo = this.manifest.getAssetIndex(); - this.assetIndexFile = dependencyManager.getGameRepository().getIndexFile(manifest.id(), assetIndexInfo.getId()); + GameRepository gameRepository = dependencyManager.getGameRepository(); + String assetId = assetIndexInfo.getId(); + this.assetIndexFile = gameRepository.getLayout().getAssetIndexFile(assetId); this.integrityCheck = integrityCheck; setStage("hmcl.install.assets"); @@ -90,7 +92,8 @@ public void execute() throws Exception { if (isCancelled()) throw new InterruptedException(); - Path file = dependencyManager.getGameRepository().getAssetObject(manifest.id(), assetIndexInfo.getId(), assetObject); + GameRepository gameRepository = dependencyManager.getGameRepository(); + Path file = gameRepository.getLayout().getAssetObject(assetObject); boolean download = !Files.isRegularFile(file); try { if (!download && integrityCheck && !assetObject.validateChecksum(file, true)) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameAssetIndexDownloadTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameAssetIndexDownloadTask.java index 110e99b72e2..a6d937c9e62 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameAssetIndexDownloadTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameAssetIndexDownloadTask.java @@ -19,10 +19,7 @@ import com.google.gson.JsonParseException; import org.jackhuang.hmcl.download.AbstractDependencyManager; -import org.jackhuang.hmcl.game.AssetIndex; -import org.jackhuang.hmcl.game.AssetIndexInfo; -import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.GameRepository; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.DigestUtils; @@ -70,7 +67,9 @@ public List> getDependencies() { @Override public void execute() { AssetIndexInfo assetIndexInfo = manifest.getAssetIndex(); - Path assetIndexFile = dependencyManager.getGameRepository().getIndexFile(manifest.id(), assetIndexInfo.getId()); + GameRepository gameRepository = dependencyManager.getGameRepository(); + String assetId = assetIndexInfo.getId(); + Path assetIndexFile = gameRepository.getLayout().getAssetIndexFile(assetId); boolean verifyHashCode = StringUtils.isNotBlank(assetIndexInfo.getSha1()) && assetIndexInfo.getUrl().contains(assetIndexInfo.getSha1()); if (Files.exists(assetIndexFile) && !forceDownloading) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameDownloadTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameDownloadTask.java index 5bb59fa6474..0b214a7b08f 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameDownloadTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameDownloadTask.java @@ -22,42 +22,86 @@ import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.CacheRepository; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; import java.nio.file.Path; import java.util.ArrayList; import java.util.Collection; import java.util.List; -/** - * Task to download Minecraft jar - * @author huangyuhui - */ +/// Downloads a Minecraft client jar to a repository-resolved or explicitly fixed destination. +@NotNullByDefault public final class GameDownloadTask extends Task { + + /// The dependency manager supplying downloads and cache access. private final DefaultDependencyManager dependencyManager; - private final String gameVersion; + + /// The optional Minecraft version used to locate a cached jar candidate. + private final @Nullable String gameVersion; + + /// The resolved manifest that supplies client download metadata. private final GameInstanceManifest manifest; + + /// The explicit destination fixed when this task is created, or `null` to resolve it at execution. + private final @Nullable Path jar; + + /// The file-download task created during execution. private final List> dependencies = new ArrayList<>(); - public GameDownloadTask(DefaultDependencyManager dependencyManager, String gameVersion, GameInstanceManifest manifest) { + /// Creates a task whose destination is resolved from the repository when execution starts. + /// + /// @param dependencyManager the dependency manager used for resolution and downloading + /// @param gameVersion the Minecraft version used as a cache key, or `null` + /// @param manifest the manifest supplying client download metadata + public GameDownloadTask( + DefaultDependencyManager dependencyManager, + @Nullable String gameVersion, + GameInstanceManifest manifest) { + this.dependencyManager = dependencyManager; + this.gameVersion = gameVersion; + this.manifest = manifest.resolve(dependencyManager.getGameRepository()); + this.jar = null; + + setSignificance(TaskSignificance.MODERATE); + } + + /// Creates a task that writes the client jar to an explicit fixed destination. + /// + /// @param dependencyManager the dependency manager used for resolution and downloading + /// @param gameVersion the Minecraft version used as a cache key, or `null` + /// @param manifest the manifest supplying client download metadata + /// @param jar the destination jar path + public GameDownloadTask( + DefaultDependencyManager dependencyManager, + @Nullable String gameVersion, + GameInstanceManifest manifest, + Path jar) { this.dependencyManager = dependencyManager; this.gameVersion = gameVersion; this.manifest = manifest.resolve(dependencyManager.getGameRepository()); + this.jar = jar; setSignificance(TaskSignificance.MODERATE); } + /// Returns the download created by [#execute()], if execution has started. + /// + /// @return the live dependency collection @Override public Collection> getDependencies() { return dependencies; } + /// Creates the file-download dependency for the configured destination. @Override public void execute() { - Path jar = dependencyManager.getGameRepository().getInstanceJar(manifest); - + Path destination = jar != null + ? jar + : dependencyManager.getGameRepository().getInstanceJar(manifest); var task = new FileDownloadTask( dependencyManager.getDownloadProvider().injectURLWithCandidates(manifest.getDownloadInfo().getUrl()), - jar, + destination, FileDownloadTask.IntegrityCheck.of(CacheRepository.SHA1, manifest.getDownloadInfo().getSha1())); task.setCaching(true); task.setCacheRepository(dependencyManager.getCacheRepository()); @@ -67,5 +111,4 @@ public void execute() { dependencies.add(task); } - } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameInstallTask.java index 84597510715..55e03b8aaae 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameInstallTask.java @@ -19,6 +19,7 @@ import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.game.DefaultGameRepository; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.task.Task; @@ -29,8 +30,6 @@ import java.util.Collections; import java.util.List; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.MINECRAFT; - public class GameInstallTask extends Task { private final DefaultGameRepository gameRepository; @@ -67,7 +66,7 @@ public boolean isRelyingOnDependencies() { public void execute() throws Exception { GameInstancePatch patch = GameInstancePatch.fromManifest( JsonUtils.fromNonNullJson(downloadTask.getResult(), GameInstanceManifest.class), - MINECRAFT.getPatchId(), + GameComponentType.GAME.getPatchId(), remote.getGameVersion(), GameInstancePatch.PRIORITY_MC).withJar(null); setResult(patch); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameLibrariesTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameLibrariesTask.java index b268f2831d3..ab2f224f3ef 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameLibrariesTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameLibrariesTask.java @@ -18,12 +18,7 @@ package org.jackhuang.hmcl.download.game; import org.jackhuang.hmcl.download.AbstractDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; -import org.jackhuang.hmcl.download.MaintainTask; -import org.jackhuang.hmcl.game.DefaultGameRepository; -import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.GameRepository; -import org.jackhuang.hmcl.game.Library; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.DigestUtils; @@ -92,7 +87,7 @@ public List> getDependencies() { } public static boolean shouldDownloadLibrary(GameRepository gameRepository, GameInstanceManifest manifest, Library library, boolean integrityCheck) { - Path file = gameRepository.getLibraryFile(manifest, library); + Path file = gameRepository.getLayout().getLibraryFile(manifest.id(), library); if (!Files.isRegularFile(file)) return true; if (!integrityCheck) { @@ -136,6 +131,7 @@ private static boolean shouldDownloadFMLLib(FMLLib fmlLib, Path file) { } } + /// {@inheritDoc} @Override public void execute() throws IOException { int progress = 0; @@ -165,25 +161,37 @@ public void execute() throws IOException { } } - Path file = gameRepository.getLibraryFile(manifest, library); + Path file = gameRepository.getLayout().getLibraryFile(manifest.id(), library); if ("optifine".equals(library.groupId()) && Files.exists(file) && GameVersionNumber.asGameVersion(gameRepository.getGameVersion(manifest).orElse(null)).compareTo("1.20.4") == 0) { - String forgeVersion = LibraryAnalyzer.analyze(manifest, "1.20.4") - .getVersion(LibraryAnalyzer.LibraryType.FORGE) - .orElse(null); - if (forgeVersion != null && LibraryAnalyzer.FORGE_OPTIFINE_BROKEN_RANGE.contains(VersionNumber.asVersion(forgeVersion))) { + @Nullable String forgeVersion = GameComponentAnalyzer.analyze(manifest, "1.20.4") + .getVersion(GameComponentType.FORGE); + if (forgeVersion != null && GameComponentAnalyzer.FORGE_OPTIFINE_BROKEN_RANGE.contains(VersionNumber.asVersion(forgeVersion))) { try (FileSystem fs2 = CompressingUtils.createWritableZipFileSystem(file)) { Files.deleteIfExists(fs2.getPath("/META-INF/mods.toml")); } catch (IOException e) { throw new IOException("Cannot fix optifine", e); } } - } else if ("org.jackhuang.hmcl".equals(library.groupId()) && "mmc-bootstrap".equals(library.artifactId())) { + } else if ("org.jackhuang.hmcl".equals(library.groupId()) + && "mmc-bootstrap".equals(library.artifactId())) { if (!Files.exists(file)) { - try (InputStream input = MaintainTask.class.getResourceAsStream("/assets/game/HMCLMultiMCBootstrap-1.0.jar")) { + try (InputStream input = Objects.requireNonNull( + GameLibrariesTask.class.getResourceAsStream( + "/assets/game/HMCLMultiMCBootstrap-1.0.jar"), + "Bundled HMCLMultiMCBootstrap is missing.")) { Files.createDirectories(file.getParent()); - Files.copy(Objects.requireNonNull(input, "Bundled HMCLMultiMCBootstrap is missing."), file, StandardCopyOption.REPLACE_EXISTING); + Files.copy(input, file, StandardCopyOption.REPLACE_EXISTING); } } + } else if ("org.jackhuang.hmcl".equals(library.groupId()) + && "transformer-discovery-service".equals(library.artifactId())) { + try (InputStream input = Objects.requireNonNull( + GameLibrariesTask.class.getResourceAsStream( + "/assets/game/HMCLTransformerDiscoveryService-1.0.jar"), + "Bundled HMCLTransformerDiscoveryService is missing.")) { + Files.createDirectories(file.getParent()); + Files.copy(input, file, StandardCopyOption.REPLACE_EXISTING); + } } if (shouldDownloadLibrary(gameRepository, manifest, library, integrityCheck) && (library.hasDownloadURL() || !"optifine".equals(library.groupId()))) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameRemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameRemoteVersion.java index 386ac9367ad..5d3dcfc68df 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameRemoteVersion.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameRemoteVersion.java @@ -18,8 +18,8 @@ package org.jackhuang.hmcl.download.game; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.game.ReleaseType; @@ -40,7 +40,7 @@ public final class GameRemoteVersion extends RemoteVersion { private final ReleaseType type; public GameRemoteVersion(String gameVersion, String selfVersion, List url, ReleaseType type, Instant releaseDate) { - super(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId(), gameVersion, selfVersion, releaseDate, getReleaseType(type), url); + super(GameComponentType.GAME, gameVersion, selfVersion, releaseDate, getReleaseType(type), url); this.type = type; } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVerificationFixTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVerificationFixTask.java index 32d40f74181..d31871026ea 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVerificationFixTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVerificationFixTask.java @@ -17,56 +17,60 @@ */ package org.jackhuang.hmcl.download.game; -import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; +import org.jackhuang.hmcl.game.GameComponentAnalyzer; +import org.jackhuang.hmcl.game.GameComponentType; +import org.jackhuang.hmcl.game.GameInstance; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.io.CompressingUtils; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; +import org.jetbrains.annotations.NotNullByDefault; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.FileSystem; import java.nio.file.Files; import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -/** - * Remove class digital verification file in game jar - * @author huangyuhui - */ +/// Removes obsolete signature files from a legacy Forge instance's fixed client jar. +@NotNullByDefault public final class GameVerificationFixTask extends Task { - private final DefaultDependencyManager dependencyManager; - private final String gameVersion; + + /// The snapshot-bound instance whose client jar may be modified. + private final GameInstance instance; + + /// The detected Minecraft version. + private final GameVersionNumber gameVersion; + + /// The effective launch manifest used to detect Forge. private final GameInstanceManifest manifest; - private final List> dependencies = new ArrayList<>(); - public GameVerificationFixTask(DefaultDependencyManager dependencyManager, String gameVersion, GameInstanceManifest manifest) { - this.dependencyManager = dependencyManager; + /// Creates a task for a fixed instance and effective launch manifest. + /// + /// @param instance the instance whose client jar may be modified + /// @param gameVersion the detected Minecraft version + /// @param manifest the effective launch manifest used to detect Forge + public GameVerificationFixTask(GameInstance instance, GameVersionNumber gameVersion, GameInstanceManifest manifest) { + this.instance = instance; this.gameVersion = gameVersion; this.manifest = manifest; setSignificance(TaskSignificance.MODERATE); } - @Override - public Collection> getDependencies() { - return dependencies; - } - + /// Removes legacy Mojang signature entries when this is a pre-1.6 Forge installation. + /// + /// @throws IOException if the client jar cannot be opened or modified @Override public void execute() throws IOException { - Path jar = dependencyManager.getGameRepository().getInstanceJar(manifest); - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(manifest, gameVersion); + Path jar = instance.getInstanceJarFile(); + var analyzer = GameComponentAnalyzer.analyze(manifest, gameVersion.toString()); - if (Files.exists(jar) && GameVersionNumber.compare(gameVersion, "1.6") < 0 && analyzer.has(LibraryAnalyzer.LibraryType.FORGE)) { + if (Files.exists(jar) && gameVersion.compareTo("1.6") < 0 && analyzer.has(GameComponentType.FORGE)) { try (FileSystem fs = CompressingUtils.createWritableZipFileSystem(jar, StandardCharsets.UTF_8)) { Files.deleteIfExists(fs.getPath("META-INF/MOJANG_C.DSA")); Files.deleteIfExists(fs.getPath("META-INF/MOJANG_C.SF")); } } } - } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIInstallTask.java index d11361ff765..07818feee9b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIInstallTask.java @@ -24,6 +24,7 @@ import org.jackhuang.hmcl.task.Task; import java.io.IOException; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -33,12 +34,22 @@ public final class LegacyFabricAPIInstallTask extends Task { private final DefaultDependencyManager dependencyManager; private final GameInstanceManifest manifest; private final LegacyFabricAPIRemoteVersion remote; + private final Path modsDirectory; private final List> dependencies = new ArrayList<>(1); - public LegacyFabricAPIInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest manifest, LegacyFabricAPIRemoteVersion remoteVersion) { + /// @param dependencyManager the dependency manager + /// @param manifest the manifest being installed into + /// @param remoteVersion the Legacy Fabric API remote version + /// @param modsDirectory the target mods directory (must already be resolved by the caller) + public LegacyFabricAPIInstallTask( + DefaultDependencyManager dependencyManager, + GameInstanceManifest manifest, + LegacyFabricAPIRemoteVersion remoteVersion, + Path modsDirectory) { this.dependencyManager = dependencyManager; this.manifest = manifest; this.remote = remoteVersion; + this.modsDirectory = modsDirectory; } @Override @@ -55,7 +66,7 @@ public boolean isRelyingOnDependencies() { public void execute() throws IOException { dependencies.add(new FileDownloadTask( remote.getVersion().file().url(), - dependencyManager.getGameRepository().getModsDirectory(manifest.id()).resolve("legacy-fabric-api-" + remote.getVersion().version() + ".jar"), + modsDirectory.resolve("legacy-fabric-api-" + remote.getVersion().version() + ".jar"), remote.getVersion().file().getIntegrityCheck()) ); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIRemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIRemoteVersion.java index fbf157f14e2..c93ed578175 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIRemoteVersion.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIRemoteVersion.java @@ -18,13 +18,14 @@ package org.jackhuang.hmcl.download.legacyfabric; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.addon.RemoteAddon; import org.jackhuang.hmcl.task.Task; +import java.nio.file.Path; import java.time.Instant; import java.util.List; @@ -40,7 +41,7 @@ public class LegacyFabricAPIRemoteVersion extends RemoteVersion { * @param urls the installer or universal jar original URL. */ LegacyFabricAPIRemoteVersion(String gameVersion, String selfVersion, String fullVersion, Instant datePublished, RemoteAddon.Version version, List urls) { - super(LibraryAnalyzer.LibraryType.LEGACY_FABRIC_API.getPatchId(), gameVersion, selfVersion, datePublished, urls); + super(GameComponentType.LEGACY_FABRIC_API, gameVersion, selfVersion, datePublished, urls); this.fullVersion = fullVersion; this.version = version; @@ -56,8 +57,11 @@ public RemoteAddon.Version getVersion() { } @Override - public Task getInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest baseVersion) { - return new LegacyFabricAPIInstallTask(dependencyManager, baseVersion, this); + public Task getInstallTask( + DefaultDependencyManager dependencyManager, + GameInstanceManifest baseVersion, + Path modsDirectory) { + return new LegacyFabricAPIInstallTask(dependencyManager, baseVersion, this, modsDirectory); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricInstallTask.java index ff0b6ef650a..b5a592e8b16 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricInstallTask.java @@ -20,13 +20,8 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.fabric.FabricInstallTask; -import org.jackhuang.hmcl.game.Arguments; -import org.jackhuang.hmcl.game.Artifact; -import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.GameInstancePatch; -import org.jackhuang.hmcl.game.Library; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.task.GetTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.JsonUtils; @@ -111,7 +106,7 @@ private GameInstancePatch getPatch(FabricInstallTask.FabricInfo legacyFabricInfo libraries.add(new Library(Artifact.fromDescriptor(legacyFabricInfo.getIntermediary().getMaven()), getMavenRepositoryByGroup(legacyFabricInfo.getIntermediary().getMaven()), null)); libraries.add(new Library(Artifact.fromDescriptor(legacyFabricInfo.getLoader().getMaven()), getMavenRepositoryByGroup(legacyFabricInfo.getLoader().getMaven()), null)); - return new GameInstancePatch(LibraryAnalyzer.LibraryType.LEGACY_FABRIC.getPatchId(), loaderVersion, GameInstancePatch.PRIORITY_LOADER, arguments, mainClass, libraries); + return new GameInstancePatch(GameComponentType.LEGACY_FABRIC.getPatchId(), loaderVersion, GameInstancePatch.PRIORITY_LOADER, arguments, mainClass, libraries); } private static String getMavenRepositoryByGroup(String maven) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricRemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricRemoteVersion.java index f8a1e88c881..efb24bee0de 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricRemoteVersion.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricRemoteVersion.java @@ -18,8 +18,8 @@ package org.jackhuang.hmcl.download.legacyfabric; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.task.Task; @@ -35,7 +35,7 @@ public class LegacyFabricRemoteVersion extends RemoteVersion { * @param urls the installer or universal jar original URL. */ LegacyFabricRemoteVersion(String gameVersion, String selfVersion, List urls) { - super(LibraryAnalyzer.LibraryType.LEGACY_FABRIC.getPatchId(), gameVersion, selfVersion, null, urls); + super(GameComponentType.LEGACY_FABRIC, gameVersion, selfVersion, null, urls); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderInstallTask.java index 985de491c2d..59c1d53cd1d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderInstallTask.java @@ -18,7 +18,6 @@ package org.jackhuang.hmcl.download.liteloader; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.Lang; @@ -65,11 +64,11 @@ public void execute() { new LibrariesDownloadInfo(new LibraryDownloadInfo(null, remote.getUrls().get(0))) ); - setResult(new GameInstancePatch(LibraryAnalyzer.LibraryType.LITELOADER.getPatchId(), + setResult(new GameInstancePatch(GameComponentType.LITELOADER.getPatchId(), remote.getSelfVersion(), 60000, new Arguments().addGameArguments("--tweakClass", "com.mumfrey.liteloader.launch.LiteLoaderTweaker"), - LibraryAnalyzer.LAUNCH_WRAPPER_MAIN, + GameComponentAnalyzer.LAUNCH_WRAPPER_MAIN, Lang.merge(remote.getLibraries(), Collections.singleton(library))) .withLogging(Collections.emptyMap()) // Mods may log in malformed format, causing XML parser to crash. So we suppress using official log4j configuration ); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderRemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderRemoteVersion.java index e2da5e16f84..f6a09e79b8e 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderRemoteVersion.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderRemoteVersion.java @@ -18,8 +18,8 @@ package org.jackhuang.hmcl.download.liteloader; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.game.Library; @@ -40,7 +40,7 @@ public class LiteLoaderRemoteVersion extends RemoteVersion { * @param urls the installer or universal jar original URL. */ LiteLoaderRemoteVersion(String gameVersion, String selfVersion, Type type, List urls, String tweakClass, Collection libraries) { - super(LibraryAnalyzer.LibraryType.LITELOADER.getPatchId(), gameVersion, selfVersion, null, type, urls); + super(GameComponentType.LITELOADER, gameVersion, selfVersion, null, type, urls); this.tweakClass = tweakClass; this.libraries = libraries; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeInstallTask.java index 3467a195d37..eb3ac1a6c2a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeInstallTask.java @@ -18,9 +18,9 @@ package org.jackhuang.hmcl.download.neoforge; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.VersionMismatchException; import org.jackhuang.hmcl.download.forge.*; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.task.FileDownloadTask; @@ -106,20 +106,20 @@ public static Task install(DefaultDependencyManager dependenc try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(installer)) { String installProfileText = Files.readString(fs.getPath("install_profile.json")); Map installProfile = JsonUtils.fromNonNullJson(installProfileText, Map.class); - if (LibraryAnalyzer.LibraryType.FORGE.getPatchId().equals(installProfile.get("profile")) && (Files.exists(fs.getPath("META-INF/NEOFORGE.RSA")) || installProfileText.contains("neoforge"))) { + if (GameComponentType.FORGE.getPatchId().equals(installProfile.get("profile")) && (Files.exists(fs.getPath("META-INF/NEOFORGE.RSA")) || installProfileText.contains("neoforge"))) { ForgeNewInstallProfile profile = JsonUtils.fromNonNullJson(installProfileText, ForgeNewInstallProfile.class); if (!gameVersion.get().equals(profile.getMinecraft())) throw new VersionMismatchException(profile.getMinecraft(), gameVersion.get()); return new ForgeNewInstallTask(dependencyManager, version, modifyNeoForgeOldVersion(gameVersion.get(), profile.getVersion()), installer).thenApplyAsync(neoForgeVersion -> { - if (!neoForgeVersion.id().equals(LibraryAnalyzer.LibraryType.FORGE.getPatchId()) || neoForgeVersion.version() == null) { + if (!neoForgeVersion.id().equals(GameComponentType.FORGE.getPatchId()) || neoForgeVersion.version() == null) { throw new IOException("Invalid neoforge version."); } - return neoForgeVersion.withId(LibraryAnalyzer.LibraryType.NEO_FORGE.getPatchId()) + return neoForgeVersion.withId(GameComponentType.NEO_FORGE.getPatchId()) .withVersion( - removePrefix(neoForgeVersion.version().replace(LibraryAnalyzer.LibraryType.FORGE.getPatchId(), ""), "-") + removePrefix(neoForgeVersion.version().replace(GameComponentType.FORGE.getPatchId(), ""), "-") ); }); - } else if (LibraryAnalyzer.LibraryType.NEO_FORGE.getPatchId().equals(installProfile.get("profile")) || "NeoForge".equals(installProfile.get("profile"))) { + } else if (GameComponentType.NEO_FORGE.getPatchId().equals(installProfile.get("profile")) || "NeoForge".equals(installProfile.get("profile"))) { ForgeNewInstallProfile profile = JsonUtils.fromNonNullJson(installProfileText, ForgeNewInstallProfile.class); if (!gameVersion.get().equals(profile.getMinecraft())) throw new VersionMismatchException(profile.getMinecraft(), gameVersion.get()); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOldInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOldInstallTask.java index 3919125371a..447f3ec02e7 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOldInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOldInstallTask.java @@ -19,7 +19,6 @@ import org.jackhuang.hmcl.download.ArtifactMalformedException; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.forge.ForgeNewInstallProfile; import org.jackhuang.hmcl.download.forge.ForgeNewInstallProfile.Processor; import org.jackhuang.hmcl.download.game.GameLibrariesTask; @@ -110,7 +109,7 @@ public void execute() throws Exception { return; } - Path jar = gameRepository.getArtifactFile(manifest, processor.getJar()); + Path jar = gameRepository.getLayout().getArtifactFile(processor.getJar()); if (!Files.isRegularFile(jar)) throw new FileNotFoundException("Game processor file not found, should be downloaded in preprocess"); @@ -128,7 +127,7 @@ public void execute() throws Exception { List classpath = new ArrayList<>(processor.getClasspath().size() + 1); for (Artifact artifact : processor.getClasspath()) { - Path file = gameRepository.getArtifactFile(manifest, artifact); + Path file = gameRepository.getLayout().getArtifactFile(artifact); if (!Files.isRegularFile(file)) throw new Exception("Game processor dependency missing"); classpath.add(file.toString()); @@ -246,7 +245,7 @@ private String parseLiteral(String literal, Map urls) { - super(LibraryAnalyzer.LibraryType.NEO_FORGE.getPatchId(), gameVersion, selfVersion, null, getType(selfVersion), urls); + super(GameComponentType.NEO_FORGE, gameVersion, selfVersion, null, getType(selfVersion), urls); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/optifine/OptiFineInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/optifine/OptiFineInstallTask.java index 8e39854b17c..b24032b8867 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/optifine/OptiFineInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/optifine/OptiFineInstallTask.java @@ -18,7 +18,6 @@ package org.jackhuang.hmcl.download.optifine; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.UnsupportedInstallationException; import org.jackhuang.hmcl.download.VersionMismatchException; import org.jackhuang.hmcl.game.*; @@ -124,13 +123,13 @@ public boolean isRelyingOnDependencies() { @Override public void execute() throws Exception { String originalMainClass = manifest.resolve(dependencyManager.getGameRepository()).mainClass(); - if (!LibraryAnalyzer.FORGE_OPTIFINE_MAIN.contains(originalMainClass)) + if (!GameComponentAnalyzer.FORGE_OPTIFINE_MAIN.contains(originalMainClass)) throw new UnsupportedInstallationException(UnsupportedInstallationException.UNSUPPORTED_LAUNCH_WRAPPER); List libraries = new ArrayList<>(4); libraries.add(optiFineLibrary); - Path optiFineInstallerLibraryPath = gameRepository.getLibraryFile(manifest, optiFineInstallerLibrary); + Path optiFineInstallerLibraryPath = gameRepository.getLayout().getLibraryFile(manifest.id(), optiFineInstallerLibrary); FileUtils.copyFile(dest, optiFineInstallerLibraryPath); try (FileSystem fs2 = CompressingUtils.createWritableZipFileSystem(optiFineInstallerLibraryPath)) { @@ -140,7 +139,7 @@ public void execute() throws Exception { // Install launch wrapper modified by OptiFine boolean hasLaunchWrapper = false; try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(dest)) { - Path optiFineLibraryPath = gameRepository.getLibraryFile(manifest, optiFineLibrary); + Path optiFineLibraryPath = gameRepository.getLayout().getLibraryFile(manifest.id(), optiFineLibrary); if (Files.exists(fs.getPath("optifine/Patcher.class"))) { String[] command = { JavaRuntime.getDefault().getBinary().toString(), @@ -165,7 +164,7 @@ public void execute() throws Exception { Path launchWrapper2 = fs.getPath("launchwrapper-2.0.jar"); if (Files.exists(launchWrapper2)) { Library launchWrapper = new Library(new Artifact("optifine", "launchwrapper", "2.0")); - Path launchWrapperFile = gameRepository.getLibraryFile(manifest, launchWrapper); + Path launchWrapperFile = gameRepository.getLayout().getLibraryFile(manifest.id(), launchWrapper); Files.createDirectories(launchWrapperFile.toAbsolutePath().getParent()); FileUtils.copyFile(launchWrapper2, launchWrapperFile); hasLaunchWrapper = true; @@ -180,7 +179,7 @@ public void execute() throws Exception { Library launchWrapper = new Library(new Artifact("optifine", "launchwrapper-of", launchWrapperVersion)); if (Files.exists(launchWrapperJar)) { - Path launchWrapperFile = gameRepository.getLibraryFile(manifest, launchWrapper); + Path launchWrapperFile = gameRepository.getLayout().getLibraryFile(manifest.id(), launchWrapper); Files.createDirectories(launchWrapperFile.toAbsolutePath().getParent()); FileUtils.copyFile(launchWrapperJar, launchWrapperFile); @@ -194,7 +193,7 @@ public void execute() throws Exception { String buildof = Files.readString(buildofText).trim(); VersionNumber buildofVer = VersionNumber.asVersion(buildof); - if (LibraryAnalyzer.BOOTSTRAP_LAUNCHER_MAIN.equals(originalMainClass)) { + if (GameComponentAnalyzer.BOOTSTRAP_LAUNCHER_MAIN.equals(originalMainClass)) { // OptiFine H1 Pre2+ is compatible with Forge 1.17 if (buildofVer.compareTo("20210924-190833") < 0) { throw new UnsupportedInstallationException(UnsupportedInstallationException.FORGE_1_17_OPTIFINE_H1_PRE2); @@ -208,11 +207,11 @@ public void execute() throws Exception { } setResult(new GameInstancePatch( - LibraryAnalyzer.LibraryType.OPTIFINE.getPatchId(), + GameComponentType.OPTIFINE.getPatchId(), remote.getSelfVersion(), 10000, new Arguments().addGameArguments("--tweakClass", "optifine.OptiFineTweaker"), - LibraryAnalyzer.LAUNCH_WRAPPER_MAIN, + GameComponentAnalyzer.LAUNCH_WRAPPER_MAIN, libraries )); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/optifine/OptiFineRemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/optifine/OptiFineRemoteVersion.java index abf36261e7a..6e527984393 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/optifine/OptiFineRemoteVersion.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/optifine/OptiFineRemoteVersion.java @@ -18,8 +18,8 @@ package org.jackhuang.hmcl.download.optifine; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.task.Task; @@ -29,7 +29,7 @@ public class OptiFineRemoteVersion extends RemoteVersion { public OptiFineRemoteVersion(String gameVersion, String selfVersion, List urls, boolean snapshot) { - super(LibraryAnalyzer.LibraryType.OPTIFINE.getPatchId(), gameVersion, selfVersion, null, snapshot ? Type.SNAPSHOT : Type.RELEASE, urls); + super(GameComponentType.OPTIFINE, gameVersion, selfVersion, null, snapshot ? Type.SNAPSHOT : Type.RELEASE, urls); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltAPIInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltAPIInstallTask.java index 1bacd55e612..e78a65ca592 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltAPIInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltAPIInstallTask.java @@ -24,6 +24,7 @@ import org.jackhuang.hmcl.task.Task; import java.io.IOException; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -38,12 +39,22 @@ public final class QuiltAPIInstallTask extends Task { private final DefaultDependencyManager dependencyManager; private final GameInstanceManifest manifest; private final QuiltAPIRemoteVersion remote; + private final Path modsDirectory; private final List> dependencies = new ArrayList<>(1); - public QuiltAPIInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest manifest, QuiltAPIRemoteVersion remoteVersion) { + /// @param dependencyManager the dependency manager + /// @param manifest the manifest being installed into + /// @param remoteVersion the Quilt API remote version + /// @param modsDirectory the target mods directory (must already be resolved by the caller) + public QuiltAPIInstallTask( + DefaultDependencyManager dependencyManager, + GameInstanceManifest manifest, + QuiltAPIRemoteVersion remoteVersion, + Path modsDirectory) { this.dependencyManager = dependencyManager; this.manifest = manifest; this.remote = remoteVersion; + this.modsDirectory = modsDirectory; } @Override @@ -60,7 +71,7 @@ public boolean isRelyingOnDependencies() { public void execute() throws IOException { dependencies.add(new FileDownloadTask( remote.getVersion().file().url(), - dependencyManager.getGameRepository().getModsDirectory(manifest.id()).resolve("quilt-api-" + remote.getVersion().version() + ".jar"), + modsDirectory.resolve("quilt-api-" + remote.getVersion().version() + ".jar"), remote.getVersion().file().getIntegrityCheck()) ); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltAPIRemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltAPIRemoteVersion.java index 2ccc82a7c24..96d46ef9a87 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltAPIRemoteVersion.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltAPIRemoteVersion.java @@ -18,13 +18,14 @@ package org.jackhuang.hmcl.download.quilt; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.addon.RemoteAddon; import org.jackhuang.hmcl.task.Task; +import java.nio.file.Path; import java.time.Instant; import java.util.List; @@ -40,7 +41,7 @@ public class QuiltAPIRemoteVersion extends RemoteVersion { * @param urls the installer or universal jar original URL. */ QuiltAPIRemoteVersion(String gameVersion, String selfVersion, String fullVersion, Instant datePublished, RemoteAddon.Version version, List urls) { - super(LibraryAnalyzer.LibraryType.QUILT_API.getPatchId(), gameVersion, selfVersion, datePublished, urls); + super(GameComponentType.QUILT_API, gameVersion, selfVersion, datePublished, urls); this.fullVersion = fullVersion; this.version = version; @@ -56,8 +57,11 @@ public RemoteAddon.Version getVersion() { } @Override - public Task getInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest baseVersion) { - return new QuiltAPIInstallTask(dependencyManager, baseVersion, this); + public Task getInstallTask( + DefaultDependencyManager dependencyManager, + GameInstanceManifest baseVersion, + Path modsDirectory) { + return new QuiltAPIInstallTask(dependencyManager, baseVersion, this, modsDirectory); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltInstallTask.java index d2284294753..8a4fa50c965 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltInstallTask.java @@ -20,13 +20,8 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.UnsupportedInstallationException; -import org.jackhuang.hmcl.game.Arguments; -import org.jackhuang.hmcl.game.Artifact; -import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.GameInstancePatch; -import org.jackhuang.hmcl.game.Library; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.task.GetTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.JsonSerializable; @@ -126,7 +121,7 @@ private static GameInstancePatch getPatch(QuiltInfo quiltInfo, String loaderVers } libraries.add(new Library(Artifact.fromDescriptor(quiltInfo.loader.maven), getMavenRepositoryByGroup(quiltInfo.loader.maven), null)); - return new GameInstancePatch(LibraryAnalyzer.LibraryType.QUILT.getPatchId(), loaderVersion, GameInstancePatch.PRIORITY_LOADER, arguments, mainClass, libraries); + return new GameInstancePatch(GameComponentType.QUILT.getPatchId(), loaderVersion, GameInstancePatch.PRIORITY_LOADER, arguments, mainClass, libraries); } private static String getMavenRepositoryByGroup(String maven) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltRemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltRemoteVersion.java index bd86cd24ea3..4385fb39077 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltRemoteVersion.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltRemoteVersion.java @@ -18,8 +18,8 @@ package org.jackhuang.hmcl.download.quilt; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.task.Task; @@ -35,7 +35,7 @@ public class QuiltRemoteVersion extends RemoteVersion { * @param urls the installer or universal jar original URL. */ QuiltRemoteVersion(String gameVersion, String selfVersion, List urls) { - super(LibraryAnalyzer.LibraryType.QUILT.getPatchId(), gameVersion, selfVersion, null, urls); + super(GameComponentType.QUILT, gameVersion, selfVersion, null, urls); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/event/GameJsonParseFailedEvent.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/event/GameJsonParseFailedEvent.java deleted file mode 100644 index b589992ad8b..00000000000 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/event/GameJsonParseFailedEvent.java +++ /dev/null @@ -1,64 +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.event; - -import org.jackhuang.hmcl.game.DefaultGameRepository; -import org.jackhuang.hmcl.util.ToStringBuilder; - -import java.nio.file.Path; - -/** - * This event gets fired when json of a game version is malformed. You can do something here. - * auto making up for the missing json, don't forget to set result to {@link Event.Result#ALLOW}. - * and even asking for removing the redundant version folder. - * - * The result ALLOW means you have corrected the json. - */ -public final class GameJsonParseFailedEvent extends Event { - private final String version; - private final Path jsonFile; - - /** - * - * @param source {@link DefaultGameRepository} - * @param jsonFile the minecraft.json file. - * @param version the version name - */ - public GameJsonParseFailedEvent(Object source, Path jsonFile, String version) { - super(source); - this.version = version; - this.jsonFile = jsonFile; - } - - public Path getJsonFile() { - return jsonFile; - } - - public String getVersion() { - return version; - } - - @Override - public String toString() { - return new ToStringBuilder(this) - .append("source", source) - .append("jsonFile", jsonFile) - .append("version", version) - .toString(); - } -} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/event/RefreshedGameInstancesEvent.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/event/RefreshedGameInstancesEvent.java deleted file mode 100644 index b7e6bccaa24..00000000000 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/event/RefreshedGameInstancesEvent.java +++ /dev/null @@ -1,40 +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.event; - -import org.jackhuang.hmcl.game.GameRepository; - -/** - * This event gets fired when all the versions in .minecraft folder are loaded. - *
- * This event is fired on the {@link org.jackhuang.hmcl.event.EventBus#EVENT_BUS} - * - * @author huangyuhui - */ -public final class RefreshedGameInstancesEvent extends Event { - - /** - * Constructor. - * - * @param source {@link GameRepository} - */ - public RefreshedGameInstancesEvent(Object source) { - super(source); - } - -} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/event/RefreshingInstancesEvent.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/event/RefreshingInstancesEvent.java deleted file mode 100644 index f70e8a6b84a..00000000000 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/event/RefreshingInstancesEvent.java +++ /dev/null @@ -1,39 +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.event; - -import org.jetbrains.annotations.NotNullByDefault; - -/// This event gets fired when loading versions in a .minecraft folder. -/// -/// This event is fired on the [org.jackhuang.hmcl.event.EventBus#EVENT_BUS] -/// -/// @author huangyuhui -@NotNullByDefault -public final class RefreshingInstancesEvent extends Event { - - /// Constructor. - public RefreshingInstancesEvent(Object source) { - super(source); - } - - @Override - public boolean hasResult() { - return true; - } -} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/event/RemoveInstanceEvent.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/event/RemoveInstanceEvent.java deleted file mode 100644 index e2f8bb25f11..00000000000 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/event/RemoveInstanceEvent.java +++ /dev/null @@ -1,56 +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.event; - -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.util.ToStringBuilder; -import org.jetbrains.annotations.NotNullByDefault; - -/// This event gets fired when a minecraft version is being removed. -/// -/// This event is fired on the [org.jackhuang.hmcl.event.EventBus#EVENT_BUS] -/// -/// @author huangyuhui -@NotNullByDefault -public class RemoveInstanceEvent extends Event { - - private final GameInstanceID instanceId; - - /// @param instanceId the instance id. - public RemoveInstanceEvent(Object source, GameInstanceID instanceId) { - super(source); - this.instanceId = instanceId; - } - - public GameInstanceID getInstanceId() { - return instanceId; - } - - @Override - public boolean hasResult() { - return true; - } - - @Override - public String toString() { - return new ToStringBuilder(this) - .append("source", source) - .append("instanceId", instanceId) - .toString(); - } -} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/event/RenameInstanceEvent.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/event/RenameInstanceEvent.java deleted file mode 100644 index 065c7bad956..00000000000 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/event/RenameInstanceEvent.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.event; - -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.GameRepository; -import org.jackhuang.hmcl.util.ToStringBuilder; -import org.jetbrains.annotations.NotNullByDefault; - -/// This event gets fired when a minecraft instance is being removed. -/// -/// This event is fired on the [org.jackhuang.hmcl.event.EventBus#EVENT_BUS] -/// -/// @author huangyuhui -@NotNullByDefault -public final class RenameInstanceEvent extends Event { - - private final GameInstanceID from, to; - - /** - * - * @param source {@link GameRepository} - * @param from the instance id. - */ - public RenameInstanceEvent(Object source, GameInstanceID from, GameInstanceID to) { - super(source); - this.from = from; - this.to = to; - } - - public GameInstanceID getFrom() { - return from; - } - - public GameInstanceID getTo() { - return to; - } - - @Override - public boolean hasResult() { - return true; - } - - @Override - public String toString() { - return new ToStringBuilder(this) - .append("source", source) - .append("from", from) - .append("to", to) - .toString(); - } -} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java new file mode 100644 index 00000000000..6ae68659e34 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java @@ -0,0 +1,363 @@ +/* + * 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.game; + +import com.google.gson.JsonParseException; +import org.jackhuang.hmcl.addon.mod.ModManager; +import org.jackhuang.hmcl.addon.resourcepack.ResourcePackManager; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; +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.Map; +import java.util.Objects; +import java.util.Optional; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// Default snapshot member for an official-layout game instance. +/// +/// Index fields (`id`, `manifest`, layout binding, and optional non-conventional file paths) belong +/// to a [DefaultGameRepositorySnapshot]. Lazy services such as [#getModManager()] and +/// [#getResourcePackManager()] belong to this snapshot member only: copies produced by +/// [#withNewSnapshot] / [#withManifest] do not inherit them, so a repository refresh or COW publish +/// does not keep a long-lived addon-manager session. +@NotNullByDefault +public abstract class DefaultGameInstance implements GameInstance { + + protected final DefaultGameRepositorySnapshot snapshot; + protected final DefaultGameRepository repository; + protected final DefaultGameRepositoryLayout layout; + protected final GameInstanceID id; + protected final GameInstanceManifest manifest; + + /// Non-conventional manifest file path discovered at load time, or `null` for the layout default. + /// + /// When set, this instance's own primary jar is the sibling path with the same base name and a + /// `.jar` extension. + protected final @Nullable Path manifestFile; + + protected GameInstanceManifest.@Nullable Resolved resolvedManifest; + + /// Cached Minecraft game version detected from this instance's primary jar. + /// + /// `null` means detection has not been attempted yet. After detection, unknown results are + /// stored as [GameVersionNumber#unknown()] rather than left null. + protected @Nullable GameVersionNumber version; + + /// Lazily created mod manager for this snapshot member only. + private @Nullable ModManager modManager; + + /// Lazily created resource-pack manager for this snapshot member only. + private @Nullable ResourcePackManager resourcePackManager; + + protected DefaultGameInstance( + DefaultGameRepositorySnapshot snapshot, + GameInstanceID id, + GameInstanceManifest manifest) { + this(snapshot, id, manifest, (Path) null); + } + + /// Creates an instance with an optional non-conventional manifest path. + /// + /// @param snapshot the snapshot that owns this instance + /// @param id the instance id (directory name under the official layout) + /// @param manifest the stored instance manifest + /// @param manifestFile the actual manifest JSON path, or `null` for [DefaultGameRepositoryLayout#getInstanceJson] + protected DefaultGameInstance( + DefaultGameRepositorySnapshot snapshot, + GameInstanceID id, + GameInstanceManifest manifest, + @Nullable Path manifestFile) { + this.snapshot = snapshot; + this.repository = snapshot.getRepository(); + this.layout = snapshot.getLayout(); + this.id = id; + this.manifest = manifest; + this.manifestFile = manifestFile; + } + + /// Creates an instance that may reuse storage paths and version cache from another snapshot wrapper. + /// + /// The manifest path is copied when `id` equals that of `shareSession`. The cached game version is + /// copied only when `id` and `manifest` also equal those of `shareSession`. Addon managers are + /// never shared: each snapshot member creates its own managers on first use. + /// + /// @param snapshot the snapshot that will own the copy + /// @param id the instance id + /// @param manifest the stored instance manifest + /// @param shareSession the instance whose stable path/version state may be reused + protected DefaultGameInstance( + DefaultGameRepositorySnapshot snapshot, + GameInstanceID id, + GameInstanceManifest manifest, + DefaultGameInstance shareSession) { + this( + snapshot, + id, + manifest, + Objects.equals(id, shareSession.id) ? shareSession.manifestFile : null); + if (Objects.equals(id, shareSession.id) && Objects.equals(manifest, shareSession.manifest)) { + this.version = shareSession.version; + } + } + + protected abstract DefaultGameInstance withNewSnapshot(DefaultGameRepositorySnapshot newSnapshot); + + /// Returns a copy of this instance bound to a new snapshot and stored manifest. + /// + /// @param newSnapshot the snapshot that will own the copy + /// @param manifest the stored instance manifest + /// @return the updated instance + protected abstract DefaultGameInstance withManifest(DefaultGameRepositorySnapshot newSnapshot, GameInstanceManifest manifest); + + @Override + public DefaultGameRepository getRepository() { + return repository; + } + + @Override + public DefaultGameRepositoryLayout getLayout() { + return layout; + } + + @Override + public GameInstanceID getId() { + return id; + } + + @Override + public GameInstanceManifest getManifest() { + return manifest; + } + + @Override + public GameInstanceManifest.Resolved getResolvedManifest() { + if (resolvedManifest == null) { + resolvedManifest = snapshot.resolve(manifest); + } + return resolvedManifest; + } + + /// {@inheritDoc} + /// + /// The detected version is cached on this instance. When the primary jar cannot be resolved or + /// its Minecraft version cannot be recognized, [GameVersionNumber#unknown()] is cached and + /// returned. + @Override + public GameVersionNumber getVersion() { + if (version == null) { + version = detectVersion(); + } + return version; + } + + /// Returns the mod manager for this snapshot member. + /// + /// The manager is created on first use and is not shared with other snapshot wrappers. After a + /// repository refresh or COW publish, callers should obtain the manager from the current + /// instance again. + /// + /// @return the mod manager + public ModManager getModManager() { + if (modManager == null) { + modManager = new ModManager(this); + } + return modManager; + } + + /// Returns the resource-pack manager for this snapshot member. + /// + /// The manager is created on first use and is not shared with other snapshot wrappers. After a + /// repository refresh or COW publish, callers should obtain the manager from the current + /// instance again. + /// + /// @return the resource-pack manager + public ResourcePackManager getResourcePackManager() { + if (resourcePackManager == null) { + resourcePackManager = new ResourcePackManager(this); + } + return resourcePackManager; + } + + /// Detects the Minecraft game version from this instance's primary client jar. + /// + /// @return the detected version, or [GameVersionNumber#unknown()] when detection fails + private GameVersionNumber detectVersion() { + try { + Path jar = getInstanceJarFile(); + Optional detected = GameVersion.minecraftVersion(jar); + if (detected.isEmpty()) { + LOG.warning("Cannot find out game version of " + id + + ", primary jar: " + jar + + ", jar exists: " + Files.exists(jar)); + return GameVersionNumber.unknown(); + } + return GameVersionNumber.asGameVersion(detected.get()); + } catch (NoSuchGameInstanceException e) { + LOG.warning("Cannot resolve game version of " + id, e); + return GameVersionNumber.unknown(); + } + } + + @Override + public Path getInstanceRoot() { + return layout.getInstanceRoot(id); + } + + /// {@inheritDoc} + /// + /// When a non-conventional path was discovered while loading this instance, that path is + /// returned; otherwise the layout default `versions//.json` is used. + @Override + public Path getManifestFile() { + return manifestFile != null ? manifestFile : layout.getInstanceJson(id); + } + + /// {@inheritDoc} + @Override + public Path getModpackConfigurationFile() { + return getInstanceRoot().resolve("modpack.json"); + } + + /// {@inheritDoc} + /// + /// When the launch manifest redirects to another version via [GameInstanceManifest#jar()], the + /// jar is resolved through the layout (or that other instance when present). Otherwise this + /// instance's own jar is returned from [#getOwnJarFile()]. + @Override + public Path getInstanceJarFile() { + GameInstanceManifest launchManifest = getResolvedManifest().launchManifest(); + GameInstanceID jarId = Optional.ofNullable(launchManifest.jar()).orElse(launchManifest.id()); + if (!jarId.equals(id)) { + DefaultGameInstance other = snapshot.findInstance(jarId); + if (other != null) { + return other.getOwnJarFile(); + } + return layout.getInstanceJarFile(jarId); + } + return getOwnJarFile(); + } + + /// Returns this instance's own primary jar without following `jar` inheritance. + /// + /// When a non-conventional manifest path is recorded, the jar is the sibling path with the same + /// base name. Otherwise the layout default `versions//.jar` is used. + /// + /// @return the jar path derived from the manifest file or the layout default + Path getOwnJarFile() { + if (manifestFile != null) { + return manifestFile.resolveSibling(FileUtils.getNameWithoutExtension(manifestFile) + ".jar"); + } + return layout.getInstanceJarFile(id); + } + + @Override + public Path getRunDirectory() { + // Official layout: shared working directory is the repository base directory. + return getRepository().getBaseDirectory(); + } + + /// {@inheritDoc} + @Override + public AssetIndex getAssetIndex(String assetId) throws IOException { + try { + return Objects.requireNonNull( + JsonUtils.fromJsonFile(getLayout().getAssetIndexFile(assetId), AssetIndex.class)); + } catch (JsonParseException | NullPointerException e) { + throw new IOException("Asset index file malformed", e); + } + } + + /// {@inheritDoc} + @Override + public Path getActualAssetDirectory(String assetId) { + try { + return reconstructAssets(assetId); + } catch (IOException | JsonParseException e) { + LOG.error("Unable to reconstruct asset directory", e); + return getLayout().getAssetDirectory(); + } + } + + /// {@inheritDoc} + @Override + public Optional getAssetObject(String assetId, String name) throws IOException { + try { + @Nullable AssetObject assetObject = getAssetIndex(assetId).getObjects().get(name); + return assetObject != null + ? Optional.of(getLayout().getAssetObject(assetObject)) + : Optional.empty(); + } catch (IOException e) { + throw e; + } catch (Exception e) { + throw new IOException( + "Unrecognized asset object " + name + " in asset " + assetId + " of version " + id, + e); + } + } + + /// Reconstructs virtual and legacy resource layouts for an asset index when required. + /// + /// @param assetId the asset index ID + /// @return the directory to supply at launch time + /// @throws IOException if an asset cannot be copied + /// @throws JsonParseException if the asset index is malformed + private Path reconstructAssets(String assetId) throws IOException, JsonParseException { + Path assetsDir = getLayout().getAssetDirectory(); + Path indexFile = getLayout().getAssetIndexFile(assetId); + Path virtualRoot = assetsDir.resolve("virtual").resolve(assetId); + + if (!Files.isRegularFile(indexFile)) { + return assetsDir; + } + + @Nullable AssetIndex index = JsonUtils.fromJsonFile(indexFile, AssetIndex.class); + if (index == null || !index.isVirtual()) { + return assetsDir; + } + + Path resourcesDir = getRunDirectory().resolve("resources"); + int existingObjects = 0; + int totalObjects = index.getObjects().size(); + for (Map.Entry entry : index.getObjects().entrySet()) { + Path target = virtualRoot.resolve(entry.getKey()); + Path original = getLayout().getAssetObject(entry.getValue()); + if (Files.exists(original)) { + existingObjects++; + if (!Files.isRegularFile(target)) { + FileUtils.copyFile(original, target); + } + + if (index.needMapToResources()) { + target = resourcesDir.resolve(entry.getKey()); + if (!Files.isRegularFile(target)) { + FileUtils.copyFile(original, target); + } + } + } + } + + return existingObjects * 10 < totalObjects ? assetsDir : virtualRoot; + } +} 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 a9ff493d382..410104abc1c 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -18,16 +18,14 @@ package org.jackhuang.hmcl.game; import com.google.gson.JsonParseException; -import org.jackhuang.hmcl.addon.mod.ModManager; -import org.jackhuang.hmcl.addon.resourcepack.ResourcePackManager; -import org.jackhuang.hmcl.download.MaintainTask; -import org.jackhuang.hmcl.event.*; -import org.jackhuang.hmcl.modpack.ModpackConfiguration; +import javafx.application.Platform; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.SimpleObjectProperty; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.FileUtils; -import org.jackhuang.hmcl.util.platform.Platform; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; @@ -38,13 +36,18 @@ import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.util.*; -import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; import java.util.stream.Stream; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @NotNullByDefault -public class DefaultGameRepository implements GameRepository { +public abstract class DefaultGameRepository implements GameRepository { + + private static final ExecutorService POOL = Lang.threadPool("DefaultGameRepository", true, 4, 10, TimeUnit.SECONDS); private static final GameInstanceManifest CLASSIC_MANIFEST = new GameInstanceManifest( new GameInstanceID("Classic"), @@ -80,7 +83,7 @@ private static Library classicLibrary(String name) { null, null, null, null, null, null); } - private static boolean hasClassicVersion(Path baseDirectory) { + private static boolean hasClassicInstance(Path baseDirectory) { Path bin = baseDirectory.resolve("bin"); return Files.isDirectory(bin) && Files.exists(bin.resolve("lwjgl.jar")) @@ -88,141 +91,228 @@ private static boolean hasClassicVersion(Path baseDirectory) { && Files.exists(bin.resolve("lwjgl_util.jar")); } - private volatile Status status; + /// Published snapshot, always updated on the JavaFX application thread when the toolkit is live. + private final ObjectProperty snapshot; + + /// Whether at least one full refresh has completed since the base directory was set. private volatile boolean loaded; - private final ConcurrentHashMap> gameVersions = new ConcurrentHashMap<>(); + /// Creates a repository rooted at the given directory with an empty initial snapshot. + /// + /// @param baseDirectory the initial repository base directory public DefaultGameRepository(Path baseDirectory) { - this.status = new Status(baseDirectory); + DefaultGameRepositorySnapshot initial = createSnapshot(createLayout(baseDirectory)); + initial.seal(); + this.snapshot = new SimpleObjectProperty<>(initial); } - public Path getBaseDirectory() { - return status.baseDirectory; - } + /// Creates the repository layout rooted at the given directory. + /// + /// @param baseDirectory the repository base directory + /// @return the layout used by this repository + protected abstract DefaultGameRepositoryLayout createLayout(Path baseDirectory); public void setBaseDirectory(Path baseDirectory) { - this.status = new Status(baseDirectory); + // Mark unloaded before publishing so snapshot listeners do not treat the empty snapshot as ready. this.loaded = false; - this.gameVersions.clear(); + DefaultGameRepositorySnapshot initial = createSnapshot(createLayout(baseDirectory)); + publishSnapshot(initial); } - public boolean isLoaded() { - return loaded; + /// {@inheritDoc} + /// + /// The returned snapshot is sealed and must not be modified. Writers must [#clone()] it, edit the + /// copy, and publish the result with [#publishSnapshot(DefaultGameRepositorySnapshot)]. + @Override + public DefaultGameRepositorySnapshot getSnapshot() { + return snapshot.get(); + } + + /// Returns a read-only view of the current published snapshot for JavaFX bindings. + /// + /// The property is the sole holder of the published snapshot. Updates are applied on the JavaFX + /// application thread so listeners may safely touch the scene graph. + /// + /// @return the observable snapshot property + public ReadOnlyObjectProperty snapshotProperty() { + return snapshot; + } + + /// Seals `newSnapshot` if needed and publishes it as the current repository snapshot. + /// + /// When the JavaFX toolkit is running, the property is updated on the JavaFX application thread + /// (blocking the caller if publish happens off the FX thread) so that listeners run on FX and + /// [#getSnapshot()] observes the new value before this method returns. + /// + /// @param newSnapshot the snapshot to publish; must not already be visible as [#getSnapshot()] + /// unless it is a freshly built replacement + protected void publishSnapshot(DefaultGameRepositorySnapshot newSnapshot) { + newSnapshot.seal(); + runOnFxThreadAndWait(() -> { + + snapshot.set(newSnapshot); + }); } - @Override - public void refresh() { - if (EventBus.EVENT_BUS.fireEvent(new RefreshingInstancesEvent(this)) == Event.Result.DENY) { + /// Runs an action on the JavaFX application thread and waits for its completion. + /// + /// The action runs on the calling thread when the JavaFX toolkit has not been initialized. + /// Interruptions are restored after a queued JavaFX action completes. + /// + /// @param action the action to run + private static void runOnFxThreadAndWait(Runnable action) { + if (Platform.isFxApplicationThread()) { + action.run(); return; } - refreshImpl(); - loaded = true; - EventBus.EVENT_BUS.fireEvent(new RefreshedGameInstancesEvent(this)); + CountDownLatch completed = new CountDownLatch(1); + try { + Platform.runLater(() -> { + try { + action.run(); + } finally { + completed.countDown(); + } + }); + } catch (IllegalStateException ignored) { + // JavaFX toolkit is not initialized (for example in headless unit tests). + action.run(); + return; + } + + boolean interrupted = false; + while (true) { + try { + completed.await(); + break; + } catch (InterruptedException e) { + interrupted = true; + } + } + if (interrupted) { + Thread.currentThread().interrupt(); + } } - protected void refreshImpl() { - Status newStatus = new Status(status.baseDirectory); + @Override + public DefaultGameRepositoryLayout getLayout() { + return getSnapshot().getLayout(); + } + + public boolean isLoaded() { + return loaded; + } + + @Override + public void refresh() { + DefaultGameRepositorySnapshot newSnapshot = createSnapshot(getSnapshot().getLayout()); + DefaultGameRepositoryLayout layout = newSnapshot.getLayout(); - if (hasClassicVersion(newStatus.baseDirectory)) { + if (hasClassicInstance(layout.getBaseDirectory())) { GameInstanceID id = CLASSIC_MANIFEST.id(); - newStatus.instances.put(id, new InstanceHolder(newStatus, id, CLASSIC_MANIFEST)); + newSnapshot.put(createInstance(newSnapshot, id, CLASSIC_MANIFEST)); } - Path versionsDir = newStatus.baseDirectory.resolve("versions"); - if (Files.isDirectory(versionsDir)) { - try (Stream stream = Files.list(versionsDir)) { - stream.parallel().filter(Files::isDirectory).flatMap(dir -> { - GameInstanceID id; - try { - id = new GameInstanceID(FileUtils.getName(dir)); - } catch (IllegalArgumentException e) { - LOG.warning("Ignoring version folder with invalid id " + dir, e); - return Stream.empty(); - } - - Path json = dir.resolve(id + ".json"); - - if (Files.notExists(json)) { - List jsons = FileUtils.listFilesByExtension(dir, "json"); - if (jsons.size() == 1) { - LOG.info("Renaming json file " + jsons.get(0) + " to " + json); - - try { - Files.move(jsons.get(0), json); - } catch (IOException e) { - LOG.warning("Cannot rename json file, ignoring version " + id, e); - return Stream.empty(); - } - - Path jar = dir.resolve(FileUtils.getNameWithoutExtension(jsons.get(0)) + ".jar"); - if (Files.exists(jar)) { - try { - Files.move(jar, dir.resolve(id + ".jar")); - } catch (IOException e) { - LOG.warning("Cannot rename jar file, ignoring version " + id, e); - return Stream.empty(); - } - } - } else { - LOG.info("No available json file found, ignoring version " + id); - return Stream.empty(); - } - } + Path instancesDir = layout.getBaseDirectory().resolve("versions"); + if (Files.isDirectory(instancesDir)) { + try (Stream stream = Files.list(instancesDir)) { + List> futures = stream + .filter(Files::isDirectory) + .map(dir -> CompletableFuture.supplyAsync( + Lang.wrap(() -> loadInstanceDirectory(newSnapshot, dir)), + POOL)) + .toList(); - GameInstanceManifest manifest; + for (CompletableFuture<@Nullable DefaultGameInstance> future : futures) { try { - manifest = readInstanceManifest(json); - } catch (Exception e) { - LOG.warning("Malformed version json " + id, e); - if (EventBus.EVENT_BUS.fireEvent(new GameJsonParseFailedEvent(this, json, id.id())) != Event.Result.ALLOW) { - return Stream.empty(); - } - - try { - manifest = readInstanceManifest(json); - } catch (Exception e2) { - LOG.error("User corrected version json is still malformed", e2); - return Stream.empty(); - } - } - - if (!id.equals(manifest.id())) { - try { - moveInstanceFiles(newStatus.baseDirectory, id, manifest.id()); - } catch (IOException e) { - LOG.warning("Ignoring instance " + manifest.id() - + " because instance id does not match folder name " + id - + ", and we cannot correct it.", e); - return Stream.empty(); + DefaultGameInstance instance = future.join(); + if (instance != null) { + newSnapshot.put(instance); } + } catch (Exception e) { + LOG.warning("Failed to load instance", e); } - - return Stream.of(manifest); - }).forEachOrdered(it -> newStatus.instances.put( - it.id(), - new InstanceHolder(newStatus, it.id(), it))); + } } catch (IOException e) { - LOG.warning("Failed to load versions from " + versionsDir, e); + LOG.warning("Failed to load instance from " + instancesDir, e); } } - Map loadedInstances = new TreeMap<>(); - for (InstanceHolder holder : newStatus.instances.values()) { + Map loadedInstances = new TreeMap<>(); + for (DefaultGameInstance instance : newSnapshot.values()) { try { - GameInstanceManifest resolved = newStatus.resolve(holder.manifest, new HashSet<>()).launchManifest(); + GameInstanceManifest resolved = instance.getResolvedManifest().launchManifest(); if (CompatibilityRule.appliesToCurrentEnvironment(resolved.compatibilityRules())) { - loadedInstances.put(holder.id, holder); + loadedInstances.put(instance.getId(), instance); } } catch (NoSuchGameInstanceException e) { - LOG.warning("Ignoring version " + holder.id + " because it inherits from a nonexistent version."); + LOG.warning("Ignoring instance " + instance.getId() + " because it inherits from a nonexistent instance."); } } - newStatus.instances.clear(); - newStatus.instances.putAll(loadedInstances); - gameVersions.clear(); - this.status = newStatus; + newSnapshot.clear(); + newSnapshot.putAll(loadedInstances); + // Mark loaded before publishing so snapshot listeners observe a ready repository. + loaded = true; + publishSnapshot(newSnapshot); + } + + /// Loads one instance directory without renaming on-disk JSON or jar files. + /// + /// When the conventional `versions//.json` is missing but the directory contains exactly + /// one JSON file, that manifest path is recorded on the instance. The primary jar is derived as + /// the sibling path with the same base name. + /// + /// @param snapshot the unsealed snapshot that will own the instance + /// @param dir the instance directory under `versions/` + /// @return the loaded instance, or `null` when the directory should be ignored + private @Nullable DefaultGameInstance loadInstanceDirectory(DefaultGameRepositorySnapshot snapshot, Path dir) { + GameInstanceID id; + try { + id = new GameInstanceID(FileUtils.getName(dir)); + } catch (IllegalArgumentException e) { + LOG.warning("Ignoring instance directory with invalid id " + dir, e); + return null; + } + + DefaultGameRepositoryLayout layout = snapshot.getLayout(); + Path conventionalJson = layout.getInstanceJson(id); + + Path json; + @Nullable Path manifestFileOverride = null; + + if (Files.isRegularFile(conventionalJson)) { + json = conventionalJson; + } else { + List jsons = FileUtils.listFilesByExtension(dir, "json"); + if (jsons.size() != 1) { + LOG.info("No available json file found, ignoring instance " + id); + return null; + } + + json = jsons.get(0); + if (!json.equals(conventionalJson)) { + manifestFileOverride = json; + } + + LOG.info("Using non-conventional instance manifest for " + id + ": " + json); + } + + GameInstanceManifest manifest; + try { + manifest = readInstanceManifest(json); + } catch (Exception e) { + LOG.warning("Malformed instance json " + id + " (" + json + ")", e); + return null; + } + + // Directory name is the repository identity; keep the on-disk files untouched. + if (!id.equals(manifest.id())) { + manifest = manifest.withId(id); + } + + return createInstance(snapshot, id, manifest, manifestFileOverride); } private static GameInstanceManifest readInstanceManifest(Path json) throws IOException, JsonParseException { @@ -234,9 +324,9 @@ private static GameInstanceManifest readInstanceManifest(Path json) throws IOExc } private static void moveInstanceFiles(Path baseDirectory, GameInstanceID from, GameInstanceID to) throws IOException { - Path versionsDir = baseDirectory.resolve("versions"); - Path fromDir = versionsDir.resolve(from.id()); - Path toDir = versionsDir.resolve(to.id()); + Path instancesDir = baseDirectory.resolve("versions"); + Path fromDir = instancesDir.resolve(from.id()); + Path toDir = instancesDir.resolve(to.id()); Files.move(fromDir, toDir); Path fromJson = toDir.resolve(from + ".json"); @@ -252,114 +342,63 @@ private static void moveInstanceFiles(Path baseDirectory, GameInstanceID from, G Files.move(fromJar, toJar); } } catch (IOException e) { - Lang.ignoringException(() -> Files.move(toJson, fromJson)); - if (hasJarFile) { - Lang.ignoringException(() -> Files.move(toJar, fromJar)); + try { + Files.move(toJson, fromJson); + } catch (Throwable e2) { + e.addSuppressed(e2); } - Lang.ignoringException(() -> Files.move(toDir, fromDir)); - throw e; - } - } - - @Override - public boolean hasInstance(GameInstanceID instanceId) { - return status.instances.containsKey(instanceId); - } - - @Override - public GameInstanceManifest getInstanceManifest(GameInstanceID instanceId) throws NoSuchGameInstanceException { - InstanceHolder instanceHolder = status.instances.get(instanceId); - if (instanceHolder == null) { - throw new NoSuchGameInstanceException(instanceId); - } - return instanceHolder.manifest; - } - - @Override - public GameInstanceManifest.Resolved getResolvedInstanceManifest(GameInstanceID instanceId) throws NoSuchGameInstanceException { - Status currentStatus = status; - - InstanceHolder instanceHolder = currentStatus.instances.get(instanceId); - if (instanceHolder == null) { - throw new NoSuchGameInstanceException(instanceId); - } - - GameInstanceManifest.Resolved resolvedManifest = instanceHolder.resolvedManifest; - if (resolvedManifest == null) { - resolvedManifest = currentStatus.resolve(instanceHolder.manifest, new HashSet<>()); - instanceHolder.resolvedManifest = resolvedManifest; - } - return resolvedManifest; - } - @Override - public int getInstanceCount() { - return status.instances.size(); - } - - @Override - public Path getInstanceRoot(GameInstanceID instanceId) { - return getBaseDirectory().resolve("versions").resolve(instanceId.id()); - } - - @Override - public Collection getInstanceManifests() { - return status.instances.values().stream().map(i -> i.manifest).toList(); - } - - @Override - public Path getLibrariesDirectory(GameInstanceManifest manifest) { - return getBaseDirectory().resolve("libraries"); - } - - @Override - public Path getLibraryFile(GameInstanceManifest manifest, Library lib) { - if ("local".equals(lib.hint())) { - if (lib.filename() != null) { - return getInstanceRoot(manifest.id()).resolve("libraries/" + lib.filename()); + if (hasJarFile) { + try { + Files.move(toJar, fromJar); + } catch (Throwable e2) { + e.addSuppressed(e2); + } } - return getInstanceRoot(manifest.id()).resolve("libraries/" + lib.artifact().getFileName()); + try { + Files.move(toDir, fromDir); + } catch (Exception e2) { + e.addSuppressed(e2); + } + throw e; } - - return getLibrariesDirectory(manifest).resolve(lib.getPath()); - } - - public Path getArtifactFile(GameInstanceManifest manifest, Artifact artifact) { - return artifact.getPath(getBaseDirectory().resolve("libraries")); } @Override - public Path getRunDirectory(GameInstanceID instanceId) { - return getBaseDirectory(); + public DefaultGameInstance getInstance(GameInstanceID id) throws NoSuchGameInstanceException { + return getSnapshot().getRegistered(id); } - @Override - public Path getInstanceJar(GameInstanceID instanceId) throws NoSuchGameInstanceException { - return getInstanceJar(getResolvedInstanceManifest(instanceId).launchManifest()); + /// Returns the instance recorded in the current snapshot for the given id. + /// + /// @param id the instance id + /// @return the instance, or `null` when absent from the current snapshot + protected @Nullable DefaultGameInstance findSnapshotInstance(GameInstanceID id) { + return getSnapshot().get(id); } @Override public Path getInstanceJar(GameInstanceManifest manifest) { GameInstanceManifest resolved = this.resolve(manifest).launchManifest(); GameInstanceID id = Optional.ofNullable(resolved.jar()).orElse(resolved.id()); - return getInstanceRoot(id).resolve(id + ".jar"); + DefaultGameInstance instance = findSnapshotInstance(id); + if (instance != null) { + return instance.getOwnJarFile(); + } + return getLayout().getInstanceJarFile(id); } @Override public boolean renameInstance(GameInstanceID from, GameInstanceID to) { - if (EventBus.EVENT_BUS.fireEvent(new RenameInstanceEvent(this, from, to)) == Event.Result.DENY) { - return false; - } - try { - Status currentStatus = status; - InstanceHolder fromHolder = currentStatus.instances.get(from); + DefaultGameRepositorySnapshot newSnapshot = getSnapshot().clone(); + DefaultGameInstance fromHolder = newSnapshot.get(from); if (fromHolder == null) { throw new NoSuchGameInstanceException(from); } - moveInstanceFiles(currentStatus.baseDirectory, from, to); + moveInstanceFiles(newSnapshot.getLayout().getBaseDirectory(), from, to); GameInstanceManifest renamedManifest = fromHolder.manifest; if (from.equals(renamedManifest.jar())) { @@ -368,53 +407,59 @@ public boolean renameInstance(GameInstanceID from, GameInstanceID to) { renamedManifest = renamedManifest.withId(to); JsonUtils.writeToJsonFile(getInstanceJson(to), renamedManifest); - Map updatedInstances = new TreeMap<>(currentStatus.instances); - updatedInstances.remove(from); - updatedInstances.put(to, new InstanceHolder(currentStatus, to, renamedManifest)); + newSnapshot.remove(from); + newSnapshot.put(fromHolder.withManifest(newSnapshot, renamedManifest)); - for (InstanceHolder holder : currentStatus.instances.values()) { - GameInstanceManifest manifest = holder.manifest; + for (DefaultGameInstance instance : List.copyOf(newSnapshot.values())) { + GameInstanceManifest manifest = instance.manifest; if (from.equals(manifest.inheritsFrom())) { GameInstanceManifest updatedManifest = manifest.withInheritsFrom(to); Path targetPath = getInstanceJson(updatedManifest.id()); Files.createDirectories(targetPath.getParent()); JsonUtils.writeToJsonFile(targetPath, updatedManifest); - updatedInstances.put(updatedManifest.id(), new InstanceHolder(currentStatus, updatedManifest.id(), updatedManifest)); + newSnapshot.put(instance.withManifest(newSnapshot, updatedManifest)); } } - currentStatus.instances.clear(); - currentStatus.instances.putAll(updatedInstances); - gameVersions.clear(); + publishSnapshot(newSnapshot); return true; } catch (IOException | JsonParseException | NoSuchGameInstanceException | InvalidPathException e) { - LOG.warning("Unable to rename version " + from + " to " + to, e); + LOG.warning("Unable to rename instance " + from + " to " + to, e); return false; } } + /// Removes an instance from the published index and attempts to remove its backing directory. + /// + /// The repository is refreshed before this method returns, including when filesystem removal + /// fails after the instance has been removed from the published snapshot. After the instance + /// directory is staged under its `_removed` sibling, failure to trash or fully delete that + /// staging directory is logged but does not change the return value. + /// + /// @param id the instance id + /// @return `false` if removal is denied or the instance directory cannot be staged; `true` if + /// the directory is absent or staging succeeds public boolean removeInstanceFromDisk(GameInstanceID id) { - if (EventBus.EVENT_BUS.fireEvent(new RemoveInstanceEvent(this, id)) == Event.Result.DENY) { - return false; - } - - Status currentStatus = status; - currentStatus.instances.remove(id); - - Path file = getInstanceRoot(id); - if (Files.notExists(file)) { - return true; + if (getSnapshot().get(id) != null) { + DefaultGameRepositorySnapshot newSnapshot = getSnapshot().clone(); + newSnapshot.remove(id); + publishSnapshot(newSnapshot); } - Path removedFile = file.toAbsolutePath().resolveSibling(FileUtils.getName(file) + "_removed"); try { - Files.move(file, removedFile, StandardCopyOption.REPLACE_EXISTING); - } catch (IOException e) { - LOG.warning("Unable to remove version folder: " + file, e); - return false; - } + Path file = getLayout().getInstanceRoot(id); + if (Files.notExists(file)) { + return true; + } + + Path removedFile = file.toAbsolutePath().resolveSibling(FileUtils.getName(file) + "_removed"); + try { + Files.move(file, removedFile, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + LOG.warning("Unable to remove instance directory: " + file, e); + return false; + } - try { if (FileUtils.moveToTrash(removedFile)) { return true; } @@ -430,312 +475,117 @@ public boolean removeInstanceFromDisk(GameInstanceID id) { try { FileUtils.deleteDirectory(removedFile); } catch (IOException e) { - LOG.warning("Unable to remove version folder: " + file, e); + LOG.warning("Unable to remove instance directory: " + removedFile, e); } return true; } finally { - refreshAsync().start(); + refresh(); } } @Override public Optional getGameVersion(GameInstanceManifest manifest) { + DefaultGameInstance instance = findSnapshotInstance(manifest.id()); + if (instance != null && manifest.equals(instance.getManifest())) { + GameVersionNumber version = instance.getVersion(); + if (version == GameVersionNumber.unknown()) { + return Optional.empty(); + } + return Optional.of(version.toString()); + } + try { GameInstanceManifest resolved = resolve(manifest).launchManifest(); Path instanceJar = getInstanceJar(resolved); - return gameVersions.computeIfAbsent(instanceJar, jar -> { - Optional gameVersion = GameVersion.minecraftVersion(jar); - if (gameVersion.isEmpty()) { - LOG.warning("Cannot find out game version of " + manifest.id() - + ", primary jar: " + jar - + ", jar exists: " + Files.exists(jar)); - } - return gameVersion; - }); + Optional gameVersion = GameVersion.minecraftVersion(instanceJar); + if (gameVersion.isEmpty()) { + LOG.warning("Cannot find out game version of " + manifest.id() + + ", primary jar: " + instanceJar + + ", jar exists: " + Files.exists(instanceJar)); + } + return gameVersion; } catch (NoSuchGameInstanceException e) { return Optional.empty(); } } - @Override - public Path getNativeDirectory(GameInstanceID instanceId, Platform platform) { - return getInstanceRoot(instanceId).resolve("natives-" + platform); - } - - @Override - public Path getModsDirectory(GameInstanceID instanceId) { - return getRunDirectory(instanceId).resolve("mods"); - } - - @Override - public Path getResourcePackDirectory(GameInstanceID instanceId) { - return getRunDirectory(instanceId).resolve("resourcepacks"); - } - + /// Returns the stored instance manifest file for an instance. + /// + /// When the instance is loaded with a non-conventional path, that path is returned; otherwise + /// the layout default `versions//.json` is used. + /// + /// @param instanceId the instance id + /// @return the manifest JSON path public Path getInstanceJson(GameInstanceID instanceId) { - return getInstanceRoot(instanceId).resolve(instanceId.id() + ".json"); - } - - @Override - public AssetIndex getAssetIndex(GameInstanceID instanceId, String assetId) throws IOException { - try { - return Objects.requireNonNull(JsonUtils.fromJsonFile(getIndexFile(instanceId, assetId), AssetIndex.class)); - } catch (JsonParseException | NullPointerException e) { - throw new IOException("Asset index file malformed", e); - } - } - - @Override - public Path getActualAssetDirectory(GameInstanceID instanceId, String assetId) { - try { - return reconstructAssets(instanceId, assetId); - } catch (IOException | JsonParseException e) { - LOG.error("Unable to reconstruct asset directory", e); - return getAssetDirectory(instanceId, assetId); - } - } - - @Override - public Path getAssetDirectory(GameInstanceID instanceId, String assetId) { - return getBaseDirectory().resolve("assets"); - } - - @Override - public Optional getAssetObject(GameInstanceID instanceId, String assetId, String name) throws IOException { - try { - AssetObject assetObject = getAssetIndex(instanceId, assetId).getObjects().get(name); - if (assetObject == null) return Optional.empty(); - return Optional.of(getAssetObject(instanceId, assetId, assetObject)); - } catch (IOException e) { - throw e; - } catch (Exception e) { - throw new IOException("Unrecognized asset object " + name + " in asset " + assetId + " of version " + instanceId, e); - } - } - - @Override - public Path getAssetObject(GameInstanceID instanceId, String assetId, AssetObject obj) { - return getAssetObject(instanceId, getAssetDirectory(instanceId, assetId), obj); - } - - public Path getAssetObject(GameInstanceID instanceId, Path assetDir, AssetObject obj) { - return assetDir.resolve("objects").resolve(obj.getLocation()); - } - - @Override - public Path getIndexFile(GameInstanceID instanceId, String assetId) { - return getAssetDirectory(instanceId, assetId).resolve("indexes").resolve(assetId + ".json"); - } - - @Override - public Path getLoggingObject(GameInstanceID instanceId, String assetId, LoggingInfo loggingInfo) { - return getAssetDirectory(instanceId, assetId).resolve("log_configs").resolve(loggingInfo.file().getId()); - } - - protected Path reconstructAssets(GameInstanceID instanceId, String assetId) throws IOException, JsonParseException { - Path assetsDir = getAssetDirectory(instanceId, assetId); - Path indexFile = getIndexFile(instanceId, assetId); - Path virtualRoot = assetsDir.resolve("virtual").resolve(assetId); - - if (!Files.isRegularFile(indexFile)) - return assetsDir; - - AssetIndex index = JsonUtils.fromJsonFile(indexFile, AssetIndex.class); - - if (index == null) - return assetsDir; - - if (index.isVirtual()) { - Path resourcesDir = getRunDirectory(instanceId).resolve("resources"); - - int cnt = 0; - int tot = index.getObjects().size(); - for (Map.Entry entry : index.getObjects().entrySet()) { - Path target = virtualRoot.resolve(entry.getKey()); - Path original = getAssetObject(instanceId, assetsDir, entry.getValue()); - if (Files.exists(original)) { - cnt++; - if (!Files.isRegularFile(target)) - FileUtils.copyFile(original, target); - - if (index.needMapToResources()) { - target = resourcesDir.resolve(entry.getKey()); - if (!Files.isRegularFile(target)) - FileUtils.copyFile(original, target); - } - } - } - - // If the scale new format existent file is lower than 0.1, use the old format. - if (cnt * 10 < tot) - return assetsDir; - else - return virtualRoot; + DefaultGameInstance instance = findSnapshotInstance(instanceId); + if (instance != null) { + return instance.getManifestFile(); } - - return assetsDir; + return getLayout().getInstanceJson(instanceId); } + /// Saves a stored manifest without applying derived launch-view normalization. + /// + /// The returned task writes the manifest and publishes a snapshot containing exactly that + /// persistent representation, including its inheritance and pending patches. + /// + /// @param instanceManifest the persistent manifest to save + /// @return the task that saves and publishes the manifest public Task saveAsync(GameInstanceManifest instanceManifest) { return Task.supplyAsync(() -> { - GameInstanceManifest savedManifest = instanceManifest.isResolvedPreservingPatches() - ? MaintainTask.maintainPreservingPatches(this, instanceManifest) - : instanceManifest; - - Path json = getInstanceJson(savedManifest.id()).toAbsolutePath(); + Path json = getInstanceJson(instanceManifest.id()).toAbsolutePath(); Files.createDirectories(json.getParent()); - JsonUtils.writeToJsonFile(json, savedManifest); + JsonUtils.writeToJsonFile(json, instanceManifest); - Status currentStatus = status; - currentStatus.instances.put(savedManifest.id(), new InstanceHolder(currentStatus, savedManifest.id(), savedManifest)); - gameVersions.clear(); - return savedManifest; + DefaultGameRepositorySnapshot newSnapshot = getSnapshot().clone(); + DefaultGameInstance existing = newSnapshot.get(instanceManifest.id()); + if (existing != null) { + newSnapshot.put(existing.withManifest(newSnapshot, instanceManifest)); + } else { + newSnapshot.put(createInstance(newSnapshot, instanceManifest.id(), instanceManifest)); + } + publishSnapshot(newSnapshot); + return instanceManifest; }); } - public Path getModpackConfiguration(GameInstanceID instanceId) { - return getInstanceRoot(instanceId).resolve("modpack.json"); - } - - @Nullable - public ModpackConfiguration readModpackConfiguration(GameInstanceID instanceId) throws IOException, NoSuchGameInstanceException { - if (!hasInstance(instanceId)) throw new NoSuchGameInstanceException(instanceId); - Path file = getModpackConfiguration(instanceId); - if (Files.notExists(file)) return null; - return JsonUtils.fromJsonFile(file, ModpackConfiguration.class); - } - - public boolean isModpack(GameInstanceID instanceId) { - return Files.exists(getModpackConfiguration(instanceId)); - } - - public Path getSavesDirectory(GameInstanceID instanceId) { - return getRunDirectory(instanceId).resolve("saves"); - } - - public Path getBackupsDirectory(GameInstanceID instanceID) { - return getRunDirectory(instanceID).resolve("backups"); - } - - public Path getSchematicsDirectory(GameInstanceID instanceId) { - return getRunDirectory(instanceId).resolve("schematics"); - } - - public ModManager getModManager(GameInstanceID instanceId) { - return new ModManager(this, instanceId); - } - - public ResourcePackManager getResourcePackManager(GameInstanceID instanceId) { - return new ResourcePackManager(this, instanceId); - } - @Override public GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest) throws NoSuchGameInstanceException { - return status.resolve(manifest, new HashSet<>()); - } - - protected static class Status { - private final Path baseDirectory; - private final Map instances = new TreeMap<>(); - - protected Status(Path baseDirectory) { - this.baseDirectory = baseDirectory; - } + return getSnapshot().resolve(manifest); + } + + /// Creates an empty unsealed snapshot for the given layout. + /// + /// @param layout the layout for the new snapshot + /// @return a new unsealed snapshot + protected DefaultGameRepositorySnapshot createSnapshot(DefaultGameRepositoryLayout layout) { + return new DefaultGameRepositorySnapshot(this, layout); + } + + /// Creates a conventional instance with layout-default storage paths. + /// + /// @param snapshot the snapshot that will own the instance + /// @param id the instance id + /// @param manifest the stored instance manifest + /// @return the new instance + protected final DefaultGameInstance createInstance( + DefaultGameRepositorySnapshot snapshot, + GameInstanceID id, + GameInstanceManifest manifest) { + return createInstance(snapshot, id, manifest, null); + } + + /// Creates an instance, optionally recording a non-conventional manifest path. + /// + /// @param snapshot the snapshot that will own the instance + /// @param id the instance id + /// @param manifest the stored instance manifest + /// @param manifestFile the actual manifest JSON path, or `null` for the layout default + /// @return the new instance + protected abstract DefaultGameInstance createInstance( + DefaultGameRepositorySnapshot snapshot, + GameInstanceID id, + GameInstanceManifest manifest, + @Nullable Path manifestFile); - private GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest, - Set resolvedSoFar) throws NoSuchGameInstanceException { - GameInstanceManifest launchManifest; - GameInstanceManifest standaloneManifest = manifest.isRoot() - ? manifest - : addPatches( - addPatches(new GameInstanceManifest(manifest.id()), List.of(manifest.toPatch())), - manifest.patches()); - - if (manifest.inheritsFrom() == null) { - if (manifest.isRoot()) { - // TODO: Breaking change, require much testing on versions installed with external installer, other launchers, and all kinds of versions. - launchManifest = manifest.patches() != null ? new GameInstanceManifest(manifest.id()).withPatches(manifest.patches()) : manifest; - } else { - launchManifest = manifest; - } - launchManifest = launchManifest.withJar(manifest.jar() == null ? manifest.id() : manifest.jar()); - } else { - // To maximize the compatibility. - if (!resolvedSoFar.add(manifest.id())) { - LOG.warning("Found circular dependency versions: " + resolvedSoFar); - launchManifest = (manifest.jar() == null ? manifest.withJar(manifest.id()) : manifest) - .withInheritsFrom(null); - } else { - InstanceHolder parentInstance = instances.get(manifest.inheritsFrom()); - if (parentInstance == null) { - throw new NoSuchGameInstanceException(manifest.inheritsFrom()); - } - - // It is supposed to auto-install a version in getVersion. - GameInstanceManifest.Resolved parentResolved = resolve(parentInstance.manifest, resolvedSoFar); - launchManifest = manifest.merge(parentResolved.launchManifest()); - standaloneManifest = addPatches( - addPatches(parentResolved.standaloneManifest(), Collections.singleton(manifest.toPatch())), - manifest.patches()); - } - } - - if (manifest.patches() != null && !manifest.patches().isEmpty()) { - // Assume patches themselves do not have patches recursively. - List sortedPatches = manifest.patches().stream() - .sorted(Comparator.comparing(GameInstancePatch::getPriority)) - .toList(); - for (GameInstancePatch patch : sortedPatches) { - launchManifest = patch.merge(launchManifest); - } - } - - launchManifest = launchManifest.withId(manifest.id()).withPatches(null); - standaloneManifest = standaloneManifest.withId(manifest.id()); - if (launchManifest.jar() != null) { - standaloneManifest = standaloneManifest.withJar(launchManifest.jar()); - } - - return new GameInstanceManifest.Resolved(manifest, launchManifest, standaloneManifest); - } - - private static GameInstanceManifest addPatches(GameInstanceManifest manifest, @Nullable Collection additional) { - if (additional == null || additional.isEmpty()) { - return manifest; - } - - Set patchIds = new HashSet<>(); - for (GameInstancePatch patch : additional) { - if (patch.id() != null) { - patchIds.add(patch.id()); - } - } - - List patches = new ArrayList<>(); - if (manifest.patches() != null) { - for (GameInstancePatch patch : manifest.patches()) { - if (patch.id() == null || !patchIds.contains(patch.id())) { - patches.add(patch); - } - } - } - patches.addAll(additional); - return manifest.withPatches(patches); - } - - } - - protected static class InstanceHolder { - protected final Status status; - protected final GameInstanceID id; - protected final GameInstanceManifest manifest; - protected @Nullable GameInstanceManifest.Resolved resolvedManifest; - protected @Nullable GameVersionNumber version; - - protected InstanceHolder(Status status, GameInstanceID id, GameInstanceManifest manifest) { - this.status = status; - this.id = id; - this.manifest = manifest; - } - } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryLayout.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryLayout.java new file mode 100644 index 00000000000..ed84f363c76 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryLayout.java @@ -0,0 +1,127 @@ +/* + * 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.game; + +import org.jetbrains.annotations.NotNullByDefault; + +import java.nio.file.Path; +import java.util.Objects; + +/// Implements the conventional official Minecraft launcher repository directory layout. +/// +/// Instance definitions are stored as `versions//.json`, client jars as +/// `versions//.jar`, with shared `libraries/` and `assets/` directories under the base +/// directory. +@NotNullByDefault +public class DefaultGameRepositoryLayout implements GameRepositoryLayout { + private final Path baseDirectory; + + /// Creates a layout rooted at the given directory. + /// + /// The path is retained as supplied and is not normalized or converted to an absolute path. + /// + /// @param baseDirectory the repository base directory + public DefaultGameRepositoryLayout(Path baseDirectory) { + this.baseDirectory = Objects.requireNonNull(baseDirectory); + } + + /// {@inheritDoc} + @Override + public Path getBaseDirectory() { + return baseDirectory; + } + + /// {@inheritDoc} + /// + /// Official layout path: `versions//` below the base directory. + @Override + public Path getInstanceRoot(GameInstanceID instanceId) { + return getBaseDirectory().resolve("versions").resolve(instanceId.id()); + } + + /// Returns the official version manifest file for an instance. + /// + /// @param instanceId the instance ID + /// @return the path `versions//.json` below the base directory + public Path getInstanceJson(GameInstanceID instanceId) { + return getInstanceRoot(instanceId).resolve(instanceId.id() + ".json"); + } + + /// Returns the conventional client jar file for an instance under the official layout. + /// + /// @param instanceId the instance ID + /// @return the path `versions//.jar` below the base directory + public Path getInstanceJarFile(GameInstanceID instanceId) { + return getInstanceRoot(instanceId).resolve(instanceId.id() + ".jar"); + } + + public Path getModpackConfigurationFile(GameInstanceID instanceId) { + return getInstanceRoot(instanceId).resolve("modpack.cfg"); + } + + /// {@inheritDoc} + /// + /// Official layout path: `libraries/` below the base directory. + @Override + public Path getLibrariesDirectory() { + return getBaseDirectory().resolve("libraries"); + } + + /// {@inheritDoc} + @Override + public Path getLibraryFile(GameInstanceID owner, Library library) { + if ("local".equals(library.hint())) { + if (library.filename() != null) { + return getInstanceRoot(owner).resolve("libraries").resolve(library.filename()); + } + + return getInstanceRoot(owner).resolve("libraries").resolve(library.artifact().getFileName()); + } + + return getLibrariesDirectory().resolve(library.getPath()); + } + + /// {@inheritDoc} + /// + /// Official layout path: `assets/` below the base directory. + @Override + public Path getAssetDirectory() { + return getBaseDirectory().resolve("assets"); + } + + /// {@inheritDoc} + @Override + public Path getAssetIndexFile(String assetId) { + return getAssetDirectory().resolve("indexes").resolve(assetId + ".json"); + } + + /// {@inheritDoc} + @Override + public Path getAssetObject(AssetObject object) { + return getAssetDirectory().resolve("objects").resolve(object.getLocation()); + } + + /// {@inheritDoc} + /// + /// The official layout stores logging configurations in a shared directory, so `assetId` does + /// not alter the returned path. + @Override + public Path getLoggingObject(String assetId, LoggingInfo loggingInfo) { + return getAssetDirectory().resolve("log_configs").resolve(loggingInfo.file().getId()); + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java new file mode 100644 index 00000000000..5c9151c2bf0 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java @@ -0,0 +1,324 @@ +/* + * 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.game; + +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// Default implementation of a repository index snapshot for [DefaultGameRepository]. +/// +/// A snapshot begins unsealed so package-private writers can populate it. [#seal()] freezes the +/// instance map; afterwards any mutating method throws. Repository write paths must [#clone()] a +/// published snapshot, edit the copy, and publish it with +/// [DefaultGameRepository#publishSnapshot(DefaultGameRepositorySnapshot)]. +/// +/// Once sealed, this object is exposed as a [GameRepositorySnapshot]. +/// +/// Mutation methods are package-private: only code in `org.jackhuang.hmcl.game` may assemble a +/// snapshot. Subclasses such as HMCL-specific snapshots may override [#newEmpty()] to preserve +/// concrete type through [#clone()], analogous to +/// [DefaultGameInstance#withNewSnapshot(DefaultGameRepositorySnapshot)]. +@NotNullByDefault +public class DefaultGameRepositorySnapshot implements GameRepositorySnapshot { + protected final DefaultGameRepository repository; + protected final DefaultGameRepositoryLayout layout; + private Map instances; + private boolean sealed; + + /// Creates an empty unsealed snapshot for building a new snapshot. + /// + /// @param repository the owning repository + /// @param layout the layout for this snapshot + public DefaultGameRepositorySnapshot(DefaultGameRepository repository, DefaultGameRepositoryLayout layout) { + this.repository = repository; + this.layout = layout; + this.instances = new TreeMap<>(); + this.sealed = false; + } + + /// Creates an empty unsealed snapshot of the same concrete type as this snapshot. + /// + /// @return a new empty unsealed snapshot + protected DefaultGameRepositorySnapshot newEmpty() { + return new DefaultGameRepositorySnapshot(repository, layout); + } + + /// Freezes this snapshot so its instance map can no longer be modified. + void seal() { + if (!sealed) { + instances = Collections.unmodifiableMap(new TreeMap<>(instances)); + sealed = true; + } + } + + /// Returns whether this snapshot has been sealed. + /// + /// @return whether mutation is forbidden + public boolean isSealed() { + return sealed; + } + + private void checkMutable() { + if (sealed) { + throw new IllegalStateException("Snapshot has been published and cannot be modified"); + } + } + + /// {@inheritDoc} + @Override + public DefaultGameRepository getRepository() { + return repository; + } + + /// {@inheritDoc} + @Override + public DefaultGameRepositoryLayout getLayout() { + return layout; + } + + /// Returns the instance with the given id. + /// + /// @param id the instance id + /// @return the instance, or `null` when absent + public @Nullable DefaultGameInstance get(GameInstanceID id) { + return instances.get(id); + } + + /// Returns the registered instance with the given id. + /// + /// @param id the instance id + /// @return the registered instance + /// @throws NoSuchGameInstanceException if the instance is absent + public DefaultGameInstance getRegistered(GameInstanceID id) throws NoSuchGameInstanceException { + DefaultGameInstance instance = instances.get(id); + if (instance != null) { + return instance; + } + throw new NoSuchGameInstanceException(id); + } + + /// {@inheritDoc} + @Override + public boolean hasInstance(GameInstanceID instanceId) { + return instances.containsKey(instanceId); + } + + /// {@inheritDoc} + @Override + public DefaultGameInstance getInstance(GameInstanceID instanceId) throws NoSuchGameInstanceException { + return getRegistered(instanceId); + } + + /// {@inheritDoc} + @Override + public @Nullable DefaultGameInstance findInstance(GameInstanceID instanceId) { + return instances.get(instanceId); + } + + /// {@inheritDoc} + @Override + public int getInstanceCount() { + return instances.size(); + } + + /// {@inheritDoc} + @Override + public Collection getInstances() { + return List.copyOf(instances.values()); + } + + /// {@inheritDoc} + @Override + public Collection getInstanceManifests() { + return instances.values().stream() + .map(instance -> instance.manifest) + .toList(); + } + + /// Returns a view of all instances in this snapshot. + /// + /// @return the instances; unmodifiable after [#seal()] + public Collection values() { + return instances.values(); + } + + /// Returns an unmodifiable map view after seal, or the live map while building. + /// + /// @return the instance map + public Map asMap() { + return instances; + } + + /// Adds or replaces an instance in this unsealed snapshot. + /// + /// @param instance the instance bound to this snapshot + void put(DefaultGameInstance instance) { + checkMutable(); + instances.put(instance.getId(), instance); + } + + /// Adds or replaces all instances from the given map. + /// + /// @param map instances keyed by id + void putAll(Map map) { + checkMutable(); + instances.putAll(map); + } + + /// Removes the instance with the given id. + /// + /// @param id the instance id + void remove(GameInstanceID id) { + checkMutable(); + instances.remove(id); + } + + /// Removes all instances from this unsealed snapshot. + void clear() { + checkMutable(); + instances.clear(); + } + + /// Creates an unsealed copy of this snapshot with instances rebound to the copy. + /// + /// @return a mutable snapshot ready for further edits before publish + @Override + public DefaultGameRepositorySnapshot clone() { + DefaultGameRepositorySnapshot newSnapshot = newEmpty(); + for (DefaultGameInstance instance : instances.values()) { + newSnapshot.put(instance.withNewSnapshot(newSnapshot)); + } + return newSnapshot; + } + + /// Resolves official-layout inheritance and patches, then normalizes the final launch view. + /// + /// @param manifest the manifest to resolve + /// @return the resolved manifest views + /// @throws NoSuchGameInstanceException if an inherited parent is missing from this snapshot + public GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest) throws NoSuchGameInstanceException { + GameInstanceManifest.Resolved resolved = resolveStructure(manifest, new HashSet<>()); + GameInstanceManifest normalizedLaunchManifest = + LaunchManifestNormalizer.normalize(resolved.launchManifest()); + return new GameInstanceManifest.Resolved( + resolved.unresolved(), normalizedLaunchManifest, resolved.standaloneManifest()); + } + + /// Resolves official-layout inheritance and patches without launch compatibility normalization. + /// + /// @param manifest the manifest to resolve + /// @param resolvedSoFar instance ids already visited in the inheritance chain + /// @return the resolved manifest views + /// @throws NoSuchGameInstanceException if an inherited parent is missing from this snapshot + private GameInstanceManifest.Resolved resolveStructure( + GameInstanceManifest manifest, + Set resolvedSoFar) throws NoSuchGameInstanceException { + GameInstanceManifest launchManifest; + GameInstanceManifest standaloneManifest = manifest.isRoot() + ? manifest + : addPatches( + addPatches(new GameInstanceManifest(manifest.id()), List.of(manifest.toPatch())), + manifest.patches()); + + if (manifest.inheritsFrom() == null) { + if (manifest.isRoot()) { + // TODO: Breaking change, require much testing on versions installed with external installer, other launchers, and all kinds of versions. + launchManifest = manifest.patches() != null + ? new GameInstanceManifest(manifest.id()).withPatches(manifest.patches()) + : manifest; + } else { + launchManifest = manifest; + } + launchManifest = launchManifest.withJar(manifest.jar() == null ? manifest.id() : manifest.jar()); + } else { + // To maximize the compatibility. + if (!resolvedSoFar.add(manifest.id())) { + LOG.warning("Found circular dependency versions: " + resolvedSoFar); + launchManifest = (manifest.jar() == null ? manifest.withJar(manifest.id()) : manifest) + .withInheritsFrom(null); + } else { + DefaultGameInstance parentInstance = instances.get(manifest.inheritsFrom()); + if (parentInstance == null) { + throw new NoSuchGameInstanceException(manifest.inheritsFrom()); + } + + // It is supposed to auto-install a version in getVersion. + GameInstanceManifest.Resolved parentResolved = + resolveStructure(parentInstance.getManifest(), resolvedSoFar); + launchManifest = manifest.merge(parentResolved.launchManifest()); + standaloneManifest = addPatches( + addPatches(parentResolved.standaloneManifest(), Collections.singleton(manifest.toPatch())), + manifest.patches()); + } + } + + if (manifest.patches() != null && !manifest.patches().isEmpty()) { + // Assume patches themselves do not have patches recursively. + List sortedPatches = manifest.patches().stream() + .sorted(Comparator.comparing(GameInstancePatch::getPriority)) + .toList(); + for (GameInstancePatch patch : sortedPatches) { + launchManifest = patch.merge(launchManifest); + } + } + + launchManifest = launchManifest.withId(manifest.id()).withPatches(null); + standaloneManifest = standaloneManifest.withId(manifest.id()); + if (launchManifest.jar() != null) { + standaloneManifest = standaloneManifest.withJar(launchManifest.jar()); + } + + return new GameInstanceManifest.Resolved(manifest, launchManifest, standaloneManifest); + } + + private static GameInstanceManifest addPatches(GameInstanceManifest manifest, @Nullable Collection additional) { + if (additional == null || additional.isEmpty()) { + return manifest; + } + + Set patchIds = new HashSet<>(); + for (GameInstancePatch patch : additional) { + if (patch.id() != null) { + patchIds.add(patch.id()); + } + } + + List patches = new ArrayList<>(); + if (manifest.patches() != null) { + for (GameInstancePatch patch : manifest.patches()) { + if (patch.id() == null || !patchIds.contains(patch.id())) { + patches.add(patch); + } + } + } + patches.addAll(additional); + return manifest.withPatches(patches); + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentAnalyzer.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentAnalyzer.java new file mode 100644 index 00000000000..5f846e23288 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentAnalyzer.java @@ -0,0 +1,221 @@ +/* + * 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.game; + +import org.jackhuang.hmcl.addon.mod.ModLoaderType; +import org.jackhuang.hmcl.util.versioning.VersionNumber; +import org.jackhuang.hmcl.util.versioning.VersionRange; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; + +import java.util.*; + +@NotNullByDefault +public final class GameComponentAnalyzer implements Iterable { + + private static GameComponentAnalyzer analyze( + GameInstanceManifest standaloneManifest, + GameInstanceManifest launchManifest, + @Nullable String gameVersion) { + var components = new EnumMap(GameComponentType.class); + + if (gameVersion != null) { + components.put(GameComponentType.GAME, new Mark(GameComponentType.GAME, gameVersion, true)); + } + + for (GameInstancePatch patch : standaloneManifest.getPatches()) { + if (patch.isHidden() || patch.id() == null) continue; + + @Nullable GameComponentType type = GameComponentType.fromPatchId(patch.id()); + if (type != null) { + components.put(type, new Mark(type, patch.version(), true)); + } + } + + List rawLibraries = launchManifest.getLibraries(); + for (Library library : rawLibraries) { + for (GameComponentType type : GameComponentType.ALL) { + if (components.containsKey(type)) continue; + + if (type.matchLibrary(library, rawLibraries)) { + components.put(type, new Mark(type, type.getComponentVersion(standaloneManifest, library.version()), false)); + break; + } + } + } + + return new GameComponentAnalyzer(standaloneManifest, components); + } + + public static GameComponentAnalyzer analyze(GameInstanceManifest.Resolved resolved, @Nullable String gameVersion) { + return analyze(resolved.standaloneManifest(), resolved.launchManifest(), gameVersion); + } + + public static GameComponentAnalyzer analyze(GameInstanceManifest manifest, @Nullable String gameVersion) { + if (manifest.inheritsFrom() != null) + throw new IllegalArgumentException("LibraryAnalyzer can only analyze independent game version"); + + return analyze(manifest, manifest, gameVersion); + } + + private final GameInstanceManifest manifest; + private final Map components; + + private GameComponentAnalyzer(GameInstanceManifest manifest, Map components) { + this.manifest = manifest; + this.components = components; + } + + public boolean has(GameComponentType type) { + return components.containsKey(type); + } + + public boolean has(ModLoaderType type) { + for (GameComponentType componentType : components.keySet()) { + if (componentType.getModLoaderType() == type) { + return true; + } + } + return false; + } + + public boolean hasModLauncher() { + return GameComponentAnalyzer.MOD_LAUNCHER_MAIN.equals(manifest.mainClass()) || manifest.getPatches().stream().anyMatch( + patch -> GameComponentAnalyzer.MOD_LAUNCHER_MAIN.equals(patch.mainClass()) + ); + } + + private static GameInstanceManifest removingMatchedLibrary(GameInstanceManifest manifest, GameComponentType type) { + List libraries = new ArrayList<>(); + List rawLibraries = manifest.getLibraries(); + for (Library library : rawLibraries) { + if (type.matchLibrary(library, rawLibraries)) { + // skip + } else { + libraries.add(library); + } + } + return manifest.withLibraries(libraries); + } + + private GameInstancePatch removingMatchedLibrary(GameInstancePatch patch, GameComponentType type) { + List libraries = new ArrayList<>(); + List rawLibraries = patch.getLibraries(); + for (Library library : rawLibraries) { + if (type.matchLibrary(library, rawLibraries)) { + // skip + } else { + libraries.add(library); + } + } + return patch.withLibraries(libraries); + } + + /// Remove library by library id + /// + /// @param componentType the patch identifier, such as `forge`, `optifine`, or `fabric` + /// @return this + public GameInstanceManifest removeLibrary(GameComponentType componentType) { + if (!has(componentType)) return manifest; + GameInstanceManifest manifest = removingMatchedLibrary(this.manifest, componentType); + return manifest.withPatches(this.manifest.getPatches().stream() + .filter(patch -> !componentType.getPatchId().equals(patch.id())) + .map(patch -> removingMatchedLibrary(patch, componentType)) + .toList()); + } + + public @Nullable String getVersion(GameComponentType type) { + Mark mark = components.get(type); + return mark != null ? mark.version() : null; + } + + /// If a library is provided in `$.patches`, it's structure is so clear that we can do any operation. + /// Otherwise, we must guess how are these libraries mixed. + /// Maybe a guessing implementation will be provided in the future. But by now, we simply set it to JUST\_EXISTED. + public boolean isClear(GameComponentType type) { + return manifest.hasPatch(type.getPatchId()); + } + + public @Unmodifiable Set getModLoaders() { + Set res = EnumSet.noneOf(ModLoaderType.class); + for (GameComponentType type : components.keySet()) { + if (type.getModLoaderType() != null) { + res.add(type.getModLoaderType()); + } + } + return res; + } + + @Override + public Iterator iterator() { + return components.values().iterator(); + } + + /// If a library is provided in `$.patches`, it's structure is so clear that we can do any operation. + /// Otherwise, we must guess how are these libraries mixed. + /// Maybe a guessing implementation will be provided in the future. But by now, we simply set it to JUST\_EXISTED. + public enum Status { + CLEAR, UNSURE, JUST_EXISTED + } + + public record Mark( + GameComponentType componentType, + @Nullable String version, + boolean clear + ) { + } + + public static final String VANILLA_MAIN = "net.minecraft.client.main.Main"; + public static final String LAUNCH_WRAPPER_MAIN = "net.minecraft.launchwrapper.Launch"; + public static final String MOD_LAUNCHER_MAIN = "cpw.mods.modlauncher.Launcher"; + public static final String BOOTSTRAP_LAUNCHER_MAIN = "cpw.mods.bootstraplauncher.BootstrapLauncher"; + public static final String FORGE_BOOTSTRAP_MAIN = "net.minecraftforge.bootstrap.ForgeBootstrap"; + public static final String NEO_FORGE_BOOTSTRAP_MAIN = "net.neoforged.fml.startup.Client"; + + public static final Set MOD_LOADER_MAIN_CLASSES_PACKAGES = Set.of( + "net.minecraftforge", + "net.neoforged", + "top.outlands", // Cleanroom + "net.fabricmc", + "org.quiltmc", + "cpw.mods" + ); + + public static final Set FORGE_OPTIFINE_MAIN = Set.of( + VANILLA_MAIN, + LAUNCH_WRAPPER_MAIN, + MOD_LAUNCHER_MAIN, + BOOTSTRAP_LAUNCHER_MAIN, + FORGE_BOOTSTRAP_MAIN, + NEO_FORGE_BOOTSTRAP_MAIN + ); + + public static final VersionRange FORGE_OPTIFINE_BROKEN_RANGE = VersionNumber.between("48.0.0", "49.0.50"); + + public static final @Unmodifiable List FORGE_TWEAKERS = List.of( + "net.minecraftforge.legacy._1_5_2.LibraryFixerTweaker", // 1.5.2 + "cpw.mods.fml.common.launcher.FMLTweaker", // 1.6.1 ~ 1.7.10 + "net.minecraftforge.fml.common.launcher.FMLTweaker" // 1.8 ~ 1.12.2 + ); + public static final @Unmodifiable List OPTIFINE_TWEAKERS = List.of( + "optifine.OptiFineTweaker", + "optifine.OptiFineForgeTweaker" + ); + public static final String LITELOADER_TWEAKER = "com.mumfrey.liteloader.launch.LiteLoaderTweaker"; +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java new file mode 100644 index 00000000000..9abe965ad91 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java @@ -0,0 +1,275 @@ +/* + * 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.game; + +import org.jackhuang.hmcl.addon.mod.ModLoaderType; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/// @author Glavo +@NotNullByDefault +public enum GameComponentType { + GAME("game") { + @Override + protected boolean matchLibrary(Library library, List libraries) { + return true; + } + }, + LEGACY_FABRIC("legacyfabric", ModLoaderType.LEGACY_FABRIC) { + @Override + protected boolean matchLibrary(Library library, List libraries) { + if ("net.fabricmc".equals(library.groupId()) && "fabric-loader".equals(library.artifactId())) { + for (Library l : libraries) { + if ("net.legacyfabric".equals(l.groupId())) { + return true; + } + } + } + return false; + } + }, + LEGACY_FABRIC_API("legacyfabric-api") { + @Override + protected boolean matchLibrary(Library library, List libraries) { + return "net.legacyfabric".equals(library.groupId()) && "legacyfabric-api".equals(library.artifactId()); + } + }, + FABRIC("fabric", ModLoaderType.FABRIC) { + @Override + protected boolean matchLibrary(Library library, List libraries) { + if ("net.fabricmc".equals(library.groupId()) && "fabric-loader".equals(library.artifactId())) { + for (Library l : libraries) { + if ("net.legacyfabric".equals(l.groupId())) { + return false; + } + } + + return true; + } + + return false; + } + }, + FABRIC_API("fabric-api") { + @Override + protected boolean matchLibrary(Library library, List libraries) { + return "net.fabricmc".equals(library.groupId()) && "fabric-api".equals(library.artifactId()); + } + }, + FORGE("forge", ModLoaderType.FORGE) { + private final Pattern FORGE_VERSION_MATCHER = Pattern.compile("^([0-9.]+)-(?[0-9.]+)(-([0-9.]+))?$"); + + @Override + protected @Nullable String getComponentVersion(GameInstanceManifest manifest, String libraryVersion) { + Matcher matcher = FORGE_VERSION_MATCHER.matcher(libraryVersion); + if (matcher.find()) { + return matcher.group("forge"); + } + return super.getComponentVersion(manifest, libraryVersion); + } + + @Override + protected boolean matchLibrary(Library library, List libraries) { + for (Library l : libraries) { + if (NEO_FORGE.matchLibrary(l, libraries)) { + return false; + } + } + + return "net.minecraftforge".equals(library.groupId()) && ("forge".equals(library.artifactId()) || "fmlloader".equals(library.artifactId())); + } + }, + CLEANROOM("cleanroom", ModLoaderType.CLEANROOM) { + @Override + protected boolean matchLibrary(Library library, List libraries) { + return "com.cleanroommc".equals(library.groupId()) && "cleanroom".equals(library.artifactId()); + } + }, + NEO_FORGE("neoforge", ModLoaderType.NEO_FORGE) { + private final Pattern NEO_FORGE_VERSION_MATCHER = Pattern.compile("^([0-9.]+)-(?[0-9.]+)(-([0-9.]+))?$"); + + @Override + protected boolean matchLibrary(Library library, List libraries) { + return "net.neoforged.fancymodloader".equals(library.groupId()) && ("core".equals(library.artifactId()) || "loader".equals(library.artifactId())); + } + + @Override + protected @Nullable String getComponentVersion(GameInstanceManifest manifest, String libraryVersion) { + String res = scanVersion(manifest); + if (res != null) { + return res; + } + + for (GameInstancePatch patch : manifest.getPatches()) { + res = scanPatch(patch); + if (res != null) { + return res; + } + } + + Matcher matcher = NEO_FORGE_VERSION_MATCHER.matcher(libraryVersion); + if (matcher.find()) { + return matcher.group("forge"); + } + + return libraryVersion; + } + + private @Nullable String scanVersion(GameInstanceManifest manifest) { + if (manifest.arguments() == null) { + return null; + } + List gameArguments = manifest.arguments().game(); + if (gameArguments == null) { + return null; + } + + for (int i = 0; i < gameArguments.size() - 1; i++) { + Argument argument = gameArguments.get(i); + if (argument instanceof StringArgument) { + String argumentValue = ((StringArgument) argument).argument(); + if ("--fml.neoForgeVersion".equals(argumentValue) || "--fml.forgeVersion".equals(argumentValue)) { + Argument next = gameArguments.get(i + 1); + if (next instanceof StringArgument) { + return ((StringArgument) next).argument(); + } + return null; // Normally, there should not be two --fml.neoForgeVersion argument. + } + } + } + return null; + } + + private @Nullable String scanPatch(GameInstancePatch patch) { + Arguments optArgument = patch.arguments(); + if (optArgument == null) { + return null; + } + List gameArguments = optArgument.game(); + if (gameArguments == null) { + return null; + } + + for (int i = 0; i < gameArguments.size() - 1; i++) { + Argument argument = gameArguments.get(i); + if (argument instanceof StringArgument) { + String argumentValue = ((StringArgument) argument).argument(); + if ("--fml.neoForgeVersion".equals(argumentValue) || "--fml.forgeVersion".equals(argumentValue)) { + Argument next = gameArguments.get(i + 1); + if (next instanceof StringArgument) { + return ((StringArgument) next).argument(); + } + return null; + } + } + } + return null; + } + + }, + LITELOADER("liteloader", ModLoaderType.LITE_LOADER) { + @Override + protected boolean matchLibrary(Library library, List libraries) { + return "com.mumfrey".equals(library.groupId()) && "liteloader".equals(library.artifactId()); + } + }, + OPTIFINE("optifine") { + private static final Set GROUPS = Set.of("net.optifine", "optifine"); + + @Override + protected boolean matchLibrary(Library library, List libraries) { + return GROUPS.contains(library.groupId()) && !library.artifactId().contains("launchwrapper"); + } + }, + QUILT("quilt", ModLoaderType.QUILT) { + @Override + protected boolean matchLibrary(Library library, List libraries) { + return "org.quiltmc".equals(library.groupId()) && "quilt-loader".equals(library.artifactId()); + } + }, + QUILT_API("quilt-api") { + @Override + protected boolean matchLibrary(Library library, List libraries) { + return "org.quiltmc".equals(library.groupId()) && "quilt-api".equals(library.artifactId()); + } + }, + BOOTSTRAP_LAUNCHER("") { + @Override + protected boolean matchLibrary(Library library, List libraries) { + return "cpw.mods".equals(library.groupId()) && "bootstraplauncher".equals(library.artifactId()); + } + }; + + public static final List ALL = List.of(GameComponentType.values()); + public static final List MOD_LOADERS = ALL.stream() + .filter(GameComponentType::isModLoader) + .toList(); + + private final String patchId; + private final @Nullable ModLoaderType modLoaderType; + + private static final Map PATCH_ID_MAP = new HashMap<>(); + + static { + for (GameComponentType type : values()) { + PATCH_ID_MAP.put(type.getPatchId(), type); + } + } + + GameComponentType(String patchId) { + this.patchId = patchId; + this.modLoaderType = null; + } + + GameComponentType(String patchId, ModLoaderType modLoaderType) { + this.patchId = patchId; + this.modLoaderType = modLoaderType; + } + + public boolean isModLoader() { + return modLoaderType != null; + } + + @Contract(pure = true) + public String getPatchId() { + return patchId; + } + + public @Nullable ModLoaderType getModLoaderType() { + return modLoaderType; + } + + public static @Nullable GameComponentType fromPatchId(String patchId) { + return PATCH_ID_MAP.get(patchId); + } + + protected abstract boolean matchLibrary(Library library, List libraries); + + protected @Nullable String getComponentVersion(GameInstanceManifest manifest, String libraryVersion) { + return libraryVersion; + } + +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java new file mode 100644 index 00000000000..18a8bc60cfd --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java @@ -0,0 +1,156 @@ +/* + * 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.game; + +import org.jackhuang.hmcl.util.platform.Platform; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; +import org.jetbrains.annotations.NotNullByDefault; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Optional; + +/// Provides a view of a game instance and its instance-specific paths within a +/// [GameRepositorySnapshot]. +/// +/// Core repository implementations publish instances as values belonging to a sealed snapshot. +/// When the repository publishes a newer snapshot, previously obtained instances may be stale. +/// Callers that need a long-lived identity should retain a [GameInstanceID] (or a higher-level +/// handle) and resolve it again from [GameRepository#getSnapshot()]. +@NotNullByDefault +public interface GameInstance { + + GameRepository getRepository(); + + GameRepositoryLayout getLayout(); + + /// Returns the instance ID. + /// + /// @return the instance ID + GameInstanceID getId(); + + /// Returns the manifest read from this instance's manifest file. + /// + /// @return the unresolved stored manifest + GameInstanceManifest getManifest(); + + /// Returns the eagerly resolved manifest views captured with this instance. + /// + /// @return the resolved manifest views + GameInstanceManifest.Resolved getResolvedManifest(); + + /// Returns the manifest used by launch-time consumers. + /// + /// @return the launch manifest + default GameInstanceManifest getLaunchManifest() { + return getResolvedManifest().launchManifest(); + } + + GameVersionNumber getVersion(); + + /// Returns the directory containing files owned by this instance. + /// + /// @return the instance root directory + Path getInstanceRoot(); + + /// Returns the stored instance manifest file for this instance. + /// + /// @return the manifest JSON path + Path getManifestFile(); + + /// Returns the launcher-specific modpack configuration file for this instance. + /// + /// @return the modpack configuration path in the instance root + Path getModpackConfigurationFile(); + + /// Returns the primary client jar selected by the resolved launch manifest. + /// + /// @return the primary client jar path + Path getInstanceJarFile(); + + /// Returns the working directory used to run this instance. + /// + /// @return the run directory + Path getRunDirectory(); + + /// Reads an asset index used by this instance. + /// + /// @param assetId the asset index ID + /// @return the parsed asset index + /// @throws IOException if the asset index cannot be read + AssetIndex getAssetIndex(String assetId) throws IOException; + + /// Returns the asset directory that should be supplied when launching this instance. + /// + /// Implementations may reconstruct virtual or legacy resource layouts before returning. + /// + /// @param assetId the asset index ID + /// @return the launch-time asset directory + Path getActualAssetDirectory(String assetId); + + /// Returns an existing asset object by its logical name. + /// + /// @param assetId the asset index ID + /// @param name the logical asset name + /// @return the asset object path, or empty when the index has no such object + /// @throws IOException if the asset index cannot be read + Optional getAssetObject(String assetId, String name) throws IOException; + + /// Returns the directory containing mods used by this instance. + /// + /// @return the mods directory below the run directory + default Path getModsDirectory() { + return getRunDirectory().resolve("mods"); + } + + /// Returns the directory containing resource packs used by this instance. + /// + /// @return the resource pack directory below the run directory + default Path getResourcePackDirectory() { + return getRunDirectory().resolve("resourcepacks"); + } + + /// Returns the directory containing saved worlds used by this instance. + /// + /// @return the saves directory below the run directory + default Path getSavesDirectory() { + return getRunDirectory().resolve("saves"); + } + + /// Returns the directory containing world backups used by this instance. + /// + /// @return the backups directory below the run directory + default Path getBackupsDirectory() { + return getRunDirectory().resolve("backups"); + } + + /// Returns the directory containing schematics used by this instance. + /// + /// @return the schematics directory below the run directory + default Path getSchematicsDirectory() { + return getRunDirectory().resolve("schematics"); + } + + /// Returns the directory used for extracted native libraries for a platform. + /// + /// @param platform the target platform + /// @return the platform-specific native directory below the instance root + default Path getNativeDirectory(Platform platform) { + return getInstanceRoot().resolve("natives-" + platform); + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstanceID.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstanceID.java index 39b7ae1c270..c289480aa5b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstanceID.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstanceID.java @@ -28,13 +28,19 @@ import java.io.IOException; +/// @author Glavo @NotNullByDefault @JsonAdapter(GameInstanceID.Adapter.class) @JsonSerializable public record GameInstanceID(String id) implements Comparable { + + public static boolean isValid(String id) { + return !id.isBlank() && !id.contains("/") && !id.contains("\\"); + } + public GameInstanceID { - if (id.isBlank()) { - throw new IllegalArgumentException("Game instance id cannot be empty"); + if (!isValid(id)) { + throw new IllegalArgumentException("Invalid game instance id: " + id); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstanceManifest.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstanceManifest.java index 4dca673a317..4ff27fe4bd2 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstanceManifest.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstanceManifest.java @@ -65,8 +65,9 @@ public record GameInstanceManifest( /// Resolved manifest views with inheritance folded. /// - /// @param launchManifest the final manifest data used by launch-time consumers - /// @param standaloneManifest the standalone manifest data with pending patches preserved + /// @param unresolved the stored manifest supplied to resolution + /// @param launchManifest the normalized final manifest data used by launch-time consumers + /// @param standaloneManifest the structural standalone manifest with pending patches preserved @NotNullByDefault public record Resolved(GameInstanceManifest unresolved, GameInstanceManifest launchManifest, @@ -91,6 +92,20 @@ public record Resolved(GameInstanceManifest unresolved, throw new IllegalArgumentException("Standalone manifest cannot inherit from another manifest"); } } + + public boolean isModded() { + String mainClass = launchManifest().mainClass(); + if (mainClass == null || GameComponentAnalyzer.LAUNCH_WRAPPER_MAIN.equals(mainClass)) { + return false; + } + + for (String packageName : GameComponentAnalyzer.MOD_LOADER_MAIN_CLASSES_PACKAGES) { + if (mainClass.startsWith(packageName)) + return true; + } + + return false; + } } GameInstanceManifest merge(GameInstanceManifest parent) { @@ -340,13 +355,6 @@ public boolean isRoot() { return root != null && root; } - /// Returns whether this manifest is already a standalone view. - /// - /// @return whether this manifest has no parent - public boolean isResolvedPreservingPatches() { - return inheritsFrom == null; - } - /// Returns the pending patches. /// /// @return the pending patches, or an empty list when absent diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstancePatch.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstancePatch.java index 03e98895d3c..06fd1c6985b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstancePatch.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstancePatch.java @@ -184,6 +184,11 @@ public GameInstancePatch withId(@Nullable String id) { return builder.toPatch(); } + /// Returns a patch copy with the given id. + public GameInstancePatch withId(@Nullable GameComponentType type) { + return withId(type != null ? type.getPatchId() : null); + } + /// Returns a patch copy with the given version. public GameInstancePatch withVersion(@Nullable String version) { if (Objects.equals(this.version, version)) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java index 5949fac8d57..04d8baab2fa 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java @@ -19,10 +19,8 @@ import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.io.FileUtils; -import org.jackhuang.hmcl.util.platform.Platform; import org.jetbrains.annotations.NotNullByDefault; -import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Collection; @@ -32,11 +30,39 @@ /// Provides indexed access to local game instances and the filesystem layout used by those instances. /// +/// The registered instance index is published as immutable [GameRepositorySnapshot] values. Readers +/// that need a consistent view across multiple lookups should retain [#getSnapshot()] rather than +/// interleaving queries with repository writes such as [#refresh()]. +/// /// Implementations are responsible for loading instance manifests, resolving inheritance and patches, /// locating instance-owned files, and exposing helper paths used by launch, download, and maintenance code. +/// +/// Path helpers that only forward to [GameRepositoryLayout] describe concepts shared by multiple +/// repository layouts (official and MultiMC-family layouts alike). Layout-specific storage details +/// remain on concrete layout types such as [DefaultGameRepositoryLayout]. @NotNullByDefault public interface GameRepository { - /// Resolves inheritance into launch and standalone manifest views. + /// Returns the filesystem layout used by this repository. + /// + /// @return the repository layout + GameRepositoryLayout getLayout(); + + /// Returns the repository base directory. + /// + /// @return the base directory from [#getLayout()] + default Path getBaseDirectory() { + return getLayout().getBaseDirectory(); + } + + /// Returns the current published snapshot of the registered instance index. + /// + /// The snapshot is immutable. Subsequent repository writes publish a replacement snapshot and + /// do not mutate the returned object. + /// + /// @return the current repository snapshot + GameRepositorySnapshot getSnapshot(); + + /// Resolves inheritance into a normalized launch view and a patch-preserving standalone view. /// /// @param manifest the manifest to resolve /// @return the resolved manifest view @@ -46,30 +72,50 @@ public interface GameRepository { /// /// @param instanceId the instance id /// @return whether the instance exists - boolean hasInstance(GameInstanceID instanceId); + default boolean hasInstance(GameInstanceID instanceId) { + return getSnapshot().hasInstance(instanceId); + } /// Returns the stored manifest for an instance without resolving inheritance or patches. /// /// @param instanceId the instance id /// @return the stored instance manifest /// @throws NoSuchGameInstanceException if the instance is not loaded in this repository - GameInstanceManifest getInstanceManifest(GameInstanceID instanceId) throws NoSuchGameInstanceException; + default GameInstanceManifest getInstanceManifest(GameInstanceID instanceId) throws NoSuchGameInstanceException { + return getSnapshot().getInstance(instanceId).getManifest(); + } /// Returns a cached launch-ready manifest view for the instance. /// /// @param instanceId the instance id /// @return the resolved manifest view - GameInstanceManifest.Resolved getResolvedInstanceManifest(GameInstanceID instanceId) throws NoSuchGameInstanceException; + default GameInstanceManifest.Resolved getResolvedInstanceManifest(GameInstanceID instanceId) + throws NoSuchGameInstanceException { + return getSnapshot().getInstance(instanceId).getResolvedManifest(); + } /// Returns the number of loaded instances. /// /// @return the loaded instance count - int getInstanceCount(); + default int getInstanceCount() { + return getSnapshot().getInstanceCount(); + } /// Returns the stored manifests for all loaded instances. /// /// @return the loaded instance manifests - Collection getInstanceManifests(); + default Collection getInstanceManifests() { + return getSnapshot().getInstanceManifests(); + } + + /// Returns the indexed game instance for the given id. + /// + /// @param id the instance id + /// @return the game instance + /// @throws NoSuchGameInstanceException if the instance is not loaded in this repository + default GameInstance getInstance(GameInstanceID id) throws NoSuchGameInstanceException { + return getSnapshot().getInstance(id); + } /// Reloads repository state from the backing storage. void refresh(); @@ -81,49 +127,13 @@ default Task refreshAsync() { return Task.runAsync(this::refresh); } - /// Returns the directory that stores files belonging to an instance. + /// Returns the directory containing the files owned by an instance. /// /// @param instanceId the instance id /// @return the instance root directory - Path getInstanceRoot(GameInstanceID instanceId); - - /// Returns the working directory used when launching an instance. - /// - /// @param instanceId the instance id - /// @return the run directory - Path getRunDirectory(GameInstanceID instanceId); - - /// Returns the base directory used to store shared libraries for a manifest. - /// - /// @param manifest the manifest whose libraries are being resolved - /// @return the libraries directory - Path getLibrariesDirectory(GameInstanceManifest manifest); - - /// Returns the expected filesystem path for a library. - /// - /// @param manifest the manifest that owns or references the library - /// @param lib the library descriptor - /// @return the library file path - Path getLibraryFile(GameInstanceManifest manifest, Library lib); - - /// Returns the directory used for extracted native libraries of an instance and platform. - /// - /// @param instanceId the instance id - /// @param platform the target platform - /// @return the native library directory - Path getNativeDirectory(GameInstanceID instanceId, Platform platform); - - /// Returns the mods directory for an instance. - /// - /// @param instanceId the instance id - /// @return the mods directory - Path getModsDirectory(GameInstanceID instanceId); - - /// Returns the resource pack directory for an instance. - /// - /// @param instanceId the instance id - /// @return the resource pack directory - Path getResourcePackDirectory(GameInstanceID instanceId); + default Path getInstanceRoot(GameInstanceID instanceId) { + return getLayout().getInstanceRoot(instanceId); + } /// Returns the primary client jar path for a manifest. /// @@ -137,24 +147,6 @@ default Task refreshAsync() { /// @return the detected Minecraft game version, or empty if it cannot be determined Optional getGameVersion(GameInstanceManifest manifest); - /// Detects the Minecraft game version associated with an instance. - /// - /// @param instanceId the instance id - /// @return the detected Minecraft game version, or empty if it cannot be determined - /// @throws NoSuchGameInstanceException if the instance is not loaded in this repository - default Optional getGameVersion(GameInstanceID instanceId) throws NoSuchGameInstanceException { - return getGameVersion(getInstanceManifest(instanceId)); - } - - /// Returns the primary client jar path for an instance. - /// - /// @param instanceId the instance id - /// @return the primary client jar path - /// @throws NoSuchGameInstanceException if the instance is not loaded in this repository - default Path getInstanceJar(GameInstanceID instanceId) throws NoSuchGameInstanceException { - return getInstanceJar(getResolvedInstanceManifest(instanceId).launchManifest()); - } - /// Renames an instance and updates repository-managed references. /// /// @param from the current instance id @@ -162,60 +154,6 @@ default Path getInstanceJar(GameInstanceID instanceId) throws NoSuchGameInstance /// @return whether the instance was renamed boolean renameInstance(GameInstanceID from, GameInstanceID to); - /// Returns the asset directory that should be used at launch time. - /// - /// @param instanceId the instance id - /// @param assetId the asset index id - /// @return the actual asset directory - Path getActualAssetDirectory(GameInstanceID instanceId, String assetId); - - /// Returns the base asset storage directory for an instance. - /// - /// @param instanceId the instance id - /// @param assetId the asset index id - /// @return the asset storage directory - Path getAssetDirectory(GameInstanceID instanceId, String assetId); - - /// Returns an existing asset object path by logical asset name. - /// - /// @param instanceId the instance id - /// @param assetId the asset index id - /// @param name the logical asset name - /// @return the asset object path, or empty if the object is not present in the asset index - /// @throws IOException if the asset index cannot be read - Optional getAssetObject(GameInstanceID instanceId, String assetId, String name) throws IOException; - - /// Returns the expected path for an asset object descriptor. - /// - /// @param instanceId the instance id - /// @param assetId the asset index id - /// @param obj the asset object descriptor - /// @return the asset object path - Path getAssetObject(GameInstanceID instanceId, String assetId, AssetObject obj); - - /// Reads an asset index. - /// - /// @param instanceId the instance id - /// @param assetId the asset index id - /// @return the asset index - /// @throws IOException if the asset index cannot be read - AssetIndex getAssetIndex(GameInstanceID instanceId, String assetId) throws IOException; - - /// Returns the path of an asset index file. - /// - /// @param instanceId the instance id - /// @param assetId the asset index id - /// @return the asset index file path - Path getIndexFile(GameInstanceID instanceId, String assetId); - - /// Returns the path of a logging configuration object. - /// - /// @param instanceId the instance id - /// @param assetId the asset index id used as the logging object namespace - /// @param loggingInfo the logging configuration descriptor - /// @return the logging object path - Path getLoggingObject(GameInstanceID instanceId, String assetId, LoggingInfo loggingInfo); - /// Returns the classpath entries whose library files are present on disk. /// /// @param manifest the manifest whose libraries should be mapped to classpath entries @@ -225,7 +163,7 @@ default Set getClasspath(GameInstanceManifest manifest) { if (manifest.libraries() != null) { for (Library library : manifest.libraries()) if (library.appliesToCurrentEnvironment() && !library.isNative()) { - Path f = getLibraryFile(manifest, library); + Path f = getLayout().getLibraryFile(manifest.id(), library); if (Files.isRegularFile(f)) classpath.add(FileUtils.getAbsolutePath(f)); } @@ -233,5 +171,4 @@ default Set getClasspath(GameInstanceManifest manifest) { return classpath; } - } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryLayout.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryLayout.java new file mode 100644 index 00000000000..0c08d8f9733 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryLayout.java @@ -0,0 +1,103 @@ +/* + * 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.game; + +import org.jetbrains.annotations.NotNullByDefault; + +import java.nio.file.Path; + +/// Computes repository paths without performing filesystem I/O. +/// +/// The methods on this interface describe path concepts that are common across repository +/// layouts used by Minecraft launchers, including the official/vanilla layout and MultiMC-family +/// layouts: a repository base directory, per-instance roots, shared libraries, and shared assets. +/// +/// Layout-specific storage for instance definitions (for example official `versions//.json` +/// files, or MultiMC `mmc-pack.json` / `patches/`) is not part of this interface. +/// +/// Implementations must be immutable. Returned paths are derived solely from the layout's base +/// directory and the supplied arguments, so callers may safely share a layout between threads. +@NotNullByDefault +public interface GameRepositoryLayout { + /// Returns the repository base directory. + /// + /// Shared libraries, assets, and layout-specific instance storage are resolved relative to this + /// directory unless a method documents otherwise. + /// + /// @return the repository base directory + Path getBaseDirectory(); + + /// Returns the directory containing the files owned by an instance. + /// + /// This is the instance's private storage root (for example official `versions//`, or a + /// MultiMC `instances//` directory). It is not necessarily the launch working directory. + /// + /// @param instanceId the instance ID + /// @return the instance root directory + Path getInstanceRoot(GameInstanceID instanceId); + + /// Returns the shared libraries directory. + /// + /// @return the libraries directory below the base directory + Path getLibrariesDirectory(); + + /// Returns the shared library file for a Maven artifact coordinate. + /// + /// Unlike [#getLibraryFile], this always resolves under [#getLibrariesDirectory] and does not + /// consult instance-local library storage. + /// + /// @param artifact the Maven artifact coordinate + /// @return the artifact file path below the shared libraries directory + default Path getArtifactFile(Artifact artifact) { + return artifact.getPath(getLibrariesDirectory()); + } + + /// Returns the file used for a library referenced by an instance. + /// + /// Libraries with the `local` hint are resolved below the owning instance's private libraries + /// storage. Other libraries are resolved below the shared libraries directory. + /// + /// @param owner the ID of the instance that owns the library reference + /// @param library the library descriptor + /// @return the library file path + Path getLibraryFile(GameInstanceID owner, Library library); + + /// Returns the shared asset directory. + /// + /// @return the assets directory below the base directory + Path getAssetDirectory(); + + /// Returns the file containing an asset index. + /// + /// @param assetId the asset index ID + /// @return the asset index file path + Path getAssetIndexFile(String assetId); + + /// Returns the content-addressed file for an asset object. + /// + /// @param object the asset object descriptor + /// @return the asset object file path + Path getAssetObject(AssetObject object); + + /// Returns the file containing a logging configuration object. + /// + /// @param assetId the asset index ID associated with the launch manifest + /// @param loggingInfo the logging configuration descriptor + /// @return the logging configuration file path + Path getLoggingObject(String assetId, LoggingInfo loggingInfo); +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositorySnapshot.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositorySnapshot.java new file mode 100644 index 00000000000..84c26a35574 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositorySnapshot.java @@ -0,0 +1,84 @@ +/* + * 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.game; + +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.util.Collection; + +/// An immutable snapshot of a [GameRepository] instance index. +/// +/// A snapshot is published as a complete value. After publication it is never mutated: repository +/// writers replace the current snapshot rather than editing a live map. Callers that need a stable +/// view across multiple lookups should retain the snapshot returned by +/// [GameRepository#getSnapshot()] instead of repeatedly querying the repository. +/// +/// [GameInstance] values obtained from a snapshot belong to that snapshot. After the repository +/// publishes a newer snapshot, previously obtained instances may be stale; request them again from +/// the current snapshot or repository when up-to-date state is required. +/// +/// Snapshot queries describe the instances indexed at publish time. +@NotNullByDefault +public interface GameRepositorySnapshot { + /// Returns the repository that published this snapshot. + /// + /// @return the owning repository + GameRepository getRepository(); + + /// Returns the filesystem layout associated with this snapshot. + /// + /// @return the repository layout + GameRepositoryLayout getLayout(); + + /// Returns whether a registered instance with the given id exists in this snapshot. + /// + /// @param instanceId the instance id + /// @return whether the instance is registered + boolean hasInstance(GameInstanceID instanceId); + + /// Returns the registered instance with the given id. + /// + /// @param instanceId the instance id + /// @return the instance + /// @throws NoSuchGameInstanceException if the instance is not registered in this snapshot + GameInstance getInstance(GameInstanceID instanceId) throws NoSuchGameInstanceException; + + /// Returns the registered instance with the given id, or `null` when absent. + /// + /// @param instanceId the instance id + /// @return the instance, or `null` when not registered + @Nullable GameInstance findInstance(GameInstanceID instanceId); + + /// Returns the number of registered instances in this snapshot. + /// + /// @return the registered instance count + int getInstanceCount(); + + /// Returns the registered instances in this snapshot. + /// + /// The returned collection is unmodifiable and reflects only this snapshot. + /// + /// @return the registered instances + Collection getInstances(); + + /// Returns the stored manifests of all registered instances in this snapshot. + /// + /// @return the registered instance manifests + Collection getInstanceManifests(); +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/JavaVersionConstraint.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/JavaVersionConstraint.java index 11ac2f57a0f..dc604f70369 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/JavaVersionConstraint.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/JavaVersionConstraint.java @@ -17,7 +17,6 @@ */ package org.jackhuang.hmcl.game; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.java.JavaRuntime; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.platform.Architecture; @@ -29,19 +28,18 @@ import java.util.List; import java.util.Objects; - -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LAUNCH_WRAPPER_MAIN; +import java.util.Optional; public enum JavaVersionConstraint { VANILLA(true, VersionRange.all(), VersionRange.all()) { @Override - protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nullable GameInstanceManifest version, @Nullable JavaRuntime java, @Nullable LibraryAnalyzer analyzer) { + protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nullable GameInstanceManifest version, @Nullable JavaRuntime java, @Nullable GameComponentAnalyzer analyzer) { // Give priority to the Java version requirements specified in the version JSON return version == null || version.javaVersion() == null; } @Override - public boolean checkJava(GameVersionNumber gameVersionNumber, GameInstanceManifest version, JavaRuntime java, LibraryAnalyzer analyzer) { + public boolean checkJava(GameVersionNumber gameVersionNumber, GameInstanceManifest version, JavaRuntime java, GameComponentAnalyzer analyzer) { GameJavaVersion minimumJavaVersion = GameJavaVersion.getMinimumJavaVersion(gameVersionNumber); return minimumJavaVersion == null || java.getParsedVersion() >= minimumJavaVersion.majorVersion(); } @@ -50,14 +48,14 @@ public boolean checkJava(GameVersionNumber gameVersionNumber, GameInstanceManife GAME_JSON(true, VersionRange.all(), VersionRange.all()) { @Override protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nullable GameInstanceManifest version, - @Nullable JavaRuntime java, @Nullable LibraryAnalyzer analyzer) { + @Nullable JavaRuntime java, @Nullable GameComponentAnalyzer analyzer) { if (version == null) return false; // We only checks for 1.7.10 and above, since 1.7.2 with Forge can only run on Java 7, but it is recorded Java 8 in game json, which is not correct. return gameVersionNumber.compareTo("1.7.10") >= 0 && version.javaVersion() != null; } @Override - public VersionRange getJavaVersionRange(GameInstanceManifest manifest, LibraryAnalyzer analyzer) { + public VersionRange getJavaVersionRange(GameInstanceManifest manifest, GameComponentAnalyzer analyzer) { String javaVersion; if (Objects.requireNonNull(manifest.javaVersion()).majorVersion() >= 9) { javaVersion = "" + manifest.javaVersion().majorVersion(); @@ -71,57 +69,57 @@ public VersionRange getJavaVersionRange(GameInstanceManifest mani MODDED_JAVA_7(false, GameVersionNumber.atMost("1.7.2"), VersionNumber.atMost("1.7.999")) { @Override protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nullable GameInstanceManifest version, - @Nullable JavaRuntime java, @Nullable LibraryAnalyzer analyzer) { - return analyzer != null && analyzer.has(LibraryAnalyzer.LibraryType.FORGE) + @Nullable JavaRuntime java, @Nullable GameComponentAnalyzer analyzer) { + return analyzer != null && analyzer.has(GameComponentType.FORGE) && super.appliesToVersionImpl(gameVersionNumber, version, java, analyzer); } }, MODDED_JAVA_8(false, GameVersionNumber.between("1.7.10", "1.16.999"), VersionNumber.between("1.8", "1.8.999")) { @Override protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nullable GameInstanceManifest version, - @Nullable JavaRuntime java, @Nullable LibraryAnalyzer analyzer) { - return analyzer != null && analyzer.has(LibraryAnalyzer.LibraryType.FORGE) + @Nullable JavaRuntime java, @Nullable GameComponentAnalyzer analyzer) { + return analyzer != null && analyzer.has(GameComponentType.FORGE) && super.appliesToVersionImpl(gameVersionNumber, version, java, analyzer); } }, MODDED_JAVA_16(false, GameVersionNumber.between("1.17", "1.17.999"), VersionNumber.between("16", "16.999")) { @Override protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nullable GameInstanceManifest version, - @Nullable JavaRuntime java, @Nullable LibraryAnalyzer analyzer) { - return analyzer != null && analyzer.has(LibraryAnalyzer.LibraryType.FORGE) + @Nullable JavaRuntime java, @Nullable GameComponentAnalyzer analyzer) { + return analyzer != null && analyzer.has(GameComponentType.FORGE) && super.appliesToVersionImpl(gameVersionNumber, version, java, analyzer); } }, MODDED_JAVA_17(false, GameVersionNumber.between("1.18", "1.20.4"), VersionNumber.between("17", "17.999")) { @Override protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nullable GameInstanceManifest version, - @Nullable JavaRuntime java, @Nullable LibraryAnalyzer analyzer) { - return analyzer != null && analyzer.has(LibraryAnalyzer.LibraryType.FORGE) + @Nullable JavaRuntime java, @Nullable GameComponentAnalyzer analyzer) { + return analyzer != null && analyzer.has(GameComponentType.FORGE) && super.appliesToVersionImpl(gameVersionNumber, version, java, analyzer); } }, MODDED_JAVA_21(false, GameVersionNumber.atLeast("1.20.5"), VersionNumber.between("21", "21.999")) { @Override protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nullable GameInstanceManifest version, - @Nullable JavaRuntime java, @Nullable LibraryAnalyzer analyzer) { - return analyzer != null && analyzer.has(LibraryAnalyzer.LibraryType.FORGE) + @Nullable JavaRuntime java, @Nullable GameComponentAnalyzer analyzer) { + return analyzer != null && analyzer.has(GameComponentType.FORGE) && super.appliesToVersionImpl(gameVersionNumber, version, java, analyzer); } }, CLEANROOM(true, GameVersionNumber.between("1.12.2", "1.12.999"), VersionRange.all()) { @Override - protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nullable GameInstanceManifest version, @Nullable JavaRuntime java, @Nullable LibraryAnalyzer analyzer) { - return analyzer != null && analyzer.has(LibraryAnalyzer.LibraryType.CLEANROOM) + protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nullable GameInstanceManifest version, @Nullable JavaRuntime java, @Nullable GameComponentAnalyzer analyzer) { + return analyzer != null && analyzer.has(GameComponentType.CLEANROOM) && super.appliesToVersionImpl(gameVersionNumber, version, java, analyzer); } @Override - public VersionRange getJavaVersionRange(GameInstanceManifest manifest, LibraryAnalyzer analyzer) { - if (analyzer == null || !analyzer.has(LibraryAnalyzer.LibraryType.CLEANROOM)) + public VersionRange getJavaVersionRange(GameInstanceManifest manifest, GameComponentAnalyzer analyzer) { + if (analyzer == null || !analyzer.has(GameComponentType.CLEANROOM)) return VersionRange.all(); - String cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM).orElse(""); - if (cleanroomVersion.isEmpty()) + @Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM); + if (cleanroomVersion == null) return VersionRange.all(); else return VersionNumber.atLeast( @@ -133,9 +131,9 @@ public VersionRange getJavaVersionRange(GameInstanceManifest mani LAUNCH_WRAPPER(true, GameVersionNumber.atMost("1.12.999"), VersionNumber.atMost("1.8.999")) { @Override protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nullable GameInstanceManifest version, - @Nullable JavaRuntime java, @Nullable LibraryAnalyzer analyzer) { + @Nullable JavaRuntime java, @Nullable GameComponentAnalyzer analyzer) { if (version == null) return false; - return super.appliesToVersionImpl(gameVersionNumber, version, java, analyzer) && LAUNCH_WRAPPER_MAIN.equals(version.mainClass()) && + return super.appliesToVersionImpl(gameVersionNumber, version, java, analyzer) && GameComponentAnalyzer.LAUNCH_WRAPPER_MAIN.equals(version.mainClass()) && version.getLibraries().stream() .filter(library -> "launchwrapper".equals(library.artifactId())) .anyMatch(library -> VersionNumber.asVersion(library.version()).compareTo(VersionNumber.asVersion("1.13")) < 0); @@ -148,15 +146,15 @@ protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nul VANILLA_LINUX_JAVA_8(true, GameVersionNumber.atMost("1.12.999"), VersionNumber.atMost("1.8.999")) { @Override protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nullable GameInstanceManifest version, - @Nullable JavaRuntime java, @Nullable LibraryAnalyzer analyzer) { + @Nullable JavaRuntime java, @Nullable GameComponentAnalyzer analyzer) { return OperatingSystem.CURRENT_OS == OperatingSystem.LINUX && Architecture.SYSTEM_ARCH == Architecture.X86_64 && (java == null || java.getArchitecture() == Architecture.X86_64) - && (analyzer == null || !analyzer.has(LibraryAnalyzer.LibraryType.CLEANROOM)); + && (analyzer == null || !analyzer.has(GameComponentType.CLEANROOM)); } @Override - public boolean checkJava(GameVersionNumber gameVersionNumber, GameInstanceManifest version, JavaRuntime java, LibraryAnalyzer analyzer) { + public boolean checkJava(GameVersionNumber gameVersionNumber, GameInstanceManifest version, JavaRuntime java, GameComponentAnalyzer analyzer) { return java.getArchitecture() != Architecture.X86_64 || super.checkJava(gameVersionNumber, version, java, analyzer); } }, @@ -164,7 +162,7 @@ public boolean checkJava(GameVersionNumber gameVersionNumber, GameInstanceManife VANILLA_X86(false, VersionRange.all(), VersionRange.all()) { @Override protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nullable GameInstanceManifest version, - @Nullable JavaRuntime java, @Nullable LibraryAnalyzer analyzer) { + @Nullable JavaRuntime java, @Nullable GameComponentAnalyzer analyzer) { if (java == null || java.getArchitecture() != Architecture.ARM64) return false; @@ -175,7 +173,7 @@ protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nul } @Override - public boolean checkJava(GameVersionNumber gameVersionNumber, GameInstanceManifest version, JavaRuntime java, LibraryAnalyzer analyzer) { + public boolean checkJava(GameVersionNumber gameVersionNumber, GameInstanceManifest version, JavaRuntime java, GameComponentAnalyzer analyzer) { return java.getArchitecture().isX86(); } }, @@ -183,10 +181,10 @@ public boolean checkJava(GameVersionNumber gameVersionNumber, GameInstanceManife MODLAUNCHER_8(false, GameVersionNumber.between("1.16.3", "1.17.1"), VersionRange.all()) { @Override protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nullable GameInstanceManifest version, - @Nullable JavaRuntime java, @Nullable LibraryAnalyzer analyzer) { + @Nullable JavaRuntime java, @Nullable GameComponentAnalyzer analyzer) { if (version == null || java == null || analyzer == null || !super.appliesToVersionImpl(gameVersionNumber, version, java, analyzer)) return false; - VersionNumber forgePatchVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.FORGE) + VersionNumber forgePatchVersion = Optional.ofNullable(analyzer.getVersion(GameComponentType.FORGE)) .map(VersionNumber::asVersion) .orElse(null); if (forgePatchVersion == null) { @@ -207,7 +205,7 @@ protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nul } @Override - public boolean checkJava(GameVersionNumber gameVersionNumber, GameInstanceManifest version, JavaRuntime java, LibraryAnalyzer analyzer) { + public boolean checkJava(GameVersionNumber gameVersionNumber, GameInstanceManifest version, JavaRuntime java, GameComponentAnalyzer analyzer) { int parsedJavaVersion = java.getParsedVersion(); if (parsedJavaVersion > 17) { return false; @@ -243,18 +241,18 @@ public VersionRange getGameVersionRange() { return gameVersionRange; } - public VersionRange getJavaVersionRange(GameInstanceManifest manifest, LibraryAnalyzer analyzer) { + public VersionRange getJavaVersionRange(GameInstanceManifest manifest, GameComponentAnalyzer analyzer) { return javaVersionRange; } public final boolean appliesToVersion(@Nullable GameVersionNumber gameVersionNumber, @Nullable GameInstanceManifest version, - @Nullable JavaRuntime java, LibraryAnalyzer analyzer) { + @Nullable JavaRuntime java, GameComponentAnalyzer analyzer) { return gameVersionRange.contains(gameVersionNumber) && appliesToVersionImpl(gameVersionNumber, version, java, analyzer); } protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nullable GameInstanceManifest version, - @Nullable JavaRuntime java, @Nullable LibraryAnalyzer analyzer) { + @Nullable JavaRuntime java, @Nullable GameComponentAnalyzer analyzer) { GameJavaVersion gameJavaVersion; if (version == null || (gameJavaVersion = version.javaVersion()) == null) { return true; @@ -271,7 +269,7 @@ protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nul } @SuppressWarnings("BooleanMethodIsAlwaysInverted") - public boolean checkJava(GameVersionNumber gameVersionNumber, GameInstanceManifest version, JavaRuntime java, LibraryAnalyzer analyzer) { + public boolean checkJava(GameVersionNumber gameVersionNumber, GameInstanceManifest version, JavaRuntime java, GameComponentAnalyzer analyzer) { return getJavaVersionRange(version, analyzer).contains(java.getVersionNumber()); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java new file mode 100644 index 00000000000..ae1ef8ad900 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java @@ -0,0 +1,296 @@ +/* + * 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.game; + +import org.jackhuang.hmcl.util.SimpleMultimap; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.versioning.VersionNumber; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Optional; + +/// Normalizes a structurally resolved manifest into the stable view consumed by launch-time code. +/// +/// Normalization depends only on manifest content. Filesystem-dependent compatibility adjustments +/// are performed separately immediately before launch. +@NotNullByDefault +public final class LaunchManifestNormalizer { + /// Prevents construction of this utility class. + private LaunchManifestNormalizer() { + } + + /// Normalizes a resolved launch manifest. + /// + /// The input must not contain inheritance or pending patches. The returned manifest has duplicate + /// libraries removed and loader-specific arguments and libraries repaired. The input is unchanged. + /// + /// @param manifest the structurally resolved launch manifest + /// @return the normalized launch manifest + /// @throws IllegalArgumentException if the manifest still contains inheritance or pending patches + public static GameInstanceManifest normalize(GameInstanceManifest manifest) { + if (manifest.inheritsFrom() != null || !manifest.getPatches().isEmpty()) { + throw new IllegalArgumentException("Launch manifest must be structurally resolved"); + } + + GameInstanceManifest normalized = uniqueLibraries(manifest); + @Nullable String mainClass = normalized.mainClass(); + + if (GameComponentAnalyzer.LAUNCH_WRAPPER_MAIN.equals(mainClass)) { + normalized = normalizeLaunchWrapper(normalized, true); + if (GameComponentAnalyzer.MOD_LAUNCHER_MAIN.equals(normalized.mainClass())) { + normalized = normalizeModLauncher(normalized); + } + } else if (GameComponentAnalyzer.MOD_LAUNCHER_MAIN.equals(mainClass)) { + normalized = normalizeModLauncher(normalized); + } else if (GameComponentAnalyzer.BOOTSTRAP_LAUNCHER_MAIN.equals(mainClass)) { + normalized = normalizeBootstrapLauncher(normalized); + } + + return removeLegacyLog4jPatch(normalized); + } + + /// Repairs LaunchWrapper tweak-class configuration. + /// + /// @param manifest the resolved manifest + /// @param reorderTweakClass whether retained tweak classes are moved to their required positions + /// @return the repaired manifest + private static GameInstanceManifest normalizeLaunchWrapper( + GameInstanceManifest manifest, + boolean reorderTweakClass) { + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(manifest, null); + GameInstanceLibraryBuilder builder = new GameInstanceLibraryBuilder(manifest); + @Nullable String mainClass = null; + + // Forge installers may replace the complete argument list, so compatible tweakers must be + // restored in deterministic order. + if (analyzer.has(GameComponentType.LITELOADER) && !analyzer.hasModLauncher()) { + builder.replaceTweakClass( + GameComponentAnalyzer.LITELOADER_TWEAKER, + GameComponentAnalyzer.LITELOADER_TWEAKER, + !reorderTweakClass, + reorderTweakClass); + } else { + builder.removeTweakClass(GameComponentAnalyzer.LITELOADER_TWEAKER); + } + + if (analyzer.has(GameComponentType.OPTIFINE)) { + if (!analyzer.has(GameComponentType.LITELOADER) && !analyzer.has(GameComponentType.FORGE)) { + if (builder.hasTweakClass(GameComponentAnalyzer.OPTIFINE_TWEAKERS.get(1))) { + builder.replaceTweakClass( + GameComponentAnalyzer.OPTIFINE_TWEAKERS.get(1), + GameComponentAnalyzer.OPTIFINE_TWEAKERS.get(0), + !reorderTweakClass, + reorderTweakClass); + } + } else if (analyzer.hasModLauncher()) { + mainClass = GameComponentAnalyzer.MOD_LAUNCHER_MAIN; + for (String optiFineTweaker : GameComponentAnalyzer.OPTIFINE_TWEAKERS) { + builder.removeTweakClass(optiFineTweaker); + } + } else if (builder.hasTweakClass(GameComponentAnalyzer.OPTIFINE_TWEAKERS.get(0))) { + builder.replaceTweakClass( + GameComponentAnalyzer.OPTIFINE_TWEAKERS.get(0), + GameComponentAnalyzer.OPTIFINE_TWEAKERS.get(1), + !reorderTweakClass, + reorderTweakClass); + } + } else { + for (String optiFineTweaker : GameComponentAnalyzer.OPTIFINE_TWEAKERS) { + builder.removeTweakClass(optiFineTweaker); + } + } + + boolean hasForge = analyzer.has(GameComponentType.FORGE); + boolean hasModLauncher = analyzer.hasModLauncher(); + for (String forgeTweaker : GameComponentAnalyzer.FORGE_TWEAKERS) { + if (!hasForge) { + builder.removeTweakClass(forgeTweaker); + } else if (!hasModLauncher && builder.hasTweakClass(forgeTweaker)) { + builder.replaceTweakClass( + forgeTweaker, + forgeTweaker, + !reorderTweakClass, + reorderTweakClass); + } + } + + GameInstanceManifest normalized = builder.build(); + return mainClass == null ? normalized : normalized.withMainClass(mainClass); + } + + /// Adds the transformer discovery service required by Forge and OptiFine on ModLauncher. + /// + /// @param manifest the resolved manifest + /// @return the repaired manifest + private static GameInstanceManifest normalizeModLauncher(GameInstanceManifest manifest) { + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(manifest, null); + if (!analyzer.has(GameComponentType.FORGE) || !analyzer.has(GameComponentType.OPTIFINE)) { + return manifest; + } + + GameInstanceLibraryBuilder builder = new GameInstanceLibraryBuilder(manifest); + Library transformerDiscoveryService = new Library( + new Artifact("org.jackhuang.hmcl", "transformer-discovery-service", "1.0")); + boolean servicePresent = manifest.getLibraries().stream() + .anyMatch(library -> library.is("org.jackhuang.hmcl", "transformer-discovery-service")); + + manifest.getLibraries().stream() + .filter(library -> library.is("optifine", "OptiFine")) + .findAny() + .ifPresent(optiFine -> { + String candidateArgument = + "-Dhmcl.transformer.candidates=${library_directory}/" + optiFine.getPath(); + List jvmArguments = builder.getMutableJvmArguments(); + if (jvmArguments.stream().noneMatch(argument -> candidateArgument.equals(argument.toString()))) { + jvmArguments.add(new StringArgument(candidateArgument)); + } + if (!servicePresent) { + builder.addLibrary(transformerDiscoveryService); + } + }); + + return builder.build(); + } + + /// Repairs the filesystem-independent BootstrapLauncher ignore-list form. + /// + /// BootstrapLauncher 0.1.17 and newer compare ignore-list entries only with file names, so the + /// primary jar placeholder can be added without inspecting the installed classpath. + /// + /// @param manifest the resolved manifest + /// @return the repaired manifest + private static GameInstanceManifest normalizeBootstrapLauncher(GameInstanceManifest manifest) { + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(manifest, null); + if (!analyzer.has(GameComponentType.FORGE) && !analyzer.has(GameComponentType.NEO_FORGE)) { + return manifest; + } + + if (Optional.ofNullable(analyzer.getVersion(GameComponentType.BOOTSTRAP_LAUNCHER)) + .filter(version -> VersionNumber.compare(version, "0.1.17") >= 0) + .isEmpty()) { + return manifest; + } + + GameInstanceLibraryBuilder builder = new GameInstanceLibraryBuilder(manifest); + List jvmArguments = builder.getMutableJvmArguments(); + for (int i = 0; i < jvmArguments.size(); i++) { + Argument argument = jvmArguments.get(i); + if (argument instanceof StringArgument) { + String value = argument.toString(); + if (value.startsWith("-DignoreList=") + && !containsCommaSeparatedValue( + value.substring("-DignoreList=".length()), "${primary_jar_name}")) { + jvmArguments.set(i, new StringArgument(value + ",${primary_jar_name}")); + } + } + } + return builder.build(); + } + + /// Returns whether a comma-separated list contains the exact requested value. + /// + /// @param values the comma-separated values + /// @param target the value to find + /// @return whether `target` is present + private static boolean containsCommaSeparatedValue(String values, String target) { + for (String value : values.split(",")) { + if (target.equals(value)) { + return true; + } + } + return false; + } + + /// Removes the obsolete HMCL Log4j patch formerly prepended to affected manifests. + /// + /// @param manifest the normalized manifest + /// @return the manifest without the obsolete first library, when present + private static GameInstanceManifest removeLegacyLog4jPatch(GameInstanceManifest manifest) { + List libraries = manifest.getLibraries(); + if (libraries.isEmpty()) { + return manifest; + } + + Library library = libraries.get(0); + if ("org.glavo".equals(library.groupId()) + && ("log4j-patch".equals(library.artifactId()) + || "log4j-patch-beta9".equals(library.artifactId())) + && "1.0".equals(library.version())) { + return manifest.withLibraries(libraries.subList(1, libraries.size())); + } + return manifest; + } + + /// Removes redundant library declarations while retaining rule-distinct variants. + /// + /// For equal compatibility rules, the newer version wins. Identical coordinates retain the + /// declaration with the richer serialized metadata. + /// + /// @param manifest the resolved manifest + /// @return the manifest with redundant libraries removed + private static GameInstanceManifest uniqueLibraries(GameInstanceManifest manifest) { + List libraries = new ArrayList<>(); + SimpleMultimap> indexes = + new SimpleMultimap<>(HashMap::new, ArrayList::new); + + for (Library library : manifest.getLibraries()) { + String id = library.groupId() + ":" + library.artifactId(); + VersionNumber version = VersionNumber.asVersion(library.version()); + String serialized = JsonUtils.GSON.toJson(library); + + if (!indexes.containsKey(id)) { + indexes.put(id, libraries.size()); + libraries.add(library); + continue; + } + + boolean duplicate = false; + for (int otherIndex : indexes.get(id)) { + Library other = libraries.get(otherIndex); + if (!CompatibilityRule.equals(library.rules(), other.rules())) { + continue; + } + + int comparison = version.compareTo(VersionNumber.asVersion(other.version())); + if (comparison > 0) { + libraries.set(otherIndex, library); + } else if (comparison == 0 && library.equals(other)) { + String otherSerialized = JsonUtils.GSON.toJson(other); + if (serialized.length() > otherSerialized.length()) { + libraries.set(otherIndex, library); + } + } else if (comparison == 0) { + continue; + } + duplicate = true; + break; + } + + if (!duplicate) { + indexes.put(id, libraries.size()); + libraries.add(library); + } + } + + return manifest.withLibraries(libraries); + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java index 4ef8e3af8bd..4884db15c52 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java @@ -19,7 +19,6 @@ import org.glavo.uuid.UUIDs; import org.jackhuang.hmcl.auth.AuthInfo; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.ServerAddress; @@ -50,20 +49,14 @@ */ public class DefaultLauncher extends Launcher { - private final LibraryAnalyzer analyzer; + private final GameComponentAnalyzer analyzer; - public DefaultLauncher(GameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options) { - this(repository, manifest, authInfo, options, null); - } - - public DefaultLauncher(GameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener) { - this(repository, manifest, authInfo, options, listener, true); - } - - public DefaultLauncher(GameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) { - super(repository, manifest, authInfo, options, listener, daemon); + public DefaultLauncher(GameInstance instance, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) { + super(instance, manifest, authInfo, options, listener, daemon); - this.analyzer = LibraryAnalyzer.analyze(manifest, repository.getGameVersion(manifest).orElse(null)); + GameVersionNumber version = instance.getVersion(); + this.analyzer = GameComponentAnalyzer.analyze(manifest, + version == GameVersionNumber.unknown() ? null : version.toString()); } private Command generateCommandLine(Path nativeFolder) throws IOException { @@ -158,11 +151,11 @@ private Command generateCommandLine(Path nativeFolder) throws IOException { if (!options.isNoGeneratedJVMArgs()) { appendJvmArgs(res); - res.addDefault("-Dminecraft.client.jar=", FileUtils.getAbsolutePath(repository.getInstanceJar(manifest))); + res.addDefault("-Dminecraft.client.jar=", FileUtils.getAbsolutePath(instance.getRepository().getInstanceJar(manifest))); if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) { res.addDefault("-Xdock:name=", "Minecraft " + manifest.id()); - repository.getAssetObject(manifest.id(), manifest.getAssetIndex().getId(), "icons/minecraft.icns") + instance.getAssetObject(manifest.getAssetIndex().getId(), "icons/minecraft.icns") .ifPresent(minecraftIcns -> { res.addDefault("-Xdock:icon=", FileUtils.getAbsolutePath(minecraftIcns)); }); @@ -281,25 +274,25 @@ private Command generateCommandLine(Path nativeFolder) throws IOException { } } - Set classpath = repository.getClasspath(manifest); + Set classpath = LaunchClasspathResolver.resolve(instance.getRepository(), manifest); - if (analyzer.has(LibraryAnalyzer.LibraryType.CLEANROOM)) { + if (analyzer.has(GameComponentType.CLEANROOM)) { classpath.removeIf(c -> c.contains("2.9.4-nightly-20150209")); } - Path jar = repository.getInstanceJar(manifest); + Path jar = instance.getRepository().getInstanceJar(manifest); if (!Files.isRegularFile(jar)) throw new IOException("Minecraft jar does not exist"); classpath.add(FileUtils.getAbsolutePath(jar.toAbsolutePath())); // Provided Minecraft arguments - Path gameAssets = repository.getActualAssetDirectory(manifest.id(), manifest.getAssetIndex().getId()); + Path gameAssets = instance.getActualAssetDirectory(manifest.getAssetIndex().getId()); Map configuration = getConfigurations(); configuration.put("${classpath}", String.join(File.pathSeparator, classpath)); configuration.put("${game_assets}", FileUtils.getAbsolutePath(gameAssets)); configuration.put("${assets_root}", FileUtils.getAbsolutePath(gameAssets)); - Optional gameVersion = repository.getGameVersion(manifest); + Optional gameVersion = findGameVersion(); // lwjgl assumes path to native libraries encoded by ASCII. // Here is a workaround for this issue: https://github.com/HMCL-dev/HMCL/issues/1141. @@ -463,7 +456,7 @@ public void decompressNatives(Path destination) throws NotDecompressingNativesEx FileUtils.cleanDirectoryQuietly(destination); for (Library library : manifest.getLibraries()) if (library.isNative()) - new Unzipper(repository.getLibraryFile(manifest, library), destination) + new Unzipper(instance.getLayout().getLibraryFile(instance.getId(), library), destination) .setFilter((zipEntry, destFile, relativePath) -> { if (!zipEntry.isDirectory() && !zipEntry.isUnixSymlink() && Files.isRegularFile(destFile) @@ -489,12 +482,23 @@ public void decompressNatives(Path destination) throws NotDecompressingNativesEx } } + /// Returns the detected Minecraft version string for this instance, if known. + /// + /// @return the version string, or empty when detection failed + private Optional findGameVersion() { + GameVersionNumber version = instance.getVersion(); + if (version == GameVersionNumber.unknown()) { + return Optional.empty(); + } + return Optional.of(version.toString()); + } + private boolean isUsingLog4j() { - return GameVersionNumber.compare(repository.getGameVersion(manifest).orElse("1.7"), "1.7") >= 0; + return GameVersionNumber.compare(findGameVersion().orElse("1.7"), "1.7") >= 0; } public Path getLog4jConfigurationFile() { - return repository.getInstanceRoot(manifest.id()).resolve("log4j2.xml"); + return instance.getInstanceRoot().resolve("log4j2.xml"); } public void extractLog4jConfigurationFile() throws IOException { @@ -502,7 +506,7 @@ public void extractLog4jConfigurationFile() throws IOException { String sourcePath; - if (GameVersionNumber.asGameVersion(repository.getGameVersion(manifest)).compareTo("1.12") < 0) { + if (GameVersionNumber.asGameVersion(findGameVersion()).compareTo("1.12") < 0) { if (options.isEnableDebugLogOutput()) { sourcePath = "/assets/game/log4j2-1.7-debug.xml"; } else { @@ -531,32 +535,32 @@ protected Map getConfigurations() { pair("${version_name}", Optional.ofNullable(options.getVersionName()).orElse(manifest.id().toString())), pair("${profile_name}", Optional.ofNullable(options.getProfileName()).orElse("Minecraft")), pair("${version_type}", Optional.ofNullable(options.getVersionType()).orElse(manifest.type() != null ? manifest.type().getId() : ReleaseType.UNKNOWN.getId())), - pair("${game_directory}", FileUtils.getAbsolutePath(repository.getRunDirectory(manifest.id()))), + pair("${game_directory}", FileUtils.getAbsolutePath(instance.getRunDirectory())), pair("${user_type}", authInfo.getUserType()), pair("${assets_index_name}", manifest.getAssetIndex().getId()), pair("${user_properties}", authInfo.getUserProperties()), pair("${resolution_width}", options.getWidth().toString()), pair("${resolution_height}", options.getHeight().toString()), - pair("${library_directory}", FileUtils.getAbsolutePath(repository.getLibrariesDirectory(manifest))), + pair("${library_directory}", FileUtils.getAbsolutePath(instance.getLayout().getLibrariesDirectory())), pair("${classpath_separator}", File.pathSeparator), - pair("${primary_jar}", FileUtils.getAbsolutePath(repository.getInstanceJar(manifest))), + pair("${primary_jar}", FileUtils.getAbsolutePath(instance.getRepository().getInstanceJar(manifest))), pair("${language}", Locale.getDefault().toLanguageTag()), // defined by HMCL // libraries_directory stands for historical reasons here. We don't know the official launcher // had already defined "library_directory" as the placeholder for path to ".minecraft/libraries" // when we propose this placeholder. - pair("${libraries_directory}", FileUtils.getAbsolutePath(repository.getLibrariesDirectory(manifest))), + pair("${libraries_directory}", FileUtils.getAbsolutePath(instance.getLayout().getLibrariesDirectory())), // file_separator is used in -DignoreList pair("${file_separator}", File.separator), - pair("${primary_jar_name}", FileUtils.getName(repository.getInstanceJar(manifest))) + pair("${primary_jar_name}", FileUtils.getName(instance.getRepository().getInstanceJar(manifest))) ); } /// Returns the native library directory selected by the launch options. private Path getNativeFolder() { if (StringUtils.isBlank(options.getNativesDir())) { - return repository.getNativeDirectory(manifest.id(), options.getJava().getPlatform()); + return instance.getNativeDirectory(options.getJava().getPlatform()); } return Path.of(options.getNativesDir()); @@ -587,7 +591,7 @@ public ManagedProcess launch() throws IOException, InterruptedException { if (isUsingLog4j()) extractLog4jConfigurationFile(); - Path runDirectory = repository.getRunDirectory(manifest.id()); + Path runDirectory = instance.getRunDirectory(); if (StringUtils.isNotBlank(options.getPreLaunchCommand())) { ProcessBuilder builder = new ProcessBuilder(StringUtils.tokenize(options.getPreLaunchCommand(), getEnvVars(nativeFolder))).directory(runDirectory.toFile()); @@ -622,8 +626,8 @@ private Map getEnvVars(Path nativeFolder) { Map env = new LinkedHashMap<>(); env.put("INST_NAME", versionName); env.put("INST_ID", versionName); - env.put("INST_DIR", FileUtils.getAbsolutePath(repository.getInstanceRoot(manifest.id()))); - env.put("INST_MC_DIR", FileUtils.getAbsolutePath(repository.getRunDirectory(manifest.id()))); + env.put("INST_DIR", FileUtils.getAbsolutePath(instance.getInstanceRoot())); + env.put("INST_MC_DIR", FileUtils.getAbsolutePath(instance.getRunDirectory())); env.put("INST_JAVA", options.getJava().getBinary().toString()); if (options.getRenderer() instanceof Renderer.Driver driver) { @@ -681,28 +685,28 @@ else if (driver instanceof Renderer.Vulkan vulkanDriver) { } } - if (analyzer.has(LibraryAnalyzer.LibraryType.FORGE)) { + if (analyzer.has(GameComponentType.FORGE)) { env.put("INST_FORGE", "1"); } - if (analyzer.has(LibraryAnalyzer.LibraryType.CLEANROOM)) { + if (analyzer.has(GameComponentType.CLEANROOM)) { env.put("INST_CLEANROOM", "1"); } - if (analyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE)) { + if (analyzer.has(GameComponentType.NEO_FORGE)) { env.put("INST_NEOFORGE", "1"); } - if (analyzer.has(LibraryAnalyzer.LibraryType.LITELOADER)) { + if (analyzer.has(GameComponentType.LITELOADER)) { env.put("INST_LITELOADER", "1"); } - if (analyzer.has(LibraryAnalyzer.LibraryType.FABRIC)) { + if (analyzer.has(GameComponentType.FABRIC)) { env.put("INST_FABRIC", "1"); } - if (analyzer.has(LibraryAnalyzer.LibraryType.OPTIFINE)) { + if (analyzer.has(GameComponentType.OPTIFINE)) { env.put("INST_OPTIFINE", "1"); } - if (analyzer.has(LibraryAnalyzer.LibraryType.QUILT)) { + if (analyzer.has(GameComponentType.QUILT)) { env.put("INST_QUILT", "1"); } - if (analyzer.has(LibraryAnalyzer.LibraryType.LEGACY_FABRIC)) { + if (analyzer.has(GameComponentType.LEGACY_FABRIC)) { env.put("INST_LEGACYFABRIC", "1"); } @@ -782,7 +786,7 @@ else if (!isWindows && !(scriptExtension.equalsIgnoreCase("sh") || scriptExtensi writer.newLine(); } writer.write("Set-Location -LiteralPath "); - writer.write(CommandBuilder.pwshString(FileUtils.getAbsolutePath(repository.getRunDirectory(manifest.id())))); + writer.write(CommandBuilder.pwshString(FileUtils.getAbsolutePath(instance.getRunDirectory()))); writer.newLine(); @@ -826,7 +830,7 @@ else if (!isWindows && !(scriptExtension.equalsIgnoreCase("sh") || scriptExtensi writer.newLine(); } writer.newLine(); - writer.write(new CommandBuilder().addAll("cd", "/D", FileUtils.getAbsolutePath(repository.getRunDirectory(manifest.id()))).toString()); + writer.write(new CommandBuilder().addAll("cd", "/D", FileUtils.getAbsolutePath(instance.getRunDirectory())).toString()); } else { writer.write("#!/usr/bin/env bash"); writer.newLine(); @@ -838,7 +842,7 @@ else if (!isWindows && !(scriptExtension.equalsIgnoreCase("sh") || scriptExtensi writer.write(new CommandBuilder().addAll("ln", "-s", FileUtils.getAbsolutePath(nativeFolder), commandLine.tempNativeFolder.toString()).toString()); writer.newLine(); } - writer.write(new CommandBuilder().addAll("cd", FileUtils.getAbsolutePath(repository.getRunDirectory(manifest.id()))).toString()); + writer.write(new CommandBuilder().addAll("cd", FileUtils.getAbsolutePath(instance.getRunDirectory())).toString()); } writer.newLine(); if (StringUtils.isNotBlank(options.getPreLaunchCommand())) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/LaunchClasspathResolver.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/LaunchClasspathResolver.java new file mode 100644 index 00000000000..5daed09cb0d --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/LaunchClasspathResolver.java @@ -0,0 +1,83 @@ +/* + * 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.launch; + +import org.jackhuang.hmcl.game.*; +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.Set; + +import static org.jackhuang.hmcl.game.GameComponentType.*; + +/// Resolves the library classpath used for one launch attempt. +@NotNullByDefault +public final class LaunchClasspathResolver { + /// Prevents construction of this utility class. + private LaunchClasspathResolver() { + } + + /// Returns a mutable classpath containing installed libraries selected for this launch. + /// + /// For Forge or LiteLoader installations containing OptiFine, an installed OptiFine installer + /// artifact replaces the ordinary artifact. With ModLauncher, the installer is omitted from the + /// ordinary classpath because transformer discovery loads it separately. The incompatible + /// `launchwrapper-of` artifact is also omitted. + /// + /// @param repository the repository that owns the installed libraries + /// @param manifest the effective launch manifest + /// @return a mutable insertion-ordered set of absolute classpath entries + public static Set resolve( + GameRepository repository, + GameInstanceManifest manifest) { + Set classpath = new LinkedHashSet<>(repository.getClasspath(manifest)); + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(manifest, null); + if (!analyzer.has(OPTIFINE) || (!analyzer.has(LITELOADER) && !analyzer.has(FORGE))) { + return classpath; + } + + boolean removeFromClasspath = GameComponentAnalyzer.MOD_LAUNCHER_MAIN.equals(manifest.mainClass()); + @Nullable Path selectedInstallerFile = null; + + for (Library library : manifest.getLibraries()) { + Path libraryFile = repository.getLayout().getLibraryFile(manifest.id(), library); + if (library.is("optifine", "OptiFine")) { + Library installer = new Library( + new Artifact("optifine", "OptiFine", library.version(), "installer")); + Path installerFile = repository.getLayout().getLibraryFile(manifest.id(), installer); + if (Files.exists(installerFile)) { + classpath.remove(FileUtils.getAbsolutePath(libraryFile)); + selectedInstallerFile = installerFile; + } + } else if (library.is("optifine", "launchwrapper-of")) { + classpath.remove(FileUtils.getAbsolutePath(libraryFile)); + } + } + + if (!removeFromClasspath + && selectedInstallerFile != null + && Files.isRegularFile(selectedInstallerFile)) { + classpath.add(FileUtils.getAbsolutePath(selectedInstallerFile)); + } + return classpath; + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/Launcher.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/Launcher.java index c20c05a8996..0e5beda3425 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/Launcher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/Launcher.java @@ -18,37 +18,50 @@ package org.jackhuang.hmcl.launch; import org.jackhuang.hmcl.auth.AuthInfo; +import org.jackhuang.hmcl.game.GameInstance; import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.GameRepository; import org.jackhuang.hmcl.game.LaunchOptions; import org.jackhuang.hmcl.util.platform.ManagedProcess; import java.io.IOException; import java.nio.file.Path; -/** - * - * @author huangyuhui - */ +/// Builds a process or script that launches a game instance. +/// +/// The [GameInstance] identifies the instance being launched (paths, repository layout, version +/// cache). [#manifest] is the effective launch-time manifest after maintenance and native +/// patching; it must not be assumed equal to [GameInstance#getManifest()] or +/// [GameInstance#getLaunchManifest()]. public abstract class Launcher { - protected final GameRepository repository; + /// The instance being launched. + protected final GameInstance instance; + + /// The effective launch manifest for this launch attempt. protected final GameInstanceManifest manifest; + + /// Authentication information passed to the game process. protected final AuthInfo authInfo; + + /// JVM, game, and process launch options. protected final LaunchOptions options; - protected final ProcessListener listener; - protected final boolean daemon; - public Launcher(GameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options) { - this(repository, manifest, authInfo, options, null); - } + /// Optional process output listener, or `null` when output is inherited. + protected final ProcessListener listener; - public Launcher(GameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener) { - this(repository, manifest, authInfo, options, listener, true); - } + /// Whether process monitors should run as daemon threads. + protected final boolean daemon; - public Launcher(GameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) { - this.repository = repository; + /// Creates a launcher for the given instance and launch plan. + /// + /// @param instance the instance being launched + /// @param manifest the effective launch-time manifest (may differ from the instance storage) + /// @param authInfo authentication information for the game process + /// @param options launch options + /// @param listener process listener, or `null` to inherit IO + /// @param daemon whether monitors should be daemon threads + public Launcher(GameInstance instance, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) { + this.instance = instance; this.manifest = manifest; this.authInfo = authInfo; this.options = options; @@ -56,11 +69,24 @@ public Launcher(GameRepository repository, GameInstanceManifest manifest, AuthIn this.daemon = daemon; } - /** - * @param file the file path. - */ + /// Returns the instance being launched. + /// + /// @return the bound [GameInstance] + public GameInstance getInstance() { + return instance; + } + + /// Writes a launch script to the given path. + /// + /// @param file the script path + /// @throws IOException if the script cannot be written public abstract void makeLaunchScript(Path file) throws IOException; + /// Starts the game process. + /// + /// @return the managed process + /// @throws IOException if the process cannot be created or launch preparation fails + /// @throws InterruptedException if interrupted while preparing or starting the process public abstract ManagedProcess launch() throws IOException, InterruptedException; } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModpackProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModpackProvider.java index 014447f9369..c4c9c4bb02e 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModpackProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModpackProvider.java @@ -20,32 +20,56 @@ import com.google.gson.JsonParseException; import kala.compress.archivers.zip.ZipArchiveReader; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.game.LaunchOptions; import org.jackhuang.hmcl.task.Task; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.charset.Charset; import java.nio.file.Path; +/// Provides format-specific operations for reading, installing, updating, and completing modpacks. +@NotNullByDefault public interface ModpackProvider { + /// Returns the persistent provider name stored in modpack configurations. + /// + /// @return the provider name String getName(); - Task createCompletionTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId); + /// Creates a task that completes missing or outdated files for a registered instance. + /// + /// @param dependencyManager the dependency manager for `instance`'s repository + /// @param instance the registered instance to complete + /// @return the completion task, or `null` when this format requires no completion + @Nullable Task createCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance); - Task createUpdateTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException; + /// Creates a task that updates a registered instance from a local modpack archive. + /// + /// @param dependencyManager the dependency manager for `instance`'s repository + /// @param instance the registered instance to update + /// @param zipFile the modpack archive + /// @param modpack the parsed modpack + /// @return the update task + /// @throws MismatchedModpackTypeException if the parsed manifest belongs to another provider + Task createUpdateTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException; - /** - * @param zipFile the opened modpack zip file. - * @param file the modpack zip file path. - * @param encoding encoding of zip file. - * @throws IOException if the file is not a valid zip file. - * @throws JsonParseException if the manifest.json is missing or malformed. - * @return the manifest. - */ + /// Reads this provider's manifest from an opened modpack archive. + /// + /// @param zipFile the opened modpack archive + /// @param file the modpack archive path + /// @param encoding the archive entry-name encoding + /// @return the parsed modpack + /// @throws IOException if the archive cannot be read as this format + /// @throws JsonParseException if the required manifest is missing or malformed Modpack readManifest(ZipArchiveReader zipFile, Path file, Charset encoding) throws IOException, JsonParseException; + /// Injects provider-specific launch options from a serialized modpack configuration. + /// + /// @param modpackConfigurationJson the serialized configuration + /// @param builder the launch options builder to update default void injectLaunchOptions(String modpackConfigurationJson, LaunchOptions.Builder builder) { } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModpackUpdateTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModpackUpdateTask.java index f06f08a7267..7d0c9c09f6b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModpackUpdateTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModpackUpdateTask.java @@ -17,64 +17,85 @@ */ package org.jackhuang.hmcl.modpack; -import org.jackhuang.hmcl.game.DefaultGameRepository; -import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.io.FileUtils; +import org.jetbrains.annotations.NotNullByDefault; +import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Collection; import java.util.Collections; +/// Runs a modpack update with an instance-directory backup and rollback on failure. +@NotNullByDefault public class ModpackUpdateTask extends Task { - private final DefaultGameRepository repository; - private final GameInstanceID id; + /// The fixed pre-update instance snapshot. + private final DefaultGameInstance instance; + + /// The task that applies the modpack update after the backup is created. private final Task updateTask; + + /// A randomly named backup directory that was unused when this task was created. private final Path backupFolder; - public ModpackUpdateTask(DefaultGameRepository repository, GameInstanceID instanceId, Task updateTask) { - this.repository = repository; - this.id = instanceId; + /// Creates an update task that backs up and restores a registered instance as one operation. + /// + /// @param instance the registered instance to update + /// @param updateTask the task that performs the update + public ModpackUpdateTask(DefaultGameInstance instance, Task updateTask) { + this.instance = instance; this.updateTask = updateTask; - Path backup = repository.getBaseDirectory().resolve("backup"); + Path backup = instance.getLayout().getBaseDirectory().resolve("backup"); while (true) { - int num = (int)(Math.random() * 10000000); - if (!Files.exists(backup.resolve(instanceId + "-" + num))) { - backupFolder = backup.resolve(instanceId + "-" + num); + int num = (int) (Math.random() * 10000000); + Path candidate = backup.resolve(instance.getId() + "-" + num); + if (!Files.exists(candidate)) { + backupFolder = candidate; break; } } } + /// Returns the update task that runs after this task creates the backup. + /// + /// @return a singleton containing the update task @Override public Collection> getDependencies() { return Collections.singleton(updateTask); } + /// Copies the instance directory into the backup directory. @Override public void execute() throws Exception { - FileUtils.copyDirectory(repository.getInstanceRoot(id), backupFolder); + FileUtils.copyDirectory(instance.getInstanceRoot(), backupFolder); } + /// Requests post-execution cleanup or rollback after the update task terminates. + /// + /// @return `true` @Override public boolean doPostExecute() { return true; } + /// Retains the backup after success, or restores it and refreshes the repository after failure. @Override public void postExecute() throws Exception { if (isDependenciesSucceeded()) { // Keep backup game version for further repair. - } else { - // Restore backup - repository.removeInstanceFromDisk(id); - - FileUtils.copyDirectory(backupFolder, repository.getInstanceRoot(id)); + return; + } - repository.refreshAsync().start(); + // Restore backup + if (!instance.getRepository().removeInstanceFromDisk(instance.getId())) { + throw new IOException("Failed to remove instance before restoring backup: " + instance.getId()); } + + FileUtils.copyDirectory(backupFolder, instance.getInstanceRoot()); + instance.getRepository().refresh(); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseCompletionTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseCompletionTask.java index d48570f82a4..aadc19a1532 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseCompletionTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseCompletionTask.java @@ -21,15 +21,16 @@ import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository; import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.game.DefaultGameRepository; +import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.addon.mod.ModManager; -import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.modpack.ModpackCompletionException; import org.jackhuang.hmcl.addon.RemoteAddon; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; import java.io.FileNotFoundException; import java.io.IOException; @@ -44,51 +45,60 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG; -/** - * Complete the CurseForge version. - * - * @author huangyuhui - */ +/// Completes missing files for an installed CurseForge modpack. +@NotNullByDefault public final class CurseCompletionTask extends Task { + /// The dependency manager used to resolve and download remote files. private final DefaultDependencyManager dependency; - private final DefaultGameRepository repository; + + /// The fixed registered instance completed by this task. + private final DefaultGameInstance instance; + + /// The mod manager associated with [#instance]. private final ModManager modManager; - private final GameInstanceID instanceId; - private CurseManifest manifest; - private List> dependencies; + /// The manifest supplied by the caller or loaded from disk, if available. + private @Nullable CurseManifest manifest; + + /// Download tasks produced during [#execute()]. + private List> dependencies = List.of(); + + /// Whether every manifest file name could be resolved. private final AtomicBoolean allNameKnown = new AtomicBoolean(true); + + /// The number of manifest entries processed in the current phase. private final AtomicInteger finished = new AtomicInteger(0); + + /// Whether a manifest entry refers to a deleted remote file. private final AtomicBoolean notFound = new AtomicBoolean(false); - /** - * Constructor. - * - * @param dependencyManager the dependency manager. - * @param instanceId the existent and physical version. - */ - public CurseCompletionTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId) { - this(dependencyManager, instanceId, null); + /// Creates a task that completes the installed CurseForge modpack. + /// + /// @param dependencyManager the dependency manager + /// @param instance the registered instance to complete + public CurseCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) { + this(dependencyManager, instance, null); } - /** - * Constructor. - * - * @param dependencyManager the dependency manager. - * @param instanceId the existent and physical version. - * @param manifest the CurseForgeModpack manifest. - */ - public CurseCompletionTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId, CurseManifest manifest) { + /// Creates a task that completes the installed CurseForge modpack using an optional manifest. + /// + /// @param dependencyManager the dependency manager + /// @param instance the registered instance to complete + /// @param manifest the CurseForge manifest, or `null` to read it from disk + public CurseCompletionTask( + DefaultDependencyManager dependencyManager, + DefaultGameInstance instance, + @Nullable CurseManifest manifest) { + dependencyManager.validateGameInstance(instance); this.dependency = dependencyManager; - this.repository = dependencyManager.getGameRepository(); - this.modManager = repository.getModManager(instanceId); - this.instanceId = instanceId; + this.instance = instance; + this.modManager = instance.getModManager(); this.manifest = manifest; if (manifest == null) try { - Path manifestFile = repository.getInstanceRoot(instanceId).resolve("manifest.json"); + Path manifestFile = instance.getInstanceRoot().resolve("manifest.json"); if (Files.exists(manifestFile)) this.manifest = JsonUtils.fromJsonFile(manifestFile, CurseManifest.class); } catch (Exception e) { @@ -113,7 +123,7 @@ public void execute() throws Exception { if (manifest == null) return; - Path root = repository.getInstanceRoot(instanceId); + Path root = instance.getInstanceRoot(); // Because in China, Curse is too difficult to visit, // if failed, ignore it and retry next time. @@ -141,7 +151,7 @@ public void execute() throws Exception { .collect(Collectors.toList())); JsonUtils.writeToJsonFile(root.resolve("manifest.json"), newManifest); - Path versionRoot = repository.getInstanceRoot(modManager.getInstanceId()); + Path versionRoot = instance.getInstanceRoot(); Path resourcePacksRoot = versionRoot.resolve("resourcepacks"); Path shaderPacksRoot = versionRoot.resolve("shaderpacks"); finished.set(0); @@ -174,17 +184,15 @@ public void execute() throws Exception { } } - /** - * Guess where to store the file. - * - * @param file The file. - * @param downloadProvider - * @param resourcePacksRoot ./resourcepacks. - * @param shaderPacksRoot ./shaderpacks. - * @return ./resourcepacks/$filename or ./shaderpacks/$filename or ./mods/$filename if the file doesn't exist. null if the file existed. - * @throws IOException If IOException was encountered during getting data from CurseForge. - */ - private Path guessFilePath(CurseManifestFile file, DownloadProvider downloadProvider, Path resourcePacksRoot, Path shaderPacksRoot) throws IOException { + /// Returns the destination for a missing CurseForge file based on its project class. + /// + /// @param file the manifest file + /// @param downloadProvider the download provider used for CurseForge requests + /// @param resourcePacksRoot the resource-pack directory + /// @param shaderPacksRoot the shader-pack directory + /// @return the destination, or `null` when the file already exists + /// @throws IOException if CurseForge metadata cannot be read + private @Nullable Path guessFilePath(CurseManifestFile file, DownloadProvider downloadProvider, Path resourcePacksRoot, Path shaderPacksRoot) throws IOException { RemoteAddon mod = CurseForgeRemoteAddonRepository.MODS.getAddonById(downloadProvider, Integer.toString(file.projectID())); int classID = ((CurseForgeRemoteAddonRepository.CurseAddon) mod.data()).classId(); String fileName = file.fileName(); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseInstallTask.java index f19dd5ec932..4414dab2e04 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseInstallTask.java @@ -77,9 +77,9 @@ public CurseInstallTask(DefaultDependencyManager dependencyManager, Path zipFile this.iconUrl = iconUrl; this.repository = dependencyManager.getGameRepository(); - this.run = repository.getRunDirectory(instanceId); + this.run = repository.getLayout().getInstanceRoot(instanceId); - Path json = repository.getModpackConfiguration(instanceId); + Path json = repository.getLayout().getModpackConfigurationFile(instanceId); if (repository.hasInstance(instanceId) && Files.notExists(json)) throw new IllegalArgumentException("Instance " + instanceId + " already exists."); @@ -116,7 +116,7 @@ public CurseInstallTask(DefaultDependencyManager dependencyManager, Path zipFile } this.config = config; dependents.add(new ModpackInstallTask<>(zipFile, run, modpack.getEncoding(), Collections.singletonList(manifest.overrides()), any -> true, config).withStage("hmcl.modpack")); - dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), Collections.singletonList(manifest.overrides()), manifest, CurseModpackProvider.INSTANCE, manifest.name(), manifest.version(), repository.getModpackConfiguration(instanceId)).withStage("hmcl.modpack")); + dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), Collections.singletonList(manifest.overrides()), manifest, CurseModpackProvider.INSTANCE, manifest.name(), manifest.version(), repository.getLayout().getModpackConfigurationFile(instanceId)).withStage("hmcl.modpack")); URI iconUri = NetworkUtils.toURIOrNull(iconUrl); if (iconUri != null) { @@ -126,7 +126,6 @@ public CurseInstallTask(DefaultDependencyManager dependencyManager, Path zipFile dependents.add(downloadIconTask = new CacheFileTask(dependencyManager.getDownloadProvider().injectURLWithCandidates(iconUrl))); } } - dependencies.add(new CurseCompletionTask(dependencyManager, instanceId, manifest)); } @Override @@ -173,7 +172,7 @@ public void execute() throws Exception { // CurseForge manifest where fileName is missing. CurseCompletionTask // resolves those file names and writes the enriched manifest to // manifest.json, so read from there when available. - Path oldManifestFile = repository.getInstanceRoot(instanceId).resolve("manifest.json"); + Path oldManifestFile = repository.getLayout().getInstanceRoot(instanceId).resolve("manifest.json"); List oldFiles = config.getManifest().files(); if (Files.exists(oldManifestFile)) { try { @@ -197,7 +196,7 @@ public void execute() throws Exception { } } - Path root = repository.getInstanceRoot(instanceId); + Path root = repository.getLayout().getInstanceRoot(instanceId); Files.createDirectories(root); JsonUtils.writeToJsonFile(root.resolve("manifest.json"), manifest); @@ -208,5 +207,8 @@ public void execute() throws Exception { LOG.warning("Failed to copy modpack icon", e); } } + + // The game builder runs as a dependent and registers the instance before this phase. + dependencies.add(new CurseCompletionTask(dependencyManager, repository.getInstance(instanceId), manifest)); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseModpackProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseModpackProvider.java index f66aef4f19a..9cc2fc59aa3 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseModpackProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseModpackProvider.java @@ -21,6 +21,7 @@ import kala.compress.archivers.zip.ZipArchiveEntry; import kala.compress.archivers.zip.ZipArchiveReader; import org.jackhuang.hmcl.download.DefaultDependencyManager; +import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.modpack.MismatchedModpackTypeException; import org.jackhuang.hmcl.modpack.Modpack; @@ -44,16 +45,16 @@ public String getName() { } @Override - public Task createCompletionTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId) { - return new CurseCompletionTask(dependencyManager, instanceId); + public Task createCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) { + return new CurseCompletionTask(dependencyManager, instance); } @Override - public Task createUpdateTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException { + public Task createUpdateTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException { if (!(modpack.getManifest() instanceof CurseManifest curseManifest)) throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName()); - return new ModpackUpdateTask(dependencyManager.getGameRepository(), instanceId, new CurseInstallTask(dependencyManager, zipFile, modpack, curseManifest, instanceId, null)); + return new ModpackUpdateTask(instance, new CurseInstallTask(dependencyManager, zipFile, modpack, curseManifest, instance.getId(), null)); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackCompletionTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackCompletionTask.java index b97bf82bc83..6a415037415 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackCompletionTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackCompletionTask.java @@ -19,9 +19,8 @@ import com.google.gson.JsonParseException; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.game.DefaultGameRepository; +import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.addon.mod.ModManager; -import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.modpack.ModpackConfiguration; import org.jackhuang.hmcl.modpack.ModpackCompletionException; import org.jackhuang.hmcl.modpack.curse.CurseMetaMod; @@ -31,6 +30,7 @@ import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.NetworkUtils; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; import java.io.FileNotFoundException; @@ -49,31 +49,50 @@ import static org.jackhuang.hmcl.util.Lang.wrapConsumer; import static org.jackhuang.hmcl.util.logging.Logger.LOG; +/// Completes and updates files for an installed MCBBS modpack. +@NotNullByDefault public class McbbsModpackCompletionTask extends CompletableFutureTask { + /// The dependency manager used to resolve and download remote files. private final DefaultDependencyManager dependency; - private final DefaultGameRepository repository; + + /// The fixed registered instance completed by this task. + private final DefaultGameInstance instance; + + /// The mod manager associated with [#instance]. private final ModManager modManager; - private final GameInstanceID instanceId; + + /// The fixed configuration-file path for [#instance]. private final Path configurationFile; - private ModpackConfiguration configuration; - private McbbsModpackManifest manifest; - private final List> dependencies = new ArrayList<>(); - private final AtomicBoolean allNameKnown = new AtomicBoolean(true); - private final AtomicInteger finished = new AtomicInteger(0); - private final AtomicBoolean notFound = new AtomicBoolean(false); + /// The configuration supplied by the caller or loaded from disk. + private @Nullable ModpackConfiguration configuration; + + /// The local or downloaded manifest currently being processed. + private @Nullable McbbsModpackManifest manifest; - public McbbsModpackCompletionTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId) { - this(dependencyManager, instanceId, null); + /// Creates a task that loads the modpack configuration from disk. + /// + /// @param dependencyManager the dependency manager + /// @param instance the registered instance to complete + public McbbsModpackCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) { + this(dependencyManager, instance, null); } - public McbbsModpackCompletionTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId, ModpackConfiguration configuration) { + /// Creates a task using an optional preloaded modpack configuration. + /// + /// @param dependencyManager the dependency manager + /// @param instance the registered instance to complete + /// @param configuration the configuration, or `null` to read it from disk + public McbbsModpackCompletionTask( + DefaultDependencyManager dependencyManager, + DefaultGameInstance instance, + @Nullable ModpackConfiguration configuration) { + dependencyManager.validateGameInstance(instance); this.dependency = dependencyManager; - this.repository = dependencyManager.getGameRepository(); - this.modManager = repository.getModManager(instanceId); - this.instanceId = instanceId; - this.configurationFile = repository.getModpackConfiguration(instanceId); + this.instance = instance; + this.modManager = instance.getModManager(); + this.configurationFile = instance.getModpackConfigurationFile(); this.configuration = configuration; setStage("hmcl.modpack.download"); @@ -110,7 +129,7 @@ public CompletableFuture getFuture(TaskCompletableFuture executor) { throw new IOException("Unable to parse server manifest.json from " + manifest.getFileApi(), e); } - Path rootPath = repository.getInstanceRoot(instanceId); + Path rootPath = instance.getInstanceRoot(); Files.createDirectories(rootPath); Map localFiles = manifest.getFiles().stream().collect(Collectors.toMap(Function.identity(), Function.identity())); @@ -172,8 +191,7 @@ public CompletableFuture getFuture(TaskCompletableFuture executor) { manifest = remoteManifest.setFiles(newFiles); return executor.all(tasks.stream().filter(Objects::nonNull).collect(Collectors.toList())); })).thenAcceptAsync(wrapConsumer(unused1 -> { - Path manifestFile = repository.getModpackConfiguration(instanceId); - JsonUtils.writeToJsonFile(manifestFile, + JsonUtils.writeToJsonFile(configurationFile, new ModpackConfiguration<>(manifest, this.configuration.getType(), this.manifest.getName(), this.manifest.getVersion(), this.manifest.getFiles().stream() .flatMap(file -> file instanceof McbbsModpackManifest.AddonFile @@ -271,10 +289,9 @@ public CompletableFuture getFuture(TaskCompletableFuture executor) { })); } - @Nullable - private Path getFilePath(McbbsModpackManifest.File file) { + private @Nullable Path getFilePath(McbbsModpackManifest.File file) { if (file instanceof McbbsModpackManifest.AddonFile) { - return modManager.getRepository().getRunDirectory(modManager.getInstanceId()).resolve(((McbbsModpackManifest.AddonFile) file).getPath()); + return instance.getRunDirectory().resolve(((McbbsModpackManifest.AddonFile) file).getPath()); } else if (file instanceof McbbsModpackManifest.CurseFile) { String fileName = ((McbbsModpackManifest.CurseFile) file).getFileName(); if (fileName == null) return null; @@ -284,7 +301,7 @@ private Path getFilePath(McbbsModpackManifest.File file) { } } - private String getFileHash(McbbsModpackManifest.File file) { + private @Nullable String getFileHash(McbbsModpackManifest.File file) { if (file instanceof McbbsModpackManifest.AddonFile) { return ((McbbsModpackManifest.AddonFile) file).getHash(); } else { @@ -292,7 +309,7 @@ private String getFileHash(McbbsModpackManifest.File file) { } } - private Task downloadFile(McbbsModpackManifest remoteManifest, McbbsModpackManifest.File file) throws IOException { + private @Nullable Task downloadFile(McbbsModpackManifest remoteManifest, McbbsModpackManifest.File file) throws IOException { if (file instanceof McbbsModpackManifest.AddonFile) { McbbsModpackManifest.AddonFile addonFile = (McbbsModpackManifest.AddonFile) file; return new FileDownloadTask( diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackExportTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackExportTask.java index bdbc6832629..3e649fd15db 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackExportTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackExportTask.java @@ -17,9 +17,9 @@ */ package org.jackhuang.hmcl.modpack.mcbbs; -import org.jackhuang.hmcl.download.LibraryAnalyzer; -import org.jackhuang.hmcl.game.DefaultGameRepository; -import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.DefaultGameInstance; +import org.jackhuang.hmcl.game.GameComponentAnalyzer; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.Library; import org.jackhuang.hmcl.modpack.ModAdviser; import org.jackhuang.hmcl.modpack.Modpack; @@ -32,6 +32,8 @@ import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.Zipper; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; +import org.jetbrains.annotations.NotNullByDefault; import java.io.File; import java.io.IOException; @@ -40,19 +42,30 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Optional; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.*; +import static org.jackhuang.hmcl.game.GameComponentType.*; import static org.jackhuang.hmcl.util.logging.Logger.LOG; +/// Exports one registered game instance as an MCBBS modpack archive. +@NotNullByDefault public class McbbsModpackExportTask extends Task { - private final DefaultGameRepository repository; - private final GameInstanceID instanceId; + /// The fixed instance snapshot exported by this task. + private final DefaultGameInstance instance; + + /// The validated export configuration. private final ModpackExportInfo info; + + /// The archive written by this task. private final Path modpackFile; - public McbbsModpackExportTask(DefaultGameRepository repository, GameInstanceID instanceId, ModpackExportInfo info, Path modpackFile) { - this.repository = repository; - this.instanceId = instanceId; + /// Creates an MCBBS modpack export task. + /// + /// @param instance the registered instance snapshot to export + /// @param info the export configuration + /// @param modpackFile the archive to write + public McbbsModpackExportTask(DefaultGameInstance instance, ModpackExportInfo info, Path modpackFile) { + this.instance = instance; this.info = info.validate(); this.modpackFile = modpackFile; @@ -67,14 +80,16 @@ public McbbsModpackExportTask(DefaultGameRepository repository, GameInstanceID i }); } + /// {@inheritDoc} @Override public void execute() throws Exception { + var instanceId = instance.getId(); ArrayList blackList = new ArrayList<>(ModAdviser.MODPACK_BLACK_LIST); blackList.add(instanceId + ".jar"); blackList.add(instanceId + ".json"); LOG.info("Compressing game files without some files in blacklist, including files or directories: usernamecache.json, asm, logs, backups, versions, assets, usercache.json, libraries, crash-reports, launcher_profiles.json, NVIDIA, TCNodeTracker"); try (var zip = new Zipper(modpackFile)) { - Path runDirectory = repository.getRunDirectory(instanceId); + Path runDirectory = instance.getRunDirectory(); List files = new ArrayList<>(); zip.putDirectory(runDirectory, "overrides", path -> { if (Modpack.acceptFile(path, blackList, info.getWhitelist())) { @@ -89,29 +104,21 @@ public void execute() throws Exception { } }); - String gameVersion = repository.getGameVersion(instanceId) - .orElseThrow(() -> new IOException("Cannot parse the version of " + instanceId)); - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(repository.getResolvedInstanceManifest(instanceId), gameVersion); + GameVersionNumber version = instance.getVersion(); + if (version == GameVersionNumber.unknown()) { + throw new IOException("Cannot parse the version of " + instanceId); + } + String gameVersion = version.toString(); + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(instance.getResolvedManifest(), gameVersion); // Mcbbs manifest List addons = new ArrayList<>(); - addons.add(new McbbsModpackManifest.Addon(MINECRAFT.getPatchId(), gameVersion)); - analyzer.getVersion(FORGE).ifPresent(forgeVersion -> - addons.add(new McbbsModpackManifest.Addon(FORGE.getPatchId(), forgeVersion))); - analyzer.getVersion(CLEANROOM).ifPresent(cleanroomVersion -> - addons.add(new McbbsModpackManifest.Addon(CLEANROOM.getPatchId(), cleanroomVersion))); - analyzer.getVersion(NEO_FORGE).ifPresent(neoForgeVersion -> - addons.add(new McbbsModpackManifest.Addon(NEO_FORGE.getPatchId(), neoForgeVersion))); - analyzer.getVersion(LITELOADER).ifPresent(liteLoaderVersion -> - addons.add(new McbbsModpackManifest.Addon(LITELOADER.getPatchId(), liteLoaderVersion))); - analyzer.getVersion(OPTIFINE).ifPresent(optifineVersion -> - addons.add(new McbbsModpackManifest.Addon(OPTIFINE.getPatchId(), optifineVersion))); - analyzer.getVersion(FABRIC).ifPresent(fabricVersion -> - addons.add(new McbbsModpackManifest.Addon(FABRIC.getPatchId(), fabricVersion))); - analyzer.getVersion(QUILT).ifPresent(quiltVersion -> - addons.add(new McbbsModpackManifest.Addon(QUILT.getPatchId(), quiltVersion))); - analyzer.getVersion(LEGACY_FABRIC).ifPresent(legacyfabricVersion -> - addons.add(new McbbsModpackManifest.Addon(LEGACY_FABRIC.getPatchId(), legacyfabricVersion))); + addons.add(new McbbsModpackManifest.Addon(GAME.getPatchId(), gameVersion)); + for (GameComponentAnalyzer.Mark mark : analyzer) { + if ((mark.componentType().isModLoader() || mark.componentType() == GameComponentType.OPTIFINE)) { + addons.add(new McbbsModpackManifest.Addon(mark.componentType().getPatchId(), mark.version())); + } + } List libraries = new ArrayList<>(); // TODO libraries @@ -127,15 +134,16 @@ public void execute() throws Exception { // CurseForge manifest List modLoaders = new ArrayList<>(); - analyzer.getVersion(FORGE).ifPresent(forgeVersion -> modLoaders.add(new CurseManifestModLoader("forge-" + forgeVersion, true))); - analyzer.getVersion(NEO_FORGE).ifPresent(forgeVersion -> modLoaders.add(new CurseManifestModLoader("neoforge-" + forgeVersion, true))); - analyzer.getVersion(FABRIC).ifPresent(fabricVersion -> modLoaders.add(new CurseManifestModLoader("fabric-" + fabricVersion, true))); + Optional.ofNullable(analyzer.getVersion(FORGE)).ifPresent(forgeVersion -> modLoaders.add(new CurseManifestModLoader("forge-" + forgeVersion, true))); + Optional.ofNullable(analyzer.getVersion(NEO_FORGE)).ifPresent(forgeVersion -> modLoaders.add(new CurseManifestModLoader("neoforge-" + forgeVersion, true))); + Optional.ofNullable(analyzer.getVersion(FABRIC)).ifPresent(fabricVersion -> modLoaders.add(new CurseManifestModLoader("fabric-" + fabricVersion, true))); // OptiFine and LiteLoader are not supported by CurseForge modpack. CurseManifest curseManifest = new CurseManifest(CurseManifest.MINECRAFT_MODPACK, 1, info.getName(), info.getVersion(), info.getAuthor(), "overrides", new CurseManifestMinecraft(gameVersion, modLoaders), Collections.emptyList()); zip.putTextFile(JsonUtils.GSON.toJson(curseManifest), "manifest.json"); } } + /// Export options supported by the MCBBS format. public static final ModpackExportInfo.Options OPTION = new ModpackExportInfo.Options() .requireFileApi(true) .requireUrl() diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackLocalInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackLocalInstallTask.java index 110a1a82d37..71366355656 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackLocalInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackLocalInstallTask.java @@ -59,9 +59,9 @@ public McbbsModpackLocalInstallTask(DefaultDependencyManager dependencyManager, this.manifest = manifest; this.instanceId = instanceId; this.repository = dependencyManager.getGameRepository(); - Path run = repository.getRunDirectory(instanceId); + Path run = repository.getLayout().getInstanceRoot(instanceId); - Path json = repository.getModpackConfiguration(instanceId); + Path json = repository.getLayout().getModpackConfigurationFile(instanceId); if (repository.hasInstance(instanceId) && Files.notExists(json)) throw new IllegalArgumentException("Instance " + instanceId + " already exists."); this.update = repository.hasInstance(instanceId); @@ -89,7 +89,7 @@ public McbbsModpackLocalInstallTask(DefaultDependencyManager dependencyManager, } catch (JsonParseException | IOException ignore) { } dependents.add(new ModpackInstallTask<>(zipFile, run, modpack.getEncoding(), Collections.singletonList("/overrides"), any -> true, config).withStage("hmcl.modpack")); - instanceTask = new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), Collections.singletonList("/overrides"), manifest, McbbsModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getModpackConfiguration(instanceId)); + instanceTask = new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), Collections.singletonList("/overrides"), manifest, McbbsModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getLayout().getModpackConfigurationFile(instanceId)); dependents.add(instanceTask.withStage("hmcl.modpack")); } @@ -119,7 +119,10 @@ public void execute() throws Exception { // TODO: maintain libraries. } - dependencies.add(new McbbsModpackCompletionTask(dependencyManager, instanceId, instanceTask.getResult())); + dependencies.add(new McbbsModpackCompletionTask( + dependencyManager, + repository.getInstance(instanceId), + instanceTask.getResult())); } private static final String PATCH_NAME = "mcbbs"; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackManifest.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackManifest.java index 69e63a1ee59..24d1b5b968e 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackManifest.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackManifest.java @@ -20,6 +20,7 @@ import com.google.gson.JsonParseException; import com.google.gson.annotations.SerializedName; import org.jackhuang.hmcl.download.DefaultDependencyManager; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.LaunchOptions; import org.jackhuang.hmcl.game.Library; @@ -38,8 +39,6 @@ import java.util.Objects; import java.util.Optional; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.MINECRAFT; - public class McbbsModpackManifest implements ModpackManifest, Validation { public static final String MANIFEST_TYPE = "minecraftModpack"; @@ -421,7 +420,7 @@ public String getAuthlibInjectorServer() { } public Modpack toModpack(Charset encoding) throws IOException { - String gameVersion = addons.stream().filter(x -> MINECRAFT.getPatchId().equals(x.id)).findAny() + String gameVersion = addons.stream().filter(x -> GameComponentType.GAME.getPatchId().equals(x.id)).findAny() .orElseThrow(() -> new IOException("Cannot find game version")).getVersion(); return new Modpack(name, author, version, gameVersion, description, encoding, this) { @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackProvider.java index 5c63d3d0649..d4993662b46 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackProvider.java @@ -21,7 +21,7 @@ import kala.compress.archivers.zip.ZipArchiveEntry; import kala.compress.archivers.zip.ZipArchiveReader; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.game.LaunchOptions; import org.jackhuang.hmcl.modpack.*; import org.jackhuang.hmcl.task.Task; @@ -41,16 +41,16 @@ public String getName() { } @Override - public Task createCompletionTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId) { - return new McbbsModpackCompletionTask(dependencyManager, instanceId); + public Task createCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) { + return new McbbsModpackCompletionTask(dependencyManager, instance); } @Override - public Task createUpdateTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException { + public Task createUpdateTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException { if (!(modpack.getManifest() instanceof McbbsModpackManifest mcbbsModpackManifest)) throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName()); - return new ModpackUpdateTask(dependencyManager.getGameRepository(), instanceId, new McbbsModpackLocalInstallTask(dependencyManager, zipFile, modpack, mcbbsModpackManifest, instanceId)); + return new ModpackUpdateTask(instance, new McbbsModpackLocalInstallTask(dependencyManager, zipFile, modpack, mcbbsModpackManifest, instance.getId())); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackRemoteInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackRemoteInstallTask.java deleted file mode 100644 index c8e0b80c05f..00000000000 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackRemoteInstallTask.java +++ /dev/null @@ -1,94 +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.modpack.mcbbs; - -import com.google.gson.JsonParseException; -import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.GameBuilder; -import org.jackhuang.hmcl.game.DefaultGameRepository; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.modpack.ModpackConfiguration; -import org.jackhuang.hmcl.task.Task; -import org.jackhuang.hmcl.util.gson.JsonUtils; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -public class McbbsModpackRemoteInstallTask extends Task { - - private final GameInstanceID instanceId; - private final DefaultDependencyManager dependency; - private final DefaultGameRepository repository; - private final List> dependencies = new ArrayList<>(1); - private final List> dependents = new ArrayList<>(1); - private final McbbsModpackManifest manifest; - - public McbbsModpackRemoteInstallTask(DefaultDependencyManager dependencyManager, McbbsModpackManifest manifest, GameInstanceID instanceId) { - this.instanceId = instanceId; - this.dependency = dependencyManager; - this.repository = dependencyManager.getGameRepository(); - this.manifest = manifest; - - Path json = repository.getModpackConfiguration(instanceId); - if (repository.hasInstance(instanceId) && Files.notExists(json)) - throw new IllegalArgumentException("Instance " + instanceId + " already exists."); - - GameBuilder builder = dependencyManager.newGameBuilder().name(instanceId); - for (McbbsModpackManifest.Addon addon : manifest.getAddons()) { - builder.version(addon.getId(), addon.getVersion()); - } - - dependents.add(builder.buildAsync()); - onDone().register(event -> { - if (event.isFailed()) - repository.removeInstanceFromDisk(instanceId); - }); - - ModpackConfiguration config; - try { - if (Files.exists(json)) { - config = JsonUtils.fromJsonFile(json, ModpackConfiguration.typeOf(McbbsModpackManifest.class)); - - if (!MODPACK_TYPE.equals(config.getType())) - throw new IllegalArgumentException("Instance " + instanceId + " is not a Mcbbs modpack. Cannot update this instance."); - } - } catch (JsonParseException | IOException ignore) { - } - } - - @Override - public List> getDependents() { - return dependents; - } - - @Override - public List> getDependencies() { - return dependencies; - } - - @Override - public void execute() throws Exception { - dependencies.add(new McbbsModpackCompletionTask(dependency, instanceId, new ModpackConfiguration<>(manifest, MODPACK_TYPE, manifest.getName(), manifest.getVersion(), Collections.emptyList()))); - } - - public static final String MODPACK_TYPE = "Server"; -} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthCompletionTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthCompletionTask.java index d44673ac8be..bf712321f6d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthCompletionTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthCompletionTask.java @@ -18,14 +18,15 @@ package org.jackhuang.hmcl.modpack.modrinth; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.game.DefaultGameRepository; +import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.addon.mod.ModManager; -import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.modpack.ModpackCompletionException; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.FileUtils; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; import java.io.FileNotFoundException; import java.io.IOException; @@ -39,46 +40,60 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG; +/// Completes missing files for an installed Modrinth modpack. +@NotNullByDefault public class ModrinthCompletionTask extends Task { + /// The dependency manager used to download remote files. private final DefaultDependencyManager dependency; - private final DefaultGameRepository repository; + + /// The fixed registered instance completed by this task. + private final DefaultGameInstance instance; + + /// The mod manager associated with [#instance]. private final ModManager modManager; - private final GameInstanceID instanceId; - private ModrinthManifest manifest; + + /// The manifest supplied by the caller or loaded from disk, if available. + private @Nullable ModrinthManifest manifest; + + /// Download tasks produced during [#execute()]. private final List> dependencies = new ArrayList<>(); + /// Whether every required download has at least one usable URL. private final AtomicBoolean allNameKnown = new AtomicBoolean(true); + + /// The number of manifest entries processed. private final AtomicInteger finished = new AtomicInteger(0); + + /// Whether a required file has no usable download URL. private final AtomicBoolean notFound = new AtomicBoolean(false); - /** - * Constructor. - * - * @param dependencyManager the dependency manager. - * @param instanceId the existent and physical version. - */ - public ModrinthCompletionTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId) { - this(dependencyManager, instanceId, null); + /// Creates a task that completes the installed Modrinth modpack. + /// + /// @param dependencyManager the dependency manager + /// @param instance the registered instance to complete + public ModrinthCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) { + this(dependencyManager, instance, null); } - /** - * Constructor. - * - * @param dependencyManager the dependency manager. - * @param instanceId the existent and physical version. - * @param manifest the CurseForgeModpack manifest. - */ - public ModrinthCompletionTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId, ModrinthManifest manifest) { + /// Creates a task that completes the installed Modrinth modpack using an optional manifest. + /// + /// @param dependencyManager the dependency manager + /// @param instance the registered instance to complete + /// @param manifest the Modrinth manifest, or `null` to read it from disk + public ModrinthCompletionTask( + DefaultDependencyManager dependencyManager, + DefaultGameInstance instance, + @Nullable ModrinthManifest manifest) { + dependencyManager.validateGameInstance(instance); this.dependency = dependencyManager; - this.repository = dependencyManager.getGameRepository(); - this.modManager = repository.getModManager(instanceId); - this.instanceId = instanceId; + this.instance = instance; + this.modManager = instance.getModManager(); this.manifest = manifest; if (manifest == null) try { - Path manifestFile = repository.getInstanceRoot(instanceId).resolve("modrinth.index.json"); + Path manifestFile = instance.getInstanceRoot().resolve("modrinth.index.json"); if (Files.exists(manifestFile)) this.manifest = JsonUtils.fromJsonFile(manifestFile, ModrinthManifest.class); } catch (Exception e) { @@ -103,7 +118,7 @@ public void execute() throws Exception { if (manifest == null) return; - Path runDirectory = FileUtils.toAbsolute(repository.getRunDirectory(instanceId)); + Path runDirectory = FileUtils.toAbsolute(instance.getRunDirectory()); Path modsDirectory = runDirectory.resolve("mods"); for (ModrinthManifest.File file : manifest.getFiles()) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthInstallTask.java index e378fa4811c..0fb2196df01 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthInstallTask.java @@ -62,9 +62,9 @@ public ModrinthInstallTask(DefaultDependencyManager dependencyManager, Path zipF this.instanceId = instanceId; this.iconUrl = iconUrl; this.repository = dependencyManager.getGameRepository(); - this.run = repository.getRunDirectory(instanceId); + this.run = repository.getLayout().getInstanceRoot(instanceId); - Path json = repository.getModpackConfiguration(instanceId); + Path json = repository.getLayout().getModpackConfigurationFile(instanceId); if (repository.hasInstance(instanceId) && Files.notExists(json)) throw new IllegalArgumentException("Instance " + instanceId + " already exists."); @@ -116,7 +116,7 @@ public ModrinthInstallTask(DefaultDependencyManager dependencyManager, Path zipF this.config = config; List subDirectories = Arrays.asList("/client-overrides", "/overrides"); dependents.add(new ModpackInstallTask<>(zipFile, run, modpack.getEncoding(), subDirectories, any -> true, config).withStage("hmcl.modpack")); - dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), subDirectories, manifest, ModrinthModpackProvider.INSTANCE, manifest.getName(), manifest.getVersionId(), repository.getModpackConfiguration(instanceId)).withStage("hmcl.modpack")); + dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), subDirectories, manifest, ModrinthModpackProvider.INSTANCE, manifest.getName(), manifest.getVersionId(), repository.getLayout().getModpackConfigurationFile(instanceId)).withStage("hmcl.modpack")); URI iconUri = NetworkUtils.toURIOrNull(iconUrl); if (iconUri != null) { @@ -127,7 +127,6 @@ public ModrinthInstallTask(DefaultDependencyManager dependencyManager, Path zipF dependents.add(downloadIconTask = new CacheFileTask(dependencyManager.getDownloadProvider().injectURLWithCandidates(iconUrl))); } } - dependencies.add(new ModrinthCompletionTask(dependencyManager, instanceId, manifest)); } @Override @@ -153,7 +152,7 @@ public void execute() throws Exception { } } - Path root = repository.getInstanceRoot(instanceId); + Path root = repository.getLayout().getInstanceRoot(instanceId); Files.createDirectories(root); JsonUtils.writeToJsonFile(root.resolve("modrinth.index.json"), manifest); @@ -164,5 +163,8 @@ public void execute() throws Exception { LOG.warning("Failed to copy modpack icon", e); } } + + // The game builder runs as a dependent and registers the instance before this phase. + dependencies.add(new ModrinthCompletionTask(dependencyManager, repository.getInstance(instanceId), manifest)); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthModpackExportTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthModpackExportTask.java index 25edbf60a0f..835ffccc323 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthModpackExportTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthModpackExportTask.java @@ -24,10 +24,11 @@ import java.nio.file.Paths; import java.util.*; +import org.jackhuang.hmcl.addon.mod.ModManager; import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository; -import org.jackhuang.hmcl.download.LibraryAnalyzer; -import org.jackhuang.hmcl.game.DefaultGameRepository; -import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.DefaultGameInstance; +import org.jackhuang.hmcl.game.GameComponentAnalyzer; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.modpack.ModAdviser; import org.jackhuang.hmcl.modpack.Modpack; import org.jackhuang.hmcl.modpack.ModpackExportInfo; @@ -35,22 +36,37 @@ import org.jackhuang.hmcl.util.DigestUtils; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.Zipper; -import org.jackhuang.hmcl.addon.mod.LocalModFile; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jackhuang.hmcl.addon.RemoteAddon; import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.*; import static org.jackhuang.hmcl.util.logging.Logger.LOG; +/// Exports one registered game instance as a Modrinth modpack archive. +@NotNullByDefault public class ModrinthModpackExportTask extends Task { - private final DefaultGameRepository repository; - private final GameInstanceID instanceId; + /// The fixed instance snapshot exported by this task. + private final DefaultGameInstance instance; + + /// The mod manager associated with the exported instance. + private final ModManager modManager; + + /// The validated export configuration. private final ModpackExportInfo info; + + /// The archive written by this task. private final Path modpackFile; - public ModrinthModpackExportTask(DefaultGameRepository repository, GameInstanceID instanceId, ModpackExportInfo info, Path modpackFile) { - this.repository = repository; - this.instanceId = instanceId; + /// Creates a Modrinth modpack export task. + /// + /// @param instance the registered instance snapshot to export + /// @param info the export configuration + /// @param modpackFile the archive to write + public ModrinthModpackExportTask(DefaultGameInstance instance, ModpackExportInfo info, Path modpackFile) { + this.instance = instance; + this.modManager = instance.getModManager(); this.info = info.validate(); this.modpackFile = modpackFile; @@ -65,17 +81,21 @@ public ModrinthModpackExportTask(DefaultGameRepository repository, GameInstanceI }); } - private ModrinthManifest.File tryGetRemoteFile(Path file, String relativePath) throws IOException { + /// Returns a remote-file manifest entry for a local file when one can be identified. + /// + /// @param file the local file + /// @param relativePath the archive-relative path + /// @return the remote-file entry, or `null` when the file must be included in overrides + private @Nullable ModrinthManifest.File tryGetRemoteFile(Path file, String relativePath) throws IOException { if (info.isNoCreateRemoteFiles()) { return null; } - boolean isDisabled = repository.getModManager(instanceId).isDisabled(file); + boolean isDisabled = modManager.isDisabled(file); if (isDisabled) { - relativePath = repository.getModManager(instanceId).enableMod(Paths.get(relativePath)).toString(); + relativePath = modManager.enableMod(Paths.get(relativePath)).toString(); } - LocalModFile localModFile = null; Optional modrinthVersion = Optional.empty(); Optional curseForgeVersion = Optional.empty(); @@ -101,7 +121,7 @@ private ModrinthManifest.File tryGetRemoteFile(Path file, String relativePath) t hashes.put("sha1", DigestUtils.digestToString("SHA-1", file)); hashes.put("sha512", DigestUtils.digestToString("SHA-512", file)); - Map env = null; + @Nullable Map env = null; if (isDisabled) { env = new HashMap<>(); env.put("client", "optional"); @@ -126,14 +146,16 @@ private ModrinthManifest.File tryGetRemoteFile(Path file, String relativePath) t ); } + /// {@inheritDoc} @Override public void execute() throws Exception { + var instanceId = instance.getId(); ArrayList blackList = new ArrayList<>(ModAdviser.MODPACK_BLACK_LIST); blackList.add(instanceId + ".jar"); blackList.add(instanceId + ".json"); LOG.info("Compressing game files without some files in blacklist, including files or directories: usernamecache.json, asm, logs, backups, versions, assets, usercache.json, libraries, crash-reports, launcher_profiles.json, NVIDIA, TCNodeTracker"); try (var zip = new Zipper(modpackFile)) { - Path runDirectory = repository.getRunDirectory(instanceId); + Path runDirectory = instance.getRunDirectory(); List files = new ArrayList<>(); Set filesInManifest = new HashSet<>(); @@ -171,20 +193,23 @@ public void execute() throws Exception { return Modpack.acceptFile(path, blackList, info.getWhitelist()); }); - String gameVersion = repository.getGameVersion(instanceId) - .orElseThrow(() -> new IOException("Cannot parse the version of " + instanceId)); - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(repository.getResolvedInstanceManifest(instanceId), gameVersion); + GameVersionNumber version = instance.getVersion(); + if (version == GameVersionNumber.unknown()) { + throw new IOException("Cannot parse the version of " + instanceId); + } + String gameVersion = version.toString(); + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(instance.getResolvedManifest(), gameVersion); Map dependencies = new HashMap<>(); dependencies.put("minecraft", gameVersion); - analyzer.getVersion(FORGE).ifPresent(forgeVersion -> + Optional.ofNullable(analyzer.getVersion(GameComponentType.FORGE)).ifPresent(forgeVersion -> dependencies.put("forge", forgeVersion)); - analyzer.getVersion(NEO_FORGE).ifPresent(neoForgeVersion -> + Optional.ofNullable(analyzer.getVersion(GameComponentType.NEO_FORGE)).ifPresent(neoForgeVersion -> dependencies.put("neoforge", neoForgeVersion)); - analyzer.getVersion(FABRIC).ifPresent(fabricVersion -> + Optional.ofNullable(analyzer.getVersion(GameComponentType.FABRIC)).ifPresent(fabricVersion -> dependencies.put("fabric-loader", fabricVersion)); - analyzer.getVersion(QUILT).ifPresent(quiltVersion -> + Optional.ofNullable(analyzer.getVersion(GameComponentType.QUILT)).ifPresent(quiltVersion -> dependencies.put("quilt-loader", quiltVersion)); ModrinthManifest manifest = new ModrinthManifest( @@ -201,6 +226,7 @@ public void execute() throws Exception { } } + /// Export options supported by the Modrinth format. public static final ModpackExportInfo.Options OPTION = new ModpackExportInfo.Options() .requireNoCreateRemoteFiles() .requireSkipCurseForgeRemoteFiles(); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthModpackProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthModpackProvider.java index 6b4f7374bf5..f1eb340bbae 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthModpackProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthModpackProvider.java @@ -20,6 +20,7 @@ import com.google.gson.JsonParseException; import kala.compress.archivers.zip.ZipArchiveReader; import org.jackhuang.hmcl.download.DefaultDependencyManager; +import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.modpack.MismatchedModpackTypeException; import org.jackhuang.hmcl.modpack.Modpack; @@ -42,16 +43,16 @@ public String getName() { } @Override - public Task createCompletionTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId) { - return new ModrinthCompletionTask(dependencyManager, instanceId); + public Task createCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) { + return new ModrinthCompletionTask(dependencyManager, instance); } @Override - public Task createUpdateTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException { + public Task createUpdateTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException { if (!(modpack.getManifest() instanceof ModrinthManifest modrinthManifest)) throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName()); - return new ModpackUpdateTask(dependencyManager.getGameRepository(), instanceId, new ModrinthInstallTask(dependencyManager, zipFile, modpack, modrinthManifest, instanceId, null)); + return new ModpackUpdateTask(instance, new ModrinthInstallTask(dependencyManager, zipFile, modpack, modrinthManifest, instance.getId(), null)); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCComponents.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCComponents.java index ba1e2604153..b4b8aa2a5bd 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCComponents.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCComponents.java @@ -17,7 +17,7 @@ */ package org.jackhuang.hmcl.modpack.multimc; -import org.jackhuang.hmcl.download.LibraryAnalyzer; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.util.io.NetworkUtils; import java.net.URI; @@ -66,21 +66,21 @@ public static String getInstallerProfile() { return builder.toString(); } - private static final Map ID_TYPE = new HashMap<>(); + private static final Map ID_TYPE = new HashMap<>(); static { - ID_TYPE.put("net.minecraft", LibraryAnalyzer.LibraryType.MINECRAFT); - ID_TYPE.put("net.minecraftforge", LibraryAnalyzer.LibraryType.FORGE); - ID_TYPE.put("net.neoforged", LibraryAnalyzer.LibraryType.NEO_FORGE); - ID_TYPE.put("com.mumfrey.liteloader", LibraryAnalyzer.LibraryType.LITELOADER); - ID_TYPE.put("net.fabricmc.fabric-loader", LibraryAnalyzer.LibraryType.FABRIC); - ID_TYPE.put("org.quiltmc.quilt-loader", LibraryAnalyzer.LibraryType.QUILT); + ID_TYPE.put("net.minecraft", GameComponentType.GAME); + ID_TYPE.put("net.minecraftforge", GameComponentType.FORGE); + ID_TYPE.put("net.neoforged", GameComponentType.NEO_FORGE); + ID_TYPE.put("com.mumfrey.liteloader", GameComponentType.LITELOADER); + ID_TYPE.put("net.fabricmc.fabric-loader", GameComponentType.FABRIC); + ID_TYPE.put("org.quiltmc.quilt-loader", GameComponentType.QUILT); } - private static final Map TYPE_ID = + private static final Map TYPE_ID = ID_TYPE.entrySet().stream().collect(Collectors.toMap(Map.Entry::getValue, Map.Entry::getKey)); - private static final Collection> PAIRS = Collections.unmodifiableCollection(ID_TYPE.entrySet()); + private static final Collection> PAIRS = Collections.unmodifiableCollection(ID_TYPE.entrySet()); static { if (TYPE_ID.isEmpty()) { @@ -88,15 +88,15 @@ public static String getInstallerProfile() { } } - public static String getComponent(LibraryAnalyzer.LibraryType type) { + public static String getComponent(GameComponentType type) { return TYPE_ID.get(type); } - public static LibraryAnalyzer.LibraryType getComponent(String type) { + public static GameComponentType getComponent(String type) { return ID_TYPE.get(type); } - public static Collection> getPairs() { + public static Collection> getPairs() { return PAIRS; } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCInstancePatch.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCInstancePatch.java index cd826f2c1fb..8278a2331ef 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCInstancePatch.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCInstancePatch.java @@ -19,7 +19,7 @@ import com.google.gson.JsonParseException; import com.google.gson.annotations.SerializedName; -import org.jackhuang.hmcl.download.LibraryAnalyzer; + import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.util.Immutable; import org.jackhuang.hmcl.util.Lang; @@ -413,7 +413,7 @@ public static ResolvedInstance resolveArtifact(List patche String gameVersion = null; for (MultiMCInstancePatch patch : patches) { - if (MultiMCComponents.getComponent(patch.getID()) == LibraryAnalyzer.LibraryType.MINECRAFT) { + if (MultiMCComponents.getComponent(patch.getID()) == GameComponentType.GAME) { gameVersion = patch.getVersion(); break; } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackExportTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackExportTask.java index a27f9ca961a..25a0d9a2962 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackExportTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackExportTask.java @@ -17,15 +17,17 @@ */ package org.jackhuang.hmcl.modpack.multimc; -import org.jackhuang.hmcl.download.LibraryAnalyzer; -import org.jackhuang.hmcl.game.DefaultGameRepository; -import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.DefaultGameInstance; +import org.jackhuang.hmcl.game.GameComponentAnalyzer; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.modpack.ModAdviser; import org.jackhuang.hmcl.modpack.Modpack; import org.jackhuang.hmcl.modpack.ModpackExportInfo; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.Zipper; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; +import org.jetbrains.annotations.NotNullByDefault; import java.io.IOException; import java.io.StringWriter; @@ -35,26 +37,31 @@ import java.util.List; import java.util.Map; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.*; import static org.jackhuang.hmcl.util.logging.Logger.LOG; -/** - * Export the game to a mod pack file. - */ +/// Exports one registered game instance as a MultiMC modpack archive. +@NotNullByDefault public class MultiMCModpackExportTask extends Task { - private final DefaultGameRepository repository; - private final GameInstanceID instanceId; + /// The fixed instance snapshot exported by this task. + private final DefaultGameInstance instance; + + /// The paths selected for inclusion in the archive. private final List whitelist; + + /// The MultiMC instance configuration written to the archive. private final MultiMCInstanceConfiguration configuration; + + /// The archive written by this task. private final Path output; - /** - * @param output mod pack file. - * @param instanceId to locate version.json - */ - public MultiMCModpackExportTask(DefaultGameRepository repository, GameInstanceID instanceId, List whitelist, MultiMCInstanceConfiguration configuration, Path output) { - this.repository = repository; - this.instanceId = instanceId; + /// Creates a MultiMC modpack export task. + /// + /// @param instance the registered instance snapshot to export + /// @param whitelist the paths selected for inclusion + /// @param configuration the MultiMC instance configuration + /// @param output the archive to write + public MultiMCModpackExportTask(DefaultGameInstance instance, List whitelist, MultiMCInstanceConfiguration configuration, Path output) { + this.instance = instance; this.whitelist = whitelist; this.configuration = configuration; this.output = output; @@ -70,26 +77,32 @@ public MultiMCModpackExportTask(DefaultGameRepository repository, GameInstanceID }); } + /// {@inheritDoc} @Override public void execute() throws Exception { + var instanceId = instance.getId(); ArrayList blackList = new ArrayList<>(ModAdviser.MODPACK_BLACK_LIST); blackList.add(instanceId + ".jar"); blackList.add(instanceId + ".json"); LOG.info("Compressing game files without some files in blacklist, including files or directories: usernamecache.json, asm, logs, backups, versions, assets, usercache.json, libraries, crash-reports, launcher_profiles.json, NVIDIA, TCNodeTracker"); try (Zipper zip = new Zipper(output)) { - zip.putDirectory(repository.getRunDirectory(instanceId), ".minecraft", path -> Modpack.acceptFile(path, blackList, whitelist)); + zip.putDirectory(instance.getRunDirectory(), ".minecraft", path -> Modpack.acceptFile(path, blackList, whitelist)); - String gameVersion = repository.getGameVersion(instanceId) - .orElseThrow(() -> new IOException("Cannot parse the version of " + instanceId)); - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(repository.getResolvedInstanceManifest(instanceId), gameVersion); + GameVersionNumber version = instance.getVersion(); + if (version == GameVersionNumber.unknown()) { + throw new IOException("Cannot parse the version of " + instanceId); + } + String gameVersion = version.toString(); + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(instance.getResolvedManifest(), gameVersion); List components = new ArrayList<>(); - components.add(new MultiMCManifest.MultiMCManifestComponent(true, false, MultiMCComponents.getComponent(MINECRAFT), gameVersion)); + components.add(new MultiMCManifest.MultiMCManifestComponent(true, false, MultiMCComponents.getComponent(GameComponentType.GAME), gameVersion)); - for (Map.Entry pair : MultiMCComponents.getPairs()) { + for (Map.Entry pair : MultiMCComponents.getPairs()) { if (pair.getValue().isModLoader()) { - analyzer.getVersion(pair.getValue()).ifPresent( - v -> components.add(new MultiMCManifest.MultiMCManifestComponent(false, false, pair.getKey(), v)) - ); + String componentVersion = analyzer.getVersion(pair.getValue()); + if (componentVersion != null) { + components.add(new MultiMCManifest.MultiMCManifestComponent(false, false, pair.getKey(), componentVersion)); + } } } @@ -104,6 +117,7 @@ public void execute() throws Exception { } } + /// Export options supported by the MultiMC format. public static final ModpackExportInfo.Options OPTION = new ModpackExportInfo.Options() .requireAuthor() .requireMinMemory(); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackInstallTask.java index da24c80ecc6..5a21a656a1a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackInstallTask.java @@ -19,8 +19,6 @@ import com.google.gson.JsonParseException; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; -import org.jackhuang.hmcl.download.MaintainTask; import org.jackhuang.hmcl.download.game.GameAssetDownloadTask; import org.jackhuang.hmcl.download.game.GameDownloadTask; import org.jackhuang.hmcl.download.game.GameLibrariesTask; @@ -35,6 +33,7 @@ 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.Nullable; import java.io.IOException; import java.io.InputStream; @@ -90,7 +89,7 @@ public MultiMCModpackInstallTask(DefaultDependencyManager dependencyManager, Pat this.dependencyManager = dependencyManager; this.repository = dependencyManager.getGameRepository(); - Path json = repository.getModpackConfiguration(instanceId); + Path json = repository.getLayout().getModpackConfigurationFile(instanceId); if (repository.hasInstance(instanceId) && Files.notExists(json)) throw new IllegalArgumentException("Instance " + instanceId + " already exists."); @@ -109,8 +108,8 @@ public boolean doPreExecute() { public void preExecute() throws Exception { // Stage #0: General Setup { - Path run = repository.getRunDirectory(instanceId); - Path json = repository.getModpackConfiguration(instanceId); + Path run = repository.getLayout().getInstanceRoot(instanceId); + Path json = repository.getLayout().getModpackConfigurationFile(instanceId); ModpackConfiguration config = null; try { @@ -130,7 +129,7 @@ public void preExecute() throws Exception { // TODO: Optimize unbearably slow ModpackInstallTask dependents.add(new ModpackInstallTask<>(zipFile, run, modpack.getEncoding(), Collections.singletonList(mcDirectory), any -> true, config).withStage("hmcl.modpack")); - dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), Collections.singletonList(mcDirectory), manifest, MultiMCModpackProvider.INSTANCE, manifest.getName(), null, repository.getModpackConfiguration(instanceId)).withStage("hmcl.modpack")); + dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), Collections.singletonList(mcDirectory), manifest, MultiMCModpackProvider.INSTANCE, manifest.getName(), null, repository.getLayout().getModpackConfigurationFile(instanceId)).withStage("hmcl.modpack")); } // Stage #1: Load all related Json-Patch from meta maven or local mod pack. @@ -145,7 +144,7 @@ public void preExecute() throws Exception { String mcVersion = null; for (MultiMCManifest.MultiMCManifestComponent component : components) { - if (MultiMCComponents.getComponent(component.getUid()) == LibraryAnalyzer.LibraryType.MINECRAFT) { + if (MultiMCComponents.getComponent(component.getUid()) == GameComponentType.GAME) { mcVersion = component.getVersion(); break; } @@ -230,10 +229,11 @@ public List> getDependents() { return dependents; } + /// {@inheritDoc} @Override public void execute() throws Exception { // Stage #3: Build Json-Patch artifact. - MultiMCInstancePatch.ResolvedInstance artifact = null; + @Nullable MultiMCInstancePatch.ResolvedInstance artifact = null; for (int i = dependents.size() - 1; i >= 0; i--) { Task task = dependents.get(i); if (task instanceof MMCInstancePatchesAssembleTask) { @@ -249,7 +249,7 @@ public void execute() throws Exception { Path libraries = root.resolve("libraries"); if (Files.exists(libraries)) - FileUtils.copyDirectory(libraries, repository.getInstanceRoot(instanceId).resolve("libraries")); + FileUtils.copyDirectory(libraries, repository.getLayout().getInstanceRoot(instanceId).resolve("libraries")); for (Library library : artifact.getManifest().getLibraries()) { if ("local".equals(library.hint())) { @@ -257,25 +257,28 @@ public void execute() throws Exception { Retain them will facilitate compatibility, as some embedded libraries may check where their JAR is. Meanwhile, potential compatibility issue with other launcher which never supports these fields might occur. Here, we make the file stored twice, to keep maximum compatibility. */ - Path from = repository.getLibraryFile(artifact.getManifest(), library); - Path target = repository.getLibraryFile(artifact.getManifest(), library.withoutCommunityFields()); + Path from = repository.getLayout().getLibraryFile(artifact.getManifest().id(), library); + Path target = repository.getLayout().getLibraryFile(artifact.getManifest().id(), library.withoutCommunityFields()); Files.createDirectories(target.getParent()); Files.copy(from, target, StandardCopyOption.REPLACE_EXISTING); } } - try (InputStream input = MaintainTask.class.getResourceAsStream("/assets/game/HMCLMultiMCBootstrap-1.0.jar")) { - Path libraryPath = repository.getLibraryFile(artifact.getManifest(), MultiMCInstancePatch.BOOTSTRAP_LIBRARY); + try (InputStream input = Objects.requireNonNull( + MultiMCModpackInstallTask.class.getResourceAsStream( + "/assets/game/HMCLMultiMCBootstrap-1.0.jar"), + "Bundled HMCLMultiMCBootstrap is missing.")) { + Path libraryPath = repository.getLayout().getLibraryFile(artifact.getManifest().id(), MultiMCInstancePatch.BOOTSTRAP_LIBRARY); Files.createDirectories(libraryPath.getParent()); - Files.copy(Objects.requireNonNull(input, "Bundled HMCLMultiMCBootstrap is missing."), libraryPath, StandardCopyOption.REPLACE_EXISTING); + Files.copy(input, libraryPath, StandardCopyOption.REPLACE_EXISTING); } - String iconKey = this.manifest.getIconKey(); + @Nullable String iconKey = this.manifest.getIconKey(); if (iconKey != null) { Path iconFile = root.resolve(iconKey + ".png"); if (Files.exists(iconFile)) { - FileUtils.copyFile(iconFile, repository.getInstanceRoot(instanceId).resolve("icon.png")); + FileUtils.copyFile(iconFile, repository.getLayout().getInstanceRoot(instanceId).resolve("icon.png")); } } } @@ -335,7 +338,7 @@ public void postExecute() throws Exception { Path root = getRootPath(fs).resolve("jarmods"); try (FileSystem mc = CompressingUtils.writable( - repository.getInstanceRoot(instanceId).resolve(instanceId + ".jar") + repository.getLayout().getInstanceRoot(instanceId).resolve(instanceId + ".jar") ).setAutoDetectEncoding(true).build()) { for (String fileName : files) { try (FileSystem jm = CompressingUtils.readonly(root.resolve(fileName)).setAutoDetectEncoding(true).build()) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackProvider.java index 479095d981d..966dd0ef372 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackProvider.java @@ -20,6 +20,7 @@ import kala.compress.archivers.zip.ZipArchiveEntry; import kala.compress.archivers.zip.ZipArchiveReader; import org.jackhuang.hmcl.download.DefaultDependencyManager; +import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.modpack.MismatchedModpackTypeException; import org.jackhuang.hmcl.modpack.Modpack; @@ -27,6 +28,7 @@ import org.jackhuang.hmcl.modpack.ModpackUpdateTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.io.FileUtils; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.io.InputStream; @@ -42,16 +44,16 @@ public String getName() { } @Override - public Task createCompletionTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId) { + public @Nullable Task createCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) { return null; } @Override - public Task createUpdateTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException { + public Task createUpdateTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException { if (!(modpack.getManifest() instanceof MultiMCInstanceConfiguration multiMCInstanceConfiguration)) throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName()); - return new ModpackUpdateTask(dependencyManager.getGameRepository(), instanceId, new MultiMCModpackInstallTask(dependencyManager, zipFile, modpack, multiMCInstanceConfiguration, instanceId)); + return new ModpackUpdateTask(instance, new MultiMCModpackInstallTask(dependencyManager, zipFile, modpack, multiMCInstanceConfiguration, instance.getId())); } private static String getRootEntryName(ZipArchiveReader file) throws IOException { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackCompletionTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackCompletionTask.java index 2caca9ab70e..b056d69e8ea 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackCompletionTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackCompletionTask.java @@ -20,9 +20,8 @@ import com.google.gson.JsonParseException; import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.GameBuilder; -import org.jackhuang.hmcl.game.DefaultGameRepository; +import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.addon.LocalAddonManager; -import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.modpack.ModpackConfiguration; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.GetTask; @@ -30,6 +29,8 @@ import org.jackhuang.hmcl.util.DigestUtils; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.file.Files; @@ -40,30 +41,57 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG; +/// Synchronizes an installed server modpack with its remote manifest. +@NotNullByDefault public class ServerModpackCompletionTask extends Task { + /// The dependency manager used for downloads and game-component updates. private final DefaultDependencyManager dependencyManager; - private final DefaultGameRepository repository; - private final GameInstanceID instanceId; - private ModpackConfiguration manifest; - private GetTask dependent; - private ServerModpackManifest remoteManifest; + + /// The fixed registered instance completed by this task. + private final DefaultGameInstance instance; + + /// The fixed configuration-file path for [#instance]. + private final Path configurationFile; + + /// The installed configuration supplied by the caller or loaded from disk. + private @Nullable ModpackConfiguration manifest; + + /// The remote-manifest request created during [#preExecute()]. + private @Nullable GetTask dependent; + + /// The remote manifest parsed during [#execute()]. + private @Nullable ServerModpackManifest remoteManifest; + + /// Download and game-builder tasks produced during [#execute()]. private final List> dependencies = new ArrayList<>(); - public ServerModpackCompletionTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId) { - this(dependencyManager, instanceId, null); + /// Creates a task that loads the installed configuration from disk. + /// + /// @param dependencyManager the dependency manager + /// @param instance the registered instance to complete + public ServerModpackCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) { + this(dependencyManager, instance, null); } - public ServerModpackCompletionTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId, ModpackConfiguration manifest) { + /// Creates a task using an optional preloaded configuration. + /// + /// @param dependencyManager the dependency manager + /// @param instance the registered instance to complete + /// @param manifest the installed configuration, or `null` to read it from disk + public ServerModpackCompletionTask( + DefaultDependencyManager dependencyManager, + DefaultGameInstance instance, + @Nullable ModpackConfiguration manifest) { + dependencyManager.validateGameInstance(instance); this.dependencyManager = dependencyManager; - this.repository = dependencyManager.getGameRepository(); - this.instanceId = instanceId; + this.instance = instance; + this.configurationFile = instance.getModpackConfigurationFile(); if (manifest == null) { try { - Path manifestFile = repository.getModpackConfiguration(instanceId); - if (Files.exists(manifestFile)) { - this.manifest = JsonUtils.fromJsonFile(manifestFile, ModpackConfiguration.typeOf(ServerModpackManifest.class)); + if (Files.exists(configurationFile)) { + this.manifest = JsonUtils.fromJsonFile(configurationFile, ModpackConfiguration.typeOf(ServerModpackManifest.class)); } } catch (Exception e) { LOG.warning("Unable to read Server modpack manifest.json", e); @@ -113,7 +141,7 @@ public void execute() throws Exception { Map oldAddons = toMap(manifest.getManifest().getAddons()); Map newAddons = toMap(remoteManifest.getAddons()); if (!Objects.equals(oldAddons, newAddons)) { - GameBuilder builder = dependencyManager.newGameBuilder().name(instanceId); + GameBuilder builder = dependencyManager.newGameBuilder().name(instance.getId()); for (ServerModpackManifest.Addon addon : remoteManifest.getAddons()) { builder.version(addon.getId(), addon.getVersion()); } @@ -121,7 +149,7 @@ public void execute() throws Exception { dependencies.add(builder.buildAsync()); } - Path rootPath = repository.getInstanceRoot(instanceId).toAbsolutePath().normalize(); + Path rootPath = instance.getInstanceRoot().toAbsolutePath().normalize(); Map files = manifest.getManifest().getFiles().stream() .collect(Collectors.toMap(ModpackConfiguration.FileInformation::getPath, Function.identity())); @@ -129,7 +157,7 @@ public void execute() throws Exception { Set remoteFiles = remoteManifest.getFiles().stream().map(ModpackConfiguration.FileInformation::getPath) .collect(Collectors.toSet()); - Path runDirectory = repository.getRunDirectory(instanceId).toAbsolutePath().normalize(); + Path runDirectory = instance.getRunDirectory().toAbsolutePath().normalize(); Path modsDirectory = runDirectory.resolve("mods"); int total = 0; @@ -193,8 +221,7 @@ public boolean doPostExecute() { @Override public void postExecute() throws Exception { if (manifest == null || StringUtils.isBlank(manifest.getManifest().getFileApi())) return; - Path manifestFile = repository.getModpackConfiguration(instanceId); - Files.createDirectories(manifestFile.getParent()); - JsonUtils.writeToJsonFile(manifestFile, new ModpackConfiguration<>(remoteManifest, this.manifest.getType(), this.manifest.getName(), this.manifest.getVersion(), remoteManifest.getFiles())); + Files.createDirectories(configurationFile.getParent()); + JsonUtils.writeToJsonFile(configurationFile, new ModpackConfiguration<>(remoteManifest, this.manifest.getType(), this.manifest.getName(), this.manifest.getVersion(), remoteManifest.getFiles())); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackExportTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackExportTask.java index c2bcfcbe702..b33e8c8ea22 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackExportTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackExportTask.java @@ -17,9 +17,9 @@ */ package org.jackhuang.hmcl.modpack.server; -import org.jackhuang.hmcl.download.LibraryAnalyzer; -import org.jackhuang.hmcl.game.DefaultGameRepository; -import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.DefaultGameInstance; +import org.jackhuang.hmcl.game.GameComponentAnalyzer; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.modpack.ModAdviser; import org.jackhuang.hmcl.modpack.Modpack; import org.jackhuang.hmcl.modpack.ModpackConfiguration; @@ -29,6 +29,8 @@ import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.Zipper; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; +import org.jetbrains.annotations.NotNullByDefault; import java.io.File; import java.io.IOException; @@ -37,18 +39,27 @@ import java.util.ArrayList; import java.util.List; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.*; import static org.jackhuang.hmcl.util.logging.Logger.LOG; +/// Exports one registered game instance as an HMCL server modpack archive. +@NotNullByDefault public class ServerModpackExportTask extends Task { - private final DefaultGameRepository repository; - private final GameInstanceID instanceId; + /// The fixed instance snapshot exported by this task. + private final DefaultGameInstance instance; + + /// The validated export configuration. private final ModpackExportInfo exportInfo; + + /// The archive written by this task. private final Path modpackFile; - public ServerModpackExportTask(DefaultGameRepository repository, GameInstanceID instanceId, ModpackExportInfo exportInfo, Path modpackFile) { - this.repository = repository; - this.instanceId = instanceId; + /// Creates a server modpack export task. + /// + /// @param instance the registered instance snapshot to export + /// @param exportInfo the export configuration + /// @param modpackFile the archive to write + public ServerModpackExportTask(DefaultGameInstance instance, ModpackExportInfo exportInfo, Path modpackFile) { + this.instance = instance; this.exportInfo = exportInfo.validate(); this.modpackFile = modpackFile; @@ -63,14 +74,16 @@ public ServerModpackExportTask(DefaultGameRepository repository, GameInstanceID }); } + /// {@inheritDoc} @Override public void execute() throws Exception { + var instanceId = instance.getId(); ArrayList blackList = new ArrayList<>(ModAdviser.MODPACK_BLACK_LIST); blackList.add(instanceId + ".jar"); blackList.add(instanceId + ".json"); LOG.info("Compressing game files without some files in blacklist, including files or directories: usernamecache.json, asm, logs, backups, versions, assets, usercache.json, libraries, crash-reports, launcher_profiles.json, NVIDIA, TCNodeTracker"); try (Zipper zip = new Zipper(modpackFile)) { - Path runDirectory = repository.getRunDirectory(instanceId); + Path runDirectory = instance.getRunDirectory(); List files = new ArrayList<>(); zip.putDirectory(runDirectory, "overrides", path -> { if (Modpack.acceptFile(path, blackList, exportInfo.getWhitelist())) { @@ -85,28 +98,28 @@ public void execute() throws Exception { } }); - String gameVersion = repository.getGameVersion(instanceId) - .orElseThrow(() -> new IOException("Cannot parse the version of " + instanceId)); - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(repository.getResolvedInstanceManifest(instanceId), gameVersion); + GameVersionNumber version = instance.getVersion(); + if (version == GameVersionNumber.unknown()) { + throw new IOException("Cannot parse the version of " + instanceId); + } + String gameVersion = version.toString(); + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(instance.getResolvedManifest(), gameVersion); List addons = new ArrayList<>(); - addons.add(new ServerModpackManifest.Addon(MINECRAFT.getPatchId(), gameVersion)); - analyzer.getVersion(FORGE).ifPresent(forgeVersion -> - addons.add(new ServerModpackManifest.Addon(FORGE.getPatchId(), forgeVersion))); - analyzer.getVersion(NEO_FORGE).ifPresent(neoForgeVersion -> - addons.add(new ServerModpackManifest.Addon(NEO_FORGE.getPatchId(), neoForgeVersion))); - analyzer.getVersion(LITELOADER).ifPresent(liteLoaderVersion -> - addons.add(new ServerModpackManifest.Addon(LITELOADER.getPatchId(), liteLoaderVersion))); - analyzer.getVersion(OPTIFINE).ifPresent(optifineVersion -> - addons.add(new ServerModpackManifest.Addon(OPTIFINE.getPatchId(), optifineVersion))); - analyzer.getVersion(FABRIC).ifPresent(fabricVersion -> - addons.add(new ServerModpackManifest.Addon(FABRIC.getPatchId(), fabricVersion))); - analyzer.getVersion(QUILT).ifPresent(quiltVersion -> - addons.add(new ServerModpackManifest.Addon(QUILT.getPatchId(), quiltVersion))); + addons.add(new ServerModpackManifest.Addon(GameComponentType.GAME.getPatchId(), gameVersion)); + + for (GameComponentAnalyzer.Mark mark : analyzer) { + if ((mark.componentType().isModLoader() || mark.componentType() == GameComponentType.OPTIFINE) + && mark.version() != null) { + addons.add(new ServerModpackManifest.Addon(mark.componentType().getPatchId(), mark.version())); + } + } + ServerModpackManifest manifest = new ServerModpackManifest(exportInfo.getName(), exportInfo.getAuthor(), exportInfo.getVersion(), exportInfo.getDescription(), StringUtils.removeSuffix(exportInfo.getFileApi(), "/"), files, addons); zip.putTextFile(JsonUtils.GSON.toJson(manifest), "server-manifest.json"); } } + /// Export options supported by the server modpack format. public static final ModpackExportInfo.Options OPTION = new ModpackExportInfo.Options() .requireAuthor() .requireFileApi(false); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackLocalInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackLocalInstallTask.java index f038c3064c2..81730b1e859 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackLocalInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackLocalInstallTask.java @@ -52,9 +52,9 @@ public ServerModpackLocalInstallTask(DefaultDependencyManager dependencyManager, this.manifest = manifest; this.instanceId = instanceId; this.repository = dependencyManager.getGameRepository(); - Path run = repository.getRunDirectory(instanceId); + Path run = repository.getLayout().getInstanceRoot(instanceId); - Path json = repository.getModpackConfiguration(instanceId); + Path json = repository.getLayout().getModpackConfigurationFile(instanceId); if (repository.hasInstance(instanceId) && Files.notExists(json)) throw new IllegalArgumentException("Instance " + instanceId + " already exists."); @@ -80,7 +80,7 @@ public ServerModpackLocalInstallTask(DefaultDependencyManager dependencyManager, } catch (JsonParseException | IOException ignore) { } dependents.add(new ModpackInstallTask<>(zipFile, run, modpack.getEncoding(), Collections.singletonList("/overrides"), any -> true, config).withStage("hmcl.modpack")); - dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), Collections.singletonList("/overrides"), manifest, ServerModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getModpackConfiguration(instanceId)).withStage("hmcl.modpack")); + dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), Collections.singletonList("/overrides"), manifest, ServerModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getLayout().getModpackConfigurationFile(instanceId)).withStage("hmcl.modpack")); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackManifest.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackManifest.java index 41a44b7b1b1..58dcc947b38 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackManifest.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackManifest.java @@ -19,6 +19,7 @@ import com.google.gson.JsonParseException; import org.jackhuang.hmcl.download.DefaultDependencyManager; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.modpack.Modpack; import org.jackhuang.hmcl.modpack.ModpackConfiguration; @@ -34,8 +35,6 @@ import java.util.Collections; import java.util.List; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.MINECRAFT; - public class ServerModpackManifest implements ModpackManifest, Validation { private final String name; private final String author; @@ -123,7 +122,7 @@ public String getVersion() { } public Modpack toModpack(Charset encoding) throws IOException { - String gameVersion = addons.stream().filter(x -> MINECRAFT.getPatchId().equals(x.id)).findAny() + String gameVersion = addons.stream().filter(x -> GameComponentType.GAME.getPatchId().equals(x.id)).findAny() .orElseThrow(() -> new IOException("Cannot find game version")).getVersion(); return new Modpack(name, author, version, gameVersion, description, encoding, this) { @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackProvider.java index 76e98f7fddf..90e82f03924 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackProvider.java @@ -20,7 +20,7 @@ import com.google.gson.JsonParseException; import kala.compress.archivers.zip.ZipArchiveReader; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.modpack.MismatchedModpackTypeException; import org.jackhuang.hmcl.modpack.Modpack; import org.jackhuang.hmcl.modpack.ModpackProvider; @@ -42,16 +42,16 @@ public String getName() { } @Override - public Task createCompletionTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId) { - return new ServerModpackCompletionTask(dependencyManager, instanceId); + public Task createCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) { + return new ServerModpackCompletionTask(dependencyManager, instance); } @Override - public Task createUpdateTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException { + public Task createUpdateTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException { if (!(modpack.getManifest() instanceof ServerModpackManifest serverModpackManifest)) throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName()); - return new ModpackUpdateTask(dependencyManager.getGameRepository(), instanceId, new ServerModpackLocalInstallTask(dependencyManager, zipFile, modpack, serverModpackManifest, instanceId)); + return new ModpackUpdateTask(instance, new ServerModpackLocalInstallTask(dependencyManager, zipFile, modpack, serverModpackManifest, instance.getId())); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackRemoteInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackRemoteInstallTask.java index e29f8438691..c56831da127 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackRemoteInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackRemoteInstallTask.java @@ -48,7 +48,7 @@ public ServerModpackRemoteInstallTask(DefaultDependencyManager dependencyManager this.repository = dependencyManager.getGameRepository(); this.manifest = manifest; - Path json = repository.getModpackConfiguration(instanceId); + Path json = repository.getLayout().getModpackConfigurationFile(instanceId); if (repository.hasInstance(instanceId) && Files.notExists(json)) throw new IllegalArgumentException("Instance " + instanceId + " already exists."); @@ -87,7 +87,10 @@ public List> getDependencies() { @Override public void execute() throws Exception { - dependencies.add(new ServerModpackCompletionTask(dependency, instanceId, new ModpackConfiguration<>(manifest, MODPACK_TYPE, manifest.getName(), manifest.getVersion(), Collections.emptyList()))); + dependencies.add(new ServerModpackCompletionTask( + dependency, + repository.getInstance(instanceId), + new ModpackConfiguration<>(manifest, MODPACK_TYPE, manifest.getName(), manifest.getVersion(), Collections.emptyList()))); } public static final String MODPACK_TYPE = "Server"; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/SettingsMap.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/SettingsMap.java index f91329f0aab..cc66267d1be 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/SettingsMap.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/SettingsMap.java @@ -17,8 +17,8 @@ */ package org.jackhuang.hmcl.util; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.game.GameComponentType; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -91,10 +91,8 @@ public void clear() { /// Returns whether the selected installation includes any non-vanilla component. public boolean isInstallingModdedVersion() { - for (LibraryAnalyzer.LibraryType value : LibraryAnalyzer.LibraryType.values()) { - if (value != LibraryAnalyzer.LibraryType.MINECRAFT - && value.isModLoader() - && get(value.getPatchId()) instanceof RemoteVersion) { + for (GameComponentType value : GameComponentType.MOD_LOADERS) { + if (get(value.getPatchId()) instanceof RemoteVersion) { return true; } } diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java new file mode 100644 index 00000000000..1f8e29c55f4 --- /dev/null +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java @@ -0,0 +1,552 @@ +/* + * 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.game; + +import org.jackhuang.hmcl.download.DefaultCacheRepository; +import org.jackhuang.hmcl.download.DefaultDependencyManager; +import org.jackhuang.hmcl.download.LaunchManifestPreparation; +import org.jackhuang.hmcl.download.MojangDownloadProvider; +import org.jackhuang.hmcl.download.game.GameDownloadTask; +import org.jackhuang.hmcl.download.game.GameVerificationFixTask; +import org.jackhuang.hmcl.launch.LaunchClasspathResolver; +import org.jackhuang.hmcl.modpack.curse.CurseCompletionTask; +import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackCompletionTask; +import org.jackhuang.hmcl.modpack.modrinth.ModrinthCompletionTask; +import org.jackhuang.hmcl.modpack.server.ServerModpackCompletionTask; +import org.jackhuang.hmcl.task.FileDownloadTask; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Tests snapshot-bound behavior of [DefaultGameInstance]. +@NotNullByDefault +public final class DefaultGameInstanceTest { + + /// Resolve normalizes only the derived launch view and leaves the stored patch structure intact. + @Test + public void testResolveNormalizesLaunchWithoutChangingStoredPatches(@TempDir Path tempDirectory) { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID instanceId = new GameInstanceID("instance"); + Library oldLibrary = new Library(new Artifact("example", "library", "1.0")); + Library newLibrary = new Library(new Artifact("example", "library", "2.0")); + List patches = List.of(new GameInstancePatch( + "loader", null, 0, null, null, List.of(oldLibrary, newLibrary))); + GameInstanceManifest storedManifest = new GameInstanceManifest(instanceId) + .withRoot(true) + .withPatches(patches); + TestGameInstance instance = repository.publish(instanceId, storedManifest); + + GameInstanceManifest.Resolved resolved = instance.getResolvedManifest(); + + assertEquals(1, resolved.launchManifest().getLibraries().size()); + assertEquals("2.0", resolved.launchManifest().getLibraries().getFirst().version()); + assertEquals(patches, resolved.standaloneManifest().getPatches()); + assertEquals(storedManifest, instance.getManifest()); + assertEquals( + resolved.launchManifest(), + LaunchManifestNormalizer.normalize(resolved.launchManifest())); + } + + /// ModLauncher normalization adds support metadata without materializing bundled files. + @Test + public void testModLauncherNormalizationDoesNotWriteBundledLibraries(@TempDir Path tempDirectory) { + TestRepository repository = new TestRepository(tempDirectory.resolve("game")); + GameInstanceID instanceId = new GameInstanceID("instance"); + GameInstanceManifest manifest = new GameInstanceManifest(instanceId) + .withMainClass(GameComponentAnalyzer.MOD_LAUNCHER_MAIN) + .withLibraries(List.of( + new Library(new Artifact("net.minecraftforge", "forge", "1.0")), + new Library(new Artifact("optifine", "OptiFine", "1.0")))); + TestGameInstance instance = repository.publish(instanceId, manifest); + GameInstanceManifest launchManifest = instance.getResolvedManifest().launchManifest(); + Library transformerService = launchManifest.getLibraries().stream() + .filter(library -> library.is( + "org.jackhuang.hmcl", "transformer-discovery-service")) + .findAny() + .orElseThrow(); + Path transformerFile = repository.getLayout().getLibraryFile(instanceId, transformerService); + + assertFalse(Files.exists(transformerFile)); + assertEquals(launchManifest, LaunchManifestNormalizer.normalize(launchManifest)); + } + + /// Launch classpath resolution selects an installed OptiFine installer without changing the manifest. + @Test + public void testLaunchClasspathSelectsInstalledOptiFine(@TempDir Path tempDirectory) + throws IOException { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID instanceId = new GameInstanceID("instance"); + Library forge = new Library(new Artifact("net.minecraftforge", "forge", "1.0")); + Library optiFine = new Library(new Artifact("optifine", "OptiFine", "1.0")); + Library optiFineLaunchWrapper = new Library( + new Artifact("optifine", "launchwrapper-of", "2.0")); + GameInstanceManifest manifest = new GameInstanceManifest(instanceId) + .withMainClass(GameComponentAnalyzer.LAUNCH_WRAPPER_MAIN) + .withLibraries(List.of(forge, optiFine, optiFineLaunchWrapper)); + GameInstanceManifest launchManifest = repository.publish(instanceId, manifest) + .getResolvedManifest() + .launchManifest(); + Library installer = new Library(new Artifact("optifine", "OptiFine", "1.0", "installer")); + Path forgeFile = repository.getLayout().getLibraryFile(instanceId, forge); + Path optiFineFile = repository.getLayout().getLibraryFile(instanceId, optiFine); + Path optiFineLaunchWrapperFile = repository.getLayout() + .getLibraryFile(instanceId, optiFineLaunchWrapper); + Path installerFile = repository.getLayout().getLibraryFile(instanceId, installer); + Files.createDirectories(forgeFile.getParent()); + Files.createDirectories(installerFile.getParent()); + Files.createDirectories(optiFineLaunchWrapperFile.getParent()); + Files.write(forgeFile, new byte[]{1}); + Files.write(optiFineFile, new byte[]{1}); + Files.write(optiFineLaunchWrapperFile, new byte[]{1}); + Files.write(installerFile, new byte[]{1}); + + GameInstanceManifest prepared = LaunchManifestPreparation.prepare(repository, launchManifest); + Set classpath = LaunchClasspathResolver.resolve(repository, prepared); + + assertSame(launchManifest, prepared); + assertEquals(Set.of( + forgeFile.toAbsolutePath().toString(), + installerFile.toAbsolutePath().toString()), classpath); + assertTrue(prepared.getLibraries().stream() + .anyMatch(library -> library.is("optifine", "OptiFine") + && library.classifier() == null)); + assertTrue(prepared.getLibraries().stream() + .anyMatch(library -> library.is("optifine", "launchwrapper-of"))); + } + + /// ModLauncher keeps an installed OptiFine installer outside its ordinary classpath. + @Test + public void testModLauncherClasspathOmitsInstalledOptiFine(@TempDir Path tempDirectory) + throws IOException { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID instanceId = new GameInstanceID("instance"); + Library forge = new Library(new Artifact("net.minecraftforge", "forge", "1.0")); + Library optiFine = new Library(new Artifact("optifine", "OptiFine", "1.0")); + GameInstanceManifest manifest = new GameInstanceManifest(instanceId) + .withMainClass(GameComponentAnalyzer.MOD_LAUNCHER_MAIN) + .withLibraries(List.of(forge, optiFine)); + GameInstanceManifest launchManifest = repository.publish(instanceId, manifest) + .getResolvedManifest() + .launchManifest(); + Library installer = new Library(new Artifact("optifine", "OptiFine", "1.0", "installer")); + Library transformerService = launchManifest.getLibraries().stream() + .filter(library -> library.is( + "org.jackhuang.hmcl", "transformer-discovery-service")) + .findAny() + .orElseThrow(); + Path forgeFile = repository.getLayout().getLibraryFile(instanceId, forge); + Path optiFineFile = repository.getLayout().getLibraryFile(instanceId, optiFine); + Path installerFile = repository.getLayout().getLibraryFile(instanceId, installer); + Path transformerServiceFile = repository.getLayout() + .getLibraryFile(instanceId, transformerService); + Files.createDirectories(forgeFile.getParent()); + Files.createDirectories(installerFile.getParent()); + Files.createDirectories(transformerServiceFile.getParent()); + Files.write(forgeFile, new byte[]{1}); + Files.write(optiFineFile, new byte[]{1}); + Files.write(installerFile, new byte[]{1}); + Files.write(transformerServiceFile, new byte[]{1}); + + Set classpath = LaunchClasspathResolver.resolve(repository, launchManifest); + + assertEquals(Set.of( + forgeFile.toAbsolutePath().toString(), + transformerServiceFile.toAbsolutePath().toString()), classpath); + assertTrue(launchManifest.getLibraries().contains(optiFine)); + } + + /// Saving a manifest preserves its root flag and pending patches without baking in normalization. + @Test + public void testSavePreservesManifestPatchStructure(@TempDir Path tempDirectory) throws Exception { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID instanceId = new GameInstanceID("instance"); + List patches = List.of(new GameInstancePatch( + "loader", + null, + 0, + null, + null, + List.of(new Library(new Artifact("example", "library", "1.0"))))); + GameInstanceManifest manifest = new GameInstanceManifest(instanceId) + .withRoot(true) + .withPatches(patches); + + repository.saveAsync(manifest).run(); + + GameInstanceManifest savedManifest = repository.getInstance(instanceId).getManifest(); + assertTrue(savedManifest.isRoot()); + assertEquals(patches, savedManifest.getPatches()); + assertTrue(savedManifest.getLibraries().isEmpty()); + } + + /// Asset and modpack paths are resolved directly from the owning instance. + @Test + public void testInstanceOwnsAssetAndModpackPaths(@TempDir Path tempDirectory) throws IOException { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID instanceId = new GameInstanceID("instance"); + TestGameInstance instance = repository.publish(instanceId, new GameInstanceManifest(instanceId)); + String assetId = "legacy"; + String assetName = "icons/minecraft.icns"; + String assetHash = "abcdef0123456789"; + Path indexFile = repository.getLayout().getAssetIndexFile(assetId); + Files.createDirectories(indexFile.getParent()); + Files.writeString(indexFile, """ + { + "objects": { + "%s": { + "hash": "%s", + "size": 1 + } + } + } + """.formatted(assetName, assetHash)); + + AssetIndex index = instance.getAssetIndex(assetId); + assertEquals(assetHash, index.getObjects().get(assetName).hash()); + assertEquals( + Optional.of(repository.getLayout().getAssetObject(index.getObjects().get(assetName))), + instance.getAssetObject(assetId, assetName)); + assertEquals(Optional.empty(), instance.getAssetObject(assetId, "missing")); + assertEquals(repository.getLayout().getAssetDirectory(), instance.getActualAssetDirectory(assetId)); + assertEquals(instance.getInstanceRoot().resolve("modpack.json"), instance.getModpackConfigurationFile()); + } + + /// The selected primary jar follows the resolved manifest's `jar` field. + @Test + public void testPrimaryJarUsesResolvedJarField(@TempDir Path tempDirectory) { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID instanceId = new GameInstanceID("instance"); + GameInstanceID jarId = new GameInstanceID("shared-jar"); + TestGameInstance instance = repository.publish(instanceId, + new GameInstanceManifest(instanceId).withJar(jarId)); + + assertEquals(repository.getLayout().getInstanceJarFile(jarId), instance.getInstanceJarFile()); + } + + /// Snapshot copies never reuse addon managers; only the version cache is shared for the same manifest. + @Test + public void testSnapshotCopyDoesNotShareAddonManagers(@TempDir Path tempDirectory) throws IOException { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID instanceId = new GameInstanceID("instance"); + GameInstanceID oldJarId = new GameInstanceID("old-jar"); + GameInstanceID newJarId = new GameInstanceID("new-jar"); + writeVersionJar(repository.getLayout().getInstanceJarFile(oldJarId), "1.20.1"); + writeVersionJar(repository.getLayout().getInstanceJarFile(newJarId), "1.21.1"); + + GameInstanceManifest oldManifest = new GameInstanceManifest(instanceId).withJar(oldJarId); + TestGameInstance original = repository.publish(instanceId, oldManifest); + assertEquals(GameVersionNumber.asGameVersion("1.20.1"), original.getVersion()); + var originalModManager = original.getModManager(); + var originalResourcePackManager = original.getResourcePackManager(); + + TestGameInstance sameManifestCopy = original.withNewSnapshot(repository.newSnapshot()); + assertSame(original.cachedVersion(), sameManifestCopy.cachedVersion()); + assertNotSame(originalModManager, sameManifestCopy.getModManager()); + assertNotSame(originalResourcePackManager, sameManifestCopy.getResourcePackManager()); + + GameInstanceManifest newManifest = oldManifest.withJar(newJarId); + TestGameInstance updated = original.withManifest(repository.newSnapshot(), newManifest); + assertNull(updated.cachedVersion()); + assertNotSame(originalModManager, updated.getModManager()); + assertNotSame(originalResourcePackManager, updated.getResourcePackManager()); + assertEquals(GameVersionNumber.asGameVersion("1.21.1"), updated.getVersion()); + } + + /// Version lookup for an explicit manifest does not reuse a same-id instance with different content. + @Test + public void testExplicitManifestDoesNotReuseDifferentCachedManifest(@TempDir Path tempDirectory) throws IOException { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID instanceId = new GameInstanceID("instance"); + GameInstanceID cachedJarId = new GameInstanceID("cached-jar"); + GameInstanceID requestedJarId = new GameInstanceID("requested-jar"); + writeVersionJar(repository.getLayout().getInstanceJarFile(cachedJarId), "1.20.1"); + writeVersionJar(repository.getLayout().getInstanceJarFile(requestedJarId), "1.21.1"); + + GameInstanceManifest cachedManifest = new GameInstanceManifest(instanceId).withJar(cachedJarId); + TestGameInstance cachedInstance = repository.publish(instanceId, cachedManifest); + assertEquals(GameVersionNumber.asGameVersion("1.20.1"), cachedInstance.getVersion()); + + GameInstanceManifest requestedManifest = cachedManifest.withJar(requestedJarId); + assertEquals(Optional.of("1.21.1"), repository.getGameVersion(requestedManifest)); + } + + /// A game download with an explicit destination does not follow a later repository snapshot. + @Test + public void testGameDownloadKeepsExplicitDestination(@TempDir Path tempDirectory) { + TestRepository repository = new TestRepository(tempDirectory.resolve("game")); + DefaultDependencyManager dependencyManager = new DefaultDependencyManager( + repository, + new MojangDownloadProvider(), + new DefaultCacheRepository(tempDirectory.resolve("cache"))); + GameInstanceManifest manifest = new GameInstanceManifest(new GameInstanceID("instance")); + Path destination = tempDirectory.resolve("fixed.jar"); + + GameDownloadTask task = new GameDownloadTask(dependencyManager, null, manifest, destination); + task.execute(); + + FileDownloadTask download = (FileDownloadTask) task.getDependencies().iterator().next(); + assertEquals(destination, download.getPath()); + } + + /// Legacy verification fixes the captured instance jar rather than a newer same-id snapshot. + @Test + public void testVerificationFixKeepsCapturedInstance(@TempDir Path tempDirectory) throws IOException { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID instanceId = new GameInstanceID("instance"); + GameInstanceManifest manifest = new GameInstanceManifest(instanceId).withLibraries(List.of( + new Library(new Artifact("net.minecraftforge", "forge", "1.5.2-7.8.1.738")))); + + TestGameInstance captured = repository.publish( + instanceId, + manifest, + tempDirectory.resolve("versions/instance/captured.json")); + writeSignedJar(captured.getInstanceJarFile()); + + TestGameInstance current = repository.publish( + instanceId, + manifest, + tempDirectory.resolve("versions/instance/current.json")); + writeSignedJar(current.getInstanceJarFile()); + + new GameVerificationFixTask(captured, GameVersionNumber.asGameVersion("1.5.2"), manifest).execute(); + + assertFalse(hasZipEntry(captured.getInstanceJarFile(), "META-INF/MOJANG_C.DSA")); + assertFalse(hasZipEntry(captured.getInstanceJarFile(), "META-INF/MOJANG_C.SF")); + assertTrue(hasZipEntry(current.getInstanceJarFile(), "META-INF/MOJANG_C.DSA")); + assertTrue(hasZipEntry(current.getInstanceJarFile(), "META-INF/MOJANG_C.SF")); + } + + /// Dependency managers and modpack completion tasks reject cross-repository instances. + @Test + public void testDependencyManagerValidatesInstanceRepository(@TempDir Path tempDirectory) { + TestRepository instanceRepository = new TestRepository(tempDirectory.resolve("instance")); + TestRepository managerRepository = new TestRepository(tempDirectory.resolve("manager")); + TestGameInstance instance = instanceRepository.publish( + new GameInstanceID("instance"), + new GameInstanceManifest(new GameInstanceID("instance"))); + DefaultDependencyManager dependencyManager = new DefaultDependencyManager( + managerRepository, + new MojangDownloadProvider(), + new DefaultCacheRepository(tempDirectory.resolve("cache"))); + + assertThrows(IllegalArgumentException.class, () -> dependencyManager.validateGameInstance(instance)); + assertThrows(IllegalArgumentException.class, () -> new CurseCompletionTask(dependencyManager, instance)); + assertThrows(IllegalArgumentException.class, () -> new McbbsModpackCompletionTask(dependencyManager, instance)); + assertThrows(IllegalArgumentException.class, () -> new ModrinthCompletionTask(dependencyManager, instance)); + assertThrows(IllegalArgumentException.class, () -> new ServerModpackCompletionTask(dependencyManager, instance)); + } + + /// Non-conventional JSON/jar basenames are kept on disk and recorded on the instance. + @Test + public void testRefreshRecordsNonConventionalStoragePaths(@TempDir Path tempDirectory) throws IOException { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID folderId = new GameInstanceID("MyInstance"); + Path instanceDir = repository.getLayout().getInstanceRoot(folderId); + Files.createDirectories(instanceDir); + + Path json = instanceDir.resolve("1.20.1.json"); + Path jar = instanceDir.resolve("1.20.1.jar"); + Files.writeString(json, "{\"id\":\"1.20.1\",\"mainClass\":\"net.minecraft.client.main.Main\",\"libraries\":[]}"); + writeVersionJar(jar, "1.20.1"); + + repository.refresh(); + + DefaultGameInstance instance = repository.getInstance(folderId); + assertEquals(json, instance.getManifestFile()); + assertEquals(jar, instance.getInstanceJarFile()); + assertEquals(GameVersionNumber.asGameVersion("1.20.1"), instance.getVersion()); + assertEquals(folderId, instance.getId()); + assertEquals(folderId, instance.getManifest().id()); + assertEquals(json, repository.getInstanceJson(folderId)); + } + + /// Writes a minimal jar containing the version metadata consumed by [GameVersion]. + /// + /// @param jar the jar path + /// @param version the Minecraft version stored in `version.json` + private static void writeVersionJar(Path jar, String version) throws IOException { + Files.createDirectories(jar.getParent()); + try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(jar))) { + output.putNextEntry(new ZipEntry("version.json")); + output.write(("{\"id\":\"" + version + "\"}").getBytes(StandardCharsets.UTF_8)); + output.closeEntry(); + } + } + + /// Writes a jar containing the legacy signature entries removed before launching Forge. + /// + /// @param jar the jar path + private static void writeSignedJar(Path jar) throws IOException { + Files.createDirectories(jar.getParent()); + try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(jar))) { + output.putNextEntry(new ZipEntry("META-INF/MOJANG_C.DSA")); + output.write(1); + output.closeEntry(); + output.putNextEntry(new ZipEntry("META-INF/MOJANG_C.SF")); + output.write(1); + output.closeEntry(); + } + } + + /// Returns whether a zip contains an entry with the given name. + /// + /// @param zipFile the zip path + /// @param entryName the entry name + /// @return `true` when the entry exists + private static boolean hasZipEntry(Path zipFile, String entryName) throws IOException { + try (ZipFile zip = new ZipFile(zipFile.toFile())) { + return zip.getEntry(entryName) != null; + } + } + + /// Minimal repository implementation for snapshot-bound instance tests. + @NotNullByDefault + private static final class TestRepository extends DefaultGameRepository { + + /// Creates a test repository rooted at the given directory. + /// + /// @param baseDirectory the repository base directory + private TestRepository(Path baseDirectory) { + super(baseDirectory); + } + + /// {@inheritDoc} + @Override + protected DefaultGameRepositoryLayout createLayout(Path baseDirectory) { + return new DefaultGameRepositoryLayout(baseDirectory); + } + + /// {@inheritDoc} + @Override + protected TestGameInstance createInstance( + DefaultGameRepositorySnapshot snapshot, + GameInstanceID id, + GameInstanceManifest manifest, + @Nullable Path manifestFile) { + return new TestGameInstance(snapshot, id, manifest, manifestFile); + } + + /// Publishes a snapshot containing one test instance. + /// + /// @param id the instance id + /// @param manifest the stored manifest + /// @return the published instance + private TestGameInstance publish(GameInstanceID id, GameInstanceManifest manifest) { + return publish(id, manifest, null); + } + + /// Publishes a snapshot containing one test instance with an optional manifest path. + /// + /// @param id the instance id + /// @param manifest the stored manifest + /// @param manifestFile the non-conventional manifest path, or `null` + /// @return the published instance + private TestGameInstance publish( + GameInstanceID id, + GameInstanceManifest manifest, + @Nullable Path manifestFile) { + DefaultGameRepositorySnapshot snapshot = newSnapshot(); + TestGameInstance instance = createInstance(snapshot, id, manifest, manifestFile); + snapshot.put(instance); + publishSnapshot(snapshot); + return instance; + } + + /// Creates an empty mutable snapshot using the current layout. + /// + /// @return the new snapshot + private DefaultGameRepositorySnapshot newSnapshot() { + return createSnapshot(getLayout()); + } + } + + /// Minimal concrete game instance that exposes cache state to tests. + @NotNullByDefault + private static final class TestGameInstance extends DefaultGameInstance { + + /// Creates a test instance without shared session state. + /// + /// @param snapshot the owning snapshot + /// @param id the instance id + /// @param manifest the stored manifest + /// @param manifestFile non-conventional manifest path, or `null` + private TestGameInstance( + DefaultGameRepositorySnapshot snapshot, + GameInstanceID id, + GameInstanceManifest manifest, + @Nullable Path manifestFile) { + super(snapshot, id, manifest, manifestFile); + } + + /// Creates a test instance that may reuse compatible session state. + /// + /// @param snapshot the owning snapshot + /// @param id the instance id + /// @param manifest the stored manifest + /// @param shareSession the prior snapshot member + private TestGameInstance( + DefaultGameRepositorySnapshot snapshot, + GameInstanceID id, + GameInstanceManifest manifest, + TestGameInstance shareSession) { + super(snapshot, id, manifest, shareSession); + } + + /// {@inheritDoc} + @Override + protected TestGameInstance withNewSnapshot(DefaultGameRepositorySnapshot newSnapshot) { + return new TestGameInstance(newSnapshot, id, manifest, this); + } + + /// {@inheritDoc} + @Override + protected TestGameInstance withManifest( + DefaultGameRepositorySnapshot newSnapshot, + GameInstanceManifest manifest) { + return new TestGameInstance(newSnapshot, id, manifest, this); + } + + /// Returns the cache without triggering version detection. + /// + /// @return the cached version, or `null` when detection has not run + private @Nullable GameVersionNumber cachedVersion() { + return version; + } + } +} diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/GameInstanceManifestTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/GameInstanceManifestTest.java index f42b58d5ce6..48c46b9c5ad 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/GameInstanceManifestTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/GameInstanceManifestTest.java @@ -27,10 +27,7 @@ import java.util.Map; import java.util.Objects; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.*; /// Tests for game instance manifest parsing and resolution behavior. @NotNullByDefault @@ -57,7 +54,49 @@ public void testRootManifestWithPatchesUsesPatchView() throws NoSuchGameInstance false, List.of(patch("patch", null))); - GameInstanceManifest.Resolved resolved = new DefaultGameRepository(Path.of(".")).resolve(manifest); + GameInstanceManifest.Resolved resolved = new DefaultGameRepository(Path.of(".")) { + @Override + protected DefaultGameRepositoryLayout createLayout(Path baseDirectory) { + return new DefaultGameRepositoryLayout(baseDirectory); + } + + @Override + protected DefaultGameInstance createInstance( + DefaultGameRepositorySnapshot snapshot, + GameInstanceID id, + GameInstanceManifest manifest, + @Nullable Path manifestFile) { + final class MyGameInstance extends DefaultGameInstance { + MyGameInstance( + DefaultGameRepositorySnapshot snapshot, + GameInstanceID id, + GameInstanceManifest manifest, + @Nullable Path manifestFile) { + super(snapshot, id, manifest, manifestFile); + } + + MyGameInstance( + DefaultGameRepositorySnapshot snapshot, + GameInstanceID id, + GameInstanceManifest manifest, + DefaultGameInstance shareSession) { + super(snapshot, id, manifest, shareSession); + } + + @Override + protected DefaultGameInstance withNewSnapshot(DefaultGameRepositorySnapshot newSnapshot) { + return new MyGameInstance(newSnapshot, id, manifest, this); + } + + @Override + protected DefaultGameInstance withManifest(DefaultGameRepositorySnapshot newSnapshot, GameInstanceManifest manifest) { + return new MyGameInstance(newSnapshot, id, manifest, this); + } + } + + return new MyGameInstance(snapshot, id, manifest, manifestFile); + } + }.resolve(manifest); assertNull(resolved.launchManifest().mainClass()); assertNull(resolved.launchManifest().patches()); @@ -127,7 +166,7 @@ public void testPatchParsingAndCopyBehavior() { GameInstancePatch patch = originalPatch .withMainClass("new.Main") - .withId(null); + .withId((String) null); JsonObject updatedJson = patch.toJsonObject(); assertEquals("value", updatedJson.get("unknownField").getAsString()); diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/util/SettingsMapTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/SettingsMapTest.java index 3a4fda31d96..c04c1adebe3 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/util/SettingsMapTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/SettingsMapTest.java @@ -17,8 +17,8 @@ */ package org.jackhuang.hmcl.util; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.game.GameComponentType; import org.jetbrains.annotations.NotNullByDefault; import org.junit.jupiter.api.Test; @@ -35,7 +35,7 @@ public final class SettingsMapTest { @Test public void minecraftSelectionIsNotModdedInstallation() { SettingsMap settings = new SettingsMap(); - settings.put(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId(), remoteVersion("game")); + settings.put(GameComponentType.GAME.getPatchId(), remoteVersion(GameComponentType.GAME)); assertFalse(settings.isInstallingModdedVersion()); } @@ -44,14 +44,14 @@ public void minecraftSelectionIsNotModdedInstallation() { @Test public void modLoaderSelectionIsModdedInstallation() { SettingsMap settings = new SettingsMap(); - settings.put(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId(), remoteVersion("game")); - settings.put(LibraryAnalyzer.LibraryType.FABRIC.getPatchId(), remoteVersion("fabric")); + settings.put(GameComponentType.GAME.getPatchId(), remoteVersion(GameComponentType.GAME)); + settings.put(GameComponentType.FABRIC.getPatchId(), remoteVersion(GameComponentType.FABRIC)); assertTrue(settings.isInstallingModdedVersion()); } /// Creates a minimal remote version for installer state tests. - private static RemoteVersion remoteVersion(String libraryId) { - return new RemoteVersion(libraryId, "1.21.11", "test", Instant.EPOCH, List.of()); + private static RemoteVersion remoteVersion(GameComponentType componentType) { + return new RemoteVersion(componentType, "1.21.11", "test", Instant.EPOCH, List.of()); } }