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..4354d77750d --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java @@ -0,0 +1,846 @@ +/* + * 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); + } + } + + for (ModLoaderType modLoader : getModLoaders()) { + return GameInstanceIconType.getIconType(modLoader).getIcon(); + } + + if (hasComponent(GameComponentType.OPTIFINE)) + return GameInstanceIconType.OPTIFINE.getIcon(); + + GameVersionNumber version = getVersion(); + 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(); + else + 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(), + javaVersion.getPlatform()) / 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)) + .setUseHighPerformanceGPU(vs.getInheritable(GameSettings::highPerformanceProperty)) + .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 abc972cc372..c7c15b333eb 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -17,143 +17,222 @@ */ 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.Bits; import org.jackhuang.hmcl.util.platform.OperatingSystem; import org.jackhuang.hmcl.util.platform.Platform; -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.concurrent.ConcurrentHashMap; 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; + private final ObjectBinding<@Nullable GameInstanceID> selectedInstanceId; - // 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<>(); + /// The selected instance resolved from the current repository snapshot. + private final ReadOnlyObjectWrapper<@Nullable HMCLGameInstance> selectedInstance; - public final EventManager onInstanceIconChanged = new EventManager<>(); + /// Settings reservations transferred to the next draft that creates the corresponding id. + private final Map preparedInstanceSettings = new ConcurrentHashMap<>(); /// 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); + } + + /// {@inheritDoc} + /// + /// Prepared settings belong to the old layout and are discarded after a successful replacement. + @Override + public void setBaseDirectory(Path baseDirectory) { + super.setBaseDirectory(baseDirectory); + preparedInstanceSettings.clear(); + } + + /// {@inheritDoc} + /// + /// Accepts an existing root only when this repository reserved the id while the root was absent. + @Override + protected boolean mayClaimDraftInstanceRoot(GameInstanceID instanceId, Path instanceRoot) { + PreparedInstanceSettings prepared = preparedInstanceSettings.get(instanceId); + if (prepared != null) { + return prepared.instanceRoot().equals(instanceRoot) && prepared.rootWasAbsent(); + } + return super.mayClaimDraftInstanceRoot(instanceId, instanceRoot); + } + + /// {@inheritDoc} + /// + /// Writes settings prepared by [#ensureIsolatedRunningDirectory(GameInstanceID)] only after the + /// draft owns the instance root. + @Override + protected void initializeDraftInstanceRoot(GameInstanceID instanceId, Path instanceRoot) throws IOException { + PreparedInstanceSettings prepared = preparedInstanceSettings.get(instanceId); + if (prepared == null) { + return; + } + if (!prepared.instanceRoot().equals(instanceRoot) || !prepared.rootWasAbsent()) { + throw new IOException("Prepared instance root cannot be claimed: " + instanceRoot); + } + + writeInstanceGameSettings(instanceId, prepared.settings()); + preparedInstanceSettings.remove(instanceId, prepared); + } + + @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()); @@ -164,70 +243,141 @@ 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, + /// {@inheritDoc} + /// + /// Resolves HMCL isolation and modpack rules directly from files and settings so an unpublished + /// installation does not require a [GameInstance]. + @Override + public Path getRunDirectoryForInstallation(GameInstanceID instanceId) { + return computeRunDirectory( + instanceId, + Files.exists(getLayout().getModpackConfigurationFile(instanceId)), + getInstanceGameSettings(instanceId)); + } + + 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 settings are retained in memory and transferred to the + /// draft that creates the instance, so the draft owns every file created for the installation. + /// + /// @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; } + + Path instanceRoot = getLayout().getInstanceRoot(instanceId).toAbsolutePath().normalize(); + PreparedInstanceSettings prepared = preparedInstanceSettings.get(instanceId); + GameSettings.Instance setting = prepared != null + ? prepared.settings() + : peekInstanceGameSettings(instanceId); + if (setting == null) { + setting = new GameSettings.Instance(); + } + setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY); + preparedInstanceSettings.put( + instanceId, + new PreparedInstanceSettings( + setting, + instanceRoot, + prepared != null ? prepared.rootWasAbsent() : Files.notExists(instanceRoot))); + } + + 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) { @@ -242,27 +392,24 @@ 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()); } + /// Duplicates an instance and publishes the copy through one exclusive repository draft. + /// + /// The destination remains unpublished until all selected instance and run-directory files have + /// been copied. Failure aborts the draft and removes the destination instance root. + /// + /// @param srcId the source instance id + /// @param dstId the destination instance id + /// @param copySaves whether saved worlds should be copied + /// @throws IOException if the destination exists or any file cannot be copied or committed 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); + GameInstanceManifest destinationManifest = fromManifest.withId(dstId).withJar(dstId); List blackList = new ArrayList<>(ModAdviser.MODPACK_BLACK_LIST); blackList.add(srcId.id() + ".jar"); @@ -270,264 +417,73 @@ public void duplicateInstance(GameInstanceID srcId, GameInstanceID dstId, boolea if (!copySaves) blackList.add("saves"); - if (Files.exists(dstDir)) throw new IOException("Instance exists"); - - Files.createDirectories(dstDir); - FileUtils.copyDirectory(srcDir, dstDir, path -> Modpack.acceptFile(path, blackList, null)); + try (DefaultGameRepositoryDraft draft = openDraft()) { + draft.put(destinationManifest); - Path fromJson = srcDir.resolve(srcId.id() + ".json"); - Path fromJar = srcDir.resolve(srcId.id() + ".jar"); - Path toJson = dstDir.resolve(dstId.id() + ".json"); - Path toJar = dstDir.resolve(dstId.id() + ".jar"); - - if (Files.exists(fromJar)) { - Files.copy(fromJar, toJar); - } - Files.copy(fromJson, toJson); + Files.createDirectories(dstDir); + FileUtils.copyDirectory(srcDir, dstDir, path -> Modpack.acceptFile(path, blackList, null)); - JsonUtils.writeToJsonFile(toJson, fromManifest.withId(dstId).withJar(dstId)); - - boolean copyOriginalGameDir; - try { - copyOriginalGameDir = !Files.isSameFile(getRunDirectory(srcId), getInstanceRoot(srcId)); - } catch (IOException e) { - copyOriginalGameDir = true; - } - - Path srcGameDir = getRunDirectory(srcId); - - GameSettings.Instance newGameSettings = copyInstanceGameSettings(srcId); - newGameSettings.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY); - newGameSettings.runningDirectoryProperty().setValue(""); - initInstanceGameSettings(dstId, newGameSettings); - saveGameSettingsSync(dstId); - - Path dstGameDir = getRunDirectory(dstId); - - 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; - } - - /// 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. - 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; - } + Path fromJar = srcDir.resolve(srcId.id() + ".jar"); + Path toJar = dstDir.resolve(dstId.id() + ".jar"); + if (Files.exists(fromJar)) { + Files.copy(fromJar, toJar); + } - LegacyGameSettingsMigrator.InstanceMigrationResult migrationResult = - LegacyGameSettingsMigrator.migrateInstanceGameSettings( - this, instanceId, - legacyParent); - if (migrationResult != null) { - initInstanceGameSettings(instanceId, migrationResult.setting()); + Path srcGameDir = getInstance(srcId).getRunDirectory(); + boolean copyOriginalGameDir; try { - saveGameSettingsSync(instanceId); - migrationResult.saveReceipt(); + copyOriginalGameDir = !Files.isSameFile(srcGameDir, srcDir); } 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); + copyOriginalGameDir = 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 newGameSettings = getInstance(srcId).copySettings(); + newGameSettings.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY); + newGameSettings.runningDirectoryProperty().setValue(""); + writeInstanceGameSettings(dstId, newGameSettings); + + Path dstGameDir = computeRunDirectory(dstId, false, newGameSettings); + if (copyOriginalGameDir) { + FileUtils.copyDirectory( + srcGameDir, + dstGameDir, + path -> Modpack.acceptFile(path, blackList, null)); } - 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); + draft.commit(); } } - public @Nullable GameSettings.Instance createInstanceGameSettings(GameInstanceID instanceId) { - if (!hasInstance(instanceId)) { - return null; - } - if (readOnlyInstanceGameSettings.contains(instanceId)) { + /// Returns instance-local settings for a registered instance ID, creating empty settings when + /// the settings file is absent and writable. + /// + /// 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. @@ -537,31 +493,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. @@ -576,294 +516,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); - } + ensureIsolatedRunningDirectory(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(); - } - - 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); - } - - if (setting.isBackupOnNextSave()) { - setting.setBackupOnNextSave(false); - SettingFileUtils.backupInvalidConfig(file); - } - FileSaver.save(file, LauncherSettings.SETTINGS_GSON.toJson(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 java, 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); - boolean highPerformanceGPU = vs.getInheritable(GameSettings::highPerformanceProperty); - GameVersionNumber gameVersionNumber = GameVersionNumber.asGameVersion(getGameVersion(instanceId)); - - @Nullable Integer maxMemory; - if (autoMemory) { - maxMemory = noJVMOptions - ? null - : Math.toIntExact(getAutoAllocatedMemory( - SystemInfo.getPhysicalMemoryStatus().available(), - java.getPlatform() - ) / 1024L / 1024L); - } else { - maxMemory = vs.getMaxMemory(); - } - - LaunchOptions.Builder builder = new LaunchOptions.Builder() - .setInstanceId(instanceId) - .setGameDir(gameDir) - .setJava(java) - .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)) - .setUseHighPerformanceGPU(vs.getInheritable(GameSettings::highPerformanceProperty)) - .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); - } + LOG.warning("Failed to save migrated instance game settings for " + instanceId, 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"); + return migrationResult.setting(); } - 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; @@ -888,8 +584,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; } } @@ -918,34 +614,14 @@ public static long getAutoAllocatedMemory(long available, Platform platform) { : 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); - } - } - }; + /// Records settings prepared for an instance that has not entered a repository draft yet. + /// + /// @param settings the settings to materialize after the draft claims the root + /// @param instanceRoot the normalized root reserved for the instance + /// @param rootWasAbsent whether the root was absent when the reservation was made + private record PreparedInstanceSettings( + GameSettings.Instance settings, + Path instanceRoot, + boolean rootWasAbsent) { } } 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..94ac7d9c083 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepositorySnapshot.java @@ -0,0 +1,55 @@ +/* + * 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()); + } + + @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..9f580f1819e 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,7 @@ import com.google.gson.JsonParseException; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; +import org.jackhuang.hmcl.download.GameBuilder; import org.jackhuang.hmcl.modpack.MinecraftInstanceTask; import org.jackhuang.hmcl.modpack.Modpack; import org.jackhuang.hmcl.modpack.ModpackConfiguration; @@ -51,12 +51,12 @@ 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"); - dependents.add(dependency.newGameBuilder().name(this.instanceId).gameVersion(modpack.getGameVersion()).buildAsync()); + dependents.add(dependency.newGameBuilder().id(this.instanceId).component(GameComponentType.GAME, modpack.getGameVersion()).buildAsync()); onDone().register(event -> { if (event.isFailed()) repository.removeInstanceFromDisk(this.instanceId); @@ -73,7 +73,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 @@ -86,20 +86,32 @@ public List> getDependents() { return dependents; } + /// {@inheritDoc} @Override 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); - 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())) - continue; - libraryTask = libraryTask.thenComposeAsync(version -> dependency.installLibraryAsync(modpack.getGameVersion(), version, mark.getLibraryId(), mark.getLibraryVersion())); - } + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(originalManifest, null); - dependencies.add(libraryTask.thenComposeAsync(repository::saveAsync)); + dependencies.add(repository.updateInstanceAsync(instanceId, publishedInstance -> { + Task libraryTask = Task.supplyAsync(() -> originalManifest); + // Forge and OptiFine libraries must be regenerated by their installers. + for (GameComponentAnalyzer.Mark mark : analyzer) { + if (mark.componentType() == GameComponentType.GAME) { + continue; + } + String componentVersion = mark.version(); + if (componentVersion == null) { + continue; + } + libraryTask = libraryTask.thenComposeAsync(manifest -> dependency.installComponentAsync( + publishedInstance, + manifest, + modpack.getGameVersion(), + mark.componentType(), + componentVersion)); + } + return libraryTask; + })); } } 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..8ed284d6b93 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,6 @@ 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.game.*; import org.jackhuang.hmcl.java.JavaManager; import org.jackhuang.hmcl.java.JavaRuntime; @@ -84,9 +82,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 +91,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 +138,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 +149,56 @@ 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); + // Resolve already deduplicated libraries; apply loader-specific argument repairs for this launch. + var launchManifest = new AtomicReference<>(LaunchManifestNormalizer.repairForLaunch( + gameInstance.getResolvedManifest().launchManifest())); + 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, launchManifest.get()) .thenComposeAsync(java -> { javaVersionRef.set(Objects.requireNonNull(java)); - version.set(NativePatcher.patchNative(repository, version.get(), gameVersion.orElse(null), java, setting, javaArguments)); + launchManifest.set(NativePatcher.patchNative(gameInstance, launchManifest.get(), java, setting, javaArguments)); if (setting.getInheritable(GameSettings::notCheckGameProperty)) return null; return Task.allOf( - dependencyManager.checkGameCompletionAsync(version.get(), integrityCheck), + dependencyManager.checkGameCompletionAsync(gameInstance, launchManifest.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(gameInstance.getVersion()) 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); + Path file = gameInstance.getLayout().getLibraryFile(gameInstance.getId(), lib); if (file.toAbsolutePath().toString().indexOf('=') >= 0) { LOG.warning("Invalid character '=' in the libraries directory path, unable to attach software renderer loader"); return null; @@ -194,7 +206,7 @@ private void launch0() { String agent = FileUtils.getAbsolutePath(file) + "=" + renderer.mesaDriverName(); - if (GameLibrariesTask.shouldDownloadLibrary(repository, version.get(), lib, integrityCheck)) { + if (GameLibrariesTask.shouldDownloadLibrary(repository, launchManifest.get(), lib, integrityCheck)) { return new LibraryDownloadTask(dependencyManager, file, lib) .thenRunAsync(() -> javaAgents.add(agent)); } else { @@ -204,18 +216,13 @@ private void launch0() { }) ); }).withStage("launch.state.dependencies") - .thenComposeAsync(() -> { - if (gameVersion.isEmpty()) { - return null; - } - return new GameVerificationFixTask(dependencyManager, gameVersion.get(), version.get()); - }) + .thenComposeAsync(() -> new GameVerificationFixTask(gameInstance, gameInstance.getVersion(), launchManifest.get())) .thenComposeAsync(() -> { if (setting.getInheritable(GameSettings::allowAutoAgentProperty) || setting.getInheritable(GameSettings::noJVMOptionsProperty) || setting.getInheritable(GameSettings::noOptimizingJVMOptionsProperty) || Boolean.TRUE.equals(state().getShownTips().get(LWJGL_3_4_1_TIP)) - || !NativePatcher.needPatchMemoryUtil(version.get(), javaVersionRef.get().getParsedVersion())) { + || !NativePatcher.needPatchMemoryUtil(launchManifest.get(), javaVersionRef.get().getParsedVersion())) { return Task.completed(null); } else { CompletableFuture future = new CompletableFuture<>(); @@ -234,8 +241,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 +282,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, - version.get(), + gameInstance, + launchManifest.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(authInfo, launchOptions, launchingLatch, gameInstance.getVersion().compareTo(GameVersionNumber.unknown()) != 0) ); }).thenComposeAsync(launcher -> { // launcher is prev task's result if (scriptFile == null) { @@ -348,8 +355,7 @@ public void onStop(boolean success, TaskExecutor executor) { message = i18n("launch.failed.decompressing_natives") + "\n" + ex.getLocalizedMessage(); } else if (ex instanceof LibraryDownloadException) { message = i18n("launch.failed.download_library", ((LibraryDownloadException) ex).getLibrary().name()) + "\n"; - if (ex.getCause() instanceof ResponseCodeException) { - ResponseCodeException rce = (ResponseCodeException) ex.getCause(); + if (ex.getCause() instanceof ResponseCodeException rce) { int responseCode = rce.getResponseCode(); String uri = rce.getUri(); if (responseCode == 404) @@ -363,8 +369,7 @@ public void onStop(boolean success, TaskExecutor executor) { URI uri = ((DownloadException) ex).getUri(); if (ex.getCause() instanceof SocketTimeoutException) { message = i18n("install.failed.downloading.timeout", uri); - } else if (ex.getCause() instanceof ResponseCodeException) { - ResponseCodeException responseCodeException = (ResponseCodeException) ex.getCause(); + } else if (ex.getCause() instanceof ResponseCodeException responseCodeException) { if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) { message = i18n("download.code." + responseCodeException.getResponseCode(), uri); } else { @@ -379,8 +384,7 @@ public void onStop(boolean success, TaskExecutor executor) { message = i18n("account.failed.injector_download_failure"); } else if (ex instanceof CharacterDeletedException) { message = i18n("account.failed.character_deleted"); - } else if (ex instanceof ResponseCodeException) { - ResponseCodeException rce = (ResponseCodeException) ex; + } else if (ex instanceof ResponseCodeException rce) { int responseCode = rce.getResponseCode(); String uri = rce.getUri(); if (responseCode == 404) @@ -423,9 +427,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()); + GameVersionNumber gameVersion = gameInstance.getVersion(); Task getJavaTask = Task.supplyAsync(() -> { try { @@ -456,9 +460,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 +484,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 +495,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 +558,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 +571,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 +641,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 +654,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 +684,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); @@ -829,16 +832,12 @@ private void enableAutoAgentForCurrentSetting() { } } - /** - * The managed process listener. - * Guarantee that one [JavaProcess], one [HMCLProcessListener]. - * Because every time we launched a game, we generates a new [HMCLProcessListener] - */ + /// The managed process listener. + /// Guarantee that one Java [Process], one [HMCLProcessListener]. + /// Because every time we launched a game, we generates a new [HMCLProcessListener] private final class HMCLProcessListener implements ProcessListener { private final ReentrantLock lock = new ReentrantLock(); - private final HMCLGameRepository repository; - private final GameInstanceManifest manifest; private final LaunchOptions launchOptions; private ManagedProcess process; private volatile boolean lwjgl; @@ -850,9 +849,7 @@ private final class HMCLProcessListener implements ProcessListener { private Thread submitLogThread; private LinkedBlockingQueue logBuffer; - public HMCLProcessListener(HMCLGameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions launchOptions, CountDownLatch launchingLatch, boolean detectWindow) { - this.repository = repository; - this.manifest = manifest; + public HMCLProcessListener(AuthInfo authInfo, LaunchOptions launchOptions, CountDownLatch launchingLatch, boolean detectWindow) { this.launchOptions = launchOptions; this.launchingLatch = launchingLatch; this.detectWindow = detectWindow; @@ -1037,8 +1034,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 47ea469b3ce..8eed247dcbd 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java @@ -17,14 +17,18 @@ */ package org.jackhuang.hmcl.game; +import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.IOUtils; import org.jackhuang.hmcl.util.io.Zipper; import org.jackhuang.hmcl.util.logging.Logger; import org.jackhuang.hmcl.util.platform.OperatingSystem; +import org.jetbrains.annotations.Nullable; import java.io.BufferedReader; import java.io.IOException; +import java.io.OutputStreamWriter; import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; @@ -41,22 +45,24 @@ private LogExporter() { } public static CompletableFuture exportLogs( - Path zipFile, DefaultGameRepository repository, GameInstanceID instanceId, String logs, String launchScript, + Path zipFile, DefaultGameInstance instance, LaunchOptions options, String logs, String launchScript, PathMatcher logMatcher) { - Path runDirectory = repository.getRunDirectory(instanceId); - Path baseDirectory = repository.getBaseDirectory(); + DefaultGameRepositorySnapshot repositorySnapshot = instance.getSnapshot(); + Path runDirectory = options.getGameDir(); List instances = new ArrayList<>(); - GameInstanceID currentInstanceId = instanceId; + GameInstanceID currentInstanceId = instance.id; HashSet resolvedSoFar = new HashSet<>(); while (true) { if (resolvedSoFar.contains(currentInstanceId)) break; resolvedSoFar.add(currentInstanceId); - GameInstanceManifest currentVersion = repository.getInstanceManifest(currentInstanceId); + @Nullable DefaultGameInstance currentInstance = repositorySnapshot.get(currentInstanceId); + if (currentInstance == null) + break; instances.add(currentInstanceId); - if (currentVersion.inheritsFrom() != null) { - currentInstanceId = currentVersion.inheritsFrom(); + if (currentInstance.getManifest().inheritsFrom() != null) { + currentInstanceId = currentInstance.getManifest().inheritsFrom(); } else { break; } @@ -74,9 +80,11 @@ public static CompletableFuture exportLogs( zipper.putTextFile(Logger.filterForbiddenToken(launchScript), OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? "launch.bat" : "launch.sh"); for (GameInstanceID id : instances) { - Path instanceJson = repository.getInstanceJson(id); - if (Files.exists(instanceJson)) { - zipper.putFile(instanceJson, id + ".json"); + @Nullable DefaultGameInstance currentInstance = repositorySnapshot.get(id); + if (currentInstance != null) { + try (var writer = new OutputStreamWriter(zipper.putStream(id + ".json"), StandardCharsets.UTF_8)) { + JsonUtils.GSON.toJson(currentInstance.getManifest(), writer); + } } } } catch (IOException e) { 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..402e67daeb9 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; 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 ebee0ade961..df0a9565f69 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; @@ -64,6 +70,8 @@ import java.io.IOException; import java.net.URI; +import java.nio.charset.Charset; +import java.nio.file.Files; import java.nio.file.Path; import java.time.LocalDate; import java.util.List; @@ -371,6 +379,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) { @@ -554,7 +598,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 5037480bfe6..d1f3108c960 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java @@ -34,7 +34,6 @@ import javafx.scene.text.TextFlow; import javafx.stage.Stage; 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,15 @@ 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 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 +88,14 @@ 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)); memory = Optional.ofNullable(launchOptions.getMaxMemory()).map(i -> i + " " + i18n("settings.memory.unit.mib")).orElse("-"); @@ -142,7 +137,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 +287,7 @@ private CompletableFuture exportGameCrashInfo() { } }); - return LogExporter.exportLogs(logFile, repository, launchOptions.getInstanceId(), logs, + return LogExporter.exportLogs(logFile, gameInstance, launchOptions, logs, new CommandBuilder().addAll(managedProcess.getCommands()).toString(), path -> { try { @@ -342,10 +338,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 +368,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 +376,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 : gameInstance.getAnalyzer()) { + 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..18fd42bcf4d 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() { @@ -149,7 +129,7 @@ protected Skin createDefaultSkin() { public final static class InstallerItemGroup { private final InstallerItem game; - private final InstallerItem[] libraries; + private final InstallerItem[] components; private Set getIncompatibles(Map> incompatibleMap, InstallerItem item) { return incompatibleMap.computeIfAbsent(item, it -> new HashSet<>()); @@ -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,22 +215,19 @@ 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")) { - this.libraries = new InstallerItem[]{game, forge, cleanroom, liteLoader, legacyfabric, legacyfabricApi, optiFine}; - } else if (GameVersionNumber.compare(gameVersion, "1.13.2") <= 0) { - this.libraries = new InstallerItem[]{game, forge, liteLoader, optiFine, legacyfabric, legacyfabricApi}; + this.components = all; + } else if (gameVersion.compareTo("1.12.2") == 0) { + this.components = new InstallerItem[]{game, forge, cleanroom, liteLoader, legacyfabric, legacyfabricApi, optiFine}; + } else if (gameVersion.compareTo("1.13.2") <= 0) { + this.components = new InstallerItem[]{game, forge, liteLoader, optiFine, legacyfabric, legacyfabricApi}; } else { - this.libraries = new InstallerItem[]{game, forge, neoForge, optiFine, fabric, fabricApi, quilt, quiltApi}; + this.components = new InstallerItem[]{game, forge, neoForge, optiFine, fabric, fabricApi, quilt, quiltApi}; } } @@ -258,8 +235,8 @@ public InstallerItem getGame() { return game; } - public InstallerItem[] getLibraries() { - return libraries; + public InstallerItem[] getComponents() { + return components; } } @@ -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..ddaccb0cb20 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,37 +59,37 @@ 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; - library.setOnInstall(() -> { + for (InstallerItem component : group.getComponents()) { + GameComponentType type = component.getComponentType(); + if (type == GameComponentType.GAME) continue; + component.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()); } - if (!(library.resolvedStateProperty().get() instanceof InstallerItem.IncompatibleState)) + if (!(component.resolvedStateProperty().get() instanceof InstallerItem.IncompatibleState)) 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, () -> controller.onPrev(false, Navigation.NavigationDirection.PREVIOUS) ), Navigation.NavigationDirection.NEXT ); }); - library.setOnRemove(() -> { - controller.getSettings().remove(libraryId); + component.setOnRemove(() -> { + controller.getSettings().remove(type.getPatchId()); reload(); }); } @@ -166,16 +167,16 @@ protected InstallersPageSkin(AbstractInstallersPage control) { } { - InstallerItem[] libraries = control.group.getLibraries(); + InstallerItem[] components = control.group.getComponents(); - FlowPane libraryPane = new FlowPane(16, 16, libraries); + FlowPane libraryPane = new FlowPane(16, 16, components); ScrollPane scrollPane = new ScrollPane(libraryPane); scrollPane.setFitToWidth(true); scrollPane.setFitToHeight(true); BorderPane.setMargin(scrollPane, new Insets(16, 0, 16, 0)); root.setCenter(scrollPane); - if (libraries.length <= 8) + if (components.length <= 8) scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); } 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..73a7d645369 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,10 +21,8 @@ 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.GameInstanceManifest; -import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.ui.InstallerItem; import org.jackhuang.hmcl.ui.wizard.WizardController; import org.jackhuang.hmcl.util.Lang; @@ -32,29 +30,29 @@ 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 { protected final BooleanProperty compatible = new SimpleBooleanProperty(); - protected final HMCLGameRepository repository; protected final String gameVersion; protected final GameInstanceManifest manifest; + protected final HMCLGameInstance instance; - public AdditionalInstallersPage(String gameVersion, GameInstanceManifest manifest, WizardController controller, HMCLGameRepository repository, DownloadProvider downloadProvider) { + public AdditionalInstallersPage(HMCLGameInstance instance, String gameVersion, WizardController controller, DownloadProvider downloadProvider) { super(controller, gameVersion, downloadProvider); + this.instance = instance; this.gameVersion = gameVersion; - this.manifest = manifest; - this.repository = repository; + this.manifest = instance.getManifest(); - txtName.setText(manifest.id().toString()); + txtName.setText(instance.getId().id()); txtName.setEditable(false); - for (InstallerItem library : group.getLibraries()) { - String libraryId = library.getLibraryId(); - if (libraryId.equals("game")) continue; - library.setOnRemove(() -> { - controller.getSettings().put(libraryId, new UpdateInstallerWizardProvider.RemoveVersionAction(libraryId)); + for (InstallerItem component : group.getComponents()) { + if (component.getComponentType() == GameComponentType.GAME) continue; + component.setOnRemove(() -> { + controller.getSettings().put( + component.getComponentType().getPatchId(), + new UpdateInstallerWizardProvider.RemoveVersionAction(component.getComponentType())); reload(); }); } @@ -72,35 +70,31 @@ public String getTitle() { return i18n("settings.tabs.installers"); } - private String getVersion(String id) { - return Optional.ofNullable(controller.getSettings().get(id)) + private String getVersion(GameComponentType type) { + return Optional.ofNullable(controller.getSettings().get(type.getPatchId())) .flatMap(it -> Lang.tryCast(it, RemoteVersion.class)) .map(RemoteVersion::getSelfVersion).orElse(null); } @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); - String currentGameVersion = Lang.nonNull(getVersion("game"), game); - + boolean gameVersionChanged = !instance.getVersion().toString().equals(getVersion(GameComponentType.GAME)); boolean compatible = true; - for (InstallerItem library : group.getLibraries()) { - String libraryId = library.getLibraryId(); - String version = analyzer.getVersion(libraryId).orElse(null); - 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) { + for (InstallerItem component : group.getComponents()) { + GameComponentType componentType = component.getComponentType(); + String version = instance.getComponentVersion(component.getComponentType()); + String libraryVersion = Lang.requireNonNullElse(getVersion(componentType), version); + boolean alreadyInstalled = version != null && !(controller.getSettings().get(componentType.getPatchId()) instanceof UpdateInstallerWizardProvider.RemoveVersionAction); + if (component.getComponentType() != GameComponentType.GAME && gameVersionChanged && getVersion(componentType) == 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)); + component.versionProperty().set(new InstallerItem.InstalledState(libraryVersion, false, true)); compatible = false; - } else if (alreadyInstalled || getVersion(libraryId) != null) { - library.versionProperty().set(new InstallerItem.InstalledState(libraryVersion, false, false)); + } else if (alreadyInstalled || getVersion(componentType) != null) { + component.versionProperty().set(new InstallerItem.InstalledState(libraryVersion, false, false)); } else { - library.versionProperty().set(null); + component.versionProperty().set(null); } } 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 ef5a1116210..7dd4eb2ac89 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; @@ -44,7 +46,6 @@ import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage; import org.jackhuang.hmcl.ui.decorator.DecoratorPage; import org.jackhuang.hmcl.ui.instances.DownloadListPage; -import org.jackhuang.hmcl.ui.instances.GameInstancePage; import org.jackhuang.hmcl.ui.instances.HMCLLocalizedDownloadListPage; import org.jackhuang.hmcl.ui.instances.Instances; import org.jackhuang.hmcl.ui.wizard.Navigation; @@ -95,7 +96,7 @@ public DownloadPage() { public DownloadPage(GameInstanceID uploadInstance) { newGameTab.setNodeSupplier(loadVersionFor(() -> new VersionsPage(versionPageNavigator, i18n("install.installer.choose", i18n("install.installer.game")), "", DownloadProviders.getDownloadProvider(), - "game", versionPageNavigator::onGameSelected))); + GameComponentType.GAME, versionPageNavigator::onGameSelected))); modpackTab.setNodeSupplier(loadVersionFor(() -> { DownloadListPage page = HMCLLocalizedDownloadListPage.ofModPack((downloadProvider, repository, __, modpack, file) -> { Instances.downloadModpackImpl(downloadProvider, repository, uploadInstance, modpack, file); @@ -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; @@ -189,19 +189,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)); } })); } @@ -303,27 +303,27 @@ 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) { GameBuilder builder = dependencyManager.newGameBuilder(); GameInstanceID instanceId = settings.get(AbstractInstallersPage.INSTANCE_ID); - builder.name(instanceId); - builder.gameVersion(((RemoteVersion) settings.get(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId())).getGameVersion()); + builder.id(instanceId); + builder.component(GameComponentType.GAME, ((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); + builder.component(remoteVersion); }); 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..2dcd0201277 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; @@ -60,12 +60,12 @@ private String getVersion(String id) { } protected void reload() { - for (InstallerItem library : group.getLibraries()) { - String libraryId = library.getLibraryId(); + for (InstallerItem component : group.getComponents()) { + String libraryId = component.getComponentType().getPatchId(); if (controller.getSettings().containsKey(libraryId)) { - library.versionProperty().set(new InstallerItem.InstalledState(getVersion(libraryId), false, false)); + component.versionProperty().set(new InstallerItem.InstalledState(getVersion(libraryId), false, false)); } else { - library.versionProperty().set(null); + component.versionProperty().set(null); } } if (!isNameModifiedByUser) { @@ -115,30 +115,25 @@ protected void onInstall() { 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)) { + for (InstallerItem component : group.getComponents()) { + if (component.getComponentType() == GameComponentType.GAME + || !controller.getSettings().containsKey(component.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 (component.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..53a29094c03 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 GameComponentType componentType; 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; - this.libraryId = libraryId; + public UpdateInstallerWizardProvider(@NotNull HMCLGameInstance gameInstance, @NotNull GameComponentType componentType, @Nullable String oldLibraryVersion) { + this.gameInstance = gameInstance; + this.componentType = componentType; this.oldLibraryVersion = oldLibraryVersion; this.downloadProvider = DownloadProviders.getDownloadProvider(); - this.dependencyManager = repository.getDependency(downloadProvider); + this.dependencyManager = gameInstance.getRepository().getDependency(downloadProvider); } @Override @@ -74,38 +71,47 @@ public Object finish(SettingsMap settings) { settings.put("success_message", i18n("install.success")); settings.put(FailureCallback.KEY, (settings1, exception, next) -> alertFailureMessage(exception, next)); - // We remove library but not save it, - // so if installation failed will not break down current version. - Task ret = Task.supplyAsync(() -> manifest); var hints = new ArrayList(); for (Object value : settings.asStringMap().values()) { if (value instanceof RemoteVersion remoteVersion) { - ret = ret.thenComposeAsync(version -> dependencyManager.installLibraryAsync(version, remoteVersion)); - hints.add(new Task.StagesHint(String.format("hmcl.install.%s:%s", remoteVersion.getLibraryId(), remoteVersion.getSelfVersion()))); - if ("game".equals(remoteVersion.getLibraryId())) { + hints.add(new Task.StagesHint(String.format("hmcl.install.%s:%s", remoteVersion.getComponentType().getPatchId(), remoteVersion.getSelfVersion()))); + if (remoteVersion.getComponentType() == GameComponentType.GAME) { hints.add(new Task.StagesHint("hmcl.install.libraries")); hints.add(new Task.StagesHint("hmcl.install.assets")); } - } else if (value instanceof RemoveVersionAction removeVersionAction) { - ret = ret.thenComposeAsync(version -> dependencyManager.removeLibraryAsync(version, removeVersionAction.libraryId)); } } - return ret.thenComposeAsync(repository::saveAsync).thenComposeAsync(repository.refreshAsync()).withStagesHints(hints); + return gameInstance.getRepository().updateInstanceAsync(gameInstance.getId(), publishedInstance -> { + Task update = Task.supplyAsync(publishedInstance::getManifest); + for (Object value : settings.asStringMap().values()) { + if (value instanceof RemoteVersion remoteVersion) { + update = update.thenComposeAsync(manifest -> + dependencyManager.installComponentAsync(publishedInstance, manifest, remoteVersion)); + } else if (value instanceof RemoveVersionAction removeVersionAction) { + update = update.thenComposeAsync(manifest -> + dependencyManager.removeComponentAsync( + publishedInstance, + manifest, + removeVersionAction.componentType)); + } + } + return update; + }).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." + componentType)), gameInstance.getVersion().toString(), downloadProvider, componentType, () -> { 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)); + } else if (componentType == GameComponentType.GAME) { + String newGameVersion = ((RemoteVersion) settings.get(componentType.getPatchId())).getSelfVersion(); + controller.onNext(new AdditionalInstallersPage(gameInstance, newGameVersion, controller, downloadProvider)); } else { - Controllers.confirm(i18n("install.change_version.confirm", i18n("install.installer." + libraryId), oldLibraryVersion, ((RemoteVersion) settings.get(libraryId)).getSelfVersion()), + Controllers.confirm(i18n("install.change_version.confirm", i18n("install.installer." + componentType), oldLibraryVersion, ((RemoteVersion) settings.get(componentType.getPatchId())).getSelfVersion()), i18n("install.change_version"), controller::onFinish, controller::onCancel); } }); @@ -129,8 +135,7 @@ public boolean cancelIfCannotGoBack() { public static void alertFailureMessage(Exception exception, Runnable next) { if (exception instanceof LibraryDownloadException) { String message = i18n("launch.failed.download_library", ((LibraryDownloadException) exception).getLibrary().name()) + "\n"; - if (exception.getCause() instanceof ResponseCodeException) { - ResponseCodeException rce = (ResponseCodeException) exception.getCause(); + if (exception.getCause() instanceof ResponseCodeException rce) { int responseCode = rce.getResponseCode(); String uri = rce.getUri(); if (responseCode == 404) @@ -145,8 +150,7 @@ public static void alertFailureMessage(Exception exception, Runnable next) { URI uri = ((DownloadException) exception).getUri(); if (exception.getCause() instanceof SocketTimeoutException) { Controllers.dialog(i18n("install.failed.downloading.timeout", uri), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next); - } else if (exception.getCause() instanceof ResponseCodeException) { - ResponseCodeException responseCodeException = (ResponseCodeException) exception.getCause(); + } else if (exception.getCause() instanceof ResponseCodeException responseCodeException) { if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) { Controllers.dialog(i18n("download.code." + responseCodeException.getResponseCode(), uri), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next); } else { @@ -155,14 +159,12 @@ public static void alertFailureMessage(Exception exception, Runnable next) { } else { Controllers.dialog(i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause()), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next); } - } else if (exception instanceof UnsupportedInstallationException) { - switch (((UnsupportedInstallationException) exception).getReason()) { - case UnsupportedInstallationException.FORGE_1_17_OPTIFINE_H1_PRE2: - Controllers.dialog(i18n("install.failed.optifine_forge_1.17"), i18n("install.failed"), MessageDialogPane.MessageType.ERROR, next); - break; - default: - Controllers.dialog(i18n("install.failed.optifine_conflict"), i18n("install.failed"), MessageDialogPane.MessageType.ERROR, next); - break; + } else if (exception instanceof UnsupportedInstallationException unsupportedInstallationException) { + switch (unsupportedInstallationException.getReason()) { + case UnsupportedInstallationException.FORGE_1_17_OPTIFINE_H1_PRE2 -> + Controllers.dialog(i18n("install.failed.optifine_forge_1.17"), i18n("install.failed"), MessageDialogPane.MessageType.ERROR, next); + default -> + Controllers.dialog(i18n("install.failed.optifine_conflict"), i18n("install.failed"), MessageDialogPane.MessageType.ERROR, next); } } else if (exception instanceof DefaultDependencyManager.UnsupportedLibraryInstallerException) { Controllers.dialog(i18n("install.failed.install_online"), i18n("install.failed"), MessageDialogPane.MessageType.ERROR, next); @@ -170,8 +172,7 @@ public static void alertFailureMessage(Exception exception, Runnable next) { Controllers.dialog(i18n("install.failed.malformed"), i18n("install.failed"), MessageDialogPane.MessageType.ERROR, next); } else if (exception instanceof GameAssetIndexDownloadTask.GameAssetIndexMalformedException) { Controllers.dialog(i18n("assets.index.malformed"), i18n("install.failed"), MessageDialogPane.MessageType.ERROR, next); - } else if (exception instanceof VersionMismatchException) { - VersionMismatchException e = ((VersionMismatchException) exception); + } else if (exception instanceof VersionMismatchException e) { Controllers.dialog(i18n("install.failed.version_mismatch", e.getExpect(), e.getActual()), i18n("install.failed"), MessageDialogPane.MessageType.ERROR, next); } else if (exception instanceof CancellationException) { // Ignore cancel @@ -181,10 +182,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/download/VersionsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/VersionsPage.java index 69a79bb7fc9..82218e8dac5 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/VersionsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/VersionsPage.java @@ -45,6 +45,7 @@ import org.jackhuang.hmcl.download.optifine.OptiFineRemoteVersion; import org.jackhuang.hmcl.download.quilt.QuiltAPIRemoteVersion; import org.jackhuang.hmcl.download.quilt.QuiltRemoteVersion; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.setting.GameInstanceIconType; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; @@ -77,7 +78,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshable { private final String gameVersion; - private final String libraryId; + private final GameComponentType componentType; private final String title; private final Navigation navigation; private final DownloadProvider downloadProvider; @@ -90,14 +91,14 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab public VersionsPage(Navigation navigation, String title, String gameVersion, DownloadProvider downloadProvider, - String libraryId, + GameComponentType componentType, Runnable callback) { this.title = title; this.gameVersion = gameVersion; - this.libraryId = libraryId; + this.componentType = componentType; this.navigation = navigation; this.downloadProvider = downloadProvider; - this.versionList = downloadProvider.getVersionListById(libraryId); + this.versionList = downloadProvider.getVersionList(componentType); this.callback = callback; refresh(); @@ -173,7 +174,7 @@ private static class RemoteVersionListCell extends ListCell { HBox actions = new HBox(8); actions.setAlignment(Pos.CENTER); { - if ("game".equals(control.libraryId)) { + if (control.componentType == GameComponentType.GAME) { JFXButton wikiButton = newToggleButton4(SVG.GLOBE_BOOK); wikiButton.setOnAction(event -> onOpenWiki()); FXUtils.installFastTooltip(wikiButton, i18n("wiki.tooltip")); @@ -199,7 +200,7 @@ private void onAction() { if (item == null) return; - control.navigation.getSettings().put(control.libraryId, item); + control.navigation.getSettings().put(control.componentType.getPatchId(), item); control.callback.run(); } @@ -339,7 +340,7 @@ private static final class VersionsPageSkin extends SkinBase { nameField.setPromptText(i18n("instance.search.prompt")); nameField.textProperty().addListener(o -> updateList()); - if ("game".equals(control.libraryId)) { + if (control.componentType == GameComponentType.GAME) { categoryField.getItems().setAll( VersionTypeFilter.ALL, VersionTypeFilter.RELEASE, 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 1fc2429b04d..b760183977e 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(); @@ -791,19 +799,22 @@ public GameSettingsPage(Class settingType) { graphicsSettings.getContent().add(highPerformancePane); highPerformancePane.setTitle(i18n("settings.advanced.renderer.gpu_preferences")); - 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(); @@ -1859,16 +1870,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. @@ -2588,8 +2603,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); @@ -2628,26 +2644,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(), @@ -2656,11 +2667,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 @@ -2700,13 +2706,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, ""); }); } @@ -2720,11 +2726,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. @@ -2788,17 +2795,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; } @@ -2810,13 +2819,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 @@ -2836,19 +2842,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 f9ad6b1a11f..1fa66ebad93 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 29e10fd0868..8f32d07cc4d 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; @@ -69,14 +66,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; @@ -130,7 +127,7 @@ public RemoteAddon getAddon() { return addon; } - public HMCLGameRepository.InstanceReference getInstanceReference() { + public HMCLGameInstance.Optional getInstanceOptional() { return instanceReference; } @@ -271,15 +268,14 @@ protected DownloadPageSkin(DownloadPage control) { FXUtils.onChangeAndOperate(control.loaded, loaded -> { if (control.versions == null) return; - if (control.instanceReference.repository() != null && control.instanceReference.instanceId() != null) { - HMCLGameRepository repository = control.instanceReference.repository(); - GameInstanceManifest.Resolved resolvedManifest = repository.getResolvedInstanceManifest(control.instanceReference.instanceId()); - String gameVersion = repository.getGameVersion(resolvedManifest.unresolved()).orElse(null); - - if (gameVersion != null && control.versions.containsKey(gameVersion)) { + @Nullable HMCLGameInstance instance = control.instanceReference.instance(); + if (instance != null) { + String gameVersion = instance.getVersion().toString(); + if (!GameVersionNumber.unknown().equals(instance.getVersion()) + && control.versions.containsKey(gameVersion)) { List addonVersions = control.versions.get(gameVersion); if (addonVersions != null && !addonVersions.isEmpty()) { - Set targetLoaders = LibraryAnalyzer.analyze(resolvedManifest, gameVersion).getModLoaders(); + Set targetLoaders = instance.getModLoaders(); resolve: for (RemoteAddon.Version addonVersion : addonVersions) { @@ -375,7 +371,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..9547350c204 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 = gameInstance.getAnalyzer(); + 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 54b881667a0..764c22ed714 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 e75758b1421..74b6d82aea2 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 @@ -34,9 +34,7 @@ import javafx.scene.layout.Region; import javafx.scene.layout.StackPane; import javafx.stage.WindowEvent; -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; @@ -47,6 +45,8 @@ 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 { @@ -63,20 +63,34 @@ public static boolean hideShowing(Node owner) { } /// 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); owner.getProperties().put(KEY, popup); @@ -154,7 +168,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..ff38c42dc6f 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,75 @@ 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; + public void loadInstance(HMCLGameInstance.Optional instance) { + this.gameInstance = instance.instance(); + if (gameInstance == null) { + itemsProperty().clear(); + return; + } - CompletableFuture.supplyAsync(() -> { - gameVersion = repository.getGameVersion(manifest).orElse(null); + HMCLGameRepository repository = gameInstance.getRepository(); - return LibraryAnalyzer.analyze(repository.getResolvedInstanceManifest(instanceId), gameVersion); - }).thenAcceptAsync(analyzer -> { - itemsProperty().clear(); + itemsProperty().clear(); + InstallerItem.InstallerItemGroup group = new InstallerItem.InstallerItemGroup(gameInstance.getVersion(), InstallerItem.Style.LIST_ITEM); - InstallerItem.InstallerItemGroup group = new InstallerItem.InstallerItemGroup(gameVersion, InstallerItem.Style.LIST_ITEM); + // Conventional libraries: game, fabric, legacyfabric, forge, cleanroom, neoforge, liteloader, optifine + for (InstallerItem component : group.getComponents()) { - // Conventional libraries: game, fabric, legacyfabric, forge, cleanroom, neoforge, liteloader, optifine - for (InstallerItem item : group.getLibraries()) { - String libraryId = item.getLibraryId(); + // Skip fabric-api and quilt-api and legacyfabric-api + if (component.getComponentType().getPatchId().endsWith("-api")) { + continue; + } - // Skip fabric-api and quilt-api and legacyfabric-api - if (libraryId.endsWith("-api")) { - continue; - } + @Nullable String libraryVersion = gameInstance.getComponentVersion(component.getComponentType()); - String libraryVersion = analyzer.getVersion(libraryId).orElse(null); + if (libraryVersion != null) { + component.versionProperty().set(new InstallerItem.InstalledState( + libraryVersion, + !gameInstance.getAnalyzer().isClear(component.getComponentType()), + false + )); + } else { + component.versionProperty().set(null); + } - if (libraryVersion != null) { - item.versionProperty().set(new InstallerItem.InstalledState( - libraryVersion, - analyzer.getLibraryStatus(libraryId) != LibraryAnalyzer.LibraryMark.LibraryStatus.CLEAR, - false - )); - } else { - item.versionProperty().set(null); - } + component.setOnInstall(() -> { + Controllers.getDecorator().startWizard(new UpdateInstallerWizardProvider(gameInstance, component.getComponentType(), libraryVersion)); + }); - item.setOnInstall(() -> { - Controllers.getDecorator().startWizard(new UpdateInstallerWizardProvider(repository, gameVersion, manifest, libraryId, libraryVersion)); - }); + component.setOnRemove(() -> repository.updateInstanceAsync( + gameInstance.getId(), + publishedInstance -> repository.getDependency().removeComponentAsync( + publishedInstance, + component.getComponentType())) + .withRunAsync(Schedulers.javafx(), this::reloadCurrentInstance) + .start()); - item.setOnRemove(() -> repository.getDependency().removeLibraryAsync(manifest, libraryId) - .thenComposeAsync(repository::saveAsync) - .withComposeAsync(repository.refreshAsync()) - .withRunAsync(Schedulers.javafx(), () -> loadInstance(this.repository, this.instanceId)) - .start()); + itemsProperty().add(component); + } - itemsProperty().add(item); - } + // other third-party libraries which are unable to manage. + for (GameComponentAnalyzer.Mark mark : gameInstance.getAnalyzer()) { + // 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.updateInstanceAsync( + gameInstance.getId(), + publishedInstance -> repository.getDependency().removeComponentAsync( + publishedInstance, + mark.componentType())) + .withRunAsync(Schedulers.javafx(), this::reloadCurrentInstance) + .start()); + + itemsProperty().add(installerItem); + } + } - // 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); + private void reloadCurrentInstance() { + if (gameInstance != null) { + loadInstance(HMCLGameInstance.Optional.of(gameInstance.getRepository(), gameInstance.getId())); + } } public void installOffline() { @@ -144,16 +148,21 @@ public void installOffline() { } private void doInstallOffline(Path file) { - Task task = repository.getDependency().installLibraryAsync(manifest, file) - .thenComposeAsync(repository::saveAsync) - .thenComposeAsync(repository.refreshAsync()); + if (gameInstance == null) { + return; + } + + HMCLGameRepository repository = gameInstance.getRepository(); + Task task = repository.updateInstanceAsync( + gameInstance.getId(), + publishedInstance -> repository.getDependency().installComponentAsync(publishedInstance, file)); task.setName(i18n("install.installer.install_offline")); TaskExecutor executor = task.executor(new TaskListener() { @Override 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 4a21f09d365..8c9603caf7c 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 @@ -48,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; @@ -56,6 +57,7 @@ import java.util.List; import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; @@ -113,9 +115,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"); @@ -133,7 +137,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(); @@ -146,7 +152,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 { @@ -157,12 +163,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) { @@ -183,21 +189,50 @@ public static void installFromJson(HMCLGameRepository repository, Path file) { GameInstanceID instanceId = new GameInstanceID(result); DefaultDependencyManager dependencyManager = repository.getDependency(); - GameInstanceManifest newVersion = manifest.withId(instanceId).withJar(instanceId); + String gameVersion = manifest.id().id(); + GameInstanceManifest newManifest = manifest.withId(instanceId).withJar(instanceId); + GameDownloadTask gameDownloadTask = new GameDownloadTask( + dependencyManager, + gameVersion, + newManifest); + AtomicReference activeDraft = new AtomicReference<>(); Controllers.taskDialog( - Task.allOf(new GameDownloadTask(dependencyManager, null, newVersion), + Task.supplyAsync(() -> { + GameRepositoryDraft draft = repository.openDraft(); + activeDraft.set(draft); + return draft; + }) + .thenComposeAsync(draft -> Task.allOf( + gameDownloadTask, Task.allOf( - new GameAssetDownloadTask(dependencyManager, newVersion, GameAssetDownloadTask.DOWNLOAD_INDEX_FORCIBLY, true), - new GameLibrariesTask(dependencyManager, newVersion, true) - ).withRunAsync(() -> { - // ignore failure - })) - .thenComposeAsync(repository.saveAsync(newVersion)) - .thenRunAsync(repository::refresh) + new GameAssetDownloadTask( + dependencyManager, + newManifest, + GameAssetDownloadTask.DOWNLOAD_INDEX_FORCIBLY, + true), + new GameLibrariesTask(dependencyManager, newManifest, true)) + .withRunAsync(() -> { + // ignore failure + }))) + .thenAcceptAsync(ignored -> { + GameRepositoryDraft draft = activeDraft.get(); + if (draft == null) { + throw new IllegalStateException("Game repository draft is unavailable"); + } + draft.put(newManifest); + draft.putPrimaryJar(instanceId, gameDownloadTask.getResult()); + draft.commit(); + }) + .whenComplete(exception -> { + GameRepositoryDraft draft = activeDraft.getAndSet(null); + if (draft != null && draft.isOpen()) { + draft.abort(); + } + }) .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); @@ -206,7 +241,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(); @@ -232,33 +269,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.getResolvedManifest().launchManifest(), + 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( @@ -276,7 +315,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); } @@ -300,12 +339,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); } @@ -313,43 +369,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() && @@ -385,10 +418,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..07e5d50427d 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,15 @@ 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); + this.gameVersion = gameInstance.getVersion().toString(); - loadMods(repository.getModManager(instanceId)); + loadMods(gameInstance.getModManager()); } private void loadMods(ModManager modManager) { @@ -131,13 +142,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 +160,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 +246,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 +279,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 +290,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 +307,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/ResourcePackListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ResourcePackListPage.java index b5456958962..c97d5cd269c 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,8 +44,7 @@ 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.SettingsManager; import org.jackhuang.hmcl.task.Schedulers; @@ -53,18 +53,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 +79,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 +93,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 +120,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 +203,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 +253,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 +278,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, 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 603f25ec5ed..4e409c402b9 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; @@ -55,6 +55,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; @@ -64,7 +65,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)) { @@ -73,14 +74,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 @@ -88,9 +100,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 aa5134042e4..11a58cad719 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 81577186c57..0aa0c872aae 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; @@ -45,12 +46,10 @@ import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.DownloadProvider; +import org.jackhuang.hmcl.download.GameBuilder; 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 +75,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 +88,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 +220,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 +244,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 +255,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) @@ -269,7 +280,7 @@ public void accept(@Nullable GameInstanceID currentGame) { JFXPopup.PopupHPosition.RIGHT, 0, -menuButton.getHeight(), - repository, versions + instances ); Node graphic = menuButton.getGraphic(); @@ -346,12 +357,12 @@ private void doAnimation(boolean show) { private void launch() { HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository(); - Instances.launch(repository, repository.getSelectedInstance()); + Instances.launch(repository.getSelectedInstance()); } private void launchNoGame() { DownloadProvider downloadProvider = DownloadProviders.getDownloadProvider(); - VersionList versionList = downloadProvider.getVersionListById("game"); + VersionList versionList = downloadProvider.getVersionList(GameComponentType.GAME); Holder instanceHolder = new Holder<>(); Task task = versionList.refreshAsync("") @@ -371,14 +382,15 @@ private void launchNoGame() { instanceHolder.value = instanceId; return dependency.newGameBuilder() - .name(instanceId) - .gameVersion(gameVersion) + .id(instanceId) + .component(GameComponentType.GAME, gameVersion) .buildAsync(); }) .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)) { LOG.warning("Failed to install game", exception); @@ -408,28 +420,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() { @@ -468,9 +487,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..32c1deb5d3e 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, JavaRuntime javaVersion, GameSettings.Effective settings, List javaArguments) { + GameVersionNumber gameVersion = instance.getVersion(); 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..28e862ebead 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,13 @@ 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.GameRepositoryDraft; +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 +37,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 +46,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 +439,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 an unpublished isolated installation resolves paths without a [HMCLGameInstance]. @Test - public void newIsolatedInstallingInstanceUsesVersionRootBeforeVersionExists(@TempDir Path tempDirectory) - throws ReflectiveOperationException { + public void newIsolatedInstallationUsesVersionRootBeforePublication(@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 +465,23 @@ public void newIsolatedInstallingInstanceUsesVersionRootBeforeVersionExists(@Tem GameInstanceID id = new GameInstanceID("1.21.11-fabric"); assertFalse(repository.hasInstance(id)); - assertEquals(repository.getBaseDirectory(), repository.getRunDirectory(id)); repository.applyDefaultIsolationSettingForNewInstance(id, true); + try (GameRepositoryDraft draft = repository.openDraft()) { + draft.put(new GameInstanceManifest(id)); + assertFalse(repository.hasInstance(id)); + assertEquals( + repository.getLayout().getInstanceRoot(id), + repository.getRunDirectoryForInstallation(id)); + draft.commit(); + } - 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 +543,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 +585,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 +594,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 +674,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 +949,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 5413738cf37..80331972ff1 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; } @@ -112,7 +114,7 @@ private void addModInfo(Path file) { return; } - Set modLoaderTypes = analyzer.getModLoaders(); + Set modLoaderTypes = instance.getModLoaders(); var supportedReaders = new ArrayList(); var unsupportedReaders = new ArrayList(); @@ -180,16 +182,12 @@ 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 = instance.getAnalyzer(); - boolean supportSubfolders = analyzer.has(LibraryAnalyzer.LibraryType.FORGE) - || analyzer.has(LibraryAnalyzer.LibraryType.QUILT) - || analyzer.has(LibraryAnalyzer.LibraryType.CLEANROOM) - || analyzer.has(LibraryAnalyzer.LibraryType.LITELOADER); + boolean supportSubfolders = analyzer.has(GameComponentType.FORGE) + || analyzer.has(GameComponentType.QUILT) + || analyzer.has(GameComponentType.CLEANROOM) + || analyzer.has(GameComponentType.LITELOADER); 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/AbstractDependencyManager.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/AbstractDependencyManager.java index b70c5da399a..beec622e2b2 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/AbstractDependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/AbstractDependencyManager.java @@ -17,6 +17,8 @@ */ package org.jackhuang.hmcl.download; +import org.jackhuang.hmcl.game.GameComponentType; + /** * * @author huangyuhui @@ -29,7 +31,7 @@ public abstract class AbstractDependencyManager implements DependencyManager { public abstract DefaultCacheRepository getCacheRepository(); @Override - public VersionList getVersionList(String id) { - return getDownloadProvider().getVersionListById(id); + public VersionList getVersionList(GameComponentType componentType) { + return getDownloadProvider().getVersionList(componentType); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/AutoDownloadProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/AutoDownloadProvider.java index 78513ad59ea..45a4ae2a1be 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/AutoDownloadProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/AutoDownloadProvider.java @@ -17,6 +17,8 @@ */ package org.jackhuang.hmcl.download; +import org.jackhuang.hmcl.game.GameComponentType; + import java.net.URI; import java.util.LinkedHashSet; import java.util.List; @@ -28,7 +30,7 @@ public final class AutoDownloadProvider implements DownloadProvider { private final List versionListProviders; private final List fileProviders; - private final ConcurrentMap> versionLists = new ConcurrentHashMap<>(); + private final ConcurrentMap> versionLists = new ConcurrentHashMap<>(); public AutoDownloadProvider( List versionListProviders, @@ -94,11 +96,11 @@ public List injectURLsWithCandidates(List urls) { } @Override - public VersionList getVersionListById(String id) { - return versionLists.computeIfAbsent(id, value -> { + public VersionList getVersionList(GameComponentType componentType) { + return versionLists.computeIfAbsent(componentType, value -> { VersionList[] lists = new VersionList[versionListProviders.size()]; for (int i = 0; i < versionListProviders.size(); i++) { - lists[i] = versionListProviders.get(i).getVersionListById(value); + lists[i] = versionListProviders.get(i).getVersionList(value); } return new MultipleSourceVersionList(lists); }); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/BMCLAPIDownloadProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/BMCLAPIDownloadProvider.java index b23a7867f7b..8db94c8d1b6 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/BMCLAPIDownloadProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/BMCLAPIDownloadProvider.java @@ -29,6 +29,7 @@ import org.jackhuang.hmcl.download.optifine.OptiFineBMCLVersionList; import org.jackhuang.hmcl.download.quilt.QuiltAPIVersionList; import org.jackhuang.hmcl.download.quilt.QuiltVersionList; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.util.Pair; import org.jackhuang.hmcl.util.io.NetworkUtils; @@ -120,21 +121,20 @@ public List getAssetObjectCandidates(String assetObjectLocation) { } @Override - public VersionList getVersionListById(String id) { - return switch (id) { - case "game" -> game; - case "fabric" -> fabric; - case "fabric-api" -> fabricApi; - case "forge" -> forge; - case "cleanroom" -> cleanroom; - case "neoforge" -> neoforge; - case "liteloader" -> liteLoader; - case "optifine" -> optifine; - case "quilt" -> quilt; - case "quilt-api" -> quiltApi; - case "legacyfabric" -> legacyFabric; - case "legacyfabric-api" -> legacyFabricApi; - default -> throw new IllegalArgumentException("Unrecognized version list id: " + id); + public VersionList getVersionList(GameComponentType componentType) { + return switch (componentType) { + case GAME -> game; + case FABRIC -> fabric; + case FABRIC_API -> fabricApi; + case FORGE -> forge; + case CLEANROOM -> cleanroom; + case NEO_FORGE -> neoforge; + case LITELOADER -> liteLoader; + case OPTIFINE -> optifine; + case QUILT -> quilt; + case QUILT_API -> quiltApi; + case LEGACY_FABRIC -> legacyFabric; + case LEGACY_FABRIC_API -> legacyFabricApi; }; } 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..b9b34c3b62f 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,56 @@ 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 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.ArrayList; import java.util.List; -import java.util.concurrent.atomic.AtomicReference; +import java.util.Optional; 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. +@NotNullByDefault 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 +95,21 @@ 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 instanceJar = instance.getInstanceJarFile(); - return Files.notExists(versionJar) || FileUtils.size(versionJar) == 0L - ? new GameDownloadTask(this, null, manifest) + return Files.notExists(instanceJar) || FileUtils.size(instanceJar) == 0L + ? new GameDownloadTask(this, manifest).thenAcceptAsync( + cachedJar -> FileUtils.copyFile(cachedJar, instanceJar)) : 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) @@ -94,36 +117,38 @@ public Task checkGameCompletionAsync(GameInstanceManifest manifest, boolean i } @Override - public Task checkLibraryCompletionAsync(GameInstanceManifest manifest, boolean integrityCheck) { + public Task checkComponentCompletionAsync(GameInstanceManifest manifest, boolean integrityCheck) { return new GameLibrariesTask(this, manifest, integrityCheck, manifest.getLibraries()); } @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.equals(GameVersionNumber.unknown())) return null; + String gameVersion = detectedVersion.toString(); - GameInstanceManifest original = repository.getInstanceManifest(manifest.id()); - GameInstanceManifest.Resolved resolvedInstanceManifest = repository.getResolvedInstanceManifest(manifest.id()); - - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(resolvedInstanceManifest, gameVersion); - for (LibraryAnalyzer.LibraryType type : LibraryAnalyzer.LibraryType.values()) { - if (!analyzer.has(type)) + GameInstanceManifest original = instance.getManifest(); + for (GameComponentType type : GameComponentType.values()) { + if (!instance.hasComponent(type)) continue; - if (type == LibraryAnalyzer.LibraryType.OPTIFINE) { - String optifinePatchVersion = analyzer.getVersion(type) - .map(optifineVersion -> { + if (type == GameComponentType.OPTIFINE) { + @Nullable String optifinePatchVersion = Optional.ofNullable(instance.getComponentVersion(type)).map(optifineVersion -> { Matcher matcher = Pattern.compile("^([0-9.]+)_(?HD_.+)$").matcher(optifineVersion); return matcher.find() ? matcher.group("optifine") : optifineVersion; }) - .orElseGet(() -> resolvedInstanceManifest.standaloneManifest().getPatches().stream() + .orElseGet(() -> instance.getResolvedManifest().standaloneManifest().getPatches().stream() .filter(patch -> "optifine".equals(patch.id())) .findAny() - .map(gameInstancePatch -> gameInstancePatch.version()) + .map(GameInstancePatch::version) .orElse(null)); boolean needsReInstallation = manifest.getLibraries().stream() @@ -134,9 +159,13 @@ public Task checkPatchCompletionAsync(GameInstanceManifest manifest, boolean if (needsReInstallation) { Library installer = new Library(new Artifact("optifine", "OptiFine", gameVersion + "_" + optifinePatchVersion, "installer")); if (GameLibrariesTask.shouldDownloadLibrary(repository, manifest, installer, integrityCheck)) { - tasks.add(installLibraryAsync(gameVersion, original, "optifine", optifinePatchVersion)); + tasks.add(installComponentAsync(instance, original, gameVersion, GameComponentType.OPTIFINE, optifinePatchVersion)); } else { - tasks.add(OptiFineInstallTask.install(this, original, repository.getLibraryFile(manifest, installer))); + tasks.add(OptiFineInstallTask.install( + this, + original, + gameVersion, + repository.getLayout().getLibraryFile(manifest.id(), installer))); } } } @@ -146,81 +175,242 @@ public Task checkPatchCompletionAsync(GameInstanceManifest manifest, boolean }); } - @Override - public Task installLibraryAsync(String gameVersion, GameInstanceManifest baseVersion, String libraryId, String libraryVersion) { - VersionList versionList = getVersionList(libraryId); + /// Installs a component using a working manifest that may be ahead of the instance's stored state. + /// + /// Used by multi-step install pipelines after a previous in-memory remove/install. `instance` + /// supplies repository identity, mods directory, and detected game version; `baseManifest` is + /// the draft JSON being edited. + /// + /// @param instance the registered instance being modified + /// @param baseManifest the working standalone-oriented manifest for this step + /// @param libraryVersion the remote component to install + /// @return the task producing the updated manifest (not yet saved) + public Task installComponentAsync( + GameInstance instance, + GameInstanceManifest baseManifest, + RemoteVersion libraryVersion) { + validateGameInstance(instance); + if (!instance.getId().equals(baseManifest.id())) { + throw new IllegalArgumentException("baseManifest id does not match instance"); + } + + Path modsDirectory = instance.getModsDirectory(); + + return removeComponentAsync(instance, baseManifest, libraryVersion.getComponentType()) + .thenComposeAsync(manifest -> libraryVersion + .getInstallTask(this, manifest, modsDirectory) + .thenApplyAsync(patch -> patch == null ? manifest : manifest.addPatch(patch))) + .withStage(String.format("hmcl.install.%s:%s", libraryVersion.getComponentType().getPatchId(), libraryVersion.getSelfVersion())); + } + + /// Installs a component into an unpublished new instance without constructing a + /// [GameInstance]. + /// + /// @param instanceId the unpublished instance id + /// @param baseManifest the working manifest for this step + /// @param gameVersion the Minecraft version used for component analysis + /// @param componentVersion the remote component to install + /// @return the task producing the updated manifest (not yet committed) + Task installNewInstanceComponentAsync( + GameInstanceID instanceId, + GameInstanceManifest baseManifest, + String gameVersion, + RemoteVersion componentVersion) { + if (!instanceId.equals(baseManifest.id())) { + throw new IllegalArgumentException("baseManifest id does not match instanceId"); + } + + Path modsDirectory = repository.getRunDirectoryForInstallation(instanceId).resolve("mods"); + return removeNewInstanceComponentAsync( + baseManifest, + GameVersionNumber.asGameVersion(gameVersion), + componentVersion.getComponentType()) + .thenComposeAsync(manifest -> componentVersion + .getInstallTask(this, manifest, modsDirectory) + .thenApplyAsync(patch -> patch == null ? manifest : manifest.addPatch(patch))) + .withStage(String.format( + "hmcl.install.%s:%s", + componentVersion.getComponentType().getPatchId(), + componentVersion.getSelfVersion())); + } + + /// Resolves and installs a component into an unpublished new instance. + /// + /// @param instanceId the unpublished instance id + /// @param baseManifest the working manifest for this step + /// @param gameVersion the Minecraft version used to look up the remote list + /// @param componentType the component list id, such as `game` or `forge` + /// @param componentVersion the component version id + /// @return the installation task + Task installNewInstanceComponentAsync( + GameInstanceID instanceId, + GameInstanceManifest baseManifest, + String gameVersion, + GameComponentType componentType, + String componentVersion) { + if (!instanceId.equals(baseManifest.id())) { + throw new IllegalArgumentException("baseManifest id does not match instanceId"); + } + + VersionList versionList = getVersionList(componentType); return versionList.loadAsync(gameVersion) - .thenComposeAsync(() -> installLibraryAsync(baseVersion, versionList.getVersion(gameVersion, libraryVersion) - .orElseThrow(() -> new IOException("Remote library " + libraryId + " has no version " + libraryVersion)))) - .withStage(String.format("hmcl.install.%s:%s", libraryId, libraryVersion)); + .thenComposeAsync(() -> installNewInstanceComponentAsync( + instanceId, + baseManifest, + gameVersion, + versionList.getVersion(gameVersion, componentVersion) + .orElseThrow(() -> new IOException( + "Remote component " + componentType + " has no version " + componentVersion)))) + .withStage(String.format("hmcl.install.%s:%s", componentType, componentVersion)); } - @Override - public Task installLibraryAsync(GameInstanceManifest baseVersion, RemoteVersion libraryVersion) { - AtomicReference removedLibraryVersion = new AtomicReference<>(); + /// Removes one component from an unpublished new instance manifest. + /// + /// @param workingManifest the manifest being edited + /// @param gameVersion the Minecraft version used for component analysis + /// @param componentType the component to remove + /// @return the task producing the updated standalone manifest + private Task removeNewInstanceComponentAsync( + GameInstanceManifest workingManifest, + GameVersionNumber gameVersion, + GameComponentType componentType) { + return Task.supplyAsync(() -> { + GameInstanceManifest standalone = workingManifest.inheritsFrom() == null + ? workingManifest + : repository.resolve(workingManifest).standaloneManifest(); + return GameComponentAnalyzer.analyze(standalone, gameVersion).removeLibrary(componentType); + }); + } - return removeLibraryAsync(baseVersion, libraryVersion.getLibraryId()) - .thenComposeAsync(version -> { - removedLibraryVersion.set(version); - return libraryVersion.getInstallTask(this, version); - }) - .thenApplyAsync(patch -> { - if (patch == null) { - return removedLibraryVersion.get(); - } else { - return removedLibraryVersion.get().addPatch(patch); - } - }) - .withStage(String.format("hmcl.install.%s:%s", libraryVersion.getLibraryId(), libraryVersion.getSelfVersion())); + /// Resolves a remote component by id/version and installs it into the working manifest. + /// + /// @param instance the registered instance being modified + /// @param baseManifest the working manifest for this step + /// @param gameVersion the Minecraft version used to look up the remote list + /// @param componentType the component list id, such as `game` or `forge` + /// @param componentVersion the component version id + /// @return the installation task + public Task installComponentAsync( + GameInstance instance, + GameInstanceManifest baseManifest, + String gameVersion, + GameComponentType componentType, + String componentVersion) { + validateGameInstance(instance); + if (!instance.getId().equals(baseManifest.id())) { + throw new IllegalArgumentException("baseManifest id does not match instance"); + } + + VersionList versionList = getVersionList(componentType); + return versionList.loadAsync(gameVersion) + .thenComposeAsync(() -> installComponentAsync( + instance, + baseManifest, + versionList.getVersion(gameVersion, componentVersion) + .orElseThrow(() -> new IOException( + "Remote library " + componentType + " has no version " + componentVersion)))) + .withStage(String.format("hmcl.install.%s:%s", componentType, componentVersion)); + } + + /// Installs a component from a local installer jar into a registered instance. + /// + /// @param instance the target instance + /// @param installer the local installer jar + /// @return the task producing the updated manifest (not yet saved) + public Task installComponentAsync(GameInstance instance, Path installer) { + validateGameInstance(instance); + return installComponentAsync(instance, instance.getManifest(), installer); } - public Task installLibraryAsync(GameInstanceManifest oldVersion, Path installer) { - return Task - .composeAsync(() -> { + /// Installs a component from a local installer jar into a working manifest. + /// + /// @param instance the registered instance (paths / identity) + /// @param baseManifest the working manifest for this step + /// @param installer the local installer jar + /// @return the task producing the updated manifest (not yet saved) + public Task installComponentAsync( + GameInstance instance, + GameInstanceManifest baseManifest, + Path installer) { + validateGameInstance(instance); + if (!instance.getId().equals(baseManifest.id())) { + throw new IllegalArgumentException("baseManifest id does not match instance"); + } + String gameVersion = instance.getVersion().toString(); + + return Task.composeAsync(() -> { try { - return CleanroomInstallTask.install(this, oldVersion, installer); + return CleanroomInstallTask.install(this, baseManifest, gameVersion, installer); } catch (IOException ignore) { } try { - return NeoForgeInstallTask.install(this, oldVersion, installer); + return NeoForgeInstallTask.install(this, baseManifest, gameVersion, installer); } catch (IOException ignore) { } try { - return ForgeInstallTask.install(this, oldVersion, installer); + return ForgeInstallTask.install(this, baseManifest, gameVersion, installer); } catch (IOException ignore) { } try { - return OptiFineInstallTask.install(this, oldVersion, installer); + return OptiFineInstallTask.install(this, baseManifest, gameVersion, installer); } catch (IOException ignore) { } throw new UnsupportedLibraryInstallerException(); }) - .thenApplyAsync(patch -> patch == null ? oldVersion : oldVersion.addPatch(patch)); + .thenApplyAsync(patch -> patch == null ? baseManifest : baseManifest.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. + /// Removes a component from a registered instance using its current stored manifest. + /// + /// @param instance the target instance + /// @param componentType the component to remove + /// @return the task producing the updated standalone manifest (not yet saved) + public Task removeComponentAsync(GameInstance instance, GameComponentType componentType) { + validateGameInstance(instance); + return removeComponentAsync(instance, instance.getManifest(), componentType); + } + + /// Removes a component from a working manifest bound to a registered instance. + /// + /// When `workingManifest` is the instance's stored manifest, edits its resolved standalone view; + /// otherwise edits the independent draft (resolving inheritance if still present). + /// + /// @param instance the registered instance + /// @param workingManifest the draft being edited + /// @param componentType the component to remove + /// @return the task producing the updated standalone manifest (not yet saved) + public Task removeComponentAsync( + GameInstance instance, + GameInstanceManifest workingManifest, + GameComponentType componentType) { + validateGameInstance(instance); + if (!instance.getId().equals(workingManifest.id())) { + throw new IllegalArgumentException("workingManifest id does not match instance"); + } + return Task.supplyAsync(() -> { - GameInstanceManifest independentVersion = repository.resolve(manifest).standaloneManifest(); - String gameVersion = repository.getGameVersion(independentVersion).orElse(null); - return LibraryAnalyzer.analyze(independentVersion, gameVersion).removeLibrary(libraryId).build(); + GameInstanceManifest standalone; + if (workingManifest.equals(instance.getManifest())) { + standalone = instance.getResolvedManifest().standaloneManifest(); + } else if (workingManifest.inheritsFrom() == null) { + standalone = workingManifest; + } else { + standalone = repository.resolve(workingManifest).standaloneManifest(); + } + + return GameComponentAnalyzer.analyze(standalone, instance.getVersion()).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 037394b0ca1..c5b0817f996 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java @@ -17,60 +17,109 @@ */ package org.jackhuang.hmcl.download; -import org.jackhuang.hmcl.game.GameInstanceManifest; +import org.jackhuang.hmcl.download.game.GameDownloadTask; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.task.Task; -import org.jackhuang.hmcl.util.function.ExceptionalFunction; +import org.jetbrains.annotations.NotNullByDefault; import java.util.ArrayList; import java.util.Map; +import java.util.Objects; -/** - * - * @author huangyuhui - */ +/// Builds a new game instance in an exclusive [GameRepositoryDraft], installs its components, and +/// publishes the completed instance once. +/// +/// Shared libraries, assets, and download caches may remain after failure. The instance manifest +/// and primary JAR enter the instance tree only when the draft commits. +@NotNullByDefault public class DefaultGameBuilder extends GameBuilder { + /// Dependency manager used for component installation and repository access. private final DefaultDependencyManager dependencyManager; + /// Creates a builder bound to the given dependency manager. + /// + /// @param dependencyManager the dependency manager for the target repository public DefaultGameBuilder(DefaultDependencyManager dependencyManager) { this.dependencyManager = dependencyManager; } + /// Returns the dependency manager used by this builder. + /// + /// @return the dependency manager public DefaultDependencyManager getDependencyManager() { return dependencyManager; } + /// {@inheritDoc} + /// + /// Retains an unpublished working manifest, installs the configured game and optional loaders, + /// resolves their patches into the final manifest, and commits it once. Failure or cancellation + /// aborts the draft. + /// + /// @return the build task + /// @throws NullPointerException if [#id] was not set @Override public Task buildAsync() { + GameInstanceID id = Objects.requireNonNull(this.id, "GameBuilder.id must be set"); + String gameVersion = (String) components.get(GameComponentType.GAME); + if (gameVersion == null) + throw new IllegalStateException("GameBuilder.gameVersion must be set"); + var hints = new ArrayList(); - Task libraryTask = Task.supplyAsync(() -> 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")); - hints.add(new Task.StagesHint("hmcl.install.assets")); + components.forEach((componentType, version) -> { + hints.add(new Task.StagesHint( + String.format("hmcl.install.%s:%s", componentType.getPatchId(), + version instanceof RemoteVersion remoteVersion + ? remoteVersion.getSelfVersion() + : (String) version))); - for (Map.Entry entry : toolVersions.entrySet()) { - libraryTask = libraryTask.thenComposeAsync(libraryTaskHelper(gameVersion, entry.getKey(), entry.getValue())); - hints.add(new Task.StagesHint(String.format("hmcl.install.%s:%s", entry.getKey(), entry.getValue()))); - } + if (componentType == GameComponentType.GAME) { + hints.add(new Task.StagesHint("hmcl.install.libraries")); + hints.add(new Task.StagesHint("hmcl.install.assets")); + } + }); - for (RemoteVersion remoteVersion : remoteVersions) { - libraryTask = libraryTask.thenComposeAsync(version -> dependencyManager.installLibraryAsync(version, remoteVersion)); - hints.add(new Task.StagesHint(String.format("hmcl.install.%s:%s", remoteVersion.getLibraryId(), remoteVersion.getSelfVersion()))); - } - var repository = dependencyManager.getGameRepository(); - boolean isUpdate = repository.hasInstance(name); + DefaultGameRepository repository = dependencyManager.getGameRepository(); + //noinspection resource + DefaultGameRepositoryDraft draft = repository.openDraft(); - return libraryTask.thenComposeAsync(repository::saveAsync).whenComplete(exception -> { - if (exception != null && !isUpdate) { - repository.removeInstanceFromDisk(name); + Task libraryTask = dependencyManager.installNewInstanceComponentAsync( + id, new GameInstanceManifest(id), gameVersion, GameComponentType.GAME, gameVersion); + + for (Map.Entry entry : components.entrySet()) { + GameComponentType componentType = entry.getKey(); + + if (entry.getValue() instanceof RemoteVersion remoteVersion) { + libraryTask = libraryTask.thenComposeAsync(manifest -> + dependencyManager.installNewInstanceComponentAsync( + id, manifest, gameVersion, remoteVersion)); + } else if (entry.getValue() instanceof String version) { + libraryTask = libraryTask.thenComposeAsync(manifest -> + dependencyManager.installNewInstanceComponentAsync( + id, manifest, gameVersion, componentType, version)); + } else { + throw new AssertionError("Unexpected version type: " + entry.getValue().getClass()); } - }).withStagesHints(hints); - } + } - private ExceptionalFunction, ?> libraryTaskHelper(String gameVersion, String libraryId, String libraryVersion) { - return version -> dependencyManager.installLibraryAsync(gameVersion, version, libraryId, libraryVersion); + return libraryTask.thenComposeAsync(manifest -> { + GameInstanceManifest resolvedManifest = draft.getBaseSnapshot().resolve(manifest).launchManifest(); + return new GameDownloadTask(dependencyManager, resolvedManifest) + .thenApplyAsync(minecraftJar -> { + draft.put(resolvedManifest); + draft.putPrimaryJar(id, minecraftJar); + return draft.commit().getInstance(id); + }); + }) + .whenComplete(exception -> { + if (draft.isOpen()) { + draft.abort(); + } + }) + .withStagesHints(hints); } + } 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..c2a4890d284 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,68 @@ */ package org.jackhuang.hmcl.download; +import org.jackhuang.hmcl.game.GameComponentType; +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. - */ - Task checkLibraryCompletionAsync(GameInstanceManifest manifest, boolean integrityCheck); + /// 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 checkComponentCompletionAsync(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. - */ - 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. - */ - 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. - */ - VersionList getVersionList(String id); + /// Returns a registered remote-version list. + /// + /// @param componentType the component type, such as `game`, `forge`, or `optifine` + /// @return the registered version list + /// @throws IllegalArgumentException if no list is registered for `id` + VersionList getVersionList(GameComponentType componentType); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DownloadProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DownloadProvider.java index 0c6117e491b..e29a30f4ae2 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DownloadProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DownloadProvider.java @@ -17,6 +17,7 @@ */ package org.jackhuang.hmcl.download; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.util.io.NetworkUtils; import java.net.URI; @@ -62,10 +63,10 @@ default List injectURLsWithCandidates(List urls) { /// the specific version list that this download provider provides. i.e. "fabric", "forge", "liteloader", "game", "optifine" /// - /// @param id the id of specific version list that this download provider provides. i.e. "fabric", "forge", "liteloader", "game", "optifine" + /// @param componentType the component type of specific version list that this download provider provides. i.e. "fabric", "forge", "liteloader", "game", "optifine" /// @return the version list /// @throws IllegalArgumentException if the version list does not exist - VersionList getVersionListById(String id); + VersionList getVersionList(GameComponentType componentType); /// The maximum download concurrency that this download provider supports. /// diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DownloadProviderWrapper.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DownloadProviderWrapper.java index 770edbc3108..45a74f47360 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DownloadProviderWrapper.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DownloadProviderWrapper.java @@ -17,6 +17,7 @@ */ package org.jackhuang.hmcl.download; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.task.Task; import java.net.URI; @@ -68,12 +69,11 @@ public List injectURLsWithCandidates(List urls) { } @Override - public VersionList getVersionListById(String id) { - + public VersionList getVersionList(GameComponentType componentType) { return new VersionList<>() { @Override public boolean hasType() { - return getProvider().getVersionListById(id).hasType(); + return getProvider().getVersionList(componentType).hasType(); } @Override @@ -83,11 +83,11 @@ public Task refreshAsync() { @Override public Task refreshAsync(String gameVersion) { - return getProvider().getVersionListById(id).refreshAsync(gameVersion) + return getProvider().getVersionList(componentType).refreshAsync(gameVersion) .thenComposeAsync(() -> { lock.writeLock().lock(); try { - versions.putAll(gameVersion, getProvider().getVersionListById(id).getVersions(gameVersion)); + versions.putAll(gameVersion, getProvider().getVersionList(componentType).getVersions(gameVersion)); } finally { lock.writeLock().unlock(); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/GameBuilder.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/GameBuilder.java index c16c19d6003..6b35aeb9645 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/GameBuilder.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/GameBuilder.java @@ -17,57 +17,41 @@ */ package org.jackhuang.hmcl.download; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.task.Task; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; import java.util.*; -/** - * The builder which provide a task to build Minecraft environment. - * - * @author huangyuhui - */ +/// The builder which provide a task to build Minecraft environment. +/// +/// @author huangyuhui +@NotNullByDefault public abstract class GameBuilder { - protected @Nullable GameInstanceID name; - protected String gameVersion = ""; - protected final Map toolVersions = new HashMap<>(); - protected final Set remoteVersions = new HashSet<>(); - - public GameInstanceID getName() { - return name; - } + protected @Nullable GameInstanceID id; + protected final EnumMap components = new EnumMap<>(GameComponentType.class); - /** - * The new game version name, for .minecraft/<version name>. - * - * @param name the name of new game version. - */ - public GameBuilder name(GameInstanceID name) { - this.name = Objects.requireNonNull(name); + /// The new game instance id, for `.minecraft/`. + /// + /// @param id the instance id of new game instance. + public GameBuilder id(GameInstanceID id) { + this.id = Objects.requireNonNull(id); return this; } - public GameBuilder gameVersion(String version) { - this.gameVersion = Objects.requireNonNull(version); - return this; - } - - /** - * @param id the core library id. i.e. "forge", "liteloader", "optifine" - * @param version the version of the core library. For documents, you can first try [VersionList.versions] - */ - public GameBuilder version(String id, String version) { - if ("game".equals(id)) - gameVersion(version); - else - toolVersions.put(id, version); + @Contract("_, _ -> this") + public GameBuilder component(GameComponentType componentType, String version) { + components.put(componentType, version); return this; } - public GameBuilder version(RemoteVersion remoteVersion) { - remoteVersions.add(remoteVersion); + @Contract("_ -> this") + public GameBuilder component(RemoteVersion remoteVersion) { + components.put(remoteVersion.getComponentType(), remoteVersion); return this; } 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 58030f6bef2..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 manifest.patches() == null ? newVersion : 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/MojangDownloadProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/MojangDownloadProvider.java index 716acbcc399..a2ecd9955a8 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/MojangDownloadProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/MojangDownloadProvider.java @@ -29,6 +29,7 @@ import org.jackhuang.hmcl.download.optifine.OptiFineBMCLVersionList; import org.jackhuang.hmcl.download.quilt.QuiltAPIVersionList; import org.jackhuang.hmcl.download.quilt.QuiltVersionList; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.util.io.NetworkUtils; import java.net.URI; @@ -81,21 +82,20 @@ public List getAssetObjectCandidates(String assetObjectLocation) { } @Override - public VersionList getVersionListById(String id) { - return switch (id) { - case "game" -> game; - case "fabric" -> fabric; - case "fabric-api" -> fabricApi; - case "forge" -> forge; - case "cleanroom" -> cleanroom; - case "neoforge" -> neoforge; - case "liteloader" -> liteLoader; - case "optifine" -> optifine; - case "quilt" -> quilt; - case "quilt-api" -> quiltApi; - case "legacyfabric" -> legacyFabric; - case "legacyfabric-api" -> legacyFabricApi; - default -> throw new IllegalArgumentException("Unrecognized version list id: " + id); + public VersionList getVersionList(GameComponentType componentType) { + return switch (componentType) { + case GAME -> game; + case FABRIC -> fabric; + case FABRIC_API -> fabricApi; + case FORGE -> forge; + case CLEANROOM -> cleanroom; + case NEO_FORGE -> neoforge; + case LITELOADER -> liteLoader; + case OPTIFINE -> optifine; + case QUILT -> quilt; + case QUILT_API -> quiltApi; + case LEGACY_FABRIC -> legacyFabric; + case LEGACY_FABRIC_API -> legacyFabricApi; }; } 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..6c71b808624 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,7 +36,7 @@ */ public class RemoteVersion implements Comparable { - private final String libraryId; + private final GameComponentType componentType; private final String gameVersion; private final String selfVersion; private final Instant releaseDate; @@ -48,8 +50,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 +61,8 @@ 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.gameVersion = Objects.requireNonNull(gameVersion); this.selfVersion = Objects.requireNonNull(selfVersion); this.releaseDate = releaseDate; @@ -68,8 +70,8 @@ public RemoteVersion(String libraryId, String gameVersion, String selfVersion, I this.type = Objects.requireNonNull(type); } - public String getLibraryId() { - return libraryId; + public GameComponentType getComponentType() { + return componentType; } public String getGameVersion() { @@ -100,6 +102,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..0882a250758 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,17 +18,19 @@ 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.download.game.GameDownloadTask; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; 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.CompressingUtils; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.file.FileSystem; @@ -37,29 +39,53 @@ import java.util.Collection; import java.util.Collections; import java.util.Map; -import java.util.Optional; +import java.util.Objects; public final class CleanroomInstallTask extends Task { private final DefaultDependencyManager dependencyManager; private final GameInstanceManifest manifest; - private final CleanroomRemoteVersion remote; - private Path installer; - private FileDownloadTask dependent; - private Task task; - private String selfVersion; - - public CleanroomInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest manifest, CleanroomRemoteVersion remoteVersion) { + /// Minecraft version whose vanilla client JAR is required by the installer processors. + private final String gameVersion; + private final @Nullable CleanroomRemoteVersion remote; + private @Nullable Path installer; + private @Nullable FileDownloadTask dependent; + private @Nullable Task task; + private @Nullable String selfVersion; + + /// Creates a Cleanroom task that downloads the selected installer. + /// + /// @param dependencyManager repository-scoped download services + /// @param manifest working manifest receiving the Cleanroom patch + /// @param remoteVersion selected Cleanroom version + public CleanroomInstallTask( + DefaultDependencyManager dependencyManager, + GameInstanceManifest manifest, + CleanroomRemoteVersion remoteVersion) { this.dependencyManager = dependencyManager; this.manifest = manifest; + this.gameVersion = remoteVersion.getGameVersion(); this.remote = remoteVersion; setSignificance(TaskSignificance.MODERATE); } - public CleanroomInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest manifest, String selfVersion, Path installer) { + /// Creates a Cleanroom task backed by an existing local installer. + /// + /// @param dependencyManager repository-scoped download services + /// @param manifest working manifest receiving the Cleanroom patch + /// @param gameVersion Minecraft version expected by the installation + /// @param selfVersion Cleanroom version recorded in the returned patch + /// @param installer Cleanroom installer JAR + public CleanroomInstallTask( + DefaultDependencyManager dependencyManager, + GameInstanceManifest manifest, + String gameVersion, + String selfVersion, + Path installer) { this.dependencyManager = dependencyManager; this.manifest = manifest; + this.gameVersion = gameVersion; this.selfVersion = selfVersion; this.remote = null; this.installer = installer; @@ -77,8 +103,9 @@ public void preExecute() throws Exception { if (installer == null) { installer = Files.createTempFile("cleanroom-installer", ".jar"); + CleanroomRemoteVersion remoteVersion = Objects.requireNonNull(remote); dependent = new FileDownloadTask( - dependencyManager.getDownloadProvider().injectURLsWithCandidates(remote.getUrls()), + dependencyManager.getDownloadProvider().injectURLsWithCandidates(remoteVersion.getUrls()), installer, null); dependent.setCacheRepository(dependencyManager.getCacheRepository()); dependent.setCaching(true); @@ -94,10 +121,10 @@ public boolean doPostExecute() { @Override public void postExecute() throws Exception { if (remote != null) { - Files.deleteIfExists(installer); + Files.deleteIfExists(Objects.requireNonNull(installer)); } - setResult(task.getResult()); + setResult(Objects.requireNonNull(task).getResult()); } @Override @@ -107,29 +134,56 @@ public Collection> getDependents() { @Override public Collection> getDependencies() { - return Collections.singleton(task); + return Collections.singleton(Objects.requireNonNull(task)); } @Override public void execute() throws IOException, VersionMismatchException, UnsupportedInstallationException { + String cleanroomVersion; if (selfVersion == null) { - task = new ForgeNewInstallTask(dependencyManager, manifest, remote.getSelfVersion(), installer).thenApplyAsync((version) -> version.withId(LibraryAnalyzer.LibraryType.CLEANROOM.getPatchId())); + cleanroomVersion = Objects.requireNonNull(remote).getSelfVersion(); } else { - task = new ForgeNewInstallTask(dependencyManager, manifest, selfVersion, installer).thenApplyAsync((version) -> version.withId(LibraryAnalyzer.LibraryType.CLEANROOM.getPatchId())); + cleanroomVersion = selfVersion; } + + task = new GameDownloadTask(dependencyManager, manifest) + .thenComposeAsync(minecraftJar -> new ForgeNewInstallTask( + dependencyManager, + manifest, + minecraftJar, + cleanroomVersion, + Objects.requireNonNull(installer))) + .thenApplyAsync(patch -> patch.withId(GameComponentType.CLEANROOM)); } - public static Task install(DefaultDependencyManager dependencyManager, GameInstanceManifest manifest, Path installer) throws IOException, VersionMismatchException { - Optional gameVersion = dependencyManager.getGameRepository().getGameVersion(manifest); - if (gameVersion.isEmpty()) throw new IOException(); + /// Creates a task that installs Cleanroom from a local installer JAR. + /// + /// @param dependencyManager repository-scoped download services + /// @param manifest working manifest receiving the Cleanroom patch + /// @param gameVersion Minecraft version expected by the installation + /// @param installer Cleanroom installer JAR + /// @return the task producing the Cleanroom patch + /// @throws IOException if the installer profile is missing, malformed, or not a + /// Cleanroom profile + /// @throws VersionMismatchException if the installer targets another Minecraft version + public static Task install( + DefaultDependencyManager dependencyManager, + GameInstanceManifest manifest, + String gameVersion, + Path installer) throws IOException, VersionMismatchException { 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()); - return new CleanroomInstallTask(dependencyManager, manifest, modifyVersion(profile.getVersion()), installer); + if (!gameVersion.equals(profile.getMinecraft())) + throw new VersionMismatchException(profile.getMinecraft(), gameVersion); + return new CleanroomInstallTask( + dependencyManager, + manifest, + gameVersion, + modifyVersion(profile.getVersion()), + installer); } else { throw new IOException(); } 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..090596ede18 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; @@ -67,7 +62,7 @@ public boolean doPreExecute() { @Override public void preExecute() throws Exception { - if (!Objects.equals("net.minecraft.client.main.Main", manifest.resolve(dependencyManager.getGameRepository()).mainClass())) + if (!Objects.equals(GameComponentAnalyzer.VANILLA_MAIN, dependencyManager.getGameRepository().resolve(manifest).launchManifest().mainClass())) throw new UnsupportedInstallationException(FABRIC_NOT_COMPATIBLE_WITH_FORGE); } @@ -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 983af4f13b7..96a1df8a929 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,8 @@ package org.jackhuang.hmcl.download.forge; import org.jackhuang.hmcl.download.*; +import org.jackhuang.hmcl.download.game.GameDownloadTask; +import org.jackhuang.hmcl.game.GameComponentAnalyzer; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.task.FileDownloadTask; @@ -33,7 +35,6 @@ import java.util.Collection; import java.util.Collections; import java.util.Map; -import java.util.Optional; import static org.jackhuang.hmcl.download.UnsupportedInstallationException.UNSUPPORTED_LAUNCH_WRAPPER; import static org.jackhuang.hmcl.util.StringUtils.removePrefix; @@ -99,44 +100,47 @@ public Collection> getDependencies() { @Override public void execute() throws IOException, VersionMismatchException, UnsupportedInstallationException { - String originalMainClass = manifest.resolve(dependencyManager.getGameRepository()).mainClass(); + String originalMainClass = dependencyManager.getGameRepository().resolve(manifest).launchManifest().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); } - if (detectForgeInstallerType(dependencyManager, manifest, installer)) - dependency = new ForgeNewInstallTask(dependencyManager, manifest, remote.getSelfVersion(), installer); - else + if (detectForgeInstallerType(remote.getGameVersion(), installer)) { + dependency = new GameDownloadTask(dependencyManager, manifest) + .thenComposeAsync(minecraftJar -> new ForgeNewInstallTask( + dependencyManager, + manifest, + minecraftJar, + remote.getSelfVersion(), + installer)); + } else { dependency = new ForgeOldInstallTask(dependencyManager, manifest, remote.getSelfVersion(), installer); + } } - /** - * Detect Forge installer type. - * - * @param dependencyManager game repository - * @param manifest instance manifest - * @param installer the Forge installer, either the new or old one. - * @return true for new, false for old - * @throws IOException if unable to read compressed content of installer file, or installer file is corrupted, or the installer is not the one we want. - * @throws VersionMismatchException if required game version of installer does not match the actual one. - */ - public static boolean detectForgeInstallerType(DependencyManager dependencyManager, GameInstanceManifest manifest, Path installer) throws IOException, VersionMismatchException { - Optional gameVersion = dependencyManager.getGameRepository().getGameVersion(manifest); - if (!gameVersion.isPresent()) throw new IOException(); + /// Returns whether a Forge installer uses the processor-based format. + /// + /// @param gameVersion Minecraft version expected by the installation + /// @param installer the Forge installer JAR + /// @return `true` for the processor-based format, or `false` for the legacy format + /// @throws IOException if the installer profile is missing, malformed, or + /// unsupported + /// @throws VersionMismatchException if the installer targets another Minecraft version + public static boolean detectForgeInstallerType(String gameVersion, Path installer) throws IOException, VersionMismatchException { try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(installer)) { String installProfileText = Files.readString(fs.getPath("install_profile.json")); Map installProfile = JsonUtils.fromNonNullJson(installProfileText, Map.class); if (installProfile.containsKey("spec")) { ForgeNewInstallProfile profile = JsonUtils.fromNonNullJson(installProfileText, ForgeNewInstallProfile.class); - if (!gameVersion.get().equals(profile.getMinecraft())) - throw new VersionMismatchException(profile.getMinecraft(), gameVersion.get()); + if (!gameVersion.equals(profile.getMinecraft())) + throw new VersionMismatchException(profile.getMinecraft(), gameVersion); return true; } else if (installProfile.containsKey("install") && installProfile.containsKey("versionInfo")) { ForgeInstallProfile profile = JsonUtils.fromNonNullJson(installProfileText, ForgeInstallProfile.class); - if (!gameVersion.get().equals(profile.install().getMinecraft())) - throw new VersionMismatchException(profile.install().getMinecraft(), gameVersion.get()); + if (!gameVersion.equals(profile.install().getMinecraft())) + throw new VersionMismatchException(profile.install().getMinecraft(), gameVersion); return false; } else { throw new IOException(); @@ -144,33 +148,43 @@ public static boolean detectForgeInstallerType(DependencyManager dependencyManag } } - /** - * Install Forge library from existing local file. - * This method will try to identify this installer whether it is in old or new format. - * - * @param dependencyManager game repository - * @param manifest instance manifest - * @param installer the Forge installer, either the new or old one. - * @return the task to install library - * @throws IOException if unable to read compressed content of installer file, or installer file is corrupted, or the installer is not the one we want. - * @throws VersionMismatchException if required game version of installer does not match the actual one. - */ - public static Task install(DefaultDependencyManager dependencyManager, GameInstanceManifest manifest, Path installer) throws IOException, VersionMismatchException { - Optional gameVersion = dependencyManager.getGameRepository().getGameVersion(manifest); - if (!gameVersion.isPresent()) throw new IOException(); + /// Creates a task that installs Forge from a local installer JAR. + /// + /// Processor-based installers obtain a verified vanilla client JAR from shared cache storage; + /// neither format reads an instance JAR. + /// + /// @param dependencyManager repository-scoped download services + /// @param manifest working manifest receiving the Forge patch + /// @param gameVersion Minecraft version expected by the installation + /// @param installer the Forge installer JAR + /// @return the task producing the Forge patch + /// @throws IOException if the installer profile is missing, malformed, or + /// unsupported + /// @throws VersionMismatchException if the installer targets another Minecraft version + public static Task install( + DefaultDependencyManager dependencyManager, + GameInstanceManifest manifest, + String gameVersion, + Path installer) throws IOException, VersionMismatchException { try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(installer)) { String installProfileText = Files.readString(fs.getPath("install_profile.json")); Map installProfile = JsonUtils.fromNonNullJson(installProfileText, Map.class); if (installProfile.containsKey("spec")) { ForgeNewInstallProfile profile = JsonUtils.fromNonNullJson(installProfileText, ForgeNewInstallProfile.class); - if (!gameVersion.get().equals(profile.getMinecraft())) - throw new VersionMismatchException(profile.getMinecraft(), gameVersion.get()); - return new ForgeNewInstallTask(dependencyManager, manifest, modifyVersion(gameVersion.get(), profile.getVersion()), installer); + if (!gameVersion.equals(profile.getMinecraft())) + throw new VersionMismatchException(profile.getMinecraft(), gameVersion); + return new GameDownloadTask(dependencyManager, manifest) + .thenComposeAsync(minecraftJar -> new ForgeNewInstallTask( + dependencyManager, + manifest, + minecraftJar, + modifyVersion(gameVersion, profile.getVersion()), + installer)); } else if (installProfile.containsKey("install") && installProfile.containsKey("versionInfo")) { ForgeInstallProfile profile = JsonUtils.fromNonNullJson(installProfileText, ForgeInstallProfile.class); - if (!gameVersion.get().equals(profile.install().getMinecraft())) - throw new VersionMismatchException(profile.install().getMinecraft(), gameVersion.get()); - return new ForgeOldInstallTask(dependencyManager, manifest, modifyVersion(gameVersion.get(), profile.install().getPath().getVersion().replaceAll("(?i)forge", "")), installer); + if (!gameVersion.equals(profile.install().getMinecraft())) + throw new VersionMismatchException(profile.install().getMinecraft(), gameVersion); + return new ForgeOldInstallTask(dependencyManager, manifest, modifyVersion(gameVersion, profile.install().getPath().getVersion().replaceAll("(?i)forge", "")), installer); } else { throw new IOException(); } 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..2d5d60b41fe 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()); @@ -196,6 +189,8 @@ public void execute() throws Exception { private final DefaultDependencyManager dependencyManager; private final DefaultGameRepository gameRepository; private final GameInstanceManifest manifest; + /// Source vanilla client JAR copied before processors are invoked. + private final Path minecraftJar; private final Path installer; private final List> dependents = new ArrayList<>(1); private final List> dependencies = new ArrayList<>(1); @@ -206,12 +201,25 @@ public void execute() throws Exception { private final String selfVersion; private Path tempDir; - private AtomicInteger processorDoneCount = new AtomicInteger(0); - - public ForgeNewInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest manifest, String selfVersion, Path installer) { + private final AtomicInteger processorDoneCount = new AtomicInteger(0); + + /// Creates a Forge processor installation task. + /// + /// @param dependencyManager repository-scoped download services + /// @param manifest working manifest receiving the Forge patch + /// @param minecraftJar source vanilla client JAR copied for processor use + /// @param selfVersion Forge version recorded in the returned patch + /// @param installer Forge installer JAR + public ForgeNewInstallTask( + DefaultDependencyManager dependencyManager, + GameInstanceManifest manifest, + Path minecraftJar, + String selfVersion, + Path installer) { this.dependencyManager = dependencyManager; this.gameRepository = dependencyManager.getGameRepository(); this.manifest = manifest; + this.minecraftJar = minecraftJar; this.installer = installer; this.selfVersion = selfVersion; @@ -268,7 +276,7 @@ private String parseLiteral(String literal, Map createProcessorTask(Processor processor, Map var @Override public void execute() throws Exception { + if (!Files.isRegularFile(minecraftJar)) { + throw new FileNotFoundException("Minecraft client JAR not found: " + minecraftJar); + } tempDir = Files.createTempDirectory("forge_installer"); + // External processors must not receive the shared cache path. + Path isolatedMinecraftJar = tempDir.resolve("minecraft.jar"); + FileUtils.copyFile(minecraftJar, isolatedMinecraftJar); Map vars = new HashMap<>(); @@ -409,11 +423,11 @@ public void execute() throws Exception { } vars.put("SIDE", "client"); - vars.put("MINECRAFT_JAR", FileUtils.getAbsolutePath(gameRepository.getInstanceJar(manifest))); - vars.put("MINECRAFT_VERSION", FileUtils.getAbsolutePath(gameRepository.getInstanceJar(manifest))); + vars.put("MINECRAFT_JAR", FileUtils.getAbsolutePath(isolatedMinecraftJar)); + vars.put("MINECRAFT_VERSION", profile.getMinecraft()); vars.put("ROOT", FileUtils.getAbsolutePath(gameRepository.getBaseDirectory())); vars.put("INSTALLER", installer.toAbsolutePath().toString()); - vars.put("LIBRARY_DIR", FileUtils.getAbsolutePath(gameRepository.getLibrariesDirectory(manifest))); + vars.put("LIBRARY_DIR", FileUtils.getAbsolutePath(gameRepository.getLayout().getLibrariesDirectory())); updateProgress(0, processors.size()); @@ -424,11 +438,11 @@ public void execute() throws Exception { dependencies.add( processorsTask.thenComposeAsync( - dependencyManager.checkLibraryCompletionAsync(forgeVersion, true))); + dependencyManager.checkComponentCompletionAsync(forgeVersion, true))); setResult(GameInstancePatch.fromManifest( forgeVersion, - LibraryAnalyzer.LibraryType.FORGE.getPatchId(), + GameComponentType.FORGE.getPatchId(), selfVersion, GameInstancePatch.PRIORITY_LOADER)); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeOldInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeOldInstallTask.java index b2793c27a57..6084d457e81 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeOldInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeOldInstallTask.java @@ -19,9 +19,10 @@ import org.jackhuang.hmcl.download.ArtifactMalformedException; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; +import org.jackhuang.hmcl.game.GameRepository; import org.jackhuang.hmcl.game.Library; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.JsonUtils; @@ -74,7 +75,8 @@ public void execute() throws Exception { // unpack the universal jar in the installer file. Library forgeLibrary = new Library(installProfile.install().getPath()); - Path forgeFile = dependencyManager.getGameRepository().getLibraryFile(manifest, forgeLibrary); + GameRepository gameRepository = dependencyManager.getGameRepository(); + Path forgeFile = gameRepository.getLayout().getLibraryFile(manifest.id(), forgeLibrary); Files.createDirectories(forgeFile.getParent()); ZipEntry forgeEntry = zipFile.getEntry(installProfile.install().getFilePath()); @@ -85,10 +87,10 @@ public void execute() throws Exception { setResult(GameInstancePatch.fromManifest( installProfile.versionInfo(), - LibraryAnalyzer.LibraryType.FORGE.getPatchId(), + GameComponentType.FORGE.getPatchId(), selfVersion, GameInstancePatch.PRIORITY_LOADER)); - dependencies.add(dependencyManager.checkLibraryCompletionAsync(installProfile.versionInfo(), true)); + dependencies.add(dependencyManager.checkComponentCompletionAsync(installProfile.versionInfo(), true)); } catch (ZipException ex) { throw new ArtifactMalformedException("Malformed forge installer file", ex); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeRemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeRemoteVersion.java index 34278a822c0..f93607092c0 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeRemoteVersion.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeRemoteVersion.java @@ -18,8 +18,8 @@ package org.jackhuang.hmcl.download.forge; 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; @@ -36,7 +36,7 @@ public class ForgeRemoteVersion extends RemoteVersion { * @param url the installer or universal jar original URL. */ public ForgeRemoteVersion(String gameVersion, String selfVersion, Instant releaseDate, List 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..01be3d36359 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 @@ -49,17 +49,17 @@ public final class GameAssetDownloadTask extends Task { private final List> dependents = new ArrayList<>(1); private final List> dependencies = new ArrayList<>(); - /** - * Constructor. - * - * @param dependencyManager the dependency manager that can provides {@link GameRepository} - * @param manifest the game version - */ + /// Constructor. + /// + /// @param dependencyManager the dependency manager that can provides [GameRepository] + /// @param manifest the game version public GameAssetDownloadTask(AbstractDependencyManager dependencyManager, GameInstanceManifest manifest, boolean forceDownloadingIndex, boolean integrityCheck) { this.dependencyManager = dependencyManager; - this.manifest = manifest.resolve(dependencyManager.getGameRepository()); + this.manifest = dependencyManager.getGameRepository().resolve(manifest).launchManifest(); 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 +90,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..6709cb5c3b6 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 @@ -18,54 +18,87 @@ package org.jackhuang.hmcl.download.game; import org.jackhuang.hmcl.download.DefaultDependencyManager; +import org.jackhuang.hmcl.game.DownloadInfo; import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.task.FileDownloadTask; +import org.jackhuang.hmcl.task.CacheFileTask; import org.jackhuang.hmcl.task.Task; -import org.jackhuang.hmcl.util.CacheRepository; +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.ArrayList; import java.util.Collection; import java.util.List; -/** - * Task to download Minecraft jar - * @author huangyuhui - */ -public final class GameDownloadTask extends Task { +/// Obtains a Minecraft client JAR from content-addressed cache storage. +@NotNullByDefault +public final class GameDownloadTask extends Task { + + /// The dependency manager supplying downloads and cache access. private final DefaultDependencyManager dependencyManager; - private final String gameVersion; + + /// The resolved manifest that supplies client download metadata. private final GameInstanceManifest manifest; + + /// The cache task created during execution. private final List> dependencies = new ArrayList<>(); - public GameDownloadTask(DefaultDependencyManager dependencyManager, String gameVersion, GameInstanceManifest manifest) { + /// Creates a task that returns a cached Minecraft client JAR. + /// + /// @param dependencyManager the dependency manager used for resolution and downloading + /// @param manifest the manifest supplying client download metadata + public GameDownloadTask( + DefaultDependencyManager dependencyManager, + GameInstanceManifest manifest) { this.dependencyManager = dependencyManager; - this.gameVersion = gameVersion; - this.manifest = manifest.resolve(dependencyManager.getGameRepository()); + this.manifest = dependencyManager.getGameRepository().resolve(manifest).launchManifest(); setSignificance(TaskSignificance.MODERATE); } + /// Returns the cache operation created by [#execute()], if execution has started. + /// + /// @return the live dependency collection @Override public Collection> getDependencies() { return dependencies; } + /// Creates the checksum-aware cache download. @Override public void execute() { - Path jar = dependencyManager.getGameRepository().getInstanceJar(manifest); - - var task = new FileDownloadTask( - dependencyManager.getDownloadProvider().injectURLWithCandidates(manifest.getDownloadInfo().getUrl()), - jar, - FileDownloadTask.IntegrityCheck.of(CacheRepository.SHA1, manifest.getDownloadInfo().getSha1())); - task.setCaching(true); - task.setCacheRepository(dependencyManager.getCacheRepository()); + DownloadInfo downloadInfo = manifest.getDownloadInfo(); + @Nullable String sha1 = downloadInfo.getSha1(); + CacheFileTask cacheTask = sha1 != null + ? new CacheFileTask( + dependencyManager.getDownloadProvider() + .injectURLWithCandidates(downloadInfo.getUrl()), + sha1) + : new CacheFileTask( + dependencyManager.getDownloadProvider() + .injectURLWithCandidates(downloadInfo.getUrl())); + cacheTask.setCacheRepository(dependencyManager.getCacheRepository()); + cacheTask.storeTo(this::setResult); + dependencies.add(cacheTask); + } - if (gameVersion != null) - task.setCandidate(dependencyManager.getCacheRepository().getCommonDirectory().resolve("jars").resolve(gameVersion + ".jar")); + /// Requests post-execution so the completed destination can be returned. + @Override + public boolean doPostExecute() { + return true; + } - dependencies.add(task); + /// Returns the downloaded or previously validated client JAR. + /// + /// @throws IOException if no regular cached JAR is available + @Override + public void postExecute() throws IOException { + @Nullable Path result = getResult(); + //noinspection ConstantValue + if (result == null || !Files.isRegularFile(result)) { + throw new IOException("Minecraft client JAR was not downloaded"); + } } - } 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..0ca46588df3 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 @@ -18,70 +18,91 @@ package org.jackhuang.hmcl.download.game; 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; import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jetbrains.annotations.NotNullByDefault; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.MINECRAFT; - +/// Downloads the base game component and returns its manifest patch without publishing it. +/// +/// The vanilla client JAR is downloaded into shared cache storage; libraries and assets use their +/// repository-wide stores. The caller owns the working manifest and must stage the returned patch +/// in its repository draft. +@NotNullByDefault public class GameInstallTask extends Task { - private final DefaultGameRepository gameRepository; + /// Dependency manager used by the download tasks. private final DefaultDependencyManager dependencyManager; + + /// Working instance manifest that will receive the game patch. private final GameInstanceManifest manifest; + + /// Selected remote game version. private final GameRemoteVersion remote; + + /// Task that downloads the selected version's manifest JSON. private final GameInstanceJsonDownloadTask downloadTask; + + /// Downloads scheduled after the remote manifest has been decoded. private final List> dependencies = new ArrayList<>(1); + /// Creates a base-game installation task. + /// + /// @param dependencyManager the dependency manager for the target repository + /// @param manifest the working instance manifest + /// @param remoteVersion the selected remote game version public GameInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest manifest, GameRemoteVersion remoteVersion) { this.dependencyManager = dependencyManager; - this.gameRepository = dependencyManager.getGameRepository(); this.manifest = manifest; this.remote = remoteVersion; this.downloadTask = new GameInstanceJsonDownloadTask(remoteVersion.getGameVersion(), dependencyManager); } + /// {@inheritDoc} @Override public Collection> getDependents() { return Collections.singleton(downloadTask); } + /// {@inheritDoc} @Override public Collection> getDependencies() { return dependencies; } + /// {@inheritDoc} @Override public boolean isRelyingOnDependencies() { return false; } + /// Decodes the downloaded game manifest and schedules its files without saving repository state. @Override 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); - GameInstanceManifest version = new GameInstanceManifest(this.manifest.id()).addPatch(patch); + GameInstanceManifest newManifest = new GameInstanceManifest(this.manifest.id()).addPatch(patch); dependencies.add(Task.allOf( - new GameDownloadTask(dependencyManager, remote.getGameVersion(), version), + new GameDownloadTask(dependencyManager, newManifest), Task.allOf( - new GameAssetDownloadTask(dependencyManager, version, GameAssetDownloadTask.DOWNLOAD_INDEX_FORCIBLY, true), - new GameLibrariesTask(dependencyManager, version, true) + new GameAssetDownloadTask(dependencyManager, newManifest, GameAssetDownloadTask.DOWNLOAD_INDEX_FORCIBLY, true), + new GameLibrariesTask(dependencyManager, newManifest, true) ).withRunAsync(() -> { // ignore failure }) - ).thenComposeAsync(gameRepository.saveAsync(version))); + )); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameInstanceJsonDownloadTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameInstanceJsonDownloadTask.java index 046159feda0..d3c37a8d66e 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameInstanceJsonDownloadTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameInstanceJsonDownloadTask.java @@ -20,6 +20,7 @@ import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.RemoteVersion; import org.jackhuang.hmcl.download.VersionList; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.task.GetTask; import org.jackhuang.hmcl.task.Task; @@ -42,7 +43,7 @@ public final class GameInstanceJsonDownloadTask extends Task { public GameInstanceJsonDownloadTask(String gameVersion, DefaultDependencyManager dependencyManager) { this.gameVersion = gameVersion; this.dependencyManager = dependencyManager; - this.gameVersionList = dependencyManager.getVersionList("game"); + this.gameVersionList = dependencyManager.getVersionList(GameComponentType.GAME); dependents.add(gameVersionList.loadAsync(gameVersion)); 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..6d817040d72 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; @@ -64,17 +59,17 @@ public final class GameLibrariesTask extends Task { * Constructor. * * @param dependencyManager the dependency manager that can provides {@link GameRepository} - * @param manifest the game version + * @param manifest the game version */ public GameLibrariesTask(AbstractDependencyManager dependencyManager, GameInstanceManifest manifest, boolean integrityCheck) { - this(dependencyManager, manifest, integrityCheck, manifest.resolve(dependencyManager.getGameRepository()).getLibraries()); + this(dependencyManager, manifest, integrityCheck, dependencyManager.getGameRepository().resolve(manifest).launchManifest().getLibraries()); } /** * Constructor. * * @param dependencyManager the dependency manager that can provides {@link GameRepository} - * @param manifest the game version + * @param manifest the game version */ public GameLibrariesTask(AbstractDependencyManager dependencyManager, GameInstanceManifest manifest, boolean integrityCheck, List libraries) { this.dependencyManager = dependencyManager; @@ -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; @@ -146,7 +142,7 @@ public void execute() throws IOException { } // https://github.com/HMCL-dev/HMCL/issues/3975 - if ("net.minecraftforge".equals(library.groupId()) && "minecraftforge".equals(library.artifactId()) + if (library.is("net.minecraftforge", "minecraftforge") && gameRepository instanceof DefaultGameRepository defaultGameRepository) { List fmlLibs = getFMLLibs(library.version()); if (fmlLibs != null) { @@ -165,25 +161,38 @@ public void execute() throws IOException { } } - Path file = gameRepository.getLibraryFile(manifest, 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))) { - try (FileSystem fs2 = CompressingUtils.createWritableZipFileSystem(file)) { - Files.deleteIfExists(fs2.getPath("/META-INF/mods.toml")); - } catch (IOException e) { - throw new IOException("Cannot fix optifine", e); + Path file = gameRepository.getLayout().getLibraryFile(manifest.id(), library); + if ("optifine".equals(library.groupId()) && Files.exists(file)) { + if (Files.exists(file) && libraries.stream().filter(it -> it.is("optifine", "OptiFine")) + .anyMatch(it -> it.version().startsWith("1.20.4_"))) { + @Nullable String forgeVersion = GameComponentAnalyzer.analyze(manifest, GameVersionNumber.asGameVersion("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 (library.is("org.jackhuang.hmcl", "mmc-bootstrap")) { 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 (library.is("org.jackhuang.hmcl", "transformer-discovery-service")) { + 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..510073482fb 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,58 @@ */ package org.jackhuang.hmcl.download.game; -import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; +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(); - if (Files.exists(jar) && GameVersionNumber.compare(gameVersion, "1.6") < 0 && analyzer.has(LibraryAnalyzer.LibraryType.FORGE)) { + if (Files.exists(jar) && gameVersion.compareTo("1.6") < 0 && instance.hasComponent(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..6181794c583 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,10 @@ 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.download.game.GameDownloadTask; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.task.FileDownloadTask; @@ -97,33 +98,61 @@ public Collection> getDependencies() { @Override public void execute() throws Exception { - dependency = install(dependencyManager, manifest, installer); + dependency = install(dependencyManager, manifest, remoteVersion.getGameVersion(), installer); } - public static Task install(DefaultDependencyManager dependencyManager, GameInstanceManifest version, Path installer) throws IOException, VersionMismatchException { - Optional gameVersion = dependencyManager.getGameRepository().getGameVersion(version); - if (!gameVersion.isPresent()) throw new IOException(); + /// Creates a task that installs NeoForge from a local installer JAR. + /// + /// The returned task obtains the matching vanilla client JAR from shared cache storage and + /// passes it explicitly to the selected processor implementation. + /// + /// @param dependencyManager repository-scoped download services + /// @param manifest working manifest receiving the NeoForge patch + /// @param gameVersion Minecraft version expected by the installation + /// @param installer the NeoForge installer JAR + /// @return the task producing the NeoForge patch + /// @throws IOException if the installer profile is missing, malformed, or + /// unsupported + /// @throws VersionMismatchException if the installer targets another Minecraft version + public static Task install( + DefaultDependencyManager dependencyManager, + GameInstanceManifest manifest, + String gameVersion, + Path installer) throws IOException, VersionMismatchException { 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 (!gameVersion.equals(profile.getMinecraft())) + throw new VersionMismatchException(profile.getMinecraft(), gameVersion); + return new GameDownloadTask(dependencyManager, manifest) + .thenComposeAsync(minecraftJar -> new ForgeNewInstallTask( + dependencyManager, + manifest, + minecraftJar, + modifyNeoForgeOldVersion(gameVersion, profile.getVersion()), + installer)) + .thenApplyAsync(neoForgeVersion -> { + 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()); - return new NeoForgeOldInstallTask(dependencyManager, version, modifyNeoForgeNewVersion(profile.getVersion()), installer); + if (!gameVersion.equals(profile.getMinecraft())) + throw new VersionMismatchException(profile.getMinecraft(), gameVersion); + return new GameDownloadTask(dependencyManager, manifest) + .thenComposeAsync(minecraftJar -> new NeoForgeOldInstallTask( + dependencyManager, + manifest, + minecraftJar, + modifyNeoForgeNewVersion(profile.getVersion()), + installer)); } else { throw new IOException(); } 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..522729d4df5 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()); @@ -174,6 +173,8 @@ public void execute() throws Exception { private final DefaultDependencyManager dependencyManager; private final DefaultGameRepository gameRepository; private final GameInstanceManifest manifest; + /// Source vanilla client JAR copied before processors are invoked. + private final Path minecraftJar; private final Path installer; private final List> dependents = new ArrayList<>(1); private final List> dependencies = new ArrayList<>(1); @@ -186,10 +187,23 @@ public void execute() throws Exception { private Path tempDir; private AtomicInteger processorDoneCount = new AtomicInteger(0); - NeoForgeOldInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest manifest, String selfVersion, Path installer) { + /// Creates a legacy NeoForge processor installation task. + /// + /// @param dependencyManager repository-scoped download services + /// @param manifest working manifest receiving the NeoForge patch + /// @param minecraftJar source vanilla client JAR copied for processor use + /// @param selfVersion NeoForge version recorded in the returned patch + /// @param installer NeoForge installer JAR + NeoForgeOldInstallTask( + DefaultDependencyManager dependencyManager, + GameInstanceManifest manifest, + Path minecraftJar, + String selfVersion, + Path installer) { this.dependencyManager = dependencyManager; this.gameRepository = dependencyManager.getGameRepository(); this.manifest = manifest; + this.minecraftJar = minecraftJar; this.installer = installer; this.selfVersion = selfVersion; @@ -246,7 +260,7 @@ private String parseLiteral(String literal, Map createProcessorTask(Processor processor, Map var @Override public void execute() throws Exception { + if (!Files.isRegularFile(minecraftJar)) { + throw new FileNotFoundException("Minecraft client JAR not found: " + minecraftJar); + } tempDir = Files.createTempDirectory("neoforge_installer"); + // External processors must not receive the shared cache path. + Path isolatedMinecraftJar = tempDir.resolve("minecraft.jar"); + FileUtils.copyFile(minecraftJar, isolatedMinecraftJar); Map vars = new HashMap<>(); @@ -387,11 +407,11 @@ public void execute() throws Exception { } vars.put("SIDE", "client"); - vars.put("MINECRAFT_JAR", FileUtils.getAbsolutePath(gameRepository.getInstanceJar(manifest))); - vars.put("MINECRAFT_VERSION", FileUtils.getAbsolutePath(gameRepository.getInstanceJar(manifest))); + vars.put("MINECRAFT_JAR", FileUtils.getAbsolutePath(isolatedMinecraftJar)); + vars.put("MINECRAFT_VERSION", profile.getMinecraft()); vars.put("ROOT", FileUtils.getAbsolutePath(gameRepository.getBaseDirectory())); vars.put("INSTALLER", installer.toAbsolutePath().toString()); - vars.put("LIBRARY_DIR", FileUtils.getAbsolutePath(gameRepository.getLibrariesDirectory(manifest))); + vars.put("LIBRARY_DIR", FileUtils.getAbsolutePath(gameRepository.getLayout().getLibrariesDirectory())); updateProgress(0, processors.size()); @@ -402,11 +422,11 @@ public void execute() throws Exception { dependencies.add( processorsTask.thenComposeAsync( - dependencyManager.checkLibraryCompletionAsync(neoForgeVersion, true))); + dependencyManager.checkComponentCompletionAsync(neoForgeVersion, true))); setResult(GameInstancePatch.fromManifest( neoForgeVersion, - LibraryAnalyzer.LibraryType.NEO_FORGE.getPatchId(), + GameComponentType.NEO_FORGE.getPatchId(), selfVersion, GameInstancePatch.PRIORITY_LOADER)); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeRemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeRemoteVersion.java index 9582c6c7f7d..4b3483f9610 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeRemoteVersion.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeRemoteVersion.java @@ -18,8 +18,8 @@ package org.jackhuang.hmcl.download.neoforge; 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; @@ -28,7 +28,7 @@ public class NeoForgeRemoteVersion extends RemoteVersion { public NeoForgeRemoteVersion(String gameVersion, String selfVersion, List 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..10f1c194486 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,9 +18,9 @@ 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.download.game.GameDownloadTask; import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.Task; @@ -34,6 +34,7 @@ import org.jenkinsci.constant_pool_scanner.ConstantPoolScanner; import org.jenkinsci.constant_pool_scanner.ConstantType; import org.jenkinsci.constant_pool_scanner.Utf8Constant; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.file.FileSystem; @@ -54,23 +55,48 @@ public final class OptiFineInstallTask extends Task { private final DefaultDependencyManager dependencyManager; private final GameInstanceManifest manifest; private final OptiFineRemoteVersion remote; - private final Path installer; + /// Vanilla client JAR used as patcher input. + private final Path minecraftJar; + private final @Nullable Path installer; private final List> dependents = new ArrayList<>(0); private final List> dependencies = new ArrayList<>(1); - private Path dest; + private @Nullable Path dest; private final Library optiFineLibrary; private final Library optiFineInstallerLibrary; - public OptiFineInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest manifest, OptiFineRemoteVersion remoteVersion) { - this(dependencyManager, manifest, remoteVersion, null); + /// Creates an OptiFine task that downloads its installer. + /// + /// @param dependencyManager repository-scoped download services + /// @param manifest working manifest receiving the OptiFine patch + /// @param remoteVersion selected OptiFine version + /// @param minecraftJar vanilla client JAR for the target Minecraft version + public OptiFineInstallTask( + DefaultDependencyManager dependencyManager, + GameInstanceManifest manifest, + OptiFineRemoteVersion remoteVersion, + Path minecraftJar) { + this(dependencyManager, manifest, remoteVersion, minecraftJar, null); } - public OptiFineInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest manifest, OptiFineRemoteVersion remoteVersion, Path installer) { + /// Creates an OptiFine task with an optional local installer. + /// + /// @param dependencyManager repository-scoped download services + /// @param manifest working manifest receiving the OptiFine patch + /// @param remoteVersion selected OptiFine version + /// @param minecraftJar vanilla client JAR for the target Minecraft version + /// @param installer local installer JAR, or `null` to download it + public OptiFineInstallTask( + DefaultDependencyManager dependencyManager, + GameInstanceManifest manifest, + OptiFineRemoteVersion remoteVersion, + Path minecraftJar, + @Nullable Path installer) { this.dependencyManager = dependencyManager; this.gameRepository = dependencyManager.getGameRepository(); this.manifest = manifest; this.remote = remoteVersion; + this.minecraftJar = minecraftJar; this.installer = installer; String mavenVersion = remote.getGameVersion() + "_" + remote.getSelfVersion(); @@ -92,17 +118,18 @@ public boolean doPreExecute() { @Override public void preExecute() throws Exception { - dest = Files.createTempFile("optifine-installer", ".jar"); + Path installerFile = Files.createTempFile("optifine-installer", ".jar"); + dest = installerFile; if (installer == null) { var task = new FileDownloadTask( dependencyManager.getDownloadProvider().injectURLsWithCandidates(remote.getUrls()), - dest, null); + installerFile, null); task.setCacheRepository(dependencyManager.getCacheRepository()); task.setCaching(true); dependents.add(task); } else { - FileUtils.copyFile(installer, dest); + FileUtils.copyFile(installer, installerFile); } } @@ -123,15 +150,19 @@ public boolean isRelyingOnDependencies() { @Override public void execute() throws Exception { - String originalMainClass = manifest.resolve(dependencyManager.getGameRepository()).mainClass(); - if (!LibraryAnalyzer.FORGE_OPTIFINE_MAIN.contains(originalMainClass)) + if (!Files.isRegularFile(minecraftJar)) { + throw new IOException("Minecraft client JAR not found: " + minecraftJar); + } + Path installerFile = Objects.requireNonNull(dest); + String originalMainClass = dependencyManager.getGameRepository().resolve(manifest).launchManifest().mainClass(); + 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); - FileUtils.copyFile(dest, optiFineInstallerLibraryPath); + Path optiFineInstallerLibraryPath = gameRepository.getLayout().getLibraryFile(manifest.id(), optiFineInstallerLibrary); + FileUtils.copyFile(installerFile, optiFineInstallerLibraryPath); try (FileSystem fs2 = CompressingUtils.createWritableZipFileSystem(optiFineInstallerLibraryPath)) { Files.deleteIfExists(fs2.getPath("/META-INF/mods.toml")); @@ -139,23 +170,23 @@ 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); + try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(installerFile)) { + Path optiFineLibraryPath = gameRepository.getLayout().getLibraryFile(manifest.id(), optiFineLibrary); if (Files.exists(fs.getPath("optifine/Patcher.class"))) { String[] command = { JavaRuntime.getDefault().getBinary().toString(), "-cp", - dest.toString(), + installerFile.toString(), "optifine.Patcher", - gameRepository.getInstanceJar(manifest).toAbsolutePath().normalize().toString(), - dest.toString(), + minecraftJar.toAbsolutePath().normalize().toString(), + installerFile.toString(), optiFineLibraryPath.toString() }; int exitCode = SystemUtils.callExternalProcess(command); if (exitCode != 0) throw new IOException("OptiFine patcher failed, command: " + new CommandBuilder().addAll(Arrays.asList(command))); } else { - FileUtils.copyFile(dest, optiFineLibraryPath); + FileUtils.copyFile(installerFile, optiFineLibraryPath); } try (FileSystem fs2 = CompressingUtils.createWritableZipFileSystem(optiFineLibraryPath)) { @@ -165,7 +196,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 +211,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 +225,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,30 +239,31 @@ 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 )); dependencies.add(new org.jackhuang.hmcl.download.game.GameLibrariesTask(dependencyManager, manifest, true, getResult().getLibraries())); } - /** - * Install OptiFine library from existing local file. - * - * @param dependencyManager game repository - * @param version instance manifest - * @param installer the OptiFine installer - * @return the task to install library - * @throws IOException if unable to read compressed content of installer file, or installer file is corrupted, or the installer is not the one we want. - * @throws VersionMismatchException if required game version of installer does not match the actual one. - */ - public static Task install(DefaultDependencyManager dependencyManager, GameInstanceManifest version, Path installer) throws IOException, VersionMismatchException { - Optional gameVersion = dependencyManager.getGameRepository().getGameVersion(version); - if (!gameVersion.isPresent()) throw new IOException(); + /// Creates a task that installs OptiFine from a local installer JAR. + /// + /// @param dependencyManager repository-scoped download services + /// @param version working manifest receiving the OptiFine patch + /// @param gameVersion Minecraft version expected by the installation + /// @param installer the OptiFine installer JAR + /// @return the task producing the OptiFine patch + /// @throws IOException if the installer is malformed or unsupported + /// @throws VersionMismatchException if the installer targets another Minecraft version + public static Task install( + DefaultDependencyManager dependencyManager, + GameInstanceManifest version, + String gameVersion, + Path installer) throws IOException, VersionMismatchException { try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(installer)) { Path configClass = fs.getPath("Config.class"); if (!Files.exists(configClass)) configClass = fs.getPath("net/optifine/Config.class"); @@ -247,11 +279,21 @@ public static Task install(DefaultDependencyManager dependenc if (mcVersion == null || ofEdition == null || ofRelease == null) throw new IOException("Unrecognized OptiFine installer"); - if (!mcVersion.equals(gameVersion.get())) - throw new VersionMismatchException(mcVersion, gameVersion.get()); - - return new OptiFineInstallTask(dependencyManager, version, - new OptiFineRemoteVersion(mcVersion, ofEdition + "_" + ofRelease, Collections.singletonList(""), false), installer); + if (!mcVersion.equals(gameVersion)) + throw new VersionMismatchException(mcVersion, gameVersion); + + OptiFineRemoteVersion remoteVersion = new OptiFineRemoteVersion( + mcVersion, + ofEdition + "_" + ofRelease, + Collections.singletonList(""), + false); + return new GameDownloadTask(dependencyManager, version) + .thenComposeAsync(minecraftJar -> new OptiFineInstallTask( + dependencyManager, + version, + remoteVersion, + minecraftJar, + installer)); } } } 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..b7b788b8ea3 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,9 @@ 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.download.game.GameDownloadTask; +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 +30,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 @@ -39,6 +40,11 @@ public String getFullVersion() { @Override public Task getInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest baseVersion) { - return new OptiFineInstallTask(dependencyManager, baseVersion, this); + return new GameDownloadTask(dependencyManager, baseVersion) + .thenComposeAsync(minecraftJar -> new OptiFineInstallTask( + dependencyManager, + baseVersion, + this, + minecraftJar)); } } 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..e747533e098 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; @@ -66,7 +61,7 @@ public boolean doPreExecute() { @Override public void preExecute() throws Exception { - if (!Objects.equals("net.minecraft.client.main.Main", manifest.resolve(dependencyManager.getGameRepository()).mainClass())) + if (!Objects.equals("net.minecraft.client.main.Main", dependencyManager.getGameRepository().resolve(manifest).launchManifest().mainClass())) throw new UnsupportedInstallationException(FABRIC_NOT_COMPATIBLE_WITH_FORGE); } @@ -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/CompatibilityRule.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/CompatibilityRule.java index 7e1549edbce..b52566ad7aa 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/CompatibilityRule.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/CompatibilityRule.java @@ -76,10 +76,6 @@ public static boolean appliesToCurrentEnvironment(Collection return action == Action.ALLOW; } - public static boolean equals(Collection rules1, Collection rules2) { - return Objects.hashCode(rules1) == Objects.hashCode(rules2); - } - @Override public boolean equals(Object o) { if (this == o) return true; 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..87a3a867424 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java @@ -0,0 +1,377 @@ +/* + * 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; + + private @Nullable GameComponentAnalyzer analyzer; + + /// 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; + } + + public DefaultGameRepositorySnapshot getSnapshot() { + return snapshot; + } + + @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; + } + + @Override + public GameComponentAnalyzer getAnalyzer() { + if (analyzer == null) { + analyzer = GameComponentAnalyzer.analyze(getResolvedManifest(), getVersion()); + } + return analyzer; + } + + /// {@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..27ea2690d59 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -18,33 +18,37 @@ 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.function.ExceptionalFunction; 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; import java.io.IOException; import java.nio.file.Files; -import java.nio.file.InvalidPathException; 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.concurrent.atomic.AtomicReference; 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 +84,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 +92,291 @@ 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; + + /// Atomically holds the repository's sole open draft, or `null` when no draft is active. + private final AtomicReference<@Nullable DefaultGameRepositoryDraft> activeDraft = new AtomicReference<>(); + + /// 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); + } + + /// 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); + + /// Returns whether a new draft may claim `instanceRoot` as draft-owned storage. + /// + /// The default implementation permits only a root that does not exist. Subclasses may recognize + /// an explicit pre-install reservation, but must not permit an unrelated pre-existing directory: + /// aborting the draft will recursively remove every claimed root. + /// + /// @param instanceId the instance being created + /// @param instanceRoot the normalized instance root + /// @return whether the draft may own and clean up the root + protected boolean mayClaimDraftInstanceRoot(GameInstanceID instanceId, Path instanceRoot) { + return Files.notExists(instanceRoot); + } + + /// Materializes subclass-specific data for a newly claimed draft instance root. + /// + /// This method is called during commit, after the draft has recorded ownership and created the + /// root, so failure cleanup will remove the root. The default implementation has no additional + /// data to materialize. + /// + /// @param instanceId the instance being created + /// @param instanceRoot the normalized instance root owned by the draft + /// @throws IOException if prepared data cannot be written + protected void initializeDraftInstanceRoot(GameInstanceID instanceId, Path instanceRoot) throws IOException { + } + + /// Replaces the repository layout with an empty snapshot rooted at `baseDirectory`. + /// + /// @param baseDirectory the new repository base directory + /// @throws IllegalStateException if a draft is active + public void setBaseDirectory(Path baseDirectory) { + checkNoActiveDraft("set base directory"); + // Mark unloaded before publishing so snapshot listeners do not treat the empty snapshot as ready. + this.loaded = false; + DefaultGameRepositorySnapshot initial = createSnapshot(createLayout(baseDirectory)); + publishSnapshot(initial); } - public Path getBaseDirectory() { - return status.baseDirectory; + /// {@inheritDoc} + /// + /// The returned snapshot is sealed and must not be modified. Normal writers must use + /// [#openDraft()]; refresh and layout replacement use the internal publication path. + @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. + /// + /// This is the low-level publication mechanism for draft commit, refresh, and layout + /// replacement. Other repository writes must use [#openDraft()]. + /// + /// 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); + }); } - public void setBaseDirectory(Path baseDirectory) { - this.status = new Status(baseDirectory); - this.loaded = false; - this.gameVersions.clear(); + /// Publishes the immutable successor snapshot of the repository's active draft. + /// + /// @param draft the active draft + /// @param newSnapshot the draft's successor snapshot + /// @throws IllegalStateException if `draft` is not the active draft + void publishDraftSnapshot( + DefaultGameRepositoryDraft draft, + DefaultGameRepositorySnapshot newSnapshot) { + checkActiveDraft(draft); + publishSnapshot(newSnapshot); + } + + /// Verifies that `draft` owns this repository's exclusive write session. + /// + /// @param draft the draft to verify + /// @throws IllegalStateException if `draft` is not active + void checkActiveDraft(DefaultGameRepositoryDraft draft) { + if (activeDraft.get() != draft) { + throw new IllegalStateException("Draft is not the active repository draft"); + } + } + + /// Releases the exclusive write session owned by `draft`. + /// + /// @param draft the draft that completed, aborted, or failed + /// @throws IllegalStateException if `draft` is not active + void releaseDraft(DefaultGameRepositoryDraft draft) { + if (!activeDraft.compareAndSet(draft, null)) { + throw new IllegalStateException("Draft is not the active repository draft"); + } + } + + /// 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; + } + + 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(); + } + } + + @Override + public DefaultGameRepositoryLayout getLayout() { + return getSnapshot().getLayout(); } public boolean isLoaded() { return loaded; } + /// {@inheritDoc} + /// + /// @throws IllegalStateException if a draft is active @Override public void refresh() { - if (EventBus.EVENT_BUS.fireEvent(new RefreshingInstancesEvent(this)) == Event.Result.DENY) { - return; - } - - refreshImpl(); - loaded = true; - EventBus.EVENT_BUS.fireEvent(new RefreshedGameInstancesEvent(this)); + checkNoActiveDraft("refresh"); + refreshRepository(); } - protected void refreshImpl() { - Status newStatus = new Status(status.baseDirectory); + /// Reloads and publishes repository state while the caller owns the direct-write session. + private void refreshRepository() { + 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 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(); - 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(); + for (CompletableFuture<@Nullable DefaultGameInstance> future : futures) { + try { + DefaultGameInstance instance = future.join(); + if (instance != null) { + newSnapshot.put(instance); } + } catch (Exception e) { + LOG.warning("Failed to load instance", e); } + } + } catch (IOException e) { + LOG.warning("Failed to load instance from " + instancesDir, e); + } + } - GameInstanceManifest manifest; - 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(); - } + // 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; + } - try { - manifest = readInstanceManifest(json); - } catch (Exception e2) { - LOG.error("User corrected version json is still malformed", e2); - return Stream.empty(); - } - } + DefaultGameRepositoryLayout layout = snapshot.getLayout(); + Path conventionalJson = layout.getInstanceJson(id); - 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(); - } - } + Path json; + @Nullable Path manifestFileOverride = null; - 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); + 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; } - } - Map loadedInstances = new TreeMap<>(); - for (InstanceHolder holder : newStatus.instances.values()) { - try { - GameInstanceManifest resolved = newStatus.resolve(holder.manifest, new HashSet<>()).launchManifest(); - if (CompatibilityRule.appliesToCurrentEnvironment(resolved.compatibilityRules())) { - loadedInstances.put(holder.id, holder); - } - } catch (NoSuchGameInstanceException e) { - LOG.warning("Ignoring version " + holder.id + " because it inherits from a nonexistent version."); + 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); } - newStatus.instances.clear(); - newStatus.instances.putAll(loadedInstances); - gameVersions.clear(); - this.status = newStatus; + return createInstance(snapshot, id, manifest, manifestFileOverride); } private static GameInstanceManifest readInstanceManifest(Path json) throws IOException, JsonParseException { @@ -233,10 +387,10 @@ private static GameInstanceManifest readInstanceManifest(Path json) throws IOExc return manifest; } - 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()); + static void moveInstanceFiles(Path baseDirectory, GameInstanceID from, GameInstanceID to) throws IOException { + 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,169 +406,102 @@ 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); - if (fromHolder == null) { - throw new NoSuchGameInstanceException(from); - } - - moveInstanceFiles(currentStatus.baseDirectory, from, to); - - GameInstanceManifest renamedManifest = fromHolder.manifest; - if (from.equals(renamedManifest.jar())) { - renamedManifest = renamedManifest.withJar(null); - } - 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)); - - for (InstanceHolder holder : currentStatus.instances.values()) { - GameInstanceManifest manifest = holder.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)); - } - } - - currentStatus.instances.clear(); - currentStatus.instances.putAll(updatedInstances); - gameVersions.clear(); + try (DefaultGameRepositoryDraft draft = openDraft()) { + draft.rename(from, to); + draft.commit(); return true; - } catch (IOException | JsonParseException | NoSuchGameInstanceException | InvalidPathException e) { - LOG.warning("Unable to rename version " + from + " to " + to, e); + } catch (IOException | JsonParseException | NoSuchGameInstanceException | IllegalArgumentException 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. + /// + /// Registered instances are removed through an exclusive draft: their roots are first moved to + /// draft-private storage, the new snapshot is published once, and the staged roots are then + /// deleted. An unregistered orphan directory uses the legacy trash-or-delete cleanup path and is + /// followed by a repository refresh. + /// + /// @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) { + try (DefaultGameRepositoryDraft draft = openDraft()) { + draft.remove(id); + draft.commit(); + return true; + } catch (IOException e) { + LOG.warning("Unable to remove instance " + id, e); + return false; + } } - Path removedFile = file.toAbsolutePath().resolveSibling(FileUtils.getName(file) + "_removed"); + checkNoActiveDraft("remove instance"); 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 +517,191 @@ 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(); + refreshRepository(); } } @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); + DefaultGameInstance instance = findSnapshotInstance(instanceId); + if (instance != null) { + return instance.getManifestFile(); } + return getLayout().getInstanceJson(instanceId); } - @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"); + /// Returns the run directory to use while installing an instance before it is published. + /// + /// The default official-layout repository uses its shared base directory. Subclasses may derive + /// an isolated directory from repository-specific settings without creating a [GameInstance]. + /// + /// @param instanceId the instance being installed + /// @return the installation run directory + public Path getRunDirectoryForInstallation(GameInstanceID instanceId) { + return getBaseDirectory(); } + /// Opens a draft for staging instance index changes and committing them once. + /// + /// @return a new open draft @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); + public DefaultGameRepositoryDraft openDraft() { + DefaultGameRepositoryDraft draft = new DefaultGameRepositoryDraft(this); + if (!activeDraft.compareAndSet(null, draft)) { + throw new IllegalStateException("Another repository draft is already open"); } - } - - @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; + return draft; + } + + /// Writes a stored manifest and publishes a new snapshot in a single draft commit. + /// + /// @param instanceManifest the persistent manifest to save + /// @return the saved manifest + /// @throws IOException if the manifest cannot be written + public GameInstanceManifest save(GameInstanceManifest instanceManifest) throws IOException { + try (DefaultGameRepositoryDraft draft = openDraft()) { + draft.put(instanceManifest); + draft.commit(); } - - return assetsDir; + return instanceManifest; } + /// 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(() -> save(instanceManifest)); + } + + /// Creates a task that updates one registered instance inside an exclusive draft. + /// + /// The updater receives the instance from the immutable published snapshot and must return a + /// working manifest with the same id. Its result is staged and committed exactly once. Failure + /// or cancellation aborts the draft; shared cache files written by the updater are retained. + /// + /// @param the checked exception type thrown while creating the update task + /// @param instanceId the instance to update + /// @param updater the asynchronous manifest update + /// @return the task that commits the updated manifest + public Task updateInstanceAsync( + GameInstanceID instanceId, + ExceptionalFunction, E> updater) { + var active = new AtomicReference<@Nullable GameRepositoryDraft>(); return Task.supplyAsync(() -> { - GameInstanceManifest savedManifest = instanceManifest.isResolvedPreservingPatches() - ? MaintainTask.maintainPreservingPatches(this, instanceManifest) - : instanceManifest; - - Path json = getInstanceJson(savedManifest.id()).toAbsolutePath(); - Files.createDirectories(json.getParent()); - JsonUtils.writeToJsonFile(json, savedManifest); - - Status currentStatus = status; - currentStatus.instances.put(savedManifest.id(), new InstanceHolder(currentStatus, savedManifest.id(), savedManifest)); - gameVersions.clear(); - return savedManifest; - }); - } - - 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); + GameRepositoryDraft draft = openDraft(); + active.set(draft); + GameInstance publishedInstance = getInstance(instanceId); + return publishedInstance; + }) + .thenComposeAsync(updater) + .thenApplyAsync(manifest -> { + GameRepositoryDraft draft = active.get(); + if (draft == null) { + throw new IllegalStateException("Game repository draft is unavailable"); + } + if (!instanceId.equals(manifest.id())) { + throw new IllegalArgumentException( + "Instance updater changed id from " + instanceId + " to " + manifest.id()); + } + draft.put(manifest); + draft.commit(); + return manifest; + }) + .whenComplete(exception -> { + GameRepositoryDraft draft = active.getAndSet(null); + if (draft != null && draft.isOpen()) { + draft.abort(); + } + }); } @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; - } - - 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); + 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); + + /// Verifies that no draft is currently active. + /// + /// @param operation operation rejected when a draft is active + /// @throws IllegalStateException if a draft is active + private void checkNoActiveDraft(String operation) { + if (activeDraft.get() != null) { + throw new IllegalStateException("Repository has an open draft; cannot " + operation); } - - 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/DefaultGameRepositoryDraft.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java new file mode 100644 index 00000000000..d8caef6366c --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java @@ -0,0 +1,749 @@ +/* + * 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.gson.JsonUtils; +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// Default exclusive [GameRepositoryDraft] implementation. +/// +/// Manifest changes are retained in memory until commit. A successful commit writes the final +/// manifests and primary JARs, applies removals and renames, and publishes one new immutable +/// snapshot. Missing new-instance roots are created while committing. Shared library and asset +/// cache writes are outside the rollback boundary. Instances of this class are not thread-safe. +@NotNullByDefault +public final class DefaultGameRepositoryDraft implements GameRepositoryDraft { + + /// Repository whose published snapshot will be replaced on commit. + private final DefaultGameRepository repository; + + /// Immutable published snapshot captured when this draft was opened. + private final DefaultGameRepositorySnapshot baseSnapshot; + + /// Current unpublished manifests keyed by instance id. + private final Map manifests = new TreeMap<>(); + + /// Instance ids whose final manifests differ from the published snapshot. + private final Set modifiedIds = new TreeSet<>(); + + /// Completed primary JAR sources to copy into instance roots during commit. + private final Map primaryJarSources = new TreeMap<>(); + + /// Instance ids whose root directories were absent before this draft first added them. + private final Set createdIds = new TreeSet<>(); + + /// Instance ids absent from the final manifest set. + private final Set removedIds = new TreeSet<>(); + + /// Ordered instance renames applied to the filesystem during commit. + private final List renames = new ArrayList<>(); + + /// Current lifecycle state. + private GameRepositoryDraft.State state = GameRepositoryDraft.State.OPEN; + + /// Creates an open draft over the repository's current published snapshot. + /// + /// @param repository the repository that owns this draft + DefaultGameRepositoryDraft(DefaultGameRepository repository) { + this.repository = repository; + this.baseSnapshot = repository.getSnapshot(); + for (GameInstanceManifest manifest : baseSnapshot.getInstanceManifests()) { + manifests.put(manifest.id(), manifest); + } + } + + /// {@inheritDoc} + @Override + public DefaultGameRepository getRepository() { + return repository; + } + + public DefaultGameRepositorySnapshot getBaseSnapshot() { + return baseSnapshot; + } + + /// {@inheritDoc} + @Override + public GameRepositoryDraft.State getState() { + return state; + } + + /// {@inheritDoc} + @Override + public boolean isOpen() { + return state == GameRepositoryDraft.State.OPEN; + } + + /// {@inheritDoc} + @Override + public boolean isCommitted() { + return state == GameRepositoryDraft.State.COMMITTED; + } + + /// {@inheritDoc} + @Override + public void put(GameInstanceManifest manifest) throws IOException { + checkOpen(); + putManifest(manifest, true); + } + + /// {@inheritDoc} + @Override + public void putPrimaryJar(GameInstanceID instanceId, Path source) throws IOException { + checkOpen(); + if (!manifests.containsKey(instanceId)) { + throw new NoSuchGameInstanceException(instanceId); + } + + Path normalizedSource = source.toAbsolutePath().normalize(); + if (!Files.isRegularFile(normalizedSource)) { + throw new IOException("Primary JAR source is not a regular file: " + normalizedSource); + } + + Path target = getPrimaryJarTarget(instanceId); + if (normalizedSource.equals(target)) { + primaryJarSources.remove(instanceId); + } else { + primaryJarSources.put(instanceId, normalizedSource); + } + } + + /// {@inheritDoc} + @Override + public void remove(GameInstanceID instanceId) { + checkOpen(); + if (manifests.remove(instanceId) == null) { + throw new NoSuchGameInstanceException(instanceId); + } + + modifiedIds.remove(instanceId); + primaryJarSources.remove(instanceId); + removedIds.add(instanceId); + } + + /// {@inheritDoc} + @Override + public void rename(GameInstanceID from, GameInstanceID to) throws IOException { + checkOpen(); + @Nullable GameInstanceManifest source = manifests.get(from); + if (source == null) { + throw new NoSuchGameInstanceException(from); + } + if (createdIds.contains(from)) { + throw new IllegalStateException("Cannot rename an instance created by the same draft"); + } + if (manifests.containsKey(to)) { + throw new IllegalArgumentException("Target instance already exists: " + to); + } + + Path targetRoot = getValidatedInstanceRoot(to); + if (Files.exists(targetRoot)) { + throw new FileAlreadyExistsException(targetRoot.toString()); + } + + GameInstanceManifest renamedManifest = source; + if (from.equals(renamedManifest.jar())) { + renamedManifest = renamedManifest.withJar(null); + } + renamedManifest = renamedManifest.withId(to); + + manifests.remove(from); + modifiedIds.remove(from); + removedIds.remove(from); + putManifest(renamedManifest, false); + + @Nullable Path primaryJarSource = primaryJarSources.remove(from); + if (primaryJarSource != null) { + primaryJarSources.put(to, primaryJarSource); + } + + manifests.replaceAll((id, manifest) -> { + if (!from.equals(manifest.inheritsFrom())) { + return manifest; + } + modifiedIds.add(id); + return manifest.withInheritsFrom(to); + }); + renames.add(new RenameOperation(from, to)); + } + + /// Updates one manifest in the in-memory write set. + /// + /// @param manifest the manifest to retain + /// @param claimNewRoot whether a previously absent instance root should become draft-owned + /// @throws IOException if a new instance root cannot be reserved + private void putManifest( + GameInstanceManifest manifest, + boolean claimNewRoot) throws IOException { + + GameInstanceID id = manifest.id(); + if (claimNewRoot + && !manifests.containsKey(id) + && baseSnapshot.get(id) == null + && !createdIds.contains(id)) { + Path root = getValidatedInstanceRoot(id); + if (!repository.mayClaimDraftInstanceRoot(id, root)) { + throw new FileAlreadyExistsException(root.toString(), null, + "An unregistered instance directory already exists"); + } + createdIds.add(id); + } + + manifests.put(id, manifest); + removedIds.remove(id); + modifiedIds.add(id); + } + + /// {@inheritDoc} + @Override + public DefaultGameRepositorySnapshot commit() throws IOException { + checkOpen(); + repository.checkActiveDraft(this); + state = GameRepositoryDraft.State.COMMITTING; + + List appliedRenames = new ArrayList<>(); + List removedRoots = new ArrayList<>(); + List appliedFiles = new ArrayList<>(); + @Nullable Path rollbackDirectory = null; + try { + DefaultGameRepositorySnapshot committedSnapshot = buildCommittedSnapshot(); + for (RenameOperation rename : renames) { + applyRename(rename, appliedRenames); + } + materializeCreatedInstanceRoots(); + + if (!removedIds.isEmpty() || !modifiedIds.isEmpty() || !primaryJarSources.isEmpty()) { + Path currentRollbackDirectory = createRollbackDirectory(); + rollbackDirectory = currentRollbackDirectory; + for (GameInstanceID id : removedIds) { + removeInstanceRoot(id, currentRollbackDirectory, removedRoots); + } + for (Map.Entry entry : primaryJarSources.entrySet()) { + applyPrimaryJar( + entry.getKey(), + entry.getValue(), + currentRollbackDirectory, + appliedFiles); + } + for (GameInstanceID id : modifiedIds) { + @Nullable GameInstanceManifest manifest = manifests.get(id); + if (manifest == null) { + throw new IllegalStateException("Modified manifest is missing: " + id); + } + applyManifest(id, manifest, currentRollbackDirectory, appliedFiles); + } + } + + repository.publishDraftSnapshot(this, committedSnapshot); + state = GameRepositoryDraft.State.COMMITTED; + repository.releaseDraft(this); + cleanupRollbackDirectoryAfterCommit(rollbackDirectory); + return committedSnapshot; + } catch (IOException | RuntimeException e) { + IOException rollbackFailure = rollbackAppliedFiles(appliedFiles); + rollbackFailure = accumulateNullable(rollbackFailure, rollbackRemovedRoots(removedRoots)); + rollbackFailure = accumulateNullable(rollbackFailure, rollbackRenames(appliedRenames)); + state = GameRepositoryDraft.State.FAILED; + repository.releaseDraft(this); + IOException cleanupFailure = cleanupCreatedInstanceRoots(); + cleanupFailure = accumulateNullable(cleanupFailure, cleanupRollbackDirectory(rollbackDirectory)); + if (rollbackFailure != null) { + e.addSuppressed(rollbackFailure); + } + if (cleanupFailure != null) { + e.addSuppressed(cleanupFailure); + } + throw e; + } + } + + /// Builds the immutable successor snapshot represented by the final manifest write set. + /// + /// @return the sealed snapshot to publish after filesystem changes succeed + private DefaultGameRepositorySnapshot buildCommittedSnapshot() { + DefaultGameRepositorySnapshot committedSnapshot = baseSnapshot.mutableCopy(); + for (RenameOperation rename : renames) { + committedSnapshot.remove(rename.from()); + } + for (GameInstanceID id : removedIds) { + committedSnapshot.remove(id); + } + for (GameInstanceID id : modifiedIds) { + @Nullable GameInstanceManifest manifest = manifests.get(id); + if (manifest == null) { + throw new IllegalStateException("Modified manifest is missing: " + id); + } + @Nullable DefaultGameInstance existing = committedSnapshot.get(manifest.id()); + DefaultGameInstance updated = existing != null + ? existing.withManifest(committedSnapshot, manifest) + : repository.createInstance(committedSnapshot, manifest.id(), manifest); + committedSnapshot.put(updated); + } + committedSnapshot.seal(); + return committedSnapshot; + } + + /// Creates and initializes roots reserved for instances added by this draft. + /// + /// @throws IOException if a root or repository-specific initial data cannot be created + private void materializeCreatedInstanceRoots() throws IOException { + for (GameInstanceID id : createdIds) { + if (!manifests.containsKey(id)) { + continue; + } + Path root = getValidatedInstanceRoot(id); + Files.createDirectories(root); + repository.initializeDraftInstanceRoot(id, root); + } + } + + /// {@inheritDoc} + @Override + public void abort() throws IOException { + if (state == GameRepositoryDraft.State.ABORTED) { + return; + } + if (state == GameRepositoryDraft.State.COMMITTED) { + throw new IllegalStateException("Draft is already committed"); + } + if (state == GameRepositoryDraft.State.COMMITTING) { + throw new IllegalStateException("Draft is committing"); + } + if (state == GameRepositoryDraft.State.FAILED) { + return; + } + + IOException failure = cleanupCreatedInstanceRoots(); + state = failure == null ? GameRepositoryDraft.State.ABORTED : GameRepositoryDraft.State.FAILED; + repository.releaseDraft(this); + if (failure != null) { + throw failure; + } + } + + /// {@inheritDoc} + @Override + public void close() throws IOException { + if (state == GameRepositoryDraft.State.OPEN) { + abort(); + } + } + + /// Returns the permanent manifest target for an instance. + /// + /// Existing instances retain a non-conventional manifest path discovered by refresh. New + /// instances use the conventional path from the base layout. + /// + /// @param id the instance id + /// @return the permanent manifest path + private Path getManifestTarget(GameInstanceID id) { + @Nullable DefaultGameInstance existing = baseSnapshot.get(id); + return (existing != null ? existing.getManifestFile() : baseSnapshot.getLayout().getInstanceJson(id)) + .toAbsolutePath() + .normalize(); + } + + /// Returns and validates the permanent target for an instance's own primary JAR. + /// + /// Existing instances retain a non-conventional JAR path discovered during refresh. New and + /// renamed instances use the conventional path from the repository layout. + /// + /// @param id the instance id + /// @return the normalized primary JAR target + /// @throws IOException if the target escapes the instance root + private Path getPrimaryJarTarget(GameInstanceID id) throws IOException { + @Nullable DefaultGameInstance existing = baseSnapshot.get(id); + Path target = (existing != null + ? existing.getOwnJarFile() + : baseSnapshot.getLayout().getInstanceJarFile(id)) + .toAbsolutePath() + .normalize(); + validateInstanceFileTarget(id, target, "Primary JAR"); + return target; + } + + /// Verifies that a file target is a strict descendant of its instance root. + /// + /// @param id the owning instance id + /// @param target the normalized target path + /// @param description description used in an exception message + /// @throws IOException if the target is outside the instance root + private void validateInstanceFileTarget( + GameInstanceID id, + Path target, + String description) throws IOException { + Path expectedRoot = getValidatedInstanceRoot(id); + if (target.equals(expectedRoot) || !target.startsWith(expectedRoot)) { + throw new IOException(description + " path escapes instance root: " + target); + } + } + + /// Creates a directory for rollback data produced by the current commit attempt. + /// + /// @return the new rollback directory + /// @throws IOException if the directory cannot be created + private Path createRollbackDirectory() throws IOException { + Path parent = baseSnapshot.getLayout().getBaseDirectory() + .toAbsolutePath() + .normalize() + .resolve(".hmcl") + .resolve("repository-drafts"); + Files.createDirectories(parent); + return Files.createTempDirectory(parent, "commit-"); + } + + /// Applies one instance directory rename. + /// + /// @param rename the requested rename + /// @param applied rollback records for completed renames + /// @throws IOException if the source files cannot be renamed + private void applyRename(RenameOperation rename, List applied) throws IOException { + Path sourceRoot = getValidatedInstanceRoot(rename.from()); + Path targetRoot = getValidatedInstanceRoot(rename.to()); + if (!Files.isDirectory(sourceRoot)) { + throw new IOException("Instance directory does not exist: " + sourceRoot); + } + if (Files.exists(targetRoot)) { + throw new FileAlreadyExistsException(targetRoot.toString()); + } + + DefaultGameRepository.moveInstanceFiles( + baseSnapshot.getLayout().getBaseDirectory(), + rename.from(), + rename.to()); + applied.add(rename); + } + + /// Moves one removed instance root into the commit rollback directory. + /// + /// @param id the removed instance id + /// @param rollbackDirectory the directory owned by the current commit attempt + /// @param removed rollback records for roots moved out of the repository + /// @throws IOException if the root cannot be moved into the rollback directory + private void removeInstanceRoot( + GameInstanceID id, + Path rollbackDirectory, + List removed) throws IOException { + Path root = getValidatedInstanceRoot(id); + if (Files.notExists(root)) { + return; + } + + Path removals = rollbackDirectory.resolve("removed"); + Files.createDirectories(removals); + Path rollbackRoot = Files.createTempDirectory(removals, "instance-"); + Files.delete(rollbackRoot); + moveReplacing(root, rollbackRoot); + removed.add(new RemovedRoot(root, rollbackRoot)); + } + + /// Writes one manifest while retaining a rollback copy. + /// + /// @param id the instance whose manifest will be replaced + /// @param manifest the final manifest + /// @param rollbackDirectory the directory owned by the current commit attempt + /// @param applied rollback records for changes already started + /// @throws IOException if the target cannot be backed up or replaced + private void applyManifest( + GameInstanceID id, + GameInstanceManifest manifest, + Path rollbackDirectory, + List applied) throws IOException { + String json = JsonUtils.GSON.toJson(manifest); + Path target = getManifestTarget(id); + validateInstanceFileTarget(id, target, "Manifest"); + + Files.createDirectories(target.getParent()); + @Nullable Path backup = backupFile(target, rollbackDirectory, "manifest-", ".json"); + applied.add(new AppliedFile(target, backup)); + Files.writeString(target, json); + } + + /// Copies a completed primary JAR into its permanent instance location while retaining a + /// rollback copy of an existing target. + /// + /// @param id the instance receiving the JAR + /// @param source the completed source JAR + /// @param rollbackDirectory the directory holding rollback files + /// @param applied rollback records for files already changed + /// @throws IOException if the source or target cannot be read or written + private void applyPrimaryJar( + GameInstanceID id, + Path source, + Path rollbackDirectory, + List applied) throws IOException { + if (!Files.isRegularFile(source)) { + throw new IOException("Primary JAR source is not a regular file: " + source); + } + + Path target = getPrimaryJarTarget(id); + Files.createDirectories(target.getParent()); + @Nullable Path backup = backupFile(target, rollbackDirectory, "jar-", ".jar"); + applied.add(new AppliedFile(target, backup)); + Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); + } + + /// Moves an existing target into rollback storage. + /// + /// @param target the file about to be replaced + /// @param rollbackDirectory the directory holding rollback files + /// @param prefix the backup file prefix + /// @param suffix the backup file suffix + /// @return the backup path, or `null` when the target did not exist + /// @throws IOException if the target cannot be backed up + private static @Nullable Path backupFile( + Path target, + Path rollbackDirectory, + String prefix, + String suffix) throws IOException { + if (Files.notExists(target)) { + return null; + } + + Path backups = rollbackDirectory.resolve("backups"); + Files.createDirectories(backups); + Path backup = Files.createTempFile(backups, prefix, suffix); + Files.delete(backup); + moveReplacing(target, backup); + return backup; + } + + /// Restores files changed by an unsuccessful commit in reverse application order. + /// + /// @param applied applied file records + /// @return the aggregated rollback failure, or `null` when rollback succeeded + private static @Nullable IOException rollbackAppliedFiles(List applied) { + @Nullable IOException failure = null; + List reversed = new ArrayList<>(applied); + Collections.reverse(reversed); + for (AppliedFile file : reversed) { + try { + Files.deleteIfExists(file.targetFile()); + if (file.backupFile() != null) { + moveReplacing(file.backupFile(), file.targetFile()); + } + } catch (IOException e) { + failure = accumulate(failure, e); + } + } + return failure; + } + + /// Restores roots moved out of the repository by an unsuccessful commit. + /// + /// @param removed removed-root rollback records + /// @return the aggregated rollback failure, or `null` when rollback succeeded + private static @Nullable IOException rollbackRemovedRoots(List removed) { + @Nullable IOException failure = null; + List reversed = new ArrayList<>(removed); + Collections.reverse(reversed); + for (RemovedRoot root : reversed) { + try { + moveReplacing(root.rollbackRoot(), root.originalRoot()); + } catch (IOException e) { + failure = accumulate(failure, e); + } + } + return failure; + } + + /// Reverses instance renames completed by an unsuccessful commit. + /// + /// @param applied completed rename records + /// @return the aggregated rollback failure, or `null` when rollback succeeded + private @Nullable IOException rollbackRenames(List applied) { + @Nullable IOException failure = null; + List reversed = new ArrayList<>(applied); + Collections.reverse(reversed); + for (RenameOperation rename : reversed) { + try { + DefaultGameRepository.moveInstanceFiles( + baseSnapshot.getLayout().getBaseDirectory(), + rename.to(), + rename.from()); + } catch (IOException e) { + failure = accumulate(failure, e); + } + } + return failure; + } + + /// Removes instance roots first created by this draft. + /// + /// @return the aggregated cleanup failure, or `null` when cleanup succeeded + private @Nullable IOException cleanupCreatedInstanceRoots() { + @Nullable IOException failure = null; + for (GameInstanceID id : createdIds) { + try { + Path root = getValidatedInstanceRoot(id); + if (Files.exists(root)) { + FileUtils.deleteDirectory(root); + } + } catch (IOException | RuntimeException e) { + IOException cleanupException = e instanceof IOException ioException + ? ioException + : new IOException("Failed to remove draft-created instance " + id, e); + failure = accumulate(failure, cleanupException); + } + } + return failure; + } + + /// Removes a commit rollback directory. + /// + /// @param rollbackDirectory the directory to remove, or `null` if none was created + /// @return the cleanup failure, or `null` when cleanup succeeded + private static @Nullable IOException cleanupRollbackDirectory(@Nullable Path rollbackDirectory) { + if (rollbackDirectory == null) { + return null; + } + try { + FileUtils.deleteDirectory(rollbackDirectory); + return null; + } catch (IOException e) { + return e; + } + } + + /// Removes rollback data after a successful commit without changing its outcome. + /// + /// @param rollbackDirectory the directory to remove, or `null` if none was created + private static void cleanupRollbackDirectoryAfterCommit(@Nullable Path rollbackDirectory) { + if (rollbackDirectory == null) { + return; + } + try { + FileUtils.deleteDirectory(rollbackDirectory); + } catch (IOException e) { + LOG.warning("Failed to remove commit rollback directory " + rollbackDirectory, e); + } + } + + /// Returns a normalized instance root after verifying that it is a strict descendant of the + /// repository's versions directory. + /// + /// @param id the instance id + /// @return the validated instance root + /// @throws IOException if the resolved root escapes the versions directory + private Path getValidatedInstanceRoot(GameInstanceID id) throws IOException { + Path versions = baseSnapshot.getLayout().getBaseDirectory() + .toAbsolutePath() + .normalize() + .resolve("versions"); + Path root = baseSnapshot.getLayout().getInstanceRoot(id).toAbsolutePath().normalize(); + if (root.equals(versions) || !root.startsWith(versions)) { + throw new IOException("Instance root escapes versions directory: " + root); + } + return root; + } + + /// Moves a file to `target`, using an atomic move when supported by the file system. + /// + /// @param source the source file + /// @param target the target file + /// @throws IOException if both atomic and regular replacement fail + private static void moveReplacing(Path source, Path target) throws IOException { + try { + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException atomicFailure) { + try { + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException replacementFailure) { + replacementFailure.addSuppressed(atomicFailure); + throw replacementFailure; + } + } + } + + /// Aggregates an additional cleanup failure. + /// + /// @param current the current aggregate, or `null` + /// @param additional the additional failure + /// @return the resulting aggregate + private static IOException accumulate(@Nullable IOException current, IOException additional) { + if (current == null) { + return additional; + } + current.addSuppressed(additional); + return current; + } + + /// Combines two optional failure aggregates. + /// + /// @param current the current aggregate, or `null` + /// @param additional the additional aggregate, or `null` + /// @return the combined aggregate, or `null` when both arguments are `null` + private static @Nullable IOException accumulateNullable( + @Nullable IOException current, + @Nullable IOException additional) { + if (additional == null) { + return current; + } + return accumulate(current, additional); + } + + /// Ensures the draft accepts changes. + /// + /// @throws IllegalStateException if the draft is not open + private void checkOpen() { + if (state != GameRepositoryDraft.State.OPEN) { + throw new IllegalStateException("Draft is " + state.name().toLowerCase(Locale.ROOT)); + } + } + + /// Records enough information to roll back one file replacement. + /// + /// @param targetFile the permanent file path + /// @param backupFile the prior file backup, or `null` when no prior file existed + private record AppliedFile( + Path targetFile, + @Nullable Path backupFile) { + } + + /// Records an instance rename requested by the draft. + /// + /// @param from the source instance id + /// @param to the target instance id + private record RenameOperation(GameInstanceID from, GameInstanceID to) { + } + + /// Records an instance root moved aside for rollback during commit. + /// + /// @param originalRoot the published instance root + /// @param rollbackRoot the temporary rollback path + private record RemovedRoot(Path originalRoot, Path rollbackRoot) { + } +} 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..93cfe166b35 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java @@ -0,0 +1,351 @@ +/* + * 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.*; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// Default implementation of a repository index snapshot for [DefaultGameRepository]. +/// +/// Package-private repository code assembles an instance in an unpublished construction phase and +/// calls [#seal()] before exposing it as a [GameRepositorySnapshot]. Once sealed, its contents never +/// change. +/// +/// 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 [#mutableCopy()], 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; + } + } + + 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(); + } + + /// 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); + } + + /// Removes the instance with the given id. + /// + /// @param id the instance id + void remove(GameInstanceID id) { + checkMutable(); + instances.remove(id); + } + + /// Creates an unpublished mutable copy with instances rebound to the copy. + /// + /// The caller must call [#seal()] before retaining or publishing the result as a snapshot. + /// + /// @return the mutable construction copy + DefaultGameRepositorySnapshot mutableCopy() { + DefaultGameRepositorySnapshot newSnapshot = newEmpty(); + for (DefaultGameInstance instance : instances.values()) { + newSnapshot.put(instance.withNewSnapshot(newSnapshot)); + } + return newSnapshot; + } + + /// Resolves official-layout inheritance and patches, then deduplicates launch libraries. + /// + /// Loader-specific argument repairs are applied later for a concrete launch attempt (for example + /// by [LaunchManifestNormalizer#repairForLaunch(GameInstanceManifest)]). + /// + /// @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 = resolve(manifest, new HashSet<>()); + GameInstanceManifest launchManifest = uniqueLibraries(resolved.launchManifest()); + if (launchManifest != resolved.launchManifest()) { + resolved = new GameInstanceManifest.Resolved( + resolved.unresolved(), + launchManifest, + resolved.standaloneManifest()); + } + return resolved; + } + + /// Resolves official-layout inheritance and patches without launch-library deduplication. + /// + /// @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 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 instances: " + 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 = + resolve(parentInstance.getManifest(), resolvedSoFar); + launchManifest = manifest.merge(parentResolved.launchManifest()); + standaloneManifest = addPatches( + addPatches(parentResolved.standaloneManifest(), List.of(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); + } + + /// Removes redundant library declarations while retaining rule-distinct variants. + /// + /// When two libraries share the same `groupId:artifactId` and equal compatibility rules, the + /// newer version wins. When versions are equal and the coordinate objects compare equal, the + /// declaration with the longer serialized JSON is kept (more metadata is treated as richer). + /// Equal id and version with unequal coordinate payloads (for example distinct `text2speech` + /// library vs native entries) are both retained. + 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(); + + 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); + // Rules differ: keep both (platform-specific variants). + if (Objects.hashCode(library.rules()) != Objects.hashCode(other.rules())) { + continue; + } + + // Rules equal: drop the older version. + int comparison = VersionNumber.compare(library.version(), other.version()); + if (comparison > 0) { + libraries.set(otherIndex, library); + } else if (comparison == 0) { + // Same library id and version: collapse true duplicates. + if (library.equals(other)) { + String otherSerialized = JsonUtils.GSON.toJson(other); + String serialized = JsonUtils.GSON.toJson(library); + // Prefer the entry with more serialized metadata when coordinates equal. + if (serialized.length() > otherSerialized.length()) { + libraries.set(otherIndex, library); + } + } else { + // Same id/version but not equal (e.g. text2speech jar vs natives): keep both. + continue; + } + } + duplicate = true; + break; + } + + if (!duplicate) { + indexes.put(id, libraries.size()); + libraries.add(library); + } + } + + return libraries.size() == manifest.getLibraries().size() + ? manifest + : manifest.withLibraries(libraries); + } + + private static GameInstanceManifest addPatches(GameInstanceManifest manifest, @Nullable List 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..2aa2ba16d23 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentAnalyzer.java @@ -0,0 +1,223 @@ +/* + * 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.GameVersionNumber; +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 GameVersionNumber gameVersion) { + var components = new EnumMap(GameComponentType.class); + @Nullable String bootstrapVersion = null; + + if (gameVersion != null && !gameVersion.equals(GameVersionNumber.unknown())) { + components.put(GameComponentType.GAME, new Mark(GameComponentType.GAME, gameVersion.toString(), 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; + } + } + + if (bootstrapVersion == null && library.is("cpw.mods", "bootstraplauncher")) { + bootstrapVersion = library.version(); + } + } + + return new GameComponentAnalyzer(standaloneManifest, components, bootstrapVersion); + } + + public static GameComponentAnalyzer analyze(GameInstanceManifest.Resolved resolved, @Nullable GameVersionNumber gameVersion) { + return analyze(resolved.standaloneManifest(), resolved.launchManifest(), gameVersion); + } + + public static GameComponentAnalyzer analyze(GameInstanceManifest manifest, @Nullable GameVersionNumber 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 @Unmodifiable Map components; + private final @Nullable String bootstrapVersion; + + private GameComponentAnalyzer(GameInstanceManifest manifest, @Unmodifiable Map components, @Nullable String bootstrapVersion) { + this.manifest = manifest; + this.components = components; + this.bootstrapVersion = bootstrapVersion; + } + + 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; + } + + public @Nullable String getBootstrapVersion() { + return bootstrapVersion; + } + + /// 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()); + } + + @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..a29ab86ce96 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java @@ -0,0 +1,272 @@ +/* + * 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 { + /// Minecraft itself is never identified from a library coordinate; the game version is supplied + /// separately to [GameComponentAnalyzer]. + GAME("game") { + @Override + protected boolean matchLibrary(Library library, List libraries) { + return false; + } + }, + LEGACY_FABRIC("legacyfabric", ModLoaderType.LEGACY_FABRIC) { + @Override + protected boolean matchLibrary(Library library, List libraries) { + if (library.is("net.fabricmc", "fabric-loader")) { + 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 library.is("net.legacyfabric", "legacyfabric-api"); + } + }, + FABRIC("fabric", ModLoaderType.FABRIC) { + @Override + protected boolean matchLibrary(Library library, List libraries) { + if (library.is("net.fabricmc", "fabric-loader")) { + 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 library.is("net.fabricmc", "fabric-api"); + } + }, + 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 library.is("com.cleanroommc", "cleanroom"); + } + }, + 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 library.is("com.mumfrey", "liteloader"); + } + }, + 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 library.is("org.quiltmc", "quilt-loader"); + } + }, + QUILT_API("quilt-api") { + @Override + protected boolean matchLibrary(Library library, List libraries) { + return library.is("org.quiltmc", "quilt-api"); + } + }, + ; + + 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..85258c4b1f9 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java @@ -0,0 +1,180 @@ +/* + * 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.platform.Platform; +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.EnumSet; +import java.util.Optional; +import java.util.Set; + +/// 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(); + } + + GameComponentAnalyzer getAnalyzer(); + + default boolean hasComponent(GameComponentType type) { + return getAnalyzer().has(type); + } + + default @Nullable String getComponentVersion(GameComponentType type) { + return getAnalyzer().getVersion(type); + } + + default Set getModLoaders() { + Set res = EnumSet.noneOf(ModLoaderType.class); + for (GameComponentAnalyzer.Mark mark : getAnalyzer()) { + if (mark.componentType().getModLoaderType() != null) { + res.add(mark.componentType().getModLoaderType()); + } + } + return res; + } + + 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..4eb06527b32 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstanceID.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstanceID.java @@ -28,28 +28,56 @@ import java.io.IOException; +/// Identifies one game instance and forms the instance's directory name in repository layouts. +/// +/// An id must be a non-blank path segment. Directory separators and the special `.` and `..` +/// segments are rejected so layout operations cannot escape or alias the instances directory. +/// +/// @param id the validated instance id @NotNullByDefault @JsonAdapter(GameInstanceID.Adapter.class) @JsonSerializable public record GameInstanceID(String id) implements Comparable { + + /// Returns whether `id` is a non-blank instance path segment. + /// + /// @param id the candidate id + /// @return whether the id satisfies the repository-independent safety requirements + public static boolean isValid(String id) { + return !id.isBlank() + && !id.equals(".") + && !id.equals("..") + && !id.contains("/") + && !id.contains("\\"); + } + + /// Creates a validated instance id. + /// + /// @throws IllegalArgumentException if `id` is not valid 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); } } + /// {@inheritDoc} @Override public int compareTo(GameInstanceID that) { return this.id.compareTo(that.id); } + /// Returns the instance id string. + /// + /// @return the value supplied to the constructor @Override public String toString() { return id; } + /// Serializes nullable instance ids as JSON strings. static final class Adapter extends TypeAdapter<@Nullable GameInstanceID> { + /// {@inheritDoc} @Override public @Nullable GameInstanceID read(JsonReader in) throws IOException { if (in.peek() == JsonToken.NULL) { @@ -60,6 +88,7 @@ static final class Adapter extends TypeAdapter<@Nullable GameInstanceID> { return new GameInstanceID(in.nextString()); } + /// {@inheritDoc} @Override public void write(JsonWriter out, @Nullable GameInstanceID value) throws IOException { out.value(value != null ? value.id() : null); 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..7ad8706099c 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 @@ -418,21 +426,6 @@ public AssetIndexInfo getAssetIndex() { } } - /// Returns whether this manifest applies to the current environment. - /// - /// @return whether this manifest applies to the current environment - public boolean appliesToCurrentEnvironment() { - return CompatibilityRule.appliesToCurrentEnvironment(compatibilityRules); - } - - /// Resolves this manifest through the repository. - /// - /// @param repository the repository that provides parent manifests - /// @return the resolved manifest - public GameInstanceManifest resolve(GameRepository repository) throws NoSuchGameInstanceException { - return repository.resolve(this).launchManifest(); - } - public GameInstanceManifest withId(GameInstanceID id) { Objects.requireNonNull(id); 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..e1741129ff0 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java @@ -18,25 +18,56 @@ package org.jackhuang.hmcl.game; 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; -import java.util.LinkedHashSet; import java.util.Optional; -import java.util.Set; /// 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(); + + /// Opens a draft for staging instance creates and manifest updates before a single publish. + /// + /// A repository permits at most one open draft. Repository refreshes, layout changes, and other + /// writes are rejected until the draft is committed, aborted, or closed. + /// + /// @return a new open draft based on the current published state + /// @throws IllegalStateException if this repository is already being modified + /// @see GameRepositoryDraft + GameRepositoryDraft openDraft(); + + /// 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,32 +77,38 @@ 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; - - /// 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 getInstanceManifest(GameInstanceID instanceId) throws NoSuchGameInstanceException { + return getSnapshot().getInstance(instanceId).getManifest(); + } /// 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. + /// Returns the indexed game instance for the given id. /// - /// @return the loaded instance manifests - Collection getInstanceManifests(); + /// @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. + /// + /// @throws IllegalStateException if this repository is already being modified void refresh(); /// Creates a task that reloads repository state from the backing storage. @@ -81,49 +118,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 +138,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,76 +145,4 @@ 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 - /// @return absolute classpath entries for existing non-native libraries - default Set getClasspath(GameInstanceManifest manifest) { - Set classpath = new LinkedHashSet<>(); - if (manifest.libraries() != null) { - for (Library library : manifest.libraries()) - if (library.appliesToCurrentEnvironment() && !library.isNative()) { - Path f = getLibraryFile(manifest, library); - if (Files.isRegularFile(f)) - classpath.add(FileUtils.getAbsolutePath(f)); - } - } - - return classpath; - } - } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java new file mode 100644 index 00000000000..6093e13c386 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java @@ -0,0 +1,155 @@ +/* + * 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.io.IOException; +import java.nio.file.Path; + +/// Provides the exclusive write session for a game repository. +/// +/// A draft records an unpublished manifest write set. It does not expose a snapshot or +/// [GameInstance]: callers retain their working [GameInstanceManifest] values while the repository's +/// immutable snapshot remains unchanged until [#commit()] succeeds. Manifest JSON is written only +/// during commit. +/// +/// A repository permits at most one open draft. Repository refreshes, layout changes, and other +/// writes are rejected while the draft is open. Draft mutation methods do not materialize new +/// instance directories; [#commit()] creates missing roots as needed. Shared library, asset, and +/// download caches are not reverted. +/// Drafts are not thread-safe; callers must serialize all operations on a draft. +/// +/// @see GameRepository#openDraft() +@NotNullByDefault +public interface GameRepositoryDraft extends AutoCloseable { + + /// Describes a draft's lifecycle state. + @NotNullByDefault + enum State { + /// The draft accepts changes and may be committed or aborted. + OPEN, + + /// The draft is applying files and publishing its immutable successor snapshot. + COMMITTING, + + /// The draft completed its commit and no longer accepts changes. + COMMITTED, + + /// The draft discarded its changes and no longer accepts changes. + ABORTED, + + /// The draft could not complete a commit or cleanup operation. + FAILED + } + + /// Returns the repository that owns this draft. + /// + /// @return the repository + GameRepository getRepository(); + + /// Returns this draft's lifecycle state. + /// + /// @return the current state + State getState(); + + /// Returns whether this draft still accepts mutations. + /// + /// @return whether the draft is open + boolean isOpen(); + + /// Returns whether this draft has been committed. + /// + /// @return whether [#commit()] has completed successfully + boolean isCommitted(); + + /// Adds or replaces a stored manifest in the unpublished draft state. + /// + /// The manifest is retained in memory until commit. This operation does not serialize the + /// manifest or create or expose a [GameInstance]. For a new id, the draft reserves its instance + /// root but does not create it before commit. + /// + /// @param manifest the persistent instance manifest + /// @throws IOException if a new instance root cannot be reserved + /// @throws IllegalStateException if the draft is not open + void put(GameInstanceManifest manifest) throws IOException; + + /// Records a primary client JAR to copy into an instance when this draft commits. + /// + /// The source remains outside the instance tree and is not modified by this draft. It must stay + /// available and unchanged until commit. No instance directory or target JAR is created by this + /// operation. + /// + /// @param instanceId the instance receiving its own primary JAR + /// @param source the completed source JAR + /// @throws IOException if the source is not a regular file or its target escapes + /// the instance root + /// @throws NoSuchGameInstanceException if the draft does not contain `instanceId` + /// @throws IllegalStateException if the draft is not open + void putPrimaryJar(GameInstanceID instanceId, Path source) throws IOException; + + /// Removes an instance from the unpublished draft state. + /// + /// Its instance root is retained until commit and moved out of the repository before the new + /// snapshot is published. Aborting leaves the published instance and its files unchanged. + /// + /// @param instanceId the instance to remove + /// @throws NoSuchGameInstanceException if the draft does not contain the instance + /// @throws IllegalStateException if the draft is not open + void remove(GameInstanceID instanceId); + + /// Renames an instance in the unpublished draft state. + /// + /// Direct inheritance references managed by the repository are updated in the same draft. The + /// source directory remains at its published location until commit. + /// + /// @param from the current instance id + /// @param to the target instance id + /// @throws IOException if the target root is invalid or already exists + /// @throws NoSuchGameInstanceException if the draft does not contain `from` + /// @throws IllegalArgumentException if the draft already contains `to` + /// @throws IllegalStateException if the draft is not open + void rename(GameInstanceID from, GameInstanceID to) throws IOException; + + /// Materializes reserved instance roots, writes recorded primary JARs and modified manifests, + /// then publishes a new immutable snapshot. + /// + /// After this method returns, [GameRepository#getInstance(GameInstanceID)] will resolve modified + /// ids from the published index. + /// + /// @return the newly published snapshot + /// @throws IOException if filesystem changes cannot be applied + /// @throws IllegalStateException if the draft is not open or is not the repository's active draft + GameRepositorySnapshot commit() throws IOException; + + /// Discards pending changes without publishing a new snapshot. + /// + /// Removes files placed under roots reserved by this draft by other installation work. Global + /// caches (libraries, assets) are not reverted. This method is idempotent after a successful + /// abort. + /// + /// @throws IOException if cleanup fails + /// @throws IllegalStateException if the draft was already committed + void abort() throws IOException; + + /// Aborts this draft when it is still open. + /// + /// @see #abort() + @Override + void close() throws IOException; +} 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..05501087650 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java @@ -0,0 +1,247 @@ +/* + * 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.versioning.VersionNumber; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.Optional; + +/// Launch-manifest library and argument adjustments used at resolve time and launch time. +/// +/// Loader-specific argument repairs run later via +/// [#repairForLaunch(GameInstanceManifest)] (for example from `LauncherHelper`) and do not depend on +/// the installed filesystem. Path-sensitive BootstrapLauncher ignore-list fixes remain in +/// `DefaultLauncher`. +@NotNullByDefault +public final class LaunchManifestNormalizer { + /// Prevents construction of this utility class. + private LaunchManifestNormalizer() { + } + + /// Applies loader-specific argument and library repairs for one launch attempt. + /// + /// Expects a structurally resolved launch manifest. Builds a single [GameComponentAnalyzer] for + /// the whole repair. The input is unchanged. + /// + /// @param manifest the launch manifest to repair + /// @return the repaired launch manifest + /// @throws IllegalArgumentException if the manifest still contains inheritance or pending patches + public static GameInstanceManifest repairForLaunch(GameInstanceManifest manifest) { + if (manifest.inheritsFrom() != null || !manifest.getPatches().isEmpty()) { + throw new IllegalArgumentException("Launch manifest must be structurally resolved"); + } + + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(manifest, null); + GameInstanceManifest repaired = manifest; + @Nullable String mainClass = repaired.mainClass(); + + if (GameComponentAnalyzer.LAUNCH_WRAPPER_MAIN.equals(mainClass)) { + // LaunchWrapper era (Forge/LiteLoader/OptiFine on 1.12 and earlier, and mixed stacks). + repaired = repairLaunchWrapper(repaired, analyzer, true); + if (GameComponentAnalyzer.MOD_LAUNCHER_MAIN.equals(repaired.mainClass())) { + // OptiFine + ModLauncher may promote mainClass off LaunchWrapper. + repaired = repairModLauncher(repaired, analyzer); + } + } else if (GameComponentAnalyzer.MOD_LAUNCHER_MAIN.equals(mainClass)) { + // Forge 1.13+ with OptiFine on ModLauncher. + repaired = repairModLauncher(repaired, analyzer); + } else if (GameComponentAnalyzer.BOOTSTRAP_LAUNCHER_MAIN.equals(mainClass)) { + // Forge / NeoForge 1.17+ BootstrapLauncher ignore-list form that does not need the + // installed filesystem (path-sensitive fixes for older BootstrapLauncher run in + // DefaultLauncher when building the process command). + repaired = repairBootstrapLauncher(repaired, analyzer); + } + // Vanilla and Fabric/Quilt need no loader-specific argument repair here. + + return removeLegacyLog4jPatch(repaired); + } + + /// Repairs LaunchWrapper tweak-class configuration. + /// + /// Installing Forge can replace the full game argument list in the version JSON, which drops + /// LiteLoader and OptiFine tweakers. Compatible tweak classes are re-inserted in deterministic + /// order when still required. + private static GameInstanceManifest repairLaunchWrapper( + GameInstanceManifest manifest, + GameComponentAnalyzer analyzer, + boolean reorderTweakClass) { + GameInstanceLibraryBuilder builder = new GameInstanceLibraryBuilder(manifest); + @Nullable String mainClass = null; + + // Re-add LiteLoader tweaker when Forge overwrote the argument list (unless ModLauncher is in use). + 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)) { + // Standalone OptiFine uses the plain OptiFine tweaker. + 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()) { + // Prefer ModLauncher over LaunchWrapper when both are present. + mainClass = GameComponentAnalyzer.MOD_LAUNCHER_MAIN; + for (String optiFineTweaker : GameComponentAnalyzer.OPTIFINE_TWEAKERS) { + builder.removeTweakClass(optiFineTweaker); + } + } else if (builder.hasTweakClass(GameComponentAnalyzer.OPTIFINE_TWEAKERS.get(0))) { + // With Forge or LiteLoader, OptiFine's Forge tweaker is required. + 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 repaired = builder.build(); + return mainClass == null ? repaired : repaired.withMainClass(mainClass); + } + + /// Adds the transformer discovery service required by Forge and OptiFine on ModLauncher. + private static GameInstanceManifest repairModLauncher( + GameInstanceManifest manifest, + GameComponentAnalyzer analyzer) { + 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 apply `ignoreList` only to the file name of each classpath + /// entry, so it is enough to ensure the primary jar name is listed. Older versions match + /// substrings against full paths and are repaired in `DefaultLauncher` using the launch-time + /// library classpath. + private static GameInstanceManifest repairBootstrapLauncher( + GameInstanceManifest manifest, + GameComponentAnalyzer analyzer) { + // Fix wrong configurations when launching 1.17+ with Forge / NeoForge. + if (!analyzer.has(GameComponentType.FORGE) && !analyzer.has(GameComponentType.NEO_FORGE)) { + return manifest; + } + + if (Optional.ofNullable(analyzer.getBootstrapVersion()) + .filter(version -> VersionNumber.compare(version, "0.1.17") >= 0) + .isEmpty()) { + return manifest; + } + + // bootstraplauncher 0.1.17+ only applies ignoreList to classpath file names, so only the + // primary jar name needs to be fixed here. + 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. + 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. + /// + /// HMCL once injected `log4j-patch` to mitigate the Log4j vulnerability. The launcher now + /// rewrites `log4j2.xml` instead, so the leftover library entry is dropped. + 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; + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/Library.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/Library.java index 5d709d9ca0c..e47bc158a2f 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/Library.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/Library.java @@ -24,6 +24,7 @@ import org.jackhuang.hmcl.util.gson.JsonSerializable; import org.jackhuang.hmcl.util.platform.Architecture; import org.jackhuang.hmcl.util.platform.OperatingSystem; +import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; @@ -151,6 +152,7 @@ public String version() { return artifact.getVersion(); } + @Contract(pure = true) public @Nullable String classifier() { if (artifact.getClassifier() == null) { if (natives != null) { 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 319cef85c4f..477067572f3 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; @@ -31,6 +30,7 @@ import org.jackhuang.hmcl.util.platform.hardware.HardwareVendor; import org.jackhuang.hmcl.util.platform.macos.HomebrewUtils; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; +import org.jackhuang.hmcl.util.versioning.VersionNumber; import org.jetbrains.annotations.Nullable; import java.io.*; @@ -42,30 +42,18 @@ import java.nio.file.StandardCopyOption; import java.util.*; import java.util.function.Supplier; +import java.util.stream.Stream; +import static org.jackhuang.hmcl.game.GameComponentType.*; import static org.jackhuang.hmcl.util.Lang.mapOf; import static org.jackhuang.hmcl.util.Pair.pair; import static org.jackhuang.hmcl.util.logging.Logger.LOG; -/** - * @author huangyuhui - */ +/// @author huangyuhui public class DefaultLauncher extends Launcher { - private final LibraryAnalyzer 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); - - this.analyzer = LibraryAnalyzer.analyze(manifest, repository.getGameVersion(manifest).orElse(null)); + public DefaultLauncher(GameInstance instance, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) { + super(instance, manifest, authInfo, options, listener, daemon); } private Command generateCommandLine(Path nativeFolder) throws IOException { @@ -160,11 +148,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)); }); @@ -283,25 +271,27 @@ private Command generateCommandLine(Path nativeFolder) throws IOException { } } - Set classpath = repository.getClasspath(manifest); + // Library classpath used both for -cp and for rewriting old BootstrapLauncher ignore lists. + Set libraryClasspath = getClasspath(); - if (analyzer.has(LibraryAnalyzer.LibraryType.CLEANROOM)) { - classpath.removeIf(c -> c.contains("2.9.4-nightly-20150209")); + if (instance.hasComponent(GameComponentType.CLEANROOM)) { + libraryClasspath.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"); + Set classpath = new LinkedHashSet<>(libraryClasspath); 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. @@ -317,6 +307,9 @@ private Command generateCommandLine(Path nativeFolder) throws IOException { Path javaNativeFolder = FileUtils.toAbsolute(nativeFolder); @Nullable List jvmArguments = Optional.ofNullable(manifest.arguments()).map(Arguments::jvm).orElse(null); + if (jvmArguments != null) { + jvmArguments = rewriteUnsafeBootstrapLauncherIgnoreList(jvmArguments, libraryClasspath); + } if (jvmArguments != null) { for (Argument jvmArgument : jvmArguments) { @@ -448,13 +441,11 @@ protected List getDefaultGameArguments() { return Arguments.DEFAULT_GAME_ARGUMENTS; } - /** - * Do something here. - * i.e. - * -Dminecraft.launcher.version=<Your launcher name> - * -Dminecraft.launcher.brand=<Your launcher version> - * -Dlog4j.configurationFile=<Your custom log4j configuration> - */ + /// Do something here. + /// i.e. + /// -Dminecraft.launcher.version= + /// -Dminecraft.launcher.brand= + /// -Dlog4j.configurationFile= protected void appendJvmArgs(CommandBuilder result) { } @@ -465,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) @@ -491,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 { @@ -504,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 { @@ -523,6 +525,91 @@ public void extractLog4jConfigurationFile() throws IOException { } } + /// Rewrites `-DignoreList=` for old BootstrapLauncher when launching Forge / NeoForge. + /// + /// BootstrapLauncher older than 0.1.17 matches each ignore-list token as a substring against + /// every classpath component. A game directory such as `/Users/asm` therefore causes every + /// library whose path contains `asm` to be ignored. Using the library classpath already built + /// for this launch, loose tokens are replaced with exact installed paths (or portable + /// `${library_directory}` placeholders). `${primary_jar}` is always retained for Jigsaw. + /// + /// BootstrapLauncher 0.1.17+ only matches file names; those manifests are repaired for launch by + /// [LaunchManifestNormalizer#repairForLaunch(GameInstanceManifest)]. + /// + /// @param jvmArguments JVM arguments from the launch manifest + /// @param libraryClasspath absolute library classpath entries for this launch (without primary jar) + /// @return a possibly rewritten argument list; the input list is not modified + private List rewriteUnsafeBootstrapLauncherIgnoreList( + List jvmArguments, + Set libraryClasspath) { + if (!GameComponentAnalyzer.BOOTSTRAP_LAUNCHER_MAIN.equals(manifest.mainClass())) { + return jvmArguments; + } + if (!instance.hasComponent(GameComponentType.FORGE) && !instance.hasComponent(GameComponentType.NEO_FORGE)) { + return jvmArguments; + } + @Nullable String bootstrapVersion = instance.getAnalyzer().getBootstrapVersion(); + if (bootstrapVersion == null || VersionNumber.compare(bootstrapVersion, "0.1.17") >= 0) { + return jvmArguments; + } + + Path libraryDirectory = instance.getLayout().getLibrariesDirectory().toAbsolutePath().normalize(); + List rewritten = new ArrayList<>(jvmArguments.size()); + boolean changed = false; + for (Argument argument : jvmArguments) { + if (argument instanceof StringArgument stringArgument) { + String value = stringArgument.argument(); + if (value.startsWith("-DignoreList=")) { + rewritten.add(new StringArgument( + "-DignoreList=" + rewriteIgnoreList( + value.substring("-DignoreList=".length()), + libraryClasspath, + libraryDirectory))); + changed = true; + continue; + } + } + rewritten.add(argument); + } + return changed ? rewritten : jvmArguments; + } + + /// Converts a substring-based BootstrapLauncher ignore list to exact classpath entries. + /// + /// For example, if `client-extra` is listed and a path component contains `client-extra`, + /// every matching library would be ignored under substring matching. Matching is performed + /// against library file names only; matched jars are rewritten to concrete paths. + /// + /// @param ignoreList the original comma-separated substring list + /// @param libraryClasspath absolute library classpath entries for this launch + /// @param libraryDirectory absolute `.minecraft/libraries` directory + /// @return the exact comma-separated ignore list + private static String rewriteIgnoreList( + String ignoreList, + Set libraryClasspath, + Path libraryDirectory) { + String[] ignoredSubstrings = ignoreList.split(","); + List exactEntries = new ArrayList<>(); + exactEntries.add("${primary_jar}"); + + for (String classpathName : libraryClasspath) { + 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); + } + protected Map getConfigurations() { return mapOf( // defined by Minecraft official launcher @@ -533,32 +620,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()); @@ -589,7 +676,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()); @@ -624,8 +711,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) { @@ -705,28 +792,28 @@ else if (driver instanceof Renderer.Vulkan vulkanDriver) { } } - if (analyzer.has(LibraryAnalyzer.LibraryType.FORGE)) { + if (instance.hasComponent(GameComponentType.FORGE)) { env.put("INST_FORGE", "1"); } - if (analyzer.has(LibraryAnalyzer.LibraryType.CLEANROOM)) { + if (instance.hasComponent(GameComponentType.CLEANROOM)) { env.put("INST_CLEANROOM", "1"); } - if (analyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE)) { + if (instance.hasComponent(GameComponentType.NEO_FORGE)) { env.put("INST_NEOFORGE", "1"); } - if (analyzer.has(LibraryAnalyzer.LibraryType.LITELOADER)) { + if (instance.hasComponent(GameComponentType.LITELOADER)) { env.put("INST_LITELOADER", "1"); } - if (analyzer.has(LibraryAnalyzer.LibraryType.FABRIC)) { + if (instance.hasComponent(GameComponentType.FABRIC)) { env.put("INST_FABRIC", "1"); } - if (analyzer.has(LibraryAnalyzer.LibraryType.OPTIFINE)) { + if (instance.hasComponent(GameComponentType.OPTIFINE)) { env.put("INST_OPTIFINE", "1"); } - if (analyzer.has(LibraryAnalyzer.LibraryType.QUILT)) { + if (instance.hasComponent(GameComponentType.QUILT)) { env.put("INST_QUILT", "1"); } - if (analyzer.has(LibraryAnalyzer.LibraryType.LEGACY_FABRIC)) { + if (instance.hasComponent(GameComponentType.LEGACY_FABRIC)) { env.put("INST_LEGACYFABRIC", "1"); } @@ -735,6 +822,47 @@ else if (driver instanceof Renderer.Vulkan vulkanDriver) { return env; } + private Set getClasspath() { + GameInstanceID instanceId = instance.getId(); + GameRepositoryLayout layout = instance.getLayout(); + + boolean processOptiFine = instance.hasComponent(OPTIFINE) && (instance.hasComponent(LITELOADER) || instance.hasComponent(FORGE)); + @Nullable Path selectedOptiFineInstallerFile = null; + + Set classpath = new LinkedHashSet<>(); + for (Library library : manifest.getLibraries()) { + if (library.appliesToCurrentEnvironment() && !library.isNative()) { + if (processOptiFine) { + if (library.is("optifine", "OptiFine")) { + // Prefer the installer jar over the patch jar when both are present. + Library installer = new Library( + new Artifact("optifine", "OptiFine", library.version(), "installer")); + Path installerFile = layout.getLibraryFile(instanceId, installer); + if (Files.isRegularFile(installerFile)) { + selectedOptiFineInstallerFile = installerFile; + continue; + } + } else if (library.is("optifine", "launchwrapper-of")) { + // Drop OptiFine's private launchwrapper; Forge/LiteLoader supply their own. + continue; + } + } + + Path libraryFile = layout.getLibraryFile(instanceId, library); + if (Files.isRegularFile(libraryFile)) + classpath.add(FileUtils.getAbsolutePath(libraryFile)); + } + } + + // Re-append the installer last so OptiFine follows Forge when Forge has no patch entry. + if (selectedOptiFineInstallerFile != null && + // With ModLauncher, OptiFine is discovered via HMCLTransformerDiscoveryService, not classpath. + !GameComponentAnalyzer.MOD_LAUNCHER_MAIN.equals(manifest.mainClass())) { + classpath.add(FileUtils.getAbsolutePath(selectedOptiFineInstallerFile)); + } + return classpath; + } + @Override public void makeLaunchScript(Path scriptFile) throws IOException { boolean isWindows = OperatingSystem.WINDOWS == OperatingSystem.CURRENT_OS; @@ -806,7 +934,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(); @@ -850,7 +978,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(); @@ -862,7 +990,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/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..6d649e4f482 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 @@ -21,6 +21,7 @@ import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.GameBuilder; import org.jackhuang.hmcl.game.DefaultGameRepository; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.modpack.*; import org.jackhuang.hmcl.task.CacheFileTask; @@ -77,20 +78,20 @@ 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."); - GameBuilder builder = dependencyManager.newGameBuilder().name(instanceId).gameVersion(manifest.minecraft().gameVersion()); + GameBuilder builder = dependencyManager.newGameBuilder().id(instanceId).component(GameComponentType.GAME, manifest.minecraft().gameVersion()); for (CurseManifestModLoader modLoader : manifest.minecraft().modLoaders()) { if (modLoader.id().startsWith("forge-")) { - builder.version("forge", modLoader.id().substring("forge-".length())); + builder.component(GameComponentType.FORGE, modLoader.id().substring("forge-".length())); } else if (modLoader.id().startsWith("fabric-")) { - builder.version("fabric", modLoader.id().substring("fabric-".length())); + builder.component(GameComponentType.FABRIC, modLoader.id().substring("fabric-".length())); } else if (modLoader.id().startsWith("neoforge-")) { - builder.version("neoforge", modLoader.id().substring("neoforge-".length())); + builder.component(GameComponentType.NEO_FORGE, modLoader.id().substring("neoforge-".length())); } } dependents.add(builder.buildAsync()); @@ -116,7 +117,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 +127,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 +173,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 +197,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 +208,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 75b50afa470..0f4f5a54a5b 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; @@ -43,19 +45,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; @@ -70,16 +83,16 @@ public McbbsModpackExportTask(DefaultGameRepository repository, GameInstanceID i }); } -/// Exports the selected game files and manifests to the target archive. - + /// {@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())) { @@ -94,29 +107,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 = instance.getAnalyzer(); // 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 @@ -134,15 +139,19 @@ 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..c2a801815c9 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 @@ -20,16 +20,14 @@ 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.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.GameInstancePatch; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.modpack.MinecraftInstanceTask; import org.jackhuang.hmcl.modpack.Modpack; import org.jackhuang.hmcl.modpack.ModpackConfiguration; import org.jackhuang.hmcl.modpack.ModpackInstallTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.file.Files; @@ -59,17 +57,19 @@ 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); - GameBuilder builder = dependencyManager.newGameBuilder().name(instanceId); + GameBuilder builder = dependencyManager.newGameBuilder().id(instanceId); for (McbbsModpackManifest.Addon addon : manifest.getAddons()) { - builder.version(addon.getId(), addon.getVersion()); + @Nullable GameComponentType componentType = GameComponentType.fromPatchId(addon.getId()); + if (componentType != null) + builder.component(componentType, addon.getVersion()); } dependents.add(builder.buildAsync()); @@ -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..ab579ff1b9f 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 @@ -21,6 +21,7 @@ import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.GameBuilder; import org.jackhuang.hmcl.game.DefaultGameRepository; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.modpack.*; import org.jackhuang.hmcl.task.CacheFileTask; @@ -62,30 +63,31 @@ 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."); - GameBuilder builder = dependencyManager.newGameBuilder().name(instanceId).gameVersion(manifest.getGameVersion()); + GameBuilder builder = dependencyManager.newGameBuilder().id(instanceId); + builder.component(GameComponentType.GAME, manifest.getGameVersion()); for (Map.Entry modLoader : manifest.getDependencies().entrySet()) { switch (modLoader.getKey()) { case "minecraft": break; case "forge": - builder.version("forge", modLoader.getValue()); + builder.component(GameComponentType.FORGE, modLoader.getValue()); break; case "neoforge": // https://github.com/HMCL-dev/HMCL/pull/5170 case "neo-forge": - builder.version("neoforge", modLoader.getValue()); + builder.component(GameComponentType.NEO_FORGE, modLoader.getValue()); break; case "fabric-loader": - builder.version("fabric", modLoader.getValue()); + builder.component(GameComponentType.FABRIC, modLoader.getValue()); break; case "quilt-loader": - builder.version("quilt", modLoader.getValue()); + builder.component(GameComponentType.QUILT, modLoader.getValue()); break; default: throw new IllegalStateException("Unsupported mod loader " + modLoader.getKey()); @@ -116,7 +118,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 +129,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 +154,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 +165,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..eb3a0997c8e 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 = instance.getAnalyzer(); 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..a671f940815 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 = instance.getAnalyzer(); 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..b957a771a51 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")); } } } @@ -293,18 +296,9 @@ public void execute() throws Exception { true )); - Artifact mainJarArtifact = artifact.getMainJar().artifact(); - String gameVersion = artifact.getGameVersion(); - if (gameVersion != null && - "com.mojang".equals(mainJarArtifact.getGroup()) && - "minecraft".equals(mainJarArtifact.getName()) && - Objects.equals(gameVersion, mainJarArtifact.getVersion()) && - "client".equals(mainJarArtifact.getClassifier()) - ) { - dependencies.add(new GameDownloadTask(dependencyManager, gameVersion, instanceManifest)); - } else { - dependencies.add(new GameDownloadTask(dependencyManager, null, instanceManifest)); - } + Path instanceJar = repository.getInstanceJar(instanceManifest); + dependencies.add(new GameDownloadTask(dependencyManager, instanceManifest) + .thenAcceptAsync(cachedJar -> FileUtils.copyFile(cachedJar, instanceJar))); } setResult(artifact); @@ -335,7 +329,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..84a50b826fe 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,9 @@ 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.game.GameComponentType; import org.jackhuang.hmcl.modpack.ModpackConfiguration; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.GetTask; @@ -30,6 +30,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 +42,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,15 +142,17 @@ 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().id(instance.getId()); for (ServerModpackManifest.Addon addon : remoteManifest.getAddons()) { - builder.version(addon.getId(), addon.getVersion()); + @Nullable GameComponentType componentType = GameComponentType.fromPatchId(addon.getId()); + if (componentType != null) + builder.component(componentType, addon.getVersion()); } 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 +160,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 +224,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..cd8b52cc8fa 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,27 @@ 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(); 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 : instance.getAnalyzer()) { + 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..57c3361ebdd 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 @@ -21,6 +21,7 @@ import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.GameBuilder; import org.jackhuang.hmcl.game.DefaultGameRepository; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.modpack.MinecraftInstanceTask; import org.jackhuang.hmcl.modpack.Modpack; @@ -28,6 +29,7 @@ import org.jackhuang.hmcl.modpack.ModpackInstallTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.file.Files; @@ -52,15 +54,17 @@ 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."); - GameBuilder builder = dependencyManager.newGameBuilder().name(instanceId); + GameBuilder builder = dependencyManager.newGameBuilder().id(instanceId); for (ServerModpackManifest.Addon addon : manifest.getAddons()) { - builder.version(addon.getId(), addon.getVersion()); + @Nullable GameComponentType componentType = GameComponentType.fromPatchId(addon.getId()); + if (componentType != null) + builder.component(componentType, addon.getVersion()); } dependents.add(builder.buildAsync()); @@ -80,7 +84,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..1f8394dedd6 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 @@ -21,10 +21,12 @@ import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.GameBuilder; import org.jackhuang.hmcl.game.DefaultGameRepository; +import org.jackhuang.hmcl.game.GameComponentType; 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 org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.file.Files; @@ -48,13 +50,15 @@ 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."); - GameBuilder builder = dependencyManager.newGameBuilder().name(instanceId); + GameBuilder builder = dependencyManager.newGameBuilder().id(instanceId); for (ServerModpackManifest.Addon addon : manifest.getAddons()) { - builder.version(addon.getId(), addon.getVersion()); + @Nullable GameComponentType componentType = GameComponentType.fromPatchId(addon.getId()); + if (componentType != null) + builder.component(componentType, addon.getVersion()); } dependents.add(builder.buildAsync()); @@ -87,7 +91,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/task/CacheFileTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/task/CacheFileTask.java index 78ffd655d3e..37c9433e7ed 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/task/CacheFileTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/task/CacheFileTask.java @@ -18,6 +18,8 @@ package org.jackhuang.hmcl.task; import org.jackhuang.hmcl.util.CacheRepository; +import org.jackhuang.hmcl.util.DigestUtils; +import org.jackhuang.hmcl.util.io.ChecksumMismatchException; import org.jackhuang.hmcl.util.io.NetworkUtils; import org.jackhuang.hmcl.util.io.UrlResponseInfo; import org.jetbrains.annotations.NotNull; @@ -34,35 +36,79 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG; -/** - * Download a file to cache repository. - * - * @author Glavo - */ +/// Downloads a remote file to a cache repository. +/// +/// @author Glavo public final class CacheFileTask extends FetchTask { + /// Expected SHA-1 checksum, or `null` when the remote cache policy determines reuse. + private final @Nullable String expectedSha1; + + /// Creates a task for one URI string using remote cache metadata. + /// + /// @param uri the HTTP or HTTPS URI string public CacheFileTask(@NotNull String uri) { this(NetworkUtils.toURI(uri)); } + /// Creates a task for one URI using remote cache metadata. + /// + /// @param uri the HTTP or HTTPS URI public CacheFileTask(@NotNull URI uri) { - super(List.of(uri)); - setName(uri.toString()); - - if (!NetworkUtils.isHttpUri(uri)) - throw new IllegalArgumentException(uri.toString()); + this(List.of(uri)); } + /// Creates a task for candidate URIs using remote cache metadata. + /// + /// @param uris candidate download URIs in attempt order public CacheFileTask(@NotNull List<@NotNull URI> uris) { super(uris); + this.expectedSha1 = null; + validateUris(uris); + setName(uris.get(0).toString()); + } + + /// Creates a task that returns content cached under a verified SHA-1 checksum. + /// + /// @param uris candidate download URIs in attempt order + /// @param expectedSha1 the expected SHA-1 checksum + public CacheFileTask( + @NotNull List<@NotNull URI> uris, + @NotNull String expectedSha1) { + super(uris); + if (!DigestUtils.isSha1Digest(expectedSha1)) { + throw new IllegalArgumentException("Invalid SHA-1 checksum: " + expectedSha1); + } + this.expectedSha1 = expectedSha1.toLowerCase(Locale.ROOT); + validateUris(uris); setName(uris.get(0).toString()); + } - if (!uris.stream().allMatch(NetworkUtils::isHttpUri)) + /// Verifies that all candidate URIs use HTTP or HTTPS. + /// + /// @param uris the candidate URIs + private static void validateUris(@NotNull List<@NotNull URI> uris) { + if (!uris.stream().allMatch(NetworkUtils::isHttpUri)) { throw new IllegalArgumentException(uris.toString()); + } } + /// Selects a verified content-addressed entry or the applicable remote-cache policy. + /// + /// @return the cache action to perform before downloading @Override protected EnumCheckETag shouldCheckETag() { + if (expectedSha1 != null) { + Optional cached = repository.checkExistentFile( + null, CacheRepository.SHA1, expectedSha1); + if (cached.isPresent()) { + setResult(cached.get()); + LOG.info("Using cached file with SHA-1 " + expectedSha1); + return EnumCheckETag.CACHED; + } + return EnumCheckETag.NOT_CHECK_E_TAG; + } + // Check cache for (URI uri : uris) { try { @@ -82,10 +128,18 @@ protected void useCachedResult(Path cache) { setResult(cache); } + /// Creates a temporary sink that publishes a successful download to the cache repository. + /// + /// @param response the HTTP response metadata + /// @param checkETag whether remote cache metadata is being checked + /// @param bmclapiHash the hash supplied by BMCLAPI, or `null` + /// @return the temporary download sink + /// @throws IOException if the temporary file cannot be created @Override protected Context getContext(@Nullable UrlResponseInfo response, boolean checkETag, @Nullable String bmclapiHash) throws IOException { - assert checkETag; - assert response != null; + if (expectedSha1 == null && (!checkETag || response == null)) { + throw new IOException("Remote response metadata is unavailable"); + } return new Context() { private final Path temp = Files.createTempFile("hmcl-download-", null); @@ -124,7 +178,15 @@ public void close() throws IOException { } try { - setResult(repository.cacheRemoteFile(response, temp)); + if (expectedSha1 != null) { + ChecksumMismatchException.verifyChecksum( + temp, CacheRepository.SHA1, expectedSha1); + setResult(repository.cacheFile( + temp, CacheRepository.SHA1, expectedSha1)); + } else { + setResult(repository.cacheRemoteFile( + Objects.requireNonNull(response), temp)); + } } finally { deleteTempFile(); } 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..ab9df59627f --- /dev/null +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java @@ -0,0 +1,602 @@ +/* + * 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.MojangDownloadProvider; +import org.jackhuang.hmcl.download.forge.ForgeNewInstallTask; +import org.jackhuang.hmcl.download.game.GameDownloadTask; +import org.jackhuang.hmcl.download.game.GameVerificationFixTask; +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.util.DigestUtils; +import org.jackhuang.hmcl.util.gson.JsonUtils; +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.Map; +import java.util.Optional; +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 { + + /// Launch repair for ModLauncher adds support metadata without materializing bundled files. + @Test + public void testModLauncherLaunchRepairDoesNotWriteBundledLibraries(@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(); + assertTrue(launchManifest.getLibraries().stream() + .noneMatch(library -> library.is( + "org.jackhuang.hmcl", "transformer-discovery-service"))); + + GameInstanceManifest repaired = LaunchManifestNormalizer.repairForLaunch(launchManifest); + Library transformerService = repaired.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(repaired, LaunchManifestNormalizer.repairForLaunch(repaired)); + } + + /// 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 cached game download can be materialized at an explicit destination. + @Test + public void testGameDownloadMaterializesExplicitDestination(@TempDir Path tempDirectory) + throws Exception { + TestRepository repository = new TestRepository(tempDirectory.resolve("game")); + DefaultCacheRepository cacheRepository = + new DefaultCacheRepository(tempDirectory.resolve("cache")); + DefaultDependencyManager dependencyManager = new DefaultDependencyManager( + repository, + new MojangDownloadProvider(), + cacheRepository); + Path source = tempDirectory.resolve("client.jar"); + Files.writeString(source, "client"); + String sha1 = DigestUtils.digestToString("SHA-1", source); + Path cached = cacheRepository.cacheFile(source, "SHA-1", sha1); + GameInstanceManifest manifest = new GameInstanceManifest(new GameInstanceID("instance")) + .withDownloads(Map.of( + DownloadType.CLIENT, + new DownloadInfo("https://example.invalid/client.jar", sha1))); + Path destination = tempDirectory.resolve("fixed.jar"); + + var task = new GameDownloadTask(dependencyManager, manifest) + .thenAcceptAsync(cachedJar -> Files.copy(cachedJar, destination)); + + assertTrue(task.executor().test()); + assertEquals("client", Files.readString(destination)); + assertEquals("client", Files.readString(cached)); + } + + /// A game download returns the content-addressed cache file without a version-named copy. + @Test + public void testGameDownloadReturnsContentAddressedCacheFile(@TempDir Path tempDirectory) + throws Exception { + TestRepository repository = new TestRepository(tempDirectory.resolve("game")); + Path cacheDirectory = tempDirectory.resolve("cache"); + DefaultCacheRepository cacheRepository = new DefaultCacheRepository(cacheDirectory); + DefaultDependencyManager dependencyManager = new DefaultDependencyManager( + repository, + new MojangDownloadProvider(), + cacheRepository); + Path source = tempDirectory.resolve("client.jar"); + Files.writeString(source, "client"); + String sha1 = DigestUtils.digestToString("SHA-1", source); + Path cached = cacheRepository.cacheFile(source, "SHA-1", sha1); + GameInstanceManifest manifest = new GameInstanceManifest(new GameInstanceID("instance")) + .withDownloads(Map.of( + DownloadType.CLIENT, + new DownloadInfo("https://example.invalid/client.jar", sha1))); + + GameDownloadTask task = new GameDownloadTask(dependencyManager, manifest); + + assertTrue(task.executor().test()); + assertEquals(cached, task.getResult()); + assertFalse(Files.exists(cacheDirectory.resolve("jars"))); + assertFalse(cached.startsWith(repository.getLayout().getInstanceRoot(manifest.id()))); + } + + /// Keeps the detached client JAR path distinct from the Minecraft version processor variable. + @Test + public void testForgeProcessorSeparatesMinecraftJarAndVersion(@TempDir Path tempDirectory) + throws IOException { + Path versionMarker = tempDirectory.resolve("minecraft-version"); + Files.writeString(versionMarker, "version"); + String markerSha1 = DigestUtils.digestToString("SHA-1", versionMarker); + + Path minecraftJar = tempDirectory.resolve("cache/client.jar"); + Files.createDirectories(minecraftJar.getParent()); + Files.writeString(minecraftJar, "client"); + + Path installer = tempDirectory.resolve("forge-installer.jar"); + writeForgeProcessorFixture( + installer, + versionMarker.toAbsolutePath().normalize().toString(), + "{MINECRAFT_VERSION}", + markerSha1); + + TestRepository repository = new TestRepository(tempDirectory.resolve("game")); + DefaultDependencyManager dependencyManager = new DefaultDependencyManager( + repository, + new MojangDownloadProvider(), + new DefaultCacheRepository(tempDirectory.resolve("download-cache"))); + ForgeNewInstallTask task = new ForgeNewInstallTask( + dependencyManager, + new GameInstanceManifest(new GameInstanceID("instance")), + minecraftJar, + "forge-test", + installer) { + @Override + protected void updateProgressImmediately(double progress) { + // Avoid JavaFX toolkit initialization in this isolated processor test. + } + }; + + var executor = task.executor(); + assertTrue(executor.test(), () -> String.valueOf(executor.getException())); + assertTrue(Files.isRegularFile(minecraftJar)); + } + + /// Prevents processor output handling from deleting the source Minecraft JAR. + @Test + public void testForgeProcessorUsesDisposableMinecraftJar(@TempDir Path tempDirectory) + throws IOException { + Path minecraftJar = tempDirectory.resolve("cache/client.jar"); + Files.createDirectories(minecraftJar.getParent()); + Files.writeString(minecraftJar, "client"); + + Path installer = tempDirectory.resolve("forge-installer.jar"); + writeForgeProcessorFixture( + installer, + "1.20.1", + "{MINECRAFT_JAR}", + "0000000000000000000000000000000000000000"); + + TestRepository repository = new TestRepository(tempDirectory.resolve("game")); + DefaultDependencyManager dependencyManager = new DefaultDependencyManager( + repository, + new MojangDownloadProvider(), + new DefaultCacheRepository(tempDirectory.resolve("download-cache"))); + ForgeNewInstallTask task = new ForgeNewInstallTask( + dependencyManager, + new GameInstanceManifest(new GameInstanceID("instance")), + minecraftJar, + "forge-test", + installer) { + @Override + protected void updateProgressImmediately(double progress) { + // Avoid JavaFX toolkit initialization in this isolated processor test. + } + }; + + assertFalse(task.executor().test()); + assertEquals("client", Files.readString(minecraftJar)); + } + + /// 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(); + } + } + + /// Writes a Forge installer fixture with one processor output. + /// + /// @param installer the installer JAR path + /// @param minecraftVersion the value stored in the install profile's `minecraft` field + /// @param outputKey processor output path expression + /// @param outputSha1 expected checksum for the processor output + private static void writeForgeProcessorFixture( + Path installer, + String minecraftVersion, + String outputKey, + String outputSha1) throws IOException { + Map profile = Map.of( + "spec", 1, + "minecraft", minecraftVersion, + "json", "version.json", + "version", "forge-test", + "libraries", List.of(), + "processors", List.of(Map.of( + "jar", "example:processor:1.0", + "outputs", Map.of(outputKey, outputSha1)))); + + try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(installer))) { + writeZipEntry(output, "install_profile.json", JsonUtils.GSON.toJson(profile)); + writeZipEntry(output, "version.json", "{\"id\":\"forge-test\",\"libraries\":[]}"); + } + } + + /// Writes one UTF-8 text entry to a ZIP stream. + /// + /// @param output the destination ZIP stream + /// @param name the entry name + /// @param content the entry content + private static void writeZipEntry(ZipOutputStream output, String name, String content) + throws IOException { + output.putNextEntry(new ZipEntry(name)); + output.write(content.getBytes(StandardCharsets.UTF_8)); + 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/DefaultGameRepositoryDraftTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraftTest.java new file mode 100644 index 00000000000..5ca62408a56 --- /dev/null +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraftTest.java @@ -0,0 +1,414 @@ +/* + * 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.task.Task; +import org.jackhuang.hmcl.util.gson.JsonUtils; +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.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Tests exclusive repository drafts and their filesystem visibility boundary. +@NotNullByDefault +public final class DefaultGameRepositoryDraftTest { + + /// Rejects path-segment instance ids before they can reach repository deletion code. + @Test + public void testRejectsSpecialPathSegmentIds() { + assertThrows(IllegalArgumentException.class, () -> new GameInstanceID(".")); + assertThrows(IllegalArgumentException.class, () -> new GameInstanceID("..")); + } + + /// Keeps a new manifest in memory until commit and publishes the resulting instance once committed. + @Test + public void testCommitPublishesModifiedManifest(@TempDir Path tempDirectory) throws IOException { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID id = new GameInstanceID("instance"); + GameInstanceManifest manifest = new GameInstanceManifest(id).withMainClass("example.Main"); + Path manifestFile = repository.getLayout().getInstanceJson(id); + Path instanceRoot = repository.getLayout().getInstanceRoot(id); + Path draftStorage = tempDirectory.resolve(".hmcl").resolve("repository-drafts"); + + try (DefaultGameRepositoryDraft draft = repository.openDraft()) { + draft.put(manifest); + + assertFalse(repository.hasInstance(id)); + assertFalse(Files.exists(instanceRoot)); + assertFalse(Files.exists(manifestFile)); + assertFalse(Files.exists(draftStorage)); + + GameRepositorySnapshot committed = draft.commit(); + assertEquals(GameRepositoryDraft.State.COMMITTED, draft.getState()); + assertEquals(manifest, committed.getInstance(id).getManifest()); + } + + assertTrue(repository.hasInstance(id)); + GameInstanceManifest stored = JsonUtils.fromNonNullJson( + Files.readString(manifestFile), + GameInstanceManifest.class); + assertEquals(id, stored.id()); + assertEquals("example.Main", stored.mainClass()); + } + + /// Materializes a completed client JAR only while committing the new instance. + @Test + public void testCommitMaterializesPrimaryJar(@TempDir Path tempDirectory) throws IOException { + TestRepository repository = new TestRepository(tempDirectory.resolve("game")); + GameInstanceID id = new GameInstanceID("instance"); + GameInstanceManifest manifest = new GameInstanceManifest(id); + Path source = tempDirectory.resolve("cache/client.jar"); + Files.createDirectories(source.getParent()); + Files.writeString(source, "client"); + Path target = repository.getLayout().getInstanceJarFile(id); + + try (DefaultGameRepositoryDraft draft = repository.openDraft()) { + draft.put(manifest); + draft.putPrimaryJar(id, source); + + assertFalse(Files.exists(repository.getLayout().getInstanceRoot(id))); + assertFalse(Files.exists(target)); + + draft.commit(); + } + + assertEquals("client", Files.readString(target)); + assertEquals("client", Files.readString(source)); + } + + /// Aborting removes files below a root that was first created by the draft. + @Test + public void testAbortRemovesDraftCreatedInstanceRoot(@TempDir Path tempDirectory) throws IOException { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID id = new GameInstanceID("instance"); + Path root = repository.getLayout().getInstanceRoot(id); + + try (DefaultGameRepositoryDraft draft = repository.openDraft()) { + draft.put(new GameInstanceManifest(id)); + Files.createDirectories(root); + Files.writeString(root.resolve("downloaded.jar"), "content"); + } + + assertFalse(Files.exists(root)); + assertFalse(repository.hasInstance(id)); + + try (DefaultGameRepositoryDraft ignored = repository.openDraft()) { + assertTrue(ignored.isOpen()); + } + } + + /// Leaves the published manifest and its JSON unchanged when an update is aborted. + @Test + public void testAbortDoesNotOverwriteExistingManifest(@TempDir Path tempDirectory) throws IOException { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID id = new GameInstanceID("instance"); + GameInstanceManifest original = new GameInstanceManifest(id).withMainClass("original.Main"); + GameInstanceManifest updated = original.withMainClass("updated.Main"); + repository.save(original); + + try (DefaultGameRepositoryDraft draft = repository.openDraft()) { + draft.put(updated); + assertEquals(original, repository.getInstance(id).getManifest()); + } + + Path manifestFile = repository.getLayout().getInstanceJson(id); + assertEquals(original, repository.getInstance(id).getManifest()); + GameInstanceManifest stored = JsonUtils.fromNonNullJson( + Files.readString(manifestFile), + GameInstanceManifest.class); + assertEquals(id, stored.id()); + assertEquals("original.Main", stored.mainClass()); + } + + /// Rejects overlapping drafts and direct snapshot writes while a draft owns the repository. + @Test + public void testDraftExcludesOtherRepositoryWrites(@TempDir Path tempDirectory) throws IOException { + TestRepository repository = new TestRepository(tempDirectory); + try (DefaultGameRepositoryDraft draft = repository.openDraft()) { + assertThrows(IllegalStateException.class, repository::openDraft); + assertThrows(IllegalStateException.class, repository::refresh); + assertThrows( + IllegalStateException.class, + () -> repository.setBaseDirectory(tempDirectory.resolve("other"))); + assertTrue(draft.isOpen()); + } + + repository.refresh(); + } + + /// Refuses to claim and later delete an unregistered directory that predates the draft. + @Test + public void testPutRejectsPreexistingUnregisteredDirectory(@TempDir Path tempDirectory) throws IOException { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID id = new GameInstanceID("instance"); + Path root = repository.getLayout().getInstanceRoot(id); + Files.createDirectories(root); + Path retained = root.resolve("retained.txt"); + Files.writeString(retained, "content"); + + try (DefaultGameRepositoryDraft draft = repository.openDraft()) { + assertThrows(FileAlreadyExistsException.class, () -> draft.put(new GameInstanceManifest(id))); + } + + assertTrue(Files.exists(retained)); + } + + /// Keeps a pending removal private and preserves the published files when the draft aborts. + @Test + public void testAbortPreservesRemovedInstance(@TempDir Path tempDirectory) throws IOException { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID id = new GameInstanceID("instance"); + repository.save(new GameInstanceManifest(id)); + Path root = repository.getLayout().getInstanceRoot(id); + + try (DefaultGameRepositoryDraft draft = repository.openDraft()) { + draft.remove(id); + assertTrue(repository.hasInstance(id)); + assertTrue(Files.isDirectory(root)); + } + + assertTrue(repository.hasInstance(id)); + assertTrue(Files.isDirectory(root)); + } + + /// Renames an instance and its direct inheritance references in one draft commit. + @Test + public void testRenameCommitsFilesAndReferences(@TempDir Path tempDirectory) throws IOException { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID parentId = new GameInstanceID("parent"); + GameInstanceID renamedId = new GameInstanceID("renamed"); + GameInstanceID childId = new GameInstanceID("child"); + repository.save(new GameInstanceManifest(parentId)); + repository.save(new GameInstanceManifest(childId).withInheritsFrom(parentId)); + + assertTrue(repository.renameInstance(parentId, renamedId)); + + assertFalse(repository.hasInstance(parentId)); + assertTrue(repository.hasInstance(renamedId)); + assertEquals(renamedId, repository.getInstance(childId).getManifest().inheritsFrom()); + assertFalse(Files.exists(repository.getLayout().getInstanceRoot(parentId))); + assertTrue(Files.isRegularFile(repository.getLayout().getInstanceJson(renamedId))); + } + + /// Removes a registered instance through a one-shot draft. + @Test + public void testRemoveInstanceUsesDraftCommit(@TempDir Path tempDirectory) throws IOException { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID id = new GameInstanceID("instance"); + repository.save(new GameInstanceManifest(id)); + Path root = repository.getLayout().getInstanceRoot(id); + + assertTrue(repository.removeInstanceFromDisk(id)); + + assertFalse(repository.hasInstance(id)); + assertFalse(Files.exists(root)); + } + + /// Restores modified manifests and releases exclusivity when publication fails after replacement. + @Test + public void testCommitFailureRollsBackAndReleasesDraft(@TempDir Path tempDirectory) throws IOException { + FailingRepository repository = new FailingRepository(tempDirectory); + GameInstanceID id = new GameInstanceID("instance"); + GameInstanceManifest original = new GameInstanceManifest(id).withMainClass("original.Main"); + repository.save(original); + repository.failDraftPublish = true; + + DefaultGameRepositoryDraft draft = repository.openDraft(); + draft.put(original.withMainClass("updated.Main")); + assertThrows(IllegalStateException.class, draft::commit); + + assertEquals(GameRepositoryDraft.State.FAILED, draft.getState()); + assertEquals(original, repository.getInstance(id).getManifest()); + GameInstanceManifest stored = JsonUtils.fromNonNullJson( + Files.readString(repository.getLayout().getInstanceJson(id)), + GameInstanceManifest.class); + assertEquals("original.Main", stored.mainClass()); + try (DefaultGameRepositoryDraft ignored = repository.openDraft()) { + assertTrue(ignored.isOpen()); + } + } + + /// Restores an existing primary JAR when snapshot publication fails after replacement. + @Test + public void testCommitFailureRollsBackPrimaryJar(@TempDir Path tempDirectory) throws IOException { + FailingRepository repository = new FailingRepository(tempDirectory.resolve("game")); + GameInstanceID id = new GameInstanceID("instance"); + GameInstanceManifest original = new GameInstanceManifest(id).withMainClass("original.Main"); + repository.save(original); + Path target = repository.getLayout().getInstanceJarFile(id); + Files.writeString(target, "original"); + Path source = tempDirectory.resolve("cache/replacement.jar"); + Files.createDirectories(source.getParent()); + Files.writeString(source, "replacement"); + repository.failDraftPublish = true; + + DefaultGameRepositoryDraft draft = repository.openDraft(); + draft.put(original.withMainClass("updated.Main")); + draft.putPrimaryJar(id, source); + assertThrows(IllegalStateException.class, draft::commit); + + assertEquals("original", Files.readString(target)); + assertEquals("replacement", Files.readString(source)); + assertEquals(GameRepositoryDraft.State.FAILED, draft.getState()); + } + + /// Runs an asynchronous update using the published instance as immutable context. + @Test + public void testUpdateInstanceAsyncCommitsWorkingManifest(@TempDir Path tempDirectory) throws Exception { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID id = new GameInstanceID("instance"); + repository.save(new GameInstanceManifest(id).withMainClass("original.Main")); + + Task update = repository.updateInstanceAsync(id, workingInstance -> Task.supplyAsync(() -> + workingInstance.getManifest().withMainClass("updated.Main"))); + assertTrue(update.executor().test()); + + assertEquals("updated.Main", repository.getInstance(id).getManifest().mainClass()); + } + + /// Rejects an asynchronous update that attempts to create a different instance id. + @Test + public void testUpdateInstanceAsyncRejectsChangedId(@TempDir Path tempDirectory) throws Exception { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID id = new GameInstanceID("instance"); + GameInstanceID otherId = new GameInstanceID("other"); + repository.save(new GameInstanceManifest(id).withMainClass("original.Main")); + + Task update = repository.updateInstanceAsync(id, workingInstance -> + Task.supplyAsync(() -> workingInstance.getManifest().withId(otherId))); + assertFalse(update.executor().test()); + + assertEquals("original.Main", repository.getInstance(id).getManifest().mainClass()); + assertFalse(repository.hasInstance(otherId)); + try (DefaultGameRepositoryDraft ignored = repository.openDraft()) { + assertTrue(ignored.isOpen()); + } + } + + /// Minimal repository implementation for draft tests. + @NotNullByDefault + private static class TestRepository extends DefaultGameRepository { + + /// Creates a test repository rooted at `baseDirectory`. + /// + /// @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); + } + } + + /// Repository that can fail draft publication before changing the published snapshot. + @NotNullByDefault + private static final class FailingRepository extends TestRepository { + + /// Whether the next draft publication should fail. + private boolean failDraftPublish; + + /// Creates a failing test repository rooted at `baseDirectory`. + /// + /// @param baseDirectory the repository base directory + private FailingRepository(Path baseDirectory) { + super(baseDirectory); + } + + /// {@inheritDoc} + @Override + void publishDraftSnapshot( + DefaultGameRepositoryDraft draft, + DefaultGameRepositorySnapshot newSnapshot) { + if (failDraftPublish) { + failDraftPublish = false; + throw new IllegalStateException("Simulated publication failure"); + } + super.publishDraftSnapshot(draft, newSnapshot); + } + } + + /// Minimal snapshot-bound instance implementation for draft tests. + @NotNullByDefault + private static final class TestGameInstance extends DefaultGameInstance { + + /// Creates a test instance. + /// + /// @param snapshot the owning snapshot + /// @param id the instance id + /// @param manifest the stored manifest + /// @param manifestFile the 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 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); + } + } +} 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/task/FetchTaskTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/task/FetchTaskTest.java index da1dd32c2f4..da6000664bd 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/task/FetchTaskTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/task/FetchTaskTest.java @@ -20,6 +20,8 @@ import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpServer; import org.jackhuang.hmcl.util.CacheRepository; +import org.jackhuang.hmcl.util.DigestUtils; +import org.jackhuang.hmcl.util.io.ChecksumMismatchException; import org.jackhuang.hmcl.util.io.NetworkUtils; import org.jackhuang.hmcl.util.io.UrlResponseInfo; import org.jetbrains.annotations.NotNullByDefault; @@ -37,6 +39,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Objects; import java.util.concurrent.atomic.AtomicInteger; import static java.nio.charset.StandardCharsets.UTF_8; @@ -72,6 +75,48 @@ public void checksumMismatchFailsWithoutReplacingTarget(@TempDir Path tempDir) t } } + /// Ensures matching content is published and reused by its expected SHA-1. + @Test + public void cacheFileTaskUsesExpectedSha1(@TempDir Path tempDir) throws IOException { + byte[] data = "cached content".getBytes(UTF_8); + String sha1 = DigestUtils.digestToString(CacheRepository.SHA1, data); + URI uri = URI.create("https://example.invalid/file"); + CacheRepository repository = newRepository(tempDir); + CacheFileTask first = new CacheFileTask(List.of(uri), sha1); + first.setCacheRepository(repository); + + try (FetchTask.Context context = first.getContext(null, false, null)) { + context.write(data, 0, data.length); + context.withResult(true); + } + + Path cached = Objects.requireNonNull(first.getResult()); + assertArrayEquals(data, Files.readAllBytes(cached)); + + CacheFileTask second = new CacheFileTask(List.of(uri), sha1); + second.setCacheRepository(repository); + assertEquals(FetchTask.EnumCheckETag.CACHED, second.shouldCheckETag()); + assertEquals(cached, second.getResult()); + } + + /// Ensures mismatched content is not published under an expected SHA-1. + @Test + public void cacheFileTaskRejectsMismatchedSha1(@TempDir Path tempDir) throws IOException { + byte[] data = "unexpected content".getBytes(UTF_8); + String expectedSha1 = "0000000000000000000000000000000000000000"; + CacheRepository repository = newRepository(tempDir); + CacheFileTask task = new CacheFileTask( + List.of(URI.create("https://example.invalid/file")), expectedSha1); + task.setCacheRepository(repository); + FetchTask.Context context = task.getContext(null, false, null); + context.write(data, 0, data.length); + context.withResult(true); + + assertThrows(ChecksumMismatchException.class, context::close); + assertTrue(repository.checkExistentFile( + null, CacheRepository.SHA1, expectedSha1).isEmpty()); + } + /// Ensures a mismatched Content-Range response is rejected before appending bytes. @Test public void invalidContentRangeFallsBackToFullDownload(@TempDir Path tempDir) throws IOException { 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()); } }